fix(new-setup): run probe before pnpm is installed

Port probe to zero-dep plain ESM (setup/probe.mjs) so /new-setup can
inject dynamic context on a fresh machine where pnpm/node_modules
don't yet exist. Skill falls back to a STATUS: unavailable block if
Node itself isn't on PATH, and the flow treats that as "run every
step from 1" (each step is idempotent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Koshkoshinsk
2026-04-19 11:03:49 +00:00
parent f6ddd20636
commit b3e8b2e047
3 changed files with 122 additions and 80 deletions

View File

@@ -1,7 +1,7 @@
--- ---
name: new-setup 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. 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 # 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 <name>` and emits a structured status block Claude parses to decide what to do next. Each step is invoked as `pnpm exec tsx setup/index.ts --step <name>` 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 ## 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 ## 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. 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 ### 1. Node bootstrap
Always runs — probe can't report on this since it lives below the Node layer. Always runs — probe can't report on this since it lives below the Node layer.

View File

@@ -19,7 +19,6 @@ const STEPS: Record<
onecli: () => import('./onecli.js'), onecli: () => import('./onecli.js'),
auth: () => import('./auth.js'), auth: () => import('./auth.js'),
'cli-agent': () => import('./cli-agent.js'), 'cli-agent': () => import('./cli-agent.js'),
probe: () => import('./probe.js'),
}; };
async function main(): Promise<void> { async function main(): Promise<void> {

View File

@@ -1,40 +1,82 @@
#!/usr/bin/env node
/** /**
* Step: probe Single upfront parallel scan for /new-setup's dynamic context * Setup step: probe Single upfront parallel scan for /new-setup's dynamic
* injection. Rendered into the SKILL.md prompt via `!`pnpm exec tsx ... probe`` * context injection. Rendered into the SKILL.md prompt via
* so Claude sees the current system state before generating its first response. * `!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. * 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 * Each step keeps its own checks; probe tells the skill which steps to skip.
* steps to bother calling.
* *
* Keep this step fast (<2s total). All probes swallow their own errors and * Plain ESM JS (zero deps) by design: this runs BEFORE setup.sh has installed
* report a neutral state rather than failing the whole scan. * 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 { execFileSync, execSync } from 'node:child_process';
import fs from 'fs'; import fs from 'node:fs';
import os from 'os'; import os from 'node:os';
import path from 'path'; import path from 'node: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 LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
const PROBE_TIMEOUT_MS = 2000; const PROBE_TIMEOUT_MS = 2000;
const HEALTH_TIMEOUT_MS = 2000; const HEALTH_TIMEOUT_MS = 2000;
const AGENT_IMAGE = 'nanoclaw-agent:latest'; const AGENT_IMAGE = 'nanoclaw-agent:latest';
const DATA_DIR = path.resolve(process.cwd(), 'data');
function childEnv(): NodeJS.ProcessEnv { function childEnv() {
const parts = [LOCAL_BIN]; const parts = [LOCAL_BIN];
if (process.env.PATH) parts.push(process.env.PATH); if (process.env.PATH) parts.push(process.env.PATH);
return { ...process.env, PATH: parts.join(path.delimiter) }; 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'); const envFile = path.join(process.cwd(), '.env');
if (!fs.existsSync(envFile)) return null; if (!fs.existsSync(envFile)) return null;
const content = fs.readFileSync(envFile, 'utf-8'); const content = fs.readFileSync(envFile, 'utf-8');
@@ -43,10 +85,7 @@ function readEnvVar(name: string): string | null {
return m[1].trim().replace(/^["']|["']$/g, ''); return m[1].trim().replace(/^["']|["']$/g, '');
} }
function probeDocker(): { function probeDocker() {
status: 'running' | 'installed_not_running' | 'not_found';
imagePresent: boolean;
} {
if (!commandExists('docker')) return { status: 'not_found', imagePresent: false }; if (!commandExists('docker')) return { status: 'not_found', imagePresent: false };
try { try {
execSync('docker info', { stdio: 'ignore', timeout: PROBE_TIMEOUT_MS }); execSync('docker info', { stdio: 'ignore', timeout: PROBE_TIMEOUT_MS });
@@ -66,7 +105,7 @@ function probeDocker(): {
return { status: 'running', imagePresent }; return { status: 'running', imagePresent };
} }
function probeOnecliUrl(): string | null { function probeOnecliUrl() {
const fromEnv = readEnvVar('ONECLI_URL'); const fromEnv = readEnvVar('ONECLI_URL');
if (fromEnv) return fromEnv; if (fromEnv) return fromEnv;
try { try {
@@ -76,7 +115,7 @@ function probeOnecliUrl(): string | null {
stdio: ['ignore', 'pipe', 'ignore'], stdio: ['ignore', 'pipe', 'ignore'],
timeout: PROBE_TIMEOUT_MS, timeout: PROBE_TIMEOUT_MS,
}).trim(); }).trim();
const parsed = JSON.parse(out) as { value?: unknown }; const parsed = JSON.parse(out);
if (typeof parsed.value === 'string' && parsed.value) return parsed.value; if (typeof parsed.value === 'string' && parsed.value) return parsed.value;
} catch { } catch {
// onecli not installed or config not set // onecli not installed or config not set
@@ -84,9 +123,7 @@ function probeOnecliUrl(): string | null {
return null; return null;
} }
async function probeOnecliStatus( async function probeOnecliStatus(url) {
url: string | null,
): Promise<'healthy' | 'installed_not_healthy' | 'not_found'> {
const installed = const installed =
commandExists('onecli') || fs.existsSync(path.join(LOCAL_BIN, 'onecli')); commandExists('onecli') || fs.existsSync(path.join(LOCAL_BIN, 'onecli'));
if (!installed) return 'not_found'; if (!installed) return 'not_found';
@@ -102,7 +139,7 @@ async function probeOnecliStatus(
} }
} }
function probeAnthropicSecret(): boolean { function probeAnthropicSecret() {
try { try {
const out = execFileSync('onecli', ['secrets', 'list'], { const out = execFileSync('onecli', ['secrets', 'list'], {
encoding: 'utf-8', encoding: 'utf-8',
@@ -110,14 +147,14 @@ function probeAnthropicSecret(): boolean {
stdio: ['ignore', 'pipe', 'ignore'], stdio: ['ignore', 'pipe', 'ignore'],
timeout: PROBE_TIMEOUT_MS, timeout: PROBE_TIMEOUT_MS,
}); });
const parsed = JSON.parse(out) as { data?: Array<{ type: string }> }; const parsed = JSON.parse(out);
return !!parsed.data?.some((s) => s.type === 'anthropic'); return !!(parsed.data && parsed.data.some((s) => s.type === 'anthropic'));
} catch { } catch {
return false; return false;
} }
} }
function probeServiceStatus(): 'running' | 'stopped' | 'not_configured' { function probeServiceStatus() {
const platform = getPlatform(); const platform = getPlatform();
if (platform === 'macos') { if (platform === 'macos') {
try { try {
@@ -127,7 +164,6 @@ function probeServiceStatus(): 'running' | 'stopped' | 'not_configured' {
}); });
const line = out.split('\n').find((l) => l.includes('com.nanoclaw')); const line = out.split('\n').find((l) => l.includes('com.nanoclaw'));
if (!line) return 'not_configured'; if (!line) return 'not_configured';
// Format: "PID STATUS LABEL" — PID is "-" when loaded but not running
const pid = line.trim().split(/\s+/)[0]; const pid = line.trim().split(/\s+/)[0];
return pid && pid !== '-' ? 'running' : 'stopped'; return pid && pid !== '-' ? 'running' : 'stopped';
} catch { } catch {
@@ -142,8 +178,6 @@ function probeServiceStatus(): 'running' | 'stopped' | 'not_configured' {
}); });
return 'running'; return 'running';
} catch { } catch {
// Either stopped, not-configured, or is-active returned non-zero.
// Distinguish by checking if the unit file exists at all.
try { try {
execSync('systemctl --user cat nanoclaw', { execSync('systemctl --user cat nanoclaw', {
stdio: 'ignore', stdio: 'ignore',
@@ -158,12 +192,17 @@ function probeServiceStatus(): 'running' | 'stopped' | 'not_configured' {
return 'not_configured'; return 'not_configured';
} }
function probeCliAgentWired(): boolean { async function probeCliAgentWired() {
const dbPath = path.join(DATA_DIR, 'v2.db'); const dbPath = path.join(DATA_DIR, 'v2.db');
if (!fs.existsSync(dbPath)) return false; 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 {
const mod = await import('better-sqlite3');
const Database = mod.default ?? mod;
const db = new Database(dbPath, { readonly: true });
try { try {
db = new Database(dbPath, { readonly: true });
const row = db const row = db
.prepare( .prepare(
`SELECT 1 FROM messaging_group_agents mga `SELECT 1 FROM messaging_group_agents mga
@@ -172,19 +211,17 @@ function probeCliAgentWired(): boolean {
) )
.get(); .get();
return !!row; return !!row;
} catch {
// Tables may not exist yet
return false;
} finally { } finally {
db?.close(); db.close();
}
} catch {
return false;
} }
} }
function probeInferredDisplayName(): string { function probeInferredDisplayName() {
const reject = (s: string | null | undefined): boolean => const reject = (s) => !s || !s.trim() || s.trim().toLowerCase() === 'root';
!s || !s.trim() || s.trim().toLowerCase() === 'root';
// 1. git global user name
try { try {
const name = execFileSync('git', ['config', '--global', 'user.name'], { const name = execFileSync('git', ['config', '--global', 'user.name'], {
encoding: 'utf-8', encoding: 'utf-8',
@@ -199,7 +236,6 @@ function probeInferredDisplayName(): string {
const user = process.env.USER || os.userInfo().username; const user = process.env.USER || os.userInfo().username;
const platform = getPlatform(); const platform = getPlatform();
// 2. Platform full-name from directory services
if (platform === 'macos') { if (platform === 'macos') {
try { try {
const fullName = execFileSync('id', ['-F', user], { const fullName = execFileSync('id', ['-F', user], {
@@ -228,20 +264,15 @@ function probeInferredDisplayName(): string {
} }
} }
// 3. $USER / whoami fallback
if (!reject(user)) return user; if (!reject(user)) return user;
return 'User'; return 'User';
} }
function probeTimezone(): { function probeTimezone() {
status: 'configured' | 'autodetected' | 'utc_suspicious' | 'needs_input';
envTz: string;
systemTz: string;
} {
const envTz = readEnvVar('TZ'); const envTz = readEnvVar('TZ');
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
let status: 'configured' | 'autodetected' | 'utc_suspicious' | 'needs_input'; let status;
if (envTz && isValidTimezone(envTz)) { if (envTz && isValidTimezone(envTz)) {
status = 'configured'; status = 'configured';
} else if (systemTz === 'UTC' || systemTz === 'Etc/UTC') { } else if (systemTz === 'UTC' || systemTz === 'Etc/UTC') {
@@ -259,34 +290,35 @@ function probeTimezone(): {
}; };
} }
export async function run(_args: string[]): Promise<void> { export async function run() {
const started = Date.now(); const started = Date.now();
// Resolve OS (with WSL distinguished)
const platform = getPlatform(); const platform = getPlatform();
const wsl = isWSL(); const wsl = isWSL();
const osLabel: 'macos' | 'linux' | 'wsl' | 'unknown' = const osLabel = wsl
wsl ? 'wsl' : platform === 'macos' ? 'macos' : platform === 'linux' ? 'linux' : 'unknown'; ? 'wsl'
: platform === 'macos'
? 'macos'
: platform === 'linux'
? 'linux'
: 'unknown';
const shell = process.env.SHELL || '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 docker = probeDocker();
const oneCliUrl = probeOnecliUrl(); const oneCliUrl = probeOnecliUrl();
const serviceStatus = probeServiceStatus(); const serviceStatus = probeServiceStatus();
const cliAgentWired = probeCliAgentWired();
const displayName = probeInferredDisplayName(); const displayName = probeInferredDisplayName();
const tz = probeTimezone(); const tz = probeTimezone();
// Async: health check is the only non-blocking probe. const [onecliStatus, cliAgentWired] = await Promise.all([
const onecliStatus = await probeOnecliStatus(oneCliUrl); probeOnecliStatus(oneCliUrl),
probeCliAgentWired(),
]);
// Secret check uses the CLI client and works whenever onecli is installed, const anthropicSecret =
// even if our direct HTTP health probe failed (different network paths). onecliStatus !== 'not_found' ? probeAnthropicSecret() : false;
const anthropicSecret = onecliStatus !== 'not_found' ? probeAnthropicSecret() : false;
const elapsedMs = Date.now() - started; const elapsedMs = Date.now() - started;
log.info('probe complete', { elapsedMs });
emitStatus('PROBE', { emitStatus('PROBE', {
OS: osLabel, OS: osLabel,
@@ -306,3 +338,12 @@ export async function run(_args: string[]): Promise<void> {
STATUS: 'success', STATUS: 'success',
}); });
} }
const invokedDirectly =
import.meta.url === `file://${path.resolve(process.argv[1] ?? '')}`;
if (invokedDirectly) {
run().catch((err) => {
console.error(err);
process.exit(1);
});
}