From 8272bc06bdd2f96d4ff1da8997ef4ab01f5d83a4 Mon Sep 17 00:00:00 2001 From: trick77 Date: Wed, 27 May 2026 16:13:48 +0200 Subject: [PATCH] feat: add TUI mouse disable preset --- AGENTS.md | 9 ++-- README.md | 22 +++++++-- bin/opencode-presets.ts | 11 ++++- package.json | 2 +- presets/tui-disable-mouse.conf | 9 ++++ src/batch.ts | 71 +++++++++++++++++++++--------- src/parse-conf.ts | 9 ++++ src/validate.ts | 29 +++++++----- test/distribute-set-values.test.ts | 1 + test/install-target.test.ts | 66 +++++++++++++++++++++++++++ test/parse-conf.test.ts | 16 +++++++ 11 files changed, 203 insertions(+), 42 deletions(-) create mode 100644 presets/tui-disable-mouse.conf create mode 100644 test/install-target.test.ts 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 @@ opencode-presets

-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