mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
feat(setup): Claude-assisted error recovery with resume-at-step retry
When a setup step fails — whether hard via fail() or soft via the
"What's left" / "Skipping the first chat" notes — offer to ask Claude
to diagnose. On consent, spawn `claude -p --output-format stream-json`
with a scrolling 3-line action window ("Reading x", "Running y") so
the 1–4 minute investigations feel active rather than hung. No hard
timeout: debugging can take time, Ctrl-C is the escape hatch.
The prompt is minimal: one-paragraph framing, failed step name + msg +
hint, and a list of file references (not contents). Claude's Read/Grep
tools fetch what they need. A per-step map in claude-assist.ts gives
the most relevant files per step; the rest is README + auto.ts +
logs/setup.log + the per-step raw log.
Claude responds with REASON + COMMAND lines. We show the reason in a
clack note, prefill the command via setup/run-suggested.sh (bash 4+
readline, 3.x fallback to Enter-to-run), and eval on the user's
confirm.
When the user runs a fix, fail() now offers to retry the failing step
rather than aborting. setup/logs.ts tracks successfully-completed step
names in-memory; fail() threads those as NANOCLAW_SKIP on a spawnSync
retry, so the child picks up exactly where the parent left off — no
rebuilding containers or reinstalling OneCLI.
Other polish in this change:
- fitToWidth + dimWrap in lib/theme.ts to prevent long spinner labels
from soft-wrapping (each terminal row stacks a stale copy otherwise).
- Shorter container step label ("Preparing your assistant's sandbox…")
so it fits on narrow terminals.
- Wordmark anchored in the clack intro line on every run.
- All 25 existing fail() call sites updated to await fail(...) since
fail is now async.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+43
-17
@@ -29,9 +29,10 @@ import { runDiscordChannel } from './channels/discord.js';
|
||||
import { runTelegramChannel } from './channels/telegram.js';
|
||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||||
import * as setupLog from './logs.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||
import { brandBold, brandChip, dimWrap, wrapForGutter } from './lib/theme.js';
|
||||
import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js';
|
||||
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
const RUN_START = Date.now();
|
||||
@@ -53,7 +54,7 @@ async function main(): Promise<void> {
|
||||
done: 'Your system looks good.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
fail(
|
||||
await fail(
|
||||
'environment',
|
||||
"Your system doesn't look quite right.",
|
||||
'See logs/setup-steps/ for details, then retry.',
|
||||
@@ -69,27 +70,27 @@ async function main(): Promise<void> {
|
||||
),
|
||||
);
|
||||
const res = await runQuietStep('container', {
|
||||
running: 'Preparing the sandbox your assistant runs in…',
|
||||
running: "Preparing your assistant's sandbox…",
|
||||
done: 'Sandbox ready.',
|
||||
failed: "Couldn't prepare the sandbox.",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'runtime_not_available') {
|
||||
fail(
|
||||
await fail(
|
||||
'container',
|
||||
"Docker isn't available.",
|
||||
'Install Docker Desktop (or start it if already installed), then retry.',
|
||||
);
|
||||
}
|
||||
if (err === 'docker_group_not_active') {
|
||||
fail(
|
||||
await fail(
|
||||
'container',
|
||||
"Docker was just installed but your shell doesn't know yet.",
|
||||
'Log out and back in (or run `newgrp docker` in a new shell), then retry.',
|
||||
);
|
||||
}
|
||||
fail(
|
||||
await fail(
|
||||
'container',
|
||||
"Couldn't build the sandbox.",
|
||||
'If Docker has a stale cache, try: `docker builder prune -f`, then retry.',
|
||||
@@ -112,13 +113,13 @@ async function main(): Promise<void> {
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'onecli_not_on_path_after_install') {
|
||||
fail(
|
||||
await fail(
|
||||
'onecli',
|
||||
'OneCLI was installed but your shell needs to refresh to see it.',
|
||||
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
||||
);
|
||||
}
|
||||
fail(
|
||||
await fail(
|
||||
'onecli',
|
||||
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||||
@@ -141,7 +142,7 @@ async function main(): Promise<void> {
|
||||
['--empty'],
|
||||
);
|
||||
if (!res.ok) {
|
||||
fail('mounts', "Couldn't write access rules.");
|
||||
await fail('mounts', "Couldn't write access rules.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +152,7 @@ async function main(): Promise<void> {
|
||||
done: 'NanoClaw is running.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
fail(
|
||||
await fail(
|
||||
'service',
|
||||
"Couldn't start NanoClaw.",
|
||||
'See logs/nanoclaw.error.log for details.',
|
||||
@@ -188,7 +189,7 @@ async function main(): Promise<void> {
|
||||
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
|
||||
);
|
||||
if (!res.ok) {
|
||||
fail(
|
||||
await fail(
|
||||
'cli-agent',
|
||||
"Couldn't bring your assistant online.",
|
||||
`You can retry later with \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\`.`,
|
||||
@@ -200,6 +201,17 @@ async function main(): Promise<void> {
|
||||
await runFirstChat();
|
||||
} else {
|
||||
renderPingFailureNote(ping);
|
||||
await offerClaudeAssist({
|
||||
stepName: 'cli-agent',
|
||||
msg:
|
||||
ping === 'socket_error'
|
||||
? "NanoClaw service isn't listening on its CLI socket."
|
||||
: "No reply from the assistant within 30 seconds.",
|
||||
hint:
|
||||
ping === 'socket_error'
|
||||
? 'Socket at data/cli.sock did not accept a connection.'
|
||||
: 'Agent container may be failing to start or authenticate.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,6 +273,18 @@ async function main(): Promise<void> {
|
||||
if (notes.length > 0) {
|
||||
p.note(notes.join('\n'), "What's left");
|
||||
}
|
||||
// "What's left" is a soft failure — we don't abort like fail(), but the
|
||||
// user is still stuck and a fix is exactly what claude-assist is for.
|
||||
const summary = notes
|
||||
.map((n) => n.replace(/^•\s*/, '').split('\n')[0].trim())
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
await offerClaudeAssist({
|
||||
stepName: 'verify',
|
||||
msg: summary || 'Verification completed with unresolved issues.',
|
||||
hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`,
|
||||
rawLogPath: res.rawLog,
|
||||
});
|
||||
p.outro(k.yellow('Almost there. A few things still need your attention.'));
|
||||
return;
|
||||
}
|
||||
@@ -293,24 +317,26 @@ async function confirmAssistantResponds(): Promise<PingResult> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
const label = 'Waking your assistant…';
|
||||
s.start(label);
|
||||
s.start(fitToWidth(label, ' (999s)'));
|
||||
const tick = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.message(`${label} ${k.dim(`(${elapsed}s)`)}`);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
||||
}, 1000);
|
||||
|
||||
const result = await pingCliAgent();
|
||||
|
||||
clearInterval(tick);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
if (result === 'ok') {
|
||||
s.stop(`Your assistant is ready. ${k.dim(`(${elapsed}s)`)}`);
|
||||
s.stop(`${fitToWidth('Your assistant is ready.', suffix)}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const msg =
|
||||
result === 'socket_error'
|
||||
? "Couldn't reach the NanoClaw service."
|
||||
: "Your assistant didn't reply in time.";
|
||||
s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`, 1);
|
||||
s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`, 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -426,7 +452,7 @@ async function runSubscriptionAuth(): Promise<void> {
|
||||
EXIT_CODE: code,
|
||||
METHOD: 'subscription',
|
||||
});
|
||||
fail(
|
||||
await fail(
|
||||
'auth',
|
||||
"Couldn't complete the Claude sign-in.",
|
||||
'Re-run setup and try again, or choose a paste option instead.',
|
||||
@@ -473,7 +499,7 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
fail(
|
||||
await fail(
|
||||
'auth',
|
||||
`Couldn't save your ${label} to the vault.`,
|
||||
'Make sure OneCLI is running (`onecli version`), then retry.',
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
fail(
|
||||
await fail(
|
||||
'discord-install',
|
||||
"Couldn't connect Discord.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
@@ -114,7 +114,7 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
fail(
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'Most likely the bot and you don\'t share a server yet — invite the bot, then retry later with `/manage-channels`.',
|
||||
@@ -211,7 +211,7 @@ async function validateDiscordToken(token: string): Promise<string> {
|
||||
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
fail(
|
||||
await fail(
|
||||
'discord-validate',
|
||||
"Discord didn't accept that token.",
|
||||
'Copy the token again from the Developer Portal and retry setup.',
|
||||
@@ -223,7 +223,7 @@ async function validateDiscordToken(token: string): Promise<string> {
|
||||
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
fail(
|
||||
await fail(
|
||||
'discord-validate',
|
||||
"Couldn't reach Discord.",
|
||||
'Check your internet connection and retry setup.',
|
||||
@@ -253,7 +253,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
fail(
|
||||
await fail(
|
||||
'discord-app-info',
|
||||
"Couldn't read your Discord application details.",
|
||||
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
|
||||
@@ -283,7 +283,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
fail(
|
||||
await fail(
|
||||
'discord-app-info',
|
||||
"Couldn't reach Discord.",
|
||||
'Check your internet connection and retry setup.',
|
||||
@@ -394,7 +394,7 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
fail(
|
||||
await fail(
|
||||
'discord-open-dm',
|
||||
"Couldn't open a DM channel with you.",
|
||||
'Make sure the bot is in a server you\'re also in, then retry setup.',
|
||||
@@ -412,7 +412,7 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
fail(
|
||||
await fail(
|
||||
'discord-open-dm',
|
||||
"Couldn't reach Discord.",
|
||||
'Check your internet connection and retry setup.',
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
fail(
|
||||
await fail(
|
||||
'telegram-install',
|
||||
"Couldn't connect Telegram.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
@@ -79,7 +79,7 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
||||
|
||||
const pair = await runPairTelegram();
|
||||
if (!pair.ok) {
|
||||
fail(
|
||||
await fail(
|
||||
'pair-telegram',
|
||||
"Couldn't pair with Telegram.",
|
||||
'Re-run setup to try again.',
|
||||
@@ -89,7 +89,7 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
||||
const platformId = pair.terminal?.fields.PLATFORM_ID;
|
||||
const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID;
|
||||
if (!platformId || !pairedUserId) {
|
||||
fail(
|
||||
await fail(
|
||||
'pair-telegram',
|
||||
'Pairing completed but came back incomplete.',
|
||||
'Re-run setup to try again.',
|
||||
@@ -118,7 +118,7 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
fail(
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/manage-channels`.',
|
||||
@@ -188,7 +188,7 @@ async function validateTelegramToken(token: string): Promise<string> {
|
||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
fail(
|
||||
await fail(
|
||||
'telegram-validate',
|
||||
"Telegram didn't accept that token.",
|
||||
'Copy the token again from @BotFather and try setup once more.',
|
||||
@@ -200,7 +200,7 @@ async function validateTelegramToken(token: string): Promise<string> {
|
||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
fail(
|
||||
await fail(
|
||||
'telegram-validate',
|
||||
"Couldn't reach Telegram.",
|
||||
'Check your internet connection and retry setup.',
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Offer Claude-assisted debugging when a setup step fails.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check `claude` is on PATH and has a working credential. If not,
|
||||
* silently skip — pre-auth failures can't use this path.
|
||||
* 2. Ask the user for consent ("Want me to ask Claude for a fix?").
|
||||
* 3. Build a minimal prompt: the one-paragraph situation, the failing
|
||||
* step's name/message/hint, and a short list of *file references*
|
||||
* (not contents) so Claude can Read what it needs on its own.
|
||||
* 4. Spawn `claude -p --output-format text` with a 2-minute timeout and
|
||||
* a spinner that shows elapsed time.
|
||||
* 5. Parse `REASON:` / `COMMAND:` out of the response. Show the reason
|
||||
* in a clack note, then hand off to `setup/run-suggested.sh` for
|
||||
* editable pre-fill + exec.
|
||||
*
|
||||
* Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs.
|
||||
*/
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { ensureAnswer } from './runner.js';
|
||||
import { fitToWidth } from './theme.js';
|
||||
|
||||
export interface AssistContext {
|
||||
stepName: string;
|
||||
msg: string;
|
||||
hint?: string;
|
||||
/** Absolute path to the per-step raw log, if the caller has one. */
|
||||
rawLogPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File-path hints per step. Claude reads these on its own via its Read tool
|
||||
* rather than us stuffing contents into the prompt. Keys are step names as
|
||||
* they appear in fail() calls; values are repo-relative paths.
|
||||
*/
|
||||
const STEP_FILES: Record<string, string[]> = {
|
||||
bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'],
|
||||
environment: ['setup/environment.ts'],
|
||||
container: [
|
||||
'setup/container.ts',
|
||||
'setup/install-docker.sh',
|
||||
'container/Dockerfile',
|
||||
],
|
||||
onecli: ['setup/onecli.ts'],
|
||||
auth: [
|
||||
'setup/auth.ts',
|
||||
'setup/register-claude-token.sh',
|
||||
'setup/install-claude.sh',
|
||||
],
|
||||
mounts: ['setup/mounts.ts'],
|
||||
service: ['setup/service.ts'],
|
||||
'cli-agent': ['setup/cli-agent.ts', 'scripts/init-cli-agent.ts'],
|
||||
channel: ['setup/auto.ts'],
|
||||
verify: ['setup/verify.ts'],
|
||||
// Channel-specific sub-steps:
|
||||
'telegram-install': ['setup/add-telegram.sh', 'setup/channels/telegram.ts'],
|
||||
'telegram-validate': ['setup/channels/telegram.ts'],
|
||||
'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'],
|
||||
'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'],
|
||||
'init-first-agent': [
|
||||
'scripts/init-first-agent.ts',
|
||||
'setup/channels/telegram.ts',
|
||||
'setup/channels/discord.ts',
|
||||
],
|
||||
};
|
||||
|
||||
const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts'];
|
||||
|
||||
/**
|
||||
* Returns `true` if the user ran a Claude-suggested fix command; callers
|
||||
* can use that signal to offer a retry instead of aborting outright.
|
||||
* Returns `false` for every other outcome (skipped, declined, no command,
|
||||
* Claude unreachable, user chose not to run).
|
||||
*/
|
||||
export async function offerClaudeAssist(
|
||||
ctx: AssistContext,
|
||||
projectRoot: string = process.cwd(),
|
||||
): Promise<boolean> {
|
||||
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
|
||||
if (!isClaudeUsable()) return false;
|
||||
|
||||
const want = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Want me to ask Claude to diagnose this?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (!want) return false;
|
||||
|
||||
const prompt = buildPrompt(ctx, projectRoot);
|
||||
const response = await queryClaudeUnderSpinner(prompt, projectRoot);
|
||||
if (!response) return false;
|
||||
|
||||
const parsed = parseResponse(response);
|
||||
if (!parsed) {
|
||||
p.log.warn("Claude responded but I couldn't parse a command out of it.");
|
||||
p.log.message(k.dim(response.trim().slice(0, 500)));
|
||||
return false;
|
||||
}
|
||||
|
||||
p.note(
|
||||
`${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`,
|
||||
"Claude's suggestion",
|
||||
);
|
||||
|
||||
const run = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Run this command? (you can edit it before executing)',
|
||||
initialValue: false,
|
||||
}),
|
||||
);
|
||||
if (!run) return false;
|
||||
|
||||
await runSuggested(parsed.command, projectRoot);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isClaudeUsable(): boolean {
|
||||
try {
|
||||
execSync('command -v claude', { stdio: 'ignore' });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// Availability without auth is half the story; a real query will still
|
||||
// fail if the token isn't registered. We try first and surface the error
|
||||
// rather than pre-checking auth with a separate round trip.
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildPrompt(ctx: AssistContext, projectRoot: string): string {
|
||||
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
|
||||
const references = [
|
||||
...BIG_PICTURE_FILES,
|
||||
...stepRefs,
|
||||
'logs/setup.log',
|
||||
ctx.rawLogPath
|
||||
? path.relative(projectRoot, ctx.rawLogPath)
|
||||
: 'logs/setup-steps/',
|
||||
].filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
const hintLine = ctx.hint ? `Hint shown to the user: ${ctx.hint}\n` : '';
|
||||
|
||||
return [
|
||||
"I'm trying to set up NanoClaw on my machine and ran into an issue",
|
||||
'during the setup flow. Please read the referenced files to understand',
|
||||
'the flow and the step that failed, look at the logs to see what went',
|
||||
'wrong, then suggest a single bash command I can run to fix it.',
|
||||
'',
|
||||
`Failed step: ${ctx.stepName}`,
|
||||
`Error shown to the user: ${ctx.msg}`,
|
||||
hintLine,
|
||||
'References (read as needed with your Read tool):',
|
||||
...references.map((r) => ` - ${r}`),
|
||||
'',
|
||||
'Respond in EXACTLY this format, nothing before or after:',
|
||||
'',
|
||||
'REASON: <one short line describing the root cause>',
|
||||
'COMMAND: <single bash command, one line, no backticks>',
|
||||
'',
|
||||
'If no safe single command can fix it, respond with:',
|
||||
'REASON: <why>',
|
||||
'COMMAND: none',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed-height scrolling window for Claude's progress.
|
||||
*
|
||||
* Clack's spinner only owns one line, so long tool-use breadcrumbs wrap
|
||||
* and blow out the gutter. Instead we manage a 4-line window ourselves:
|
||||
* a spinner header + 3 lines showing the most recent tool actions. On
|
||||
* each update we use raw ANSI (cursor up, clear line) to redraw in
|
||||
* place. When the query finishes we clear the whole block and emit a
|
||||
* single `p.log.success` / `p.log.error` so the flow continues in
|
||||
* standard clack style.
|
||||
*/
|
||||
const WINDOW_SIZE = 3;
|
||||
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
|
||||
const HIDE_CURSOR = '\x1b[?25l';
|
||||
const SHOW_CURSOR = '\x1b[?25h';
|
||||
|
||||
async function queryClaudeUnderSpinner(
|
||||
prompt: string,
|
||||
projectRoot: string,
|
||||
): Promise<string | null> {
|
||||
const out = process.stdout;
|
||||
const start = Date.now();
|
||||
const actions: string[] = [];
|
||||
let frameIdx = 0;
|
||||
|
||||
const redraw = (): void => {
|
||||
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
|
||||
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const header = fitToWidth('Asking Claude to diagnose…', suffix);
|
||||
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
|
||||
|
||||
for (let i = 0; i < WINDOW_SIZE; i++) {
|
||||
const idx = actions.length - WINDOW_SIZE + i;
|
||||
const action = idx >= 0 ? actions[idx] : '';
|
||||
out.write('\x1b[2K');
|
||||
if (action) {
|
||||
out.write(`${k.gray('│')} ${k.dim(`▸ ${fitToWidth(action, '')}`)}`);
|
||||
} else {
|
||||
out.write(k.gray('│'));
|
||||
}
|
||||
out.write('\n');
|
||||
}
|
||||
};
|
||||
|
||||
const clearBlock = (): void => {
|
||||
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||
for (let i = 0; i < WINDOW_SIZE + 1; i++) {
|
||||
out.write('\x1b[2K\n');
|
||||
}
|
||||
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||
};
|
||||
|
||||
// Seed the block: move cursor to a fresh line, then write (header + window)
|
||||
// blank lines so `redraw()`'s cursor-up math lands correctly. Hide the
|
||||
// cursor for the duration so the redraw doesn't flicker.
|
||||
out.write(HIDE_CURSOR);
|
||||
for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n');
|
||||
redraw();
|
||||
|
||||
// If the user Ctrl-C's during the query, we never reach `finish()` —
|
||||
// add an exit hook so the cursor comes back regardless.
|
||||
const restoreCursorOnExit = (): void => {
|
||||
out.write(SHOW_CURSOR);
|
||||
};
|
||||
process.once('exit', restoreCursorOnExit);
|
||||
|
||||
const frameTick = setInterval(() => {
|
||||
frameIdx++;
|
||||
redraw();
|
||||
}, 250);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let lineBuf = '';
|
||||
let finalText = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const finish = (
|
||||
kind: 'ok' | 'error',
|
||||
payload: string | null,
|
||||
): void => {
|
||||
clearInterval(frameTick);
|
||||
clearBlock();
|
||||
out.write(SHOW_CURSOR);
|
||||
process.off('exit', restoreCursorOnExit);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
if (kind === 'ok') {
|
||||
p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`);
|
||||
resolve(payload);
|
||||
} else {
|
||||
p.log.error(
|
||||
`${fitToWidth("Claude couldn't help here.", suffix)}${k.dim(suffix)}`,
|
||||
);
|
||||
const tail = stderr.trim().split('\n').slice(-3).join('\n');
|
||||
if (tail) p.log.message(k.dim(tail));
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
// No hard timeout — debugging can take a long time, and the cost of
|
||||
// cutting Claude off mid-investigation is worse than letting the
|
||||
// spinner run. The user can Ctrl-C if they want to abort.
|
||||
const child = spawn(
|
||||
'claude',
|
||||
[
|
||||
'-p',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
],
|
||||
{ cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
);
|
||||
|
||||
child.stdout.on('data', (c: Buffer) => {
|
||||
lineBuf += c.toString('utf-8');
|
||||
let idx: number;
|
||||
while ((idx = lineBuf.indexOf('\n')) !== -1) {
|
||||
const line = lineBuf.slice(0, idx);
|
||||
lineBuf = lineBuf.slice(idx + 1);
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const event = JSON.parse(line) as StreamEvent;
|
||||
handleStreamEvent(event, {
|
||||
setAction: (a) => {
|
||||
actions.push(a);
|
||||
redraw();
|
||||
},
|
||||
appendText: (t) => {
|
||||
finalText += t;
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Malformed or non-JSON line — ignore.
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', (c: Buffer) => {
|
||||
stderr += c.toString('utf-8');
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (code === 0 && finalText.trim()) finish('ok', finalText);
|
||||
else finish('error', null);
|
||||
});
|
||||
child.on('error', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
finish('error', null);
|
||||
});
|
||||
|
||||
child.stdin.end(prompt);
|
||||
});
|
||||
}
|
||||
|
||||
// Minimal shape of the stream-json events we care about. Claude emits
|
||||
// many more, but we only read tool_use blocks (for breadcrumbs) and text
|
||||
// blocks (to reassemble the final REASON/COMMAND answer).
|
||||
interface StreamEvent {
|
||||
type: string;
|
||||
message?: {
|
||||
content?: Array<
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'tool_use'; name: string; input: Record<string, unknown> }
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
function handleStreamEvent(
|
||||
event: StreamEvent,
|
||||
cb: { setAction: (a: string) => void; appendText: (t: string) => void },
|
||||
): void {
|
||||
if (event.type !== 'assistant') return;
|
||||
const blocks = event.message?.content ?? [];
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text') {
|
||||
cb.appendText(block.text);
|
||||
} else if (block.type === 'tool_use') {
|
||||
cb.setAction(formatToolUse(block.name, block.input));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatToolUse(name: string, input: Record<string, unknown>): string {
|
||||
const truncate = (v: string, n: number): string =>
|
||||
v.length > n ? v.slice(0, n) + '…' : v;
|
||||
if (name === 'Read') {
|
||||
const f = String(input.file_path ?? '');
|
||||
return `Reading ${shortenPath(f)}`;
|
||||
}
|
||||
if (name === 'Bash') {
|
||||
const cmd = String(input.command ?? '').replace(/\s+/g, ' ').trim();
|
||||
return `Running ${truncate(cmd, 60)}`;
|
||||
}
|
||||
if (name === 'Grep') return `Searching for "${truncate(String(input.pattern ?? ''), 40)}"`;
|
||||
if (name === 'Glob') return `Finding ${truncate(String(input.pattern ?? ''), 40)}`;
|
||||
return `Using ${name}`;
|
||||
}
|
||||
|
||||
function shortenPath(abs: string): string {
|
||||
const root = process.cwd();
|
||||
return abs.startsWith(`${root}/`) ? abs.slice(root.length + 1) : abs;
|
||||
}
|
||||
|
||||
function parseResponse(
|
||||
raw: string,
|
||||
): { reason: string; command: string } | null {
|
||||
// Accept the fields anywhere in the output — Claude sometimes wraps the
|
||||
// answer in a trailing explanation we can safely ignore.
|
||||
const reasonMatch = raw.match(/^\s*REASON:\s*(.+?)\s*$/m);
|
||||
const commandMatch = raw.match(/^\s*COMMAND:\s*(.+?)\s*$/m);
|
||||
if (!reasonMatch || !commandMatch) return null;
|
||||
const command = commandMatch[1].trim();
|
||||
if (!command || command.toLowerCase() === 'none') return null;
|
||||
return { reason: reasonMatch[1].trim(), command };
|
||||
}
|
||||
|
||||
function runSuggested(command: string, projectRoot: string): Promise<void> {
|
||||
const script = path.join(projectRoot, 'setup/run-suggested.sh');
|
||||
if (!fs.existsSync(script)) {
|
||||
p.log.error(`Missing helper: ${script}`);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('bash', [script, command], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
child.on('close', () => resolve());
|
||||
child.on('error', () => resolve());
|
||||
});
|
||||
}
|
||||
+51
-6
@@ -11,13 +11,15 @@
|
||||
*
|
||||
* See docs/setup-flow.md for the three-level output contract.
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { offerClaudeAssist } from './claude-assist.js';
|
||||
import { fitToWidth } from './theme.js';
|
||||
|
||||
export type Fields = Record<string, string>;
|
||||
export type Block = { type: string; fields: Fields };
|
||||
@@ -261,23 +263,25 @@ async function runUnderSpinner<
|
||||
): Promise<T> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start(labels.running);
|
||||
s.start(fitToWidth(labels.running, ' (999s)'));
|
||||
const tick = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.message(`${labels.running} ${k.dim(`(${elapsed}s)`)}`);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
|
||||
}, 1000);
|
||||
|
||||
const result = await work();
|
||||
|
||||
clearInterval(tick);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
if (result.ok) {
|
||||
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
||||
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
||||
s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`);
|
||||
s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
|
||||
s.stop(`${failMsg} ${k.dim(`(${elapsed}s)`)}`, 1);
|
||||
s.stop(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`, 1);
|
||||
dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
return result;
|
||||
@@ -301,12 +305,53 @@ export function dumpTranscriptOnFailure(transcript: string): void {
|
||||
* Abort the setup run with a user-facing error, logging the abort to the
|
||||
* progression log. Takes the step name explicitly so callers are clear
|
||||
* about which step they're failing from — no hidden module state.
|
||||
*
|
||||
* Before aborting we offer Claude-assisted debugging. Callers must
|
||||
* `await fail(...)` so the offer can actually run before we call
|
||||
* process.exit. The return type is `Promise<never>`; control-flow
|
||||
* narrowing still works after `await`.
|
||||
*/
|
||||
export function fail(stepName: string, msg: string, hint?: string): never {
|
||||
export async function fail(
|
||||
stepName: string,
|
||||
msg: string,
|
||||
hint?: string,
|
||||
rawLogPath?: string,
|
||||
): Promise<never> {
|
||||
setupLog.abort(stepName, msg);
|
||||
p.log.error(msg);
|
||||
if (hint) p.log.message(k.dim(hint));
|
||||
p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/'));
|
||||
|
||||
const ranFix = await offerClaudeAssist({ stepName, msg, hint, rawLogPath });
|
||||
|
||||
// If the user just ran a Claude-suggested fix, offer to resume the flow
|
||||
// at the step that failed instead of aborting. We re-exec via spawnSync
|
||||
// and pass NANOCLAW_SKIP with every step that already completed so the
|
||||
// child skips them and picks up where we left off.
|
||||
if (ranFix) {
|
||||
const retry = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: `Fix applied. Retry the ${stepName} step?`,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (retry) {
|
||||
const existingSkip = (process.env.NANOCLAW_SKIP ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const skipList = [
|
||||
...new Set([...existingSkip, ...setupLog.completedStepNames()]),
|
||||
].join(',');
|
||||
p.log.step(`Retrying from ${stepName}…`);
|
||||
const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NANOCLAW_SKIP: skipList },
|
||||
});
|
||||
process.exit(result.status ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
p.cancel('Setup aborted.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,28 @@ function visibleLength(s: string): number {
|
||||
return s.replace(ANSI_RE, '').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a label so the final line — base + reserved suffix — fits in
|
||||
* the terminal width. Use on spinner labels that get an elapsed counter
|
||||
* appended: if the total exceeds terminal width, clack's cursor-up
|
||||
* redraw math breaks and each tick stacks a copy of the line instead
|
||||
* of replacing it.
|
||||
*
|
||||
* `suffix` is the reserved space for what we'll append after `fit()`
|
||||
* returns (e.g. ` (999s)` or a tool-use breadcrumb). We don't include
|
||||
* it in the output — caller appends it.
|
||||
*/
|
||||
export function fitToWidth(base: string, suffix: string): string {
|
||||
const cols = process.stdout.columns ?? 80;
|
||||
// Overhead we reserve before sizing the label:
|
||||
// spinner icon (1) + 2 padding spaces = 3
|
||||
// clack's animated ellipsis after the label = up to 3 (". " -> "...")
|
||||
// 1-char safety margin so wide-char glyphs don't tip over the edge
|
||||
// Total reserved budget = 7 cols plus the caller's suffix.
|
||||
const budget = Math.max(20, cols - 7 - visibleLength(suffix));
|
||||
return base.length > budget ? base.slice(0, budget - 1) + '…' : base;
|
||||
}
|
||||
|
||||
function wrapLine(line: string, width: number): string {
|
||||
if (visibleLength(line) <= width) return line;
|
||||
const words = line.split(' ');
|
||||
|
||||
@@ -30,6 +30,16 @@ const PROGRESS_LOG = path.join(LOGS_DIR, 'setup.log');
|
||||
export const progressLogPath = PROGRESS_LOG;
|
||||
export const stepsDir = STEPS_DIR;
|
||||
|
||||
// Track steps that finished cleanly in this run. Used by fail() to build
|
||||
// a NANOCLAW_SKIP list when re-executing after a Claude-assisted fix, so
|
||||
// the retry picks up at the failing step instead of redoing every step
|
||||
// before it.
|
||||
const completedInRun = new Set<string>();
|
||||
|
||||
export function completedStepNames(): string[] {
|
||||
return [...completedInRun];
|
||||
}
|
||||
|
||||
/** Wipe prior logs and write a header. Called once per fresh run (by nanoclaw.sh or as a fallback by auto.ts if invoked standalone). */
|
||||
export function reset(meta: Record<string, string>): void {
|
||||
if (fs.existsSync(STEPS_DIR)) {
|
||||
@@ -71,6 +81,10 @@ export function step(
|
||||
if (rawRel) lines.push(` raw: ${rawRel}`);
|
||||
lines.push('');
|
||||
fs.appendFileSync(PROGRESS_LOG, lines.join('\n') + '\n');
|
||||
|
||||
if (status === 'success' || status === 'skipped') {
|
||||
completedInRun.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
/** A user answered a prompt. Logs as its own entry because the setup path depends on it. */
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run a command suggested by claude-assist, giving the user a chance to
|
||||
# edit it first. Same pattern as setup/register-claude-token.sh: bash 4+
|
||||
# pre-fills readline so Enter literally submits; bash 3.x (macOS default
|
||||
# /bin/bash) shows the command and waits for Enter.
|
||||
#
|
||||
# This script is the allowlisted unit — the `eval` happens inside. The
|
||||
# caller has already shown the command to the user and gotten confirmation.
|
||||
|
||||
set -u
|
||||
|
||||
CMD="${1:-}"
|
||||
if [ -z "$CMD" ]; then
|
||||
echo "run-suggested: no command provided" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
|
||||
# Pre-fill readline; user can edit before pressing Enter.
|
||||
read -r -e -i "$CMD" -p "$ " cmd </dev/tty
|
||||
else
|
||||
# Fallback: display + Enter-to-run.
|
||||
echo "$ $CMD"
|
||||
read -r -p "Press Enter to run, Ctrl-C to abort. " _ </dev/tty
|
||||
cmd="$CMD"
|
||||
fi
|
||||
|
||||
if [ -z "$cmd" ]; then
|
||||
echo "run-suggested: empty command after edit, skipping." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
eval "$cmd"
|
||||
Reference in New Issue
Block a user