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:
216
setup/auto.ts
216
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<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.");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user