diff --git a/AGENTS.md b/AGENTS.md index 9e09927..5e07939 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,9 +12,10 @@ export OPENCODE_PRESETS_CACHE=/tmp/oc-test/cache npm run build && node dist/bin/opencode-presets.js install ./presets/.conf ``` -For non-interactive runs pipe answers via `printf`/`yes`. The CLI uses -one shared readline session with a buffered queue; multi-line piped -input is correct. +For non-interactive runs prefer `--set NAME=VALUE` (or `--set-env +NAME=ENV_VAR` for secrets) over piping answers via `printf`/`yes`. +The interactive readline path still works — the CLI uses one shared +session with a buffered queue — but `--set` is sturdier in scripts. ## Conf module format — required headers diff --git a/README.md b/README.md index 1c6cfaf..fc2710b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,44 @@ Every change shows a diff and asks before touching anything. A backup is written to `~/.cache/opencode-presets/backups/` before each write — no auto-pruning, so they pile up. +### Non-interactive prompt values (`--set` / `--set-env`) + +Presets with `@prompt` directives normally ask interactively. To +drive them from a script (or just paste a one-liner from a wiki), +pre-fill any prompt with `--set NAME=VALUE`: + +```sh +opencode-presets install mcp-http \ + --set name=openrag \ + --set url=https://openrag.example.internal/mcp \ + --set headerName=X-Bitbucket-Token \ + --set 'headerValue=raw-token-here' +``` + +**Quote values that contain shell metacharacters** (`$`, `!`, `*`, +backticks, spaces, etc.) with single quotes — otherwise the shell +expands them before `opencode-presets` ever sees the value. A +Bitbucket PAT that starts with `$` will silently turn into an empty +string without quoting. + +For secrets, prefer `--set-env NAME=ENV_VAR`. The CLI reads the +value from the named environment variable at install time, so the +token never appears in shell history or process listings: + +```sh +export BITBUCKET_TOKEN=… +opencode-presets install mcp-http \ + --set name=openrag \ + --set url=https://openrag.example.internal/mcp \ + --set headerName=X-Bitbucket-Token \ + --set-env headerValue=BITBUCKET_TOKEN +``` + +When installing several modules in one call, scope a value with +`.` if the same prompt name appears in more than one +of them: `--set mcp-http.name=openrag`. Unscoped values across +ambiguous prompts are rejected with a clear error. + ## Built-in presets | Preset | Category | Mode | Description | diff --git a/bin/opencode-presets.ts b/bin/opencode-presets.ts index b5c8f8d..f8021c9 100755 --- a/bin/opencode-presets.ts +++ b/bin/opencode-presets.ts @@ -10,6 +10,7 @@ import { backup } from '../src/backup.js'; import { c, confirm, closeUi } from '../src/ui.js'; import { listConfs } from '../src/list.js'; import { runBatch, runRemoveBatch } from '../src/batch.js'; +import { parseInstallArgs, CliArgsError } from '../src/cli-args.js'; import { validateAgainstSchema } from '../src/validate.js'; const CACHE_DIR = process.env.OPENCODE_PRESETS_CACHE @@ -87,7 +88,18 @@ async function main(): Promise { } if (sub === 'install') { - const { resets, confPaths } = parseInstallArgs(argv.slice(1)); + let parsed; + try { parsed = parseInstallArgs(argv.slice(1)); } + catch (e) { + if (e instanceof CliArgsError) { + console.error(c.err('error: ') + e.message); + console.error(''); + printUsage(); + process.exit(1); + } + throw e; + } + const { resets, confPaths, setValues } = parsed; if (confPaths.length === 0 && resets.length === 0) { printUsage(); process.exit(1); @@ -96,6 +108,7 @@ async function main(): Promise { await runBatch({ resets, confPaths: resolved, + setValues, target: TARGET, cacheDir: CACHE_DIR, backupDir: BACKUP_DIR, @@ -141,24 +154,6 @@ async function fileExists(path: string): Promise { try { await stat(path); return true; } catch { return false; } } -function parseInstallArgs(args: string[]): { resets: string[]; confPaths: string[] } { - const resets: string[] = []; - const confPaths: string[] = []; - for (let i = 0; i < args.length; i++) { - const a = args[i]; - if (a === '--reset') { - const next = args[++i]; - if (!next) { printUsage(); process.exit(1); } - resets.push(next); - } else if (a.startsWith('--reset=')) { - resets.push(a.slice('--reset='.length)); - } else { - confPaths.push(a); - } - } - return { resets, confPaths }; -} - async function runResetAll(): Promise { const existing = await loadJsonOrNull(TARGET); if (existing === null) { @@ -300,9 +295,14 @@ async function loadJsonOrNull(path: string): Promise | n function printUsage(): void { console.log('Usage:'); console.log(' opencode-presets list [] [--long] list available .conf modules'); - console.log(' opencode-presets install [--reset ]... ...'); + console.log(' opencode-presets install [--reset ]... [--set NAME=VALUE]... ...'); console.log(' apply one or more modules'); console.log(' (with optional pre-resets)'); + console.log(' --set NAME=VALUE pre-fill a prompt non-interactively. Scope across multiple'); + console.log(' modules with --set .NAME=VALUE. Quote values with'); + console.log(" single quotes ('...') if they contain $, !, *, etc."); + console.log(' --set-env NAME=ENV_VAR read the value from $ENV_VAR (recommended for secrets so'); + console.log(' tokens never land in shell history or process listings)'); console.log(' opencode-presets remove ... remove one or more installed presets'); console.log(' opencode-presets reset [] wipe a path, or wipe everything'); console.log(' to the minimal baseline if no path'); diff --git a/package-lock.json b/package-lock.json index ec41305..a8968bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-presets", - "version": "0.3.2", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-presets", - "version": "0.3.2", + "version": "0.4.0", "license": "MIT", "dependencies": { "ajv": "8.20.0", diff --git a/package.json b/package.json index 6ecaff2..5d9cf6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-presets", - "version": "0.3.2", + "version": "0.4.0", "description": "Interactive CLI that patches opencode.json with curated config presets — LSP, MCP, permissions.", "type": "module", "bin": { diff --git a/src/batch.ts b/src/batch.ts index 963ca18..84dc541 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -8,6 +8,7 @@ import type { ApplyStats, RemoveStats, MergeMode } from './merge.js'; import { fetchAsset } from './fetch-asset.js'; import { backup } from './backup.js'; import { c, confirm, promptText, promptSecret, describe, wrap } from './ui.js'; +import type { SetValue } from './cli-args.js'; import { validateAgainstSchema } from './validate.js'; const SCHEMA_URL = 'https://opencode.ai/config.json'; @@ -16,7 +17,7 @@ const ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; type Json = unknown; type JsonObject = Record; -interface BatchModule { +export interface BatchModule { confPath: string; meta: ConfMeta; body: Json; @@ -42,6 +43,7 @@ interface ResetStat { export interface RunBatchOpts { resets: string[]; confPaths: string[]; + setValues?: SetValue[]; target: string; cacheDir: string; backupDir: string; @@ -56,7 +58,7 @@ export interface RunRemoveBatchOpts { // Run a batch consisting of zero or more --reset paths followed by // zero or more module installs. Either side may be empty. -export async function runBatch({ resets, confPaths, target, cacheDir, backupDir }: RunBatchOpts): Promise { +export async function runBatch({ resets, confPaths, setValues, target, cacheDir, backupDir }: RunBatchOpts): Promise { const modules: BatchModule[] = []; for (const cp of confPaths) { try { @@ -68,6 +70,15 @@ export async function runBatch({ resets, confPaths, target, cacheDir, backupDir } } + // Distribute --set / --set-env values to modules. Errors here surface + // before the user is asked to confirm anything. + try { + distributeSetValues(modules, setValues ?? []); + } catch (e) { + console.error(c.err('error: ') + (e instanceof Error ? e.message : String(e))); + process.exit(1); + } + const existing = await loadJsonOrNull(target); console.log(''); @@ -88,16 +99,25 @@ export async function runBatch({ resets, confPaths, target, cacheDir, backupDir // ── Collect prompts per module ── for (const m of modules) { if (m.meta.prompts.length === 0) continue; - console.log(''); - console.log(c.bold(m.meta.name) + c.dim(' — inputs:')); - m.promptValues = {}; + const preset = m.promptValues ?? {}; + const allPreset = m.meta.prompts.every(p => p.name in preset); + if (!allPreset) { + console.log(''); + console.log(c.bold(m.meta.name) + c.dim(' — inputs:')); + } + m.promptValues = { ...preset }; for (const p of m.meta.prompts) { - const label = ' ' + c.bold(p.name) + - (p.help ? c.meta(' (' + p.help + ')') : '') + - (p.default !== undefined ? c.meta(' [default: ' + p.default + ']') : '') + - ': '; - const raw = p.type === 'secret' ? await promptSecret(label) : await promptText(label); - let val = raw; + let val: string; + if (p.name in preset) { + val = preset[p.name]; + } else { + const label = ' ' + c.bold(p.name) + + (p.help ? c.meta(' (' + p.help + ')') : '') + + (p.default !== undefined ? c.meta(' [default: ' + p.default + ']') : '') + + ': '; + const raw = p.type === 'secret' ? await promptSecret(label) : await promptText(label); + val = raw; + } if (!val) { if (p.default !== undefined) { val = p.default; @@ -440,7 +460,9 @@ function expandCacheInValue(value: Json, cacheDir: string): Json { if (Array.isArray(value)) return value.map(v => expandCacheInValue(v, cacheDir)); if (value && typeof value === 'object') { const out: JsonObject = {}; - for (const [k, v] of Object.entries(value as JsonObject)) out[k] = expandCacheInValue(v, cacheDir); + for (const [k, v] of Object.entries(value as JsonObject)) { + out[expandCacheStr(k, cacheDir)] = expandCacheInValue(v, cacheDir); + } return out; } return value; @@ -460,12 +482,47 @@ function expandPromptsInValue(value: Json, promptValues: Record) if (Array.isArray(value)) return value.map(v => expandPromptsInValue(v, promptValues)); if (value && typeof value === 'object') { const out: JsonObject = {}; - for (const [k, v] of Object.entries(value as JsonObject)) out[k] = expandPromptsInValue(v, promptValues); + for (const [k, v] of Object.entries(value as JsonObject)) { + out[expandPromptsStr(k, promptValues)] = expandPromptsInValue(v, promptValues); + } return out; } return value; } +// Route --set / --set-env values to their target modules. Scoped values +// (preset.name) must match a module's @name. Unscoped values are +// accepted only when exactly one loaded module declares that prompt; +// ambiguity is a hard error rather than guessing. Unknown names and +// duplicate assignments are also rejected. +export function distributeSetValues(modules: BatchModule[], setValues: SetValue[]): void { + if (setValues.length === 0) return; + for (const sv of setValues) { + const matches = modules.filter(m => { + if (sv.scope !== undefined && m.meta.name !== sv.scope) return false; + return m.meta.prompts.some(p => p.name === sv.name); + }); + if (matches.length === 0) { + if (sv.scope !== undefined) { + throw new Error(`--set ${sv.scope}.${sv.name}: no module named "${sv.scope}" declares a prompt "${sv.name}"`); + } + throw new Error(`--set ${sv.name}: no installed module declares a prompt with that name`); + } + if (matches.length > 1) { + const names = matches.map(m => m.meta.name).join(', '); + throw new Error( + `--set ${sv.name}: ambiguous — declared by multiple modules (${names}). Scope it as --set .${sv.name}=...` + ); + } + const m = matches[0]; + m.promptValues ??= {}; + if (sv.name in m.promptValues) { + throw new Error(`--set ${sv.name}: value provided more than once for module "${m.meta.name}"`); + } + m.promptValues[sv.name] = sv.value; + } +} + function isPlainObject(v: unknown): v is JsonObject { return v !== null && typeof v === 'object' && !Array.isArray(v); } diff --git a/src/cli-args.ts b/src/cli-args.ts new file mode 100644 index 0000000..1758245 --- /dev/null +++ b/src/cli-args.ts @@ -0,0 +1,112 @@ +// CLI argument parsing for `install`. Pure functions; tested in +// test/cli-args.test.ts. Errors are thrown so callers can decide how +// to surface them (the bin entry prints + exits). +import process from 'node:process'; + +export interface SetValue { + scope?: string; // optional . prefix from --set foo.name=... + name: string; + value: string; +} + +export interface InstallArgs { + resets: string[]; + confPaths: string[]; + setValues: SetValue[]; +} + +// Prompt names mirror parse-conf's @prompt validation (letter or underscore start). +const PROMPT_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_-]*$/; +// Preset scope and env-var names: alphanumeric, underscore, hyphen; must not start with `-`. +const SCOPE_RE = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/; +const ENV_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +export class CliArgsError extends Error { + constructor(message: string) { + super(message); + this.name = 'CliArgsError'; + } +} + +// args is everything after `install` (so --reset, --set, --set-env, and conf paths). +// Forms accepted: +// --reset --reset= +// --set = --set== +// --set .= +// --set-env = --set-env== +// Anything else is treated as a conf path/name. +export function parseInstallArgs(args: string[]): InstallArgs { + const resets: string[] = []; + const confPaths: string[] = []; + const setValues: SetValue[] = []; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + + if (a === '--reset') { + const next = args[++i]; + if (next === undefined) throw new CliArgsError('--reset requires a path argument'); + resets.push(next); + continue; + } + if (a.startsWith('--reset=')) { + resets.push(a.slice('--reset='.length)); + continue; + } + + if (a === '--set' || a === '--set-env') { + const next = args[++i]; + if (next === undefined) throw new CliArgsError(`${a} requires a NAME=VALUE argument`); + setValues.push(parseSetSpec(next, a === '--set-env')); + continue; + } + if (a.startsWith('--set=')) { + setValues.push(parseSetSpec(a.slice('--set='.length), false)); + continue; + } + if (a.startsWith('--set-env=')) { + setValues.push(parseSetSpec(a.slice('--set-env='.length), true)); + continue; + } + + confPaths.push(a); + } + + return { resets, confPaths, setValues }; +} + +function parseSetSpec(spec: string, fromEnv: boolean): SetValue { + const eq = spec.indexOf('='); + if (eq <= 0) { + throw new CliArgsError( + `${fromEnv ? '--set-env' : '--set'} expects NAME=${fromEnv ? 'ENV_VAR' : 'VALUE'}, got "${spec}"` + ); + } + const lhs = spec.slice(0, eq); + const rhs = spec.slice(eq + 1); + + let scope: string | undefined; + let name = lhs; + const dot = lhs.indexOf('.'); + if (dot >= 0) { + scope = lhs.slice(0, dot); + name = lhs.slice(dot + 1); + if (!SCOPE_RE.test(scope)) throw new CliArgsError(`invalid preset scope "${scope}"`); + } + if (!PROMPT_NAME_RE.test(name)) throw new CliArgsError(`invalid prompt name "${name}"`); + + if (fromEnv) { + if (!ENV_NAME_RE.test(rhs)) { + throw new CliArgsError(`--set-env value must be an env var name, got "${rhs}"`); + } + const resolved = process.env[rhs]; + if (resolved === undefined) { + throw new CliArgsError(`--set-env: environment variable "${rhs}" is not set`); + } + if (resolved === '') { + throw new CliArgsError(`--set-env: environment variable "${rhs}" is empty`); + } + return { scope, name, value: resolved }; + } + return { scope, name, value: rhs }; +} diff --git a/test/cli-args.test.ts b/test/cli-args.test.ts new file mode 100644 index 0000000..1509a62 --- /dev/null +++ b/test/cli-args.test.ts @@ -0,0 +1,143 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseInstallArgs, CliArgsError } from '../src/cli-args.js'; + +describe('parseInstallArgs — existing behavior', () => { + test('plain conf paths', () => { + const r = parseInstallArgs(['foo', 'bar.conf', './baz/qux.conf']); + assert.deepEqual(r.resets, []); + assert.deepEqual(r.setValues, []); + assert.deepEqual(r.confPaths, ['foo', 'bar.conf', './baz/qux.conf']); + }); + + test('--reset two-arg form', () => { + const r = parseInstallArgs(['--reset', 'mcp.foo', 'bar']); + assert.deepEqual(r.resets, ['mcp.foo']); + assert.deepEqual(r.confPaths, ['bar']); + }); + + test('--reset=path form', () => { + const r = parseInstallArgs(['--reset=mcp.foo', 'bar']); + assert.deepEqual(r.resets, ['mcp.foo']); + }); + + test('--reset without value throws', () => { + assert.throws(() => parseInstallArgs(['--reset']), CliArgsError); + }); +}); + +describe('parseInstallArgs — --set', () => { + test('plain NAME=VALUE', () => { + const r = parseInstallArgs(['mcp-http', '--set', 'name=openrag']); + assert.deepEqual(r.confPaths, ['mcp-http']); + assert.deepEqual(r.setValues, [{ scope: undefined, name: 'name', value: 'openrag' }]); + }); + + test('--set=NAME=VALUE attached form', () => { + const r = parseInstallArgs(['--set=url=https://x/y']); + assert.deepEqual(r.setValues, [{ scope: undefined, name: 'url', value: 'https://x/y' }]); + }); + + test('value containing = is preserved verbatim', () => { + const r = parseInstallArgs(['--set', 'headerValue=token=with=equals']); + assert.equal(r.setValues[0].value, 'token=with=equals'); + }); + + test('scoped .=value', () => { + const r = parseInstallArgs(['--set', 'mcp-http.name=openrag']); + assert.deepEqual(r.setValues, [{ scope: 'mcp-http', name: 'name', value: 'openrag' }]); + }); + + test('--set without value throws', () => { + assert.throws(() => parseInstallArgs(['--set']), CliArgsError); + }); + + test('--set with no = throws', () => { + assert.throws(() => parseInstallArgs(['--set', 'foo']), CliArgsError); + }); + + test('--set with empty name throws', () => { + assert.throws(() => parseInstallArgs(['--set', '=value']), CliArgsError); + }); + + test('--set with invalid name throws', () => { + assert.throws(() => parseInstallArgs(['--set', '1bad=value']), CliArgsError); + }); + + test('empty value is allowed', () => { + const r = parseInstallArgs(['--set', 'name=']); + assert.equal(r.setValues[0].value, ''); + }); + + test('multiple --set accumulate', () => { + const r = parseInstallArgs([ + 'mcp-http', + '--set', 'name=openrag', + '--set', 'url=https://x', + '--set=headerName=X-Bitbucket-Token', + ]); + assert.equal(r.setValues.length, 3); + assert.deepEqual(r.setValues.map(s => s.name), ['name', 'url', 'headerName']); + }); +}); + +describe('parseInstallArgs — --set-env', () => { + test('reads from process.env', () => { + process.env.OPENCODE_TEST_TOKEN = 'sekret'; + try { + const r = parseInstallArgs(['--set-env', 'headerValue=OPENCODE_TEST_TOKEN']); + assert.deepEqual(r.setValues, [{ scope: undefined, name: 'headerValue', value: 'sekret' }]); + } finally { + delete process.env.OPENCODE_TEST_TOKEN; + } + }); + + test('unset env var throws', () => { + delete process.env.OPENCODE_DEFINITELY_NOT_SET; + assert.throws( + () => parseInstallArgs(['--set-env', 'x=OPENCODE_DEFINITELY_NOT_SET']), + CliArgsError, + ); + }); + + test('invalid env var name throws', () => { + assert.throws(() => parseInstallArgs(['--set-env', 'x=not a var']), CliArgsError); + }); + + test('--set-env=NAME=VAR attached form', () => { + process.env.OPENCODE_TEST_TOKEN2 = 'v'; + try { + const r = parseInstallArgs(['--set-env=headerValue=OPENCODE_TEST_TOKEN2']); + assert.equal(r.setValues[0].value, 'v'); + } finally { + delete process.env.OPENCODE_TEST_TOKEN2; + } + }); + + test('empty env var value throws', () => { + process.env.OPENCODE_TEST_EMPTY = ''; + try { + assert.throws( + () => parseInstallArgs(['--set-env', 'x=OPENCODE_TEST_EMPTY']), + /is empty/, + ); + } finally { + delete process.env.OPENCODE_TEST_EMPTY; + } + }); +}); + +describe('parseInstallArgs — mixed', () => { + test('resets, sets, and confs interleaved', () => { + const r = parseInstallArgs([ + '--reset', 'mcp.foo', + 'mcp-http', + '--set', 'name=openrag', + 'permissions-git-safe', + '--set=url=https://x', + ]); + assert.deepEqual(r.resets, ['mcp.foo']); + assert.deepEqual(r.confPaths, ['mcp-http', 'permissions-git-safe']); + assert.equal(r.setValues.length, 2); + }); +}); diff --git a/test/distribute-set-values.test.ts b/test/distribute-set-values.test.ts new file mode 100644 index 0000000..214e55a --- /dev/null +++ b/test/distribute-set-values.test.ts @@ -0,0 +1,78 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { distributeSetValues } from '../src/batch.js'; +import type { BatchModule } from '../src/batch.js'; +import type { SetValue } from '../src/cli-args.js'; +import type { ConfMeta } from '../src/parse-conf.js'; + +function mod(name: string, prompts: string[]): BatchModule { + const meta: ConfMeta = { + name, + description: '', + author: '', + version: '0.0.0', + path: 'x', + mode: 'replace', + fetch: [], + prompts: prompts.map(p => ({ name: p, type: 'text' as const, help: '' })), + }; + return { confPath: `${name}.conf`, meta, body: {} }; +} + +const sv = (name: string, value: string, scope?: string): SetValue => + ({ scope, name, value }); + +describe('distributeSetValues', () => { + test('routes unscoped value to the only module that declares it', () => { + const m = mod('mcp-http', ['name', 'url']); + distributeSetValues([m], [sv('name', 'openrag'), sv('url', 'https://x')]); + assert.deepEqual(m.promptValues, { name: 'openrag', url: 'https://x' }); + }); + + test('scoped value targets the named module', () => { + const a = mod('mcp-http', ['name']); + const b = mod('other', ['name']); + distributeSetValues([a, b], [sv('name', 'openrag', 'mcp-http')]); + assert.deepEqual(a.promptValues, { name: 'openrag' }); + assert.equal(b.promptValues, undefined); + }); + + test('rejects unknown prompt name', () => { + const m = mod('mcp-http', ['name']); + assert.throws( + () => distributeSetValues([m], [sv('typo', 'x')]), + /no installed module declares a prompt/, + ); + }); + + test('rejects scoped value when scope does not match', () => { + const m = mod('mcp-http', ['name']); + assert.throws( + () => distributeSetValues([m], [sv('name', 'x', 'other')]), + /no module named "other"/, + ); + }); + + test('rejects ambiguous unscoped value across modules', () => { + const a = mod('mcp-http', ['name']); + const b = mod('mcp-http-noauth', ['name']); + assert.throws( + () => distributeSetValues([a, b], [sv('name', 'x')]), + /ambiguous/, + ); + }); + + test('rejects duplicate assignment to the same prompt', () => { + const m = mod('mcp-http', ['name']); + assert.throws( + () => distributeSetValues([m], [sv('name', 'a'), sv('name', 'b')]), + /provided more than once/, + ); + }); + + test('no-op when setValues is empty', () => { + const m = mod('mcp-http', ['name']); + distributeSetValues([m], []); + assert.equal(m.promptValues, undefined); + }); +});