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 new file mode 100755 index 0000000..e94e383 --- /dev/null +++ b/nanoclaw.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# +# NanoClaw — end-to-end setup entry point. +# +# 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. +# +# 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 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 +# 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" + +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"; } +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"; } +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 + +# 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')" + +# ─── first step: install the basics (Node + pnpm + native modules) ───── + +BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log" +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 +# 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 "Basics installed" "$BOOTSTRAP_DUR" + write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" +else + spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR" + write_bootstrap_entry failed "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" + write_abort_entry bootstrap "exit-${BOOTSTRAP_RC}" + + echo + echo "$(dim '── last 40 lines of ')$(dim "$BOOTSTRAP_RAW")$(dim ' ──')" + tail -40 "$BOOTSTRAP_RAW" + echo + echo "$(dim "Full raw log: $BOOTSTRAP_RAW")" + echo "$(dim "Progression: $PROGRESS_LOG")" + exit 1 +fi + +# ─── hand off to setup:auto ──────────────────────────────────────────── + +# 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 + +# --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/package.json b/package.json index e2af027..536714f 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/", @@ -23,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/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/setup.sh b/setup.sh index af2c5e5..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"; } @@ -72,6 +80,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 +144,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 diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh new file mode 100755 index 0000000..5036bd4 --- /dev/null +++ b/setup/add-telegram.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# +# 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" + +# Keep in sync with .claude/skills/add-telegram/SKILL.md. +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 + ! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + 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. + log "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 + 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); + } + ' + + log "Installing ${ADAPTER_VERSION}…" + pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${ADAPTER_VERSION} failed" + exit 1 + } + + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } +else + log "Adapter files already installed — skipping install phase." +fi + +# 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 + echo "TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}" >> .env +fi + +# 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 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 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}" >&2 2>/dev/null \ + || open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \ + || true + ;; + Linux) + xdg-open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \ + || xdg-open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \ + || true + ;; + esac +fi + +log "Restarting service so the new adapter picks up the token…" +case "$(uname -s)" in + Darwin) + launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true + ;; + Linux) + systemctl --user restart nanoclaw >&2 2>/dev/null \ + || sudo systemctl restart nanoclaw >&2 2>/dev/null \ + || true + ;; +esac + +# Give the Telegram adapter a moment to finish starting before pair-telegram +# begins polling for the user's code message. +sleep 5 + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts new file mode 100644 index 0000000..a0068bb --- /dev/null +++ b/setup/auto.ts @@ -0,0 +1,500 @@ +/** + * Non-interactive setup driver — the step sequencer for `pnpm run setup:auto`. + * + * 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 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 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 * as p from '@clack/prompts'; +import k from 'kleur'; + +import { runTelegramChannel } from './channels/telegram.js'; +import * as setupLog from './logs.js'; +import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; +import { brandBold, brandChip } from './lib/theme.js'; + +const CLI_AGENT_NAME = 'Terminal Agent'; +const RUN_START = Date.now(); + +async function main(): Promise { + printIntro(); + initProgressionLog(); + + const skip = new Set( + (process.env.NANOCLAW_SKIP ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ); + + if (!skip.has('environment')) { + const res = await runQuietStep('environment', { + running: 'Checking your system…', + done: 'Your system looks good.', + }); + 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: '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 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 doesn't know yet.", + 'Log out and back in (or run `newgrp docker` in a new shell), then retry.', + ); + } + fail( + 'container', + "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: "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 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', + `Couldn't set up OneCLI (${err ?? 'unknown error'}).`, + 'Make sure curl is installed and ~/.local/bin is writable, then retry.', + ); + } + } + + if (!skip.has('auth')) { + await runAuthStep(); + } + + if (!skip.has('mounts')) { + const res = await runQuietStep( + 'mounts', + { + running: "Setting your assistant's access rules…", + done: 'Access rules set.', + skipped: 'Access rules already set.', + }, + ['--empty'], + ); + if (!res.ok) { + fail('mounts', "Couldn't write access rules."); + } + } + + if (!skip.has('service')) { + const res = await runQuietStep('service', { + running: 'Starting NanoClaw in the background…', + done: 'NanoClaw is running.', + }); + if (!res.ok) { + fail( + 'service', + "Couldn't start NanoClaw.", + 'See logs/nanoclaw.error.log for details.', + ); + } + if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') { + 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' + + ' systemctl --user restart nanoclaw', + ), + ); + } + } + + let displayName: string | undefined; + const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel'); + if (needsDisplayName) { + const fallback = process.env.USER?.trim() || 'Operator'; + const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim(); + displayName = preset || (await askDisplayName(fallback)); + } + + if (!skip.has('cli-agent')) { + const res = await runQuietStep( + 'cli-agent', + { + running: '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', + "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}"\`.`, + ); + } + } + + if (!skip.has('channel')) { + const choice = await askChannelChoice(); + if (choice === 'telegram') { + await runTelegramChannel(displayName!); + } else { + 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: '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('• 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( + "• 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('• 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.outro(k.yellow('Almost there. A few things still need your attention.')); + return; + } + } + + 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("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 ──────────────────────────────────── + +async function askDisplayName(fallback: string): Promise { + const answer = ensureAnswer( + await p.text({ + message: 'What should your assistant 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: 'Want to chat with your assistant from your phone?', + options: [ + { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, + { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, + ], + }), + ); + 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 isBootstrapped = process.env.NANOCLAW_BOOTSTRAPPED === '1'; + const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`; + + if (isReexec) { + 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('Setting up your personal AI assistant')}`); + p.intro(k.dim("Let's get you set up.")); +} + +/** + * 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.'); + process.exit(1); +}); diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts new file mode 100644 index 0000000..348cd05 --- /dev/null +++ b/setup/channels/telegram.ts @@ -0,0 +1,279 @@ +/** + * 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: `Connecting Telegram to @${botUsername}…`, + done: 'Telegram connected.', + }, + { + env: { TELEGRAM_BOT_TOKEN: token }, + extraFields: { BOT_USERNAME: botUsername }, + }, + ); + if (!install.ok) { + fail( + 'telegram-install', + "Couldn't connect Telegram.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } + + const pair = await runPairTelegram(); + if (!pair.ok) { + fail( + 'pair-telegram', + "Couldn't pair with Telegram.", + 'Re-run setup to try again.', + ); + } + + const platformId = pair.terminal?.fields.PLATFORM_ID; + const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID; + if (!platformId || !pairedUserId) { + fail( + 'pair-telegram', + 'Pairing completed but came back incomplete.', + 'Re-run setup to try again.', + ); + } + + 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: `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 }, + }, + ); + if (!init.ok) { + fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/manage-channels`.', + ); + } +} + +async function collectTelegramToken(): Promise { + p.note( + [ + "Your assistant talks to you through a Telegram bot you create.", + "Here's how:", + '', + ' 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'), + '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 (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { + return "That doesn't look right. It should be :"; + } + 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('Checking your bot token…'); + 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(`Found your bot: @${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 didn't accept that token: ${reason}`, 1); + setupLog.step('telegram-validate', 'failed', Date.now() - start, { + ERROR: reason, + }); + fail( + 'telegram-validate', + "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(`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', + "Couldn't reach Telegram.", + 'Check your internet connection and retry setup.', + ); + } +} + +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('Generating a secret code for your bot…'); + 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('Your secret code is ready.'); + } else { + stopSpinner("Old code expired. Here's a fresh one."); + } + 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(`Got "${block.fields.CANDIDATE ?? '?'}", not a 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 ended 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 this code to your bot from Telegram.'), + ].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 assistant 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/container.ts b/setup/container.ts index 3e48ecf..a2e6433 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -2,13 +2,69 @@ * 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'; import { log } from '../src/log.js'; -import { commandExists } from './platform.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 { + return dockerStatus() === 'ok'; +} + +/** + * 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 { + 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 'other'; + } + } catch (err) { + log.warn('Start command failed', { err }); + return 'other'; + } + + for (let i = 0; i < 30; i++) { + await sleep(2000); + const s = dockerStatus(); + if (s === 'ok') { + log.info('Docker is up'); + 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 'no-daemon'; +} + function parseArgs(args: string[]): { runtime: string } { // `--runtime` is still accepted for backwards compatibility with the /setup // skill, but `docker` is the only supported value. @@ -41,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, @@ -54,19 +119,41 @@ 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); + { + 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: error, + LOG: 'logs/setup.log', + }); + process.exit(2); + } } const buildCmd = 'docker build'; 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))); +} 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`; +} diff --git a/setup/pair-telegram.ts b/setup/pair-telegram.ts new file mode 100644 index 0000000..f3f9bf8 --- /dev/null +++ b/setup/pair-telegram.ts @@ -0,0 +1,116 @@ +/** + * 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. + * + * 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 + * 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}`; +} + +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); + 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) => { + emitStatus('PAIR_TELEGRAM_ATTEMPT', { + CANDIDATE: a.candidate, + }); + }, + }); + + 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); + emitStatus('PAIR_TELEGRAM_CODE', { + CODE: record.code, + REASON: 'regenerated', + }); + continue; + } + const reason = invalidated ? 'max-regenerations-exceeded' : message; + emitStatus('PAIR_TELEGRAM', { + STATUS: 'failed', + CODE: record.code, + ERROR: reason, + }); + process.exit(2); + } + } +} diff --git a/setup/register-claude-token.sh b/setup/register-claude-token.sh new file mode 100755 index 0000000..e0707bf --- /dev/null +++ b/setup/register-claude-token.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 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; } + +tmpfile=$(mktemp -t claude-setup-token.XXXXXX) +trap 'rm -f "$tmpfile"' EXIT + +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. + +Press Enter to continue, or edit the command first. + +EOF + +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 + 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 "Saving it to your OneCLI vault as '${SECRET_NAME}' (host: ${HOST_PATTERN})…" + +onecli secrets create \ + --name "$SECRET_NAME" \ + --type anthropic \ + --value "$token" \ + --host-pattern "$HOST_PATTERN" + +echo "Done." 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 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'); + }); + }); +} 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,