From 91f2925c635767363e2b05b2264756774fa44d0c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 22 Sep 2026 17:28:56 +0000 Subject: [PATCH] docs(config): document opener settings (#1945) * docs(config): document opener settings * test(config): harden opener argument contract --- .../reference/configuration/config-json.md | 38 ++++++++++++++++++- test/commands/workset.test.ts | 33 +++++++++++----- test/core/openers.test.ts | 14 +++++++ 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/docs-lab/reference/configuration/config-json.md b/docs-lab/reference/configuration/config-json.md index 464b0c14..44284080 100644 --- a/docs-lab/reference/configuration/config-json.md +++ b/docs-lab/reference/configuration/config-json.md @@ -15,7 +15,7 @@ The CLI keeps its machine-level settings at `~/.config/openspec/config.json` on | `workflows` | list of strings | No | The workflow list a `custom` profile installs | | `featureFlags` | map: flag → boolean | No | Boolean feature toggles | | `defaultStore` | string | No | Machine-level fallback store for root resolution | -| `openers` | list | No | The tools worksets open in, and how each is launched | +| `openers` | map: tool id → settings | No | The tools worksets open in, and how each is launched | | `telemetry` | map | No | Telemetry opt-out, anonymous id, and notice-seen state | ### profile @@ -40,7 +40,41 @@ The machine-level fallback store id for root resolution, consulted only when no ### openers -The tools a workset can open in, and how each is launched. Entries are hand-edited and validated on use. Each may set `style` (`workspace-file` or `attach-dirs`), `label`, `command`, `args`, and `attach_flag`, and is merged over the built-in defaults. +The tools a workset can open in, keyed by tool id. Edit `openers` in the global `config.json` with `openspec config edit` in your terminal. + +| Field | Contract | +| --- | --- | +| `style` | `workspace-file` or `attach-dirs`. Required for a new tool; optional for a built-in. | +| `label` | Non-empty string shown in the tool picker. Defaults to the id for a new tool. | +| `command` | Non-empty executable name or path. Defaults to the id for a new tool. Put arguments in `args`, not in this string. | +| `args` | Array of strings passed before the workspace file or attach flags. Defaults to `[]` for a new tool. | +| `attach_flag` | Non-empty string paired with each member path for `attach-dirs`. Defaults to `--add-dir` for a new tool. Ignored for `workspace-file`. | + +**Built-in overrides:** `code`, `cursor`, `claude`, and `codex` retain any fields you omit. Setting `args` replaces the entire argument list; `[]` clears it. + +**Launch styles:** `workspace-file` passes the generated `.code-workspace` path to the executable. `attach-dirs` passes one flag/path pair per member, including the primary member. + +**Availability:** `attach-dirs` openers, including Claude Code and Codex, are disabled by default. You cannot select or save them with `--tool`, and OpenSpec refuses to open a workset that already names one. Configuration overrides do not enable the `attach-dirs` launch style. + +**Validation:** unknown fields, invalid types, and a new tool without `style` fail when a workset command reads the opener table. + +This example adds VS Code Insiders and passes `--new-window` whenever the built-in VS Code opener launches: + +```json +{ + "openers": { + "code-insiders": { + "style": "workspace-file", + "label": "VS Code Insiders" + }, + "code": { + "args": ["--new-window"] + } + } +} +``` + +The corresponding `code-insiders` or `code` executable must be installed and available on `PATH`. ### telemetry diff --git a/test/commands/workset.test.ts b/test/commands/workset.test.ts index e1ad7321..ef4901b3 100644 --- a/test/commands/workset.test.ts +++ b/test/commands/workset.test.ts @@ -613,24 +613,35 @@ describe('openspec workset (7.1)', () => { }); describe('opener config', () => { - it('adds a new workspace-file tool from config', async () => { - writeOpenersConfig({ zed: { style: 'workspace-file' } }); - await createPlatform(['--tool', 'zed']); - const fakeZed = createFakeTool(tempDir, 'zed'); + it.each([ + { tool: 'code-insiders', args: [] }, + { tool: 'code', args: ['--new-window'] }, + ])('launches the documented $tool opener configuration', async ({ tool, args }) => { + delete process.env.OPENSPEC_ENABLE_CLI_AGENT_OPENERS; + writeOpenersConfig({ + 'code-insiders': { style: 'workspace-file', label: 'VS Code Insiders' }, + code: { args: ['--new-window'] }, + }); + const created = await createPlatform(['--tool', tool]); + expect(created.exitCode).toBe(0); + const fakeEditor = createFakeTool(tempDir, tool); const result = await runCLI(['workset', 'open', 'platform'], { cwd: tempDir, - env: envWithFakeTools(env, [fakeZed]), + env: envWithFakeTools(env, [fakeEditor]), }); expect(result.exitCode).toBe(0); - expect(readLaunchLog(fakeZed.logPath).args).toEqual([ - getWorksetCodeWorkspacePath('platform', pathOptions()), - ]); + expect(readLaunchLog(fakeEditor.logPath)).toEqual({ + cwd: memberA, + args: [...args, getWorksetCodeWorkspacePath('platform', pathOptions())], + }); }); - it('renaming an attach flag is a one-line local fix', async () => { - writeOpenersConfig({ claude: { attach_flag: '--dir' } }); + it('passes configured args before configured attach pairs', async () => { + writeOpenersConfig({ + claude: { args: ['--model', 'opus'], attach_flag: '--dir' }, + }); await createPlatform(['--tool', 'claude']); const fakeClaude = createFakeTool(tempDir, 'claude'); @@ -641,6 +652,8 @@ describe('openspec workset (7.1)', () => { expect(result.exitCode).toBe(0); expect(readLaunchLog(fakeClaude.logPath).args).toEqual([ + '--model', + 'opus', '--dir', memberA, '--dir', diff --git a/test/core/openers.test.ts b/test/core/openers.test.ts index fb440ab5..24902f3e 100644 --- a/test/core/openers.test.ts +++ b/test/core/openers.test.ts @@ -71,6 +71,17 @@ describe('openers core', () => { expect(claude?.style).toBe('attach-dirs'); }); + it.each([{ args: [] }, { args: ['--model', 'custom model'] }])( + 'replaces built-in args with $args without changing omitted fields', + ({ args }) => { + const builtin = findOpener([...BUILTIN_OPENERS], 'codex')!; + const table = mergeOpenerTable({ codex: { args } }, CONFIG_PATH); + + expect(findOpener(table, 'codex')).toEqual({ ...builtin, args }); + expect(builtin.args).toEqual(['--sandbox', 'workspace-write']); + } + ); + it('rejects an unknown style naming the two valid styles', () => { try { mergeOpenerTable({ vim: { style: 'tabs' } }, CONFIG_PATH); @@ -98,6 +109,9 @@ describe('openers core', () => { expect(() => mergeOpenerTable({ zed: { style: 'workspace-file', extra: 1 } }, CONFIG_PATH) ).toThrowError(/Invalid openers config/); + expect(() => + mergeOpenerTable({ code: { args: ['--wait', 1] } }, CONFIG_PATH) + ).toThrowError(/Invalid openers config/); }); });