/** * `shard setup` — interactive first-provider config wizard. * * Guides a new user through creating their first `~/.config/shard/config.json` with a * single provider + model alias. Interactive when run on a TTY, but every prompt is * skippable: * * - **Expert skip** — pass `--yes` (or any of `--provider`/`--model`/`--base-url`/…) to * accept defaults / flag values without prompting. Non-TTY stdin auto-uses defaults so * the wizard never hangs in CI or a pipe. * - **Existing config safe** — if a config already exists, the wizard refuses to * overwrite it (exit 0, points at `shard config show`). `--force` opts out. * * API keys are **never** written to the config file. For a bearer-auth provider the * wizard records only the *env var name* that supplies the key at runtime and prints the * `export` line to run. This matches the `shard config init` precedent and the AGENTS.md * secret-safety rule. * * Design: the pure pieces (`parseSetupArgs`, `buildSetupConfig`, the provider presets) * are fully unit-testable without I/O. `runSetupSub` is the thin I/O wrapper — it takes * an injectable `prompt` function + injectable fs deps so tests drive the interactive * flow with queued answers and a virtual filesystem. * * Layer: L4. Imports `@/cli/config` (same layer) + `node:readline`/`node:fs`/`node:path`. */ import * as readline from 'node:readline'; import { writeFileSync as fsWriteFileSync, mkdirSync as fsMkdirSync, existsSync as fsExistsSync } from 'node:fs'; import { dirname } from 'node:path'; import { resolveConfigPath, type ShardConfig } from '@/cli/config'; // ─── Provider presets ────────────────────────────────────────────────────────── /** A selectable provider template offered by the wizard. */ export interface ProviderPreset { /** Provider key written to `providers[key]` (the `custom` key is replaced on input). */ key: string; /** Human label shown in the menu. */ label: string; /** Default base URL (empty for `custom` — prompted). */ baseURL: string; /** Default auth strategy. */ auth: 'bearer' | 'none'; /** Default env var name for bearer auth (undefined for none). */ envVar?: string; /** Default concrete model id. */ modelName: string; /** Default short alias for the model. */ modelAlias: string; } /** * Built-in provider presets. Ordered so OpenAI (the most common first config) is the * default (menu choice `1`). Ollama covers the no-auth local case. Custom is the escape * hatch for any other OpenAI-compatible endpoint. */ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [ { key: 'openai', label: 'OpenAI', baseURL: 'https://api.openai.com/v1', auth: 'bearer', envVar: 'OPENAI_API_KEY', modelName: 'gpt-4o-mini', modelAlias: 'fast', }, { key: 'ollama', label: 'Ollama (local, no auth)', baseURL: 'http://127.0.0.1:11434/v1', auth: 'none', modelName: 'llama3.2:3b', modelAlias: 'local', }, { key: 'custom', label: 'Custom OpenAI-compatible endpoint', baseURL: '', auth: 'bearer', envVar: 'SHARD_API_KEY', modelName: '', modelAlias: 'default', }, ]; // ─── Parsed args ─────────────────────────────────────────────────────────────── /** Flags accepted by `shard setup`. All optional — the wizard prompts for the rest. */ export interface ParsedSetupArgs { /** Accept defaults / flag values without prompting (expert skip). */ yes: boolean; /** Overwrite an existing config. */ force: boolean; /** Launch the local web experience after setup completes. */ launchWeb: boolean; /** Provider key (matches a preset, or a custom key). */ providerKey?: string; /** Base URL override. */ baseURL?: string; /** Concrete model id override. */ modelName?: string; /** Model alias override. */ modelAlias?: string; /** Auth strategy override. */ authKind?: 'bearer' | 'none'; /** Env var name override (bearer auth). */ envVar?: string; /** Explicit config path (testing). */ configPath?: string; } /** Parse `shard setup` argv. Throws on unknown flags / bad values. */ export function parseSetupArgs(args: string[]): ParsedSetupArgs { let yes = false; let force = false; let launchWeb = false; let providerKey: string | undefined; let baseURL: string | undefined; let modelName: string | undefined; let modelAlias: string | undefined; let authKind: 'bearer' | 'none' | undefined; let envVar: string | undefined; let configPath: string | undefined; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === undefined) continue; if (a === '--yes' || a === '-y') { yes = true; } else if (a === '--force') { force = true; } else if (a === '--web') { launchWeb = true; } else if (a === '-h' || a === '--help') { throw new SetupHelpRequested(); } else if (a === '--provider') { providerKey = args[++i]; if (providerKey === undefined || providerKey.length === 0) throw new Error('invalid --provider value'); } else if (a.startsWith('--provider=')) { providerKey = a.slice('--provider='.length); if (providerKey.length === 0) throw new Error('invalid --provider value'); } else if (a === '--base-url') { baseURL = args[++i]; if (baseURL === undefined || baseURL.length === 0) throw new Error('invalid --base-url value'); } else if (a.startsWith('--base-url=')) { baseURL = a.slice('--base-url='.length); } else if (a === '--model' || a === '-m') { modelName = args[++i]; if (modelName === undefined || modelName.length === 0) throw new Error('invalid --model value'); } else if (a.startsWith('--model=')) { modelName = a.slice('--model='.length); } else if (a === '--alias') { modelAlias = args[++i]; if (modelAlias === undefined || modelAlias.length === 0) throw new Error('invalid --alias value'); } else if (a.startsWith('--alias=')) { modelAlias = a.slice('--alias='.length); } else if (a === '--auth') { const v = args[++i]; if (v !== 'bearer' && v !== 'none') throw new Error(`invalid --auth value: ${v ?? ''}`); authKind = v; } else if (a.startsWith('--auth=')) { const v = a.slice('--auth='.length); if (v !== 'bearer' && v !== 'none') throw new Error(`invalid --auth value: ${v}`); authKind = v; } else if (a === '--env-var') { envVar = args[++i]; if (envVar === undefined || envVar.length === 0) throw new Error('invalid --env-var value'); } else if (a.startsWith('--env-var=')) { envVar = a.slice('--env-var='.length); } else if (a === '--config') { configPath = args[++i]; if (configPath === undefined || configPath.length === 0) throw new Error('invalid --config value'); } else if (a.startsWith('--config=')) { configPath = a.slice('--config='.length); } else { throw new Error(`unknown argument: ${a}`); } } return { yes, force, launchWeb, ...(providerKey !== undefined ? { providerKey } : undefined), ...(baseURL !== undefined ? { baseURL } : undefined), ...(modelName !== undefined ? { modelName } : undefined), ...(modelAlias !== undefined ? { modelAlias } : undefined), ...(authKind !== undefined ? { authKind } : undefined), ...(envVar !== undefined ? { envVar } : undefined), ...(configPath !== undefined ? { configPath } : undefined), }; } /** Sentinel thrown when `--help` is requested (handled by the I/O wrapper). */ export class SetupHelpRequested extends Error { constructor() { super('help'); this.name = 'SetupHelpRequested'; } } // ─── Collected answers ───────────────────────────────────────────────────────── /** The resolved wizard answers — everything needed to build a starter config. */ export interface SetupAnswers { providerKey: string; baseURL: string; auth: { kind: 'bearer'; envVar: string } | { kind: 'none' }; modelAlias: string; modelName: string; } /** * Build a starter {@link ShardConfig} from collected answers. Pure — no I/O. The config * sets `defaultProvider`, a single `providers[key]` entry, a `models[alias]` override, * and `defaultModel[provider]` so the agent resolves without extra flags. */ export function buildSetupConfig(a: SetupAnswers): ShardConfig { return { defaultProvider: a.providerKey, defaultModel: { [a.providerKey]: a.modelAlias }, providers: { [a.providerKey]: { baseURL: a.baseURL, auth: a.auth, }, }, models: { [a.modelAlias]: { provider: a.providerKey, name: a.modelName }, }, }; } // ─── Interactive collection ──────────────────────────────────────────────────── /** Injectable prompt function: ask a question, return the trimmed answer or default. */ export type PromptFn = (question: string, defaultValue?: string) => Promise; /** Render the provider preset menu (1-indexed). */ function providerMenu(): string { const lines = ['Choose a provider:']; PROVIDER_PRESETS.forEach((p, i) => { lines.push(` ${i + 1}. ${p.label}`); }); return lines.join('\n'); } /** A blank preset used when a custom provider key is supplied via flag. */ function blankCustomPreset(key: string): ProviderPreset { return { key, label: key, baseURL: '', auth: 'bearer', envVar: 'SHARD_API_KEY', modelName: '', modelAlias: 'default', }; } /** * Collect wizard answers, prompting only for values not supplied via flags. When * `interactive` is false (expert skip / non-TTY) every prompt is bypassed and flag * values / preset defaults are used. The `prompt` function is injectable so tests drive * the flow with queued answers. */ export async function collectAnswers( parsed: ParsedSetupArgs, prompt: PromptFn, interactive: boolean, ): Promise { // 1 — Provider preset selection. let preset: ProviderPreset; const matchedPreset = parsed.providerKey !== undefined ? PROVIDER_PRESETS.find((p) => p.key === parsed.providerKey) : undefined; if (matchedPreset !== undefined) { preset = matchedPreset; } else if (parsed.providerKey !== undefined) { preset = blankCustomPreset(parsed.providerKey); } else if (!interactive) { preset = PROVIDER_PRESETS[0]!; } else { const choice = await prompt(providerMenu(), '1'); const idx = Number.parseInt(choice, 10) - 1; preset = PROVIDER_PRESETS[idx] ?? PROVIDER_PRESETS[0]!; } // 2 — Provider key (custom preset prompts for one). let providerKey = preset.key; if (preset.key === 'custom') { if (parsed.providerKey !== undefined) { providerKey = parsed.providerKey; } else if (interactive) { providerKey = await prompt('Provider key (e.g. my-provider)', 'custom'); } else { providerKey = 'custom'; } } // 3 — Base URL. let baseURL = parsed.baseURL ?? preset.baseURL; if (baseURL.length === 0) { if (interactive) { baseURL = await prompt('Base URL (e.g. https://api.openai.com/v1)', 'https://api.openai.com/v1'); } else { baseURL = 'https://api.openai.com/v1'; } } // 4 — Auth strategy + env var (bearer only). Only prompt for the env var on the custom // preset — known presets (openai) carry a sensible default the user rarely changes. const authKind = parsed.authKind ?? preset.auth; let envVar = parsed.envVar ?? preset.envVar; if (authKind === 'bearer') { if (envVar === undefined || envVar.length === 0) envVar = 'SHARD_API_KEY'; if (interactive && preset.key === 'custom' && parsed.envVar === undefined) { envVar = await prompt('API key environment variable', envVar); } } // 5 — Model name + alias. Prompt only for values not supplied via a flag. let modelName = parsed.modelName ?? preset.modelName; if (modelName.length === 0) { if (interactive) { modelName = await prompt('Model name (e.g. gpt-4o-mini)', 'gpt-4o-mini'); } else { modelName = 'gpt-4o-mini'; } } let modelAlias = parsed.modelAlias ?? preset.modelAlias; if (interactive && parsed.modelAlias === undefined) { modelAlias = await prompt('Model alias', modelAlias); } return { providerKey, baseURL, auth: authKind === 'bearer' ? { kind: 'bearer', envVar: envVar! } : { kind: 'none' }, modelAlias, modelName, }; } // ─── Readline prompt factory ─────────────────────────────────────────────────── /** Create a readline-backed prompt + a `close` that tears down the interface. */ function createReadlinePrompt( input: NodeJS.ReadableStream, output: NodeJS.WritableStream, ): { prompt: PromptFn; close: () => void } { const rl = readline.createInterface({ input, output, terminal: false }); return { prompt: (question: string, defaultValue?: string): Promise => new Promise((resolve) => { const suffix = defaultValue !== undefined && defaultValue.length > 0 ? ` [${defaultValue}]` : ''; rl.question(`${question}${suffix}: `, (answer) => { const trimmed = answer.trim(); resolve(trimmed.length > 0 ? trimmed : (defaultValue ?? '')); }); }), close: () => rl.close(), }; } // ─── Runner ──────────────────────────────────────────────────────────────────── /** Injectable I/O deps so tests avoid the real filesystem. */ export interface SetupRunDeps { env?: Record; configPath?: string; existsSync?: (path: string) => boolean; mkdirSync?: (path: string, opts?: { recursive: boolean }) => void; writeFileSync?: (path: string, data: string) => void; /** Injected prompt (tests). When omitted, a readline prompt is created on a TTY. */ prompt?: PromptFn; /** Injected stdin (tests / non-interactive defaults). */ input?: NodeJS.ReadableStream; /** Injected stdout (tests). */ output?: NodeJS.WritableStream; } /** * `shard setup` entry point. Returns an exit code (0 success, 2 error). Existing config * is safe (refuses to overwrite without `--force`). Non-interactive when `--yes` or * non-TTY stdin. */ export async function runSetupSub(args: string[], opts?: SetupRunDeps): Promise { let parsed: ParsedSetupArgs; try { parsed = parseSetupArgs(args); } catch (error) { if (error instanceof SetupHelpRequested) { process.stdout.write( 'Usage: shard setup [options]\n' + ' Interactive first-provider config wizard. Creates ~/.config/shard/config.json.\n' + ' Existing config is preserved (use --force to overwrite).\n' + 'Options:\n' + ' --yes, -y Accept defaults without prompting (expert skip)\n' + ' --force Overwrite an existing config\n' + ' --web Launch the local web experience after setup\n' + ' (approves the first browser connection inline)\n' + ' --provider Provider key (openai, ollama, custom, or your own)\n' + ' --base-url Provider base URL\n' + ' --model Concrete model id\n' + ' --alias Short model alias\n' + ' --auth Auth strategy\n' + ' --env-var Env var supplying the API key (bearer auth)\n' + ' --config Config path (default: XDG)\n', ); return 0; } process.stderr.write(`shard: ${error instanceof Error ? error.message : String(error)}\n`); return 2; } const env = opts?.env ?? process.env; const existsSync = opts?.existsSync ?? ((p: string) => fsExistsSync(p)); const mkdirSync = opts?.mkdirSync ?? ((p: string, o?: { recursive: boolean }) => fsMkdirSync(p, o)); const writeFileSync = opts?.writeFileSync ?? ((p: string, d: string) => fsWriteFileSync(p, d)); const configPath = opts?.configPath ?? parsed.configPath ?? resolveConfigPath(env); // Existing config safe — refuse to overwrite without --force. if (existsSync(configPath) && !parsed.force) { process.stdout.write( `shard: config already exists at ${configPath}\n` + ` View it with: shard config show\n` + ` Re-run with --force to overwrite.\n`, ); return 0; } // Determine interactivity. Expert skip (--yes) or non-TTY stdin (no injected prompt) // → use defaults/flags without prompting, so the wizard never hangs in CI or a pipe. const stdin = opts?.input ?? process.stdin; const isTTY = (stdin as { isTTY?: boolean }).isTTY ?? false; const interactive = !parsed.yes && (opts?.prompt !== undefined || isTTY); let prompt: PromptFn; let closePrompt: () => void = () => {}; if (opts?.prompt !== undefined) { prompt = opts.prompt; } else if (!interactive) { prompt = async () => ''; } else { const { prompt: p, close } = createReadlinePrompt(stdin, opts?.output ?? process.stdout); prompt = p; closePrompt = close; } let answers: SetupAnswers; try { answers = await collectAnswers(parsed, prompt, interactive); } catch (err) { closePrompt(); process.stderr.write(`shard: ${(err as Error).message}\n`); return 2; } finally { closePrompt(); } const config = buildSetupConfig(answers); try { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); } catch (err) { process.stderr.write(`shard: failed to write config at ${configPath}: ${(err as Error).message}\n`); return 2; } // Success + next steps. For bearer auth, print the export line (key never persisted). process.stdout.write(`Created config at ${configPath}\n`); if (answers.auth.kind === 'bearer') { process.stdout.write(`\nSet your API key (not stored in the config):\n export ${answers.auth.envVar}='...'\n`); } process.stdout.write(`\nVerify your setup:\n shard config show\n`); process.stdout.write(`Start Shard:\n shard web # daemon + browser client\n`); return 0; }