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:
161
setup/lib/setup-config-parse.ts
Normal file
161
setup/lib/setup-config-parse.ts
Normal file
@@ -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<string, string | boolean | number>;
|
||||
|
||||
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_<KEY> env var.');
|
||||
lines.push('Run without flags for the default interactive flow.');
|
||||
stream.write(lines.join('\n') + '\n');
|
||||
}
|
||||
127
setup/lib/setup-config-screen.ts
Normal file
127
setup/lib/setup-config-screen.ts
Normal file
@@ -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<ConfigValues> {
|
||||
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<string>({
|
||||
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<void> {
|
||||
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<string>({
|
||||
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;
|
||||
}
|
||||
130
setup/lib/setup-config.ts
Normal file
130
setup/lib/setup-config.ts
Normal file
@@ -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_<UPPER_SNAKE> 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;
|
||||
}
|
||||
Reference in New Issue
Block a user