refactor(new-setup): rewrite probe in pure bash, drop unavailable fallback

The probe now returns a real snapshot from second zero, so every step
consults real probe fields instead of falling back to "run every step
blindly" when Node isn't installed. Also drops the redundant
CLI_AGENT_WIRED field (it gated the last step on its own end-state) and
scopes timezone out of the probe (timezone is not part of /new-setup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
exe.dev user
2026-04-19 12:40:53 +00:00
parent 5542107b9e
commit 96d7656112
4 changed files with 243 additions and 385 deletions

View File

@@ -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 <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. 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 ## Current state
@@ -22,9 +22,7 @@ Start with a probe: a single parallel scan that snapshots every prerequisite and
## Flow ## Flow
Parse the probe block above. For each step below, consult the named probe fields and skip, ask, or run accordingly. 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.
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.
## Ordering and parallelism ## 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. 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` - 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` - 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: Parse the status block:
@@ -118,8 +116,6 @@ Start the NanoClaw background service — it relays messages between the user an
### 6. First CLI agent ### 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. 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. 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.

View File

@@ -106,7 +106,7 @@ function installOnecli(): { stdout: string; ok: boolean } {
} }
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> { async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
// `/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; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {

View File

@@ -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);
});
}

View File

@@ -1,19 +1,248 @@
#!/bin/bash #!/bin/bash
# Wrapper for setup/probe.mjs so /new-setup's inline `!` block is a single # Setup step: probe — single upfront parallel-ish scan that snapshots every
# shell command (permission-check friendly). When Node isn't installed yet, # prerequisite and dependency for /new-setup's dynamic context injection.
# emit an "unavailable" status block so the skill's flow knows to skip the # Rendered into the SKILL.md prompt via `!bash setup/probe.sh` so Claude sees
# probe's skip-if conditions and run every step from 1. # 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 set -u
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" START_S=$(date +%s)
if command -v node >/dev/null 2>&1; then PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec node "$PROJECT_ROOT/setup/probe.mjs" "$@" LOCAL_BIN="$HOME/.local/bin"
AGENT_IMAGE="nanoclaw-agent:latest"
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 fi
cat <<'EOF' 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")
END_S=$(date +%s)
ELAPSED_MS=$(( (END_S - START_S) * 1000 ))
cat <<EOF
=== NANOCLAW SETUP: PROBE === === NANOCLAW SETUP: PROBE ===
STATUS: unavailable OS: ${OS}
REASON: node_not_installed SHELL: ${SHELL_NAME}
HOST_DEPS: ${HOST_DEPS}
DOCKER: ${DOCKER_STATUS}
IMAGE_PRESENT: ${IMAGE_PRESENT}
ONECLI_STATUS: ${ONECLI_STATUS}
ONECLI_URL: ${ONECLI_URL_VAL:-none}
ANTHROPIC_SECRET: ${ANTHROPIC_SECRET}
SERVICE_STATUS: ${SERVICE_STATUS}
INFERRED_DISPLAY_NAME: ${DISPLAY_NAME}
ELAPSED_MS: ${ELAPSED_MS}
STATUS: success
=== END === === END ===
EOF EOF