diff --git a/setup/auto.ts b/setup/auto.ts index 2191e9a..3be7856 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -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 { 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 { ), ); 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 { 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 { ['--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 { 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 { ['--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 { 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 { 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 { 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 { 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 { }, ); if (!res.ok) { - fail( + await fail( 'auth', `Couldn't save your ${label} to the vault.`, 'Make sure OneCLI is running (`onecli version`), then retry.', diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index f384902..010310e 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -78,7 +78,7 @@ export async function runDiscordChannel(displayName: string): Promise { }, ); 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 { }, ); 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 { 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 { 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 { 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 { 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 { 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 { 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.', diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 7fe5d26..df253c6 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -70,7 +70,7 @@ export async function runTelegramChannel(displayName: string): Promise { }, ); 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 { 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 { 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 { }, ); 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 { 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 { 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.', diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts new file mode 100644 index 0000000..4735be6 --- /dev/null +++ b/setup/lib/claude-assist.ts @@ -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 = { + 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 { + 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: ', + 'COMMAND: ', + '', + 'If no safe single command can fix it, respond with:', + 'REASON: ', + '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 { + 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 } + >; + }; +} + +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 { + 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 { + 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()); + }); +} diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index 59b3da6..0e33c74 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -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; export type Block = { type: string; fields: Fields }; @@ -261,23 +263,25 @@ async function runUnderSpinner< ): Promise { 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`; 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 { 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); } diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 0a08eae..6f21d15 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -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(' '); diff --git a/setup/logs.ts b/setup/logs.ts index 127f969..7e37beb 100644 --- a/setup/logs.ts +++ b/setup/logs.ts @@ -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(); + +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): 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. */ diff --git a/setup/run-suggested.sh b/setup/run-suggested.sh new file mode 100755 index 0000000..3cd47b5 --- /dev/null +++ b/setup/run-suggested.sh @@ -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 &2 + exit 0 +fi + +echo +eval "$cmd"