diff --git a/setup/auto.ts b/setup/auto.ts index 203b0bf..ea5dec3 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -115,10 +115,44 @@ async function main(): Promise { 4, ), ); - const res = await runQuietStep('onecli', { - running: "Setting up OneCLI, your agent's vault…", - done: 'OneCLI vault ready.', - }); + + // Respect an existing OneCLI install. Re-running the installer would + // rebind the listener and knock any other app using that gateway + // offline — confirm with the user before doing that. + const existing = detectExistingOnecli(); + let reuse = false; + if (existing) { + const choice = ensureAnswer( + await p.select({ + message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`, + options: [ + { + value: 'reuse', + label: 'Use the existing instance', + hint: 'recommended — keeps other apps bound to this vault working', + }, + { + value: 'fresh', + label: 'Install a fresh instance for NanoClaw', + hint: 'reinstalls onecli; other apps may need to reconnect', + }, + ], + }), + ) as 'reuse' | 'fresh'; + setupLog.userInput('onecli_choice', choice); + reuse = choice === 'reuse'; + } + + const res = await runQuietStep( + 'onecli', + { + running: reuse + ? 'Hooking up to your existing OneCLI…' + : "Setting up OneCLI, your agent's vault…", + done: 'OneCLI vault ready.', + }, + reuse ? ['--reuse'] : [], + ); if (!res.ok) { const err = res.terminal?.fields.ERROR; if (err === 'onecli_not_on_path_after_install') { @@ -691,6 +725,46 @@ function anthropicSecretExists(): boolean { } } +/** + * Probe the host for a working OneCLI install so we can offer to reuse it + * instead of re-running the installer (which rebinds the listener and breaks + * any other app already using that gateway). + */ +function detectExistingOnecli(): { version: string; apiHost: string } | null { + try { + const ver = spawnSync('onecli', ['version'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (ver.status !== 0) return null; + const version = (ver.stdout ?? '').trim(); + if (!version) return null; + + const host = spawnSync('onecli', ['config', 'get', 'api-host'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (host.status !== 0) return null; + const raw = (host.stdout ?? '').trim(); + if (!raw) return null; + + // onecli 1.3+ emits JSON by default. Older versions would print raw text. + try { + const parsed = JSON.parse(raw) as { data?: unknown; value?: unknown }; + const val = parsed.data ?? parsed.value; + if (typeof val === 'string' && val.trim()) { + return { version, apiHost: val.trim() }; + } + } catch { + // not JSON — try to extract a URL directly + } + const m = raw.match(/https?:\/\/[\w.\-]+(?::\d+)?/); + return m ? { version, apiHost: m[0] } : null; + } catch { + return null; + } +} + function runInheritScript(cmd: string, args: string[]): Promise { return new Promise((resolve) => { const child = spawn(cmd, args, { stdio: 'inherit' }); diff --git a/setup/onecli.ts b/setup/onecli.ts index c4ce83f..6be722a 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -1,13 +1,15 @@ /** * Step: onecli — Install + configure the OneCLI gateway and CLI. * - * Aggregates what the old /setup + /init-onecli skills ran as loose shell - * commands. Idempotent: skips install if `onecli` already works, and safely - * re-applies PATH, api-host, and .env updates. + * Two modes: + * (default) run the OneCLI installer, configure api-host, write .env. + * --reuse skip the installer; reuse the onecli instance already running + * on the host. Required for users who have other apps bound to + * an existing gateway, since re-running the installer rebinds + * the listener and breaks those consumers. * - * Emits ONECLI_URL so /new-setup SKILL.md can forward it downstream (e.g. as - * ${ONECLI_URL} in status messages). Polls /health to give downstream steps - * (auth, service) a ready gateway. + * Emits ONECLI_URL and polls /health so downstream steps (auth, service) + * get a ready gateway. */ import { execFileSync, execSync } from 'child_process'; import fs from 'fs'; @@ -37,6 +39,32 @@ function onecliVersion(): string | null { } } +/** + * Ask the installed onecli CLI for its configured api-host. Returns null if + * onecli isn't on PATH, errors, or has no api-host configured. + * + * Tolerates both JSON output (onecli 1.3+) and older raw-text output. + */ +export function getOnecliApiHost(): string | null { + try { + const out = execFileSync('onecli', ['config', 'get', 'api-host'], { + encoding: 'utf-8', + env: childEnv(), + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + try { + const parsed = JSON.parse(out) as { data?: unknown; value?: unknown }; + const val = parsed.data ?? parsed.value; + if (typeof val === 'string' && val.trim()) return val.trim(); + } catch { + // not JSON — fall through to URL extraction + } + return extractUrlFromOutput(out); + } catch { + return null; + } +} + function extractUrlFromOutput(output: string): string | null { const match = output.match(/https?:\/\/[\w.\-]+(?::\d+)?/); return match ? match[0] : null; @@ -106,9 +134,49 @@ async function pollHealth(url: string, timeoutMs: number): Promise { return false; } -export async function run(_args: string[]): Promise { +export async function run(args: string[]): Promise { + const reuse = args.includes('--reuse'); ensureShellProfilePath(); + if (reuse) { + // Reuse-mode: don't touch the running gateway at all. Just verify it + // exists, read its api-host, write ONECLI_URL to .env, and move on. + const version = onecliVersion(); + if (!version) { + emitStatus('ONECLI', { + INSTALLED: false, + STATUS: 'failed', + ERROR: 'onecli_not_found_for_reuse', + HINT: 'onecli not on PATH. Re-run setup and choose "install fresh".', + LOG: 'logs/setup.log', + }); + process.exit(1); + } + const url = getOnecliApiHost(); + if (!url) { + emitStatus('ONECLI', { + INSTALLED: true, + STATUS: 'failed', + ERROR: 'onecli_api_host_not_configured', + HINT: 'Existing onecli has no api-host set. Run `onecli config set api-host ` or re-run setup with install-fresh.', + LOG: 'logs/setup.log', + }); + process.exit(1); + } + writeEnvOnecliUrl(url); + log.info('Reusing existing OneCLI', { url }); + const healthy = await pollHealth(url, 5000); + emitStatus('ONECLI', { + INSTALLED: true, + REUSED: true, + ONECLI_URL: url, + HEALTHY: healthy, + STATUS: 'success', + LOG: 'logs/setup.log', + }); + return; + } + log.info('Installing OneCLI gateway and CLI'); const res = installOnecli(); if (!res.ok) {