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:
160
setup/auto.ts
160
setup/auto.ts
@@ -14,7 +14,7 @@
|
|||||||
* "Terminal Agent".
|
* "Terminal Agent".
|
||||||
* NANOCLAW_SKIP comma-separated step names to skip
|
* NANOCLAW_SKIP comma-separated step names to skip
|
||||||
* (environment|container|onecli|auth|mounts|
|
* (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
|
* Timezone defaults to the host system's TZ. Run
|
||||||
* pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>
|
* 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 { runDiscordChannel } from './channels/discord.js';
|
||||||
import { runTelegramChannel } from './channels/telegram.js';
|
import { runTelegramChannel } from './channels/telegram.js';
|
||||||
|
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||||
import * as setupLog from './logs.js';
|
import * as setupLog from './logs.js';
|
||||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.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 CLI_AGENT_NAME = 'Terminal Agent';
|
||||||
const RUN_START = Date.now();
|
const RUN_START = Date.now();
|
||||||
@@ -61,8 +62,9 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
if (!skip.has('container')) {
|
if (!skip.has('container')) {
|
||||||
p.log.message(
|
p.log.message(
|
||||||
k.dim(
|
dimWrap(
|
||||||
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
|
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
|
||||||
|
4,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const res = await runQuietStep('container', {
|
const res = await runQuietStep('container', {
|
||||||
@@ -97,8 +99,9 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
if (!skip.has('onecli')) {
|
if (!skip.has('onecli')) {
|
||||||
p.log.message(
|
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.',
|
'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', {
|
const res = await runQuietStep('onecli', {
|
||||||
@@ -178,18 +181,26 @@ async function main(): Promise<void> {
|
|||||||
const res = await runQuietStep(
|
const res = await runQuietStep(
|
||||||
'cli-agent',
|
'cli-agent',
|
||||||
{
|
{
|
||||||
running: 'Setting up your terminal chat…',
|
running: 'Bringing your assistant online…',
|
||||||
done: 'Terminal chat ready. Try `pnpm run chat hi`.',
|
done: 'Assistant wired up.',
|
||||||
},
|
},
|
||||||
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
|
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
fail(
|
fail(
|
||||||
'cli-agent',
|
'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}"\`.`,
|
`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')) {
|
if (!skip.has('channel')) {
|
||||||
@@ -200,7 +211,10 @@ async function main(): Promise<void> {
|
|||||||
await runDiscordChannel(displayName!);
|
await runDiscordChannel(displayName!);
|
||||||
} else {
|
} else {
|
||||||
p.log.info(
|
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<void> {
|
|||||||
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
|
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
|
||||||
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.');
|
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.');
|
||||||
}
|
}
|
||||||
const agentPing = res.terminal?.fields.AGENT_PING;
|
const service = res.terminal?.fields.SERVICE;
|
||||||
if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
|
if (service === 'running_other_checkout') {
|
||||||
notes.push(
|
notes.push(
|
||||||
"• Your assistant didn't reply to a test message. " +
|
wrapForGutter(
|
||||||
'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
[
|
||||||
|
'• 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) {
|
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`.');
|
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."));
|
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<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) ────────────────────────────────────────
|
// ─── auth step (select → branch) ────────────────────────────────────────
|
||||||
|
|
||||||
async function runAuthStep(): Promise<void> {
|
async function runAuthStep(): Promise<void> {
|
||||||
@@ -440,7 +558,6 @@ function maybeReexecUnderSg(): void {
|
|||||||
|
|
||||||
function printIntro(): void {
|
function printIntro(): void {
|
||||||
const isReexec = process.env.NANOCLAW_REEXEC_SG === '1';
|
const isReexec = process.env.NANOCLAW_REEXEC_SG === '1';
|
||||||
const isBootstrapped = process.env.NANOCLAW_BOOTSTRAPPED === '1';
|
|
||||||
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
||||||
|
|
||||||
if (isReexec) {
|
if (isReexec) {
|
||||||
@@ -450,18 +567,11 @@ function printIntro(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When we were called via nanoclaw.sh, the wordmark + subtitle were
|
// Always include the wordmark inside the clack intro line. When bash ran
|
||||||
// already printed in bash. Just open the clack gutter with a short,
|
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark
|
||||||
// neutral intro so the flow continues without duplication.
|
// above us; the small repeat is worth it to keep the brand anchored at
|
||||||
if (isBootstrapped) {
|
// the visible top of the clack session once the bash output scrolls away.
|
||||||
p.intro(k.dim("Let's get you set up."));
|
p.intro(`${wordmark} ${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."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
50
setup/install-claude.sh
Executable file
50
setup/install-claude.sh
Executable 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
50
setup/lib/agent-ping.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,3 +37,66 @@ export function brandChip(s: string): string {
|
|||||||
}
|
}
|
||||||
return k.bgCyan(k.black(k.bold(s)));
|
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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,8 +25,26 @@ HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}"
|
|||||||
|
|
||||||
command -v onecli >/dev/null \
|
command -v onecli >/dev/null \
|
||||||
|| { echo "onecli not found. Install it first (see /setup §4)." >&2; exit 1; }
|
|| { 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 \
|
command -v script >/dev/null \
|
||||||
|| { echo "script(1) is required for PTY capture." >&2; exit 1; }
|
|| { echo "script(1) is required for PTY capture." >&2; exit 1; }
|
||||||
|
|
||||||
|
|||||||
122
setup/verify.ts
122
setup/verify.ts
@@ -4,7 +4,7 @@
|
|||||||
*
|
*
|
||||||
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
|
* 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 fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -14,6 +14,7 @@ import Database from 'better-sqlite3';
|
|||||||
import { DATA_DIR } from '../src/config.js';
|
import { DATA_DIR } from '../src/config.js';
|
||||||
import { readEnvFile } from '../src/env.js';
|
import { readEnvFile } from '../src/env.js';
|
||||||
import { log } from '../src/log.js';
|
import { log } from '../src/log.js';
|
||||||
|
import { pingCliAgent } from './lib/agent-ping.js';
|
||||||
import {
|
import {
|
||||||
getPlatform,
|
getPlatform,
|
||||||
getServiceManager,
|
getServiceManager,
|
||||||
@@ -29,19 +30,35 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
|
|
||||||
log.info('Starting verification');
|
log.info('Starting verification');
|
||||||
|
|
||||||
// 1. Check service status
|
// 1. Check service status + detect checkout mismatch.
|
||||||
let service = 'not_found';
|
//
|
||||||
|
// 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();
|
const mgr = getServiceManager();
|
||||||
|
|
||||||
if (mgr === 'launchd') {
|
if (mgr === 'launchd') {
|
||||||
try {
|
try {
|
||||||
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
||||||
if (output.includes('com.nanoclaw')) {
|
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
|
||||||
// Check if it has a PID (actually running)
|
if (line) {
|
||||||
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
|
const pidField = line.trim().split(/\s+/)[0];
|
||||||
if (line) {
|
if (pidField !== '-' && pidField) {
|
||||||
const pidField = line.trim().split(/\s+/)[0];
|
service = 'running';
|
||||||
service = pidField !== '-' && pidField ? 'running' : 'stopped';
|
const pid = Number(pidField);
|
||||||
|
if (Number.isInteger(pid) && pid > 0) {
|
||||||
|
runningFromPath = resolveBinaryScript(pid);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
service = 'stopped';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -52,6 +69,18 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
|
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
|
||||||
service = 'running';
|
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 {
|
} catch {
|
||||||
try {
|
try {
|
||||||
const output = execSync(`${prefix} list-unit-files`, {
|
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) {
|
if (raw && Number.isInteger(pid) && pid > 0) {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
service = 'running';
|
service = 'running';
|
||||||
|
runningFromPath = resolveBinaryScript(pid);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
service = 'stopped';
|
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
|
// 2. Check container runtime
|
||||||
let containerRuntime = 'none';
|
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.
|
* Given a PID, resolve the script path the process is executing (i.e. the
|
||||||
* Silent by default — stdout/stderr of the child are captured but not
|
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any
|
||||||
* forwarded. Kills the child after 90s so verify can't hang on a wedged
|
* error — callers should treat null as "couldn't tell" and skip the
|
||||||
* agent (chat.ts's own timeout is 120s, which is too long for setup).
|
* mismatch check rather than flag a false positive.
|
||||||
*/
|
*/
|
||||||
function pingCliAgent(): Promise<'ok' | 'no_reply' | 'socket_error'> {
|
function resolveBinaryScript(pid: number): string | null {
|
||||||
return new Promise((resolve) => {
|
try {
|
||||||
const child = spawn('pnpm', ['run', 'chat', 'ping'], {
|
// BSD ps (macOS) and util-linux both honour `-o command=` (full argv,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
// no header). Node argv: "node /path/to/dist/index.js ...".
|
||||||
});
|
const out = execSync(`ps -p ${pid} -o command=`, {
|
||||||
let stdout = '';
|
encoding: 'utf-8',
|
||||||
let settled = false;
|
}).trim();
|
||||||
const timer = setTimeout(() => {
|
const tokens = out.split(/\s+/);
|
||||||
if (settled) return;
|
const script = tokens.find((t) => /\.(js|mjs|cjs|ts)$/.test(t));
|
||||||
settled = true;
|
return script ?? null;
|
||||||
child.kill('SIGKILL');
|
} catch {
|
||||||
resolve('no_reply');
|
return null;
|
||||||
}, 90_000);
|
}
|
||||||
|
}
|
||||||
child.stdout.on('data', (chunk: Buffer) => {
|
|
||||||
stdout += chunk.toString('utf-8');
|
function isPathInside(candidate: string, parent: string): boolean {
|
||||||
});
|
const rel = path.relative(parent, candidate);
|
||||||
child.on('close', (code) => {
|
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user