Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ export OPENCODE_PRESETS_CACHE=/tmp/oc-test/cache
npm run build && node dist/bin/opencode-presets.js install ./presets/<name>.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

Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<preset>.<name>` 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 |
Expand Down
40 changes: 20 additions & 20 deletions bin/opencode-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -87,7 +88,18 @@ async function main(): Promise<void> {
}

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);
Expand All @@ -96,6 +108,7 @@ async function main(): Promise<void> {
await runBatch({
resets,
confPaths: resolved,
setValues,
target: TARGET,
cacheDir: CACHE_DIR,
backupDir: BACKUP_DIR,
Expand Down Expand Up @@ -141,24 +154,6 @@ async function fileExists(path: string): Promise<boolean> {
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<void> {
const existing = await loadJsonOrNull(TARGET);
if (existing === null) {
Expand Down Expand Up @@ -300,9 +295,14 @@ async function loadJsonOrNull(path: string): Promise<Record<string, unknown> | n
function printUsage(): void {
console.log('Usage:');
console.log(' opencode-presets list [<dir>] [--long] list available .conf modules');
console.log(' opencode-presets install [--reset <path>]... <conf>...');
console.log(' opencode-presets install [--reset <path>]... [--set NAME=VALUE]... <conf>...');
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 <preset>.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 <conf>... remove one or more installed presets');
console.log(' opencode-presets reset [<path>] wipe a path, or wipe everything');
console.log(' to the minimal baseline if no path');
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
83 changes: 70 additions & 13 deletions src/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,7 +17,7 @@ const ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
type Json = unknown;
type JsonObject = Record<string, unknown>;

interface BatchModule {
export interface BatchModule {
confPath: string;
meta: ConfMeta;
body: Json;
Expand All @@ -42,6 +43,7 @@ interface ResetStat {
export interface RunBatchOpts {
resets: string[];
confPaths: string[];
setValues?: SetValue[];
target: string;
cacheDir: string;
backupDir: string;
Expand All @@ -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<void> {
export async function runBatch({ resets, confPaths, setValues, target, cacheDir, backupDir }: RunBatchOpts): Promise<void> {
const modules: BatchModule[] = [];
for (const cp of confPaths) {
try {
Expand All @@ -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('');
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -460,12 +482,47 @@ function expandPromptsInValue(value: Json, promptValues: Record<string, string>)
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 <preset>.${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);
}
Expand Down
Loading