mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
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:
@@ -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 <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
|
||||
|
||||
!`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.
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
STATUS: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
const invokedDirectly =
|
||||
import.meta.url === `file://${path.resolve(process.argv[1] ?? '')}`;
|
||||
if (invokedDirectly) {
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user