From 2311721375bd3e6f880ab688f588c8f2a328a562 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 17:04:48 +0300 Subject: [PATCH 01/24] feat(setup): add scripted setup driver and auto-start Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm run setup:auto` chains the deterministic setup steps (environment → timezone → container → mounts → service → verify) by spawning the existing per-step CLI and parsing its status blocks. Config via env: NANOCLAW_TZ, NANOCLAW_SKIP. Credentials + channel install + /manage-channels stay interactive — verify reports what's left and exits 0 rather than failing the driver. Also have the container step try to start Docker when it's installed but not running (open -a Docker on macOS, sudo systemctl start docker on Linux) and poll `docker info` for up to 60s before giving up. Both /setup and setup:auto pick this up automatically. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 1 + setup/auto.ts | 164 +++++++++++++++++++++++++++++++++++++++++++++ setup/container.ts | 72 ++++++++++++++++---- 3 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 setup/auto.ts diff --git a/package.json b/package.json index e2af027..a7f8804 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "format:check": "prettier --check \"src/**/*.ts\"", "prepare": "husky", "setup": "tsx setup/index.ts", + "setup:auto": "tsx setup/auto.ts", "chat": "tsx scripts/chat.ts", "auth": "tsx src/whatsapp-auth.ts", "lint": "eslint src/", diff --git a/setup/auto.ts b/setup/auto.ts new file mode 100644 index 0000000..0cbac93 --- /dev/null +++ b/setup/auto.ts @@ -0,0 +1,164 @@ +/** + * 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_TZ IANA zone override (skip autodetect) + * NANOCLAW_SKIP comma-separated step names to skip + * (environment|timezone|container|mounts|service|verify) + * + * Credential setup (OneCLI + channel auth + `/manage-channels`) is *not* + * scripted — those require interactive platform flows and are handled by + * `/setup`, `/add-`, and `/manage-channels` afterwards. + */ +import { spawn } from 'child_process'; + +type Fields = Record; +type StepResult = { ok: boolean; fields: Fields; exitCode: number }; + +function parseStatus(stdout: string): Fields { + const out: Fields = {}; + let inBlock = false; + for (const line of stdout.split('\n')) { + if (line.startsWith('=== NANOCLAW SETUP:')) { + inBlock = true; + continue; + } + if (line.startsWith('=== END ===')) { + inBlock = false; + continue; + } + if (!inBlock) continue; + const idx = line.indexOf(':'); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) out[key] = value; + } + return out; +} + +function runStep(name: string, extra: string[] = []): Promise { + return new Promise((resolve) => { + console.log(`\n── ${name} ────────────────────────────────────`); + const args = ['exec', 'tsx', 'setup/index.ts', '--step', name]; + if (extra.length > 0) args.push('--', ...extra); + + const child = spawn('pnpm', args, { stdio: ['inherit', 'pipe', 'inherit'] }); + let buf = ''; + child.stdout.on('data', (chunk: Buffer) => { + const s = chunk.toString('utf-8'); + buf += s; + process.stdout.write(s); + }); + child.on('close', (code) => { + const fields = parseStatus(buf); + resolve({ + ok: code === 0 && fields.STATUS === 'success', + fields, + exitCode: 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 { + const skip = new Set( + (process.env.NANOCLAW_SKIP ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ); + const tz = process.env.NANOCLAW_TZ; + + if (!skip.has('environment')) { + const env = await runStep('environment'); + if (!env.ok) fail('environment check failed'); + } + + if (!skip.has('timezone')) { + const res = await runStep('timezone', tz ? ['--tz', tz] : []); + if (res.fields.NEEDS_USER_INPUT === 'true') { + fail( + 'Timezone could not be autodetected.', + 'Set NANOCLAW_TZ to an IANA zone (e.g. NANOCLAW_TZ=America/New_York).', + ); + } + if (!res.ok) fail('timezone step failed'); + } + + if (!skip.has('container')) { + const res = await runStep('container'); + if (!res.ok) { + if (res.fields.ERROR === 'runtime_not_available') { + fail( + 'Docker is not available and could not be started automatically.', + 'Install Docker Desktop or start it manually, then retry.', + ); + } + fail( + 'container build/test failed', + 'For stale build cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', + ); + } + } + + if (!skip.has('mounts')) { + const res = await runStep('mounts', ['--empty']); + if (!res.ok && res.fields.STATUS !== 'skipped') { + fail('mount allowlist step failed'); + } + } + + if (!skip.has('service')) { + const res = await runStep('service'); + if (!res.ok) { + fail( + 'service install failed', + 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', + ); + } + if (res.fields.DOCKER_GROUP_STALE === 'true') { + console.warn( + '\n[setup:auto] Docker group stale in systemd session. Run:\n' + + ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + + ' systemctl --user restart nanoclaw', + ); + } + } + + if (!skip.has('verify')) { + const res = await runStep('verify'); + if (!res.ok) { + console.log('\n[setup:auto] Scripted steps done. Remaining (interactive):'); + if (res.fields.CREDENTIALS !== 'configured') { + console.log(' • OneCLI + Anthropic secret — see `/setup` §4 or https://onecli.sh'); + } + if (!res.fields.CONFIGURED_CHANNELS) { + console.log(' • Install a channel: `/add-discord`, `/add-slack`, `/add-telegram`, …'); + } + if (res.fields.REGISTERED_GROUPS === '0') { + console.log(' • Wire the channel to an agent group: `/manage-channels`'); + } + return; + } + } + + console.log('\n[setup:auto] Complete.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/setup/container.ts b/setup/container.ts index 3e48ecf..aadd04c 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -4,11 +4,54 @@ */ import { execSync } from 'child_process'; import path from 'path'; +import { setTimeout as sleep } from 'timers/promises'; import { log } from '../src/log.js'; -import { commandExists } from './platform.js'; +import { commandExists, getPlatform } from './platform.js'; import { emitStatus } from './status.js'; +function dockerRunning(): boolean { + try { + execSync('docker info', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** + * Try to start Docker if it's installed but idle. Poll for up to 60s. + * Returns true once `docker info` succeeds, false if we gave up. + */ +async function tryStartDocker(): Promise { + const platform = getPlatform(); + log.info('Docker not running — attempting to start', { platform }); + + try { + if (platform === 'macos') { + execSync('open -a Docker', { stdio: 'ignore' }); + } else if (platform === 'linux') { + // Inherit stdio so sudo can prompt for a password if needed. + execSync('sudo systemctl start docker', { stdio: 'inherit' }); + } else { + return false; + } + } catch (err) { + log.warn('Start command failed', { err }); + return false; + } + + for (let i = 0; i < 30; i++) { + await sleep(2000); + if (dockerRunning()) { + log.info('Docker is up'); + return true; + } + } + log.warn('Docker did not become ready within 60s'); + return false; +} + function parseArgs(args: string[]): { runtime: string } { // `--runtime` is still accepted for backwards compatibility with the /setup // skill, but `docker` is the only supported value. @@ -54,19 +97,20 @@ export async function run(args: string[]): Promise { process.exit(2); } - try { - execSync('docker info', { stdio: 'ignore' }); - } catch { - emitStatus('SETUP_CONTAINER', { - RUNTIME: runtime, - IMAGE: image, - BUILD_OK: false, - TEST_OK: false, - STATUS: 'failed', - ERROR: 'runtime_not_available', - LOG: 'logs/setup.log', - }); - process.exit(2); + if (!dockerRunning()) { + const started = await tryStartDocker(); + if (!started) { + emitStatus('SETUP_CONTAINER', { + RUNTIME: runtime, + IMAGE: image, + BUILD_OK: false, + TEST_OK: false, + STATUS: 'failed', + ERROR: 'runtime_not_available', + LOG: 'logs/setup.log', + }); + process.exit(2); + } } const buildCmd = 'docker build'; From 3ce4101cd9ce9770f4f11fce792c4e15aeee564e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 17:13:39 +0300 Subject: [PATCH 02/24] feat(setup): chain OneCLI install in setup:auto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install half of the OneCLI step is fully scriptable (the gateway and CLI install themselves via `curl | sh`, PATH + api-host + .env updates are idempotent). Register the Anthropic secret is still interactive — the auto driver leaves that for `/setup` §4 to handle. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 0cbac93..84db937 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -9,11 +9,12 @@ * Config via env: * NANOCLAW_TZ IANA zone override (skip autodetect) * NANOCLAW_SKIP comma-separated step names to skip - * (environment|timezone|container|mounts|service|verify) + * (environment|timezone|container|onecli|mounts|service|verify) * - * Credential setup (OneCLI + channel auth + `/manage-channels`) is *not* - * scripted — those require interactive platform flows and are handled by - * `/setup`, `/add-`, and `/manage-channels` afterwards. + * OneCLI is installed and configured here, but secret registration (the + * Anthropic token or API key), channel auth, and `/manage-channels` stay + * interactive — they need human input. Finish those with `/setup` §4 + * onwards, `/add-`, and `/manage-channels`. */ import { spawn } from 'child_process'; @@ -114,6 +115,22 @@ async function main(): Promise { } } + if (!skip.has('onecli')) { + const res = await runStep('onecli'); + if (!res.ok) { + if (res.fields.ERROR === '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 (${res.fields.ERROR ?? 'unknown'})`, + 'Check that curl + a writable ~/.local/bin are available; re-run `pnpm run setup:auto`.', + ); + } + } + if (!skip.has('mounts')) { const res = await runStep('mounts', ['--empty']); if (!res.ok && res.fields.STATUS !== 'skipped') { @@ -143,7 +160,7 @@ async function main(): Promise { if (!res.ok) { console.log('\n[setup:auto] Scripted steps done. Remaining (interactive):'); if (res.fields.CREDENTIALS !== 'configured') { - console.log(' • OneCLI + Anthropic secret — see `/setup` §4 or https://onecli.sh'); + console.log(' • Register an Anthropic secret in OneCLI — see `/setup` §4'); } if (!res.fields.CONFIGURED_CHANNELS) { console.log(' • Install a channel: `/add-discord`, `/add-slack`, `/add-telegram`, …'); From ee5995ae16a9fa5a7a350c53f07243710bd1d6fe Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 17:38:43 +0300 Subject: [PATCH 03/24] feat(setup): add register-claude-token.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bash helper that registers an Anthropic credential in OneCLI via three paths: Claude subscription (runs `claude setup-token` under script(1) for PTY capture), paste an existing sk-ant-oat… OAuth token, or paste an sk-ant-api… API key. On bash 4+ the `claude setup-token` command is pre-filled in the readline buffer so Enter submits it. On bash 3.2 (macOS default /bin/bash) we fall back to a plain confirmation prompt. Token extraction strips ANSI + TTY-wrap line breaks and anchors on sk-ant-oat…AA with a length cap (via perl; BSD grep caps {n,m} at 255). Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/register-claude-token.sh | 127 +++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100755 setup/register-claude-token.sh diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh new file mode 100755 index 0000000..2c0860d --- /dev/null +++ b/setup/register-claude-token.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prefer bash 4+ (for `read -e -i` readline preload). macOS ships 3.2 in +# /bin/bash, but Homebrew users usually have 5.x first on PATH. The readline +# preload is optional — on 3.x we fall back to a plain confirmation prompt. + +# Register an Anthropic credential with OneCLI. Three paths: +# 1) Claude subscription — run `claude setup-token` (browser sign-in) +# and capture the resulting OAuth token. +# 2) Paste an existing sk-ant-oat… OAuth token you already have. +# 3) Paste an Anthropic API key (sk-ant-api…). +# +# Env overrides: +# SECRET_NAME OneCLI secret name (default: Anthropic) +# HOST_PATTERN OneCLI host pattern (default: api.anthropic.com) + +SECRET_NAME="${SECRET_NAME:-Anthropic}" +HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}" + +command -v onecli >/dev/null \ + || { echo "onecli not found. Install it first (see /setup §4)." >&2; exit 1; } + +TOKEN="" + +capture_via_claude_setup_token() { + command -v claude >/dev/null \ + || { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; } + command -v script >/dev/null \ + || { echo "script(1) is required for PTY capture." >&2; exit 1; } + + local tmpfile + tmpfile=$(mktemp -t claude-setup-token.XXXXXX) + trap 'rm -f "$tmpfile"' RETURN + + cat <<'EOF' +A browser window will open for sign-in. Token is captured automatically. +Press Enter to run, or edit the command first. + +EOF + + local cmd="claude setup-token" + if [[ ${BASH_VERSINFO[0]:-0} -ge 4 ]]; then + # bash 4+: pre-fill the readline buffer so Enter literally submits. + read -r -e -i "$cmd" -p "$ " cmd /dev/null | grep -q util-linux; then + script -q -c "$cmd" "$tmpfile" + else + # BSD script: command is argv after the file, so let it word-split. + # shellcheck disable=SC2086 + script -q "$tmpfile" $cmd + fi + + # Strip ANSI codes + newlines (TTY wraps the token mid-string), then match + # the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255. + TOKEN=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \ + | tr -d '\n\r' \ + | perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \ + | tail -1 || true) + + if [[ -z "$TOKEN" ]]; then + local keep + keep=$(mktemp -t claude-setup-token-log.XXXXXX) + cp "$tmpfile" "$keep" + echo >&2 + echo "No sk-ant-oat…AA token found. Raw log: $keep" >&2 + exit 1 + fi +} + +prompt_for_pasted() { + local prefix="$1" # "oat" or "api" + local value + echo + echo "Paste your sk-ant-${prefix}… credential and press Enter." + echo "(Input is hidden for safety.)" + read -r -s -p "> " value &2 + exit 1 + fi + if [[ ! "$value" =~ ^sk-ant-${prefix} ]]; then + echo "Value does not start with sk-ant-${prefix}. Aborting." >&2 + exit 1 + fi + TOKEN="$value" +} + +cat <&2; exit 1 ;; +esac + +echo +echo "Got token: ${TOKEN:0:16}…${TOKEN: -4}" +echo "Registering with OneCLI as '$SECRET_NAME' (host pattern: $HOST_PATTERN)…" + +onecli secrets create \ + --name "$SECRET_NAME" \ + --type anthropic \ + --value "$TOKEN" \ + --host-pattern "$HOST_PATTERN" + +echo "Done." From b0cae1ba4cef1fa4a21beae5907f36334184adfc Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 17:41:29 +0300 Subject: [PATCH 04/24] feat(setup): chain register-claude-token.sh into setup:auto Runs after the OneCLI install step and before mounts/service. Skips silently when `onecli secrets list` already reports an Anthropic secret, so re-running setup:auto on a configured install is a no-op. Child process uses stdio:inherit so the menu + browser sign-in flow work normally. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 84db937..66d6880 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -9,14 +9,15 @@ * Config via env: * NANOCLAW_TZ IANA zone override (skip autodetect) * NANOCLAW_SKIP comma-separated step names to skip - * (environment|timezone|container|onecli|mounts|service|verify) + * (environment|timezone|container|onecli|auth|mounts|service|verify) * - * OneCLI is installed and configured here, but secret registration (the - * Anthropic token or API key), channel auth, and `/manage-channels` stay - * interactive — they need human input. Finish those with `/setup` §4 - * onwards, `/add-`, and `/manage-channels`. + * Anthropic credential registration runs via setup/register-claude-token.sh + * (the only step that truly requires human input — browser sign-in or a + * pasted token/key). Channel auth and `/manage-channels` remain separate + * because they're platform-specific and typically handled via `/add-` + * and `/manage-channels` after this driver completes. */ -import { spawn } from 'child_process'; +import { spawn, spawnSync } from 'child_process'; type Fields = Record; type StepResult = { ok: boolean; fields: Fields; exitCode: number }; @@ -67,6 +68,26 @@ function runStep(name: string, extra: string[] = []): Promise { }); } +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 runBashScript(relPath: string): Promise { + return new Promise((resolve) => { + const child = spawn('bash', [relPath], { 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}`); @@ -131,6 +152,24 @@ async function main(): Promise { } } + if (!skip.has('auth')) { + if (anthropicSecretExists()) { + console.log( + '\n── auth ────────────────────────────────────\n' + + '[setup:auto] OneCLI already has an Anthropic secret — skipping.', + ); + } else { + console.log('\n── auth ────────────────────────────────────'); + const code = await runBashScript('setup/register-claude-token.sh'); + if (code !== 0) { + fail( + 'Anthropic credential registration failed or was aborted.', + 'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', + ); + } + } + } + if (!skip.has('mounts')) { const res = await runStep('mounts', ['--empty']); if (!res.ok && res.fields.STATUS !== 'skipped') { @@ -160,7 +199,7 @@ async function main(): Promise { if (!res.ok) { console.log('\n[setup:auto] Scripted steps done. Remaining (interactive):'); if (res.fields.CREDENTIALS !== 'configured') { - console.log(' • Register an Anthropic secret in OneCLI — see `/setup` §4'); + console.log(' • Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`'); } if (!res.fields.CONFIGURED_CHANNELS) { console.log(' • Install a channel: `/add-discord`, `/add-slack`, `/add-telegram`, …'); From 264849da6c05305d758a338bdd18dd6677f851d2 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 17:45:04 +0300 Subject: [PATCH 05/24] feat(setup): add nanoclaw.sh entry point Single command end-to-end: `bash nanoclaw.sh` runs setup.sh for bootstrap and hands off to `pnpm run setup:auto` on success. Passes through NANOCLAW_TZ, NANOCLAW_SKIP, SECRET_NAME, HOST_PATTERN via env. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100755 nanoclaw.sh diff --git a/nanoclaw.sh b/nanoclaw.sh new file mode 100755 index 0000000..6a23558 --- /dev/null +++ b/nanoclaw.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# NanoClaw — scripted end-to-end install. +# +# Runs `bash setup.sh` (bootstrap: Node check, pnpm install, native module +# verify), then `pnpm run setup:auto` (environment → timezone → container → +# onecli → auth → mounts → service → verify). +# +# Everything that can be scripted runs unattended; the one interactive pause +# is the auth step (browser sign-in or paste token/API key). +# +# Config via env — passed through unchanged: +# NANOCLAW_TZ IANA zone override +# NANOCLAW_SKIP comma-separated setup:auto step names to skip +# SECRET_NAME OneCLI secret name (default: Anthropic) +# HOST_PATTERN OneCLI host pattern (default: api.anthropic.com) + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_ROOT" + +cat <<'EOF' +═══════════════════════════════════════════════════════════════ + NanoClaw scripted setup +═══════════════════════════════════════════════════════════════ + +Phase 1: bootstrap (Node + pnpm + native modules) + +EOF + +if ! bash setup.sh; then + echo + echo "[nanoclaw.sh] Bootstrap failed. Inspect logs/setup.log and retry." >&2 + exit 1 +fi + +cat <<'EOF' + +═══════════════════════════════════════════════════════════════ + Phase 2: setup:auto +═══════════════════════════════════════════════════════════════ + +EOF + +# exec so signals (Ctrl-C) propagate directly to the child. +exec pnpm run setup:auto From fd2e404ba95aec6a478ce55af9bdd7b8280e4402 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 21 Apr 2026 15:05:52 +0000 Subject: [PATCH 06/24] fix(setup): auto-install Node and bypass corepack prompt Node check now triggers setup/install-node.sh when missing/too old, and COREPACK_ENABLE_DOWNLOAD_PROMPT=0 prevents the first-use prompt from hanging the script when stdout is redirected to the log. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/setup.sh b/setup.sh index af2c5e5..e163df8 100755 --- a/setup.sh +++ b/setup.sh @@ -72,6 +72,11 @@ install_deps() { cd "$PROJECT_ROOT" + # Corepack's first-use "Do you want to continue? [Y/n]" prompt would hang + # the script since we redirect stdout/stderr to the log file — the prompt + # is invisible but corepack still blocks on stdin. Auto-accept. + export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + # Enable corepack so `pnpm` shim lands on PATH. log "Enabling corepack" corepack enable >> "$LOG_FILE" 2>&1 || true @@ -131,6 +136,16 @@ log "=== Bootstrap started ===" detect_platform check_node +if [ "$NODE_OK" = "false" ]; then + log "Node missing or too old — running setup/install-node.sh" + echo "Node not found — installing via setup/install-node.sh" + if bash "$PROJECT_ROOT/setup/install-node.sh" 2>&1 | tee -a "$LOG_FILE"; then + hash -r 2>/dev/null || true + check_node + else + log "install-node.sh failed" + fi +fi install_deps check_build_tools From e86d0d93dd50c474015e6aa73fc16e8c08fe7e18 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 18:45:19 +0300 Subject: [PATCH 07/24] feat(setup): wire CLI agent in setup:auto Chains `cli-agent` (wraps scripts/init-cli-agent.ts) between service and verify. Without this wiring, the socket at data/cli.sock accepts the connection but there's no agent group routed to `cli/local`, so `pnpm run chat` hangs waiting for a reply. Defaults: display name from NANOCLAW_DISPLAY_NAME env, falling back to \$USER then "Operator". Agent persona name from NANOCLAW_AGENT_NAME, defaulting to the display name. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 66d6880..bbe6326 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -7,9 +7,12 @@ * module check). This driver picks up from there. * * Config via env: - * NANOCLAW_TZ IANA zone override (skip autodetect) - * NANOCLAW_SKIP comma-separated step names to skip - * (environment|timezone|container|onecli|auth|mounts|service|verify) + * NANOCLAW_TZ IANA zone override (skip autodetect) + * NANOCLAW_DISPLAY_NAME operator name for the CLI agent (default: $USER) + * NANOCLAW_AGENT_NAME agent persona name (default: display name) + * NANOCLAW_SKIP comma-separated step names to skip + * (environment|timezone|container|onecli|auth| + * mounts|service|cli-agent|verify) * * Anthropic credential registration runs via setup/register-claude-token.sh * (the only step that truly requires human input — browser sign-in or a @@ -194,6 +197,24 @@ async function main(): Promise { } } + if (!skip.has('cli-agent')) { + const displayName = + process.env.NANOCLAW_DISPLAY_NAME?.trim() || + process.env.USER?.trim() || + 'Operator'; + const agentName = process.env.NANOCLAW_AGENT_NAME?.trim(); + const args = ['--display-name', displayName]; + if (agentName) args.push('--agent-name', agentName); + + const res = await runStep('cli-agent', args); + if (!res.ok) { + fail( + 'CLI agent wiring failed', + 'Re-run `pnpm exec tsx scripts/init-cli-agent.ts --display-name ""` to fix.', + ); + } + } + if (!skip.has('verify')) { const res = await runStep('verify'); if (!res.ok) { @@ -202,10 +223,10 @@ async function main(): Promise { console.log(' • Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`'); } if (!res.fields.CONFIGURED_CHANNELS) { - console.log(' • Install a channel: `/add-discord`, `/add-slack`, `/add-telegram`, …'); - } - if (res.fields.REGISTERED_GROUPS === '0') { - console.log(' • Wire the channel to an agent group: `/manage-channels`'); + console.log( + ' • Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …', + ); + console.log(' (CLI channel is already wired: `pnpm run chat hi`)'); } return; } From be6cec59adc64b8f0d24e8d8710d33ac3fff6b8e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 21 Apr 2026 15:55:04 +0000 Subject: [PATCH 08/24] fix(setup): auto-recover from stale docker group mid-session - container: install Docker via setup/install-docker.sh when missing, distinguish socket EACCES from daemon-down so we bail fast instead of polling 60s, and re-exec the step under `sg docker` when usermod hasn't reached the current shell. - auto: after the container step, re-exec the whole driver under `sg docker` (with a NANOCLAW_REEXEC_SG guard) so onecli/service/verify also get docker-group access without a re-login. Surface the new docker_group_not_active error from the container step. - service: when the systemd user manager has a stale group list, auto- apply \`sudo setfacl -m u:\$USER:rw /var/run/docker.sock\` so the service can start without waiting for the next login. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 34 +++++++++++++++++++ setup/container.ts | 81 +++++++++++++++++++++++++++++++++++----------- setup/service.ts | 27 ++++++++++++++-- 3 files changed, 121 insertions(+), 21 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index bbe6326..8ef87d8 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -71,6 +71,33 @@ function runStep(name: string, extra: string[] = []): Promise { }); } +/** + * 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; // already re-exec'd + 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; + + console.log( + '\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'], { + stdio: 'inherit', + env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, + }); + process.exit(res.status ?? 1); +} + function anthropicSecretExists(): boolean { try { const res = spawnSync('onecli', ['secrets', 'list'], { @@ -132,11 +159,18 @@ async function main(): Promise { 'Install Docker Desktop or start it manually, then retry.', ); } + if (res.fields.ERROR === '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 `pnpm run setup:auto`.', + ); + } fail( 'container build/test failed', 'For stale build cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', ); } + maybeReexecUnderSg(); } if (!skip.has('onecli')) { diff --git a/setup/container.ts b/setup/container.ts index aadd04c..a2e6433 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -2,7 +2,7 @@ * Step: container — Build container image and verify with test run. * Replaces 03-setup-container.sh */ -import { execSync } from 'child_process'; +import { execSync, spawnSync } from 'child_process'; import path from 'path'; import { setTimeout as sleep } from 'timers/promises'; @@ -10,20 +10,28 @@ import { log } from '../src/log.js'; import { commandExists, getPlatform } from './platform.js'; import { emitStatus } from './status.js'; +type DockerStatus = 'ok' | 'no-permission' | 'no-daemon' | 'other'; + +function dockerStatus(): DockerStatus { + const res = spawnSync('docker', ['info'], { encoding: 'utf-8' }); + if (res.status === 0) return 'ok'; + const err = `${res.stderr ?? ''}\n${res.stdout ?? ''}`; + if (/permission denied/i.test(err)) return 'no-permission'; + if (/cannot connect|is the docker daemon running|no such file/i.test(err)) return 'no-daemon'; + return 'other'; +} + function dockerRunning(): boolean { - try { - execSync('docker info', { stdio: 'ignore' }); - return true; - } catch { - return false; - } + return dockerStatus() === 'ok'; } /** - * Try to start Docker if it's installed but idle. Poll for up to 60s. - * Returns true once `docker info` succeeds, false if we gave up. + * Try to start Docker if it's installed but idle. Poll up to 60s for the + * daemon to come up — but bail immediately if the socket is reachable and + * only blocked by a group-permission error, since that won't resolve by + * waiting (the caller handles the sg re-exec for that case). */ -async function tryStartDocker(): Promise { +async function tryStartDocker(): Promise { const platform = getPlatform(); log.info('Docker not running — attempting to start', { platform }); @@ -34,22 +42,27 @@ async function tryStartDocker(): Promise { // Inherit stdio so sudo can prompt for a password if needed. execSync('sudo systemctl start docker', { stdio: 'inherit' }); } else { - return false; + return 'other'; } } catch (err) { log.warn('Start command failed', { err }); - return false; + return 'other'; } for (let i = 0; i < 30; i++) { await sleep(2000); - if (dockerRunning()) { + const s = dockerStatus(); + if (s === 'ok') { log.info('Docker is up'); - return true; + return 'ok'; + } + if (s === 'no-permission') { + log.info('Docker daemon is up but socket is not accessible (group membership)'); + return 'no-permission'; } } log.warn('Docker did not become ready within 60s'); - return false; + return 'no-daemon'; } function parseArgs(args: string[]): { runtime: string } { @@ -84,6 +97,15 @@ export async function run(args: string[]): Promise { process.exit(4); } + if (!commandExists('docker')) { + log.info('Docker not found — running setup/install-docker.sh'); + try { + execSync('bash setup/install-docker.sh', { cwd: projectRoot, stdio: 'inherit' }); + } catch (err) { + log.warn('install-docker.sh failed', { err }); + } + } + if (!commandExists('docker')) { emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, @@ -97,16 +119,37 @@ export async function run(args: string[]): Promise { process.exit(2); } - if (!dockerRunning()) { - const started = await tryStartDocker(); - if (!started) { + { + let status = dockerStatus(); + if (status !== 'ok') { + status = await tryStartDocker(); + } + + // Socket is unreachable due to group perms — current shell's supplementary + // groups are fixed at login, so `usermod -aG docker` (via install-docker.sh + // or a prior install) doesn't affect us until next login. Re-exec this + // step under `sg docker` so the child picks up docker as its primary + // group and can talk to /var/run/docker.sock without a logout. + if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) { + log.info('Re-executing container step under `sg docker`'); + const res = spawnSync( + 'sg', + ['docker', '-c', 'pnpm exec tsx setup/index.ts --step container'], + { cwd: projectRoot, stdio: 'inherit' }, + ); + process.exit(res.status ?? 1); + } + + if (status !== 'ok') { + const error = + status === 'no-permission' ? 'docker_group_not_active' : 'runtime_not_available'; emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false, STATUS: 'failed', - ERROR: 'runtime_not_available', + ERROR: error, LOG: 'logs/setup.log', }); process.exit(2); diff --git a/setup/service.ts b/setup/service.ts index bc85d16..56bf393 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -11,6 +11,7 @@ import path from 'path'; import { log } from '../src/log.js'; import { + commandExists, getPlatform, getNodePath, getServiceManager, @@ -255,12 +256,34 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; fs.writeFileSync(unitPath, unit); log.info('Wrote systemd unit', { unitPath }); - // Detect stale docker group before starting (user systemd only) - const dockerGroupStale = !runningAsRoot && checkDockerGroupStale(); + // Detect stale docker group before starting (user systemd only). The user + // systemd manager is a long-running process whose group list is frozen at + // login, so `usermod -aG docker` mid-session doesn't reach it. Rather than + // require the user to log out + back in, punch a POSIX ACL onto the socket + // that grants the current user rw directly. This is temporary — the socket + // is recreated by dockerd on restart (and by then the user has relogged, so + // normal group perms apply again). + let dockerGroupStale = !runningAsRoot && checkDockerGroupStale(); if (dockerGroupStale) { log.warn( 'Docker group not active in systemd session — user was likely added to docker group mid-session', ); + if (commandExists('setfacl')) { + const user = execSync('whoami', { encoding: 'utf-8' }).trim(); + try { + execSync(`sudo setfacl -m u:${user}:rw /var/run/docker.sock`, { + stdio: 'inherit', + }); + log.info( + 'Applied temporary ACL to /var/run/docker.sock (resets on docker restart or reboot)', + ); + dockerGroupStale = false; + } catch (err) { + log.warn('Failed to apply setfacl workaround', { err }); + } + } else { + log.warn('setfacl not installed — cannot apply automatic workaround'); + } } // Kill orphaned nanoclaw processes to avoid channel connection conflicts From 1c748f1f2b16396ffd5fa60831f1d787dd5da228 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 22:18:08 +0300 Subject: [PATCH 09/24] refactor(setup): drop timezone step from setup:auto chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timezone step blocked the scripted flow on headless servers where the resolved TZ was UTC (interactive /setup confirms, setup:auto had to bail). Drop it from the chain — host TZ defaults to whatever the OS reports. Users who need an explicit override run the step on demand: `pnpm exec tsx setup/index.ts --step timezone -- --tz `. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 5 ++--- setup/auto.ts | 19 +++++-------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 6a23558..2a98f98 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -3,14 +3,13 @@ # NanoClaw — scripted end-to-end install. # # Runs `bash setup.sh` (bootstrap: Node check, pnpm install, native module -# verify), then `pnpm run setup:auto` (environment → timezone → container → -# onecli → auth → mounts → service → verify). +# verify), then `pnpm run setup:auto` (environment → container → onecli → +# auth → mounts → service → cli-agent → verify). # # Everything that can be scripted runs unattended; the one interactive pause # is the auth step (browser sign-in or paste token/API key). # # Config via env — passed through unchanged: -# NANOCLAW_TZ IANA zone override # NANOCLAW_SKIP comma-separated setup:auto step names to skip # SECRET_NAME OneCLI secret name (default: Anthropic) # HOST_PATTERN OneCLI host pattern (default: api.anthropic.com) diff --git a/setup/auto.ts b/setup/auto.ts index 8ef87d8..3945d82 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -7,13 +7,16 @@ * module check). This driver picks up from there. * * Config via env: - * NANOCLAW_TZ IANA zone override (skip autodetect) * NANOCLAW_DISPLAY_NAME operator name for the CLI agent (default: $USER) * NANOCLAW_AGENT_NAME agent persona name (default: display name) * NANOCLAW_SKIP comma-separated step names to skip - * (environment|timezone|container|onecli|auth| + * (environment|container|onecli|auth| * mounts|service|cli-agent|verify) * + * Timezone is not configured here — it defaults to the host system's TZ. + * Run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` later + * if autodetect is wrong (e.g. headless server with TZ=UTC). + * * Anthropic credential registration runs via setup/register-claude-token.sh * (the only step that truly requires human input — browser sign-in or a * pasted token/key). Channel auth and `/manage-channels` remain separate @@ -132,24 +135,12 @@ async function main(): Promise { .map((s) => s.trim()) .filter(Boolean), ); - const tz = process.env.NANOCLAW_TZ; if (!skip.has('environment')) { const env = await runStep('environment'); if (!env.ok) fail('environment check failed'); } - if (!skip.has('timezone')) { - const res = await runStep('timezone', tz ? ['--tz', tz] : []); - if (res.fields.NEEDS_USER_INPUT === 'true') { - fail( - 'Timezone could not be autodetected.', - 'Set NANOCLAW_TZ to an IANA zone (e.g. NANOCLAW_TZ=America/New_York).', - ); - } - if (!res.ok) fail('timezone step failed'); - } - if (!skip.has('container')) { const res = await runStep('container'); if (!res.ok) { From 81838bbb345555e2f3896916773ecc4d2c02ec54 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 23:43:43 +0300 Subject: [PATCH 10/24] fix(setup): clarify silent-paste prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicitly tell the user that nothing appears on screen as they paste and that a single Enter submits. "(Input is hidden for safety.)" was ambiguous — users kept waiting for a visible confirmation. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/register-claude-token.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh index 2c0860d..9c042d9 100755 --- a/setup/register-claude-token.sh +++ b/setup/register-claude-token.sh @@ -80,7 +80,8 @@ prompt_for_pasted() { local value echo echo "Paste your sk-ant-${prefix}… credential and press Enter." - echo "(Input is hidden for safety.)" + echo "Nothing will appear on the screen as you paste — that's intentional." + echo "Paste once, then just press Enter to submit." read -r -s -p "> " value Date: Tue, 21 Apr 2026 23:46:49 +0300 Subject: [PATCH 11/24] feat(setup): prompt for display name, hardcode agent persona MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the cli-agent step, ask the operator what the agent should call them (defaults to \$USER). The agent's own persona name is hardcoded to "Terminal Agent" — this is the scratch CLI agent, not one of the operator's real personas. NANOCLAW_DISPLAY_NAME still skips the prompt. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 3945d82..9a37a45 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -7,8 +7,9 @@ * module check). This driver picks up from there. * * Config via env: - * NANOCLAW_DISPLAY_NAME operator name for the CLI agent (default: $USER) - * NANOCLAW_AGENT_NAME agent persona name (default: display name) + * NANOCLAW_DISPLAY_NAME operator name for the CLI agent — skips the + * interactive prompt before cli-agent. If unset, + * the driver asks, defaulting to $USER. * NANOCLAW_SKIP comma-separated step names to skip * (environment|container|onecli|auth| * mounts|service|cli-agent|verify) @@ -24,6 +25,9 @@ * and `/manage-channels` after this driver completes. */ import { spawn, spawnSync } from 'child_process'; +import { createInterface } from 'readline/promises'; + +const CLI_AGENT_NAME = 'Terminal Agent'; type Fields = Record; type StepResult = { ok: boolean; fields: Fields; exitCode: number }; @@ -114,6 +118,18 @@ function anthropicSecretExists(): boolean { } } +async function askDisplayName(fallback: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question( + `\nWhat should the agent call you? [${fallback}]: `, + ); + return answer.trim() || fallback; + } finally { + rl.close(); + } +} + function runBashScript(relPath: string): Promise { return new Promise((resolve) => { const child = spawn('bash', [relPath], { stdio: 'inherit' }); @@ -223,19 +239,20 @@ async function main(): Promise { } if (!skip.has('cli-agent')) { - const displayName = - process.env.NANOCLAW_DISPLAY_NAME?.trim() || - process.env.USER?.trim() || - 'Operator'; - const agentName = process.env.NANOCLAW_AGENT_NAME?.trim(); - const args = ['--display-name', displayName]; - if (agentName) args.push('--agent-name', agentName); + const fallback = process.env.USER?.trim() || 'Operator'; + const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim(); + const displayName = preset || (await askDisplayName(fallback)); - const res = await runStep('cli-agent', args); + const res = await runStep('cli-agent', [ + '--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 ""` to fix.', + `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, ); } } From 85faa3eab08171c815673cbbe72f177264abf519 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 23:49:28 +0300 Subject: [PATCH 12/24] fix(setup): rephrase display-name prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Your agents" — the name is stored on the operator's user row and applies to every future agent they wire up, not just this scratch CLI one. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup/auto.ts b/setup/auto.ts index 9a37a45..dbe8733 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -122,7 +122,7 @@ async function askDisplayName(fallback: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); try { const answer = await rl.question( - `\nWhat should the agent call you? [${fallback}]: `, + `\nWhat should your agents call you? [${fallback}]: `, ); return answer.trim() || fallback; } finally { From c87cd250b2989072e5fd12770cc65325b2d50797 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 21 Apr 2026 23:52:51 +0300 Subject: [PATCH 13/24] feat(verify): end-to-end agent ping via CLI channel Verify now runs \`pnpm run chat ping\` silently and checks for a reply. Emits AGENT_PING=ok|no_reply|socket_error|skipped; skipped when the service isn't running or no groups are wired (those already fail the verify via other checks). Kills the child after 90s so a wedged container can't hang setup (chat.ts's own 120s timeout is too long here). setup:auto surfaces AGENT_PING!=ok in its failure summary. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 6 +++++ setup/verify.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index dbe8733..d3b8113 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -264,6 +264,12 @@ async function main(): Promise { if (res.fields.CREDENTIALS !== 'configured') { console.log(' • 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') { + console.log( + ` • CLI agent did not reply (status: ${res.fields.AGENT_PING}). ` + + 'Check `logs/nanoclaw.log` and `groups/*/logs/container-*.log`, then try `pnpm run chat hi`.', + ); + } if (!res.fields.CONFIGURED_CHANNELS) { console.log( ' • Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …', diff --git a/setup/verify.ts b/setup/verify.ts index 6dd6a44..4be9c3f 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -4,7 +4,7 @@ * * Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks. */ -import { execSync } from 'child_process'; +import { execSync, spawn } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -175,12 +175,22 @@ export async function run(_args: string[]): Promise { mountAllowlist = 'configured'; } + // 7. End-to-end: ping the CLI agent and confirm it replies. Only run if + // everything upstream looks healthy, since a broken socket would just hang. + let agentPing: 'ok' | 'no_reply' | 'socket_error' | 'skipped' = 'skipped'; + if (service === 'running' && registeredGroups > 0) { + log.info('Pinging CLI agent'); + agentPing = await pingCliAgent(); + log.info('Agent ping result', { agentPing }); + } + // Determine overall status const status = service === 'running' && credentials !== 'missing' && anyChannelConfigured && - registeredGroups > 0 + registeredGroups > 0 && + (agentPing === 'ok' || agentPing === 'skipped') ? 'success' : 'failed'; @@ -194,9 +204,55 @@ export async function run(_args: string[]): Promise { CHANNEL_AUTH: JSON.stringify(channelAuth), REGISTERED_GROUPS: registeredGroups, MOUNT_ALLOWLIST: mountAllowlist, + AGENT_PING: agentPing, STATUS: status, LOG: 'logs/setup.log', }); if (status === 'failed') process.exit(1); } + +/** + * Send a one-word message through the CLI channel and check for a reply. + * Silent by default — stdout/stderr of the child are captured but not + * forwarded. Kills the child after 90s so verify can't hang on a wedged + * agent (chat.ts's own timeout is 120s, which is too long for setup). + */ +function pingCliAgent(): Promise<'ok' | 'no_reply' | 'socket_error'> { + return new Promise((resolve) => { + const child = spawn('pnpm', ['run', 'chat', 'ping'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGKILL'); + resolve('no_reply'); + }, 90_000); + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf-8'); + }); + child.on('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + // chat.ts: exit 0 on reply, 2 on socket error, 3 on no reply. + if (code === 2) { + resolve('socket_error'); + } else if (code === 0 && stdout.trim().length > 0) { + resolve('ok'); + } else { + resolve('no_reply'); + } + }); + child.on('error', () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve('socket_error'); + }); + }); +} From 9c7e1d02af92a0b7ef3b5f6079a1dfb2883edd2e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 00:04:14 +0300 Subject: [PATCH 14/24] feat(setup): optional Telegram wiring in setup:auto After cli-agent, prompt the user to connect a messaging app. For now only Telegram is offered; "skip" falls through to the existing CLI flow. setup/add-telegram.sh runs the scriptable half of /add-telegram: fetch the channels branch, copy the adapter + pair-telegram files, append the self-registration import, install @chat-adapter/telegram@4.26.0 (pinned to match the skill), rebuild, collect TELEGRAM_BOT_TOKEN via silent paste, write .env + data/env/env, and kick the service so the new adapter is live. Idempotent throughout. setup:auto then runs the existing `pair-telegram` step with --intent main. The step emits the 4-digit code in its status stream, which is already forwarded to stdout by runStep. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 2 +- setup/add-telegram.sh | 134 ++++++++++++++++++++++++++++++++++++++++++ setup/auto.ts | 45 +++++++++++++- 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100755 setup/add-telegram.sh diff --git a/nanoclaw.sh b/nanoclaw.sh index 2a98f98..2dc0f04 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -4,7 +4,7 @@ # # Runs `bash setup.sh` (bootstrap: Node check, pnpm install, native module # verify), then `pnpm run setup:auto` (environment → container → onecli → -# auth → mounts → service → cli-agent → verify). +# auth → mounts → service → cli-agent → channel → verify). # # Everything that can be scripted runs unattended; the one interactive pause # is the auth step (browser sign-in or paste token/API key). diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh new file mode 100755 index 0000000..c822994 --- /dev/null +++ b/setup/add-telegram.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Install the Telegram adapter (Phase A of the /add-telegram skill), collect +# the bot token, write .env + data/env/env, and restart the service so the +# new adapter is live. Idempotent. +# +# Pair-telegram (the interactive code-sending step) is run separately by the +# caller (setup/auto.ts) so it can stream status blocks to the user. + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-telegram/SKILL.md. +ADAPTER_VERSION="@chat-adapter/telegram@4.26.0" +CHANNELS_BRANCH="origin/channels" + +need_install() { + [[ ! -f src/channels/telegram.ts ]] && return 0 + [[ ! -f setup/pair-telegram.ts ]] && return 0 + ! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +if need_install; then + echo "[add-telegram] Fetching channels branch…" + git fetch origin channels >/dev/null 2>&1 + + echo "[add-telegram] Copying adapter files from $CHANNELS_BRANCH…" + for f in \ + src/channels/telegram.ts \ + src/channels/telegram-pairing.ts \ + src/channels/telegram-pairing.test.ts \ + src/channels/telegram-markdown-sanitize.ts \ + src/channels/telegram-markdown-sanitize.test.ts \ + setup/pair-telegram.ts + do + git show "$CHANNELS_BRANCH:$f" > "$f" + done + + # Append self-registration import if missing. + if ! grep -q "^import './telegram.js';" src/channels/index.ts; then + echo "import './telegram.js';" >> src/channels/index.ts + fi + + # Register pair-telegram step if not already in the STEPS map. + # Uses node (not sed) since sed's in-place + escape semantics differ + # between BSD (macOS) and GNU. + node -e ' + const fs = require("fs"); + const p = "setup/index.ts"; + let s = fs.readFileSync(p, "utf-8"); + if (!s.includes("\047pair-telegram\047")) { + s = s.replace( + /(register: \(\) => import\(\x27\.\/register\.js\x27\),)/, + "$1\n \x27pair-telegram\x27: () => import(\x27./pair-telegram.js\x27)," + ); + fs.writeFileSync(p, s); + } + ' + + echo "[add-telegram] Installing $ADAPTER_VERSION…" + pnpm install "$ADAPTER_VERSION" + + echo "[add-telegram] Building…" + pnpm run build >/dev/null +else + echo "[add-telegram] Adapter files already installed — skipping install phase." +fi + +# Token collection. +if grep -q '^TELEGRAM_BOT_TOKEN=.' .env 2>/dev/null; then + echo "[add-telegram] TELEGRAM_BOT_TOKEN already set in .env — skipping token prompt." +else + cat <<'EOF' + +── Create a Telegram bot ────────────────────────────────────── + + 1. Open Telegram and message @BotFather + 2. Send: /newbot + 3. Follow the prompts (bot name, username ending in "bot") + 4. Copy the token it gives you (format: :) + +Optional but recommended for groups: + 5. @BotFather → /mybots → your bot → Bot Settings → Group Privacy → OFF + +EOF + echo "Paste your TELEGRAM_BOT_TOKEN and press Enter." + echo "Nothing will appear on the screen as you paste — that's intentional." + echo "Paste once, then just press Enter to submit." + read -r -s -p "> " TOKEN &2 + exit 1 + fi + + # Telegram bot tokens: :<35+ base64url-ish chars>. + if [[ ! "$TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then + echo "[add-telegram] Token format looks wrong (expected :). Aborting." >&2 + exit 1 + fi + + touch .env + if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then + awk -v tok="$TOKEN" '/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \ + .env > .env.tmp && mv .env.tmp .env + else + echo "TELEGRAM_BOT_TOKEN=$TOKEN" >> .env + fi +fi + +# Container reads from data/env/env (the host mounts it). +mkdir -p data/env +cp .env data/env/env + +echo "[add-telegram] Restarting service so the new adapter picks up the token…" +case "$(uname -s)" in + Darwin) + launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >/dev/null 2>&1 || true + ;; + Linux) + systemctl --user restart nanoclaw >/dev/null 2>&1 \ + || sudo systemctl restart nanoclaw >/dev/null 2>&1 \ + || true + ;; +esac + +# Give the Telegram adapter a moment to finish starting before pair-telegram +# begins polling for the user's code message. +sleep 5 + +echo "[add-telegram] Install + credentials complete." diff --git a/setup/auto.ts b/setup/auto.ts index d3b8113..12a3070 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -11,8 +11,8 @@ * interactive prompt before cli-agent. If unset, * the driver asks, defaulting to $USER. * NANOCLAW_SKIP comma-separated step names to skip - * (environment|container|onecli|auth| - * mounts|service|cli-agent|verify) + * (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 ` later @@ -130,6 +130,19 @@ async function askDisplayName(fallback: string): Promise { } } +async function askChannelChoice(): Promise<'telegram' | 'skip'> { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + console.log('\nConnect a messaging app so you can chat from your phone?'); + console.log(' 1) Telegram'); + console.log(' 2) Skip — just use the CLI for now'); + const answer = (await rl.question('Choose [1/2]: ')).trim(); + return answer === '1' ? 'telegram' : 'skip'; + } finally { + rl.close(); + } +} + function runBashScript(relPath: string): Promise { return new Promise((resolve) => { const child = spawn('bash', [relPath], { stdio: 'inherit' }); @@ -257,6 +270,34 @@ async function main(): Promise { } } + if (!skip.has('channel')) { + const choice = await askChannelChoice(); + if (choice === 'telegram') { + const installCode = await runBashScript('setup/add-telegram.sh'); + if (installCode !== 0) { + fail( + 'Telegram install failed.', + 'Re-run `bash setup/add-telegram.sh`, then `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', + ); + } + + console.log( + '\n[setup:auto] Pairing Telegram. A 4-digit code will appear below.\n' + + ' From Telegram, send just those 4 digits to your bot\n' + + ' (DM the bot for a personal chat, or prefix with your\n' + + ' bot handle in a group with privacy on).\n', + ); + + const pair = await runStep('pair-telegram', ['--intent', 'main']); + if (!pair.ok) { + fail( + 'Telegram pairing failed.', + 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', + ); + } + } + } + if (!skip.has('verify')) { const res = await runStep('verify'); if (!res.ok) { From 92c28a956de32c631612c5550a0f1677f07bdd77 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 00:11:35 +0300 Subject: [PATCH 15/24] feat(setup): run init-first-agent after Telegram pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pair-telegram only identifies the chat and operator — it returns PLATFORM_ID and ADMIN_USER_ID but doesn't create the agent group, grant owner, or send the welcome. scripts/init-first-agent.ts does that, matching the pattern the /new-setup skill already uses for channel wiring. Also prompts for the agent's own name (default: Nano), overridable via NANOCLAW_AGENT_NAME. displayName is hoisted out of the cli-agent block so both cli-agent and channel wiring share the value. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 75 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 12a3070..e76d9cf 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -7,9 +7,11 @@ * module check). This driver picks up from there. * * Config via env: - * NANOCLAW_DISPLAY_NAME operator name for the CLI agent — skips the - * interactive prompt before cli-agent. If unset, - * the driver asks, defaulting to $USER. + * 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) @@ -28,6 +30,7 @@ import { spawn, spawnSync } from 'child_process'; import { createInterface } from 'readline/promises'; const CLI_AGENT_NAME = 'Terminal Agent'; +const DEFAULT_AGENT_NAME = 'Nano'; type Fields = Record; type StepResult = { ok: boolean; fields: Fields; exitCode: number }; @@ -130,6 +133,18 @@ async function askDisplayName(fallback: string): Promise { } } +async function askAgentName(fallback: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question( + `\nWhat should your agent be called? [${fallback}]: `, + ); + return answer.trim() || fallback; + } finally { + rl.close(); + } +} + async function askChannelChoice(): Promise<'telegram' | 'skip'> { const rl = createInterface({ input: process.stdin, output: process.stdout }); try { @@ -150,6 +165,15 @@ function runBashScript(relPath: string): Promise { }); } +function runTsxScript(relPath: string, args: string[] = []): Promise { + 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}`); @@ -251,21 +275,26 @@ async function main(): Promise { } } - if (!skip.has('cli-agent')) { + // Resolved once, reused by cli-agent + channel wiring. + 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(); - const displayName = preset || (await askDisplayName(fallback)); + displayName = preset || (await askDisplayName(fallback)); + } + if (!skip.has('cli-agent')) { const res = await runStep('cli-agent', [ '--display-name', - displayName, + 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.`, + `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, ); } } @@ -295,6 +324,38 @@ async function main(): Promise { 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', ); } + + const platformId = pair.fields.PLATFORM_ID; + const adminUserId = pair.fields.ADMIN_USER_ID; + if (!platformId || !adminUserId) { + fail( + 'pair-telegram succeeded but did not return PLATFORM_ID and ADMIN_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)); + + console.log('\n── wiring first agent ──────────────────────────'); + const initCode = await runTsxScript('scripts/init-first-agent.ts', [ + '--channel', 'telegram', + '--user-id', adminUserId, + '--platform-id', platformId, + '--display-name', displayName!, + '--agent-name', agentName, + ]); + if (initCode !== 0) { + fail( + 'Wiring the Telegram agent failed.', + `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${adminUserId}" --platform-id "${platformId}" --display-name "${displayName!}" --agent-name "${agentName}"\`.`, + ); + } + + console.log( + `\n[setup:auto] Telegram is wired. ${agentName} will DM you a welcome shortly.`, + ); } } From e7d798b00da16f2925ed50402b232d76935780f7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 00:17:42 +0300 Subject: [PATCH 16/24] feat(setup): validate Telegram token via getMe and deep-link to bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the token is in .env, call https://api.telegram.org/bot/getMe — if ok, extract the bot's username and \`open tg://resolve?domain=\` so the Telegram desktop app lands on the bot chat. When pair-telegram prints the 4-digit code a moment later, the user just types it into the already- open chat instead of hunting for their bot. Falls back to https://t.me/ if the tg:// scheme isn't registered, and just warns-and-continues if getMe fails (network hiccup shouldn't block setup). Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-telegram.sh | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index c822994..8183c33 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -111,10 +111,46 @@ EOF fi fi +# Validate the token via getMe so a typo surfaces before we restart the +# service, and capture the bot's username for the deep link. +TELEGRAM_BOT_TOKEN_VALUE="$(grep '^TELEGRAM_BOT_TOKEN=' .env | head -1 | cut -d= -f2-)" +BOT_USERNAME="" +if [[ -n "$TELEGRAM_BOT_TOKEN_VALUE" ]]; then + INFO=$(curl -fsS --max-time 8 \ + "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN_VALUE}/getMe" 2>/dev/null || true) + if echo "$INFO" | grep -q '"ok":true'; then + # Crude JSON parse — the response is always a flat object here. + BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p') + if [[ -n "$BOT_USERNAME" ]]; then + echo "[add-telegram] Token validated — bot is @${BOT_USERNAME}." + fi + else + echo "[add-telegram] Warning: getMe did not return ok. Continuing, but the token may be wrong." + fi +fi + # Container reads from data/env/env (the host mounts it). mkdir -p data/env cp .env data/env/env +# Deep-link into the bot's chat in the installed Telegram app so the user +# is already on the right screen when pair-telegram prints the code. +if [[ -n "$BOT_USERNAME" ]]; then + case "$(uname -s)" in + Darwin) + open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \ + || open "https://t.me/${BOT_USERNAME}" >/dev/null 2>&1 \ + || true + ;; + Linux) + xdg-open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \ + || xdg-open "https://t.me/${BOT_USERNAME}" >/dev/null 2>&1 \ + || true + ;; + esac + echo "[add-telegram] Opened Telegram → @${BOT_USERNAME}. Keep it open for the pairing code." +fi + echo "[add-telegram] Restarting service so the new adapter picks up the token…" case "$(uname -s)" in Darwin) From 5a472c4155ec81424b8ee8ebbecdcc9468e43090 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 00:19:23 +0300 Subject: [PATCH 17/24] fix(setup): print bot URL alongside the deep-link attempt Headless / SSH / WSL users won't have \`open\` or \`xdg-open\` wired up, so the deep-link fails silently and they have no clue where to go. Always print https://t.me/ so the URL is at least clickable or copy-pasteable from the terminal. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-telegram.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 8183c33..13ffaa9 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -134,21 +134,24 @@ mkdir -p data/env cp .env data/env/env # Deep-link into the bot's chat in the installed Telegram app so the user -# is already on the right screen when pair-telegram prints the code. +# is already on the right screen when pair-telegram prints the code. Also +# always print the URL so headless / remote-SSH users can open it manually. if [[ -n "$BOT_USERNAME" ]]; then + BOT_URL="https://t.me/${BOT_USERNAME}" case "$(uname -s)" in Darwin) open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \ - || open "https://t.me/${BOT_USERNAME}" >/dev/null 2>&1 \ + || open "$BOT_URL" >/dev/null 2>&1 \ || true ;; Linux) xdg-open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \ - || xdg-open "https://t.me/${BOT_USERNAME}" >/dev/null 2>&1 \ + || xdg-open "$BOT_URL" >/dev/null 2>&1 \ || true ;; esac - echo "[add-telegram] Opened Telegram → @${BOT_USERNAME}. Keep it open for the pairing code." + echo "[add-telegram] Bot chat: ${BOT_URL}" + echo "[add-telegram] (If Telegram didn't open automatically, click the link above.)" fi echo "[add-telegram] Restarting service so the new adapter picks up the token…" From 356a4d0a9fd3fe7da7444065a56a603131f016cf Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 00:22:53 +0300 Subject: [PATCH 18/24] feat(setup): render Telegram pairing code in a focused banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pair-telegram step emits PAIR_TELEGRAM_ISSUED / _NEW_CODE / _ATTEMPT blocks meant for /setup skill parsing — dumping them raw in setup:auto left the operator squinting at key/value clutter. Intercept the stream line-by-line, suppress the block framing, and print just the 4-digit code inside a box with a short instruction. Wrong-code attempts and the final success block also get short human lines. parseStatus still runs on the full buffered output at close so PLATFORM_ID / ADMIN_USER_ID flow through unchanged to init-first-agent. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 122 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 8 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index e76d9cf..096368c 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -81,6 +81,119 @@ function runStep(name: string, extra: string[] = []): Promise { }); } +/** + * Variant of runStep for `pair-telegram`. The step emits machine-readable + * status blocks (PAIR_TELEGRAM_ISSUED, PAIR_TELEGRAM_ATTEMPT, etc.) meant + * for the /setup skill to parse and relay. Running it directly leaves the + * operator staring at noisy blocks — this filters them and renders a + * focused banner around the 4-digit code instead. + */ +function runPairTelegram(intent: string): Promise { + return new Promise((resolve) => { + console.log('\n── pair-telegram ───────────────────────────────'); + const args = [ + 'exec', 'tsx', 'setup/index.ts', + '--step', 'pair-telegram', + '--', '--intent', intent, + ]; + const child = spawn('pnpm', args, { stdio: ['inherit', 'pipe', 'inherit'] }); + + let buf = ''; + let partial = ''; + let inBlock = false; + let blockType = ''; + let blockFields: Record = {}; + + function handleLine(line: string): void { + if (line.startsWith('=== NANOCLAW SETUP:')) { + inBlock = true; + blockType = line.replace('=== NANOCLAW SETUP:', '').replace('===', '').trim(); + blockFields = {}; + return; + } + if (line.startsWith('=== END ===')) { + inBlock = false; + renderBlock(blockType, blockFields); + return; + } + if (inBlock) { + const idx = line.indexOf(':'); + if (idx > -1) { + blockFields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + return; + } + process.stdout.write(line + '\n'); + } + + function renderBlock(type: string, fields: Record): void { + switch (type) { + case 'PAIR_TELEGRAM_ISSUED': + printCodeBanner(fields.CODE ?? '????'); + break; + case 'PAIR_TELEGRAM_NEW_CODE': + console.log('\n Previous code invalidated. New code:'); + printCodeBanner(fields.CODE ?? '????'); + break; + case 'PAIR_TELEGRAM_ATTEMPT': + console.log( + ` Got "${fields.RECEIVED_CODE ?? '?'}" — doesn't match. A new code is on its way.`, + ); + break; + case 'PAIR_TELEGRAM': + if (fields.STATUS === 'success') { + console.log('\n ✓ Telegram paired.'); + } else if (fields.STATUS === 'failed') { + console.log(`\n ✗ Pairing failed: ${fields.ERROR ?? 'unknown'}`); + } + break; + default: { + // Forward unknown blocks verbatim (forward-compat). + const lines = [`=== NANOCLAW SETUP: ${type} ===`]; + for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`); + lines.push('=== END ==='); + process.stdout.write(lines.join('\n') + '\n'); + } + } + } + + child.stdout.on('data', (chunk: Buffer) => { + const s = chunk.toString('utf-8'); + buf += s; + partial += s; + const lines = partial.split('\n'); + partial = lines.pop() ?? ''; + for (const line of lines) handleLine(line); + }); + child.on('close', (code) => { + if (partial) handleLine(partial); + const fields = parseStatus(buf); + resolve({ + ok: code === 0 && fields.STATUS === 'success', + fields, + exitCode: code ?? 1, + }); + }); + }); +} + +function printCodeBanner(code: string): void { + // Double-space between digits for readability in a 4-digit code. + const digits = code.trim().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')); +} + /** * After installing Docker, this process's supplementary groups are still * frozen from login — subsequent steps that talk to /var/run/docker.sock @@ -310,14 +423,7 @@ async function main(): Promise { ); } - console.log( - '\n[setup:auto] Pairing Telegram. A 4-digit code will appear below.\n' + - ' From Telegram, send just those 4 digits to your bot\n' + - ' (DM the bot for a personal chat, or prefix with your\n' + - ' bot handle in a group with privacy on).\n', - ); - - const pair = await runStep('pair-telegram', ['--intent', 'main']); + const pair = await runPairTelegram('main'); if (!pair.ok) { fail( 'Telegram pairing failed.', From e24ecbf8b083be9ecf5cacd8e460c64ae7e0b9c7 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 00:27:43 +0300 Subject: [PATCH 19/24] refactor(setup): own pair-telegram.ts in this branch with clean output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously setup:auto parsed pair-telegram's machine-readable status blocks and rendered a banner on top. Fork the script instead: check in setup/pair-telegram.ts with a focused 4-digit banner, a short wrong-attempt line, and a single final PAIR_TELEGRAM status block (kept so the parent driver still picks up PLATFORM_ID and PAIRED_USER_ID via parseStatus). Drop pair-telegram.ts from add-telegram.sh's copy list so the local version isn't overwritten on re-runs. The other adapter files (telegram.ts, telegram-pairing.ts, etc.) still come from the channels branch. Also fix a latent bug: auto.ts was reading ADMIN_USER_ID from the success block, but the actual field name is PAIRED_USER_ID — init-first-agent would have been called with --user-id "". Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-telegram.sh | 6 +- setup/auto.ts | 125 ++--------------------------------------- setup/pair-telegram.ts | 124 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 122 deletions(-) create mode 100644 setup/pair-telegram.ts diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 13ffaa9..262502d 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -17,7 +17,6 @@ CHANNELS_BRANCH="origin/channels" need_install() { [[ ! -f src/channels/telegram.ts ]] && return 0 - [[ ! -f setup/pair-telegram.ts ]] && return 0 ! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0 return 1 } @@ -26,14 +25,15 @@ if need_install; then echo "[add-telegram] Fetching channels branch…" git fetch origin channels >/dev/null 2>&1 + # 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. echo "[add-telegram] Copying adapter files from $CHANNELS_BRANCH…" for f in \ src/channels/telegram.ts \ src/channels/telegram-pairing.ts \ src/channels/telegram-pairing.test.ts \ src/channels/telegram-markdown-sanitize.ts \ - src/channels/telegram-markdown-sanitize.test.ts \ - setup/pair-telegram.ts + src/channels/telegram-markdown-sanitize.test.ts do git show "$CHANNELS_BRANCH:$f" > "$f" done diff --git a/setup/auto.ts b/setup/auto.ts index 096368c..d1358ca 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -81,119 +81,6 @@ function runStep(name: string, extra: string[] = []): Promise { }); } -/** - * Variant of runStep for `pair-telegram`. The step emits machine-readable - * status blocks (PAIR_TELEGRAM_ISSUED, PAIR_TELEGRAM_ATTEMPT, etc.) meant - * for the /setup skill to parse and relay. Running it directly leaves the - * operator staring at noisy blocks — this filters them and renders a - * focused banner around the 4-digit code instead. - */ -function runPairTelegram(intent: string): Promise { - return new Promise((resolve) => { - console.log('\n── pair-telegram ───────────────────────────────'); - const args = [ - 'exec', 'tsx', 'setup/index.ts', - '--step', 'pair-telegram', - '--', '--intent', intent, - ]; - const child = spawn('pnpm', args, { stdio: ['inherit', 'pipe', 'inherit'] }); - - let buf = ''; - let partial = ''; - let inBlock = false; - let blockType = ''; - let blockFields: Record = {}; - - function handleLine(line: string): void { - if (line.startsWith('=== NANOCLAW SETUP:')) { - inBlock = true; - blockType = line.replace('=== NANOCLAW SETUP:', '').replace('===', '').trim(); - blockFields = {}; - return; - } - if (line.startsWith('=== END ===')) { - inBlock = false; - renderBlock(blockType, blockFields); - return; - } - if (inBlock) { - const idx = line.indexOf(':'); - if (idx > -1) { - blockFields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); - } - return; - } - process.stdout.write(line + '\n'); - } - - function renderBlock(type: string, fields: Record): void { - switch (type) { - case 'PAIR_TELEGRAM_ISSUED': - printCodeBanner(fields.CODE ?? '????'); - break; - case 'PAIR_TELEGRAM_NEW_CODE': - console.log('\n Previous code invalidated. New code:'); - printCodeBanner(fields.CODE ?? '????'); - break; - case 'PAIR_TELEGRAM_ATTEMPT': - console.log( - ` Got "${fields.RECEIVED_CODE ?? '?'}" — doesn't match. A new code is on its way.`, - ); - break; - case 'PAIR_TELEGRAM': - if (fields.STATUS === 'success') { - console.log('\n ✓ Telegram paired.'); - } else if (fields.STATUS === 'failed') { - console.log(`\n ✗ Pairing failed: ${fields.ERROR ?? 'unknown'}`); - } - break; - default: { - // Forward unknown blocks verbatim (forward-compat). - const lines = [`=== NANOCLAW SETUP: ${type} ===`]; - for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`); - lines.push('=== END ==='); - process.stdout.write(lines.join('\n') + '\n'); - } - } - } - - child.stdout.on('data', (chunk: Buffer) => { - const s = chunk.toString('utf-8'); - buf += s; - partial += s; - const lines = partial.split('\n'); - partial = lines.pop() ?? ''; - for (const line of lines) handleLine(line); - }); - child.on('close', (code) => { - if (partial) handleLine(partial); - const fields = parseStatus(buf); - resolve({ - ok: code === 0 && fields.STATUS === 'success', - fields, - exitCode: code ?? 1, - }); - }); - }); -} - -function printCodeBanner(code: string): void { - // Double-space between digits for readability in a 4-digit code. - const digits = code.trim().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')); -} - /** * After installing Docker, this process's supplementary groups are still * frozen from login — subsequent steps that talk to /var/run/docker.sock @@ -423,7 +310,7 @@ async function main(): Promise { ); } - const pair = await runPairTelegram('main'); + const pair = await runStep('pair-telegram', ['--intent', 'main']); if (!pair.ok) { fail( 'Telegram pairing failed.', @@ -432,10 +319,10 @@ async function main(): Promise { } const platformId = pair.fields.PLATFORM_ID; - const adminUserId = pair.fields.ADMIN_USER_ID; - if (!platformId || !adminUserId) { + const pairedUserId = pair.fields.PAIRED_USER_ID; + if (!platformId || !pairedUserId) { fail( - 'pair-telegram succeeded but did not return PLATFORM_ID and ADMIN_USER_ID.', + '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.', ); } @@ -447,7 +334,7 @@ async function main(): Promise { console.log('\n── wiring first agent ──────────────────────────'); const initCode = await runTsxScript('scripts/init-first-agent.ts', [ '--channel', 'telegram', - '--user-id', adminUserId, + '--user-id', pairedUserId, '--platform-id', platformId, '--display-name', displayName!, '--agent-name', agentName, @@ -455,7 +342,7 @@ async function main(): Promise { if (initCode !== 0) { fail( 'Wiring the Telegram agent failed.', - `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${adminUserId}" --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}"\`.`, ); } diff --git a/setup/pair-telegram.ts b/setup/pair-telegram.ts new file mode 100644 index 0000000..cf7259b --- /dev/null +++ b/setup/pair-telegram.ts @@ -0,0 +1,124 @@ +/** + * 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. + * + * Used exclusively by `setup:auto` / `bash nanoclaw.sh` on this branch. Human- + * facing output is a focused banner for the code (no parseable block), plus a + * short line for wrong attempts / regenerations. A single machine-readable + * PAIR_TELEGRAM status block is still emitted at the end so the parent driver + * can pick up PLATFORM_ID / PAIRED_USER_ID / IS_GROUP. + * + * Depends on src/channels/telegram-pairing.js, which setup/add-telegram.sh + * copies in from the `channels` branch before this step runs. setup/ is + * excluded from the host tsconfig, so this file's import resolves only at + * runtime — tsc won't complain on branches that haven't run add-telegram yet. + */ +import path from 'path'; + +import { + createPairing, + waitForPairing, + type PairingIntent, +} from '../src/channels/telegram-pairing.js'; +import { DATA_DIR } from '../src/config.js'; +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; + +import { emitStatus } from './status.js'; + +function parseArgs(args: string[]): PairingIntent { + let intent: PairingIntent = 'main'; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--intent') { + const raw = args[++i] || 'main'; + if (raw === 'main') { + intent = 'main'; + } else if (raw.startsWith('wire-to:')) { + intent = { kind: 'wire-to', folder: raw.slice('wire-to:'.length) }; + } else if (raw.startsWith('new-agent:')) { + intent = { kind: 'new-agent', folder: raw.slice('new-agent:'.length) }; + } else { + throw new Error(`Unknown intent: ${raw}`); + } + } + } + return intent; +} + +function intentToString(intent: PairingIntent): string { + if (intent === 'main') return 'main'; + 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 { + const intent = parseArgs(args); + + // Pairing stores state under DATA_DIR; the DB isn't strictly needed for the + // pairing primitive itself, but the inbound interceptor running inside the + // live service needs migrations applied. Touch it here so a fresh install + // doesn't fail on the first code match. + const db = initDb(path.join(DATA_DIR, 'v2.db')); + runMigrations(db); + + const MAX_REGENERATIONS = 5; + let record = await createPairing(intent); + printCodeBanner(record.code); + + for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) { + try { + const consumed = await waitForPairing(record.code, { + onAttempt: (a) => { + console.log( + ` Got "${a.candidate}" — doesn't match. A new code is on its way.`, + ); + }, + }); + + console.log('\n ✓ Telegram paired.\n'); + emitStatus('PAIR_TELEGRAM', { + STATUS: 'success', + CODE: record.code, + INTENT: intentToString(consumed.intent), + PLATFORM_ID: consumed.consumed!.platformId, + IS_GROUP: consumed.consumed!.isGroup, + PAIRED_USER_ID: consumed.consumed!.adminUserId + ? `telegram:${consumed.consumed!.adminUserId}` + : '', + }); + return; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const invalidated = /invalidated by wrong code/.test(message); + if (invalidated && regen < MAX_REGENERATIONS) { + record = await createPairing(intent); + console.log('\n Previous code invalidated. New code:'); + printCodeBanner(record.code); + continue; + } + const reason = invalidated ? 'max-regenerations-exceeded' : message; + console.error(`\n ✗ Pairing failed: ${reason}`); + emitStatus('PAIR_TELEGRAM', { + STATUS: 'failed', + CODE: record.code, + ERROR: reason, + }); + process.exit(2); + } + } +} From 6e0d742a7fdc0335d3ced3c2951bcda4e7ffd473 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 01:09:26 +0300 Subject: [PATCH 20/24] feat(setup): brand setup:auto with @clack/prompts + brand palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package.json | 5 +- pnpm-lock.yaml | 76 +++++ setup/add-telegram.sh | 4 +- setup/auto.ts | 596 ++++++++++++++++++++++++--------- setup/pair-telegram.ts | 50 ++- setup/register-claude-token.sh | 2 +- 6 files changed, 541 insertions(+), 192 deletions(-) diff --git a/package.json b/package.json index a7f8804..536714f 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,13 @@ "test:watch": "vitest" }, "dependencies": { + "@chat-adapter/telegram": "4.26.0", + "@clack/prompts": "^1.2.0", "@onecli-sh/sdk": "^0.3.1", "better-sqlite3": "11.10.0", "chat": "^4.24.0", - "cron-parser": "5.5.0" + "cron-parser": "5.5.0", + "kleur": "^4.1.5" }, "devDependencies": { "@eslint/js": "^9.35.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1aa197..4f284a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: 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': specifier: ^0.3.1 version: 0.3.1 @@ -20,6 +26,9 @@ importers: cron-parser: specifier: 5.5.0 version: 5.5.0 + kleur: + specifier: ^4.1.5 + version: 4.1.5 devDependencies: '@eslint/js': specifier: ^9.35.0 @@ -60,6 +69,18 @@ importers: 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': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -748,6 +769,15 @@ packages: fast-levenshtein@2.0.6: 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: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -866,6 +896,10 @@ packages: keyv@4.5.4: 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: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1239,6 +1273,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1462,6 +1499,31 @@ packages: 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': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2105,6 +2167,16 @@ snapshots: 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): optionalDependencies: picomatch: 4.0.4 @@ -2191,6 +2263,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@4.1.5: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -2735,6 +2809,8 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + sisteransi@1.0.5: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 262502d..4d540af 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -27,7 +27,7 @@ if need_install; then # 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. - echo "[add-telegram] Copying adapter files from $CHANNELS_BRANCH…" + echo "[add-telegram] Copying adapter files from ${CHANNELS_BRANCH}…" for f in \ src/channels/telegram.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" echo "[add-telegram] Building…" diff --git a/setup/auto.ts b/setup/auto.ts index d1358ca..482fcea 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -20,67 +20,249 @@ * Run `pnpm exec tsx setup/index.ts --step timezone -- --tz ` later * if autodetect is wrong (e.g. headless server with TZ=UTC). * - * Anthropic credential registration runs via setup/register-claude-token.sh - * (the only step that truly requires human input — browser sign-in or a - * pasted token/key). Channel auth and `/manage-channels` remain separate - * because they're platform-specific and typically handled via `/add-` - * and `/manage-channels` after this driver completes. + * 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 { createInterface } from 'readline/promises'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; const CLI_AGENT_NAME = 'Terminal Agent'; const DEFAULT_AGENT_NAME = 'Nano'; -type Fields = Record; -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 out: Fields = {}; - let inBlock = false; - for (const line of stdout.split('\n')) { - if (line.startsWith('=== NANOCLAW SETUP:')) { - inBlock = true; - continue; +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; +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 ===')) { - inBlock = false; - continue; + if (this.current) { + this.blocks.push(this.current); + this.onBlock(this.current); + this.current = null; + } + return; } - if (!inBlock) continue; - const idx = line.indexOf(':'); - if (idx === -1) continue; - const key = line.slice(0, idx).trim(); - const value = line.slice(idx + 1).trim(); - if (key) out[key] = value; + 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; } - return out; } -function runStep(name: string, extra: string[] = []): Promise { +/** + * 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 { return new Promise((resolve) => { - console.log(`\n── ${name} ────────────────────────────────────`); - const args = ['exec', 'tsx', 'setup/index.ts', '--step', name]; + const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName]; if (extra.length > 0) args.push('--', ...extra); - const child = spawn('pnpm', args, { stdio: ['inherit', 'pipe', 'inherit'] }); - let buf = ''; - child.stdout.on('data', (chunk: Buffer) => { - const s = chunk.toString('utf-8'); - buf += s; - process.stdout.write(s); + 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) => { - 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({ - ok: code === 0 && fields.STATUS === 'success', - fields, + 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 { + 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, +): Promise { + 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(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 @@ -89,7 +271,7 @@ function runStep(name: string, extra: string[] = []): Promise { * 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; // already re-exec'd + 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; @@ -97,10 +279,7 @@ function maybeReexecUnderSg(): void { if (!/permission denied/i.test(err)) return; if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; - console.log( - '\n[setup:auto] Docker socket not accessible in current group — ' + - 're-executing under `sg docker` to pick up new group membership.', - ); + 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' }, @@ -121,67 +300,121 @@ function anthropicSecretExists(): boolean { } } -async function askDisplayName(fallback: string): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - const answer = await rl.question( - `\nWhat should your agents call you? [${fallback}]: `, - ); - return answer.trim() || fallback; - } finally { - rl.close(); +function runInheritScript(cmd: string, args: string[]): Promise { + 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 { + 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 { + 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 { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - const answer = await rl.question( - `\nWhat should your agent be called? [${fallback}]: `, - ); - return answer.trim() || fallback; - } finally { - rl.close(); - } + 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 rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - console.log('\nConnect a messaging app so you can chat from your phone?'); - console.log(' 1) Telegram'); - console.log(' 2) Skip — just use the CLI for now'); - const answer = (await rl.question('Choose [1/2]: ')).trim(); - return answer === '1' ? 'telegram' : 'skip'; - } finally { - rl.close(); + 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; } -} -function runBashScript(relPath: string): Promise { - return new Promise((resolve) => { - const child = spawn('bash', [relPath], { stdio: 'inherit' }); - child.on('close', (code) => resolve(code ?? 1)); - }); -} - -function runTsxScript(relPath: string, args: string[] = []): Promise { - 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); + 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 { + printIntro(); + const skip = new Set( (process.env.NANOCLAW_SKIP ?? '') .split(',') @@ -190,92 +423,113 @@ async function main(): Promise { ); if (!skip.has('environment')) { - const env = await runStep('environment'); - if (!env.ok) fail('environment check failed'); + 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 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.fields.ERROR === 'runtime_not_available') { + 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 (res.fields.ERROR === 'docker_group_not_active') { + 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 `pnpm run setup:auto`.', + 'Log out and back in (or run `newgrp docker` in a new shell), then retry.', ); } fail( - 'container build/test failed', - 'For stale build cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', + '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 runStep('onecli'); + const res = await runQuietStep('onecli', { + running: 'Installing OneCLI credential vault…', + done: 'OneCLI installed.', + }); 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( 'OneCLI installed but not on PATH.', 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', ); } fail( - `OneCLI install failed (${res.fields.ERROR ?? 'unknown'})`, - 'Check that curl + a writable ~/.local/bin are available; re-run `pnpm run setup:auto`.', + `OneCLI install failed (${err ?? 'unknown'}).`, + 'Check that curl + a writable ~/.local/bin are available, then retry.', ); } } if (!skip.has('auth')) { if (anthropicSecretExists()) { - console.log( - '\n── auth ────────────────────────────────────\n' + - '[setup:auto] OneCLI already has an Anthropic secret — skipping.', - ); + p.log.success('OneCLI already has an Anthropic secret — skipping.'); } else { - console.log('\n── auth ────────────────────────────────────'); - const code = await runBashScript('setup/register-claude-token.sh'); + 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 runStep('mounts', ['--empty']); - if (!res.ok && res.fields.STATUS !== 'skipped') { - fail('mount allowlist step failed'); - } + 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 runStep('service'); + const res = await runQuietStep('service', { + running: 'Installing the background service…', + done: 'Service installed and running.', + }); if (!res.ok) { fail( - 'service install failed', + 'Service install failed.', 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', ); } - if (res.fields.DOCKER_GROUP_STALE === 'true') { - console.warn( - '\n[setup:auto] Docker group stale in systemd session. Run:\n' + - ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + - ' systemctl --user restart nanoclaw', + 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', + ), ); } } - // Resolved once, reused by cli-agent + channel wiring. let displayName: string | undefined; const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel'); if (needsDisplayName) { @@ -285,15 +539,17 @@ async function main(): Promise { } if (!skip.has('cli-agent')) { - const res = await runStep('cli-agent', [ - '--display-name', - displayName!, - '--agent-name', - CLI_AGENT_NAME, - ]); + 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', + 'CLI agent wiring failed.', `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 { if (!skip.has('channel')) { const choice = await askChannelChoice(); 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) { fail( '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) { fail( 'Telegram pairing failed.', @@ -318,8 +578,8 @@ async function main(): Promise { ); } - const platformId = pair.fields.PLATFORM_ID; - const pairedUserId = pair.fields.PAIRED_USER_ID; + 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.', @@ -331,54 +591,72 @@ async function main(): Promise { process.env.NANOCLAW_AGENT_NAME?.trim() || (await askAgentName(DEFAULT_AGENT_NAME)); - console.log('\n── wiring first agent ──────────────────────────'); - const initCode = await runTsxScript('scripts/init-first-agent.ts', [ - '--channel', 'telegram', - '--user-id', pairedUserId, - '--platform-id', platformId, - '--display-name', displayName!, - '--agent-name', agentName, - ]); - if (initCode !== 0) { + 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}"\`.`, ); } - - console.log( - `\n[setup:auto] Telegram is wired. ${agentName} will DM you a welcome shortly.`, - ); + } else { + p.log.info('No messaging channel wired — you can add one later with `/add-`.'); } } 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) { - console.log('\n[setup:auto] Scripted steps done. Remaining (interactive):'); - if (res.fields.CREDENTIALS !== 'configured') { - console.log(' • Anthropic secret not detected — re-run `bash setup/register-claude-token.sh`'); + const notes: string[] = []; + if (res.terminal?.fields.CREDENTIALS !== 'configured') { + 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') { - console.log( - ` • CLI agent did not reply (status: ${res.fields.AGENT_PING}). ` + + 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.fields.CONFIGURED_CHANNELS) { - console.log( - ' • Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …', - ); - console.log(' (CLI channel is already wired: `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'), 'What’s left'); + } + p.outro(k.yellow('Scripted steps done — some pieces still need you.')); 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) => { - console.error(err); + p.log.error(err instanceof Error ? err.message : String(err)); + p.cancel('Setup aborted.'); process.exit(1); }); diff --git a/setup/pair-telegram.ts b/setup/pair-telegram.ts index cf7259b..f3f9bf8 100644 --- a/setup/pair-telegram.ts +++ b/setup/pair-telegram.ts @@ -2,11 +2,16 @@ * 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. * - * Used exclusively by `setup:auto` / `bash nanoclaw.sh` on this branch. Human- - * facing output is a focused banner for the code (no parseable block), plus a - * short line for wrong attempts / regenerations. A single machine-readable - * PAIR_TELEGRAM status block is still emitted at the end so the parent driver - * can pick up PLATFORM_ID / PAIRED_USER_ID / IS_GROUP. + * Emits machine-readable status blocks only. The parent driver + * (`setup:auto`) renders the code / attempt / success UI with clack. Running + * this step directly will look sparse — that's intentional. + * + * 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 * 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}`; } -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 { const intent = parseArgs(args); @@ -78,19 +67,21 @@ export async function run(args: string[]): Promise { const MAX_REGENERATIONS = 5; 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++) { try { const consumed = await waitForPairing(record.code, { onAttempt: (a) => { - console.log( - ` Got "${a.candidate}" — doesn't match. A new code is on its way.`, - ); + emitStatus('PAIR_TELEGRAM_ATTEMPT', { + CANDIDATE: a.candidate, + }); }, }); - console.log('\n ✓ Telegram paired.\n'); emitStatus('PAIR_TELEGRAM', { STATUS: 'success', CODE: record.code, @@ -107,12 +98,13 @@ export async function run(args: string[]): Promise { const invalidated = /invalidated by wrong code/.test(message); if (invalidated && regen < MAX_REGENERATIONS) { record = await createPairing(intent); - console.log('\n Previous code invalidated. New code:'); - printCodeBanner(record.code); + emitStatus('PAIR_TELEGRAM_CODE', { + CODE: record.code, + REASON: 'regenerated', + }); continue; } const reason = invalidated ? 'max-regenerations-exceeded' : message; - console.error(`\n ✗ Pairing failed: ${reason}`); emitStatus('PAIR_TELEGRAM', { STATUS: 'failed', CODE: record.code, diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh index 9c042d9..8bcab73 100755 --- a/setup/register-claude-token.sh +++ b/setup/register-claude-token.sh @@ -117,7 +117,7 @@ esac echo 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 \ --name "$SECRET_NAME" \ From 5269edada4ddcb23c27ac201c7b5eb2c8a60fdc5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 02:02:13 +0300 Subject: [PATCH 21/24] feat(setup): three-level output (clack UI / progression log / raw per-step) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents and implements the output contract from docs/setup-flow.md: Level 1: clack UI — branded, concise, product content Level 2: logs/setup.log — append-only, linear, structured entries for humans + AI agents reviewing a run Level 3: logs/setup-steps/NN-name.log — full raw stdout+stderr per step Every scripted sub-step, including bootstrap, emits at all three levels. Bootstrap now runs under a bash-side clack-alike spinner with live elapsed time; its apt/pnpm output is captured to 01-bootstrap.log and summarised as a progression entry. setup.sh's legacy log() routes to the raw log instead of contaminating the progression log. Telegram install becomes fully branded: setup/auto.ts owns the BotFather instructions (clack note), token paste (clack password with format validation), and getMe check (clack spinner). add-telegram.sh drops to a non-interactive installer that reads TELEGRAM_BOT_TOKEN from env, logs to stderr, and emits a single ADD_TELEGRAM status block on stdout. The Anthropic credential flow is the one intentional break — register- claude-token.sh still inherits the TTY for claude setup-token's browser dance; it logs as an 'interactive' progression entry with the method. setup/logs.ts centralises the level 2/3 formatting: reset, header, step, userInput, complete, abort, stepRawLog. User answers (display name, agent name, channel choice, telegram_token preview) log as their own entries so the setup path is reconstructable from the progression log alone. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/setup-flow.md | 226 ++++++++++++++++++++++++++ nanoclaw.sh | 162 +++++++++++++++++-- setup.sh | 12 +- setup/add-telegram.sh | 171 ++++++++++---------- setup/auto.ts | 359 ++++++++++++++++++++++++++++++++++++------ setup/logs.ts | 130 +++++++++++++++ 6 files changed, 909 insertions(+), 151 deletions(-) create mode 100644 docs/setup-flow.md create mode 100644 setup/logs.ts diff --git a/docs/setup-flow.md b/docs/setup-flow.md new file mode 100644 index 0000000..800411c --- /dev/null +++ b/docs/setup-flow.md @@ -0,0 +1,226 @@ +# Setup flow + +This document is the contract for NanoClaw's end-to-end scripted setup +(`bash nanoclaw.sh` → `pnpm run setup:auto`). Read it before adding a new +step, fixing a regression, or changing how output is rendered. + +## The three output levels + +Every setup step produces output at **three distinct levels**. They have +different audiences, go to different places, and are formatted differently. +Don't conflate them. + +| Level | Audience | Destination | Format | +|---|---|---|---| +| 1. User-facing | The operator running setup | Terminal (via clack) | Branded, concise, informational — "product content" | +| 2. Progression | Future debuggers, AI agents reviewing a failed run, release support | `logs/setup.log` (one file, append-only) | Structured per-step blocks, linear chronology, human + machine readable | +| 3. Raw | Whoever is deep-debugging a specific step | `logs/setup-steps/NN-step-name.log` (one file per step) | Full raw child stdout + stderr, verbatim | + +Think of it as: the user sees a **summary**, the progression log is an +**index with key facts**, the raw logs are the **evidence**. + +### Level 1: user-facing (clack) + +Rendered by `setup/auto.ts` via `@clack/prompts`. This is our *product +surface* for setup — every line should read as if we designed it for a +stranger on day one. + +- Clack spinners for in-progress work. Show elapsed time. +- `p.log.success` / `p.log.step` / `p.log.warn` for permanent status + markers. +- `p.note` for multi-line information (pairing code, next steps). +- `p.text` / `p.select` / `p.password` for prompts. +- Brand palette: `brand()` / `brandBold()` / `brandChip()` helpers in + `setup/auto.ts`. Truecolor when the terminal supports it, 16-color + cyan fallback otherwise, plain text when piped / `NO_COLOR`. + +Rules: +- **No discontinuity.** Every sub-step belongs to the same visual flow. + The only exception is Anthropic credential registration (see below). +- **No raw child output.** Never `stdio: 'inherit'` a child whose output + wasn't written by us. Capture it and show it on failure only. +- **No debug-style prefixes** (`[add-telegram] …`, `INFO …`, timestamps). + Those belong in levels 2 and 3. +- **No emoji** unless the clack glyph requires it. + +### Level 2: progression log + +`logs/setup.log` — one file per setup run, append-only, cumulative across +a multi-run install (if a run fails midway and is re-attempted, the new +entries append). It's the thing you'd ask an operator to paste when they +report a setup bug, and the thing an AI agent would read to understand +what happened. + +Entry format: + +``` +=== [2026-04-22T22:14:12Z] bootstrap [45.1s] → success === + platform: linux + is_wsl: false + node_version: 22.22.2 + deps_ok: true + native_ok: true + raw: logs/setup-steps/01-bootstrap.log + +=== [2026-04-22T22:14:57Z] environment [2.3s] → success === + docker: running + apple_container: not_found + raw: logs/setup-steps/02-environment.log + +=== [2026-04-22T22:15:00Z] container [92.4s] → success === + runtime: docker + image: nanoclaw-agent:latest + build_ok: true + raw: logs/setup-steps/03-container.log +``` + +Design constraints: +- Start-time timestamp (UTC, ISO-8601) on the opening line so a `grep` + gives you the sequence. +- Duration in seconds with one decimal — fast steps read as "0.5s", not + "0ms". +- Status is one of: `success`, `skipped`, `failed`, `aborted`. +- Fields are step-specific but **must** be short scalar values. No JSON, + no multi-line. If a value is long, put it in the raw log and reference + it. +- Always emit a `raw:` pointer, even on success — makes debugging the + second failure easier. +- **User choices** are their own entries, not nested inside a step: + + ``` + === [2026-04-22T22:17:44Z] user-input → display_name === + value: gav + + === [2026-04-22T22:17:51Z] user-input → channel_choice === + value: telegram + ``` + + These matter because the path through the setup flow depends on them. + +The log opens with a header block identifying the run, and closes with +a completion block: + +``` +## 2026-04-22T22:14:12Z · setup:auto started + user: exedev + cwd: /home/exedev/nanoclaw + branch: branded-setup + commit: 6e0d742 + +… (step entries) … + +## 2026-04-22T22:18:54Z · completed (total 4m42s) +``` + +On failure the completion block names the failing step and its error: + +``` +## 2026-04-22T22:16:40Z · aborted at container (err=cache_miss) +``` + +### Level 3: raw per-step logs + +`logs/setup-steps/NN-step-name.log` — one file per step, numbered in +execution order (zero-padded 2-digit prefix for natural sorting). Full +verbatim stdout + stderr from the child process. Truncated and rewritten +on each run (not appended). + +Contents are whatever the step emits: apt output, docker build layers, +pnpm install spam, `curl` bodies, etc. This is the evidence plane — +"what did the shell actually see?" Nothing is filtered. + +## Contract for a new step + +When you add a step (either a TS step in `setup/.ts` or a bash +installer invoked from `auto.ts`), it must: + +1. **Receive a raw-log path** from the caller. Write all stdout + stderr + there. Don't write to the terminal directly. +2. **Emit a single terminal status block** at the end, containing + `STATUS: success|skipped|failed` and any step-specific fields: + + ``` + === NANOCLAW SETUP: STEP_NAME === + STATUS: success + KEY: value + KEY: value + === END === + ``` + + Field names are `UPPER_SNAKE_CASE`. Values are short scalars. + +3. If it's a long-running step, optionally emit **sub-status blocks** + mid-stream. `auto.ts` parses them live and can render intermediate + UI (as `pair-telegram` does with `PAIR_TELEGRAM_CODE` / + `PAIR_TELEGRAM_ATTEMPT`). + +4. **Exit non-zero** on hard failure so `auto.ts` can distinguish + "step ran to completion and reported failed" from "step crashed". + +The driver handles the rest: spinner in level 1, structured append to +level 2, raw capture to level 3. + +## The Anthropic exception + +Anthropic credential registration (`setup/register-claude-token.sh`) is +the **one** permitted break in the visual flow. Why: + +- `claude setup-token` opens a browser, runs its own OAuth prompt, and + prints the token. It owns the TTY via `script(1)`. +- We don't want to re-implement the OAuth device flow ourselves. +- We don't want to intercept / mirror the token (it appears in the + user's terminal already — mirroring it adds attack surface). + +So during this step: +- The clack flow explicitly pauses (a `p.log.step` marker says "this + part is interactive, you're handing off to Anthropic"). +- The child inherits stdio fully. +- When control returns, clack resumes on the next line with a success + marker. + +The level-2 log still gets an entry (`auth [interactive] → success` +with the method — subscription / oauth-token / api-key). Level-3 captures +are optional here; mirroring `script -q` output is tricky and the risk of +leaking the token to disk outweighs the debugging value. + +## File reference + +| File | Role | +|---|---| +| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. | +| `setup.sh` | Phase 1 bootstrap: Node, pnpm, native-module verify. Emits its own `BOOTSTRAP` status block (historically printed to stdout; now goes to the bootstrap raw log). | +| `setup/auto.ts` | Phase 2 driver. Orchestrates the clack UI, step execution, user prompts, and writes to all three log levels for every step it spawns. | +| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. | +| `setup/.ts` | Individual step implementations. Must emit one terminal status block; must not write directly to the terminal. | +| `setup/register-claude-token.sh` | The Anthropic exception. Inherits stdio, prints its own UI, returns a status to the driver. | +| `setup/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. | +| `setup/pair-telegram.ts` | Emits `PAIR_TELEGRAM_CODE` / `PAIR_TELEGRAM_ATTEMPT` / `PAIR_TELEGRAM` status blocks. Never prints UI. The driver renders it via clack notes. | + +## Common pitfalls + +- **Printing debug output from inside a step.** Tempting during + development; forbidden in checked-in code. All runtime messaging goes + through status blocks (level 2) or raw log writes (level 3). +- **Adding a `console.log` that "just this once" goes to the terminal.** + It breaks the clack flow — the spinner line gets torn. Use + `log.info` / `log.error` from `src/log.ts` (writes to the raw log) + instead. +- **`stdio: 'inherit'` for a non-exception child.** See Anthropic above. + Anything else needs `pipe` + explicit capture. +- **Tee-ing to stderr.** Clack's spinner owns the terminal during a step. + Even stderr writes tear the frame. Pipe everything, then choose what + to surface. +- **UTF-8 in bash `$VAR…` positions.** Bash's lexer can pull the first + byte of a multi-byte character into the variable name and trip + `set -u`. Always brace: `${VAR}…`. + +## Future work (not yet implemented) + +- **Progression log rotation.** Today's implementation truncates on each + run. Future: roll prior runs to `logs/setup.log.1`, `.2`, etc. +- **Raw log rotation for multi-run installs.** Currently each run + overwrites. Fine for now; revisit if support needs to compare + successive attempts. +- **Structured output from `register-claude-token.sh`.** The interactive + step emits no machine-readable status today. Future could add a + post-interaction status block with the method used. diff --git a/nanoclaw.sh b/nanoclaw.sh index 2dc0f04..17df82c 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -2,12 +2,15 @@ # # NanoClaw — scripted end-to-end install. # -# Runs `bash setup.sh` (bootstrap: Node check, pnpm install, native module -# verify), then `pnpm run setup:auto` (environment → container → onecli → -# auth → mounts → service → cli-agent → channel → verify). +# Phase 1: bootstrap (Node + pnpm + native module verify). Runs bash-side +# since tsx isn't available until pnpm install completes. +# Phase 2: setup:auto (all remaining steps under clack). # -# Everything that can be scripted runs unattended; the one interactive pause -# is the auth step (browser sign-in or paste token/API key). +# Both phases obey the same three-level output contract (see +# docs/setup-flow.md): +# 1. User-facing — concise status line with elapsed time +# 2. Progression log — logs/setup.log (header + one entry per phase/step) +# 3. Raw per-step log — logs/setup-steps/NN-name.log (full verbatim output) # # Config via env — passed through unchanged: # NANOCLAW_SKIP comma-separated setup:auto step names to skip @@ -19,28 +22,163 @@ set -euo pipefail PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$PROJECT_ROOT" +LOGS_DIR="$PROJECT_ROOT/logs" +STEPS_DIR="$LOGS_DIR/setup-steps" +PROGRESS_LOG="$LOGS_DIR/setup.log" + +# ─── log helpers ──────────────────────────────────────────────────────── + +ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +write_header() { + local ts + ts=$(ts_utc) + local branch commit + branch=$(git branch --show-current 2>/dev/null || echo unknown) + commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) + { + echo "## ${ts} · setup:auto started" + echo " invocation: nanoclaw.sh" + echo " user: $(whoami)" + echo " cwd: ${PROJECT_ROOT}" + echo " branch: ${branch}" + echo " commit: ${commit}" + echo "" + } > "$PROGRESS_LOG" +} + +# grep_field FIELD FILE — first value of FIELD: from a status block. +grep_field() { + grep "^$1:" "$2" 2>/dev/null | head -1 | sed "s/^$1: *//" || true +} + +write_bootstrap_entry() { + local status=$1 dur=$2 raw=$3 + local ts + ts=$(ts_utc) + local platform is_wsl node_version deps_ok native_ok has_build_tools + platform=$(grep_field PLATFORM "$raw") + is_wsl=$(grep_field IS_WSL "$raw") + node_version=$(grep_field NODE_VERSION "$raw" | head -1) + deps_ok=$(grep_field DEPS_OK "$raw") + native_ok=$(grep_field NATIVE_OK "$raw") + has_build_tools=$(grep_field HAS_BUILD_TOOLS "$raw") + { + echo "=== [${ts}] bootstrap [${dur}s] → ${status} ===" + [ -n "$platform" ] && echo " platform: ${platform}" + [ -n "$is_wsl" ] && echo " is_wsl: ${is_wsl}" + [ -n "$node_version" ] && echo " node_version: ${node_version}" + [ -n "$deps_ok" ] && echo " deps_ok: ${deps_ok}" + [ -n "$native_ok" ] && echo " native_ok: ${native_ok}" + [ -n "$has_build_tools" ] && echo " has_build_tools: ${has_build_tools}" + # Emit the raw path relative to PROJECT_ROOT so the progression log + # is portable and matches the TS-side format (logs/setup-steps/NN-…). + echo " raw: ${raw#${PROJECT_ROOT}/}" + echo "" + } >> "$PROGRESS_LOG" +} + +write_abort_entry() { + local step=$1 error=$2 + local ts + ts=$(ts_utc) + echo "## ${ts} · aborted at ${step} (${error})" >> "$PROGRESS_LOG" +} + +# ─── bash-side "clack-alike" status line ──────────────────────────────── + +use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; } +dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; } +gray() { use_ansi && printf '\033[90m%s\033[0m' "$1" || printf '%s' "$1"; } +red() { use_ansi && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"; } +clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; } + +spinner_start() { printf '%s %s…' "$(gray '◒')" "$1"; } +spinner_update() { clear_line; printf '%s %s… %s' "$(gray '◒')" "$1" "$(dim "(${2}s)")"; } +spinner_success() { clear_line; printf '%s %s %s\n' "$(gray '◇')" "$1" "$(dim "(${2}s)")"; } +spinner_failure() { clear_line; printf '%s %s %s\n' "$(red '✗')" "$1" "$(dim "(${2}s)")"; } + +# ─── fresh-run setup ──────────────────────────────────────────────────── + +rm -rf "$STEPS_DIR" +rm -f "$PROGRESS_LOG" +mkdir -p "$STEPS_DIR" "$LOGS_DIR" +write_header + cat <<'EOF' ═══════════════════════════════════════════════════════════════ NanoClaw scripted setup ═══════════════════════════════════════════════════════════════ -Phase 1: bootstrap (Node + pnpm + native modules) +Phase 1 · bootstrap EOF -if ! bash setup.sh; then +# ─── phase 1: bootstrap ───────────────────────────────────────────────── + +BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log" +BOOTSTRAP_LABEL="Bootstrapping Node, pnpm, native modules" +BOOTSTRAP_START=$(date +%s) + +spinner_start "$BOOTSTRAP_LABEL" + +# Run in the background so we can tick elapsed time. Capture exit code via +# a tmpfile (subshell $? is lost after the while loop finishes). +BOOTSTRAP_EXIT_FILE=$(mktemp -t nanoclaw-bootstrap-exit.XXXXXX) +( + # setup.sh's legacy `log()` writes to a file; point it at the raw log + # so its verbose entries land alongside the stdout we're capturing. + export NANOCLAW_BOOTSTRAP_LOG="$BOOTSTRAP_RAW" + if bash setup.sh > "$BOOTSTRAP_RAW" 2>&1; then + echo 0 > "$BOOTSTRAP_EXIT_FILE" + else + echo $? > "$BOOTSTRAP_EXIT_FILE" + fi +) & +BOOTSTRAP_PID=$! + +while kill -0 "$BOOTSTRAP_PID" 2>/dev/null; do + sleep 1 + if kill -0 "$BOOTSTRAP_PID" 2>/dev/null; then + spinner_update "$BOOTSTRAP_LABEL" "$(( $(date +%s) - BOOTSTRAP_START ))" + fi +done +# `wait` surfaces the child's exit code; we've already captured it. +wait "$BOOTSTRAP_PID" 2>/dev/null || true + +BOOTSTRAP_RC=$(cat "$BOOTSTRAP_EXIT_FILE") +rm -f "$BOOTSTRAP_EXIT_FILE" +BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START )) + +if [ "$BOOTSTRAP_RC" -eq 0 ]; then + spinner_success "Bootstrap complete" "$BOOTSTRAP_DUR" + write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" +else + spinner_failure "Bootstrap failed" "$BOOTSTRAP_DUR" + write_bootstrap_entry failed "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" + write_abort_entry bootstrap "exit-${BOOTSTRAP_RC}" + echo - echo "[nanoclaw.sh] Bootstrap failed. Inspect logs/setup.log and retry." >&2 + echo "$(dim '── last 40 lines of ')$(dim "$BOOTSTRAP_RAW")$(dim ' ──')" + tail -40 "$BOOTSTRAP_RAW" + echo + echo "Full raw log: $BOOTSTRAP_RAW" + echo "Progression: $PROGRESS_LOG" exit 1 fi +echo cat <<'EOF' - -═══════════════════════════════════════════════════════════════ - Phase 2: setup:auto -═══════════════════════════════════════════════════════════════ +Phase 2 · setup:auto EOF +# ─── phase 2: clack driver ────────────────────────────────────────────── + +# NANOCLAW_BOOTSTRAPPED=1 tells setup/auto.ts that the progression log has +# already been initialized (header + bootstrap entry), so it should append +# rather than wipe. +export NANOCLAW_BOOTSTRAPPED=1 + # exec so signals (Ctrl-C) propagate directly to the child. exec pnpm run setup:auto diff --git a/setup.sh b/setup.sh index e163df8..ae5da27 100755 --- a/setup.sh +++ b/setup.sh @@ -6,9 +6,17 @@ set -euo pipefail # This is the only bash script in the setup flow. PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LOG_FILE="$PROJECT_ROOT/logs/setup.log" -mkdir -p "$PROJECT_ROOT/logs" +# Where verbose bootstrap logs go. nanoclaw.sh captures setup.sh's stdout to +# the per-step raw log, but legacy code in this script + install-node.sh +# also calls `log` which writes to a file. Route those to the raw log so +# they don't contaminate the progression log (logs/setup.log). +# Default: write to the raw bootstrap log if nanoclaw.sh pointed us there, +# else fall back to a dedicated bootstrap log (keeps standalone `bash +# setup.sh` invocations working). +LOG_FILE="${NANOCLAW_BOOTSTRAP_LOG:-${PROJECT_ROOT}/logs/bootstrap.log}" + +mkdir -p "$(dirname "$LOG_FILE")" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [bootstrap] $*" >> "$LOG_FILE"; } diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 4d540af..5036bd4 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -1,12 +1,15 @@ #!/usr/bin/env bash -set -euo pipefail - -# Install the Telegram adapter (Phase A of the /add-telegram skill), collect -# the bot token, write .env + data/env/env, and restart the service so the -# new adapter is live. Idempotent. # -# Pair-telegram (the interactive code-sending step) is run separately by the -# caller (setup/auto.ts) so it can stream status blocks to the user. +# Install the Telegram adapter, persist the bot token to .env + data/env/env, +# restart the service, and open the bot's chat page in the local Telegram +# client. Non-interactive — the operator-facing "Create a bot" instructions +# and token paste live in setup/auto.ts. The token comes in via the +# TELEGRAM_BOT_TOKEN env var. +# +# Emits exactly one status block on stdout (ADD_TELEGRAM) at the end. All +# chatty progress messages go to stderr so setup:auto's raw-log capture +# sees the full story without cluttering the final block for the parser. +set -euo pipefail PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$PROJECT_ROOT" @@ -15,19 +18,49 @@ cd "$PROJECT_ROOT" ADAPTER_VERSION="@chat-adapter/telegram@4.26.0" CHANNELS_BRANCH="origin/channels" +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + local username=${BOT_USERNAME:-} + echo "=== NANOCLAW SETUP: ADD_TELEGRAM ===" + echo "STATUS: ${status}" + echo "ADAPTER_VERSION: ${ADAPTER_VERSION}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$username" ] && echo "BOT_USERNAME: ${username}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-telegram] $*" >&2; } + +if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then + emit_status failed "TELEGRAM_BOT_TOKEN env var not set" + exit 1 +fi + +if ! [[ "$TELEGRAM_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then + emit_status failed "token format invalid (expected :)" + exit 1 +fi + need_install() { - [[ ! -f src/channels/telegram.ts ]] && return 0 + [ ! -f src/channels/telegram.ts ] && return 0 ! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0 return 1 } +ADAPTER_ALREADY_INSTALLED=true if need_install; then - echo "[add-telegram] Fetching channels branch…" - git fetch origin channels >/dev/null 2>&1 + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch origin channels >&2 2>/dev/null || { + emit_status failed "git fetch origin channels failed" + exit 1 + } # 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. - echo "[add-telegram] Copying adapter files from ${CHANNELS_BRANCH}…" + log "Copying adapter files from ${CHANNELS_BRANCH}…" for f in \ src/channels/telegram.ts \ src/channels/telegram-pairing.ts \ @@ -35,7 +68,7 @@ if need_install; then src/channels/telegram-markdown-sanitize.ts \ src/channels/telegram-markdown-sanitize.test.ts do - git show "$CHANNELS_BRANCH:$f" > "$f" + git show "${CHANNELS_BRANCH}:$f" > "$f" done # Append self-registration import if missing. @@ -59,109 +92,71 @@ if need_install; then } ' - echo "[add-telegram] Installing ${ADAPTER_VERSION}…" - pnpm install "$ADAPTER_VERSION" + log "Installing ${ADAPTER_VERSION}…" + pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${ADAPTER_VERSION} failed" + exit 1 + } - echo "[add-telegram] Building…" - pnpm run build >/dev/null + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } else - echo "[add-telegram] Adapter files already installed — skipping install phase." + log "Adapter files already installed — skipping install phase." fi -# Token collection. -if grep -q '^TELEGRAM_BOT_TOKEN=.' .env 2>/dev/null; then - echo "[add-telegram] TELEGRAM_BOT_TOKEN already set in .env — skipping token prompt." +# Persist token. auto.ts validates before this point, so a bad token here +# would be an internal bug rather than operator input. +touch .env +if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then + awk -v tok="$TELEGRAM_BOT_TOKEN" \ + '/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \ + .env > .env.tmp && mv .env.tmp .env else - cat <<'EOF' - -── Create a Telegram bot ────────────────────────────────────── - - 1. Open Telegram and message @BotFather - 2. Send: /newbot - 3. Follow the prompts (bot name, username ending in "bot") - 4. Copy the token it gives you (format: :) - -Optional but recommended for groups: - 5. @BotFather → /mybots → your bot → Bot Settings → Group Privacy → OFF - -EOF - echo "Paste your TELEGRAM_BOT_TOKEN and press Enter." - echo "Nothing will appear on the screen as you paste — that's intentional." - echo "Paste once, then just press Enter to submit." - read -r -s -p "> " TOKEN &2 - exit 1 - fi - - # Telegram bot tokens: :<35+ base64url-ish chars>. - if [[ ! "$TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then - echo "[add-telegram] Token format looks wrong (expected :). Aborting." >&2 - exit 1 - fi - - touch .env - if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then - awk -v tok="$TOKEN" '/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \ - .env > .env.tmp && mv .env.tmp .env - else - echo "TELEGRAM_BOT_TOKEN=$TOKEN" >> .env - fi + echo "TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}" >> .env fi -# Validate the token via getMe so a typo surfaces before we restart the -# service, and capture the bot's username for the deep link. -TELEGRAM_BOT_TOKEN_VALUE="$(grep '^TELEGRAM_BOT_TOKEN=' .env | head -1 | cut -d= -f2-)" +# Look up the bot username (auto.ts already validated; we re-query here so +# standalone invocations still work). +INFO=$(curl -fsS --max-time 8 \ + "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true) BOT_USERNAME="" -if [[ -n "$TELEGRAM_BOT_TOKEN_VALUE" ]]; then - INFO=$(curl -fsS --max-time 8 \ - "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN_VALUE}/getMe" 2>/dev/null || true) - if echo "$INFO" | grep -q '"ok":true'; then - # Crude JSON parse — the response is always a flat object here. - BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p') - if [[ -n "$BOT_USERNAME" ]]; then - echo "[add-telegram] Token validated — bot is @${BOT_USERNAME}." - fi - else - echo "[add-telegram] Warning: getMe did not return ok. Continuing, but the token may be wrong." - fi +if echo "$INFO" | grep -q '"ok":true'; then + BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p') fi # Container reads from data/env/env (the host mounts it). mkdir -p data/env cp .env data/env/env -# Deep-link into the bot's chat in the installed Telegram app so the user -# is already on the right screen when pair-telegram prints the code. Also -# always print the URL so headless / remote-SSH users can open it manually. -if [[ -n "$BOT_USERNAME" ]]; then - BOT_URL="https://t.me/${BOT_USERNAME}" +# Deep-link into the bot's chat so the user is already on the right screen +# when pair-telegram prints the code. Silent best-effort — runs under a +# spinner, any output (from `open` / `xdg-open`) goes to the raw log. +if [ -n "$BOT_USERNAME" ]; then case "$(uname -s)" in Darwin) - open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \ - || open "$BOT_URL" >/dev/null 2>&1 \ + open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \ + || open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \ || true ;; Linux) - xdg-open "tg://resolve?domain=${BOT_USERNAME}" >/dev/null 2>&1 \ - || xdg-open "$BOT_URL" >/dev/null 2>&1 \ + xdg-open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \ + || xdg-open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \ || true ;; esac - echo "[add-telegram] Bot chat: ${BOT_URL}" - echo "[add-telegram] (If Telegram didn't open automatically, click the link above.)" fi -echo "[add-telegram] Restarting service so the new adapter picks up the token…" +log "Restarting service so the new adapter picks up the token…" case "$(uname -s)" in Darwin) - launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >/dev/null 2>&1 || true + launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true ;; Linux) - systemctl --user restart nanoclaw >/dev/null 2>&1 \ - || sudo systemctl restart nanoclaw >/dev/null 2>&1 \ + systemctl --user restart nanoclaw >&2 2>/dev/null \ + || sudo systemctl restart nanoclaw >&2 2>/dev/null \ || true ;; esac @@ -170,4 +165,4 @@ esac # begins polling for the user's code message. sleep 5 -echo "[add-telegram] Install + credentials complete." +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index 482fcea..c16b6e5 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -26,13 +26,19 @@ * with inherited stdio — clack resumes cleanly on the next step. */ import { spawn, spawnSync } from 'child_process'; +import fs from 'fs'; import * as p from '@clack/prompts'; import k from 'kleur'; +import * as setupLog from './logs.js'; + const CLI_AGENT_NAME = 'Terminal Agent'; const DEFAULT_AGENT_NAME = 'Nano'; +const RUN_START = Date.now(); +let failingStep = 'setup'; + /** * Brand palette, pulled from assets/nanoclaw-logo.png: * brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body @@ -123,14 +129,16 @@ class StatusStream { } /** - * 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. + * Spawn a setup step as a child process. Output is tee'd to the provided + * raw log file (level 3) and parsed for status blocks (level 2 summary). + * The onBlock callback fires per status block as they close so the UI can + * react mid-stream. */ function spawnStep( stepName: string, extra: string[], onBlock: (block: Block) => void, + rawLogPath: string, ): Promise { return new Promise((resolve) => { const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName]; @@ -138,13 +146,20 @@ function spawnStep( const child = spawn('pnpm', args, { stdio: ['ignore', 'pipe', 'pipe'] }); const stream = new StatusStream(onBlock); + const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); + raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`); - child.stdout.on('data', (chunk: Buffer) => stream.write(chunk.toString('utf-8'))); + child.stdout.on('data', (chunk: Buffer) => { + stream.write(chunk.toString('utf-8')); + raw.write(chunk); + }); child.stderr.on('data', (chunk: Buffer) => { stream.transcript += chunk.toString('utf-8'); + raw.write(chunk); }); child.on('close', (code) => { + raw.end(); // 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. @@ -170,22 +185,90 @@ type SpinnerLabels = { failed?: string; }; -/** Run a step under a clack spinner. Child output is captured; shown only on failure. */ +/** Run a step under a clack spinner. Teed to a per-step raw log + progression entry at the end. */ async function runQuietStep( stepName: string, labels: SpinnerLabels, extra: string[] = [], -): Promise { - return runUnderSpinner(labels, () => spawnStep(stepName, extra, () => {})); +): Promise { + failingStep = stepName; + const rawLog = setupLog.stepRawLog(stepName); + const start = Date.now(); + const result = await runUnderSpinner(labels, () => + spawnStep(stepName, extra, () => {}, rawLog), + ); + const durationMs = Date.now() - start; + writeStepEntry(stepName, result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; } -/** Run an arbitrary child under a spinner, capturing its stdout/stderr. */ +/** Run an arbitrary child under a spinner. Same raw-log + progression treatment as runQuietStep. */ async function runQuietChild( + logName: string, cmd: string, args: string[], labels: SpinnerLabels, -): Promise<{ ok: boolean; exitCode: number; transcript: string }> { - return runUnderSpinner(labels, () => spawnQuiet(cmd, args)); + opts?: { + /** Extra fields to merge into the progression entry (on top of any status-block fields). */ + extraFields?: Record; + /** Environment overrides to pass to the child process. */ + env?: NodeJS.ProcessEnv; + }, +): Promise<{ + ok: boolean; + exitCode: number; + transcript: string; + terminal: Block | null; + rawLog: string; + durationMs: number; +}> { + failingStep = logName; + const rawLog = setupLog.stepRawLog(logName); + const start = Date.now(); + const result = await runUnderSpinner(labels, () => + spawnQuiet(cmd, args, rawLog, opts?.env), + ); + const durationMs = Date.now() - start; + + const blockFields = summariseTerminalFields(result.terminal); + const fields = { ...blockFields, ...(opts?.extraFields ?? {}) }; + const rawStatus = result.terminal?.fields.STATUS; + const status: 'success' | 'skipped' | 'failed' = !result.ok + ? 'failed' + : rawStatus === 'skipped' + ? 'skipped' + : 'success'; + setupLog.step(logName, status, durationMs, fields, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** Turn a step's terminal-block fields into a concise progression-log entry. */ +function writeStepEntry( + stepName: string, + result: StepResult, + durationMs: number, + rawLog: string, +): void { + const rawStatus = result.terminal?.fields.STATUS; + const logStatus: 'success' | 'skipped' | 'failed' = !result.ok + ? 'failed' + : rawStatus === 'skipped' + ? 'skipped' + : 'success'; + const fields = summariseTerminalFields(result.terminal); + setupLog.step(stepName, logStatus, durationMs, fields, rawLog); +} + +/** Strip STATUS + LOG (redundant) and any oversize values from the terminal block's fields. */ +function summariseTerminalFields(block: Block | null): Record { + if (!block) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(block.fields)) { + if (k === 'STATUS' || k === 'LOG') continue; + if (v.length > 120) continue; // keep it skimmable; full value lives in the raw log + out[k] = v; + } + return out; } async function runUnderSpinner< @@ -221,14 +304,34 @@ async function runUnderSpinner< function spawnQuiet( cmd: string, args: string[], -): Promise<{ ok: boolean; exitCode: number; transcript: string }> { + rawLogPath: string, + envOverride?: NodeJS.ProcessEnv, +): Promise<{ ok: boolean; exitCode: number; transcript: string; terminal: Block | null; blocks: Block[] }> { return new Promise((resolve) => { - const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(cmd, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: envOverride ? { ...process.env, ...envOverride } : process.env, + }); let transcript = ''; - child.stdout.on('data', (c: Buffer) => { transcript += c.toString('utf-8'); }); - child.stderr.on('data', (c: Buffer) => { transcript += c.toString('utf-8'); }); + const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); + raw.write(`# ${[cmd, ...args].join(' ')} — ${new Date().toISOString()}\n\n`); + const blocks: Block[] = []; + const stream = new StatusStream((b) => blocks.push(b)); + child.stdout.on('data', (c: Buffer) => { + const s = c.toString('utf-8'); + transcript += s; + stream.write(s); + raw.write(c); + }); + child.stderr.on('data', (c: Buffer) => { + transcript += c.toString('utf-8'); + raw.write(c); + }); child.on('close', (code) => { - resolve({ ok: code === 0, exitCode: code ?? 1, transcript }); + raw.end(); + const terminal = + [...blocks].reverse().find((b) => b.fields.STATUS) ?? null; + resolve({ ok: code === 0, exitCode: code ?? 1, transcript, terminal, blocks }); }); }); } @@ -248,15 +351,17 @@ function dumpTranscriptOnFailure(transcript: string): void { } function fail(msg: string, hint?: string): never { + setupLog.abort(failingStep, msg); p.log.error(msg); if (hint) p.log.message(k.dim(hint)); - p.log.message(k.dim('Logs: logs/setup.log')); + p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); p.cancel('Setup aborted.'); process.exit(1); } function ensureAnswer(value: T | symbol): T { if (p.isCancel(value)) { + setupLog.abort(failingStep, 'user-cancelled'); p.cancel('Setup cancelled.'); process.exit(0); } @@ -317,7 +422,10 @@ function formatCodeCard(code: string): string { ].join('\n'); } -async function runPairTelegram(): Promise { +async function runPairTelegram(): Promise { + failingStep = 'pair-telegram'; + const rawLog = setupLog.stepRawLog('pair-telegram'); + const start = Date.now(); const s = p.spinner(); s.start('Creating pairing code…'); let spinnerActive = true; @@ -329,29 +437,35 @@ async function runPairTelegram(): Promise { } }; - 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.'); + 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); + } } - 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); - } - } - }); + }, + rawLog, + ); + const durationMs = Date.now() - start; // Safety net: if the child died without emitting a terminal block, make // sure we don't leave the spinner running. @@ -359,7 +473,9 @@ async function runPairTelegram(): Promise { stopSpinner(result.ok ? 'Done.' : 'Pairing exited unexpectedly.', result.ok ? 0 : 1); if (!result.ok) dumpTranscriptOnFailure(result.transcript); } - return result; + + writeStepEntry('pair-telegram', result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; } async function askDisplayName(fallback: string): Promise { @@ -370,7 +486,9 @@ async function askDisplayName(fallback: string): Promise { defaultValue: fallback, }), ); - return (answer as string).trim() || fallback; + const value = (answer as string).trim() || fallback; + setupLog.userInput('display_name', value); + return value; } async function askAgentName(fallback: string): Promise { @@ -381,7 +499,9 @@ async function askAgentName(fallback: string): Promise { defaultValue: fallback, }), ); - return (answer as string).trim() || fallback; + const value = (answer as string).trim() || fallback; + setupLog.userInput('agent_name', value); + return value; } async function askChannelChoice(): Promise<'telegram' | 'skip'> { @@ -394,9 +514,94 @@ async function askChannelChoice(): Promise<'telegram' | 'skip'> { ], }), ); + setupLog.userInput('channel_choice', String(choice)); return choice as 'telegram' | 'skip'; } +async function collectTelegramToken(): Promise { + p.note( + [ + '1. Open Telegram and message @BotFather', + '2. Send: /newbot', + '3. Follow the prompts (name + username ending in "bot")', + '4. Copy the token it gives you (format: :)', + '', + k.dim('Optional, but recommended for groups:'), + k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'), + ].join('\n'), + 'Create a Telegram bot', + ); + + const answer = ensureAnswer( + await p.password({ + message: 'Paste your bot token', + validate: (v) => { + if (!v || !v.trim()) return 'Token is required'; + if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { + return 'Format looks wrong — expected :'; + } + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + setupLog.userInput( + 'telegram_token', + `${token.slice(0, 12)}…${token.slice(-4)}`, + ); + return token; +} + +async function validateTelegramToken(token: string): Promise { + failingStep = 'telegram-validate'; + const s = p.spinner(); + const start = Date.now(); + s.start('Validating token with Telegram…'); + try { + const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); + const data = (await res.json()) as { + ok?: boolean; + result?: { username?: string; id?: number }; + description?: string; + }; + const elapsed = Math.round((Date.now() - start) / 1000); + if (data.ok && data.result?.username) { + const username = data.result.username; + s.stop(`Bot is @${username}. ${k.dim(`(${elapsed}s)`)}`); + setupLog.step( + 'telegram-validate', + 'success', + Date.now() - start, + { BOT_USERNAME: username, BOT_ID: data.result.id ?? '' }, + ); + return username; + } + const reason = data.description ?? 'token rejected by Telegram'; + s.stop(`Telegram rejected the token: ${reason}`, 1); + setupLog.step( + 'telegram-validate', + 'failed', + Date.now() - start, + { ERROR: reason }, + ); + fail( + 'Telegram rejected the token.', + 'Double-check the token (copy it again from @BotFather) and retry.', + ); + } catch (err) { + const elapsed = Math.round((Date.now() - start) / 1000); + s.stop(`Could not reach Telegram. ${k.dim(`(${elapsed}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('telegram-validate', 'failed', Date.now() - start, { + ERROR: message, + }); + fail( + 'Telegram API unreachable.', + 'Check your network connection and retry.', + ); + } +} + function printIntro(): void { const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; @@ -414,6 +619,7 @@ function printIntro(): void { async function main(): Promise { printIntro(); + initProgressionLog(); const skip = new Set( (process.env.NANOCLAW_SKIP ?? '') @@ -479,22 +685,28 @@ async function main(): Promise { } if (!skip.has('auth')) { + failingStep = 'auth'; if (anthropicSecretExists()) { p.log.success('OneCLI already has an Anthropic secret — skipping.'); + setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); } 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 start = Date.now(); const code = await runInheritScript('bash', ['setup/register-claude-token.sh']); + const durationMs = Date.now() - start; console.log(); if (code !== 0) { + setupLog.step('auth', 'failed', durationMs, { EXIT_CODE: code }); fail( 'Anthropic credential registration failed or was aborted.', 'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', ); } + setupLog.step('auth', 'interactive', durationMs, { METHOD: 'register-claude-token.sh' }); p.log.success('Anthropic credential registered with OneCLI.'); } } @@ -558,17 +770,28 @@ async function main(): Promise { 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) { + const token = await collectTelegramToken(); + const botUsername = await validateTelegramToken(token); + + const install = await runQuietChild( + 'telegram-install', + 'bash', + ['setup/add-telegram.sh'], + { + running: `Installing Telegram adapter and wiring @${botUsername}…`, + done: `Telegram adapter ready.`, + }, + { + env: { TELEGRAM_BOT_TOKEN: token }, + extraFields: { BOT_USERNAME: botUsername }, + }, + ); + if (!install.ok) { fail( 'Telegram install failed.', - 'Re-run `bash setup/add-telegram.sh`, then retry `pnpm run setup:auto`.', + 'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.', ); } - p.log.success('Telegram adapter installed.'); const pair = await runPairTelegram(); if (!pair.ok) { @@ -592,6 +815,7 @@ async function main(): Promise { (await askAgentName(DEFAULT_AGENT_NAME)); const init = await runQuietChild( + 'init-first-agent', 'pnpm', [ 'exec', 'tsx', 'scripts/init-first-agent.ts', @@ -605,6 +829,9 @@ async function main(): Promise { running: `Wiring ${agentName} to your Telegram chat…`, done: `${agentName} is wired — welcome DM incoming.`, }, + { + extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId }, + }, ); if (!init.ok) { fail( @@ -652,9 +879,43 @@ async function main(): Promise { `${k.cyan('Open Claude Code:')} claude`, ].join('\n'); p.note(nextSteps, 'Next steps'); + setupLog.complete(Date.now() - RUN_START); p.outro(k.green('Setup complete.')); } +/** + * Bootstrap (nanoclaw.sh) normally initializes logs/setup.log and writes + * the bootstrap entry before we even boot. If someone runs `pnpm run + * setup:auto` directly, start a fresh progression log here so we don't + * append to a stale one from a previous run. + */ +function initProgressionLog(): void { + if (process.env.NANOCLAW_BOOTSTRAPPED === '1') return; + let commit = ''; + try { + commit = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { + encoding: 'utf-8', + }).stdout.trim(); + } catch { + // git not available or not a repo — skip + } + let branch = ''; + try { + branch = spawnSync('git', ['branch', '--show-current'], { + encoding: 'utf-8', + }).stdout.trim(); + } catch { + // skip + } + setupLog.reset({ + invocation: 'setup:auto (standalone)', + user: process.env.USER ?? 'unknown', + cwd: process.cwd(), + branch: branch || 'unknown', + commit: commit || 'unknown', + }); +} + main().catch((err) => { p.log.error(err instanceof Error ? err.message : String(err)); p.cancel('Setup aborted.'); diff --git a/setup/logs.ts b/setup/logs.ts new file mode 100644 index 0000000..127f969 --- /dev/null +++ b/setup/logs.ts @@ -0,0 +1,130 @@ +/** + * Three-level setup logging primitives. See docs/setup-flow.md for the + * contract and design rationale. + * + * Level 1: clack UI in setup/auto.ts (not here) + * Level 2: logs/setup.log — structured, append-only progression log + * Level 3: logs/setup-steps/NN-name.log — raw stdout+stderr per step + * + * Usage from auto.ts: + * + * import * as setupLog from './logs.js'; + * + * const rawLog = setupLog.stepRawLog('container'); + * const { ok, durationMs, terminal } = + * await spawnIntoRawLog('...', rawLog); + * setupLog.step('container', ok ? 'success' : 'failed', durationMs, + * { RUNTIME: 'docker', BUILD_OK: terminal.fields.BUILD_OK }, + * rawLog); + * + * nanoclaw.sh emits the bootstrap entry directly via a bash helper so + * the format stays consistent without needing IPC between bash and tsx. + */ +import fs from 'fs'; +import path from 'path'; + +const LOGS_DIR = 'logs'; +const STEPS_DIR = path.join(LOGS_DIR, 'setup-steps'); +const PROGRESS_LOG = path.join(LOGS_DIR, 'setup.log'); + +export const progressLogPath = PROGRESS_LOG; +export const stepsDir = STEPS_DIR; + +/** Wipe prior logs and write a header. Called once per fresh run (by nanoclaw.sh or as a fallback by auto.ts if invoked standalone). */ +export function reset(meta: Record): void { + if (fs.existsSync(STEPS_DIR)) { + fs.rmSync(STEPS_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(STEPS_DIR, { recursive: true }); + if (fs.existsSync(PROGRESS_LOG)) fs.unlinkSync(PROGRESS_LOG); + header(meta); +} + +/** Append a run-start header to the progression log. Idempotent: creates the file if missing. */ +export function header(meta: Record): void { + fs.mkdirSync(LOGS_DIR, { recursive: true }); + const ts = new Date().toISOString(); + const lines = [`## ${ts} · setup:auto started`]; + for (const [k, v] of Object.entries(meta)) { + lines.push(` ${k}: ${v}`); + } + lines.push(''); + fs.appendFileSync(PROGRESS_LOG, lines.join('\n') + '\n'); +} + +/** Append one step entry to the progression log. */ +export function step( + name: string, + status: 'success' | 'skipped' | 'failed' | 'aborted' | 'interactive', + durationMs: number, + fields: Record, + rawRel?: string, +): void { + fs.mkdirSync(LOGS_DIR, { recursive: true }); + const ts = new Date().toISOString(); + const dur = formatDuration(durationMs); + const lines = [`=== [${ts}] ${name} [${dur}] → ${status} ===`]; + for (const [k, v] of Object.entries(fields)) { + if (v === undefined || v === null || v === '') continue; + lines.push(` ${k.toLowerCase()}: ${String(v)}`); + } + if (rawRel) lines.push(` raw: ${rawRel}`); + lines.push(''); + fs.appendFileSync(PROGRESS_LOG, lines.join('\n') + '\n'); +} + +/** A user answered a prompt. Logs as its own entry because the setup path depends on it. */ +export function userInput(key: string, value: string): void { + fs.mkdirSync(LOGS_DIR, { recursive: true }); + const ts = new Date().toISOString(); + fs.appendFileSync( + PROGRESS_LOG, + `=== [${ts}] user-input → ${key} ===\n value: ${value}\n\n`, + ); +} + +/** Append the success footer. */ +export function complete(totalMs: number): void { + const ts = new Date().toISOString(); + fs.appendFileSync( + PROGRESS_LOG, + `## ${ts} · completed (total ${formatDurationTotal(totalMs)})\n`, + ); +} + +/** Append the failure footer. Keep error short — full context lives in the failing step's raw log. */ +export function abort(stepName: string, error: string): void { + const ts = new Date().toISOString(); + fs.appendFileSync( + PROGRESS_LOG, + `## ${ts} · aborted at ${stepName} (${error})\n`, + ); +} + +/** + * Return the next raw-log path for a given step name. Numbering is derived + * from the count of existing NN-*.log files in STEPS_DIR, so bootstrap's + * pre-existing 01-bootstrap.log (written by nanoclaw.sh before this module + * is loaded) counts toward the sequence. + */ +export function stepRawLog(name: string): string { + fs.mkdirSync(STEPS_DIR, { recursive: true }); + const existing = fs + .readdirSync(STEPS_DIR) + .filter((n) => /^\d+-.+\.log$/.test(n)); + const nextIdx = existing.length + 1; + const num = String(nextIdx).padStart(2, '0'); + const safeName = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase(); + return path.join(STEPS_DIR, `${num}-${safeName}.log`); +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function formatDurationTotal(ms: number): string { + const mins = Math.floor(ms / 60000); + const secs = Math.round((ms % 60000) / 1000); + return mins > 0 ? `${mins}m${secs}s` : `${secs}s`; +} From 416fe018550cbb3f83826cc1a3d67dc165920720 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 02:13:22 +0300 Subject: [PATCH 22/24] refactor(setup): drop CLI-bonus wiring from init-first-agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit init-first-agent used to double-wire the CLI channel to every new DM agent as a convenience for `pnpm run chat`, gated by --no-cli-bonus. With the /new-setup-2 flow gone and a dedicated scratch CLI agent created earlier in setup:auto, that bonus just stomps on CLI routing the user already set up. Remove the CLI_CHANNEL/CLI_PLATFORM_ID constants, ensureCliMessagingGroup, the --no-cli-bonus flag, and the cli-bonus wiring block. Pass the paired user's identity through to the welcome delivery so the sender resolver sees the real owner (e.g. telegram:) instead of cli:local. Extend the CLI channel's admin-transport payload to accept optional sender/senderId overrides — falls back to the old cli/cli:local defaults when omitted, so existing callers are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/init-first-agent.ts | 74 +++++++++++-------------------------- src/channels/cli.ts | 6 ++- 2 files changed, 26 insertions(+), 54 deletions(-) diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index 29ca6d4..c634851 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -1,16 +1,13 @@ /** * Init the first (or Nth) NanoClaw v2 agent for a DM channel. * - * Wires a real DM channel (discord, telegram, etc.) to a new agent group - * (and the local CLI channel as a convenience bonus), then hands a welcome - * message to the running service via its CLI socket. The service routes - * that message into the DM session, which wakes the container synchronously — - * the agent processes the welcome and DMs the operator through the normal - * delivery path. + * Wires a real DM channel (discord, telegram, etc.) to a new agent group, + * then hands a welcome message to the running service via the CLI socket + * (admin transport). The service routes that message into the DM session, + * which wakes the container synchronously — the agent processes the welcome + * and DMs the operator through the normal delivery path. * - * For the CLI-only scratch agent used during `/new-setup`, see - * `scripts/init-cli-agent.ts` — that's a distinct flow and doesn't run - * through here. + * CLI channel wiring is handled separately by `scripts/init-cli-agent.ts`. * * Creates/reuses: user, owner grant (if none), agent group + filesystem, * messaging group(s), wiring. @@ -27,8 +24,7 @@ * --platform-id discord:@me:1491573333382523708 \ * --display-name "Gavriel" \ * [--agent-name "Andy"] \ - * [--welcome "System instruction: ..."] \ - * [--no-cli-bonus] + * [--welcome "System instruction: ..."] * * For direct-addressable channels (telegram, whatsapp, etc.), --platform-id * is typically the same as the handle in --user-id, with the channel prefix. @@ -53,7 +49,6 @@ import { initGroupFilesystem } from '../src/group-init.js'; import type { AgentGroup, MessagingGroup } from '../src/types.js'; interface Args { - noCliBonus: boolean; channel: string; userId: string; platformId: string; @@ -65,18 +60,12 @@ interface Args { const DEFAULT_WELCOME = 'System instruction: run /welcome to introduce yourself to the user on this new channel.'; -const CLI_CHANNEL = 'cli'; -const CLI_PLATFORM_ID = 'local'; - function parseArgs(argv: string[]): Args { - const out: Partial = { noCliBonus: false }; + const out: Partial = {}; for (let i = 0; i < argv.length; i++) { const key = argv[i]; const val = argv[i + 1]; switch (key) { - case '--no-cli-bonus': - out.noCliBonus = true; - break; case '--channel': out.channel = (val ?? '').toLowerCase(); i++; @@ -115,7 +104,6 @@ function parseArgs(argv: string[]): Args { } return { - noCliBonus: out.noCliBonus ?? false, channel: out.channel!, userId: out.userId!, platformId: out.platformId!, @@ -137,24 +125,6 @@ function generateId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -function ensureCliMessagingGroup(now: string): MessagingGroup { - let cliMg = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID); - if (cliMg) return cliMg; - - cliMg = { - id: generateId('mg'), - channel_type: CLI_CHANNEL, - platform_id: CLI_PLATFORM_ID, - name: 'Local CLI', - is_group: 0, - unknown_sender_policy: 'public', - created_at: now, - }; - createMessagingGroup(cliMg); - console.log(`Created CLI messaging group: ${cliMg.id}`); - return cliMg; -} - function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: string): void { const existing = getMessagingGroupAgentByPair(mg.id, ag.id); if (existing) { @@ -252,29 +222,23 @@ async function main(): Promise { console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`); } - // 4. Wire DM (auto-creates companion agent_destinations row) and, - // unless suppressed, also wire the CLI channel so `pnpm run chat` works - // against the new agent immediately. `/new-setup-2` sets --no-cli-bonus - // so the scratch CLI agent from `/new-setup` keeps owning CLI routing. + // 4. Wire DM messaging group to the agent. wireIfMissing(dmMg, ag, now, 'dm'); - if (!args.noCliBonus) { - const cliMg = ensureCliMessagingGroup(now); - wireIfMissing(cliMg, ag, now, 'cli-bonus'); - } // 5. Welcome delivery over the CLI socket. Router picks up the line, // writes the message into the DM session's inbound.db, and wakes the - // container synchronously — no sweep wait. - await sendWelcomeViaCliSocket(dmMg, args.welcome); + // container synchronously — no sweep wait. The paired user's identity is + // passed so the sender resolver sees the real owner, not cli:local. + await sendWelcomeViaCliSocket(dmMg, args.welcome, { + senderId: userId, + sender: args.displayName, + }); console.log(''); console.log('Init complete.'); console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`); console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`); console.log(` channel: ${args.channel} ${dmMg.platform_id}`); - if (!args.noCliBonus) { - console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``); - } console.log(''); console.log('Welcome DM queued — the agent will greet you shortly.'); } @@ -288,7 +252,11 @@ async function main(): Promise { * Throws if the socket isn't reachable — this script requires the service * to be running. */ -async function sendWelcomeViaCliSocket(dmMg: MessagingGroup, welcome: string): Promise { +async function sendWelcomeViaCliSocket( + dmMg: MessagingGroup, + welcome: string, + identity: { senderId: string; sender: string }, +): Promise { const sockPath = path.join(DATA_DIR, 'cli.sock'); await new Promise((resolve, reject) => { @@ -318,6 +286,8 @@ async function sendWelcomeViaCliSocket(dmMg: MessagingGroup, welcome: string): P const payload = JSON.stringify({ text: welcome, + senderId: identity.senderId, + sender: identity.sender, to: { channelType: dmMg.channel_type, platformId: dmMg.platform_id, diff --git a/src/channels/cli.ts b/src/channels/cli.ts index b738186..ad78bea 100644 --- a/src/channels/cli.ts +++ b/src/channels/cli.ts @@ -183,6 +183,8 @@ function createAdapter(): ChannelAdapter { text?: unknown; to?: unknown; reply_to?: unknown; + sender?: unknown; + senderId?: unknown; }; try { payload = JSON.parse(line); @@ -209,8 +211,8 @@ function createAdapter(): ChannelAdapter { timestamp: new Date().toISOString(), content: JSON.stringify({ text: payload.text, - sender: 'cli', - senderId: `cli:${PLATFORM_ID}`, + sender: typeof payload.sender === 'string' ? payload.sender : 'cli', + senderId: typeof payload.senderId === 'string' ? payload.senderId : `cli:${PLATFORM_ID}`, }), }, replyTo: replyTo ?? undefined, From 9b7d4d50e409fb240f6a1a80c2bfe5d43330ae9e Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 02:26:50 +0300 Subject: [PATCH 23/24] refactor(setup): split auto.ts into runner + theme + telegram channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auto.ts had grown to 923 lines with ~10 interleaved responsibilities. Split into three focused modules, keeping auto.ts as a pure step sequencer: - setup/lib/runner.ts (325 lines) — spawn + stream-parse + spinner-wrap primitives. Exports: spawnStep, spawnQuiet, runQuietStep, runQuietChild, runUnderSpinner (internal), StatusStream, types (Fields, Block, StepResult, SpinnerLabels, QuietChildResult), writeStepEntry, summariseTerminalFields, dumpTranscriptOnFailure, fail(), ensureAnswer(). - setup/lib/theme.ts (39 lines) — brand palette (brand, brandBold, brandChip) with USE_ANSI / TRUECOLOR gating, so both auto.ts and channel flows can render the NanoClaw cyan without duplicating the detection. - setup/channels/telegram.ts (277 lines) — runTelegramChannel(displayName) owns the full flow: BotFather instructions, token paste + validation (via getMe), install script, pair-telegram streaming UI (code card + attempt checkpoints), agent-name prompt, init-first-agent wiring. auto.ts drops to 376 lines. main() reads as a clean sequence of `if (!skip.has(X)) await Xstep(...)` blocks. fail() now takes the step name explicitly — no module-level failingStep state. Every call site is grep-friendly and self-contained (fail('container', msg, hint)). Typechecks clean. Smoke-tested end-to-end: intro, mounts step, progression log, and outro all render the same as before the split. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 813 ++++++------------------------------- setup/channels/telegram.ts | 277 +++++++++++++ setup/lib/runner.ts | 325 +++++++++++++++ setup/lib/theme.ts | 39 ++ 4 files changed, 774 insertions(+), 680 deletions(-) create mode 100644 setup/channels/telegram.ts create mode 100644 setup/lib/runner.ts create mode 100644 setup/lib/theme.ts diff --git a/setup/auto.ts b/setup/auto.ts index c16b6e5..bb23650 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -1,621 +1,37 @@ /** - * 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. + * Non-interactive setup driver — the step sequencer for `pnpm run setup:auto`. * - * Prerequisite: `bash setup.sh` has run (Node >= 20, pnpm install, native - * module check). This driver picks up from there. + * Responsibility: orchestrate the sequence of steps end-to-end and route + * between them. The runner, spawning, status parsing, spinner, abort, and + * prompt primitives live in `setup/lib/runner.ts`; theming in + * `setup/lib/theme.ts`; Telegram's full flow in `setup/channels/telegram.ts`. * * 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_AGENT_NAME messaging-channel agent name (consumed by the + * channel flow). 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 ` 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. + * Timezone defaults to the host system's TZ. Run + * pnpm exec tsx setup/index.ts --step timezone -- --tz + * later if autodetect is wrong. */ import { spawn, spawnSync } from 'child_process'; -import fs from 'fs'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { runTelegramChannel } from './channels/telegram.js'; import * as setupLog from './logs.js'; +import { ensureAnswer, fail, runQuietStep } from './lib/runner.js'; +import { brandBold, brandChip } from './lib/theme.js'; const CLI_AGENT_NAME = 'Terminal Agent'; -const DEFAULT_AGENT_NAME = 'Nano'; - const RUN_START = Date.now(); -let failingStep = 'setup'; - -/** - * 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; -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. Output is tee'd to the provided - * raw log file (level 3) and parsed for status blocks (level 2 summary). - * The onBlock callback fires per status block as they close so the UI can - * react mid-stream. - */ -function spawnStep( - stepName: string, - extra: string[], - onBlock: (block: Block) => void, - rawLogPath: string, -): Promise { - 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); - const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); - raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`); - - child.stdout.on('data', (chunk: Buffer) => { - stream.write(chunk.toString('utf-8')); - raw.write(chunk); - }); - child.stderr.on('data', (chunk: Buffer) => { - stream.transcript += chunk.toString('utf-8'); - raw.write(chunk); - }); - - child.on('close', (code) => { - raw.end(); - // 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. Teed to a per-step raw log + progression entry at the end. */ -async function runQuietStep( - stepName: string, - labels: SpinnerLabels, - extra: string[] = [], -): Promise { - failingStep = stepName; - const rawLog = setupLog.stepRawLog(stepName); - const start = Date.now(); - const result = await runUnderSpinner(labels, () => - spawnStep(stepName, extra, () => {}, rawLog), - ); - const durationMs = Date.now() - start; - writeStepEntry(stepName, result, durationMs, rawLog); - return { ...result, rawLog, durationMs }; -} - -/** Run an arbitrary child under a spinner. Same raw-log + progression treatment as runQuietStep. */ -async function runQuietChild( - logName: string, - cmd: string, - args: string[], - labels: SpinnerLabels, - opts?: { - /** Extra fields to merge into the progression entry (on top of any status-block fields). */ - extraFields?: Record; - /** Environment overrides to pass to the child process. */ - env?: NodeJS.ProcessEnv; - }, -): Promise<{ - ok: boolean; - exitCode: number; - transcript: string; - terminal: Block | null; - rawLog: string; - durationMs: number; -}> { - failingStep = logName; - const rawLog = setupLog.stepRawLog(logName); - const start = Date.now(); - const result = await runUnderSpinner(labels, () => - spawnQuiet(cmd, args, rawLog, opts?.env), - ); - const durationMs = Date.now() - start; - - const blockFields = summariseTerminalFields(result.terminal); - const fields = { ...blockFields, ...(opts?.extraFields ?? {}) }; - const rawStatus = result.terminal?.fields.STATUS; - const status: 'success' | 'skipped' | 'failed' = !result.ok - ? 'failed' - : rawStatus === 'skipped' - ? 'skipped' - : 'success'; - setupLog.step(logName, status, durationMs, fields, rawLog); - return { ...result, rawLog, durationMs }; -} - -/** Turn a step's terminal-block fields into a concise progression-log entry. */ -function writeStepEntry( - stepName: string, - result: StepResult, - durationMs: number, - rawLog: string, -): void { - const rawStatus = result.terminal?.fields.STATUS; - const logStatus: 'success' | 'skipped' | 'failed' = !result.ok - ? 'failed' - : rawStatus === 'skipped' - ? 'skipped' - : 'success'; - const fields = summariseTerminalFields(result.terminal); - setupLog.step(stepName, logStatus, durationMs, fields, rawLog); -} - -/** Strip STATUS + LOG (redundant) and any oversize values from the terminal block's fields. */ -function summariseTerminalFields(block: Block | null): Record { - if (!block) return {}; - const out: Record = {}; - for (const [k, v] of Object.entries(block.fields)) { - if (k === 'STATUS' || k === 'LOG') continue; - if (v.length > 120) continue; // keep it skimmable; full value lives in the raw log - out[k] = v; - } - return out; -} - -async function runUnderSpinner< - T extends { ok: boolean; transcript: string; terminal?: Block | null }, ->( - labels: SpinnerLabels, - work: () => Promise, -): Promise { - 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[], - rawLogPath: string, - envOverride?: NodeJS.ProcessEnv, -): Promise<{ ok: boolean; exitCode: number; transcript: string; terminal: Block | null; blocks: Block[] }> { - return new Promise((resolve) => { - const child = spawn(cmd, args, { - stdio: ['ignore', 'pipe', 'pipe'], - env: envOverride ? { ...process.env, ...envOverride } : process.env, - }); - let transcript = ''; - const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); - raw.write(`# ${[cmd, ...args].join(' ')} — ${new Date().toISOString()}\n\n`); - const blocks: Block[] = []; - const stream = new StatusStream((b) => blocks.push(b)); - child.stdout.on('data', (c: Buffer) => { - const s = c.toString('utf-8'); - transcript += s; - stream.write(s); - raw.write(c); - }); - child.stderr.on('data', (c: Buffer) => { - transcript += c.toString('utf-8'); - raw.write(c); - }); - child.on('close', (code) => { - raw.end(); - const terminal = - [...blocks].reverse().find((b) => b.fields.STATUS) ?? null; - resolve({ ok: code === 0, exitCode: code ?? 1, transcript, terminal, blocks }); - }); - }); -} - -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 { - setupLog.abort(failingStep, msg); - p.log.error(msg); - if (hint) p.log.message(k.dim(hint)); - p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); - p.cancel('Setup aborted.'); - process.exit(1); -} - -function ensureAnswer(value: T | symbol): T { - if (p.isCancel(value)) { - setupLog.abort(failingStep, 'user-cancelled'); - 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 { - 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 { - failingStep = 'pair-telegram'; - const rawLog = setupLog.stepRawLog('pair-telegram'); - const start = Date.now(); - 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); - } - } - }, - rawLog, - ); - const durationMs = Date.now() - start; - - // 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); - } - - writeStepEntry('pair-telegram', result, durationMs, rawLog); - return { ...result, rawLog, durationMs }; -} - -async function askDisplayName(fallback: string): Promise { - const answer = ensureAnswer( - await p.text({ - message: 'What should your agents call you?', - placeholder: fallback, - defaultValue: fallback, - }), - ); - const value = (answer as string).trim() || fallback; - setupLog.userInput('display_name', value); - return value; -} - -async function askAgentName(fallback: string): Promise { - const answer = ensureAnswer( - await p.text({ - message: 'What should your messaging agent be called?', - placeholder: fallback, - defaultValue: fallback, - }), - ); - const value = (answer as string).trim() || fallback; - setupLog.userInput('agent_name', value); - return value; -} - -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' }, - ], - }), - ); - setupLog.userInput('channel_choice', String(choice)); - return choice as 'telegram' | 'skip'; -} - -async function collectTelegramToken(): Promise { - p.note( - [ - '1. Open Telegram and message @BotFather', - '2. Send: /newbot', - '3. Follow the prompts (name + username ending in "bot")', - '4. Copy the token it gives you (format: :)', - '', - k.dim('Optional, but recommended for groups:'), - k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'), - ].join('\n'), - 'Create a Telegram bot', - ); - - const answer = ensureAnswer( - await p.password({ - message: 'Paste your bot token', - validate: (v) => { - if (!v || !v.trim()) return 'Token is required'; - if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { - return 'Format looks wrong — expected :'; - } - return undefined; - }, - }), - ); - const token = (answer as string).trim(); - setupLog.userInput( - 'telegram_token', - `${token.slice(0, 12)}…${token.slice(-4)}`, - ); - return token; -} - -async function validateTelegramToken(token: string): Promise { - failingStep = 'telegram-validate'; - const s = p.spinner(); - const start = Date.now(); - s.start('Validating token with Telegram…'); - try { - const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); - const data = (await res.json()) as { - ok?: boolean; - result?: { username?: string; id?: number }; - description?: string; - }; - const elapsed = Math.round((Date.now() - start) / 1000); - if (data.ok && data.result?.username) { - const username = data.result.username; - s.stop(`Bot is @${username}. ${k.dim(`(${elapsed}s)`)}`); - setupLog.step( - 'telegram-validate', - 'success', - Date.now() - start, - { BOT_USERNAME: username, BOT_ID: data.result.id ?? '' }, - ); - return username; - } - const reason = data.description ?? 'token rejected by Telegram'; - s.stop(`Telegram rejected the token: ${reason}`, 1); - setupLog.step( - 'telegram-validate', - 'failed', - Date.now() - start, - { ERROR: reason }, - ); - fail( - 'Telegram rejected the token.', - 'Double-check the token (copy it again from @BotFather) and retry.', - ); - } catch (err) { - const elapsed = Math.round((Date.now() - start) / 1000); - s.stop(`Could not reach Telegram. ${k.dim(`(${elapsed}s)`)}`, 1); - const message = err instanceof Error ? err.message : String(err); - setupLog.step('telegram-validate', 'failed', Date.now() - start, { - ERROR: message, - }); - fail( - 'Telegram API unreachable.', - 'Check your network connection and retry.', - ); - } -} - -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 { printIntro(); @@ -629,11 +45,11 @@ async function main(): Promise { ); if (!skip.has('environment')) { - const res = await runQuietStep( - 'environment', - { running: 'Checking environment…', done: 'Environment OK.' }, - ); - if (!res.ok) fail('Environment check failed.'); + const res = await runQuietStep('environment', { + running: 'Checking environment…', + done: 'Environment OK.', + }); + if (!res.ok) fail('environment', 'Environment check failed.'); } if (!skip.has('container')) { @@ -646,17 +62,20 @@ async function main(): Promise { const err = res.terminal?.fields.ERROR; if (err === 'runtime_not_available') { fail( + 'container', '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( + 'container', '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', 'Container build/test failed.', 'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', ); @@ -673,11 +92,13 @@ async function main(): Promise { const err = res.terminal?.fields.ERROR; if (err === 'onecli_not_on_path_after_install') { fail( + 'onecli', 'OneCLI installed but not on PATH.', 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', ); } fail( + 'onecli', `OneCLI install failed (${err ?? 'unknown'}).`, 'Check that curl + a writable ~/.local/bin are available, then retry.', ); @@ -685,7 +106,6 @@ async function main(): Promise { } if (!skip.has('auth')) { - failingStep = 'auth'; if (anthropicSecretExists()) { p.log.success('OneCLI already has an Anthropic secret — skipping.'); setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); @@ -702,22 +122,29 @@ async function main(): Promise { if (code !== 0) { setupLog.step('auth', 'failed', durationMs, { EXIT_CODE: code }); fail( + 'auth', 'Anthropic credential registration failed or was aborted.', 'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', ); } - setupLog.step('auth', 'interactive', durationMs, { METHOD: 'register-claude-token.sh' }); + setupLog.step('auth', 'interactive', durationMs, { + METHOD: 'register-claude-token.sh', + }); 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.'); + const res = await runQuietStep( + 'mounts', + { + running: 'Writing mount allowlist…', + done: 'Mount allowlist in place.', + skipped: 'Mount allowlist already configured.', + }, + ['--empty'], + ); + if (!res.ok) fail('mounts', 'Mount allowlist step failed.'); } if (!skip.has('service')) { @@ -727,6 +154,7 @@ async function main(): Promise { }); if (!res.ok) { fail( + 'service', 'Service install failed.', 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', ); @@ -761,6 +189,7 @@ async function main(): Promise { ); if (!res.ok) { fail( + 'cli-agent', 'CLI agent wiring failed.', `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, ); @@ -770,75 +199,7 @@ async function main(): Promise { if (!skip.has('channel')) { const choice = await askChannelChoice(); if (choice === 'telegram') { - const token = await collectTelegramToken(); - const botUsername = await validateTelegramToken(token); - - const install = await runQuietChild( - 'telegram-install', - 'bash', - ['setup/add-telegram.sh'], - { - running: `Installing Telegram adapter and wiring @${botUsername}…`, - done: `Telegram adapter ready.`, - }, - { - env: { TELEGRAM_BOT_TOKEN: token }, - extraFields: { BOT_USERNAME: botUsername }, - }, - ); - if (!install.ok) { - fail( - 'Telegram install failed.', - 'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.', - ); - } - - 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( - 'init-first-agent', - '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.`, - }, - { - extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId }, - }, - ); - 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}"\`.`, - ); - } + await runTelegramChannel(displayName!); } else { p.log.info('No messaging channel wired — you can add one later with `/add-`.'); } @@ -883,6 +244,98 @@ async function main(): Promise { p.outro(k.green('Setup complete.')); } +// ─── prompts owned by the sequencer ──────────────────────────────────── + +async function askDisplayName(fallback: string): Promise { + const answer = ensureAnswer( + await p.text({ + message: 'What should your agents call you?', + placeholder: fallback, + defaultValue: fallback, + }), + ); + const value = (answer as string).trim() || fallback; + setupLog.userInput('display_name', value); + return value; +} + +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' }, + ], + }), + ); + setupLog.userInput('channel_choice', String(choice)); + return choice as 'telegram' | 'skip'; +} + +// ─── interactive / env helpers ───────────────────────────────────────── + +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 { + return new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: 'inherit' }); + child.on('close', (code) => resolve(code ?? 1)); + }); +} + +/** + * 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); +} + +// ─── intro + progression-log init ────────────────────────────────────── + +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 ')}`); +} + /** * Bootstrap (nanoclaw.sh) normally initializes logs/setup.log and writes * the bootstrap entry before we even boot. If someone runs `pnpm run diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts new file mode 100644 index 0000000..d3e3f89 --- /dev/null +++ b/setup/channels/telegram.ts @@ -0,0 +1,277 @@ +/** + * Telegram channel flow for setup:auto. + * + * `runTelegramChannel(displayName)` owns the full branch from the + * BotFather instructions through the welcome DM: + * + * 1. BotFather instructions (clack note) + * 2. Paste the bot token (clack password) — format-validated + * 3. getMe via the Bot API to resolve the bot's username + * 4. Install the adapter (setup/add-telegram.sh, non-interactive) + * 5. Run the pair-telegram step, rendering code events as clack notes + * 6. Ask for the messaging-agent name (defaulting to "Nano") + * 7. Wire the agent via scripts/init-first-agent.ts + * + * All output obeys the three-level contract: clack UI for the user, + * structured entries in logs/setup.log, full raw output in per-step files + * under logs/setup-steps/. See docs/setup-flow.md. + */ +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { + type Block, + type StepResult, + dumpTranscriptOnFailure, + ensureAnswer, + fail, + runQuietChild, + spawnStep, + writeStepEntry, +} from '../lib/runner.js'; +import { brandBold } from '../lib/theme.js'; + +const DEFAULT_AGENT_NAME = 'Nano'; + +export async function runTelegramChannel(displayName: string): Promise { + const token = await collectTelegramToken(); + const botUsername = await validateTelegramToken(token); + + const install = await runQuietChild( + 'telegram-install', + 'bash', + ['setup/add-telegram.sh'], + { + running: `Installing Telegram adapter and wiring @${botUsername}…`, + done: 'Telegram adapter ready.', + }, + { + env: { TELEGRAM_BOT_TOKEN: token }, + extraFields: { BOT_USERNAME: botUsername }, + }, + ); + if (!install.ok) { + fail( + 'telegram-install', + 'Telegram install failed.', + 'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.', + ); + } + + const pair = await runPairTelegram(); + if (!pair.ok) { + fail( + 'pair-telegram', + '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', + '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 = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + '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.`, + }, + { + extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId }, + }, + ); + if (!init.ok) { + fail( + 'init-first-agent', + '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}"\`.`, + ); + } +} + +async function collectTelegramToken(): Promise { + p.note( + [ + '1. Open Telegram and message @BotFather', + '2. Send: /newbot', + '3. Follow the prompts (name + username ending in "bot")', + '4. Copy the token it gives you (format: :)', + '', + k.dim('Optional, but recommended for groups:'), + k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'), + ].join('\n'), + 'Create a Telegram bot', + ); + + const answer = ensureAnswer( + await p.password({ + message: 'Paste your bot token', + validate: (v) => { + if (!v || !v.trim()) return 'Token is required'; + if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { + return 'Format looks wrong — expected :'; + } + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + setupLog.userInput( + 'telegram_token', + `${token.slice(0, 12)}…${token.slice(-4)}`, + ); + return token; +} + +async function validateTelegramToken(token: string): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start('Validating token with Telegram…'); + try { + const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); + const data = (await res.json()) as { + ok?: boolean; + result?: { username?: string; id?: number }; + description?: string; + }; + const elapsedS = Math.round((Date.now() - start) / 1000); + if (data.ok && data.result?.username) { + const username = data.result.username; + s.stop(`Bot is @${username}. ${k.dim(`(${elapsedS}s)`)}`); + setupLog.step('telegram-validate', 'success', Date.now() - start, { + BOT_USERNAME: username, + BOT_ID: data.result.id ?? '', + }); + return username; + } + const reason = data.description ?? 'token rejected by Telegram'; + s.stop(`Telegram rejected the token: ${reason}`, 1); + setupLog.step('telegram-validate', 'failed', Date.now() - start, { + ERROR: reason, + }); + fail( + 'telegram-validate', + 'Telegram rejected the token.', + 'Double-check the token (copy it again from @BotFather) and retry.', + ); + } catch (err) { + const elapsedS = Math.round((Date.now() - start) / 1000); + s.stop(`Could not reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('telegram-validate', 'failed', Date.now() - start, { + ERROR: message, + }); + fail( + 'telegram-validate', + 'Telegram API unreachable.', + 'Check your network connection and retry.', + ); + } +} + +async function runPairTelegram(): Promise< + StepResult & { rawLog: string; durationMs: number } +> { + const rawLog = setupLog.stepRawLog('pair-telegram'); + const start = Date.now(); + 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: 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); + } + } + }, + rawLog, + ); + const durationMs = Date.now() - start; + + // 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); + } + + writeStepEntry('pair-telegram', result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; +} + +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 resolveAgentName(): Promise { + const preset = process.env.NANOCLAW_AGENT_NAME?.trim(); + if (preset) { + setupLog.userInput('agent_name', preset); + return preset; + } + const answer = ensureAnswer( + await p.text({ + message: 'What should your messaging agent be called?', + placeholder: DEFAULT_AGENT_NAME, + defaultValue: DEFAULT_AGENT_NAME, + }), + ); + const value = (answer as string).trim() || DEFAULT_AGENT_NAME; + setupLog.userInput('agent_name', value); + return value; +} diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts new file mode 100644 index 0000000..59b3da6 --- /dev/null +++ b/setup/lib/runner.ts @@ -0,0 +1,325 @@ +/** + * Step runner + abort helpers for setup:auto. + * + * Responsibilities: + * - Stream-parse setup-step status blocks (`=== NANOCLAW SETUP: … ===`) + * - Spawn children with output tee'd to a per-step raw log (level 3) + * - Wrap each run in a clack spinner with live elapsed time (level 1) + * - Append a structured entry to the progression log (level 2) via + * `setup/logs.ts` when the run ends + * - Abort helpers (`fail`, `ensureAnswer`) used by step orchestrators + * + * See docs/setup-flow.md for the three-level output contract. + */ +import { spawn } from 'child_process'; +import fs from 'fs'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; + +export type Fields = Record; +export type Block = { type: string; fields: Fields }; + +export type StepResult = { + ok: boolean; + exitCode: number; + blocks: Block[]; + transcript: string; + /** The last block with a STATUS field (the terminal/result block). */ + terminal: Block | null; +}; + +export type QuietChildResult = { + ok: boolean; + exitCode: number; + transcript: string; + terminal: Block | null; + blocks: Block[]; +}; + +export type SpinnerLabels = { + running: string; + done: string; + skipped?: string; + failed?: string; +}; + +/** + * 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). + */ +export 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. Output is tee'd to the provided + * raw log file (level 3) and parsed for status blocks (level 2 summary). + * The onBlock callback fires per status block as they close so the UI can + * react mid-stream. + */ +export function spawnStep( + stepName: string, + extra: string[], + onBlock: (block: Block) => void, + rawLogPath: string, +): Promise { + 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); + const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); + raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`); + + child.stdout.on('data', (chunk: Buffer) => { + stream.write(chunk.toString('utf-8')); + raw.write(chunk); + }); + child.stderr.on('data', (chunk: Buffer) => { + stream.transcript += chunk.toString('utf-8'); + raw.write(chunk); + }); + + child.on('close', (code) => { + raw.end(); + 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, + }); + }); + }); +} + +export function spawnQuiet( + cmd: string, + args: string[], + rawLogPath: string, + envOverride?: NodeJS.ProcessEnv, +): Promise { + return new Promise((resolve) => { + const child = spawn(cmd, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: envOverride ? { ...process.env, ...envOverride } : process.env, + }); + let transcript = ''; + const raw = fs.createWriteStream(rawLogPath, { flags: 'w' }); + raw.write(`# ${[cmd, ...args].join(' ')} — ${new Date().toISOString()}\n\n`); + const blocks: Block[] = []; + const stream = new StatusStream((b) => blocks.push(b)); + child.stdout.on('data', (c: Buffer) => { + const s = c.toString('utf-8'); + transcript += s; + stream.write(s); + raw.write(c); + }); + child.stderr.on('data', (c: Buffer) => { + transcript += c.toString('utf-8'); + raw.write(c); + }); + child.on('close', (code) => { + raw.end(); + const terminal = + [...blocks].reverse().find((b) => b.fields.STATUS) ?? null; + resolve({ ok: code === 0, exitCode: code ?? 1, transcript, terminal, blocks }); + }); + }); +} + +/** Run a step under a clack spinner. Teed to a per-step raw log + progression entry at the end. */ +export async function runQuietStep( + stepName: string, + labels: SpinnerLabels, + extra: string[] = [], +): Promise { + const rawLog = setupLog.stepRawLog(stepName); + const start = Date.now(); + const result = await runUnderSpinner(labels, () => + spawnStep(stepName, extra, () => {}, rawLog), + ); + const durationMs = Date.now() - start; + writeStepEntry(stepName, result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** Run an arbitrary child under a spinner. Same raw-log + progression treatment as runQuietStep. */ +export async function runQuietChild( + logName: string, + cmd: string, + args: string[], + labels: SpinnerLabels, + opts?: { + /** Extra fields to merge into the progression entry (on top of any status-block fields). */ + extraFields?: Record; + /** Environment overrides to pass to the child process. */ + env?: NodeJS.ProcessEnv; + }, +): Promise { + const rawLog = setupLog.stepRawLog(logName); + const start = Date.now(); + const result = await runUnderSpinner(labels, () => + spawnQuiet(cmd, args, rawLog, opts?.env), + ); + const durationMs = Date.now() - start; + + const blockFields = summariseTerminalFields(result.terminal); + const fields = { ...blockFields, ...(opts?.extraFields ?? {}) }; + const rawStatus = result.terminal?.fields.STATUS; + const status: 'success' | 'skipped' | 'failed' = !result.ok + ? 'failed' + : rawStatus === 'skipped' + ? 'skipped' + : 'success'; + setupLog.step(logName, status, durationMs, fields, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** Turn a step's terminal-block fields into a concise progression-log entry. */ +export function writeStepEntry( + stepName: string, + result: StepResult, + durationMs: number, + rawLog: string, +): void { + const rawStatus = result.terminal?.fields.STATUS; + const logStatus: 'success' | 'skipped' | 'failed' = !result.ok + ? 'failed' + : rawStatus === 'skipped' + ? 'skipped' + : 'success'; + const fields = summariseTerminalFields(result.terminal); + setupLog.step(stepName, logStatus, durationMs, fields, rawLog); +} + +/** Strip STATUS + LOG (redundant) and any oversize values from the terminal block's fields. */ +export function summariseTerminalFields(block: Block | null): Record { + if (!block) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(block.fields)) { + if (k === 'STATUS' || k === 'LOG') continue; + if (v.length > 120) continue; // keep it skimmable; full value lives in the raw log + out[k] = v; + } + return out; +} + +async function runUnderSpinner< + T extends { ok: boolean; transcript: string; terminal?: Block | null }, +>( + labels: SpinnerLabels, + work: () => Promise, +): Promise { + 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; +} + +export 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(); + } +} + +/** + * Abort the setup run with a user-facing error, logging the abort to the + * progression log. Takes the step name explicitly so callers are clear + * about which step they're failing from — no hidden module state. + */ +export function fail(stepName: string, msg: string, hint?: string): never { + setupLog.abort(stepName, msg); + p.log.error(msg); + if (hint) p.log.message(k.dim(hint)); + p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/')); + p.cancel('Setup aborted.'); + process.exit(1); +} + +/** + * Unwrap a clack prompt result. If the user cancelled (Ctrl-C / Esc), exit + * gracefully. Cancel is exit 0 — it's not an abort worth logging to the + * progression log, since the operator initiated it deliberately. + */ +export function ensureAnswer(value: T | symbol): T { + if (p.isCancel(value)) { + p.cancel('Setup cancelled.'); + process.exit(0); + } + return value as T; +} diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts new file mode 100644 index 0000000..9bd18a5 --- /dev/null +++ b/setup/lib/theme.ts @@ -0,0 +1,39 @@ +/** + * NanoClaw brand palette for the terminal. + * + * Colors pulled from assets/nanoclaw-logo.png: + * brand cyan ≈ #2BB7CE — the "Claw" wordmark + mascot body + * brand navy ≈ #171B3B — the dark logo background + outlines + * + * Rendering gates: + * - No TTY (piped / redirected) → plain text, no ANSI + * - NO_COLOR set → plain text, no ANSI + * - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan) + * - Otherwise → kleur's 16-color cyan (closest fallback) + */ +import k from 'kleur'; + +const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; +const TRUECOLOR = + USE_ANSI && + (process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit'); + +export function 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); +} + +export function 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)); +} + +export function 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))); +} From 7d2081660bec24db1e2c0bf577c2028b6c03ad73 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 02:57:20 +0300 Subject: [PATCH 24/24] feat(setup): rewrite copy for first-time users + split auth flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content pass: every user-facing line is rewritten from the perspective of someone trying NanoClaw for the first time. Phase labels and devops framing are gone. Examples: "Environment OK" → "Your system looks good." "Container image ready" → "Sandbox ready." "OneCLI installed" → "OneCLI vault ready." "Anthropic credential" → "Claude account" "Mount allowlist in place" → "Access rules set." "Service installed/running" → "NanoClaw is running." "Wiring the terminal agent" → "Setting up your terminal chat…" "Setup complete" → "You're ready! Enjoy NanoClaw." Long-running steps get a one-sentence "why" that teaches a NanoClaw differentiator while the user waits: bootstrap → "NanoClaw is small and runs entirely on your machine. Yours to modify." container → "Your assistant lives in its own sandbox. It can only see what you explicitly share." onecli → "Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox." OneCLI is now named explicitly and framed as "your agent's vault" in the install step, the paste-auth save step, the subscription-auth banner, and their associated failure hints. Auth split (option b: explicit step name on fail): the auth-method choice moves from the bash menu in register-claude-token.sh into a clack select. Only the subscription path still breaks out to the interactive TTY for `claude setup-token`; paste-based OAuth tokens and API keys stay in clack via p.password() and register directly via `onecli secrets create`. register-claude-token.sh is scoped down to the subscription flow only. nanoclaw.sh: dropped the "Phase 1 / Phase 2" labels. The wordmark and subtitle now print bash-side so setup:auto skips repeating them and the flow reads as one continuous sequence. Bootstrap label is "Installing the basics" with a dim gutter-line "why" preamble. pnpm's `> nanoclaw@X setup:auto` preamble is suppressed via --silent. Em-dash pass on user-facing copy: every em-dash that functions as an em-dash in a user-visible string is replaced with period, semicolon, comma, or parens. Code comments and JSDoc are untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 77 +++++---- setup/auto.ts | 286 +++++++++++++++++++++++---------- setup/channels/telegram.ts | 78 ++++----- setup/register-claude-token.sh | 154 +++++++----------- 4 files changed, 346 insertions(+), 249 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 17df82c..e94e383 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -1,15 +1,18 @@ #!/usr/bin/env bash # -# NanoClaw — scripted end-to-end install. +# NanoClaw — end-to-end setup entry point. # -# Phase 1: bootstrap (Node + pnpm + native module verify). Runs bash-side -# since tsx isn't available until pnpm install completes. -# Phase 2: setup:auto (all remaining steps under clack). +# Runs two parts from the user's perspective as one continuous flow: +# - bash-side: install the basics (Node + pnpm + native modules) under a +# bash-rendered clack-alike spinner. Can't use setup/auto.ts here since +# tsx isn't available until pnpm install completes. +# - hand off to `pnpm run setup:auto`, which renders the rest with +# @clack/prompts. The wordmark is printed once here so setup:auto can +# skip it and the flow reads as a single sequence. # -# Both phases obey the same three-level output contract (see -# docs/setup-flow.md): +# Obeys the three-level output contract (see docs/setup-flow.md): # 1. User-facing — concise status line with elapsed time -# 2. Progression log — logs/setup.log (header + one entry per phase/step) +# 2. Progression log — logs/setup.log (header + one entry per step) # 3. Raw per-step log — logs/setup-steps/NN-name.log (full verbatim output) # # Config via env — passed through unchanged: @@ -91,6 +94,19 @@ use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; } dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; } gray() { use_ansi && printf '\033[90m%s\033[0m' "$1" || printf '%s' "$1"; } red() { use_ansi && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"; } +bold() { use_ansi && printf '\033[1m%s\033[0m' "$1" || printf '%s' "$1"; } +# brand cyan (≈ #2BB7CE) — truecolor when supported, 16-color cyan fallback. +brand_bold() { + if use_ansi; then + if [ "${COLORTERM:-}" = "truecolor" ] || [ "${COLORTERM:-}" = "24bit" ]; then + printf '\033[1;38;2;43;183;206m%s\033[0m' "$1" + else + printf '\033[1;36m%s\033[0m' "$1" + fi + else + printf '%s' "$1" + fi +} clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; } spinner_start() { printf '%s %s…' "$(gray '◒')" "$1"; } @@ -105,21 +121,20 @@ rm -f "$PROGRESS_LOG" mkdir -p "$STEPS_DIR" "$LOGS_DIR" write_header -cat <<'EOF' -═══════════════════════════════════════════════════════════════ - NanoClaw scripted setup -═══════════════════════════════════════════════════════════════ +# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1 +# and skip printing these again, so the flow stays visually continuous. +printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" +printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')" -Phase 1 · bootstrap - -EOF - -# ─── phase 1: bootstrap ───────────────────────────────────────────────── +# ─── first step: install the basics (Node + pnpm + native modules) ───── BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log" -BOOTSTRAP_LABEL="Bootstrapping Node, pnpm, native modules" +BOOTSTRAP_LABEL="Installing the basics" BOOTSTRAP_START=$(date +%s) +# One-line "why" that teaches a differentiator while the user waits. +printf '%s %s\n' "$(gray '│')" \ + "$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")" spinner_start "$BOOTSTRAP_LABEL" # Run in the background so we can tick elapsed time. Capture exit code via @@ -151,10 +166,10 @@ rm -f "$BOOTSTRAP_EXIT_FILE" BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START )) if [ "$BOOTSTRAP_RC" -eq 0 ]; then - spinner_success "Bootstrap complete" "$BOOTSTRAP_DUR" + spinner_success "Basics installed" "$BOOTSTRAP_DUR" write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" else - spinner_failure "Bootstrap failed" "$BOOTSTRAP_DUR" + spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR" write_bootstrap_entry failed "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" write_abort_entry bootstrap "exit-${BOOTSTRAP_RC}" @@ -162,23 +177,19 @@ else echo "$(dim '── last 40 lines of ')$(dim "$BOOTSTRAP_RAW")$(dim ' ──')" tail -40 "$BOOTSTRAP_RAW" echo - echo "Full raw log: $BOOTSTRAP_RAW" - echo "Progression: $PROGRESS_LOG" + echo "$(dim "Full raw log: $BOOTSTRAP_RAW")" + echo "$(dim "Progression: $PROGRESS_LOG")" exit 1 fi -echo -cat <<'EOF' -Phase 2 · setup:auto +# ─── hand off to setup:auto ──────────────────────────────────────────── -EOF - -# ─── phase 2: clack driver ────────────────────────────────────────────── - -# NANOCLAW_BOOTSTRAPPED=1 tells setup/auto.ts that the progression log has -# already been initialized (header + bootstrap entry), so it should append -# rather than wipe. +# NANOCLAW_BOOTSTRAPPED=1 tells setup/auto.ts to skip the wordmark (we +# already printed it) and to append to the progression log rather than +# wipe it. export NANOCLAW_BOOTSTRAPPED=1 -# exec so signals (Ctrl-C) propagate directly to the child. -exec pnpm run setup:auto +# --silent suppresses pnpm's `> nanoclaw@1.2.52 setup:auto / > tsx setup/auto.ts` +# preamble so the flow continues visually from "Basics installed" straight +# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly. +exec pnpm --silent run setup:auto diff --git a/setup/auto.ts b/setup/auto.ts index bb23650..a0068bb 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -27,7 +27,7 @@ import k from 'kleur'; import { runTelegramChannel } from './channels/telegram.js'; import * as setupLog from './logs.js'; -import { ensureAnswer, fail, runQuietStep } from './lib/runner.js'; +import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { brandBold, brandChip } from './lib/theme.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -46,121 +46,116 @@ async function main(): Promise { if (!skip.has('environment')) { const res = await runQuietStep('environment', { - running: 'Checking environment…', - done: 'Environment OK.', + running: 'Checking your system…', + done: 'Your system looks good.', }); - if (!res.ok) fail('environment', 'Environment check failed.'); + if (!res.ok) { + fail( + 'environment', + "Your system doesn't look quite right.", + 'See logs/setup-steps/ for details, then retry.', + ); + } } if (!skip.has('container')) { + p.log.message( + k.dim( + 'Your assistant lives in its own sandbox. It can only see what you explicitly share.', + ), + ); const res = await runQuietStep('container', { - running: 'Building the agent container image…', - done: 'Container image ready.', - failed: 'Container build failed.', + running: 'Preparing the sandbox your assistant runs in…', + done: 'Sandbox ready.', + failed: "Couldn't prepare the sandbox.", }); if (!res.ok) { const err = res.terminal?.fields.ERROR; if (err === 'runtime_not_available') { fail( 'container', - 'Docker is not available and could not be started automatically.', - 'Install Docker Desktop or start it manually, then retry.', + "Docker isn't available.", + 'Install Docker Desktop (or start it if already installed), then retry.', ); } if (err === 'docker_group_not_active') { fail( 'container', - 'Docker was just installed but your shell is not yet in the `docker` group.', + "Docker was just installed but your shell doesn't know yet.", 'Log out and back in (or run `newgrp docker` in a new shell), then retry.', ); } fail( 'container', - 'Container build/test failed.', - 'For stale cache: `docker builder prune -f`, then retry `pnpm run setup:auto`.', + "Couldn't build the sandbox.", + 'If Docker has a stale cache, try: `docker builder prune -f`, then retry.', ); } maybeReexecUnderSg(); } if (!skip.has('onecli')) { + p.log.message( + k.dim( + 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', + ), + ); const res = await runQuietStep('onecli', { - running: 'Installing OneCLI credential vault…', - done: 'OneCLI installed.', + running: "Setting up OneCLI, your agent's vault…", + done: 'OneCLI vault ready.', }); if (!res.ok) { const err = res.terminal?.fields.ERROR; if (err === 'onecli_not_on_path_after_install') { fail( 'onecli', - 'OneCLI installed but not on PATH.', + 'OneCLI was installed but your shell needs to refresh to see it.', 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', ); } fail( 'onecli', - `OneCLI install failed (${err ?? 'unknown'}).`, - 'Check that curl + a writable ~/.local/bin are available, then retry.', + `Couldn't set up OneCLI (${err ?? 'unknown error'}).`, + 'Make sure curl is installed and ~/.local/bin is writable, then retry.', ); } } if (!skip.has('auth')) { - if (anthropicSecretExists()) { - p.log.success('OneCLI already has an Anthropic secret — skipping.'); - setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); - } 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 start = Date.now(); - const code = await runInheritScript('bash', ['setup/register-claude-token.sh']); - const durationMs = Date.now() - start; - console.log(); - if (code !== 0) { - setupLog.step('auth', 'failed', durationMs, { EXIT_CODE: code }); - fail( - 'auth', - 'Anthropic credential registration failed or was aborted.', - 'Re-run `bash setup/register-claude-token.sh` or handle via `/setup` §4.', - ); - } - setupLog.step('auth', 'interactive', durationMs, { - METHOD: 'register-claude-token.sh', - }); - p.log.success('Anthropic credential registered with OneCLI.'); - } + await runAuthStep(); } if (!skip.has('mounts')) { const res = await runQuietStep( 'mounts', { - running: 'Writing mount allowlist…', - done: 'Mount allowlist in place.', - skipped: 'Mount allowlist already configured.', + running: "Setting your assistant's access rules…", + done: 'Access rules set.', + skipped: 'Access rules already set.', }, ['--empty'], ); - if (!res.ok) fail('mounts', 'Mount allowlist step failed.'); + if (!res.ok) { + fail('mounts', "Couldn't write access rules."); + } } if (!skip.has('service')) { const res = await runQuietStep('service', { - running: 'Installing the background service…', - done: 'Service installed and running.', + running: 'Starting NanoClaw in the background…', + done: 'NanoClaw is running.', }); if (!res.ok) { fail( 'service', - 'Service install failed.', - 'Check logs/nanoclaw.error.log, or run `/setup` to iterate interactively.', + "Couldn't start NanoClaw.", + 'See logs/nanoclaw.error.log for details.', ); } if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') { - p.log.warn('Docker group stale in systemd session.'); + p.log.warn( + "NanoClaw's permissions need a tweak before it can reach Docker.", + ); p.log.message( k.dim( ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + @@ -182,16 +177,16 @@ async function main(): Promise { const res = await runQuietStep( 'cli-agent', { - running: 'Wiring the terminal agent…', - done: 'Terminal agent wired (try `pnpm run chat hi`).', + running: 'Setting up your terminal chat…', + done: 'Terminal chat ready. Try `pnpm run chat hi`.', }, ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], ); if (!res.ok) { fail( 'cli-agent', - 'CLI agent wiring failed.', - `Re-run \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\` to fix.`, + "Couldn't set up the terminal chat.", + `You can retry later with \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\`.`, ); } } @@ -201,47 +196,165 @@ async function main(): Promise { if (choice === 'telegram') { await runTelegramChannel(displayName!); } else { - p.log.info('No messaging channel wired — you can add one later with `/add-`.'); + p.log.info( + "No messaging app for now. You can add one later (like Telegram, Slack, or Discord).", + ); } } if (!skip.has('verify')) { const res = await runQuietStep('verify', { - running: 'Verifying the install…', - done: 'Install verified.', - failed: 'Verification found issues.', + running: 'Making sure everything works together…', + done: "Everything's connected.", + failed: 'A few things still need your attention.', }); 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`.'); + notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.'); } const agentPing = res.terminal?.fields.AGENT_PING; if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') { notes.push( - `• CLI agent did not reply (status: ${agentPing}). ` + - 'Check `logs/nanoclaw.log` and `groups/*/logs/container-*.log`, then try `pnpm run chat hi`.', + "• Your assistant didn't reply to a test message. " + + 'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', ); } if (!res.terminal?.fields.CONFIGURED_CHANNELS) { - notes.push('• Optional: add a messaging channel — `/add-discord`, `/add-slack`, `/add-telegram`, …'); + notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.'); } if (notes.length > 0) { - p.note(notes.join('\n'), 'What’s left'); + p.note(notes.join('\n'), "What's left"); } - p.outro(k.yellow('Scripted steps done — some pieces still need you.')); + p.outro(k.yellow('Almost there. A few things still need your attention.')); 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'); + const rows: [string, string][] = [ + ['Chat in the terminal:', 'pnpm run chat hi'], + ["See what's happening:", 'tail -f logs/nanoclaw.log'], + ['Open Claude Code:', 'claude'], + ]; + const labelWidth = Math.max(...rows.map(([l]) => l.length)); + const nextSteps = rows + .map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`) + .join('\n'); + p.note(nextSteps, 'Try these'); setupLog.complete(Date.now() - RUN_START); - p.outro(k.green('Setup complete.')); + p.outro(k.green("You're ready! Enjoy NanoClaw.")); +} + +// ─── auth step (select → branch) ──────────────────────────────────────── + +async function runAuthStep(): Promise { + if (anthropicSecretExists()) { + p.log.success('Your Claude account is already connected.'); + setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); + return; + } + + const method = ensureAnswer( + await p.select({ + message: 'How would you like to connect to Claude?', + options: [ + { + value: 'subscription', + label: 'Sign in with my Claude subscription', + hint: 'recommended if you have Pro or Max', + }, + { + value: 'oauth', + label: 'Paste an OAuth token I already have', + hint: 'sk-ant-oat…', + }, + { + value: 'api', + label: 'Paste an Anthropic API key', + hint: 'pay-per-use via console.anthropic.com', + }, + ], + }), + ) as 'subscription' | 'oauth' | 'api'; + setupLog.userInput('auth_method', method); + + if (method === 'subscription') { + await runSubscriptionAuth(); + } else { + await runPasteAuth(method); + } +} + +async function runSubscriptionAuth(): Promise { + p.log.step("Opening the Claude sign-in flow…"); + console.log( + k.dim(' (a browser will open for sign-in; this part is interactive)'), + ); + console.log(); + const start = Date.now(); + const code = await runInheritScript('bash', [ + 'setup/register-claude-token.sh', + ]); + const durationMs = Date.now() - start; + console.log(); + if (code !== 0) { + setupLog.step('auth', 'failed', durationMs, { + EXIT_CODE: code, + METHOD: 'subscription', + }); + fail( + 'auth', + "Couldn't complete the Claude sign-in.", + 'Re-run setup and try again, or choose a paste option instead.', + ); + } + setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' }); + p.log.success('Claude account connected.'); +} + +async function runPasteAuth(method: 'oauth' | 'api'): Promise { + const label = method === 'oauth' ? 'OAuth token' : 'API key'; + const prefix = method === 'oauth' ? 'sk-ant-oat' : 'sk-ant-api'; + + const answer = ensureAnswer( + await p.password({ + message: `Paste your ${label}`, + validate: (v) => { + if (!v || !v.trim()) return 'Required'; + if (!v.trim().startsWith(prefix)) { + return `Should start with ${prefix}…`; + } + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + + const res = await runQuietChild( + 'auth', + 'onecli', + [ + 'secrets', 'create', + '--name', 'Anthropic', + '--type', 'anthropic', + '--value', token, + '--host-pattern', 'api.anthropic.com', + ], + { + running: `Saving your ${label} to your OneCLI vault…`, + done: 'Claude account connected.', + }, + { + extraFields: { METHOD: method }, + }, + ); + if (!res.ok) { + fail( + 'auth', + `Couldn't save your ${label} to the vault.`, + 'Make sure OneCLI is running (`onecli version`), then retry.', + ); + } } // ─── prompts owned by the sequencer ──────────────────────────────────── @@ -249,7 +362,7 @@ async function main(): Promise { async function askDisplayName(fallback: string): Promise { const answer = ensureAnswer( await p.text({ - message: 'What should your agents call you?', + message: 'What should your assistant call you?', placeholder: fallback, defaultValue: fallback, }), @@ -262,10 +375,10 @@ async function askDisplayName(fallback: string): Promise { async function askChannelChoice(): Promise<'telegram' | 'skip'> { const choice = ensureAnswer( await p.select({ - message: 'Connect a messaging app so you can chat from your phone?', + message: 'Want to chat with your assistant from your phone?', options: [ - { value: 'telegram', label: 'Telegram', hint: 'recommended' }, - { value: 'skip', label: 'Skip — use the CLI only' }, + { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, + { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, ], }), ); @@ -311,7 +424,7 @@ function maybeReexecUnderSg(): void { 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`.'); + 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' }, @@ -323,17 +436,28 @@ function maybeReexecUnderSg(): void { function printIntro(): void { const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; + const isBootstrapped = process.env.NANOCLAW_BOOTSTRAPPED === '1'; const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; if (isReexec) { - p.intro(`${brandChip(' setup:auto ')} ${wordmark} ${k.dim('· resuming under docker group')}`); + p.intro( + `${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`, + ); + return; + } + + // When we were called via nanoclaw.sh, the wordmark + subtitle were + // already printed in bash. Just open the clack gutter with a short, + // neutral intro so the flow continues without duplication. + if (isBootstrapped) { + p.intro(k.dim("Let's get you set up.")); return; } console.log(); console.log(` ${wordmark}`); - console.log(` ${k.dim('end-to-end scripted setup of your personal assistant')}`); - p.intro(`${brandChip(' setup:auto ')}`); + console.log(` ${k.dim('Setting up your personal AI assistant')}`); + p.intro(k.dim("Let's get you set up.")); } /** diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index d3e3f89..348cd05 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -43,8 +43,8 @@ export async function runTelegramChannel(displayName: string): Promise { 'bash', ['setup/add-telegram.sh'], { - running: `Installing Telegram adapter and wiring @${botUsername}…`, - done: 'Telegram adapter ready.', + running: `Connecting Telegram to @${botUsername}…`, + done: 'Telegram connected.', }, { env: { TELEGRAM_BOT_TOKEN: token }, @@ -54,8 +54,8 @@ export async function runTelegramChannel(displayName: string): Promise { if (!install.ok) { fail( 'telegram-install', - 'Telegram install failed.', - 'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.', + "Couldn't connect Telegram.", + 'See logs/setup-steps/ for details, then retry setup.', ); } @@ -63,8 +63,8 @@ export async function runTelegramChannel(displayName: string): Promise { if (!pair.ok) { fail( 'pair-telegram', - 'Telegram pairing failed.', - 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.', + "Couldn't pair with Telegram.", + 'Re-run setup to try again.', ); } @@ -73,8 +73,8 @@ export async function runTelegramChannel(displayName: string): Promise { if (!platformId || !pairedUserId) { fail( 'pair-telegram', - '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.', + 'Pairing completed but came back incomplete.', + 'Re-run setup to try again.', ); } @@ -92,8 +92,8 @@ export async function runTelegramChannel(displayName: string): Promise { '--agent-name', agentName, ], { - running: `Wiring ${agentName} to your Telegram chat…`, - done: `${agentName} is wired — welcome DM incoming.`, + running: `Connecting ${agentName} to your Telegram chat…`, + done: `${agentName} is ready. Check Telegram for a welcome message.`, }, { extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId }, @@ -102,8 +102,8 @@ export async function runTelegramChannel(displayName: string): Promise { if (!init.ok) { fail( 'init-first-agent', - '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}"\`.`, + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/manage-channels`.', ); } } @@ -111,24 +111,26 @@ export async function runTelegramChannel(displayName: string): Promise { async function collectTelegramToken(): Promise { p.note( [ - '1. Open Telegram and message @BotFather', - '2. Send: /newbot', - '3. Follow the prompts (name + username ending in "bot")', - '4. Copy the token it gives you (format: :)', + "Your assistant talks to you through a Telegram bot you create.", + "Here's how:", '', - k.dim('Optional, but recommended for groups:'), - k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'), + ' 1. Open Telegram and message @BotFather', + ' 2. Send /newbot and follow the prompts', + ' 3. Copy the token it gives you (it looks like :)', + '', + k.dim('Planning to add your assistant to group chats? In @BotFather:'), + k.dim(' /mybots → your bot → Bot Settings → Group Privacy → OFF'), ].join('\n'), - 'Create a Telegram bot', + 'Set up your Telegram bot', ); const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', validate: (v) => { - if (!v || !v.trim()) return 'Token is required'; + if (!v || !v.trim()) return "Token is required"; if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { - return 'Format looks wrong — expected :'; + return "That doesn't look right. It should be :"; } return undefined; }, @@ -145,7 +147,7 @@ async function collectTelegramToken(): Promise { async function validateTelegramToken(token: string): Promise { const s = p.spinner(); const start = Date.now(); - s.start('Validating token with Telegram…'); + s.start('Checking your bot token…'); try { const res = await fetch(`https://api.telegram.org/bot${token}/getMe`); const data = (await res.json()) as { @@ -156,7 +158,7 @@ async function validateTelegramToken(token: string): Promise { const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.result?.username) { const username = data.result.username; - s.stop(`Bot is @${username}. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`); setupLog.step('telegram-validate', 'success', Date.now() - start, { BOT_USERNAME: username, BOT_ID: data.result.id ?? '', @@ -164,26 +166,26 @@ async function validateTelegramToken(token: string): Promise { return username; } const reason = data.description ?? 'token rejected by Telegram'; - s.stop(`Telegram rejected the token: ${reason}`, 1); + s.stop(`Telegram didn't accept that token: ${reason}`, 1); setupLog.step('telegram-validate', 'failed', Date.now() - start, { ERROR: reason, }); fail( 'telegram-validate', - 'Telegram rejected the token.', - 'Double-check the token (copy it again from @BotFather) and retry.', + "Telegram didn't accept that token.", + 'Copy the token again from @BotFather and try setup once more.', ); } catch (err) { const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Could not reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('telegram-validate', 'failed', Date.now() - start, { ERROR: message, }); fail( 'telegram-validate', - 'Telegram API unreachable.', - 'Check your network connection and retry.', + "Couldn't reach Telegram.", + 'Check your internet connection and retry setup.', ); } } @@ -194,7 +196,7 @@ async function runPairTelegram(): Promise< const rawLog = setupLog.stepRawLog('pair-telegram'); const start = Date.now(); const s = p.spinner(); - s.start('Creating pairing code…'); + s.start('Generating a secret code for your bot…'); let spinnerActive = true; const stopSpinner = (msg: string, code?: number) => { @@ -211,15 +213,15 @@ async function runPairTelegram(): Promise< if (block.type === 'PAIR_TELEGRAM_CODE') { const reason = block.fields.REASON ?? 'initial'; if (reason === 'initial') { - stopSpinner('Pairing code ready.'); + stopSpinner('Your secret code is ready.'); } else { - stopSpinner('Previous code invalidated. New code below.'); + stopSpinner("Old code expired. Here's a fresh one."); } - p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code'); - s.start('Waiting for the code from Telegram…'); + p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); + s.start('Waiting for you to send the code from Telegram…'); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { - stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`); + stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`); s.start('Waiting for the correct code…'); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM') { @@ -238,7 +240,7 @@ async function runPairTelegram(): Promise< // sure we don't leave the spinner running. if (spinnerActive) { stopSpinner( - result.ok ? 'Done.' : 'Pairing exited unexpectedly.', + result.ok ? 'Done.' : 'Pairing ended unexpectedly.', result.ok ? 0 : 1, ); if (!result.ok) dumpTranscriptOnFailure(result.transcript); @@ -254,7 +256,7 @@ function formatCodeCard(code: string): string { '', ` ${brandBold(spaced)}`, '', - k.dim(' Send these digits from Telegram to your bot.'), + k.dim(' Send this code to your bot from Telegram.'), ].join('\n'); } @@ -266,7 +268,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your messaging agent be called?', + message: 'What should your assistant be called?', placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh index 8bcab73..e0707bf 100755 --- a/setup/register-claude-token.sh +++ b/setup/register-claude-token.sh @@ -1,128 +1,88 @@ #!/usr/bin/env bash set -euo pipefail -# Prefer bash 4+ (for `read -e -i` readline preload). macOS ships 3.2 in -# /bin/bash, but Homebrew users usually have 5.x first on PATH. The readline -# preload is optional — on 3.x we fall back to a plain confirmation prompt. - -# Register an Anthropic credential with OneCLI. Three paths: -# 1) Claude subscription — run `claude setup-token` (browser sign-in) -# and capture the resulting OAuth token. -# 2) Paste an existing sk-ant-oat… OAuth token you already have. -# 3) Paste an Anthropic API key (sk-ant-api…). +# Register a Claude subscription OAuth token with OneCLI — the *only* auth +# path that needs a TTY break in the flow. Paste-based paths (existing +# OAuth token / API key) are handled in-process by setup/auto.ts using +# clack prompts, then onecli secrets create is invoked directly from TS. +# +# Flow: +# 1. Run `claude setup-token` under a PTY (via script(1)) so the browser +# OAuth dance works and its token is captured into a tempfile. +# 2. Regex the sk-ant-oat…AA token out of the ANSI-stripped capture. +# 3. Register it with OneCLI. # # Env overrides: # SECRET_NAME OneCLI secret name (default: Anthropic) # HOST_PATTERN OneCLI host pattern (default: api.anthropic.com) +# Prefer bash 4+ (for `read -e -i` readline preload). macOS ships 3.2 in +# /bin/bash, but Homebrew users usually have 5.x first on PATH. The +# readline preload is optional — on 3.x we fall back to a plain prompt. + SECRET_NAME="${SECRET_NAME:-Anthropic}" HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}" command -v onecli >/dev/null \ || { echo "onecli not found. Install it first (see /setup §4)." >&2; exit 1; } +command -v claude >/dev/null \ + || { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; } +command -v script >/dev/null \ + || { echo "script(1) is required for PTY capture." >&2; exit 1; } -TOKEN="" +tmpfile=$(mktemp -t claude-setup-token.XXXXXX) +trap 'rm -f "$tmpfile"' EXIT -capture_via_claude_setup_token() { - command -v claude >/dev/null \ - || { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; } - command -v script >/dev/null \ - || { echo "script(1) is required for PTY capture." >&2; exit 1; } +cat <<'EOF' +A browser window will open for you to sign in with your Claude account. +When you finish, we'll save the token to your OneCLI vault automatically. - local tmpfile - tmpfile=$(mktemp -t claude-setup-token.XXXXXX) - trap 'rm -f "$tmpfile"' RETURN - - cat <<'EOF' -A browser window will open for sign-in. Token is captured automatically. -Press Enter to run, or edit the command first. +Press Enter to continue, or edit the command first. EOF - local cmd="claude setup-token" - if [[ ${BASH_VERSINFO[0]:-0} -ge 4 ]]; then - # bash 4+: pre-fill the readline buffer so Enter literally submits. - read -r -e -i "$cmd" -p "$ " cmd /dev/null | grep -q util-linux; then - script -q -c "$cmd" "$tmpfile" - else - # BSD script: command is argv after the file, so let it word-split. - # shellcheck disable=SC2086 - script -q "$tmpfile" $cmd - fi +# `script` arg order differs between BSD (macOS) and util-linux. +if script --version 2>/dev/null | grep -q util-linux; then + script -q -c "$cmd" "$tmpfile" +else + # BSD script: command is argv after the file, so let it word-split. + # shellcheck disable=SC2086 + script -q "$tmpfile" $cmd +fi - # Strip ANSI codes + newlines (TTY wraps the token mid-string), then match - # the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255. - TOKEN=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \ - | tr -d '\n\r' \ - | perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \ - | tail -1 || true) +# Strip ANSI codes + newlines (TTY wraps the token mid-string), then match +# the sk-ant-oat…AA token. perl because BSD grep caps {n,m} at 255. +token=$(sed $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' "$tmpfile" \ + | tr -d '\n\r' \ + | perl -ne 'print "$1\n" while /(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g' \ + | tail -1 || true) - if [[ -z "$TOKEN" ]]; then - local keep - keep=$(mktemp -t claude-setup-token-log.XXXXXX) - cp "$tmpfile" "$keep" - echo >&2 - echo "No sk-ant-oat…AA token found. Raw log: $keep" >&2 - exit 1 - fi -} - -prompt_for_pasted() { - local prefix="$1" # "oat" or "api" - local value - echo - echo "Paste your sk-ant-${prefix}… credential and press Enter." - echo "Nothing will appear on the screen as you paste — that's intentional." - echo "Paste once, then just press Enter to submit." - read -r -s -p "> " value &2 - exit 1 - fi - if [[ ! "$value" =~ ^sk-ant-${prefix} ]]; then - echo "Value does not start with sk-ant-${prefix}. Aborting." >&2 - exit 1 - fi - TOKEN="$value" -} - -cat <&2; exit 1 ;; -esac +if [ -z "$token" ]; then + keep=$(mktemp -t claude-setup-token-log.XXXXXX) + cp "$tmpfile" "$keep" + echo >&2 + echo "No sk-ant-oat…AA token found. Raw log: $keep" >&2 + exit 1 +fi echo -echo "Got token: ${TOKEN:0:16}…${TOKEN: -4}" -echo "Registering with OneCLI as '${SECRET_NAME}' (host pattern: ${HOST_PATTERN})…" +echo "Got token: ${token:0:16}…${token: -4}" +echo "Saving it to your OneCLI vault as '${SECRET_NAME}' (host: ${HOST_PATTERN})…" onecli secrets create \ --name "$SECRET_NAME" \ --type anthropic \ - --value "$TOKEN" \ + --value "$token" \ --host-pattern "$HOST_PATTERN" echo "Done."