docs(config): document opener settings (#1945)

* docs(config): document opener settings

* test(config): harden opener argument contract
This commit is contained in:
Clay Good
2026-09-22 17:28:56 +00:00
committed by GitHub
parent 416599a85e
commit 91f2925c63
3 changed files with 73 additions and 12 deletions
@@ -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
+23 -10
View File
@@ -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',
+14
View File
@@ -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/);
});
});