feat(setup): advanced settings registry with remote OneCLI support

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) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-26 23:39:12 +03:00
parent 7de1fc1b3c
commit efdd05a7ef
6 changed files with 565 additions and 104 deletions

View File

@@ -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<void> {
// 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<void> {
),
);
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<OnecliChoice>({
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.");
}
/**