From 6db81554bf7d3b42df3ddd3d533385fd24b02151 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 19 Apr 2026 10:43:38 +0000 Subject: [PATCH] feat(new-setup): add probe step for dynamic context injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single upfront parallel scan the SKILL.md renders via `!`...`` so Claude sees system state before generating its first response. Each field maps to a routing decision (skip/run/ask) for a downstream step. Reports: OS, SHELL, DOCKER + IMAGE_PRESENT, ONECLI_STATUS + ONECLI_URL, ANTHROPIC_SECRET, SERVICE_STATUS, CLI_AGENT_WIRED, INFERRED_DISPLAY_NAME, TZ_STATUS + TZ_ENV + TZ_SYSTEM. Runs in ~200ms on a fully-set-up host. Not a replacement for per-step idempotency — each step keeps its own checks since probe is a snapshot and can go stale by execution time. Uses /api/health (OneCLI's actual endpoint). Anthropic secret check uses the CLI client so it works whenever onecli is installed, even if the direct HTTP health probe fails (different network paths). Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/index.ts | 1 + setup/probe.ts | 308 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 setup/probe.ts diff --git a/setup/index.ts b/setup/index.ts index 526ea7d63..baa97e332 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -19,6 +19,7 @@ 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.ts new file mode 100644 index 000000000..14d08296d --- /dev/null +++ b/setup/probe.ts @@ -0,0 +1,308 @@ +/** + * 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. + * + * 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. + * + * Keep this step 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'; + +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'; + +function childEnv(): NodeJS.ProcessEnv { + 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 { + 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(): { + status: 'running' | 'installed_not_running' | 'not_found'; + imagePresent: boolean; +} { + 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(): string | null { + 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) as { value?: unknown }; + if (typeof parsed.value === 'string' && parsed.value) return parsed.value; + } catch { + // onecli not installed or config not set + } + return null; +} + +async function probeOnecliStatus( + url: string | null, +): Promise<'healthy' | 'installed_not_healthy' | 'not_found'> { + 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(): boolean { + 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) as { data?: Array<{ type: string }> }; + return !!parsed.data?.some((s) => s.type === 'anthropic'); + } catch { + return false; + } +} + +function probeServiceStatus(): 'running' | 'stopped' | 'not_configured' { + 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'; + // Format: "PID STATUS LABEL" — PID is "-" when loaded but not running + 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 { + // 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', + timeout: PROBE_TIMEOUT_MS, + }); + return 'stopped'; + } catch { + return 'not_configured'; + } + } + } + return 'not_configured'; +} + +function probeCliAgentWired(): boolean { + const dbPath = path.join(DATA_DIR, 'v2.db'); + if (!fs.existsSync(dbPath)) return false; + let db: Database.Database | null = null; + 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; + } 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'; + + // 1. git global user name + 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(); + + // 2. Platform full-name from directory services + 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 + } + } + + // 3. $USER / whoami fallback + if (!reject(user)) return user; + return 'User'; +} + +function probeTimezone(): { + status: 'configured' | 'autodetected' | 'utc_suspicious' | 'needs_input'; + envTz: string; + systemTz: string; +} { + const envTz = readEnvVar('TZ'); + const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + + let status: 'configured' | 'autodetected' | 'utc_suspicious' | 'needs_input'; + 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(_args: string[]): Promise { + 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 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); + + // 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 elapsedMs = Date.now() - started; + log.info('probe complete', { elapsedMs }); + + emitStatus('PROBE', { + OS: osLabel, + SHELL: shell, + 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', + }); +}