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
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
## Testing — never touch the real config

Always point the CLI at a temp target/cache. Never run install/remove/reset
against the user's actual `~/.config/opencode/opencode.json`.
against the user's actual `~/.config/opencode/opencode.json` or
`~/.config/opencode/tui.json`.

```sh
rm -rf /tmp/oc-test && mkdir -p /tmp/oc-test/cache /tmp/oc-test/cfg
export OPENCODE_CONFIG=/tmp/oc-test/cfg/opencode.json
export OPENCODE_TUI_CONFIG=/tmp/oc-test/cfg/tui.json
export OPENCODE_PRESETS_CACHE=/tmp/oc-test/cache
npm run build && node dist/bin/opencode-presets.js install ./presets/<name>.conf
```
Expand All @@ -23,6 +25,7 @@ Every `presets/*.conf` must start with these directives (order doesn't
matter):

- `@name`, `@description`, `@author`, `@version`, `@path` — required.
- `@target` — `config` (default, writes `opencode.json`) | `tui` (writes `tui.json`).
- `@mode` — `replace` (default) | `merge` | `merge-overwrite` | `append`.
- `@fetch: URL -> dest [sha256=hex]` — repeatable.
- `@prompt: name | type | help | default` — repeatable; type ∈
Expand Down Expand Up @@ -50,7 +53,7 @@ explicitly and call it out in the module's `@description`. Do not

## Backups — skip on no-op

A backup must be written before every actual write to opencode.json,
A backup must be written before every actual write to a target config file,
and skipped when the apply is a byte-equal no-op. Don't introduce
backups for `--help`, `list`, or declined prompts.

Expand Down Expand Up @@ -85,7 +88,7 @@ chars. When adding entries to `permission.bash`:
## File naming and placement

- New modules go in `presets/` with a category prefix:
`permissions-*`, `mcp-*`, `lsp-*`, etc.
`permissions-*`, `mcp-*`, `lsp-*`, `tui-*`, etc.
- One concern per module. If a module would touch two unrelated
paths, split it.
- Choose `@path` deep enough that two unrelated modules don't overlap.
Expand Down
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
<img src="logo.svg" alt="opencode-presets" width="720">
</p>

Small CLI that patches `~/.config/opencode/opencode.json` from
prepared presets. Use it to add LSP overrides, MCP servers, and
permission rules without hand-editing JSON.
Small CLI that patches OpenCode config files from prepared presets.
Use it to add LSP overrides, MCP servers, permission rules, and TUI
preferences without hand-editing JSON.

## Install

Expand Down Expand Up @@ -91,6 +91,7 @@ on a readline for the next.
| `permissions-container-info` | Permissions | merge | Read-only docker, podman, oc inspection commands |
| `permissions-toolchain-info` | Permissions | merge | Version probes for common dev toolchains |
| `default-agent-plan` | Agent | replace | Sets the default agent to "plan" so opencode always starts in plan mode instead of build mode |
| `tui-disable-mouse` | TUI | replace | Disables TUI mouse capture so native terminal selection and scrolling keep working |

Install multiple at once:

Expand Down Expand Up @@ -156,13 +157,20 @@ For ad-hoc presets you don't want to put in a repo and don't need
on other machines, drop them in `./presets/` from wherever you run
the tool, or use `OPENCODE_PRESETS_PATH`.

## Pointing at a different opencode.json
## Pointing at different config files

```sh
OPENCODE_CONFIG=/path/to/other-opencode.json opencode-presets install ...
OPENCODE_PRESETS_CACHE=/some/cache opencode-presets install ...
```

TUI presets target `~/.config/opencode/tui.json` by default. Override
that path with `OPENCODE_TUI_CONFIG`:

```sh
OPENCODE_TUI_CONFIG=/path/to/tui.json opencode-presets install tui-disable-mouse
```

## Writing your own preset

Plain JSONC with a header. Drop into one of the dirs above, or pass
Expand All @@ -173,11 +181,17 @@ an absolute path.
// @description: one paragraph of what this fixes / sets up.
// @author: you <you@example.com>
// @version: 1.0.0
// @target: config
// @path: some.dotted.path
// @mode: merge
{ "key": "value" }
```

`@target` is optional and defaults to `config`, which writes
`opencode.json`. Use `@target: tui` for TUI presets that write
`tui.json`. A single install or remove operation cannot mix `config`
and `tui` presets; run separate commands for those.

`@fetch: <url> -> <dest> [sha256=hex]` downloads to the cache.
`@prompt: name | text|secret | help` collects input at install time.
Both repeatable. Reference fetched files as `{{cache}}/<name>` and
Expand Down
11 changes: 9 additions & 2 deletions bin/opencode-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const BACKUP_DIR = resolve(CACHE_DIR, 'backups');
const TARGET = process.env.OPENCODE_CONFIG
? resolve(process.env.OPENCODE_CONFIG)
: resolve(homedir(), '.config/opencode/opencode.json');
const TUI_TARGET = process.env.OPENCODE_TUI_CONFIG
? resolve(process.env.OPENCODE_TUI_CONFIG)
: resolve(homedir(), '.config/opencode/tui.json');

const __dirname = dirname(fileURLToPath(import.meta.url));
// __dirname is either <repo>/bin (running source via ts) or
Expand Down Expand Up @@ -55,6 +58,7 @@ const DEFAULT_PRESET_DIRS: string[] = [
];

const SCHEMA_URL = 'https://opencode.ai/config.json';
const TUI_SCHEMA_URL = 'https://opencode.ai/tui.json';
const EMPTY_CONFIG = { '$schema': SCHEMA_URL };

async function main(): Promise<void> {
Expand Down Expand Up @@ -114,7 +118,8 @@ async function main(): Promise<void> {
resets,
confPaths: resolved,
setValues,
target: TARGET,
targets: { config: TARGET, tui: TUI_TARGET },
schemas: { config: SCHEMA_URL, tui: TUI_SCHEMA_URL },
cacheDir: CACHE_DIR,
backupDir: BACKUP_DIR,
});
Expand All @@ -127,7 +132,8 @@ async function main(): Promise<void> {
const resolved = await Promise.all(args.map(resolveConfArg));
await runRemoveBatch({
confPaths: resolved,
target: TARGET,
targets: { config: TARGET, tui: TUI_TARGET },
schemas: { config: SCHEMA_URL, tui: TUI_SCHEMA_URL },
cacheDir: CACHE_DIR,
backupDir: BACKUP_DIR,
});
Expand Down Expand Up @@ -314,6 +320,7 @@ function printUsage(): void {
console.log('');
console.log('Environment:');
console.log(' OPENCODE_CONFIG target opencode.json (default ~/.config/opencode/opencode.json)');
console.log(' OPENCODE_TUI_CONFIG target tui.json (default ~/.config/opencode/tui.json)');
console.log(' OPENCODE_PRESETS_CACHE cache dir (default ~/.cache/opencode-presets)');
console.log(' OPENCODE_PRESETS_PATH colon-separated extra preset dirs (searched first by `list`,');
console.log(' ahead of ./presets and <repo>/presets)');
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "opencode-presets",
"version": "0.8.0",
"description": "Interactive CLI that patches opencode.json with curated config presets — LSP, MCP, permissions.",
"description": "Interactive CLI that patches OpenCode config files with curated presets — LSP, MCP, permissions, TUI.",
"type": "module",
"bin": {
"opencode-presets": "dist/bin/opencode-presets.js"
Expand Down
9 changes: 9 additions & 0 deletions presets/tui-disable-mouse.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// @name: tui-disable-mouse
// @description: Disables mouse capture in the OpenCode TUI so the terminal's
// native selection and scrolling behavior is preserved.
// @author: Jan <jan@trick77.com>
// @version: 0.1.0
// @target: tui
// @path: mouse

false
71 changes: 50 additions & 21 deletions src/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';

import { parseConf } from './parse-conf.js';
import type { ConfMeta, FetchDirective } from './parse-conf.js';
import type { ConfMeta, ConfTarget, FetchDirective } from './parse-conf.js';
import { applyAtPath, removeAtPath, getAtPath } from './merge.js';
import type { ApplyStats, RemoveStats, MergeMode } from './merge.js';
import { fetchAsset } from './fetch-asset.js';
Expand All @@ -12,6 +12,7 @@ import type { SetValue } from './cli-args.js';
import { validateAgainstSchema } from './validate.js';

const SCHEMA_URL = 'https://opencode.ai/config.json';
const TUI_SCHEMA_URL = 'https://opencode.ai/tui.json';
const ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;

type Json = unknown;
Expand Down Expand Up @@ -44,21 +45,26 @@ export interface RunBatchOpts {
resets: string[];
confPaths: string[];
setValues?: SetValue[];
target: string;
targets?: Record<ConfTarget, string>;
schemas?: Record<ConfTarget, string>;
target?: string;
cacheDir: string;
backupDir: string;
}

export interface RunRemoveBatchOpts {
confPaths: string[];
target: string;
targets?: Record<ConfTarget, string>;
schemas?: Record<ConfTarget, string>;
target?: string;
cacheDir: string;
backupDir: string;
}

// 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, setValues, target, cacheDir, backupDir }: RunBatchOpts): Promise<void> {
export async function runBatch(opts: RunBatchOpts): Promise<void> {
const { resets, confPaths, setValues, cacheDir, backupDir } = opts;
const modules: BatchModule[] = [];
for (const cp of confPaths) {
try {
Expand All @@ -79,6 +85,11 @@ export async function runBatch({ resets, confPaths, setValues, target, cacheDir,
process.exit(1);
}

const targetName = resolveBatchTarget(modules, resets);
const targets = opts.targets ?? { config: opts.target!, tui: opts.target! };
const schemas = opts.schemas ?? { config: SCHEMA_URL, tui: TUI_SCHEMA_URL };
const target = targets[targetName];
const schemaUrl = schemas[targetName];
const existing = await loadJsonOrNull(target);

console.log('');
Expand Down Expand Up @@ -149,7 +160,7 @@ export async function runBatch({ resets, confPaths, setValues, target, cacheDir,
}

// ── Compute cumulative new root ──
const startRoot: JsonObject = (existing ?? { '$schema': SCHEMA_URL }) as JsonObject;
const startRoot: JsonObject = (existing ?? { '$schema': schemaUrl }) as JsonObject;
let working: JsonObject = structuredCloneSafe(startRoot);

const preBatchKeysByPath: Record<string, Set<string>> = {};
Expand Down Expand Up @@ -204,19 +215,19 @@ export async function runBatch({ resets, confPaths, setValues, target, cacheDir,
const isNoOp = existing !== null && JSON.stringify(working) === JSON.stringify(existing);
if (isNoOp) {
console.log('');
console.log(' ' + c.dim('· no change — opencode.json untouched, no backup written'));
console.log(' ' + c.dim('· no change — target file untouched, no backup written'));
return;
}

// ── Pre-write validation: abort if the resulting JSON would be invalid.
await validateOrAbort(working, cacheDir, 'pre-write');
await validateOrAbort(working, cacheDir, schemaUrl, 'pre-write');

let backupPath: string | null = null;
try {
backupPath = await backup(target, backupDir);
} catch (e) {
console.error(c.err('error: backup failed: ') + (e instanceof Error ? e.message : String(e)));
console.error(c.err('aborting; opencode.json not modified.'));
console.error(c.err('aborting; target file not modified.'));
process.exit(1);
}
if (backupPath) console.log(' ' + c.ok('✓') + ' backed up → ' + c.meta(backupPath));
Expand All @@ -227,7 +238,7 @@ export async function runBatch({ resets, confPaths, setValues, target, cacheDir,
await rename(tmp, target);

// ── Post-write sanity check: re-read what we just wrote and re-validate.
await validateAfterWrite(target, cacheDir);
await validateAfterWrite(target, cacheDir, schemaUrl);

console.log('');
console.log(renderFooter({ resetStats, modules, backupPath }));
Expand Down Expand Up @@ -323,7 +334,8 @@ function renderFooter(
}

// Atomic multi-module remove: parse all, single confirm, single backup, single write.
export async function runRemoveBatch({ confPaths, target, cacheDir, backupDir }: RunRemoveBatchOpts): Promise<void> {
export async function runRemoveBatch(opts: RunRemoveBatchOpts): Promise<void> {
const { confPaths, cacheDir, backupDir } = opts;
const modules: RemoveModule[] = [];
for (const cp of confPaths) {
let parsed;
Expand All @@ -349,9 +361,14 @@ export async function runRemoveBatch({ confPaths, target, cacheDir, backupDir }:
modules.push({ meta, body, expandedBody });
}

const targetName = resolveBatchTarget(modules, []);
const targets = opts.targets ?? { config: opts.target!, tui: opts.target! };
const schemas = opts.schemas ?? { config: SCHEMA_URL, tui: TUI_SCHEMA_URL };
const target = targets[targetName];
const schemaUrl = schemas[targetName];
const existing = await loadJsonOrNull(target);
if (existing === null) {
console.log(c.dim('opencode.json does not exist — nothing to remove.'));
console.log(c.dim('target file does not exist — nothing to remove.'));
return;
}

Expand All @@ -375,11 +392,11 @@ export async function runRemoveBatch({ confPaths, target, cacheDir, backupDir }:
}

if (JSON.stringify(working) === JSON.stringify(existing)) {
console.log(' ' + c.dim('· no change — opencode.json untouched, no backup written'));
console.log(' ' + c.dim('· no change — target file untouched, no backup written'));
return;
}

await validateOrAbort(working, cacheDir, 'pre-write');
await validateOrAbort(working, cacheDir, schemaUrl, 'pre-write');

let backupPath: string | null = null;
try { backupPath = await backup(target, backupDir); }
Expand All @@ -394,7 +411,7 @@ export async function runRemoveBatch({ confPaths, target, cacheDir, backupDir }:
await writeFile(tmp, JSON.stringify(working, null, 2) + '\n', 'utf8');
await rename(tmp, target);

await validateAfterWrite(target, cacheDir);
await validateAfterWrite(target, cacheDir, schemaUrl);

console.log('');
console.log(c.ok('✓') + ' removed ' + c.bold(`${modules.length} preset${modules.length === 1 ? '' : 's'}`));
Expand All @@ -410,8 +427,20 @@ export async function runRemoveBatch({ confPaths, target, cacheDir, backupDir }:
// Validate the would-be-new config against opencode's JSON schema.
// On invalid, print the errors and abort BEFORE backup/write.
// On schema-unavailable (offline + no cache), print a warning and proceed.
async function validateOrAbort(config: unknown, cacheDir: string, phase: 'pre-write' | 'post-write'): Promise<void> {
const result = await validateAgainstSchema(config, cacheDir);
function resolveBatchTarget(modules: Array<{ meta: ConfMeta }>, resets: string[]): ConfTarget {
const targets = new Set(modules.map(m => m.meta.target));
if (targets.size > 1) {
console.error(c.err('error: ') + 'cannot combine presets with different @target values in one operation.');
console.error(c.err(' ') + 'run separate commands for config and tui presets.');
process.exit(1);
}
if (targets.size === 1) return [...targets][0];
if (resets.length > 0) return 'config';
return 'config';
}

async function validateOrAbort(config: unknown, cacheDir: string, schemaUrl: string, phase: 'pre-write' | 'post-write'): Promise<void> {
const result = await validateAgainstSchema(config, cacheDir, schemaUrl);
if (result.skipped) {
console.error(c.warn('⚠ ') + result.errors.join('; '));
return;
Expand All @@ -420,26 +449,26 @@ async function validateOrAbort(config: unknown, cacheDir: string, phase: 'pre-wr

if (phase === 'pre-write') {
console.error('');
console.error(c.err('✗ ') + c.bold('schema validation failed — would have produced an invalid opencode.json'));
console.error(c.err('✗ ') + c.bold('schema validation failed — would have produced an invalid target file'));
for (const e of result.errors.slice(0, 12)) console.error(' ' + c.err(e));
if (result.errors.length > 12) console.error(c.dim(` … (+${result.errors.length - 12} more)`));
console.error('');
console.error(c.err('aborting; opencode.json not modified.'));
console.error(c.err('aborting; target file not modified.'));
process.exit(1);
} else {
// post-write: shouldn't happen if pre-write passed, but surface as a loud warning.
console.error('');
console.error(c.err('⚠ post-write validation FAILED — opencode.json on disk does not match the schema.'));
console.error(c.err('⚠ post-write validation FAILED — target file on disk does not match the schema.'));
console.error(c.err(' This is unexpected. Errors:'));
for (const e of result.errors.slice(0, 12)) console.error(' ' + c.err(e));
}
}

async function validateAfterWrite(target: string, cacheDir: string): Promise<void> {
async function validateAfterWrite(target: string, cacheDir: string, schemaUrl: string): Promise<void> {
try {
const raw = await readFile(target, 'utf8');
const parsed = JSON.parse(raw);
await validateOrAbort(parsed, cacheDir, 'post-write');
await validateOrAbort(parsed, cacheDir, schemaUrl, 'post-write');
} catch (e) {
console.error(c.err('⚠ could not re-read written file for post-write check: ') +
(e instanceof Error ? e.message : String(e)));
Expand Down
Loading