diff --git a/.claude/skills/new-setup/SKILL.md b/.claude/skills/new-setup/SKILL.md index a671fb0..6b95695 100644 --- a/.claude/skills/new-setup/SKILL.md +++ b/.claude/skills/new-setup/SKILL.md @@ -14,7 +14,7 @@ Before each step, narrate to the user in your own words what's about to happen Each step is invoked as `pnpm exec tsx setup/index.ts --step ` and emits a structured status block Claude parses to decide what to do next. -Start with a probe: a single parallel scan that snapshots every prerequisite and dependency. The rest of the flow reads this snapshot to decide what to run, skip, or ask about — no per-step re-checking. The probe is plain ESM JS (`setup/probe.mjs`) with no external deps so it can run before step 1 has installed `pnpm`/`node_modules`. +Start with a probe: a single upfront scan that snapshots every prerequisite and dependency. The rest of the flow reads this snapshot to decide what to run, skip, or ask about — no per-step re-checking. The probe is pure bash (`setup/probe.sh`) with no external deps so it runs correctly before Node has been installed. ## Current state @@ -22,9 +22,7 @@ Start with a probe: a single parallel scan that snapshots every prerequisite and ## Flow -Parse the probe block above. For each step below, consult the named probe fields and skip, ask, or run accordingly. - -If the probe reports `STATUS: unavailable` (Node isn't installed yet), ignore all `skip if …` probe conditions and run every step from 1 onward — each step has its own idempotency check, so re-running is safe. +Parse the probe block above. For each step below, consult the named probe fields and skip, ask, or run accordingly. The probe always returns a real snapshot — there is no "node not installed" fallback; `HOST_DEPS=missing` is how you know Node/pnpm haven't been bootstrapped yet. ## Ordering and parallelism @@ -40,12 +38,12 @@ One permitted parallelism: Check probe results and skip if `HOST_DEPS=ok` — Node, pnpm, `node_modules`, and `better-sqlite3`'s native binding are already in place. -If the probe reported `STATUS: unavailable` (Node isn't installed yet — probe itself couldn't run), install Node 22 **before** running `bash setup.sh`, otherwise the first bootstrap run is guaranteed to fail: +If `HOST_DEPS=missing` and `node --version` fails (Node isn't installed at all), install Node 22 **before** running `bash setup.sh`, otherwise the first bootstrap run is guaranteed to fail: - macOS: `brew install node@22` - Linux / WSL: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs` -Then run `bash setup.sh`. If the probe succeeded but `HOST_DEPS=missing`, run `bash setup.sh` directly — Node is there, deps aren't. +Then run `bash setup.sh`. If Node is already present and only `HOST_DEPS=missing`, run `bash setup.sh` directly — deps just haven't been installed yet. Parse the status block: @@ -118,8 +116,6 @@ Start the NanoClaw background service — it relays messages between the user an ### 6. First CLI agent -Check probe results and skip if `CLI_AGENT_WIRED=true`. - If step 2's container build is still running in the background, join it here before proceeding — the agent needs the image. Create the first agent and wire it to the CLI channel. Ask the user "What should I call you?" first — default the offered value to `INFERRED_DISPLAY_NAME` from the probe. diff --git a/setup/onecli.ts b/setup/onecli.ts index ddb68c6..226d302 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -106,7 +106,7 @@ function installOnecli(): { stdout: string; ok: boolean } { } async function pollHealth(url: string, timeoutMs: number): Promise { - // `/api/health` matches the path probe.mjs uses — keep them aligned. + // `/api/health` matches the path probe.sh uses — keep them aligned. const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { diff --git a/setup/probe.mjs b/setup/probe.mjs deleted file mode 100644 index 5aea0d4..0000000 --- a/setup/probe.mjs +++ /dev/null @@ -1,367 +0,0 @@ -#!/usr/bin/env node -/** - * Setup step: probe — Single upfront parallel scan for /new-setup's dynamic - * context injection. Rendered into the SKILL.md prompt via - * `!node setup/probe.mjs` so Claude sees the current system state before - * generating its first response. - * - * This is a routing aid, NOT a replacement for per-step idempotency checks. - * Each step keeps its own checks; probe tells the skill which steps to skip. - * - * Plain ESM JS (zero deps) by design: this runs BEFORE setup.sh has installed - * pnpm and node_modules, so it can only use Node built-ins. `better-sqlite3` - * is dynamic-imported so the probe degrades gracefully on fresh installs. - * - * Keep fast (<2s total). All probes swallow their own errors and report a - * neutral state rather than failing the whole scan. - */ -import { execFileSync, execSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin'); -const PROBE_TIMEOUT_MS = 2000; -const HEALTH_TIMEOUT_MS = 2000; -const AGENT_IMAGE = 'nanoclaw-agent:latest'; -const DATA_DIR = path.resolve(process.cwd(), 'data'); - -function childEnv() { - const parts = [LOCAL_BIN]; - if (process.env.PATH) parts.push(process.env.PATH); - return { ...process.env, PATH: parts.join(path.delimiter) }; -} - -function getPlatform() { - const p = os.platform(); - if (p === 'darwin') return 'macos'; - if (p === 'linux') return 'linux'; - return 'unknown'; -} - -function isWSL() { - if (os.platform() !== 'linux') return false; - try { - const release = fs.readFileSync('/proc/version', 'utf-8').toLowerCase(); - return release.includes('microsoft') || release.includes('wsl'); - } catch { - return false; - } -} - -function commandExists(name) { - try { - execSync(`command -v ${name}`, { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - -function isValidTimezone(tz) { - try { - new Intl.DateTimeFormat(undefined, { timeZone: tz }); - return true; - } catch { - return false; - } -} - -function emitStatus(step, fields) { - const lines = [`=== NANOCLAW SETUP: ${step} ===`]; - for (const [k, v] of Object.entries(fields)) { - lines.push(`${k}: ${v}`); - } - lines.push('=== END ==='); - console.log(lines.join('\n')); -} - -function readEnvVar(name) { - const envFile = path.join(process.cwd(), '.env'); - if (!fs.existsSync(envFile)) return null; - const content = fs.readFileSync(envFile, 'utf-8'); - const m = content.match(new RegExp(`^${name}=(.+)$`, 'm')); - if (!m) return null; - return m[1].trim().replace(/^["']|["']$/g, ''); -} - -function probeDocker() { - if (!commandExists('docker')) return { status: 'not_found', imagePresent: false }; - try { - execSync('docker info', { stdio: 'ignore', timeout: PROBE_TIMEOUT_MS }); - } catch { - return { status: 'installed_not_running', imagePresent: false }; - } - let imagePresent = false; - try { - execSync(`docker image inspect ${AGENT_IMAGE}`, { - stdio: 'ignore', - timeout: PROBE_TIMEOUT_MS, - }); - imagePresent = true; - } catch { - // image not built yet - } - return { status: 'running', imagePresent }; -} - -function probeOnecliUrl() { - const fromEnv = readEnvVar('ONECLI_URL'); - if (fromEnv) return fromEnv; - try { - const out = execFileSync('onecli', ['config', 'get', 'api-host'], { - encoding: 'utf-8', - env: childEnv(), - stdio: ['ignore', 'pipe', 'ignore'], - timeout: PROBE_TIMEOUT_MS, - }).trim(); - const parsed = JSON.parse(out); - if (typeof parsed.value === 'string' && parsed.value) return parsed.value; - } catch { - // onecli not installed or config not set - } - return null; -} - -async function probeOnecliStatus(url) { - const installed = - commandExists('onecli') || fs.existsSync(path.join(LOCAL_BIN, 'onecli')); - if (!installed) return 'not_found'; - if (!url) return 'installed_not_healthy'; - try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS); - const res = await fetch(`${url}/api/health`, { signal: controller.signal }); - clearTimeout(timer); - return res.ok ? 'healthy' : 'installed_not_healthy'; - } catch { - return 'installed_not_healthy'; - } -} - -function probeAnthropicSecret() { - try { - const out = execFileSync('onecli', ['secrets', 'list'], { - encoding: 'utf-8', - env: childEnv(), - stdio: ['ignore', 'pipe', 'ignore'], - timeout: PROBE_TIMEOUT_MS, - }); - const parsed = JSON.parse(out); - return !!(parsed.data && parsed.data.some((s) => s.type === 'anthropic')); - } catch { - return false; - } -} - -function probeServiceStatus() { - const platform = getPlatform(); - if (platform === 'macos') { - try { - const out = execSync('launchctl list', { - encoding: 'utf-8', - timeout: PROBE_TIMEOUT_MS, - }); - const line = out.split('\n').find((l) => l.includes('com.nanoclaw')); - if (!line) return 'not_configured'; - const pid = line.trim().split(/\s+/)[0]; - return pid && pid !== '-' ? 'running' : 'stopped'; - } catch { - return 'not_configured'; - } - } - if (platform === 'linux') { - try { - execSync('systemctl --user is-active nanoclaw', { - stdio: 'ignore', - timeout: PROBE_TIMEOUT_MS, - }); - return 'running'; - } catch { - try { - execSync('systemctl --user cat nanoclaw', { - stdio: 'ignore', - timeout: PROBE_TIMEOUT_MS, - }); - return 'stopped'; - } catch { - return 'not_configured'; - } - } - } - return 'not_configured'; -} - -async function probeCliAgentWired() { - const dbPath = path.join(DATA_DIR, 'v2.db'); - if (!fs.existsSync(dbPath)) return false; - // Dynamic-import so probe still runs before `pnpm install` has built the - // native module. On truly fresh installs `data/v2.db` can't exist anyway, - // so the short-circuit above handles that path. - try { - const mod = await import('better-sqlite3'); - const Database = mod.default ?? mod; - const db = new Database(dbPath, { readonly: true }); - try { - const row = db - .prepare( - `SELECT 1 FROM messaging_group_agents mga - JOIN messaging_groups mg ON mg.id = mga.messaging_group_id - WHERE mg.channel_type = 'cli' LIMIT 1`, - ) - .get(); - return !!row; - } finally { - db.close(); - } - } catch { - return false; - } -} - -function probeInferredDisplayName() { - const reject = (s) => !s || !s.trim() || s.trim().toLowerCase() === 'root'; - - try { - const name = execFileSync('git', ['config', '--global', 'user.name'], { - encoding: 'utf-8', - timeout: 1000, - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - if (!reject(name)) return name; - } catch { - // git missing or no config set - } - - const user = process.env.USER || os.userInfo().username; - const platform = getPlatform(); - - if (platform === 'macos') { - try { - const fullName = execFileSync('id', ['-F', user], { - encoding: 'utf-8', - timeout: 1000, - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - if (!reject(fullName)) return fullName; - } catch { - // id -F not supported - } - } else if (platform === 'linux') { - try { - const entry = execFileSync('getent', ['passwd', user], { - encoding: 'utf-8', - timeout: 1000, - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - const gecos = entry.split(':')[4]; - if (gecos) { - const fullName = gecos.split(',')[0].trim(); - if (!reject(fullName)) return fullName; - } - } catch { - // getent missing - } - } - - if (!reject(user)) return user; - return 'User'; -} - -function probeHostDeps() { - const nodeModules = path.resolve(process.cwd(), 'node_modules'); - if (!fs.existsSync(nodeModules)) return 'missing'; - // better-sqlite3's compiled native binding is the canonical proof that - // `pnpm install` ran AND the native build step succeeded. Cheaper than - // actually loading the module, and unambiguous on success. - const nativeBinding = path.join( - nodeModules, - 'better-sqlite3', - 'build', - 'Release', - 'better_sqlite3.node', - ); - return fs.existsSync(nativeBinding) ? 'ok' : 'missing'; -} - -function probeTimezone() { - const envTz = readEnvVar('TZ'); - const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; - - let status; - if (envTz && isValidTimezone(envTz)) { - status = 'configured'; - } else if (systemTz === 'UTC' || systemTz === 'Etc/UTC') { - status = 'utc_suspicious'; - } else if (systemTz && isValidTimezone(systemTz)) { - status = 'autodetected'; - } else { - status = 'needs_input'; - } - - return { - status, - envTz: envTz || 'none', - systemTz: systemTz || 'unknown', - }; -} - -export async function run() { - const started = Date.now(); - - const platform = getPlatform(); - const wsl = isWSL(); - const osLabel = wsl - ? 'wsl' - : platform === 'macos' - ? 'macos' - : platform === 'linux' - ? 'linux' - : 'unknown'; - const shell = process.env.SHELL || 'unknown'; - - const docker = probeDocker(); - const oneCliUrl = probeOnecliUrl(); - const serviceStatus = probeServiceStatus(); - const displayName = probeInferredDisplayName(); - const tz = probeTimezone(); - const hostDeps = probeHostDeps(); - - const [onecliStatus, cliAgentWired] = await Promise.all([ - probeOnecliStatus(oneCliUrl), - probeCliAgentWired(), - ]); - - const anthropicSecret = - onecliStatus !== 'not_found' ? probeAnthropicSecret() : false; - - const elapsedMs = Date.now() - started; - - emitStatus('PROBE', { - OS: osLabel, - SHELL: shell, - HOST_DEPS: hostDeps, - DOCKER: docker.status, - IMAGE_PRESENT: docker.imagePresent, - ONECLI_STATUS: onecliStatus, - ONECLI_URL: oneCliUrl || 'none', - ANTHROPIC_SECRET: anthropicSecret, - SERVICE_STATUS: serviceStatus, - CLI_AGENT_WIRED: cliAgentWired, - INFERRED_DISPLAY_NAME: displayName, - TZ_STATUS: tz.status, - TZ_ENV: tz.envTz, - TZ_SYSTEM: tz.systemTz, - ELAPSED_MS: elapsedMs, - STATUS: 'success', - }); -} - -const invokedDirectly = - import.meta.url === `file://${path.resolve(process.argv[1] ?? '')}`; -if (invokedDirectly) { - run().catch((err) => { - console.error(err); - process.exit(1); - }); -} diff --git a/setup/probe.sh b/setup/probe.sh index 8be2948..6f40fff 100755 --- a/setup/probe.sh +++ b/setup/probe.sh @@ -1,19 +1,248 @@ #!/bin/bash -# Wrapper for setup/probe.mjs so /new-setup's inline `!` block is a single -# shell command (permission-check friendly). When Node isn't installed yet, -# emit an "unavailable" status block so the skill's flow knows to skip the -# probe's skip-if conditions and run every step from 1. +# Setup step: probe — single upfront parallel-ish scan that snapshots every +# prerequisite and dependency for /new-setup's dynamic context injection. +# Rendered into the SKILL.md prompt via `!bash setup/probe.sh` so Claude sees +# the current system state before generating its first response. +# +# Pure bash by design: this runs BEFORE setup.sh has installed Node, pnpm, and +# node_modules, so it cannot rely on any Node-based tooling. Every field below +# is computed from POSIX utilities + grep/awk/curl. +# +# This is a routing aid, NOT a replacement for per-step idempotency checks. +# Each step keeps its own checks; probe tells the skill which steps to skip. +# +# Keep fast (<2s total). All probes swallow their own errors and report a +# neutral state rather than failing the whole scan. set -u +START_S=$(date +%s) + PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOCAL_BIN="$HOME/.local/bin" +AGENT_IMAGE="nanoclaw-agent:latest" -if command -v node >/dev/null 2>&1; then - exec node "$PROJECT_ROOT/setup/probe.mjs" "$@" +export PATH="$LOCAL_BIN:$PATH" + +command_exists() { command -v "$1" >/dev/null 2>&1; } + +# Best-effort 2s timeout; falls back to no timeout on macOS if `timeout` isn't +# installed (the probed commands are all expected to return fast anyway). +with_timeout() { + if command_exists timeout; then timeout 2 "$@" + elif command_exists gtimeout; then gtimeout 2 "$@" + else "$@" + fi +} + +trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +read_env_var() { + local name="$1" + local envfile="$PROJECT_ROOT/.env" + [[ -f "$envfile" ]] || return 0 + local line + line=$(grep -E "^${name}=" "$envfile" 2>/dev/null | head -n1) || return 0 + [[ -z "$line" ]] && return 0 + local val="${line#*=}" + val="${val%\"}"; val="${val#\"}" + val="${val%\'}"; val="${val#\'}" + trim "$val" +} + +probe_os() { + case "$(uname -s 2>/dev/null)" in + Darwin) echo "macos" ;; + Linux) + if [[ -r /proc/version ]] && grep -qEi "microsoft|wsl" /proc/version; then + echo "wsl" + else + echo "linux" + fi + ;; + *) echo "unknown" ;; + esac +} + +probe_host_deps() { + local node_modules="$PROJECT_ROOT/node_modules" + local native="$node_modules/better-sqlite3/build/Release/better_sqlite3.node" + # `better-sqlite3`'s compiled native binding is the canonical proof that + # `pnpm install` ran AND the native build step succeeded. + if [[ -d "$node_modules" && -f "$native" ]]; then + echo "ok" + else + echo "missing" + fi +} + +# Sets DOCKER_STATUS and IMAGE_PRESENT as globals. +probe_docker() { + DOCKER_STATUS="not_found" + IMAGE_PRESENT="false" + command_exists docker || return 0 + if ! with_timeout docker info >/dev/null 2>&1; then + DOCKER_STATUS="installed_not_running" + return 0 + fi + DOCKER_STATUS="running" + if with_timeout docker image inspect "$AGENT_IMAGE" >/dev/null 2>&1; then + IMAGE_PRESENT="true" + fi +} + +probe_onecli_url() { + local url + url=$(read_env_var ONECLI_URL) + if [[ -n "$url" ]]; then + printf '%s' "$url" + return + fi + command_exists onecli || return 0 + local out + out=$(with_timeout onecli config get api-host 2>/dev/null) || return 0 + # Minimal JSON extract: {"value":"http..."} — avoid hard dep on jq + if [[ "$out" =~ \"value\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then + printf '%s' "${BASH_REMATCH[1]}" + fi +} + +probe_onecli_status() { + local url="$1" + if ! command_exists onecli && [[ ! -x "$LOCAL_BIN/onecli" ]]; then + echo "not_found"; return + fi + if [[ -z "$url" ]]; then + echo "installed_not_healthy"; return + fi + if command_exists curl \ + && curl -fsS --max-time 2 "${url}/api/health" >/dev/null 2>&1; then + echo "healthy" + else + echo "installed_not_healthy" + fi +} + +probe_anthropic_secret() { + command_exists onecli || { echo "false"; return; } + local out + out=$(with_timeout onecli secrets list 2>/dev/null) || { echo "false"; return; } + if echo "$out" | grep -Eq '"type"[[:space:]]*:[[:space:]]*"anthropic"'; then + echo "true" + else + echo "false" + fi +} + +probe_service_status() { + local platform="$1" + case "$platform" in + macos) + command_exists launchctl || { echo "not_configured"; return; } + local line + line=$(with_timeout launchctl list 2>/dev/null | grep "com.nanoclaw") || { + echo "not_configured"; return; } + local pid + pid=$(echo "$line" | awk '{print $1}') + if [[ -n "$pid" && "$pid" != "-" ]]; then + echo "running" + else + echo "stopped" + fi + ;; + linux|wsl) + command_exists systemctl || { echo "not_configured"; return; } + if with_timeout systemctl --user is-active nanoclaw >/dev/null 2>&1; then + echo "running" + elif with_timeout systemctl --user cat nanoclaw >/dev/null 2>&1; then + echo "stopped" + else + echo "not_configured" + fi + ;; + *) + echo "not_configured" + ;; + esac +} + +probe_display_name() { + local platform="$1" + local reject_re='^(|root)$' + local name + + if command_exists git; then + name=$(trim "$(git config --global user.name 2>/dev/null)") + if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then + printf '%s' "$name"; return + fi + fi + + local user="${USER:-$(id -un 2>/dev/null)}" + + case "$platform" in + macos) + if command_exists id; then + name=$(trim "$(id -F "$user" 2>/dev/null)") + if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then + printf '%s' "$name"; return + fi + fi + ;; + linux|wsl) + if command_exists getent; then + local entry gecos + entry=$(getent passwd "$user" 2>/dev/null) + gecos=$(echo "$entry" | awk -F: '{print $5}') + name=$(trim "$(echo "$gecos" | awk -F, '{print $1}')") + if [[ -n "$name" && ! "$name" =~ $reject_re ]]; then + printf '%s' "$name"; return + fi + fi + ;; + esac + + if [[ -n "$user" && ! "$user" =~ $reject_re ]]; then + printf '%s' "$user" + else + printf 'User' + fi +} + +OS=$(probe_os) +SHELL_NAME="${SHELL:-unknown}" +HOST_DEPS=$(probe_host_deps) +probe_docker +ONECLI_URL_VAL=$(probe_onecli_url) +ONECLI_STATUS=$(probe_onecli_status "$ONECLI_URL_VAL") +if [[ "$ONECLI_STATUS" == "not_found" ]]; then + ANTHROPIC_SECRET="false" +else + ANTHROPIC_SECRET=$(probe_anthropic_secret) fi +SERVICE_STATUS=$(probe_service_status "$OS") +DISPLAY_NAME=$(probe_display_name "$OS") -cat <<'EOF' +END_S=$(date +%s) +ELAPSED_MS=$(( (END_S - START_S) * 1000 )) + +cat <