From efdd05a7efefa2fa720c6ad436674a5dffa2c5d9 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 26 Apr 2026 23:39:12 +0300 Subject: [PATCH 1/4] feat(setup): advanced settings registry with remote OneCLI support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single config registry that drives both CLI flags and an opt-in advanced-settings screen, so power users can override defaults like remote OneCLI host/token or alt Anthropic endpoints without burdening the standard linear flow with extra prompts. Why: advanced configurations didn't fit cleanly into the existing sequenced setup. PR #2030 took the "add another prompt step" route for remote OneCLI; this approach instead routes those overrides through a single source of truth so adding the next knob (alt endpoint, custom host pattern, …) doesn't mean another prompt-or-skip decision. setup/lib/setup-config.ts — schema (typed entry list with surface 'flag' | 'flag+ui'), name derivation (camelCase → NANOCLAW_UPPER_SNAKE + --kebab-case), seeded with --onecli-api-host, --onecli-api-token, --anthropic-base-url, plus existing NANOCLAW_SKIP / NANOCLAW_DISPLAY_NAME as flag-only entries. setup/lib/setup-config-parse.ts — argv parser (--key value, --key=value, --no-bool, -- terminator), env reader, applyToEnv() bridge that writes resolved values back to process.env so existing step code keeps reading env vars unchanged. Also --help printer. setup/lib/setup-config-screen.ts — interactive menu loop. Entries render with current value as hint; selecting one opens the right prompt type (text / password for secrets / confirm / brightSelect for enums); "Done" returns to the main flow. setup/auto.ts — parses argv first (--help short-circuits before any render), folds env+flags into process.env, then offers a welcome menu: "Standard setup" (default) vs "Advanced". The onecli step branches on NANOCLAW_ONECLI_API_HOST: if set, skips the local-vs-fresh prompt entirely, runs pollHealth pre-flight, then calls runQuietStep with --remote-url. Token, when provided, writes through to ONECLI_API_KEY in .env. Welcome copy tightened (drops the duplicate wordmark/tagline) so the bash → clack handoff reads as one flow. setup/onecli.ts — cherries the --remote-url implementation from PR run()) and generalizes writeEnvOnecliUrl into a writeEnvVar helper so ONECLI_API_KEY follows the same upsert path. nanoclaw.sh — forwards "$@" to setup:auto so flags reach the parser; trims the redundant "Setting up your personal AI assistant" subtitle and the bootstrap teach line so the pre-clack section isn't competing with the clack intro for the same role. Token plumbing only fires in --remote-url mode; local installs are unauthenticated against localhost and don't need it. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 15 ++- setup/auto.ts | 216 ++++++++++++++++++------------- setup/lib/setup-config-parse.ts | 161 +++++++++++++++++++++++ setup/lib/setup-config-screen.ts | 127 ++++++++++++++++++ setup/lib/setup-config.ts | 130 +++++++++++++++++++ setup/onecli.ts | 20 ++- 6 files changed, 565 insertions(+), 104 deletions(-) create mode 100644 setup/lib/setup-config-parse.ts create mode 100644 setup/lib/setup-config-screen.ts create mode 100644 setup/lib/setup-config.ts diff --git a/nanoclaw.sh b/nanoclaw.sh index f8b58e7cb..058dbbf66 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -129,10 +129,10 @@ rm -f "$PROGRESS_LOG" mkdir -p "$STEPS_DIR" "$LOGS_DIR" write_header -# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1 -# and skip printing these again, so the flow stays visually continuous. -printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" -printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')" +# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing, +# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and +# skips re-printing the wordmark, keeping the flow visually continuous. +printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" # ─── pre-flight: Homebrew on macOS ───────────────────────────────────── # setup/install-node.sh and setup/install-docker.sh both require `brew` on @@ -190,7 +190,7 @@ BOOTSTRAP_START=$(date +%s) # One-line "why" that teaches a differentiator while the user waits. printf '%s %s\n' "$(gray '│')" \ - "$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")" + "$(dim "Small. Runs on your machine. Yours to modify.")" spinner_start "$BOOTSTRAP_LABEL" # Run in the background so we can tick elapsed time. Capture exit code via @@ -222,7 +222,7 @@ rm -f "$BOOTSTRAP_EXIT_FILE" BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START )) if [ "$BOOTSTRAP_RC" -eq 0 ]; then - spinner_success "Basics installed" "$BOOTSTRAP_DUR" + spinner_success "Basics ready" "$BOOTSTRAP_DUR" write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW" else spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR" @@ -259,4 +259,5 @@ fi # --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts` # preamble so the flow continues visually from "Basics installed" straight # into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly. -exec pnpm --silent run setup:auto +# `-- "$@"` forwards any flags (e.g. --onecli-api-host) to setup:auto. +exec pnpm --silent run setup:auto -- "$@" diff --git a/setup/auto.ts b/setup/auto.ts index 88ea84e90..91f9bc115 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -36,7 +36,15 @@ import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { brightSelect } from './lib/bright-select.js'; import { offerClaudeAssist } from './lib/claude-assist.js'; +import { + applyToEnv, + parseFlags, + printHelp, + readFromEnv, +} from './lib/setup-config-parse.js'; +import { runAdvancedScreen } from './lib/setup-config-screen.js'; import { runWindowedStep } from './lib/windowed-runner.js'; +import { pollHealth } from './onecli.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { pollHealth } from './onecli.js'; import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js'; @@ -52,10 +60,45 @@ const RUN_START = Date.now(); type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip'; async function main(): Promise { + // Parse CLI flags first — `--help` short-circuits before we render anything, + // and flag values get folded into process.env so existing step code reading + // NANOCLAW_* sees them unchanged. + const flagResult = parseFlags(process.argv.slice(2)); + if (flagResult.help) { + printHelp(); + process.exit(0); + } + if (flagResult.errors.length > 0) { + for (const err of flagResult.errors) console.error(`error: ${err}`); + console.error(''); + console.error('Run with --help for the full list of supported flags.'); + process.exit(1); + } + let configValues = { ...readFromEnv(), ...flagResult.values }; + applyToEnv(configValues); + printIntro(); initProgressionLog(); phEmit('auto_started'); + // Welcome menu — default path or open advanced overrides before any setup + // work begins. Default lands on standard so Enter is the happy path. + const startChoice = ensureAnswer( + await brightSelect<'default' | 'advanced'>({ + message: 'How would you like to begin?', + options: [ + { value: 'default', label: 'Standard setup' }, + { value: 'advanced', label: 'Advanced', hint: 'override defaults' }, + ], + initialValue: 'default', + }), + ) as 'default' | 'advanced'; + setupLog.userInput('start_choice', startChoice); + if (startChoice === 'advanced') { + configValues = await runAdvancedScreen(configValues); + applyToEnv(configValues); + } + const skip = new Set( (process.env.NANOCLAW_SKIP ?? '') .split(',') @@ -123,108 +166,95 @@ async function main(): Promise { ), ); - type OnecliChoice = 'reuse' | 'fresh' | 'remote'; + const remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim(); - const existing = detectExistingOnecli(); - const onecliOptions: { value: OnecliChoice; label: string; hint?: string }[] = [ - ...(existing - ? [ - { - value: 'reuse' as OnecliChoice, - label: 'Use the existing instance on the same host', - hint: 'recommended — keeps other apps bound to this vault working', - }, - ] - : []), - { - value: 'fresh', - label: 'Install a fresh instance for NanoClaw', - hint: existing ? 'reinstalls onecli; other apps may need to reconnect' : 'recommended', - }, - { - value: 'remote', - label: 'Connect to an OneCLI on another host', - hint: 'point to a remote URL', - }, - ]; - - const onecliChoice = ensureAnswer( - await brightSelect({ - message: existing - ? `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?` - : 'How would you like to set up OneCLI?', - options: onecliOptions, - }), - ) as OnecliChoice; - setupLog.userInput('onecli_choice', onecliChoice); - - let remoteUrl: string | undefined; - if (onecliChoice === 'remote') { - while (true) { - const answer = ensureAnswer( - await p.text({ - message: 'OneCLI URL on the remote machine', - placeholder: 'http://192.168.1.10:10254', - validate: (v) => { - const t = (v ?? '').trim(); - if (!t) return 'Required'; - if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://'; - return undefined; - }, - }), + if (remoteHost) { + // Advanced-settings override: user has already named a remote vault, + // so skip the local-vs-fresh prompt entirely. Health-check it here + // rather than letting the step fail silently — a typo in the URL is a + // common mistake and the answer is human-fixable. + const s = p.spinner(); + s.start(`Checking remote OneCLI at ${remoteHost}…`); + const healthy = await pollHealth(remoteHost, 5000); + if (!healthy) { + s.stop(`Couldn't reach OneCLI at ${remoteHost}.`, 1); + await fail( + 'onecli', + `Couldn't reach OneCLI at ${remoteHost}.`, + 'Check the URL and that OneCLI is running on the remote machine, then retry.', ); - remoteUrl = (answer as string).trim(); - setupLog.userInput('onecli_remote_url', remoteUrl); - - const s = p.spinner(); - s.start('Checking remote OneCLI…'); - const healthy = await pollHealth(remoteUrl, 5000); - if (healthy) { - s.stop('Remote OneCLI is reachable.'); - break; - } - s.stop(`Couldn't reach OneCLI at ${remoteUrl}.`, 1); - p.log.warn(wrapForGutter('Make sure OneCLI is running and accessible from this machine, then try again.', 4)); } - } + s.stop('Remote OneCLI is reachable.'); - const stepArgs = - onecliChoice === 'reuse' ? ['--reuse'] : onecliChoice === 'remote' ? ['--remote-url', remoteUrl!] : []; - - const res = await runQuietStep( - 'onecli', - { - running: - onecliChoice === 'reuse' - ? 'Hooking up to your existing OneCLI…' - : onecliChoice === 'remote' - ? `Connecting to remote OneCLI at ${remoteUrl}…` - : "Setting up OneCLI, your agent's vault…", - done: 'OneCLI vault ready.', - }, - stepArgs, - ); - if (!res.ok) { - const err = res.terminal?.fields.ERROR; - if (onecliChoice === 'remote') { + const res = await runQuietStep( + 'onecli', + { + running: `Connecting to remote OneCLI at ${remoteHost}…`, + done: 'OneCLI vault ready.', + }, + ['--remote-url', remoteHost], + ); + if (!res.ok) { + const err = res.terminal?.fields.ERROR; await fail( 'onecli', `Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`, 'Check the URL and that OneCLI is running on the remote machine, then retry.', ); } - if (err === 'onecli_not_on_path_after_install') { + } else { + // 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 brightSelect({ + 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') { + await fail( + 'onecli', + 'OneCLI was installed but your shell needs to refresh to see it.', + 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', + ); + } await fail( 'onecli', - 'OneCLI was installed but your shell needs to refresh to see it.', - 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.', + `Couldn't set up OneCLI (${err ?? 'unknown error'}).`, + 'Make sure curl is installed and ~/.local/bin is writable, then retry.', ); } - await fail( - 'onecli', - `Couldn't set up OneCLI (${err ?? 'unknown error'}).`, - 'Make sure curl is installed and ~/.local/bin is writable, then retry.', - ); } } @@ -981,11 +1011,11 @@ function printIntro(): void { return; } - // Always include the wordmark inside the clack intro line. When bash ran - // first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark - // above us; the small repeat is worth it to keep the brand anchored at - // the visible top of the clack session once the bash output scrolls away. - p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`); + // bash already printed the wordmark above us; the clack intro carries the + // welcome framing alone so the two don't double up. Standalone runs of + // setup:auto still see this as the first line — fine without the wordmark + // since the line itself signals the start of the flow. + p.intro("Let's get you set up."); } /** diff --git a/setup/lib/setup-config-parse.ts b/setup/lib/setup-config-parse.ts new file mode 100644 index 000000000..85e35724e --- /dev/null +++ b/setup/lib/setup-config-parse.ts @@ -0,0 +1,161 @@ +/** + * Parser/reader/writer for the advanced-config registry (setup-config.ts). + * + * readFromEnv() → values found in process.env + * parseFlags() → values from argv, plus --help and any pass-through args + * applyToEnv() → write resolved values back to process.env so existing + * step code keeps reading env vars unchanged + * printHelp() → render --help from the registry + * + * Flag parsing supports: + * --key value space form + * --key=value equals form + * --key booleans only (sets true) + * --no-key booleans only (sets false) + */ +import { + CONFIG, + envVarFor, + flagFor, + findByFlag, + type Entry, +} from './setup-config.js'; + +export type ConfigValues = Record; + +function coerce(e: Entry, raw: string): string | number | boolean | undefined { + switch (e.type) { + case 'boolean': { + const v = raw.toLowerCase(); + if (['true', '1', 'yes'].includes(v)) return true; + if (['false', '0', 'no'].includes(v)) return false; + return undefined; + } + case 'integer': { + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; + } + default: + return raw; + } +} + +export function readFromEnv(env: NodeJS.ProcessEnv = process.env): ConfigValues { + const out: ConfigValues = {}; + for (const e of CONFIG) { + const raw = env[envVarFor(e)]; + if (raw === undefined || raw === '') continue; + const v = coerce(e, raw); + if (v !== undefined) out[e.key] = v; + } + return out; +} + +export type FlagParseResult = { + values: ConfigValues; + rest: string[]; + help: boolean; + errors: string[]; +}; + +export function parseFlags(argv: string[]): FlagParseResult { + const values: ConfigValues = {}; + const rest: string[] = []; + const errors: string[] = []; + let help = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (arg === '--help' || arg === '-h') { + help = true; + continue; + } + // POSIX end-of-options. pnpm passes a bare `--` through when invoked as + // `pnpm run script --` with nothing after it; treat the rest as + // pass-through positional args. + if (arg === '--') { + rest.push(...argv.slice(i + 1)); + break; + } + if (!arg.startsWith('--')) { + rest.push(arg); + continue; + } + + const eq = arg.indexOf('='); + let name = eq === -1 ? arg : arg.slice(0, eq); + const inline: string | undefined = eq === -1 ? undefined : arg.slice(eq + 1); + + let negated = false; + if (name.startsWith('--no-')) { + negated = true; + name = `--${name.slice(5)}`; + } + + const entry = findByFlag(name); + if (!entry) { + errors.push(`Unknown flag: ${arg}`); + continue; + } + + if (entry.type === 'boolean') { + if (negated) values[entry.key] = false; + else if (inline !== undefined) { + const v = coerce(entry, inline); + if (v === undefined) errors.push(`Invalid boolean for ${name}: ${inline}`); + else values[entry.key] = v; + } else values[entry.key] = true; + continue; + } + + const raw = inline !== undefined ? inline : argv[++i]; + if (raw === undefined) { + errors.push(`Missing value for ${name}`); + continue; + } + const v = coerce(entry, raw); + if (v === undefined) { + errors.push(`Invalid ${entry.type} for ${name}: ${raw}`); + continue; + } + if (entry.type === 'string' || entry.type === 'url') { + const err = entry.validate?.(raw); + if (err) { + errors.push(`${name}: ${err}`); + continue; + } + } + values[entry.key] = v; + } + + return { values, rest, help, errors }; +} + +export function applyToEnv( + values: ConfigValues, + env: NodeJS.ProcessEnv = process.env, +): void { + for (const e of CONFIG) { + if (!(e.key in values)) continue; + const v = values[e.key]; + env[envVarFor(e)] = + typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v); + } +} + +export function printHelp(stream: NodeJS.WritableStream = process.stdout): void { + const lines: string[] = []; + lines.push('Usage: bash nanoclaw.sh [flags...]'); + lines.push(''); + lines.push('Flags:'); + const width = Math.max(...CONFIG.map((e) => flagFor(e).length)); + for (const e of CONFIG) { + const flag = flagFor(e).padEnd(width + 2); + lines.push(` ${flag}${e.help}`); + } + lines.push(''); + lines.push('Each flag also reads from its corresponding NANOCLAW_ env var.'); + lines.push('Run without flags for the default interactive flow.'); + stream.write(lines.join('\n') + '\n'); +} diff --git a/setup/lib/setup-config-screen.ts b/setup/lib/setup-config-screen.ts new file mode 100644 index 000000000..ad8ae6253 --- /dev/null +++ b/setup/lib/setup-config-screen.ts @@ -0,0 +1,127 @@ +/** + * Advanced-settings screen — menu of UI-visible entries from the config + * registry. The user picks one entry, edits it, returns to the menu, and + * exits via "Done". Returns a fresh values object; the caller passes it to + * applyToEnv() so downstream step code reads them via env vars. + * + * Per-entry edit contract: + * - Blank input on text/password/integer = leave current value unchanged. + * - Enums get a synthetic "leave unchanged" first option. + * - Booleans use confirm with the current value as initialValue. + * - Secret entries mask the current value as bullets in hints/labels. + */ +import * as p from '@clack/prompts'; + +import { brightSelect } from './bright-select.js'; +import { ensureAnswer } from './runner.js'; +import { CONFIG, type Entry } from './setup-config.js'; +import type { ConfigValues } from './setup-config-parse.js'; + +const SKIP_SENTINEL = '__leave_unchanged__'; +const DONE_SENTINEL = '__done__'; +const MASK = '••••••••'; + +export async function runAdvancedScreen( + initial: ConfigValues, +): Promise { + const result: ConfigValues = { ...initial }; + const visible = CONFIG.filter((e) => e.surface === 'flag+ui'); + + while (true) { + const options = [ + ...visible.map((e) => ({ + value: e.key, + label: e.label, + hint: hintFor(e, result), + })), + { value: DONE_SENTINEL, label: 'Done — continue with setup' }, + ]; + + const choice = ensureAnswer( + await brightSelect({ + message: 'Pick a setting to override', + options, + initialValue: DONE_SENTINEL, + }), + ) as string; + + if (choice === DONE_SENTINEL) return result; + const entry = visible.find((e) => e.key === choice); + if (entry) await promptOne(entry, result); + } +} + +function hintFor(e: Entry, values: ConfigValues): string { + const v = values[e.key]; + if (v === undefined) return 'not set'; + if (e.secret) return MASK; + return String(v); +} + +async function promptOne(e: Entry, values: ConfigValues): Promise { + if (e.type === 'boolean') { + const init = + typeof values[e.key] === 'boolean' + ? (values[e.key] as boolean) + : (e.default ?? false); + const ans = ensureAnswer( + await p.confirm({ message: e.label, initialValue: init }), + ); + values[e.key] = ans as boolean; + return; + } + + if (e.type === 'enum') { + const ans = ensureAnswer( + await brightSelect({ + message: e.label, + options: [ + { value: SKIP_SENTINEL, label: 'Leave unchanged' }, + ...e.options, + ], + initialValue: SKIP_SENTINEL, + }), + ); + if (ans !== SKIP_SENTINEL) values[e.key] = ans as string; + return; + } + + if (e.type === 'integer') { + const ans = ensureAnswer( + await p.text({ + message: e.label, + placeholder: e.default !== undefined ? String(e.default) : undefined, + validate: (v) => { + const s = (v ?? '').trim(); + if (!s) return undefined; + const n = Number(s); + if (!Number.isFinite(n)) return 'Must be a number'; + if (e.min !== undefined && n < e.min) return `Must be ≥ ${e.min}`; + if (e.max !== undefined && n > e.max) return `Must be ≤ ${e.max}`; + return undefined; + }, + }), + ); + const trimmed = ((ans as string) ?? '').trim(); + if (trimmed) values[e.key] = Number(trimmed); + return; + } + + // string | url + const validate = (v: string | undefined): string | undefined => { + const s = (v ?? '').trim(); + if (!s) return undefined; + return e.validate?.(s); + }; + const ans = ensureAnswer( + e.secret + ? await p.password({ message: e.label, validate }) + : await p.text({ + message: e.label, + placeholder: e.placeholder ?? e.default, + validate, + }), + ); + const trimmed = ((ans as string) ?? '').trim(); + if (trimmed) values[e.key] = trimmed; +} diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts new file mode 100644 index 000000000..ad450862b --- /dev/null +++ b/setup/lib/setup-config.ts @@ -0,0 +1,130 @@ +/** + * Setup-time advanced-config registry. + * + * One source of truth for: CLI flags, env-var names, the advanced-settings + * screen, and `--help` output. The flag parser, env reader, and UI screen + * all consume this list and write resolved values back to `process.env` so + * existing step code keeps reading env vars unchanged. + * + * Default name conventions (overridable per entry): + * key 'fooBar' → envVar 'NANOCLAW_FOO_BAR' → flag '--foo-bar' + * + * Surface levels: + * 'flag' — CLI flag + env var only (debug/internal knobs) + * 'flag+ui' — also shown in the advanced-settings screen + */ + +export type EntrySurface = 'flag' | 'flag+ui'; + +interface BaseEntry { + /** Canonical camelCase key. */ + key: string; + /** Override of the auto-derived NANOCLAW_ env var. */ + envVar?: string; + /** Override of the auto-derived --kebab-case flag. */ + flag?: string; + label: string; + help: string; + surface: EntrySurface; + /** UI section header. Entries without a group land in 'Other'. */ + group?: string; + /** Mask in UI, redact in logs. */ + secret?: boolean; +} + +interface StringEntry extends BaseEntry { + type: 'string' | 'url'; + default?: string; + placeholder?: string; + validate?: (v: string) => string | undefined; +} + +interface EnumEntry extends BaseEntry { + type: 'enum'; + options: { value: string; label: string; hint?: string }[]; + default?: string; +} + +interface BoolEntry extends BaseEntry { + type: 'boolean'; + default?: boolean; +} + +interface IntEntry extends BaseEntry { + type: 'integer'; + default?: number; + min?: number; + max?: number; +} + +export type Entry = StringEntry | EnumEntry | BoolEntry | IntEntry; + +const httpUrl = (v: string): string | undefined => + /^https?:\/\/\S+/.test(v) ? undefined : 'Must be http(s)://…'; + +export const CONFIG: Entry[] = [ + { + key: 'onecliApiHost', + label: 'OneCLI vault URL', + help: 'Use a remote OneCLI vault instead of installing one locally.', + surface: 'flag+ui', + group: 'OneCLI', + type: 'url', + placeholder: 'https://vault.example.internal', + validate: httpUrl, + }, + { + key: 'onecliApiToken', + label: 'OneCLI access token', + help: 'Bearer token for the remote vault. Required if --onecli-api-host is set.', + surface: 'flag+ui', + group: 'OneCLI', + type: 'string', + secret: true, + placeholder: 'oat_…', + }, + { + key: 'anthropicBaseUrl', + label: 'Anthropic API base URL', + help: 'Use a proxy or alternative endpoint instead of api.anthropic.com.', + surface: 'flag+ui', + group: 'Anthropic', + type: 'url', + placeholder: 'https://api.anthropic.com', + validate: httpUrl, + }, + + // Existing env-var knobs — flag-only so they don't clutter the UI screen. + { + key: 'skip', + envVar: 'NANOCLAW_SKIP', + label: 'Skip steps', + help: 'Comma-separated step names to skip (debugging only).', + surface: 'flag', + type: 'string', + }, + { + key: 'displayName', + envVar: 'NANOCLAW_DISPLAY_NAME', + label: 'Display name', + help: 'Skip the "what should your assistant call you?" prompt.', + surface: 'flag', + type: 'string', + }, +]; + +// ─── name derivation ─────────────────────────────────────────────────── + +export function envVarFor(e: Entry): string { + if (e.envVar) return e.envVar; + return `NANOCLAW_${e.key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`; +} + +export function flagFor(e: Entry): string { + if (e.flag) return e.flag; + return `--${e.key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`; +} + +export function findByFlag(flag: string): Entry | null { + return CONFIG.find((e) => flagFor(e) === flag) ?? null; +} diff --git a/setup/onecli.ts b/setup/onecli.ts index 3f46c8870..d6dda3874 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -86,17 +86,22 @@ function ensureShellProfilePath(): void { } } -function writeEnvOnecliUrl(url: string): void { +function writeEnvVar(name: string, value: string): void { const envFile = path.join(process.cwd(), '.env'); let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : ''; - if (/^ONECLI_URL=/m.test(content)) { - content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`); + const re = new RegExp(`^${name}=.*$`, 'm'); + if (re.test(content)) { + content = content.replace(re, `${name}=${value}`); } else { - content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`; + content = content.trimEnd() + (content ? '\n' : '') + `${name}=${value}\n`; } fs.writeFileSync(envFile, content); } +function writeEnvOnecliUrl(url: string): void { + writeEnvVar('ONECLI_URL', url); +} + // Last-known-good CLI release. Used only if BOTH the upstream installer // and the redirect-based version probe fail. Bump deliberately when a // new CLI release ships. @@ -257,6 +262,8 @@ export async function run(args: string[]): Promise { ensureShellProfilePath(); if (remoteUrl) { + // Remote-mode: install only the CLI, point it at the remote gateway, and + // record the URL in .env. No local gateway is started. log.info('Installing OneCLI CLI for remote gateway', { remoteUrl }); const res = installOnecliCliOnly(); if (!res.ok || !onecliVersion()) { @@ -279,6 +286,11 @@ export async function run(args: string[]): Promise { } writeEnvOnecliUrl(remoteUrl); log.info('Wrote ONECLI_URL to .env', { url: remoteUrl }); + const remoteToken = process.env.NANOCLAW_ONECLI_API_TOKEN?.trim(); + if (remoteToken) { + writeEnvVar('ONECLI_API_KEY', remoteToken); + log.info('Wrote ONECLI_API_KEY to .env'); + } const healthy = await pollHealth(remoteUrl, 5000); emitStatus('ONECLI', { INSTALLED: true, From f048447ec551ced6606e6ac59089df7741211230 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 27 Apr 2026 00:00:59 +0300 Subject: [PATCH 2/4] feat(setup): authenticate onecli CLI for remote vault setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without `onecli auth login`, setup-time CLI calls (e.g. `secrets list` inside anthropicSecretExists, `secrets create` in runPasteAuth) hit a secured remote vault unauthenticated and fail silently — the auth step sees no existing Anthropic credential and prompts the user to add one even when it's already in the remote vault. Two auth surfaces matter here: the CLI's persistent store via `onecli auth login --api-key`, and ONECLI_API_KEY in .env that the runtime SDK reads at request time. We need both. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/onecli.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/setup/onecli.ts b/setup/onecli.ts index d6dda3874..fbf76a90f 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -288,6 +288,18 @@ export async function run(args: string[]): Promise { log.info('Wrote ONECLI_URL to .env', { url: remoteUrl }); const remoteToken = process.env.NANOCLAW_ONECLI_API_TOKEN?.trim(); if (remoteToken) { + // Two auth surfaces: `onecli auth login` persists the key for CLI + // calls during setup itself (e.g. detecting an existing Anthropic + // secret via `onecli secrets list`), and ONECLI_API_KEY in .env is + // read by the runtime SDK at request time. Both are needed. + try { + execFileSync('onecli', ['auth', 'login', '--api-key', remoteToken], { + stdio: 'ignore', + env: childEnv(), + }); + } catch (err) { + log.warn('onecli auth login failed', { err }); + } writeEnvVar('ONECLI_API_KEY', remoteToken); log.info('Wrote ONECLI_API_KEY to .env'); } From e706dcac000e26c33eec4b1508ec495df8a2a9ae Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 27 Apr 2026 00:04:42 +0300 Subject: [PATCH 3/4] feat(setup): default OneCLI remote URL to https://app.onecli.sh Replaces the example.internal placeholder with the hosted gateway URL so the advanced screen and --help suggest the canonical destination out of the box. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/lib/setup-config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts index ad450862b..7e2873ed8 100644 --- a/setup/lib/setup-config.ts +++ b/setup/lib/setup-config.ts @@ -70,7 +70,8 @@ export const CONFIG: Entry[] = [ surface: 'flag+ui', group: 'OneCLI', type: 'url', - placeholder: 'https://vault.example.internal', + default: 'https://app.onecli.sh', + placeholder: 'https://app.onecli.sh', validate: httpUrl, }, { From 7693a209708b6eca03813989ab44f60135edb7bc Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 27 Apr 2026 00:05:45 +0300 Subject: [PATCH 4/4] feat(setup): validate onecli api token starts with oc_ Matches the OneCLI CLI's own format expectation ("oc_... format" per `onecli auth login --help`) so a malformed token gets caught at setup time rather than at first vault call. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/lib/setup-config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts index 7e2873ed8..0a59731cd 100644 --- a/setup/lib/setup-config.ts +++ b/setup/lib/setup-config.ts @@ -82,7 +82,8 @@ export const CONFIG: Entry[] = [ group: 'OneCLI', type: 'string', secret: true, - placeholder: 'oat_…', + placeholder: 'oc_…', + validate: (v) => (v.startsWith('oc_') ? undefined : 'Must start with oc_'), }, { key: 'anthropicBaseUrl',