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>
This commit is contained in:
gavrielc
2026-04-22 01:09:26 +03:00
parent e24ecbf8b0
commit 6e0d742a7f
6 changed files with 541 additions and 192 deletions

View File

@@ -24,10 +24,13 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@chat-adapter/telegram": "4.26.0",
"@clack/prompts": "^1.2.0",
"@onecli-sh/sdk": "^0.3.1", "@onecli-sh/sdk": "^0.3.1",
"better-sqlite3": "11.10.0", "better-sqlite3": "11.10.0",
"chat": "^4.24.0", "chat": "^4.24.0",
"cron-parser": "5.5.0" "cron-parser": "5.5.0",
"kleur": "^4.1.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.35.0", "@eslint/js": "^9.35.0",

76
pnpm-lock.yaml generated
View File

@@ -8,6 +8,12 @@ importers:
.: .:
dependencies: dependencies:
'@chat-adapter/telegram':
specifier: 4.26.0
version: 4.26.0
'@clack/prompts':
specifier: ^1.2.0
version: 1.2.0
'@onecli-sh/sdk': '@onecli-sh/sdk':
specifier: ^0.3.1 specifier: ^0.3.1
version: 0.3.1 version: 0.3.1
@@ -20,6 +26,9 @@ importers:
cron-parser: cron-parser:
specifier: 5.5.0 specifier: 5.5.0
version: 5.5.0 version: 5.5.0
kleur:
specifier: ^4.1.5
version: 4.1.5
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.35.0 specifier: ^9.35.0
@@ -60,6 +69,18 @@ importers:
packages: packages:
'@chat-adapter/shared@4.26.0':
resolution: {integrity: sha512-YD0MGktFXrArUqTBsyPfL5vkdD1WBS58PAWO0oVrMQAMmPxpAXfWGjBtZCkf3y8R8Svb0uVuVXiMZSForaEnMQ==}
'@chat-adapter/telegram@4.26.0':
resolution: {integrity: sha512-PE2HoCQ4648VNKZTuHFanQNoYzM/niNoSbDyYlPq6VOoB5qsoo1ctR8TERyl1EfPBNexWZpSWYrrnQPr15LUfA==}
'@clack/core@1.2.0':
resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==}
'@clack/prompts@1.2.0':
resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==}
'@emnapi/core@1.9.2': '@emnapi/core@1.9.2':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
@@ -748,6 +769,15 @@ packages:
fast-levenshtein@2.0.6: fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-string-truncated-width@1.2.1:
resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==}
fast-string-width@1.1.0:
resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==}
fast-wrap-ansi@0.1.6:
resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -866,6 +896,10 @@ packages:
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
levn@0.4.1: levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1239,6 +1273,9 @@ packages:
simple-get@4.0.1: simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1462,6 +1499,31 @@ packages:
snapshots: snapshots:
'@chat-adapter/shared@4.26.0':
dependencies:
chat: 4.26.0
transitivePeerDependencies:
- supports-color
'@chat-adapter/telegram@4.26.0':
dependencies:
'@chat-adapter/shared': 4.26.0
chat: 4.26.0
transitivePeerDependencies:
- supports-color
'@clack/core@1.2.0':
dependencies:
fast-wrap-ansi: 0.1.6
sisteransi: 1.0.5
'@clack/prompts@1.2.0':
dependencies:
'@clack/core': 1.2.0
fast-string-width: 1.1.0
fast-wrap-ansi: 0.1.6
sisteransi: 1.0.5
'@emnapi/core@1.9.2': '@emnapi/core@1.9.2':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.1 '@emnapi/wasi-threads': 1.2.1
@@ -2105,6 +2167,16 @@ snapshots:
fast-levenshtein@2.0.6: {} fast-levenshtein@2.0.6: {}
fast-string-truncated-width@1.2.1: {}
fast-string-width@1.1.0:
dependencies:
fast-string-truncated-width: 1.2.1
fast-wrap-ansi@0.1.6:
dependencies:
fast-string-width: 1.1.0
fdir@6.5.0(picomatch@4.0.4): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
@@ -2191,6 +2263,8 @@ snapshots:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
kleur@4.1.5: {}
levn@0.4.1: levn@0.4.1:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@@ -2735,6 +2809,8 @@ snapshots:
once: 1.4.0 once: 1.4.0
simple-concat: 1.0.1 simple-concat: 1.0.1
sisteransi@1.0.5: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
stackback@0.0.2: {} stackback@0.0.2: {}

View File

@@ -27,7 +27,7 @@ if need_install; then
# pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT # pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT
# in this list — do not overwrite the local version with the channels copy. # in this list — do not overwrite the local version with the channels copy.
echo "[add-telegram] Copying adapter files from $CHANNELS_BRANCH" echo "[add-telegram] Copying adapter files from ${CHANNELS_BRANCH}"
for f in \ for f in \
src/channels/telegram.ts \ src/channels/telegram.ts \
src/channels/telegram-pairing.ts \ src/channels/telegram-pairing.ts \
@@ -59,7 +59,7 @@ if need_install; then
} }
' '
echo "[add-telegram] Installing $ADAPTER_VERSION" echo "[add-telegram] Installing ${ADAPTER_VERSION}"
pnpm install "$ADAPTER_VERSION" pnpm install "$ADAPTER_VERSION"
echo "[add-telegram] Building…" echo "[add-telegram] Building…"

View File

@@ -20,67 +20,249 @@
* Run `pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>` later * Run `pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>` later
* if autodetect is wrong (e.g. headless server with TZ=UTC). * if autodetect is wrong (e.g. headless server with TZ=UTC).
* *
* Anthropic credential registration runs via setup/register-claude-token.sh * UI is rendered with @clack/prompts: spinners wrap each step, child output
* (the only step that truly requires human input — browser sign-in or a * is captured quietly and only dumped on failure. Interactive children
* pasted token/key). Channel auth and `/manage-channels` remain separate * (register-claude-token.sh, add-telegram.sh) bypass the spinner and run
* because they're platform-specific and typically handled via `/add-<channel>` * with inherited stdio — clack resumes cleanly on the next step.
* and `/manage-channels` after this driver completes.
*/ */
import { spawn, spawnSync } from 'child_process'; import { spawn, spawnSync } from 'child_process';
import { createInterface } from 'readline/promises';
import * as p from '@clack/prompts';
import k from 'kleur';
const CLI_AGENT_NAME = 'Terminal Agent'; const CLI_AGENT_NAME = 'Terminal Agent';
const DEFAULT_AGENT_NAME = 'Nano'; const DEFAULT_AGENT_NAME = 'Nano';
type Fields = Record<string, string>; /**
type StepResult = { ok: boolean; fields: Fields; exitCode: number }; * 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');
function parseStatus(stdout: string): Fields { const brand = (s: string): string => {
const out: Fields = {}; if (!USE_ANSI) return s;
let inBlock = false; if (TRUECOLOR) return `\x1b[38;2;43;183;206m${s}\x1b[0m`;
for (const line of stdout.split('\n')) { return k.cyan(s);
if (line.startsWith('=== NANOCLAW SETUP:')) { };
inBlock = true; const brandBold = (s: string): string => {
continue; 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 (line.startsWith('=== END ===')) {
inBlock = false; if (this.current) {
continue; this.blocks.push(this.current);
this.onBlock(this.current);
this.current = null;
}
return;
} }
if (!inBlock) continue; if (!this.current) return;
const idx = line.indexOf(':'); const colon = line.indexOf(':');
if (idx === -1) continue; if (colon === -1) return;
const key = line.slice(0, idx).trim(); const key = line.slice(0, colon).trim();
const value = line.slice(idx + 1).trim(); const value = line.slice(colon + 1).trim();
if (key) out[key] = value; if (key) this.current.fields[key] = value;
} }
return out;
} }
function runStep(name: string, extra: string[] = []): Promise<StepResult> { /**
* 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) => { return new Promise((resolve) => {
console.log(`\n── ${name} ────────────────────────────────────`); const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName];
const args = ['exec', 'tsx', 'setup/index.ts', '--step', name];
if (extra.length > 0) args.push('--', ...extra); if (extra.length > 0) args.push('--', ...extra);
const child = spawn('pnpm', args, { stdio: ['inherit', 'pipe', 'inherit'] }); const child = spawn('pnpm', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let buf = ''; const stream = new StatusStream(onBlock);
child.stdout.on('data', (chunk: Buffer) => {
const s = chunk.toString('utf-8'); child.stdout.on('data', (chunk: Buffer) => stream.write(chunk.toString('utf-8')));
buf += s; child.stderr.on('data', (chunk: Buffer) => {
process.stdout.write(s); stream.transcript += chunk.toString('utf-8');
}); });
child.on('close', (code) => { child.on('close', (code) => {
const fields = parseStatus(buf); // 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({ resolve({
ok: code === 0 && fields.STATUS === 'success', ok,
fields,
exitCode: code ?? 1, 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 * After installing Docker, this process's supplementary groups are still
* frozen from login — subsequent steps that talk to /var/run/docker.sock * frozen from login — subsequent steps that talk to /var/run/docker.sock
@@ -89,7 +271,7 @@ function runStep(name: string, extra: string[] = []): Promise<StepResult> {
* so the rest of the run inherits the docker group without a re-login. * so the rest of the run inherits the docker group without a re-login.
*/ */
function maybeReexecUnderSg(): void { function maybeReexecUnderSg(): void {
if (process.env.NANOCLAW_REEXEC_SG === '1') return; // already re-exec'd if (process.env.NANOCLAW_REEXEC_SG === '1') return;
if (process.platform !== 'linux') return; if (process.platform !== 'linux') return;
const info = spawnSync('docker', ['info'], { encoding: 'utf-8' }); const info = spawnSync('docker', ['info'], { encoding: 'utf-8' });
if (info.status === 0) return; if (info.status === 0) return;
@@ -97,10 +279,7 @@ function maybeReexecUnderSg(): void {
if (!/permission denied/i.test(err)) return; if (!/permission denied/i.test(err)) return;
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
console.log( p.log.warn('Docker socket not accessible in current group — re-executing under `sg docker`.');
'\n[setup:auto] Docker socket not accessible in current group — ' +
're-executing under `sg docker` to pick up new group membership.',
);
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
stdio: 'inherit', stdio: 'inherit',
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
@@ -121,67 +300,121 @@ function anthropicSecretExists(): boolean {
} }
} }
async function askDisplayName(fallback: string): Promise<string> { function runInheritScript(cmd: string, args: string[]): Promise<number> {
const rl = createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => {
try { const child = spawn(cmd, args, { stdio: 'inherit' });
const answer = await rl.question( child.on('close', (code) => resolve(code ?? 1));
`\nWhat should your agents call you? [${fallback}]: `, });
); }
return answer.trim() || fallback;
} finally { function formatCodeCard(code: string): string {
rl.close(); 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> { async function askAgentName(fallback: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout }); const answer = ensureAnswer(
try { await p.text({
const answer = await rl.question( message: 'What should your messaging agent be called?',
`\nWhat should your agent be called? [${fallback}]: `, placeholder: fallback,
); defaultValue: fallback,
return answer.trim() || fallback; }),
} finally { );
rl.close(); return (answer as string).trim() || fallback;
}
} }
async function askChannelChoice(): Promise<'telegram' | 'skip'> { async function askChannelChoice(): Promise<'telegram' | 'skip'> {
const rl = createInterface({ input: process.stdin, output: process.stdout }); const choice = ensureAnswer(
try { await p.select({
console.log('\nConnect a messaging app so you can chat from your phone?'); message: 'Connect a messaging app so you can chat from your phone?',
console.log(' 1) Telegram'); options: [
console.log(' 2) Skip — just use the CLI for now'); { value: 'telegram', label: 'Telegram', hint: 'recommended' },
const answer = (await rl.question('Choose [1/2]: ')).trim(); { value: 'skip', label: 'Skip — use the CLI only' },
return answer === '1' ? 'telegram' : 'skip'; ],
} finally { }),
rl.close(); );
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;
} }
}
function runBashScript(relPath: string): Promise<number> { console.log();
return new Promise((resolve) => { console.log(` ${wordmark}`);
const child = spawn('bash', [relPath], { stdio: 'inherit' }); console.log(` ${k.dim('end-to-end scripted setup of your personal assistant')}`);
child.on('close', (code) => resolve(code ?? 1)); p.intro(`${brandChip(' setup:auto ')}`);
});
}
function runTsxScript(relPath: string, args: string[] = []): Promise<number> {
return new Promise((resolve) => {
const child = spawn('pnpm', ['exec', 'tsx', relPath, ...args], {
stdio: 'inherit',
});
child.on('close', (code) => resolve(code ?? 1));
});
}
function fail(msg: string, hint?: string): never {
console.error(`\n[setup:auto] ${msg}`);
if (hint) console.error(` ${hint}`);
console.error(' Logs: logs/setup.log');
process.exit(1);
} }
async function main(): Promise<void> { async function main(): Promise<void> {
printIntro();
const skip = new Set( const skip = new Set(
(process.env.NANOCLAW_SKIP ?? '') (process.env.NANOCLAW_SKIP ?? '')
.split(',') .split(',')
@@ -190,92 +423,113 @@ async function main(): Promise<void> {
); );
if (!skip.has('environment')) { if (!skip.has('environment')) {
const env = await runStep('environment'); const res = await runQuietStep(
if (!env.ok) fail('environment check failed'); 'environment',
{ running: 'Checking environment…', done: 'Environment OK.' },
);
if (!res.ok) fail('Environment check failed.');
} }
if (!skip.has('container')) { if (!skip.has('container')) {
const res = await runStep('container'); const res = await runQuietStep('container', {
running: 'Building the agent container image…',
done: 'Container image ready.',
failed: 'Container build failed.',
});
if (!res.ok) { if (!res.ok) {
if (res.fields.ERROR === 'runtime_not_available') { const err = res.terminal?.fields.ERROR;
if (err === 'runtime_not_available') {
fail( fail(
'Docker is not available and could not be started automatically.', 'Docker is not available and could not be started automatically.',
'Install Docker Desktop or start it manually, then retry.', 'Install Docker Desktop or start it manually, then retry.',
); );
} }
if (res.fields.ERROR === 'docker_group_not_active') { if (err === 'docker_group_not_active') {
fail( fail(
'Docker was just installed but your shell is not yet in the `docker` group.', '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 `pnpm run setup:auto`.', 'Log out and back in (or run `newgrp docker` in a new shell), then retry.',
); );
} }
fail( fail(
'container build/test failed', 'Container build/test failed.',
'For stale build cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', 'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.',
); );
} }
maybeReexecUnderSg(); maybeReexecUnderSg();
} }
if (!skip.has('onecli')) { if (!skip.has('onecli')) {
const res = await runStep('onecli'); const res = await runQuietStep('onecli', {
running: 'Installing OneCLI credential vault…',
done: 'OneCLI installed.',
});
if (!res.ok) { if (!res.ok) {
if (res.fields.ERROR === 'onecli_not_on_path_after_install') { const err = res.terminal?.fields.ERROR;
if (err === 'onecli_not_on_path_after_install') {
fail( fail(
'OneCLI installed but not on PATH.', 'OneCLI installed but not on PATH.',
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
); );
} }
fail( fail(
`OneCLI install failed (${res.fields.ERROR ?? 'unknown'})`, `OneCLI install failed (${err ?? 'unknown'}).`,
'Check that curl + a writable ~/.local/bin are available; re-run `pnpm run setup:auto`.', 'Check that curl + a writable ~/.local/bin are available, then retry.',
); );
} }
} }
if (!skip.has('auth')) { if (!skip.has('auth')) {
if (anthropicSecretExists()) { if (anthropicSecretExists()) {
console.log( p.log.success('OneCLI already has an Anthropic secret — skipping.');
'\n── auth ────────────────────────────────────\n' +
'[setup:auto] OneCLI already has an Anthropic secret — skipping.',
);
} else { } else {
console.log('\n── auth ────────────────────────────────────'); p.log.step('Registering your Anthropic credential…');
const code = await runBashScript('setup/register-claude-token.sh'); 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) { if (code !== 0) {
fail( fail(
'Anthropic credential registration failed or was aborted.', 'Anthropic credential registration failed or was aborted.',
'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', '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')) { if (!skip.has('mounts')) {
const res = await runStep('mounts', ['--empty']); const res = await runQuietStep('mounts', {
if (!res.ok && res.fields.STATUS !== 'skipped') { running: 'Writing mount allowlist…',
fail('mount allowlist step failed'); done: 'Mount allowlist in place.',
} skipped: 'Mount allowlist already configured.',
}, ['--empty']);
if (!res.ok) fail('Mount allowlist step failed.');
} }
if (!skip.has('service')) { if (!skip.has('service')) {
const res = await runStep('service'); const res = await runQuietStep('service', {
running: 'Installing the background service…',
done: 'Service installed and running.',
});
if (!res.ok) { if (!res.ok) {
fail( fail(
'service install failed', 'Service install failed.',
'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.',
); );
} }
if (res.fields.DOCKER_GROUP_STALE === 'true') { if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
console.warn( p.log.warn('Docker group stale in systemd session.');
'\n[setup:auto] Docker group stale in systemd session. Run:\n' + p.log.message(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + k.dim(
' systemctl --user restart nanoclaw', ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
' systemctl --user restart nanoclaw',
),
); );
} }
} }
// Resolved once, reused by cli-agent + channel wiring.
let displayName: string | undefined; let displayName: string | undefined;
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel'); const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
if (needsDisplayName) { if (needsDisplayName) {
@@ -285,15 +539,17 @@ async function main(): Promise<void> {
} }
if (!skip.has('cli-agent')) { if (!skip.has('cli-agent')) {
const res = await runStep('cli-agent', [ const res = await runQuietStep(
'--display-name', 'cli-agent',
displayName!, {
'--agent-name', running: 'Wiring the terminal agent…',
CLI_AGENT_NAME, done: 'Terminal agent wired (try `pnpm run chat hi`).',
]); },
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
);
if (!res.ok) { if (!res.ok) {
fail( fail(
'CLI agent wiring failed', 'CLI agent wiring failed.',
`Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`,
); );
} }
@@ -302,15 +558,19 @@ async function main(): Promise<void> {
if (!skip.has('channel')) { if (!skip.has('channel')) {
const choice = await askChannelChoice(); const choice = await askChannelChoice();
if (choice === 'telegram') { if (choice === 'telegram') {
const installCode = await runBashScript('setup/add-telegram.sh'); 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) { if (installCode !== 0) {
fail( fail(
'Telegram install failed.', 'Telegram install failed.',
'Re-run `bash setup/add-telegram.sh`, then `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', 'Re-run `bash setup/add-telegram.sh`, then retry `pnpm run setup:auto`.',
); );
} }
p.log.success('Telegram adapter installed.');
const pair = await runStep('pair-telegram', ['--intent', 'main']); const pair = await runPairTelegram();
if (!pair.ok) { if (!pair.ok) {
fail( fail(
'Telegram pairing failed.', 'Telegram pairing failed.',
@@ -318,8 +578,8 @@ async function main(): Promise<void> {
); );
} }
const platformId = pair.fields.PLATFORM_ID; const platformId = pair.terminal?.fields.PLATFORM_ID;
const pairedUserId = pair.fields.PAIRED_USER_ID; const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID;
if (!platformId || !pairedUserId) { if (!platformId || !pairedUserId) {
fail( fail(
'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.', 'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.',
@@ -331,54 +591,72 @@ async function main(): Promise<void> {
process.env.NANOCLAW_AGENT_NAME?.trim() || process.env.NANOCLAW_AGENT_NAME?.trim() ||
(await askAgentName(DEFAULT_AGENT_NAME)); (await askAgentName(DEFAULT_AGENT_NAME));
console.log('\n── wiring first agent ──────────────────────────'); const init = await runQuietChild(
const initCode = await runTsxScript('scripts/init-first-agent.ts', [ 'pnpm',
'--channel', 'telegram', [
'--user-id', pairedUserId, 'exec', 'tsx', 'scripts/init-first-agent.ts',
'--platform-id', platformId, '--channel', 'telegram',
'--display-name', displayName!, '--user-id', pairedUserId,
'--agent-name', agentName, '--platform-id', platformId,
]); '--display-name', displayName!,
if (initCode !== 0) { '--agent-name', agentName,
],
{
running: `Wiring ${agentName} to your Telegram chat…`,
done: `${agentName} is wired — welcome DM incoming.`,
},
);
if (!init.ok) {
fail( fail(
'Wiring the Telegram agent failed.', '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}"\`.`, `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 {
console.log( p.log.info('No messaging channel wired — you can add one later with `/add-<channel>`.');
`\n[setup:auto] Telegram is wired. ${agentName} will DM you a welcome shortly.`,
);
} }
} }
if (!skip.has('verify')) { if (!skip.has('verify')) {
const res = await runStep('verify'); const res = await runQuietStep('verify', {
running: 'Verifying the install…',
done: 'Install verified.',
failed: 'Verification found issues.',
});
if (!res.ok) { if (!res.ok) {
console.log('\n[setup:auto] Scripted steps done. Remaining (interactive):'); const notes: string[] = [];
if (res.fields.CREDENTIALS !== 'configured') { if (res.terminal?.fields.CREDENTIALS !== 'configured') {
console.log(' • Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`'); notes.push('• Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`.');
} }
if (res.fields.AGENT_PING && res.fields.AGENT_PING !== 'ok' && res.fields.AGENT_PING !== 'skipped') { const agentPing = res.terminal?.fields.AGENT_PING;
console.log( if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
` • CLI agent did not reply (status: ${res.fields.AGENT_PING}). ` + notes.push(
`• CLI agent did not reply (status: ${agentPing}). ` +
'Check `logs/nanoclaw.log` and `groups/*/logs/container-*.log`, then try `pnpm run chat hi`.', 'Check `logs/nanoclaw.log` and `groups/*/logs/container-*.log`, then try `pnpm run chat hi`.',
); );
} }
if (!res.fields.CONFIGURED_CHANNELS) { if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
console.log( notes.push('• Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …');
' • Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …',
);
console.log(' (CLI channel is already wired: `pnpm run chat hi`)');
} }
if (notes.length > 0) {
p.note(notes.join('\n'), 'Whats left');
}
p.outro(k.yellow('Scripted steps done — some pieces still need you.'));
return; return;
} }
} }
console.log('\n[setup:auto] Complete.'); 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) => { main().catch((err) => {
console.error(err); p.log.error(err instanceof Error ? err.message : String(err));
p.cancel('Setup aborted.');
process.exit(1); process.exit(1);
}); });

View File

@@ -2,11 +2,16 @@
* Step: pair-telegram — issue a one-time pairing code and wait for the * Step: pair-telegram — issue a one-time pairing code and wait for the
* operator to send the code from the chat they want to register. * operator to send the code from the chat they want to register.
* *
* Used exclusively by `setup:auto` / `bash nanoclaw.sh` on this branch. Human- * Emits machine-readable status blocks only. The parent driver
* facing output is a focused banner for the code (no parseable block), plus a * (`setup:auto`) renders the code / attempt / success UI with clack. Running
* short line for wrong attempts / regenerations. A single machine-readable * this step directly will look sparse — that's intentional.
* PAIR_TELEGRAM status block is still emitted at the end so the parent driver *
* can pick up PLATFORM_ID / PAIRED_USER_ID / IS_GROUP. * Blocks emitted:
* PAIR_TELEGRAM_CODE { CODE, REASON=initial|regenerated }
* PAIR_TELEGRAM_ATTEMPT { CANDIDATE }
* PAIR_TELEGRAM (final) { STATUS=success, CODE, INTENT, PLATFORM_ID,
* IS_GROUP, PAIRED_USER_ID }
* or { STATUS=failed, CODE, ERROR }
* *
* Depends on src/channels/telegram-pairing.js, which setup/add-telegram.sh * Depends on src/channels/telegram-pairing.js, which setup/add-telegram.sh
* copies in from the `channels` branch before this step runs. setup/ is * copies in from the `channels` branch before this step runs. setup/ is
@@ -50,22 +55,6 @@ function intentToString(intent: PairingIntent): string {
return `${intent.kind}:${intent.folder}`; return `${intent.kind}:${intent.folder}`;
} }
function printCodeBanner(code: string): void {
const digits = code.split('').join(' ');
const content = [
'',
` PAIRING CODE: ${digits}`,
'',
' Send these digits from Telegram to your bot.',
'',
];
const width = Math.max(...content.map((l) => l.length));
const top = ' ╔' + '═'.repeat(width + 2) + '╗';
const bot = ' ╚' + '═'.repeat(width + 2) + '╝';
const mid = content.map((l) => ' ║ ' + l.padEnd(width) + ' ║');
console.log(['', top, ...mid, bot, ''].join('\n'));
}
export async function run(args: string[]): Promise<void> { export async function run(args: string[]): Promise<void> {
const intent = parseArgs(args); const intent = parseArgs(args);
@@ -78,19 +67,21 @@ export async function run(args: string[]): Promise<void> {
const MAX_REGENERATIONS = 5; const MAX_REGENERATIONS = 5;
let record = await createPairing(intent); let record = await createPairing(intent);
printCodeBanner(record.code); emitStatus('PAIR_TELEGRAM_CODE', {
CODE: record.code,
REASON: 'initial',
});
for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) { for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) {
try { try {
const consumed = await waitForPairing(record.code, { const consumed = await waitForPairing(record.code, {
onAttempt: (a) => { onAttempt: (a) => {
console.log( emitStatus('PAIR_TELEGRAM_ATTEMPT', {
` Got "${a.candidate}" — doesn't match. A new code is on its way.`, CANDIDATE: a.candidate,
); });
}, },
}); });
console.log('\n ✓ Telegram paired.\n');
emitStatus('PAIR_TELEGRAM', { emitStatus('PAIR_TELEGRAM', {
STATUS: 'success', STATUS: 'success',
CODE: record.code, CODE: record.code,
@@ -107,12 +98,13 @@ export async function run(args: string[]): Promise<void> {
const invalidated = /invalidated by wrong code/.test(message); const invalidated = /invalidated by wrong code/.test(message);
if (invalidated && regen < MAX_REGENERATIONS) { if (invalidated && regen < MAX_REGENERATIONS) {
record = await createPairing(intent); record = await createPairing(intent);
console.log('\n Previous code invalidated. New code:'); emitStatus('PAIR_TELEGRAM_CODE', {
printCodeBanner(record.code); CODE: record.code,
REASON: 'regenerated',
});
continue; continue;
} }
const reason = invalidated ? 'max-regenerations-exceeded' : message; const reason = invalidated ? 'max-regenerations-exceeded' : message;
console.error(`\n ✗ Pairing failed: ${reason}`);
emitStatus('PAIR_TELEGRAM', { emitStatus('PAIR_TELEGRAM', {
STATUS: 'failed', STATUS: 'failed',
CODE: record.code, CODE: record.code,

View File

@@ -117,7 +117,7 @@ esac
echo echo
echo "Got token: ${TOKEN:0:16}${TOKEN: -4}" echo "Got token: ${TOKEN:0:16}${TOKEN: -4}"
echo "Registering with OneCLI as '$SECRET_NAME' (host pattern: $HOST_PATTERN)…" echo "Registering with OneCLI as '${SECRET_NAME}' (host pattern: ${HOST_PATTERN})…"
onecli secrets create \ onecli secrets create \
--name "$SECRET_NAME" \ --name "$SECRET_NAME" \