diff --git a/.claude/skills/new-setup/SKILL.md b/.claude/skills/new-setup/SKILL.md index 4ea6ece..3643b92 100644 --- a/.claude/skills/new-setup/SKILL.md +++ b/.claude/skills/new-setup/SKILL.md @@ -1,7 +1,7 @@ --- name: new-setup description: Shortest path from zero to a working two-way agent chat, for any user regardless of technical background — ends at a running NanoClaw instance with at least one CLI-reachable agent. -allowed-tools: Bash(bash setup.sh) Bash(pnpm exec tsx setup/index.ts *) Bash(pnpm run chat *) Bash(brew install *) Bash(curl -fsSL https://get.docker.com | sh) Bash(sudo usermod -aG docker *) Bash(open -a Docker) Bash(sudo systemctl start docker) +allowed-tools: Bash(bash setup.sh) Bash(node setup/probe.mjs) Bash(pnpm exec tsx setup/index.ts *) Bash(pnpm run chat *) Bash(brew install *) Bash(curl -fsSL https://get.docker.com | sh) Bash(sudo usermod -aG docker *) Bash(open -a Docker) Bash(sudo systemctl start docker) --- # NanoClaw bare-minimum setup @@ -14,16 +14,18 @@ For each step, print a one-liner to the user explaining what it does and why it' 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. +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`. ## Current state -!`pnpm exec tsx setup/index.ts --step probe` +!`command -v node >/dev/null 2>&1 && node setup/probe.mjs || printf '=== NANOCLAW SETUP: PROBE ===\nSTATUS: unavailable\nREASON: node_not_installed\n=== END ===\n'` ## Flow Parse the probe block above. For each step below, consult the named probe fields and skip, ask, or run accordingly. Before running any step, say the quoted one-liner to the user. +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. + ### 1. Node bootstrap Always runs — probe can't report on this since it lives below the Node layer. diff --git a/setup/index.ts b/setup/index.ts index baa97e3..526ea7d 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -19,7 +19,6 @@ const STEPS: Record< onecli: () => import('./onecli.js'), auth: () => import('./auth.js'), 'cli-agent': () => import('./cli-agent.js'), - probe: () => import('./probe.js'), }; async function main(): Promise { diff --git a/setup/probe.ts b/setup/probe.mjs similarity index 62% rename from setup/probe.ts rename to setup/probe.mjs index 14d0829..a28f759 100644 --- a/setup/probe.ts +++ b/setup/probe.mjs @@ -1,40 +1,82 @@ +#!/usr/bin/env node /** - * Step: probe — Single upfront parallel scan for /new-setup's dynamic context - * injection. Rendered into the SKILL.md prompt via `!`pnpm exec tsx ... probe`` - * so Claude sees the current system state before generating its first response. + * 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 existing step keeps its own checks; probe just tells the skill which - * steps to bother calling. + * Each step keeps its own checks; probe tells the skill which steps to skip. * - * Keep this step fast (<2s total). All probes swallow their own errors and - * report a neutral state rather than failing the whole scan. + * 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 'child_process'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -import Database from 'better-sqlite3'; - -import { DATA_DIR } from '../src/config.js'; -import { log } from '../src/log.js'; -import { isValidTimezone } from '../src/timezone.js'; -import { commandExists, getPlatform, isWSL } from './platform.js'; -import { emitStatus } from './status.js'; +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(): NodeJS.ProcessEnv { +function childEnv() { const parts = [LOCAL_BIN]; if (process.env.PATH) parts.push(process.env.PATH); return { ...process.env, PATH: parts.join(path.delimiter) }; } -function readEnvVar(name: string): string | null { +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'); @@ -43,10 +85,7 @@ function readEnvVar(name: string): string | null { return m[1].trim().replace(/^["']|["']$/g, ''); } -function probeDocker(): { - status: 'running' | 'installed_not_running' | 'not_found'; - imagePresent: boolean; -} { +function probeDocker() { if (!commandExists('docker')) return { status: 'not_found', imagePresent: false }; try { execSync('docker info', { stdio: 'ignore', timeout: PROBE_TIMEOUT_MS }); @@ -66,7 +105,7 @@ function probeDocker(): { return { status: 'running', imagePresent }; } -function probeOnecliUrl(): string | null { +function probeOnecliUrl() { const fromEnv = readEnvVar('ONECLI_URL'); if (fromEnv) return fromEnv; try { @@ -76,7 +115,7 @@ function probeOnecliUrl(): string | null { stdio: ['ignore', 'pipe', 'ignore'], timeout: PROBE_TIMEOUT_MS, }).trim(); - const parsed = JSON.parse(out) as { value?: unknown }; + const parsed = JSON.parse(out); if (typeof parsed.value === 'string' && parsed.value) return parsed.value; } catch { // onecli not installed or config not set @@ -84,9 +123,7 @@ function probeOnecliUrl(): string | null { return null; } -async function probeOnecliStatus( - url: string | null, -): Promise<'healthy' | 'installed_not_healthy' | 'not_found'> { +async function probeOnecliStatus(url) { const installed = commandExists('onecli') || fs.existsSync(path.join(LOCAL_BIN, 'onecli')); if (!installed) return 'not_found'; @@ -102,7 +139,7 @@ async function probeOnecliStatus( } } -function probeAnthropicSecret(): boolean { +function probeAnthropicSecret() { try { const out = execFileSync('onecli', ['secrets', 'list'], { encoding: 'utf-8', @@ -110,14 +147,14 @@ function probeAnthropicSecret(): boolean { stdio: ['ignore', 'pipe', 'ignore'], timeout: PROBE_TIMEOUT_MS, }); - const parsed = JSON.parse(out) as { data?: Array<{ type: string }> }; - return !!parsed.data?.some((s) => s.type === 'anthropic'); + const parsed = JSON.parse(out); + return !!(parsed.data && parsed.data.some((s) => s.type === 'anthropic')); } catch { return false; } } -function probeServiceStatus(): 'running' | 'stopped' | 'not_configured' { +function probeServiceStatus() { const platform = getPlatform(); if (platform === 'macos') { try { @@ -127,7 +164,6 @@ function probeServiceStatus(): 'running' | 'stopped' | 'not_configured' { }); const line = out.split('\n').find((l) => l.includes('com.nanoclaw')); if (!line) return 'not_configured'; - // Format: "PID STATUS LABEL" — PID is "-" when loaded but not running const pid = line.trim().split(/\s+/)[0]; return pid && pid !== '-' ? 'running' : 'stopped'; } catch { @@ -142,8 +178,6 @@ function probeServiceStatus(): 'running' | 'stopped' | 'not_configured' { }); return 'running'; } catch { - // Either stopped, not-configured, or is-active returned non-zero. - // Distinguish by checking if the unit file exists at all. try { execSync('systemctl --user cat nanoclaw', { stdio: 'ignore', @@ -158,33 +192,36 @@ function probeServiceStatus(): 'running' | 'stopped' | 'not_configured' { return 'not_configured'; } -function probeCliAgentWired(): boolean { +async function probeCliAgentWired() { const dbPath = path.join(DATA_DIR, 'v2.db'); if (!fs.existsSync(dbPath)) return false; - let db: Database.Database | null = null; + // 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 { - db = new Database(dbPath, { readonly: true }); - 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; + 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 { - // Tables may not exist yet return false; - } finally { - db?.close(); } } -function probeInferredDisplayName(): string { - const reject = (s: string | null | undefined): boolean => - !s || !s.trim() || s.trim().toLowerCase() === 'root'; +function probeInferredDisplayName() { + const reject = (s) => !s || !s.trim() || s.trim().toLowerCase() === 'root'; - // 1. git global user name try { const name = execFileSync('git', ['config', '--global', 'user.name'], { encoding: 'utf-8', @@ -199,7 +236,6 @@ function probeInferredDisplayName(): string { const user = process.env.USER || os.userInfo().username; const platform = getPlatform(); - // 2. Platform full-name from directory services if (platform === 'macos') { try { const fullName = execFileSync('id', ['-F', user], { @@ -228,20 +264,15 @@ function probeInferredDisplayName(): string { } } - // 3. $USER / whoami fallback if (!reject(user)) return user; return 'User'; } -function probeTimezone(): { - status: 'configured' | 'autodetected' | 'utc_suspicious' | 'needs_input'; - envTz: string; - systemTz: string; -} { +function probeTimezone() { const envTz = readEnvVar('TZ'); const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; - let status: 'configured' | 'autodetected' | 'utc_suspicious' | 'needs_input'; + let status; if (envTz && isValidTimezone(envTz)) { status = 'configured'; } else if (systemTz === 'UTC' || systemTz === 'Etc/UTC') { @@ -259,34 +290,35 @@ function probeTimezone(): { }; } -export async function run(_args: string[]): Promise { +export async function run() { const started = Date.now(); - // Resolve OS (with WSL distinguished) const platform = getPlatform(); const wsl = isWSL(); - const osLabel: 'macos' | 'linux' | 'wsl' | 'unknown' = - wsl ? 'wsl' : platform === 'macos' ? 'macos' : platform === 'linux' ? 'linux' : 'unknown'; + const osLabel = wsl + ? 'wsl' + : platform === 'macos' + ? 'macos' + : platform === 'linux' + ? 'linux' + : 'unknown'; const shell = process.env.SHELL || 'unknown'; - // Sync probes (child_process is blocking; parallelizing provides little gain - // and complicates error handling). const docker = probeDocker(); const oneCliUrl = probeOnecliUrl(); const serviceStatus = probeServiceStatus(); - const cliAgentWired = probeCliAgentWired(); const displayName = probeInferredDisplayName(); const tz = probeTimezone(); - // Async: health check is the only non-blocking probe. - const onecliStatus = await probeOnecliStatus(oneCliUrl); + const [onecliStatus, cliAgentWired] = await Promise.all([ + probeOnecliStatus(oneCliUrl), + probeCliAgentWired(), + ]); - // Secret check uses the CLI client and works whenever onecli is installed, - // even if our direct HTTP health probe failed (different network paths). - const anthropicSecret = onecliStatus !== 'not_found' ? probeAnthropicSecret() : false; + const anthropicSecret = + onecliStatus !== 'not_found' ? probeAnthropicSecret() : false; const elapsedMs = Date.now() - started; - log.info('probe complete', { elapsedMs }); emitStatus('PROBE', { OS: osLabel, @@ -306,3 +338,12 @@ export async function run(_args: string[]): Promise { STATUS: 'success', }); } + +const invokedDirectly = + import.meta.url === `file://${path.resolve(process.argv[1] ?? '')}`; +if (invokedDirectly) { + run().catch((err) => { + console.error(err); + process.exit(1); + }); +}