feat(setup): ping agent before chat, detect stale service, auto-install Claude

Round-trip confirmation before first chat. After cli-agent wires up the
Terminal Agent, send `chat ping` through the CLI socket under a spinner
with 30s timeout (shared helper in setup/lib/agent-ping.ts, also used by
verify). Only after a real reply do we show "Your assistant is ready."
and enter the chat loop. Ping failures surface a targeted note
(socket_error vs no_reply) and skip the prompt — so users never type
into the void.

Checkout-mismatch detection. verify resolves the running service PID's
script path via `ps -p <pid> -o command=` and compares to projectRoot.
If the service is running from a sibling clone (common for developers
with multiple checkouts), SERVICE comes back as running_other_checkout
instead of running, AGENT_PING is skipped, and the failure note tells
the user exactly which bootout + bootstrap pair to run.

Native Claude Code install on demand. Only the subscription auth path
needs `claude`; the paste-token and paste-API-key paths don't. So
register-claude-token.sh now runs setup/install-claude.sh when `claude`
is missing (curl -fsSL https://claude.ai/install.sh | bash), then
prepends ~/.local/bin to PATH in-process so the rest of the script can
see the fresh binary.

Gutter-safe wrapping. wrapForGutter + dimWrap in lib/theme.ts hard-wrap
text to `process.stdout.columns - gutter` on word boundaries, measuring
visible length (ANSI-stripped). dimWrap applies the dim envelope per
line because clack resets styling at each line break when rendering
multi-line log content — a single outer dim() only colors the first
line. Applied to the long "why" notes before container + onecli, the
channel-skip info, the ping-failure note, and the checkout-mismatch
remediation.

Wordmark anchoring. printIntro always includes the NanoClaw wordmark in
the clack intro line, whether or not nanoclaw.sh already printed one in
bash. Worth ~1 line of redundancy so the brand stays visible at the top
of the clack session after bootstrap output scrolls out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-22 11:06:15 +03:00
parent 9b6e5b24a1
commit 72b7a72cbb
6 changed files with 389 additions and 78 deletions

View File

@@ -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 <zone>
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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,6 +230,20 @@ async function main(): Promise<void> {
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.');
}
const service = res.terminal?.fields.SERVICE;
if (service === 'running_other_checkout') {
notes.push(
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(
@@ -223,6 +251,7 @@ async function main(): Promise<void> {
'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<void> {
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 3060s — 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<PingResult> {
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<void> {
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<void> {
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<void> {
@@ -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.")}`);
}
/**

50
setup/install-claude.sh Executable file
View File

@@ -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 ==="

50
setup/lib/agent-ping.ts Normal file
View File

@@ -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<PingResult> {
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');
});
});
}

View File

@@ -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');
}

View File

@@ -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; }

View File

@@ -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<void> {
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 `<DATA_DIR>/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';
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<void> {
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<void> {
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<void> {
}
/**
* 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');
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;
}
});
child.on('error', () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve('socket_error');
});
});
}
function isPathInside(candidate: string, parent: string): boolean {
const rel = path.relative(parent, candidate);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}