diff --git a/setup/auto.ts b/setup/auto.ts index 97f38ac..49be3f3 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -14,7 +14,7 @@ * "Terminal Agent". * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth|mounts| - * service|cli-agent|channel|verify) + * service|cli-agent|channel|verify|first-chat) * * Timezone defaults to the host system's TZ. Run * pnpm exec tsx setup/index.ts --step timezone -- --tz @@ -27,9 +27,10 @@ import k from 'kleur'; import { runDiscordChannel } from './channels/discord.js'; import { runTelegramChannel } from './channels/telegram.js'; +import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; -import { brandBold, brandChip } from './lib/theme.js'; +import { brandBold, brandChip, dimWrap, wrapForGutter } from './lib/theme.js'; const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); @@ -61,8 +62,9 @@ async function main(): Promise { if (!skip.has('container')) { p.log.message( - k.dim( + dimWrap( 'Your assistant lives in its own sandbox. It can only see what you explicitly share.', + 4, ), ); const res = await runQuietStep('container', { @@ -97,8 +99,9 @@ async function main(): Promise { if (!skip.has('onecli')) { p.log.message( - k.dim( + dimWrap( 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', + 4, ), ); const res = await runQuietStep('onecli', { @@ -178,18 +181,26 @@ async function main(): Promise { const res = await runQuietStep( 'cli-agent', { - running: 'Setting up your terminal chat…', - done: 'Terminal chat ready. Try `pnpm run chat hi`.', + running: 'Bringing your assistant online…', + done: 'Assistant wired up.', }, ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], ); if (!res.ok) { fail( 'cli-agent', - "Couldn't set up the terminal chat.", + "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}"\`.`, ); } + if (!skip.has('first-chat')) { + const ping = await confirmAssistantResponds(); + if (ping === 'ok') { + await runFirstChat(); + } else { + renderPingFailureNote(ping); + } + } } if (!skip.has('channel')) { @@ -200,7 +211,10 @@ async function main(): Promise { await runDiscordChannel(displayName!); } else { p.log.info( - "No messaging app for now. You can add one later (like Telegram, Discord, or Slack).", + wrapForGutter( + 'No messaging app for now. You can add one later (like Telegram, Discord, or Slack).', + 4, + ), ); } } @@ -216,12 +230,27 @@ async function main(): Promise { if (res.terminal?.fields.CREDENTIALS !== 'configured') { notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.'); } - const agentPing = res.terminal?.fields.AGENT_PING; - if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') { + const service = res.terminal?.fields.SERVICE; + if (service === 'running_other_checkout') { notes.push( - "• Your assistant didn't reply to a test message. " + - 'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', + wrapForGutter( + [ + '• Your NanoClaw service is running from a different folder on this machine.', + ' Point it at this checkout with:', + ' launchctl bootout gui/$(id -u)/com.nanoclaw', + ' launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist', + ].join('\n'), + 6, + ), ); + } else { + const agentPing = res.terminal?.fields.AGENT_PING; + if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') { + notes.push( + "• Your assistant didn't reply to a test message. " + + 'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', + ); + } } if (!res.terminal?.fields.CONFIGURED_CHANNELS) { notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.'); @@ -248,6 +277,95 @@ async function main(): Promise { p.outro(k.green("You're ready! Enjoy NanoClaw.")); } +// ─── first-chat step ─────────────────────────────────────────────────── + +/** + * Round-trip ping against the CLI socket before we ask the user to chat. + * Renders its own spinner with elapsed time because a cold-start container + * boot can take 30–60s — the elapsed counter is the difference between + * "patient" and "is this hung?". Returns the raw result so the caller can + * branch between the chat loop (ok) and a diagnostic note (anything else). + */ +async function confirmAssistantResponds(): Promise { + const s = p.spinner(); + const start = Date.now(); + const label = 'Waking your assistant…'; + s.start(label); + const tick = setInterval(() => { + const elapsed = Math.round((Date.now() - start) / 1000); + s.message(`${label} ${k.dim(`(${elapsed}s)`)}`); + }, 1000); + + const result = await pingCliAgent(); + + clearInterval(tick); + const elapsed = Math.round((Date.now() - start) / 1000); + if (result === 'ok') { + s.stop(`Your assistant is ready. ${k.dim(`(${elapsed}s)`)}`); + } 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); + } + return result; +} + +function renderPingFailureNote(result: PingResult): void { + const body = + result === 'socket_error' + ? [ + wrapForGutter( + "The NanoClaw service isn't listening on its local socket. Try restarting it, then chat with `pnpm run chat hi`:", + 6, + ), + '', + k.dim(' macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw'), + k.dim(' Linux: systemctl --user restart nanoclaw'), + ].join('\n') + : wrapForGutter( + 'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', + 6, + ); + p.note(body, 'Skipping the first chat'); +} + +/** + * Chat loop. Each message is piped through `pnpm run chat`, which uses + * the same Unix-socket path the ping just exercised, so output streams + * back inline as the agent replies. An empty input ends the loop. + */ +async function runFirstChat(): Promise { + while (true) { + const answer = ensureAnswer( + await p.text({ + message: 'Say something to your assistant', + placeholder: 'press Enter with nothing to continue', + }), + ); + const text = ((answer as string | undefined) ?? '').trim(); + if (!text) return; + await sendChatMessage(text); + } +} + +function sendChatMessage(message: string): Promise { + return new Promise((resolve) => { + // `pnpm --silent` suppresses the `> nanoclaw@… chat` preamble so the + // agent's reply reads as a clean block under the prompt. Splitting on + // whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv + // with spaces on the far side. + const child = spawn( + 'pnpm', + ['--silent', 'run', 'chat', ...message.split(/\s+/)], + { stdio: ['ignore', 'inherit', 'inherit'] }, + ); + child.on('close', () => resolve()); + child.on('error', () => resolve()); + }); +} + // ─── auth step (select → branch) ──────────────────────────────────────── async function runAuthStep(): Promise { @@ -440,7 +558,6 @@ function maybeReexecUnderSg(): void { function printIntro(): void { const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; - const isBootstrapped = process.env.NANOCLAW_BOOTSTRAPPED === '1'; const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; if (isReexec) { @@ -450,18 +567,11 @@ function printIntro(): void { return; } - // When we were called via nanoclaw.sh, the wordmark + subtitle were - // already printed in bash. Just open the clack gutter with a short, - // neutral intro so the flow continues without duplication. - if (isBootstrapped) { - p.intro(k.dim("Let's get you set up.")); - return; - } - - console.log(); - console.log(` ${wordmark}`); - console.log(` ${k.dim('Setting up your personal AI assistant')}`); - p.intro(k.dim("Let's get you set up.")); + // Always include the wordmark inside the clack intro line. When bash ran + // first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark + // above us; the small repeat is worth it to keep the brand anchored at + // the visible top of the clack session once the bash output scrolls away. + p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`); } /** diff --git a/setup/install-claude.sh b/setup/install-claude.sh new file mode 100755 index 0000000..485f0b4 --- /dev/null +++ b/setup/install-claude.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Install the Claude Code CLI on the host via the official native installer. +# Invoked from setup/register-claude-token.sh when the user picks the +# subscription auth path and `claude` is missing. The other two auth paths +# (paste OAuth token, paste API key) don't need the CLI, so this runs on +# demand rather than up front. +# +# The native installer is Node-independent (downloads a prebuilt binary to +# ~/.local/bin) and is the path Anthropic documents. This matches the +# pattern used by install-docker.sh / install-node.sh: the script itself is +# the allowlisted unit; the curl | bash pipe lives inside it. + +set -euo pipefail + +echo "=== NANOCLAW SETUP: INSTALL_CLAUDE ===" + +if command -v claude >/dev/null 2>&1; then + echo "STATUS: already-installed" + echo "CLAUDE_VERSION: $(claude --version 2>/dev/null || echo unknown)" + echo "=== END ===" + exit 0 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: curl not available." + echo "=== END ===" + exit 1 +fi + +echo "STEP: claude-native-install" +curl -fsSL https://claude.ai/install.sh | bash + +# Native installer writes to ~/.local/bin and appends a PATH line to the +# user's rc file; that doesn't help this session, so put it on PATH now. +if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" +fi +hash -r 2>/dev/null || true + +if ! command -v claude >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: claude not found on PATH after install." + echo "=== END ===" + exit 1 +fi + +echo "STATUS: installed" +echo "CLAUDE_VERSION: $(claude --version 2>/dev/null || echo unknown)" +echo "=== END ===" diff --git a/setup/lib/agent-ping.ts b/setup/lib/agent-ping.ts new file mode 100644 index 0000000..8c5127f --- /dev/null +++ b/setup/lib/agent-ping.ts @@ -0,0 +1,50 @@ +/** + * Round-trip check against the CLI Unix socket. + * + * Shared by `setup/verify.ts` (end-of-run health check) and `setup/auto.ts` + * (confirm the freshly-wired agent actually responds before prompting the + * user to chat with it). + * + * Exit-code contract follows `scripts/chat.ts`: + * 0 → got a reply on stdout + * 2 → socket unreachable (service not running or wrong checkout) + * 3 → no reply before chat.ts's own 120s hard stop + * This wrapper also guards with its own timeout in case chat.ts hangs. + */ +import { spawn } from 'child_process'; + +export type PingResult = 'ok' | 'no_reply' | 'socket_error'; + +export function pingCliAgent(timeoutMs = 30_000): Promise { + return new Promise((resolve) => { + const child = spawn('pnpm', ['run', 'chat', 'ping'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGKILL'); + resolve('no_reply'); + }, timeoutMs); + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf-8'); + }); + child.on('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 2) resolve('socket_error'); + else if (code === 0 && stdout.trim().length > 0) resolve('ok'); + else resolve('no_reply'); + }); + child.on('error', () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve('socket_error'); + }); + }); +} diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 9bd18a5..0a08eae 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -37,3 +37,66 @@ export function brandChip(s: string): string { } return k.bgCyan(k.black(k.bold(s))); } + +/** + * Wrap text so it fits inside clack's gutter without the terminal's soft + * wrap breaking the `│ …` bar on long lines. Works on a single string with + * embedded `\n`s; each logical line is wrapped independently. + * + * The `gutter` argument is the total horizontal overhead clack adds for + * the component the text lives in (e.g. 4 for `p.log.*`'s `│ ` prefix; + * 6-ish for `p.note`'s box). Caller picks it; we just subtract from + * `process.stdout.columns` and hard-wrap at word boundaries. + */ +export function wrapForGutter(text: string, gutter: number): string { + const cols = process.stdout.columns ?? 80; + const width = Math.max(30, cols - gutter); + return text + .split('\n') + .map((line) => wrapLine(line, width)) + .join('\n'); +} + +/** + * Wrap + dim together. Needed instead of `k.dim(wrapForGutter(...))` + * because clack resets styling at each line break when rendering + * multi-line log content — a single outer dim envelope only colors the + * first line. Applying dim per-line gives each wrapped row its own + * `\x1b[2m…\x1b[0m` envelope so the whole block reads as one block. + */ +export function dimWrap(text: string, gutter: number): string { + return wrapForGutter(text, gutter) + .split('\n') + .map((line) => k.dim(line)) + .join('\n'); +} + +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function visibleLength(s: string): number { + return s.replace(ANSI_RE, '').length; +} + +function wrapLine(line: string, width: number): string { + if (visibleLength(line) <= width) return line; + const words = line.split(' '); + const rows: string[] = []; + let cur = ''; + let curLen = 0; + for (const word of words) { + const wLen = visibleLength(word); + if (curLen === 0) { + cur = word; + curLen = wLen; + } else if (curLen + 1 + wLen <= width) { + cur += ' ' + word; + curLen += 1 + wLen; + } else { + rows.push(cur); + cur = word; + curLen = wLen; + } + } + if (cur) rows.push(cur); + return rows.join('\n'); +} diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh index e0707bf..e0adfc6 100755 --- a/setup/register-claude-token.sh +++ b/setup/register-claude-token.sh @@ -25,8 +25,26 @@ HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}" command -v onecli >/dev/null \ || { echo "onecli not found. Install it first (see /setup §4)." >&2; exit 1; } -command -v claude >/dev/null \ - || { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; } + +if ! command -v claude >/dev/null 2>&1; then + echo "Claude Code CLI not found — installing it now (needed for subscription sign-in)…" + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if ! bash "$SCRIPT_DIR/install-claude.sh"; then + echo >&2 + echo "Couldn't install the Claude Code CLI automatically." >&2 + echo "Install it manually with" >&2 + echo " curl -fsSL https://claude.ai/install.sh | bash" >&2 + echo "and re-run setup." >&2 + exit 1 + fi + # install-claude.sh PATH additions are scoped to its own subshell; redo + # them here so the rest of this script can see the fresh `claude` binary. + if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" + fi + hash -r 2>/dev/null || true +fi + command -v script >/dev/null \ || { echo "script(1) is required for PTY capture." >&2; exit 1; } diff --git a/setup/verify.ts b/setup/verify.ts index 4be9c3f..ab0b80e 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -4,7 +4,7 @@ * * Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks. */ -import { execSync, spawn } from 'child_process'; +import { execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -14,6 +14,7 @@ import Database from 'better-sqlite3'; import { DATA_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; import { log } from '../src/log.js'; +import { pingCliAgent } from './lib/agent-ping.js'; import { getPlatform, getServiceManager, @@ -29,19 +30,35 @@ export async function run(_args: string[]): Promise { log.info('Starting verification'); - // 1. Check service status - let service = 'not_found'; + // 1. Check service status + detect checkout mismatch. + // + // Why the mismatch matters: the host binds `/cli.sock` relative + // to the project root it was started from. If the running service is from + // a sibling checkout (common for developers with multiple clones), this + // repo's `data/cli.sock` won't exist — AGENT_PING would return a + // misleading `socket_error`. Surface the mismatch directly instead. + let service: + | 'not_found' + | 'stopped' + | 'running' + | 'running_other_checkout' = 'not_found'; + let runningFromPath: string | null = null; const mgr = getServiceManager(); if (mgr === 'launchd') { try { const output = execSync('launchctl list', { encoding: 'utf-8' }); - if (output.includes('com.nanoclaw')) { - // Check if it has a PID (actually running) - const line = output.split('\n').find((l) => l.includes('com.nanoclaw')); - if (line) { - const pidField = line.trim().split(/\s+/)[0]; - service = pidField !== '-' && pidField ? 'running' : 'stopped'; + const line = output.split('\n').find((l) => l.includes('com.nanoclaw')); + if (line) { + const pidField = line.trim().split(/\s+/)[0]; + if (pidField !== '-' && pidField) { + service = 'running'; + const pid = Number(pidField); + if (Number.isInteger(pid) && pid > 0) { + runningFromPath = resolveBinaryScript(pid); + } + } else { + service = 'stopped'; } } } catch { @@ -52,6 +69,18 @@ export async function run(_args: string[]): Promise { try { execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' }); service = 'running'; + try { + const pidStr = execSync( + `${prefix} show nanoclaw -p MainPID --value`, + { encoding: 'utf-8' }, + ).trim(); + const pid = Number(pidStr); + if (Number.isInteger(pid) && pid > 0) { + runningFromPath = resolveBinaryScript(pid); + } + } catch { + // couldn't read MainPID; leave runningFromPath null + } } catch { try { const output = execSync(`${prefix} list-unit-files`, { @@ -74,13 +103,23 @@ export async function run(_args: string[]): Promise { if (raw && Number.isInteger(pid) && pid > 0) { process.kill(pid, 0); service = 'running'; + runningFromPath = resolveBinaryScript(pid); } } catch { service = 'stopped'; } } } - log.info('Service status', { service }); + + if ( + service === 'running' && + runningFromPath && + !isPathInside(runningFromPath, projectRoot) + ) { + service = 'running_other_checkout'; + } + + log.info('Service status', { service, runningFromPath }); // 2. Check container runtime let containerRuntime = 'none'; @@ -213,46 +252,27 @@ export async function run(_args: string[]): Promise { } /** - * Send a one-word message through the CLI channel and check for a reply. - * Silent by default — stdout/stderr of the child are captured but not - * forwarded. Kills the child after 90s so verify can't hang on a wedged - * agent (chat.ts's own timeout is 120s, which is too long for setup). + * Given a PID, resolve the script path the process is executing (i.e. the + * first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any + * error — callers should treat null as "couldn't tell" and skip the + * mismatch check rather than flag a false positive. */ -function pingCliAgent(): Promise<'ok' | 'no_reply' | 'socket_error'> { - return new Promise((resolve) => { - const child = spawn('pnpm', ['run', 'chat', 'ping'], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stdout = ''; - let settled = false; - const timer = setTimeout(() => { - if (settled) return; - settled = true; - child.kill('SIGKILL'); - resolve('no_reply'); - }, 90_000); - - child.stdout.on('data', (chunk: Buffer) => { - stdout += chunk.toString('utf-8'); - }); - child.on('close', (code) => { - if (settled) return; - settled = true; - clearTimeout(timer); - // chat.ts: exit 0 on reply, 2 on socket error, 3 on no reply. - if (code === 2) { - resolve('socket_error'); - } else if (code === 0 && stdout.trim().length > 0) { - resolve('ok'); - } else { - resolve('no_reply'); - } - }); - child.on('error', () => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve('socket_error'); - }); - }); +function resolveBinaryScript(pid: number): string | null { + try { + // BSD ps (macOS) and util-linux both honour `-o command=` (full argv, + // no header). Node argv: "node /path/to/dist/index.js ...". + const out = execSync(`ps -p ${pid} -o command=`, { + encoding: 'utf-8', + }).trim(); + const tokens = out.split(/\s+/); + const script = tokens.find((t) => /\.(js|mjs|cjs|ts)$/.test(t)); + return script ?? null; + } catch { + return null; + } +} + +function isPathInside(candidate: string, parent: string): boolean { + const rel = path.relative(parent, candidate); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); }