Files
nanoclaw/setup/auto.ts
gavrielc 6e0d742a7f feat(setup): brand setup:auto with @clack/prompts + brand palette
Wraps the scripted setup flow in a branded, friendly UI. Each step runs
under a clack spinner with elapsed time; child stdout/stderr is captured
quietly and dumped only on failure. Interactive children (token paste,
Anthropic OAuth) bypass the spinner and inherit the TTY.

- intro: NanoClaw wordmark + brand-cyan accent chip, truecolor with
  kleur fallback and NO_COLOR / non-TTY awareness
- pair-telegram: emits PAIR_TELEGRAM_CODE / _ATTEMPT status blocks only;
  auto.ts renders clack notes + "received X — doesn't match" checkpoints
- streaming status-block parser handles mid-step events without waiting
  for the child to exit
- terminal-block detection now finds any block with a STATUS field
  (handles MOUNTS emitting CONFIGURE_MOUNTS, etc.) and treats 'skipped'
  as a success variant with an optional friendlier label

Also fixes a latent bash bug where `$VAR…` (unbraced followed by a
multi-byte Unicode character) pulled ellipsis bytes into the variable
name lookup and tripped `set -u`. Braced `${VAR}` in add-telegram.sh
and register-claude-token.sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:09:26 +03:00

663 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Non-interactive setup driver. Chains the deterministic setup steps so a
* scripted install can go from a fresh checkout to a running service without
* the `/setup` skill.
*
* Prerequisite: `bash setup.sh` has run (Node >= 20, pnpm install, native
* module check). This driver picks up from there.
*
* Config via env:
* NANOCLAW_DISPLAY_NAME how the agents address the operator — skips the
* prompt. Defaults to $USER.
* NANOCLAW_AGENT_NAME name for the messaging-channel agent (Telegram,
* etc.) — skips the prompt. Defaults to "Nano".
* (The CLI scratch agent is always "Terminal Agent".)
* NANOCLAW_SKIP comma-separated step names to skip
* (environment|container|onecli|auth|mounts|
* service|cli-agent|channel|verify)
*
* Timezone is not configured here — it defaults to the host system's TZ.
* Run `pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>` later
* if autodetect is wrong (e.g. headless server with TZ=UTC).
*
* UI is rendered with @clack/prompts: spinners wrap each step, child output
* is captured quietly and only dumped on failure. Interactive children
* (register-claude-token.sh, add-telegram.sh) bypass the spinner and run
* with inherited stdio — clack resumes cleanly on the next step.
*/
import { spawn, spawnSync } from 'child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
const CLI_AGENT_NAME = 'Terminal Agent';
const DEFAULT_AGENT_NAME = 'Nano';
/**
* Brand palette, pulled from assets/nanoclaw-logo.png:
* brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body
* brand navy ≈ #171B3B — the dark logo background + outlines
* Gated on TTY + NO_COLOR so piped / CI output stays plain. Falls back to
* kleur's 16-color cyan when the terminal isn't truecolor.
*/
const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
const TRUECOLOR =
USE_ANSI &&
(process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit');
const brand = (s: string): string => {
if (!USE_ANSI) return s;
if (TRUECOLOR) return `\x1b[38;2;43;183;206m${s}\x1b[0m`;
return k.cyan(s);
};
const brandBold = (s: string): string => {
if (!USE_ANSI) return s;
if (TRUECOLOR) return `\x1b[1;38;2;43;183;206m${s}\x1b[0m`;
return k.bold(k.cyan(s));
};
const brandChip = (s: string): string => {
if (!USE_ANSI) return s;
if (TRUECOLOR) {
return `\x1b[48;2;43;183;206m\x1b[38;2;23;27;59m\x1b[1m${s}\x1b[0m`;
}
return k.bgCyan(k.black(k.bold(s)));
};
type Fields = Record<string, string>;
type Block = { type: string; fields: Fields };
type StepResult = {
ok: boolean;
exitCode: number;
blocks: Block[];
transcript: string;
/** The last block matching `stepName.toUpperCase()` if any. */
terminal: Block | null;
};
/**
* Streaming parser for `=== NANOCLAW SETUP: TYPE ===` blocks. Emits each
* block as it closes so the UI can react mid-stream (e.g. render a pairing
* code card as soon as pair-telegram emits it, rather than after the step
* has finished).
*/
class StatusStream {
private lineBuf = '';
private current: Block | null = null;
readonly blocks: Block[] = [];
transcript = '';
constructor(private readonly onBlock: (block: Block) => void) {}
write(chunk: string): void {
this.transcript += chunk;
this.lineBuf += chunk;
let idx: number;
while ((idx = this.lineBuf.indexOf('\n')) !== -1) {
const line = this.lineBuf.slice(0, idx);
this.lineBuf = this.lineBuf.slice(idx + 1);
this.processLine(line);
}
}
private processLine(line: string): void {
const start = line.match(/^=== NANOCLAW SETUP: (\S+) ===/);
if (start) {
this.current = { type: start[1], fields: {} };
return;
}
if (line.startsWith('=== END ===')) {
if (this.current) {
this.blocks.push(this.current);
this.onBlock(this.current);
this.current = null;
}
return;
}
if (!this.current) return;
const colon = line.indexOf(':');
if (colon === -1) return;
const key = line.slice(0, colon).trim();
const value = line.slice(colon + 1).trim();
if (key) this.current.fields[key] = value;
}
}
/**
* Spawn a setup step as a child process, swallowing stdout/stderr into a
* buffer. The provided onBlock callback fires per status block as they
* parse. Returns when the child exits.
*/
function spawnStep(
stepName: string,
extra: string[],
onBlock: (block: Block) => void,
): Promise<StepResult> {
return new Promise((resolve) => {
const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName];
if (extra.length > 0) args.push('--', ...extra);
const child = spawn('pnpm', args, { stdio: ['ignore', 'pipe', 'pipe'] });
const stream = new StatusStream(onBlock);
child.stdout.on('data', (chunk: Buffer) => stream.write(chunk.toString('utf-8')));
child.stderr.on('data', (chunk: Buffer) => {
stream.transcript += chunk.toString('utf-8');
});
child.on('close', (code) => {
// Step block types don't always mirror step names (e.g. `mounts` emits
// CONFIGURE_MOUNTS, `container` emits SETUP_CONTAINER). Any block with
// a STATUS field is a terminal block; the last one wins.
const terminal =
[...stream.blocks].reverse().find((b) => b.fields.STATUS) ?? null;
const status = terminal?.fields.STATUS;
const ok = code === 0 && (status === 'success' || status === 'skipped');
resolve({
ok,
exitCode: code ?? 1,
blocks: stream.blocks,
transcript: stream.transcript,
terminal,
});
});
});
}
type SpinnerLabels = {
running: string;
done: string;
skipped?: string;
failed?: string;
};
/** Run a step under a clack spinner. Child output is captured; shown only on failure. */
async function runQuietStep(
stepName: string,
labels: SpinnerLabels,
extra: string[] = [],
): Promise<StepResult> {
return runUnderSpinner(labels, () => spawnStep(stepName, extra, () => {}));
}
/** Run an arbitrary child under a spinner, capturing its stdout/stderr. */
async function runQuietChild(
cmd: string,
args: string[],
labels: SpinnerLabels,
): Promise<{ ok: boolean; exitCode: number; transcript: string }> {
return runUnderSpinner(labels, () => spawnQuiet(cmd, args));
}
async function runUnderSpinner<
T extends { ok: boolean; transcript: string; terminal?: Block | null },
>(
labels: SpinnerLabels,
work: () => Promise<T>,
): Promise<T> {
const s = p.spinner();
const start = Date.now();
s.start(labels.running);
const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000);
s.message(`${labels.running} ${k.dim(`(${elapsed}s)`)}`);
}, 1000);
const result = await work();
clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000);
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)`)}`);
} else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
s.stop(`${failMsg} ${k.dim(`(${elapsed}s)`)}`, 1);
dumpTranscriptOnFailure(result.transcript);
}
return result;
}
function spawnQuiet(
cmd: string,
args: string[],
): Promise<{ ok: boolean; exitCode: number; transcript: string }> {
return new Promise((resolve) => {
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let transcript = '';
child.stdout.on('data', (c: Buffer) => { transcript += c.toString('utf-8'); });
child.stderr.on('data', (c: Buffer) => { transcript += c.toString('utf-8'); });
child.on('close', (code) => {
resolve({ ok: code === 0, exitCode: code ?? 1, transcript });
});
});
}
function dumpTranscriptOnFailure(transcript: string): void {
const lines = transcript.split('\n').filter((l) => {
if (l.startsWith('=== NANOCLAW SETUP:')) return false;
if (l.startsWith('=== END ===')) return false;
return true;
});
const tail = lines.slice(-40).join('\n').trimEnd();
if (tail) {
console.log();
console.log(k.dim(tail));
console.log();
}
}
function fail(msg: string, hint?: string): never {
p.log.error(msg);
if (hint) p.log.message(k.dim(hint));
p.log.message(k.dim('Logs: logs/setup.log'));
p.cancel('Setup aborted.');
process.exit(1);
}
function ensureAnswer<T>(value: T | symbol): T {
if (p.isCancel(value)) {
p.cancel('Setup cancelled.');
process.exit(0);
}
return value as T;
}
/**
* After installing Docker, this process's supplementary groups are still
* frozen from login — subsequent steps that talk to /var/run/docker.sock
* (onecli install, service start, …) fail with EACCES even though the
* daemon is up. Detect that and re-exec the whole driver under `sg docker`
* so the rest of the run inherits the docker group without a re-login.
*/
function maybeReexecUnderSg(): void {
if (process.env.NANOCLAW_REEXEC_SG === '1') return;
if (process.platform !== 'linux') return;
const info = spawnSync('docker', ['info'], { encoding: 'utf-8' });
if (info.status === 0) return;
const err = `${info.stderr ?? ''}\n${info.stdout ?? ''}`;
if (!/permission denied/i.test(err)) return;
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
p.log.warn('Docker socket not accessible in current group — re-executing under `sg docker`.');
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
stdio: 'inherit',
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
});
process.exit(res.status ?? 1);
}
function anthropicSecretExists(): boolean {
try {
const res = spawnSync('onecli', ['secrets', 'list'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (res.status !== 0) return false;
return /anthropic/i.test(res.stdout ?? '');
} catch {
return false;
}
}
function runInheritScript(cmd: string, args: string[]): Promise<number> {
return new Promise((resolve) => {
const child = spawn(cmd, args, { stdio: 'inherit' });
child.on('close', (code) => resolve(code ?? 1));
});
}
function formatCodeCard(code: string): string {
const spaced = code.split('').join(' ');
return [
'',
` ${brandBold(spaced)}`,
'',
k.dim(' Send these digits from Telegram to your bot.'),
].join('\n');
}
async function runPairTelegram(): Promise<StepResult> {
const s = p.spinner();
s.start('Creating pairing code…');
let spinnerActive = true;
const stopSpinner = (msg: string, code?: number) => {
if (spinnerActive) {
s.stop(msg, code);
spinnerActive = false;
}
};
const result = await spawnStep('pair-telegram', ['--intent', 'main'], (block) => {
if (block.type === 'PAIR_TELEGRAM_CODE') {
const reason = block.fields.REASON ?? 'initial';
if (reason === 'initial') {
stopSpinner('Pairing code ready.');
} else {
stopSpinner('Previous code invalidated. New code below.');
}
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code');
s.start('Waiting for the code from Telegram…');
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`);
s.start('Waiting for the correct code…');
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM') {
if (block.fields.STATUS === 'success') {
stopSpinner('Telegram paired.');
} else {
stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1);
}
}
});
// Safety net: if the child died without emitting a terminal block, make
// sure we don't leave the spinner running.
if (spinnerActive) {
stopSpinner(result.ok ? 'Done.' : 'Pairing exited unexpectedly.', result.ok ? 0 : 1);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
return result;
}
async function askDisplayName(fallback: string): Promise<string> {
const answer = ensureAnswer(
await p.text({
message: 'What should your agents call you?',
placeholder: fallback,
defaultValue: fallback,
}),
);
return (answer as string).trim() || fallback;
}
async function askAgentName(fallback: string): Promise<string> {
const answer = ensureAnswer(
await p.text({
message: 'What should your messaging agent be called?',
placeholder: fallback,
defaultValue: fallback,
}),
);
return (answer as string).trim() || fallback;
}
async function askChannelChoice(): Promise<'telegram' | 'skip'> {
const choice = ensureAnswer(
await p.select({
message: 'Connect a messaging app so you can chat from your phone?',
options: [
{ value: 'telegram', label: 'Telegram', hint: 'recommended' },
{ value: 'skip', label: 'Skip — use the CLI only' },
],
}),
);
return choice as 'telegram' | 'skip';
}
function printIntro(): void {
const isReexec = process.env.NANOCLAW_REEXEC_SG === '1';
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
if (isReexec) {
p.intro(`${brandChip(' setup:auto ')} ${wordmark} ${k.dim('· resuming under docker group')}`);
return;
}
console.log();
console.log(` ${wordmark}`);
console.log(` ${k.dim('end-to-end scripted setup of your personal assistant')}`);
p.intro(`${brandChip(' setup:auto ')}`);
}
async function main(): Promise<void> {
printIntro();
const skip = new Set(
(process.env.NANOCLAW_SKIP ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
);
if (!skip.has('environment')) {
const res = await runQuietStep(
'environment',
{ running: 'Checking environment…', done: 'Environment OK.' },
);
if (!res.ok) fail('Environment check failed.');
}
if (!skip.has('container')) {
const res = await runQuietStep('container', {
running: 'Building the agent container image…',
done: 'Container image ready.',
failed: 'Container build failed.',
});
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
if (err === 'runtime_not_available') {
fail(
'Docker is not available and could not be started automatically.',
'Install Docker Desktop or start it manually, then retry.',
);
}
if (err === 'docker_group_not_active') {
fail(
'Docker was just installed but your shell is not yet in the `docker` group.',
'Log out and back in (or run `newgrp docker` in a new shell), then retry.',
);
}
fail(
'Container build/test failed.',
'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.',
);
}
maybeReexecUnderSg();
}
if (!skip.has('onecli')) {
const res = await runQuietStep('onecli', {
running: 'Installing OneCLI credential vault…',
done: 'OneCLI installed.',
});
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
if (err === 'onecli_not_on_path_after_install') {
fail(
'OneCLI installed but not on PATH.',
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
);
}
fail(
`OneCLI install failed (${err ?? 'unknown'}).`,
'Check that curl + a writable ~/.local/bin are available, then retry.',
);
}
}
if (!skip.has('auth')) {
if (anthropicSecretExists()) {
p.log.success('OneCLI already has an Anthropic secret — skipping.');
} else {
p.log.step('Registering your Anthropic credential…');
console.log(
k.dim(' (browser sign-in or paste a token/key — this part is interactive)'),
);
console.log();
const code = await runInheritScript('bash', ['setup/register-claude-token.sh']);
console.log();
if (code !== 0) {
fail(
'Anthropic credential registration failed or was aborted.',
'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.',
);
}
p.log.success('Anthropic credential registered with OneCLI.');
}
}
if (!skip.has('mounts')) {
const res = await runQuietStep('mounts', {
running: 'Writing mount allowlist…',
done: 'Mount allowlist in place.',
skipped: 'Mount allowlist already configured.',
}, ['--empty']);
if (!res.ok) fail('Mount allowlist step failed.');
}
if (!skip.has('service')) {
const res = await runQuietStep('service', {
running: 'Installing the background service…',
done: 'Service installed and running.',
});
if (!res.ok) {
fail(
'Service install failed.',
'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.',
);
}
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
p.log.warn('Docker group stale in systemd session.');
p.log.message(
k.dim(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
' systemctl --user restart nanoclaw',
),
);
}
}
let displayName: string | undefined;
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
if (needsDisplayName) {
const fallback = process.env.USER?.trim() || 'Operator';
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
displayName = preset || (await askDisplayName(fallback));
}
if (!skip.has('cli-agent')) {
const res = await runQuietStep(
'cli-agent',
{
running: 'Wiring the terminal agent…',
done: 'Terminal agent wired (try `pnpm run chat hi`).',
},
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
);
if (!res.ok) {
fail(
'CLI agent wiring failed.',
`Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`,
);
}
}
if (!skip.has('channel')) {
const choice = await askChannelChoice();
if (choice === 'telegram') {
p.log.step('Installing the Telegram adapter and collecting your bot token…');
console.log();
const installCode = await runInheritScript('bash', ['setup/add-telegram.sh']);
console.log();
if (installCode !== 0) {
fail(
'Telegram install failed.',
'Re-run `bash setup/add-telegram.sh`, then retry `pnpm run setup:auto`.',
);
}
p.log.success('Telegram adapter installed.');
const pair = await runPairTelegram();
if (!pair.ok) {
fail(
'Telegram pairing failed.',
'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.',
);
}
const platformId = pair.terminal?.fields.PLATFORM_ID;
const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID;
if (!platformId || !pairedUserId) {
fail(
'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.',
'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main` and capture the success block.',
);
}
const agentName =
process.env.NANOCLAW_AGENT_NAME?.trim() ||
(await askAgentName(DEFAULT_AGENT_NAME));
const init = await runQuietChild(
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'telegram',
'--user-id', pairedUserId,
'--platform-id', platformId,
'--display-name', displayName!,
'--agent-name', agentName,
],
{
running: `Wiring ${agentName} to your Telegram chat…`,
done: `${agentName} is wired — welcome DM incoming.`,
},
);
if (!init.ok) {
fail(
'Wiring the Telegram agent failed.',
`Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName!}" --agent-name "${agentName}"\`.`,
);
}
} else {
p.log.info('No messaging channel wired — you can add one later with `/add-<channel>`.');
}
}
if (!skip.has('verify')) {
const res = await runQuietStep('verify', {
running: 'Verifying the install…',
done: 'Install verified.',
failed: 'Verification found issues.',
});
if (!res.ok) {
const notes: string[] = [];
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
notes.push('• Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`.');
}
const agentPing = res.terminal?.fields.AGENT_PING;
if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
notes.push(
`• CLI agent did not reply (status: ${agentPing}). ` +
'Check `logs/nanoclaw.log` and `groups/*/logs/container-*.log`, then try `pnpm run chat hi`.',
);
}
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
notes.push('• Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …');
}
if (notes.length > 0) {
p.note(notes.join('\n'), 'Whats left');
}
p.outro(k.yellow('Scripted steps done — some pieces still need you.'));
return;
}
}
const nextSteps = [
`${k.cyan('Chat from the CLI:')} pnpm run chat hi`,
`${k.cyan('Tail host logs:')} tail -f logs/nanoclaw.log`,
`${k.cyan('Open Claude Code:')} claude`,
].join('\n');
p.note(nextSteps, 'Next steps');
p.outro(k.green('Setup complete.'));
}
main().catch((err) => {
p.log.error(err instanceof Error ? err.message : String(err));
p.cancel('Setup aborted.');
process.exit(1);
});