Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
38 changes: 36 additions & 2 deletions docs-lab/reference/configuration/config-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | State the CLI keeps: anonymous id and notice-seen |

### profile
Expand All @@ -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, which must support that format. `attach-dirs` passes one flag/path pair per member, including the primary member.

**Availability:** `attach-dirs` openers, including Claude Code and Codex, are temporarily disabled by default. They are hidden from the picker and rejected by `workset create --tool` and `workset open --tool`. 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

Expand Down
25 changes: 17 additions & 8 deletions test/commands/workset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,20 +613,29 @@ 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 () => {
Expand Down
11 changes: 11 additions & 0 deletions test/core/openers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading