diff --git a/AGENTS.md b/AGENTS.md
index d99bb3a..9c99928 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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/.conf
```
@@ -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 ∈
@@ -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.
@@ -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.
diff --git a/README.md b/README.md
index 2f6d44b..01f5c48 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,9 @@
-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
@@ -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:
@@ -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
@@ -173,11 +181,17 @@ an absolute path.
// @description: one paragraph of what this fixes / sets up.
// @author: you
// @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: -> [sha256=hex]` downloads to the cache.
`@prompt: name | text|secret | help` collects input at install time.
Both repeatable. Reference fetched files as `{{cache}}/` and
diff --git a/bin/opencode-presets.ts b/bin/opencode-presets.ts
index 5fb8f81..3f8f4f7 100755
--- a/bin/opencode-presets.ts
+++ b/bin/opencode-presets.ts
@@ -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 /bin (running source via ts) or
@@ -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 {
@@ -114,7 +118,8 @@ async function main(): Promise {
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,
});
@@ -127,7 +132,8 @@ async function main(): Promise {
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,
});
@@ -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 /presets)');
diff --git a/package.json b/package.json
index 57db133..ecedf02 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/presets/tui-disable-mouse.conf b/presets/tui-disable-mouse.conf
new file mode 100644
index 0000000..67e0c30
--- /dev/null
+++ b/presets/tui-disable-mouse.conf
@@ -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
+// @version: 0.1.0
+// @target: tui
+// @path: mouse
+
+false
diff --git a/src/batch.ts b/src/batch.ts
index 84dc541..6e8cb1c 100644
--- a/src/batch.ts
+++ b/src/batch.ts
@@ -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';
@@ -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;
@@ -44,21 +45,26 @@ export interface RunBatchOpts {
resets: string[];
confPaths: string[];
setValues?: SetValue[];
- target: string;
+ targets?: Record;
+ schemas?: Record;
+ target?: string;
cacheDir: string;
backupDir: string;
}
export interface RunRemoveBatchOpts {
confPaths: string[];
- target: string;
+ targets?: Record;
+ schemas?: Record;
+ 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 {
+export async function runBatch(opts: RunBatchOpts): Promise {
+ const { resets, confPaths, setValues, cacheDir, backupDir } = opts;
const modules: BatchModule[] = [];
for (const cp of confPaths) {
try {
@@ -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('');
@@ -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> = {};
@@ -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));
@@ -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 }));
@@ -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 {
+export async function runRemoveBatch(opts: RunRemoveBatchOpts): Promise {
+ const { confPaths, cacheDir, backupDir } = opts;
const modules: RemoveModule[] = [];
for (const cp of confPaths) {
let parsed;
@@ -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;
}
@@ -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); }
@@ -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'}`));
@@ -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 {
- 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 {
+ const result = await validateAgainstSchema(config, cacheDir, schemaUrl);
if (result.skipped) {
console.error(c.warn('⚠ ') + result.errors.join('; '));
return;
@@ -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 {
+async function validateAfterWrite(target: string, cacheDir: string, schemaUrl: string): Promise {
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)));
diff --git a/src/parse-conf.ts b/src/parse-conf.ts
index 5afd83e..31d2c85 100644
--- a/src/parse-conf.ts
+++ b/src/parse-conf.ts
@@ -8,6 +8,7 @@ export interface FetchDirective {
}
export type PromptType = 'text' | 'secret';
+export type ConfTarget = 'config' | 'tui';
export interface PromptDirective {
name: string;
@@ -21,6 +22,7 @@ export interface ConfMeta {
description: string;
author: string;
version: string;
+ target: ConfTarget;
path: string;
mode: MergeMode;
fetch: FetchDirective[];
@@ -47,6 +49,7 @@ export function parseConfString(raw: string, filePath = ''): ParsedConf
description: '',
author: '',
version: '',
+ target: 'config',
path: '',
mode: 'replace',
fetch: [],
@@ -75,6 +78,12 @@ export function parseConfString(raw: string, filePath = ''): ParsedConf
case 'path':
meta[key] = value;
break;
+ case 'target':
+ if (value !== 'config' && value !== 'tui') {
+ throw parseError(filePath, i + 1, `@target must be "config" or "tui", got "${value}"`);
+ }
+ meta.target = value;
+ break;
case 'mode':
if (value !== 'replace' && value !== 'merge' && value !== 'merge-overwrite' && value !== 'append') {
throw parseError(filePath, i + 1, `@mode must be "replace", "merge", "merge-overwrite", or "append", got "${value}"`);
diff --git a/src/validate.ts b/src/validate.ts
index d998750..f79ecff 100644
--- a/src/validate.ts
+++ b/src/validate.ts
@@ -13,7 +13,7 @@ export interface ValidationResult {
}
type ValidatorFn = ((v: unknown) => boolean) & { errors?: unknown[] | null };
-let _validator: ValidatorFn | null = null;
+const _validators = new Map();
// Validate `config` against opencode's JSON schema. Lazily fetches and
// caches the schema on first call. On network failure with no cache,
@@ -21,11 +21,12 @@ let _validator: ValidatorFn | null = null;
// surface a non-blocking warning rather than aborting offline use.
export async function validateAgainstSchema(
config: unknown,
- cacheDir: string
+ cacheDir: string,
+ schemaUrl = SCHEMA_URL
): Promise {
let schema: unknown;
try {
- schema = await loadSchema(cacheDir);
+ schema = await loadSchema(cacheDir, schemaUrl);
} catch (e) {
return {
ok: true,
@@ -37,7 +38,8 @@ export async function validateAgainstSchema(
};
}
- if (!_validator) {
+ let validator = _validators.get(schemaUrl);
+ if (!validator) {
const ajv = new Ajv2020({
strict: false, // schema uses non-standard `ref` / `allowComments` keywords
allErrors: true,
@@ -49,7 +51,8 @@ export async function validateAgainstSchema(
},
});
try {
- _validator = (await ajv.compileAsync(schema as object)) as ValidatorFn;
+ validator = (await ajv.compileAsync(schema as object)) as ValidatorFn;
+ _validators.set(schemaUrl, validator);
} catch (e) {
// Schema compile failed (e.g. unresolved $ref offline). Surface as
// a non-blocking skip rather than aborting the user's apply.
@@ -61,7 +64,6 @@ export async function validateAgainstSchema(
}
}
- const validator: ValidatorFn = _validator;
const valid = validator(config);
if (valid) return { ok: true, errors: [] };
@@ -80,8 +82,8 @@ function formatError(err: unknown): string {
return `${path}: ${msg}${extra}`;
}
-async function loadSchema(cacheDir: string): Promise {
- const cachePath = `${cacheDir}/schema.json`;
+async function loadSchema(cacheDir: string, schemaUrl: string): Promise {
+ const cachePath = `${cacheDir}/${schemaCacheName(schemaUrl)}`;
if (await fileExists(cachePath)) {
try {
const raw = await readFile(cachePath, 'utf8');
@@ -91,8 +93,8 @@ async function loadSchema(cacheDir: string): Promise {
}
}
// Fetch and cache.
- const res = await fetch(SCHEMA_URL);
- if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${SCHEMA_URL}`);
+ const res = await fetch(schemaUrl);
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${schemaUrl}`);
const text = await res.text();
// Parse to validate before caching.
const parsed = JSON.parse(text);
@@ -101,6 +103,11 @@ async function loadSchema(cacheDir: string): Promise {
return parsed;
}
+function schemaCacheName(schemaUrl: string): string {
+ if (schemaUrl.endsWith('/tui.json')) return 'tui-schema.json';
+ return 'schema.json';
+}
+
async function fileExists(path: string): Promise {
try { await stat(path); return true; } catch { return false; }
}
@@ -108,5 +115,5 @@ async function fileExists(path: string): Promise {
// Allow callers to force a fresh fetch on next validate (e.g. after a
// hypothetical `opencode-presets schema refresh` command).
export function resetValidatorCache(): void {
- _validator = null;
+ _validators.clear();
}
diff --git a/test/distribute-set-values.test.ts b/test/distribute-set-values.test.ts
index 214e55a..a34cb30 100644
--- a/test/distribute-set-values.test.ts
+++ b/test/distribute-set-values.test.ts
@@ -11,6 +11,7 @@ function mod(name: string, prompts: string[]): BatchModule {
description: '',
author: '',
version: '0.0.0',
+ target: 'config',
path: 'x',
mode: 'replace',
fetch: [],
diff --git a/test/install-target.test.ts b/test/install-target.test.ts
new file mode 100644
index 0000000..855aaa7
--- /dev/null
+++ b/test/install-target.test.ts
@@ -0,0 +1,66 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { spawn } from 'node:child_process';
+
+test('install routes tui presets to OPENCODE_TUI_CONFIG', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'opencode-presets-target-'));
+ const cacheDir = join(dir, 'cache');
+ const opencodeConfig = join(dir, 'opencode.json');
+ const tuiConfig = join(dir, 'tui.json');
+ const preset = join(dir, 'tui-disable-mouse.conf');
+
+ await writeFile(preset, `// @name: tui-disable-mouse
+// @description: Disable mouse capture in the OpenCode TUI.
+// @author: test
+// @version: 0.1.0
+// @target: tui
+// @path: mouse
+
+false
+`, 'utf8');
+
+ const result = await runCli(['install', preset], {
+ OPENCODE_CONFIG: opencodeConfig,
+ OPENCODE_TUI_CONFIG: tuiConfig,
+ OPENCODE_PRESETS_CACHE: cacheDir,
+ }, 'y\n');
+
+ assert.equal(result.code, 0, result.stderr + result.stdout);
+ assert.match(result.stdout, new RegExp(`Target.*${escapeRegExp(tuiConfig)}`));
+ assert.deepEqual(JSON.parse(await readFile(tuiConfig, 'utf8')), {
+ '$schema': 'https://opencode.ai/tui.json',
+ mouse: false,
+ });
+
+ await assert.rejects(() => stat(opencodeConfig), /ENOENT/);
+});
+
+function runCli(
+ args: string[],
+ env: Record,
+ input: string,
+): Promise<{ code: number | null; stdout: string; stderr: string }> {
+ return new Promise((resolvePromise, rejectPromise) => {
+ const bin = resolve(import.meta.dirname, '../bin/opencode-presets.js');
+ const child = spawn(process.execPath, [bin, ...args], {
+ env: { ...process.env, ...env },
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.setEncoding('utf8');
+ child.stderr.setEncoding('utf8');
+ child.stdout.on('data', chunk => { stdout += chunk; });
+ child.stderr.on('data', chunk => { stderr += chunk; });
+ child.on('error', rejectPromise);
+ child.on('close', code => resolvePromise({ code, stdout, stderr }));
+ child.stdin.end(input);
+ });
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
diff --git a/test/parse-conf.test.ts b/test/parse-conf.test.ts
index b76e6dc..b8a581c 100644
--- a/test/parse-conf.test.ts
+++ b/test/parse-conf.test.ts
@@ -17,6 +17,7 @@ describe('parseConfString — required headers', () => {
assert.equal(meta.author, 'someone');
assert.equal(meta.version, '0.1.0');
assert.equal(meta.path, 'a.b.c');
+ assert.equal(meta.target, 'config');
assert.equal(meta.mode, 'replace');
assert.deepEqual(meta.fetch, []);
assert.deepEqual(meta.prompts, []);
@@ -31,6 +32,21 @@ describe('parseConfString — required headers', () => {
}
});
+describe('parseConfString — @target', () => {
+ test('accepts config and tui targets', () => {
+ for (const target of ['config', 'tui']) {
+ const src = minimalHeader + `// @target: ${target}\n\n{ "x": 1 }`;
+ const { meta } = parseConfString(src);
+ assert.equal(meta.target, target);
+ }
+ });
+
+ test('rejects unknown target', () => {
+ const src = minimalHeader + '// @target: bogus\n\n{}';
+ assert.throws(() => parseConfString(src), /@target must be/);
+ });
+});
+
describe('parseConfString — multi-line description', () => {
test('concatenates continuation lines with single spaces', () => {
const src = `// @name: foo