Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479/** * `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<string>;
/** 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<SetupAnswers> { // 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<string> => 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<string, string | undefined>; 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<number> { 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 <key> Provider key (openai, ollama, custom, or your own)\n' + ' --base-url <url> Provider base URL\n' + ' --model <name> Concrete model id\n' + ' --alias <name> Short model alias\n' + ' --auth <bearer|none> Auth strategy\n' + ' --env-var <name> Env var supplying the API key (bearer auth)\n' + ' --config <path> 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;}