From 5ad697138549648fab71dc488a50d235213e4b3d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 15 Sep 2026 16:31:25 -0700 Subject: [PATCH 1/9] Add local-file-only dor open dispatch through user Tools --- docs/specs/dor-cli.md | 1 + docs/specs/dor-tool.md | 20 ++++-- dor/src/cli.ts | 3 + dor/src/commands/open.ts | 54 +++++++++++++++ dor/src/commands/tool.ts | 2 +- dor/src/commands/types.ts | 3 + dor/test/cli-output.test.mjs | 23 ++++++- dor/test/snapshots/help/dor.md | 2 + dor/test/snapshots/help/open.md | 34 ++++++++++ lib/src/components/Wall.test.tsx | 30 +++++++++ lib/src/components/wall/tool-takeover.test.ts | 8 +++ lib/src/components/wall/tool-takeover.ts | 12 ++-- lib/src/components/wall/use-dor-control.ts | 20 ++++-- lib/src/host/tool-host.ts | 2 + lib/src/host/tool-open.test.ts | 66 +++++++++++++++++++ lib/src/host/tool-open.ts | 24 +++++++ lib/src/host/tool-registry.ts | 24 +++++-- lib/src/lib/platform/tool-types.ts | 1 + scripts/spec-word-budgets.json | 4 +- 19 files changed, 309 insertions(+), 24 deletions(-) create mode 100644 dor/src/commands/open.ts create mode 100644 dor/test/snapshots/help/open.md create mode 100644 lib/src/host/tool-open.test.ts create mode 100644 lib/src/host/tool-open.ts diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 1d931af0d..09ca234b0 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -422,6 +422,7 @@ The spec keeps the behavior help cannot express: | `list` | Filters are ANDed client-side; `--port` filters terminals (browser Surfaces never match) and implies the opt-in detail scan, `--ports` only requests it. **Owns every Workspace read**: `--workspace` narrows to one, `--all` groups every Workspace's rows under its header — **every Workspace keeps its header**, including one a filter emptied, so the text listing and the JSON `workspaces` array name the same Workspaces — `--workspaces` is the overview, and the three cannot be combined. **`--all --json` adds `caller_workspace_ref` / `focused_workspace_ref`** beside the `_surface_ref` pair, which under `--all` names a `surface:N` every Workspace has; the `_surface_id` halves stay unique. **`--workspaces` takes `--json` and nothing else**, by an allowlist, so a flag added to `list` is refused there until it is named. | | `workspace` | **Mutation only** ([dor workspace](#dor-workspace)). | | `skill` | Prints the bundled skill or installs its bootstrap stub; [Agent Skill](#agent-skill) owns the contract. | +| `tool`, `open` | `docs/specs/dor-tool.md` owns Tool inputs and local-file dispatch. | **`await` never prints terminal text.** Stdout is only the resolution cause, the narrative goes to stderr on every outcome, and JSON appears only on a resolution diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index 8ed1ae3e8..d406f1b42 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -79,7 +79,7 @@ Source of truth: `acquireToolSpawnLock` / the `surface.tool` handler in `lib/src 6. **Must share grant updates safely across host processes**, merging against the latest file under the existing lock and atomic-write protocol. 7. **Never content-hash grants or re-prompt solely because the config changed.** (rationale) -Reserved: **Must keep future implicit glob dispatch user-global and limited to user-global Tools**, and gate any future repo `prespawn_*` execution on the same approval; see scope **dor-tools** under [Future](#future). +**Must keep implicit file dispatch user-global and limited to user-global Tools.** Reserved: any future repo `prespawn_*` execution uses the same approval; see scope **dor-tools** under [Future](#future). Source of truth: `createToolHost` in `lib/src/host/tool-host.ts`; `FileToolTrustStore` / `lookupTool` in `lib/src/host/tool-trust.ts`; `resolveUpstreamUrl` in `lib/src/host/git-upstream.ts`; `ToolApproval` in `lib/src/components/wall/ToolApproval.tsx`; `resolveToolApproval` in `lib/src/components/Wall.tsx`. Tests: `lib/src/host/tool-trust.test.ts`, `lib/src/components/Wall.test.tsx`. @@ -131,6 +131,18 @@ Source of truth: `ToolPanel` in `lib/src/components/wall/ToolPanel.tsx`; `ToolPa Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `dor/test/snapshots/help/tool.md`; `ToolSurfaceResponse` in `dor/src/commands/types.ts`. +## Opening local files + +**Must accept exactly one existing local regular file for `dor open`.** Resolve it with the `$TARGET` rules in Declaring tools. URLs (including `file:`), directories, and Surface handles fail; no native-editor fallback occurs. + +**Must select the first matching entry of the user file's ordered `open` list**, whose entries contain `match` and `tool`. `--tool` explicitly selects a user Tool. Every association must name a Tool in that same user file. Never discover project configuration during this lookup; project `open` rules are ignored with a warning during explicit project-tool lookup. + +**Must match patterns without `/` against the canonical filename, and patterns with `/` against the canonical path relative to the invocation CWD.** Normalize separators to `/` and use Node's POSIX `matchesGlob` semantics, including explicit patterns for dotfiles. A miss names the user config path and suggests `--tool`. + +**Must pass the canonical file path as one input to the selected Tool.** Reuse follows Identity and dedupe; `$TARGET` in the key provides per-file identity. `--fresh` bypasses reuse. **Never transform a plain calling terminal through `dor open`.** Create a focus-neutral split or reveal the existing Tool; an idle match in the caller's Tool pane uses the answer/prompt handshake only for a standalone integrated `dor open` invocation. + +Source of truth: `openCommand` in `dor/src/commands/open.ts`; `resolveOpenTool` in `lib/src/host/tool-open.ts`; `parseToolFile` in `lib/src/host/tool-registry.ts`; `surface.tool` in `lib/src/components/wall/use-dor-control.ts`. Tests: `lib/src/host/tool-open.test.ts`, `dor/test/cli-output.test.mjs`, `lib/src/components/Wall.test.tsx`. + ## Take-over **Must run a standalone `dor tool` invocation in its calling pane when every takeover condition holds.** Otherwise use the ordinary split path. Trust approval and keyed reuse take precedence. (rationale) @@ -193,10 +205,8 @@ Source of truth: `PersistedToolMetadata` in `lib/src/lib/session-types.ts`; `sav **Scope: dor-tools** — remaining design, in implementation order. -- **C — glob table + `dor open`.** User-global glob rules - (pattern → tool name), `dor open ` as sugar over `dor tool`, - and the loopback file/viewer endpoint a local *file* needs (the - iframe proxy instruments only `http://` upstreams). +- **C — local-file presentation.** The loopback file/viewer endpoint a local + file needs (the iframe proxy instruments only `http://` upstreams). - **D1 — reaping without cooperation.** Idle-threshold reap + rehydrate-from-args + `persist: "never"`: every stateless tool, no new API, no Windows question. diff --git a/dor/src/cli.ts b/dor/src/cli.ts index faf7d6b07..7cf936b7d 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -18,6 +18,7 @@ import { sendCommand } from './commands/send.js'; import { skillCommand } from './commands/skill.js'; import { splitCommand } from './commands/split.js'; import { toolCommand } from './commands/tool.js'; +import { openCommand } from './commands/open.js'; import { versionCommand } from './commands/version.js'; import { workspaceCommand } from './commands/workspace.js'; import { errorLine, errorMessage, fail } from './commands/shared.js'; @@ -90,6 +91,7 @@ const COMMANDS = [ splitCommand, ensureCommand, toolCommand, + openCommand, versionCommand, skillCommand, sendCommand, @@ -106,6 +108,7 @@ const ROUTES = { split: splitCommand.command, ensure: ensureCommand.command, tool: toolCommand.command, + open: openCommand.command, version: versionCommand.command, skill: skillCommand.command, send: sendCommand.command, diff --git a/dor/src/commands/open.ts b/dor/src/commands/open.ts new file mode 100644 index 000000000..78617e6cf --- /dev/null +++ b/dor/src/commands/open.ts @@ -0,0 +1,54 @@ +import { buildCommand } from '@stricli/core'; +import type { Command, DorCommandContext } from './types.js'; +import { callerWorkingDirectory, errorMessage, requireControlClient, stringParser, workspaceFlag, workspaceParam, writeStderr, writeStdout } from './shared.js'; +import { renderToolResponse } from './tool.js'; + +interface OpenFlags { + json?: boolean; + minimize?: boolean; + fresh?: boolean; + surface?: string; + workspace?: string; + cwd?: string; + tool?: string; +} + +export const openCommand: Command = { + name: 'open', + command: buildCommand({ + docs: { + brief: 'Open a local file with a Dor Tool.', + fullDescription: `Opens one existing local file. Relative paths resolve from the caller's directory (or --cwd); symlink aliases resolve to the same file. URLs, directories, and Surface handles are not accepted. + +The first matching rule in the user dormouse.yml selects a user Tool. --tool chooses a user Tool explicitly. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. + +The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match the canonical file path relative to the invocation directory, using forward slashes and Node glob syntax. Dotfiles require explicit patterns. + +The selected Tool receives the canonical absolute filename as one argument. Configure prespawn_dedupe: [$TARGET] to reveal the same file on repeated opens within a Workspace. --fresh bypasses reuse. + +Opening creates a focus-neutral split or reveals an existing Tool, never taking over the caller's terminal. The command prints the Surface handle; --json prints structured output.`, + }, + parameters: { + flags: { + json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, + minimize: { kind: 'boolean', brief: 'Create the surface minimized.', optional: true, withNegated: false }, + fresh: { kind: 'boolean', brief: 'Open another instance even when the Tool has a key.', optional: true, withNegated: false }, + surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split when creating.', optional: true, placeholder: 'id|ref' }, + workspace: workspaceFlag, + cwd: { kind: 'parsed', parse: stringParser, brief: 'Directory for resolving the file.', optional: true, placeholder: 'path' }, + tool: { kind: 'parsed', parse: stringParser, brief: 'Use this user-global Tool.', optional: true, placeholder: 'name' }, + }, + positional: { kind: 'tuple', parameters: [{ parse: stringParser, brief: 'Local file to open.', placeholder: 'file' }] }, + }, + async func(this: DorCommandContext, flags: OpenFlags, file: string) { + const client = requireControlClient(this.options, 20_000); + if (client instanceof Error) return client; + try { + const response = await client.toolSurface({ file, tool: flags.tool, cwd: callerWorkingDirectory(flags.cwd, this.options.env), + fresh: flags.fresh === true, minimized: flags.minimize === true, surface: flags.surface, ...workspaceParam(flags.workspace) }); + for (const warning of response.warnings ?? []) writeStderr(this, `${warning}\n`); + writeStdout(this, renderToolResponse(response, flags.json === true)); + } catch (error) { return new Error(errorMessage(error)); } + }, + }), +}; diff --git a/dor/src/commands/tool.ts b/dor/src/commands/tool.ts index 080058e69..4a1f52d0b 100644 --- a/dor/src/commands/tool.ts +++ b/dor/src/commands/tool.ts @@ -187,7 +187,7 @@ async function runToolCommand(this: DorCommandContext, flags: ToolFlags, ...rest } } -function renderToolResponse(response: ToolSurfaceResponse, json: boolean): string { +export function renderToolResponse(response: ToolSurfaceResponse, json: boolean): string { if (json) { return renderJson({ status: response.status, diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index 33a42d7bc..208c1f5ba 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -255,6 +255,9 @@ export interface EnsureSurfaceResponse { * authorized it (`docs/specs/dor-tool.md` -> Trust). */ export interface ToolSurfaceRequest extends WorkspaceScopedRequest { + /** Local-file dispatch; never eligible for caller takeover. */ + file?: string; + tool?: string; /** Registered tool name (`dor tool `). */ name?: string; args?: string[]; diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 8a9930798..51688f647 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -185,7 +185,7 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { const named = typeof request.name === 'string'; const command = named ? `pnpm ${request.name}` - : buildShellCommandForKind('posix', request.command); + : request.file ? `viewer ${request.file}` : buildShellCommandForKind('posix', request.command); const keyed = named && request.name === 'storybook' && !request.fresh; return { status: keyed ? 'existing' : 'created', @@ -1799,3 +1799,24 @@ test('tool routes named and anonymous launches to an explicit Workspace', async assert.equal(client.requests[0].request.workspace, 'workspace:2'); } }); + +test('open forwards one file, explicit handler, placement, and Workspace to Tool dispatch', async () => { + const client = fixtureClient(); + const result = await runCli(['open', '--json', '--tool', 'markdown', '--workspace', 'workspace:2', '--fresh', '--minimize', '--surface', 'surface:4', 'a b.md'], { client, env: { PWD: '/repo' } }); + assert.equal(result.exitCode, 0); + client.requests[0].request.cwd = smudgeWindowsPaths(client.requests[0].request.cwd); + assert.deepEqual(client.requests[0], { method: 'toolSurface', request: { + file: 'a b.md', tool: 'markdown', cwd: '/repo', + workspace: 'workspace:2', fresh: true, minimized: true, surface: 'surface:4', + } }); + assert.equal(JSON.parse(result.stdout).surface_ref, 'surface:4'); +}); + +test('open requires exactly one file', async () => { + for (const args of [['open'], ['open', 'one.md', 'two.md']]) { + const client = fixtureClient(); + const result = await runCli(args, { client }); + assert.equal(result.exitCode, 1); + assert.equal(client.requests.length, 0); + } +}); diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index 9d445e507..3ffb3ae8a 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -8,6 +8,7 @@ USAGE dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] [--workspace ref] -- ... dor tool [--global] [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] [--workspace ref] [args...] dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] [--workspace ref] -- ... + dor open [--json] [--minimize] [--fresh] [--surface id|ref] [--workspace ref] [--cwd path] [--tool name] dor version [--json] dor skill [--install] [--json] dor send ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] [--workspace ref] @@ -30,6 +31,7 @@ COMMANDS split Create a new terminal surface by splitting an existing surface. ensure Ensure one surface is running a command. tool Run a command as a Dor Tool. + open Open a local file with a Dor Tool. version Print the dor CLI version. skill Print the Dormouse agent skill, or install its bootstrap stub. send Send text or key input to a terminal surface. diff --git a/dor/test/snapshots/help/open.md b/dor/test/snapshots/help/open.md new file mode 100644 index 000000000..d1271c51c --- /dev/null +++ b/dor/test/snapshots/help/open.md @@ -0,0 +1,34 @@ +# dor open + +Invocation: `dor open --help` + +```text +USAGE + dor open [--json] [--minimize] [--fresh] [--surface id|ref] [--workspace ref] [--cwd path] [--tool name] + dor open --help + +Opens one existing local file. Relative paths resolve from the caller's directory (or --cwd); symlink aliases resolve to the same file. URLs, directories, and Surface handles are not accepted. + +The first matching rule in the user dormouse.yml selects a user Tool. --tool chooses a user Tool explicitly. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. + +The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match the canonical file path relative to the invocation directory, using forward slashes and Node glob syntax. Dotfiles require explicit patterns. + +The selected Tool receives the canonical absolute filename as one argument. Configure prespawn_dedupe: [$TARGET] to reveal the same file on repeated opens within a Workspace. --fresh bypasses reuse. + +Opening creates a focus-neutral split or reveals an existing Tool, never taking over the caller's terminal. The command prints the Surface handle; --json prints structured output. + +FLAGS + [--json] Print JSON output. + [--minimize] Create the surface minimized. + [--fresh] Open another instance even when the Tool has a key. + [--surface] Surface to split when creating. + [--workspace] Workspace to act in, instead of the caller's. + [--cwd] Directory for resolving the file. + [--tool] Use this user-global Tool. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +ARGUMENTS + file Local file to open. + +``` diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index b041614f3..fc7a38d2f 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -1457,6 +1457,36 @@ describe('Wall on the Lath engine', () => { } }); + it('dispatches open through the user host and splits even when the caller could be taken over', async () => { + setToolsEnabled(true); + vi.spyOn(terminalRegistry, 'isPaneOscDriven').mockReturnValue(true); + const toolControl = vi.fn(async () => ({ status: 'ok' as const, scope: 'user' as const, + projectRoot: '/config', path: '/config/dormouse.yml', name: 'viewer', run: ['view', '/repo/a.md'], + key: ['/repo/a.md'], render: 'iframe' as const, port: 'auto' as const, warnings: [] })); + Object.assign(fake, { toolControl }); + try { + await act(async () => root.render()); + await flush(); + terminalRegistry.seedTerminalManualCwd('pane-a', '/repo'); + terminalRegistry.applyTerminalSemanticEvents('pane-a', [ + { type: 'commandLine', commandLine: 'dor tool viewer a.md' }, + { type: 'commandStart', source: 'osc633_boundaries' }, + ]); + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, surfaceId: 'pane-a', params: { file: 'a.md', cwd: '/repo' }, respond, + } }))); + await settle(() => respond.mock.calls.length > 0); + expect(toolControl).toHaveBeenCalledWith({ op: 'open', target: 'a.md', cwd: '/repo', tool: undefined }); + expect(respond).toHaveBeenCalledWith(expect.objectContaining({ ok: true, result: expect.objectContaining({ status: 'created' }) })); + expect(respond.mock.calls[0][0].result.surfaceId).not.toBe('pane-a'); + expect(leafCount()).toBe(2); + } finally { + act(() => terminalRegistry.removeTerminalPaneState('pane-a')); + setToolsEnabled(false); + } + }); + it('keeps pending file inputs distinct and quotes argv after approval', async () => { setToolsEnabled(true); let trusted = false; diff --git a/lib/src/components/wall/tool-takeover.test.ts b/lib/src/components/wall/tool-takeover.test.ts index 5203e0700..12105d44d 100644 --- a/lib/src/components/wall/tool-takeover.test.ts +++ b/lib/src/components/wall/tool-takeover.test.ts @@ -57,6 +57,14 @@ describe('toolTakesOverCaller', () => { expect(toolTakesOverCaller(passing)).toBe(true); }); + it('only permits open to rerun an existing Tool, never take over a terminal', () => { + const opening = { ...passing, rawCommandLine: 'dor open README.md' }; + expect(toolTakesOverCaller(opening)).toBe(false); + expect(toolRerunsInCaller(opening, 'open')).toBe(false); + expect(toolRerunsInCaller({ ...opening, kind: 'tool' }, 'open')).toBe(true); + expect(toolRerunsInCaller({ ...opening, kind: 'tool', rawCommandLine: 'dor open README.md && echo done' }, 'open')).toBe(false); + }); + it('splits when any condition fails', () => { const splits: Array<[string, Partial]> = [ ['--surface named a reference', { explicitSurface: true }], diff --git a/lib/src/components/wall/tool-takeover.ts b/lib/src/components/wall/tool-takeover.ts index e8d823da5..280741ee8 100644 --- a/lib/src/components/wall/tool-takeover.ts +++ b/lib/src/components/wall/tool-takeover.ts @@ -22,11 +22,11 @@ const COMPOUND_SYNTAX = /[;&|<>()`\n\r]/; * (`docs/specs/dor-tool.md` -> Take-over). Case folds on the launcher, which is * a filename, and not on the verb, which stricli parses case-sensitively. */ -export function isNakedToolInvocation(rawCommandLine: string | null | undefined): boolean { +export function isNakedToolInvocation(rawCommandLine: string | null | undefined, verb: 'tool' | 'open' = 'tool'): boolean { const line = rawCommandLine?.trim(); if (!line || COMPOUND_SYNTAX.test(line)) return false; const argv0 = commandArgv0(line)?.toLowerCase(); - return argv0 === 'dor' && primaryCommandTokens(line)[1] === 'tool'; + return argv0 === 'dor' && primaryCommandTokens(line)[1] === verb; } /** What the placement rule reads. Every field is already known to the handler. */ @@ -57,8 +57,8 @@ export interface ToolTakeoverGate { * pane whose reported line is this invocation and nothing else. Both placements * need it, and neither can proceed without it. */ -function callerTypedTool(gate: ToolTakeoverGate): boolean { - return gate.oscDriven && isNakedToolInvocation(gate.rawCommandLine); +function callerTypedTool(gate: ToolTakeoverGate, verb: 'tool' | 'open' = 'tool'): boolean { + return gate.oscDriven && isNakedToolInvocation(gate.rawCommandLine, verb); } /** @@ -82,6 +82,6 @@ export function toolTakesOverCaller(gate: ToolTakeoverGate): boolean { * nothing to place, and the tool re-runs in its own directory, exactly as an * `adopted` match from any other pane does. */ -export function toolRerunsInCaller(gate: ToolTakeoverGate): boolean { - return gate.kind === 'tool' && callerTypedTool(gate); +export function toolRerunsInCaller(gate: ToolTakeoverGate, verb: 'tool' | 'open' = 'tool'): boolean { + return gate.kind === 'tool' && callerTypedTool(gate, verb); } diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index 8d8531e63..e97353158 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -81,6 +81,8 @@ export type DorControlParams = { fresh?: unknown; args?: unknown; global?: unknown; + file?: unknown; + tool?: unknown; }; // The webview view of a control request: the shared wire payload, but with @@ -865,7 +867,12 @@ export function useDorControl({ detail.respond({ ok: false, error: 'cwd is required' }); return; } - const toolName = stringParam(params.name)?.trim(); + let toolName = stringParam(params.name)?.trim(); + const openFile = stringParam(params.file); + if (openFile !== undefined && (params.name !== undefined || params.command !== undefined || params.args !== undefined)) { + detail.respond({ ok: false, error: 'open accepts a file and optional tool, not a command' }); + return; + } let command: string; let key: string[] | null = null; let toolScope: 'user' | undefined; @@ -878,7 +885,7 @@ export function useDorControl({ let port: 'announced' | 'auto' = 'auto'; const toolShell = getDefaultShellOpts()?.shell; - if (toolName) { + if (toolName || openFile !== undefined) { // The registry, the closed substitution set, and the trust gate all // live behind this one host call (`dor/commands/types` -> // ToolSurfaceRequest). @@ -887,7 +894,9 @@ export function useDorControl({ detail.respond({ ok: false, error: 'this host cannot read a dormouse.yml; use `dor tool -- `' }); return; } - const lookup = await toolControl({ op: 'lookup', name: toolName, cwd, args: toolArgs, global: booleanParam(params.global) }); + const lookup = await toolControl(openFile !== undefined + ? { op: 'open', target: openFile, cwd, tool: stringParam(params.tool) } + : { op: 'lookup', name: toolName!, cwd, args: toolArgs, global: booleanParam(params.global) }); if (unavailable()) return; switch (lookup.status) { case 'trust-recorded': @@ -897,6 +906,7 @@ export function useDorControl({ case 'ok': command = typeof lookup.run === 'string' ? lookup.run : dorCommandString([...lookup.run])!; toolScope = lookup.scope; + toolName = lookup.name; // Namespaced under the host-resolved tool name, so two tools in // one repo with scope-only keys stay distinct and a runtime // re-key cannot name another tool's key. @@ -1071,7 +1081,7 @@ export function useDorControl({ const matchedCwd = getTerminalPaneState(match.id).cwd?.path ?? cwd; if (match.id === callerId && !surfaceRunsCommand(getTerminalPaneState(match.id), matchedCommand, matchedCwd)) { - if (!callerGate || !toolRerunsInCaller(callerGate)) { + if (!callerGate || !toolRerunsInCaller(callerGate, openFile !== undefined ? 'open' : 'tool')) { // Nothing can be typed behind a line that is not this // invocation alone, and there is no survivor to reveal — the // user is sitting in it. Say so instead of reporting a tool @@ -1144,7 +1154,7 @@ export function useDorControl({ // rather than splitting (docs/specs/dor-tool.md -> Take-over). Must stay // below the pending-approval and key-match returns above: both of those // placements win over this one. - if (callerId && callerGate && toolTakesOverCaller(callerGate)) { + if (openFile === undefined && callerId && callerGate && toolTakesOverCaller(callerGate)) { // Answered before the tool starts, because answering is what frees // the shell to run it. detail.respond({ diff --git a/lib/src/host/tool-host.ts b/lib/src/host/tool-host.ts index 7b07f4ddf..c79433083 100644 --- a/lib/src/host/tool-host.ts +++ b/lib/src/host/tool-host.ts @@ -11,6 +11,7 @@ import type { ToolControlResult, ToolHostRequest } from '../lib/platform/tool-types'; import { resolveUpstreamUrl } from './git-upstream'; import { resolveToolInput } from './tool-input'; +import { resolveOpenTool } from './tool-open'; import { readUserToolFile, userToolConfigPath } from './tool-user-config'; import { FileToolTrustStore, @@ -54,6 +55,7 @@ export function createToolHost(options: { stateDir?: string; userConfigPath?: st } try { + if (request.op === 'open') return await resolveOpenTool(request, options.userConfigPath ?? userToolConfigPath()); const args = request.args ?? []; const lookup = request.global ? { status: 'no-file' as const } : await lookupTool(request.name, request.cwd, trust); if (lookup.status === 'no-file' || lookup.status === 'unknown-tool') { diff --git a/lib/src/host/tool-open.test.ts b/lib/src/host/tool-open.test.ts new file mode 100644 index 000000000..ec8458dd1 --- /dev/null +++ b/lib/src/host/tool-open.test.ts @@ -0,0 +1,66 @@ +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it } from 'vitest'; +import { createToolHost } from './tool-host'; + +let root: string; +let config: string; +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'dor-open-'))); + config = join(root, 'user.yml'); + await mkdir(join(root, 'docs')); + await writeFile(join(root, 'docs', 'README.md'), 'hi'); + await writeFile(config, `tools: + special: + run: [special, $TARGET] + prespawn_dedupe: [$TARGET] + markdown: + run: [markdown, $TARGET] +open: + - match: docs/README.md + tool: special + - match: '**/*.md' + tool: markdown +`); +}); +afterEach(async () => { await rm(root, { recursive: true, force: true }); }); +const host = () => createToolHost({ userConfigPath: config }); + +it('uses the first user rule and never discovers project definitions', async () => { + await writeFile(join(root, 'dormouse.yml'), 'this is not even valid Tool configuration'); + const target = join(root, 'docs', 'README.md'); + expect(await host().handle({ op: 'open', target: 'docs/README.md', cwd: root })).toMatchObject({ + status: 'ok', scope: 'user', name: 'special', run: ['special', target], key: [target], + }); + expect(await host().handle({ op: 'open', target, cwd: root, tool: 'markdown' })).toMatchObject({ + status: 'ok', scope: 'user', name: 'markdown', run: ['markdown', target], + }); +}); + +it('matches slashless patterns against filenames and preserves spaces and metacharacters', async () => { + await writeFile(config, "tools:\n viewer:\n run: [viewer, $TARGET]\nopen:\n - {match: '*.md', tool: viewer}\n"); + const target = join(root, 'docs', 'a b; $(touch nope).md'); + await writeFile(target, 'hi'); + expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'ok', run: ['viewer', target] }); +}); + +it.skipIf(process.platform === 'win32')('keys symlink aliases on the same canonical file', async () => { + const target = join(root, 'docs', 'README.md'); + await symlink(target, join(root, 'alias.md')); + const first = await host().handle({ op: 'open', target, cwd: root }); + const second = await host().handle({ op: 'open', target: 'alias.md', cwd: root }); + expect(second).toEqual(first); +}); + +it.each(['https://example.com/file.md', 'file:///etc/passwd', 'surface:3', 'docs', 'missing.md'])('rejects %s as a local regular-file target', async target => { + expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'error' }); +}); + +it('names the user configuration in unmatched-file errors and refuses broken associations', async () => { + const target = join(root, 'unknown.binary'); + await writeFile(target, 'hi'); + expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'error', message: expect.stringContaining(config) }); + await writeFile(config, 'open:\n - {match: "*", tool: undeclared}\n'); + expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'error', message: expect.stringContaining('defined in this user file') }); +}); diff --git a/lib/src/host/tool-open.ts b/lib/src/host/tool-open.ts new file mode 100644 index 000000000..0ad7b44ea --- /dev/null +++ b/lib/src/host/tool-open.ts @@ -0,0 +1,24 @@ +import { basename, posix, relative, sep } from 'node:path'; +import type { ToolLookupResult } from '../lib/platform/tool-types'; +import { resolveLocalToolTarget, resolveToolInput } from './tool-input'; +import { readUserToolFile } from './tool-user-config'; + +/** Dispatch is entirely user-owned. Never discover a project file here, even + * when its Tool name shadows the rule's selected user Tool. */ +export async function resolveOpenTool( + request: { target: string; cwd: string; tool?: string }, + path: string, +): Promise { + const target = await resolveLocalToolTarget(request.target, request.cwd); + const file = await readUserToolFile(path); + const relativePath = relative(request.cwd, target).split(sep).join('/'); + const name = request.tool ?? file?.open.find(rule => + posix.matchesGlob(rule.match.includes('/') ? relativePath : basename(target), rule.match))?.tool; + const entry = name && file?.tools.get(name); + if (!file || !entry) return { status: 'error', message: request.tool + ? `no user Tool '${request.tool}' in ${path}` + : `no Tool matches '${request.target}'; add an open rule to ${path}, or use dor open --tool ` }; + const input = await resolveToolInput(entry, { cwd: request.cwd, projectRoot: null, args: [target] }); + return { status: 'ok', projectRoot: file.dir, path, name: entry.name, scope: 'user', + ...input, render: entry.render, port: entry.port, warnings: [...file.warnings] }; +} diff --git a/lib/src/host/tool-registry.ts b/lib/src/host/tool-registry.ts index f0be9b9ca..299422979 100644 --- a/lib/src/host/tool-registry.ts +++ b/lib/src/host/tool-registry.ts @@ -42,7 +42,10 @@ export interface ToolEntry { readonly dedupeTemplate: readonly string[] | null; } +export interface OpenRule { readonly match: string; readonly tool: string } + export interface ToolFile { + readonly open: readonly OpenRule[]; readonly scope: ToolScope; /** Absolute directory holding the file. `$PROJECT_ROOT` for a repo scope. */ readonly dir: string; @@ -123,12 +126,11 @@ export function parseToolFile( } // An empty file is a valid file with no tools, not a broken one. if (doc === null || doc === undefined) { - return { scope, dir, tools: new Map(), warnings: [] }; + return { scope, dir, tools: new Map(), warnings: [], open: [] }; } if (!isRecord(doc)) throw new ToolFileError(`${path}: expected a mapping at the top level`); - const toolsNode = doc.tools; - if (toolsNode === undefined) return { scope, dir, tools: new Map(), warnings: [] }; + const toolsNode = doc.tools ?? {}; if (!isRecord(toolsNode)) throw new ToolFileError(`${path}: 'tools' must be a mapping of name to entry`); const tools = new Map(); @@ -186,7 +188,21 @@ export function parseToolFile( tools.set(name, { name, run: typeof run === 'string' ? run.trim() : run, render, port, dedupeTemplate }); } - return { scope, dir, tools, warnings }; + const open: OpenRule[] = []; + if (doc.open !== undefined) { + if (scope === 'repo') warnings.push(`${path}: project open rules are ignored; configure associations in the user file`); + else { + if (!Array.isArray(doc.open)) throw new ToolFileError(`${path}: 'open' must be an ordered list`); + for (const rule of doc.open) { + if (!isRecord(rule) || typeof rule.match !== 'string' || !rule.match || typeof rule.tool !== 'string' + || !tools.has(rule.tool) || Object.keys(rule).some(key => key !== 'match' && key !== 'tool')) { + throw new ToolFileError(`${path}: each open rule needs a match pattern and a tool defined in this user file`); + } + open.push({ match: rule.match, tool: rule.tool }); + } + } + } + return { scope, dir, tools, warnings, open }; } /** diff --git a/lib/src/lib/platform/tool-types.ts b/lib/src/lib/platform/tool-types.ts index 0f81528c9..8a85f8e71 100644 --- a/lib/src/lib/platform/tool-types.ts +++ b/lib/src/lib/platform/tool-types.ts @@ -7,6 +7,7 @@ */ export type ToolHostRequest = + | { op: 'open'; target: string; cwd: string; tool?: string } | { op: 'lookup'; name: string; cwd: string; args?: string[]; global?: boolean } | { op: 'trust'; kind: 'upstream' | 'folder'; projectRoot: string }; diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 016f50ed0..16f9edbdb 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,8 +6,8 @@ "docs/specs/auto-update.md": 1100, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4600, - "docs/specs/dor-cli.md": 5950, - "docs/specs/dor-tool.md": 2850, + "docs/specs/dor-cli.md": 6000, + "docs/specs/dor-tool.md": 3050, "docs/specs/glossary.md": 3000, "docs/specs/layout.md": 8800, "docs/specs/mobile-terminal-ui.md": 1950, From e372ab003ddfee90c599b707f34c30af9e522e97 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 15 Sep 2026 16:51:48 -0700 Subject: [PATCH 2/9] Support file glob matching on all supported hosts --- docs/specs/dor-tool.md | 4 +- dor/src/commands/open.ts | 2 +- dor/test/snapshots/help/open.md | 2 +- lib/package.json | 6 +- lib/src/host/tool-open.test.ts | 11 ++++ lib/src/host/tool-open.ts | 10 ++- lib/src/host/tool-registry.test.ts | 8 +++ lib/src/host/tool-registry.ts | 3 + pnpm-lock.yaml | 99 +++++++++++++++++------------- 9 files changed, 92 insertions(+), 53 deletions(-) diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index 40592b874..d90138843 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -135,9 +135,9 @@ Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `dor/test/snapshot **Must accept exactly one existing local regular file for `dor open`.** Resolve it with the `$TARGET` rules in Declaring tools. URLs (including `file:`), directories, and Surface handles fail; no native-editor fallback occurs. -**Must select the first matching entry of the user file's ordered `open` list**, whose entries contain `match` and `tool`. `--tool` explicitly selects a user Tool. Every association must name a Tool in that same user file. Never discover project configuration during this lookup; project `open` rules are ignored with a warning during explicit project-tool lookup. +**Must select the first matching entry of the user file's ordered `open` list**, whose entries contain `match` and `tool`. `--tool` explicitly selects a user Tool. Every association must name an argument-list Tool in that same user file. Never discover project configuration during this lookup; project `open` rules are ignored with a warning during explicit project-tool lookup. -**Must match patterns without `/` against the canonical filename, and patterns with `/` against the canonical path relative to the invocation CWD.** Normalize separators to `/` and use Node's POSIX `matchesGlob` semantics, including explicit patterns for dotfiles. A miss names the user config path and suggests `--tool`. +**Must match patterns without `/` against the canonical filename, and patterns with `/` against both the CWD-relative and canonical absolute paths.** Normalize separators to `/` and use bundled picomatch with POSIX separators, case-sensitive matching, and explicit patterns for dotfiles. A miss names the user config path and suggests `--tool`. **Must pass the canonical file path as one input to the selected Tool.** Reuse follows Identity and dedupe; `$TARGET` in the key provides per-file identity. `--fresh` bypasses reuse. **Never transform a plain calling terminal through `dor open`.** Create a focus-neutral split or reveal the existing Tool; an idle match in the caller's Tool pane uses the answer/prompt handshake only for a standalone integrated `dor open` invocation. diff --git a/dor/src/commands/open.ts b/dor/src/commands/open.ts index 78617e6cf..744ac0d0c 100644 --- a/dor/src/commands/open.ts +++ b/dor/src/commands/open.ts @@ -22,7 +22,7 @@ export const openCommand: Command = { The first matching rule in the user dormouse.yml selects a user Tool. --tool chooses a user Tool explicitly. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. -The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match the canonical file path relative to the invocation directory, using forward slashes and Node glob syntax. Dotfiles require explicit patterns. +The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match both the canonical absolute path and the path relative to the invocation directory. Matching uses picomatch glob syntax with forward slashes and case sensitivity. Dotfiles require explicit patterns. The selected Tool receives the canonical absolute filename as one argument. Configure prespawn_dedupe: [$TARGET] to reveal the same file on repeated opens within a Workspace. --fresh bypasses reuse. diff --git a/dor/test/snapshots/help/open.md b/dor/test/snapshots/help/open.md index d1271c51c..c6db5e99c 100644 --- a/dor/test/snapshots/help/open.md +++ b/dor/test/snapshots/help/open.md @@ -11,7 +11,7 @@ Opens one existing local file. Relative paths resolve from the caller's director The first matching rule in the user dormouse.yml selects a user Tool. --tool chooses a user Tool explicitly. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. -The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match the canonical file path relative to the invocation directory, using forward slashes and Node glob syntax. Dotfiles require explicit patterns. +The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match both the canonical absolute path and the path relative to the invocation directory. Matching uses picomatch glob syntax with forward slashes and case sensitivity. Dotfiles require explicit patterns. The selected Tool receives the canonical absolute filename as one argument. Configure prespawn_dedupe: [$TARGET] to reveal the same file on repeated opens within a Workspace. --fresh bypasses reuse. diff --git a/lib/package.json b/lib/package.json index 71aa855e6..59f1c2b88 100644 --- a/lib/package.json +++ b/lib/package.json @@ -32,19 +32,21 @@ "dor-lib-common": "workspace:*", "fflate": "0.8.3", "jsonc-parser": "3.3.1", + "picomatch": "^4.0.7", "react": "^19.2.6", "react-dom": "^19.2.6", "remote-lib-common": "workspace:*", "tailwind-merge": "^3.6.0", "tailwind-variants": "^3.2.2", - "yaml": "^2.9.0", - "uqr": "^0.1.3" + "uqr": "^0.1.3", + "yaml": "^2.9.0" }, "devDependencies": { "@storybook/addon-docs": "^10.4.0", "@storybook/react": "^10.4.0", "@storybook/react-vite": "^10.4.0", "@tailwindcss/vite": "^4.3.0", + "@types/picomatch": "^4.0.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", diff --git a/lib/src/host/tool-open.test.ts b/lib/src/host/tool-open.test.ts index ec8458dd1..faac7c36c 100644 --- a/lib/src/host/tool-open.test.ts +++ b/lib/src/host/tool-open.test.ts @@ -64,3 +64,14 @@ it('names the user configuration in unmatched-file errors and refuses broken ass await writeFile(config, 'open:\n - {match: "*", tool: undeclared}\n'); expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'error', message: expect.stringContaining('defined in this user file') }); }); + + +it('matches catch-all rules above the invocation directory and canonical absolute rules', async () => { + await mkdir(join(root, 'work')); + await writeFile(config, `tools:\n viewer:\n run: [view, $TARGET]\nopen:\n - {match: '**/*.md', tool: viewer}\n`); + const request = { op: 'open', target: '../docs/README.md', cwd: join(root, 'work') } as const; + expect(await host().handle(request)).toMatchObject({ status: 'ok', name: 'viewer' }); + const canonicalPattern = join(root, 'docs').replace(/\\/g, '/') + '/**'; + await writeFile(config, `tools:\n viewer:\n run: [view, $TARGET]\nopen:\n - {match: '${canonicalPattern}', tool: viewer}\n`); + expect(await host().handle(request)).toMatchObject({ status: 'ok', name: 'viewer' }); +}); diff --git a/lib/src/host/tool-open.ts b/lib/src/host/tool-open.ts index 0ad7b44ea..a385b4d8f 100644 --- a/lib/src/host/tool-open.ts +++ b/lib/src/host/tool-open.ts @@ -1,4 +1,5 @@ -import { basename, posix, relative, sep } from 'node:path'; +import { basename, relative, sep } from 'node:path'; +import picomatch from 'picomatch'; import type { ToolLookupResult } from '../lib/platform/tool-types'; import { resolveLocalToolTarget, resolveToolInput } from './tool-input'; import { readUserToolFile } from './tool-user-config'; @@ -12,8 +13,11 @@ export async function resolveOpenTool( const target = await resolveLocalToolTarget(request.target, request.cwd); const file = await readUserToolFile(path); const relativePath = relative(request.cwd, target).split(sep).join('/'); - const name = request.tool ?? file?.open.find(rule => - posix.matchesGlob(rule.match.includes('/') ? relativePath : basename(target), rule.match))?.tool; + const canonicalPath = target.split(sep).join('/'); + const name = request.tool ?? file?.open.find(rule => { + const matches = picomatch(rule.match, { windows: false }); + return rule.match.includes('/') ? matches(relativePath) || matches(canonicalPath) : matches(basename(target)); + })?.tool; const entry = name && file?.tools.get(name); if (!file || !entry) return { status: 'error', message: request.tool ? `no user Tool '${request.tool}' in ${path}` diff --git a/lib/src/host/tool-registry.test.ts b/lib/src/host/tool-registry.test.ts index bef504fb8..39e7f747c 100644 --- a/lib/src/host/tool-registry.test.ts +++ b/lib/src/host/tool-registry.test.ts @@ -200,3 +200,11 @@ it('rejects a shell command with a target-only dedupe key at declaration time', expect(() => parse('tools:\n viewer:\n run: view\n prespawn_dedupe: [$TARGET]\n')) .toThrow('$TARGET in prespawn_dedupe requires an argument-list run'); }); + + +it('keeps project associations inert and validates user associations at declaration time', () => { + expect(parse('open: malformed-but-inert\n').warnings).toEqual([expect.stringContaining('project open rules are ignored')]); + expect(() => parse('open: nope\n', USER)).toThrow("'open' must be an ordered list"); + expect(() => parse('tools:\n viewer:\n run: viewer\nopen:\n - {match: "*.md", tool: viewer}\n', USER)) + .toThrow("open rule for 'viewer' needs an argument-list run"); +}); diff --git a/lib/src/host/tool-registry.ts b/lib/src/host/tool-registry.ts index 26b442fe8..f037bfcb2 100644 --- a/lib/src/host/tool-registry.ts +++ b/lib/src/host/tool-registry.ts @@ -201,6 +201,9 @@ export function parseToolFile( || !tools.has(rule.tool) || Object.keys(rule).some(key => key !== 'match' && key !== 'tool')) { throw new ToolFileError(`${path}: each open rule needs a match pattern and a tool defined in this user file`); } + if (typeof tools.get(rule.tool)!.run === 'string') { + throw new ToolFileError(`${path}: open rule for '${rule.tool}' needs an argument-list run to receive the file`); + } open.push({ match: rule.match, tool: rule.tool }); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 990719cb7..e7d019470 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(typescript@6.0.3) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/react': specifier: ^19.2.14 version: 19.3.0 @@ -44,7 +44,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) storybook: specifier: ^10.4.0 version: 10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0) @@ -53,7 +53,7 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) dor: dependencies: @@ -125,6 +125,9 @@ importers: jsonc-parser: specifier: 3.3.1 version: 3.3.1 + picomatch: + specifier: ^4.0.7 + version: 4.0.7 react: specifier: ^19.2.6 version: 19.3.0 @@ -155,10 +158,13 @@ importers: version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + '@types/picomatch': + specifier: ^4.0.3 + version: 4.0.3 '@types/react': specifier: ^19.2.14 version: 19.3.0 @@ -167,7 +173,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) chromatic: specifier: ^17.0.0 version: 17.8.0 @@ -188,10 +194,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) relay: dependencies: @@ -278,7 +284,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@tauri-apps/cli': specifier: ^2.11.2 version: 2.11.4 @@ -290,7 +296,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) cross-spawn: specifier: ^7.0.6 version: 7.0.6 @@ -311,10 +317,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) standalone/sidecar: dependencies: @@ -358,7 +364,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/node': specifier: ^24.0.0 version: 24.13.4 @@ -370,7 +376,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@vscode/vsce': specifier: ^3.9.1 version: 3.9.2(supports-color@7.2.0) @@ -391,10 +397,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) website: dependencies: @@ -422,10 +428,10 @@ importers: devDependencies: '@react-router/dev': specifier: ^8.0.0 - version: 8.3.1(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 8.3.1(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/node': specifier: ^24.13.3 version: 24.13.4 @@ -443,10 +449,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -2309,6 +2315,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: @@ -5448,11 +5457,11 @@ snapshots: optionalDependencies: '@types/node': 24.13.4 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@6.0.3) - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 @@ -5779,7 +5788,7 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@react-router/dev@8.3.1(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@react-router/dev@8.3.1(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/generator': 7.29.8 @@ -5805,7 +5814,7 @@ snapshots: semver: 7.8.5 tinyglobby: 0.2.17 valibot: 1.4.2(typescript@6.0.3) - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -6044,11 +6053,11 @@ snapshots: transitivePeerDependencies: - '@types/react-dom' - '@storybook/builder-vite@10.6.0(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/builder-vite@10.6.0(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: storybook: 10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0) ts-dedent: 2.3.0 - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@storybook/global@5.0.0': {} @@ -6065,11 +6074,11 @@ snapshots: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@storybook/react-vite@10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/react-vite@10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0(rollup@4.62.2) - '@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@storybook/react': 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3) empathic: 2.0.1 magic-string: 1.3.1 @@ -6079,7 +6088,7 @@ snapshots: resolve: 1.22.12 storybook: 10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0) tsconfig-paths: 4.2.0 - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -6088,11 +6097,11 @@ snapshots: - rollup - supports-color - '@storybook/react-vite@10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/react-vite@10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0(rollup@4.62.2) - '@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@storybook/react': 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(typescript@6.0.3) empathic: 2.0.1 magic-string: 1.3.1 @@ -6102,7 +6111,7 @@ snapshots: resolve: 1.22.12 storybook: 10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0) tsconfig-paths: 4.2.0 - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -6206,12 +6215,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@tauri-apps/api@2.11.1': {} @@ -6380,6 +6389,8 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/picomatch@4.0.3': {} + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -6412,10 +6423,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@vitest/expect@3.2.4': dependencies: @@ -6434,13 +6445,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -8847,7 +8858,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0): + vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -8861,10 +8872,10 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)): + vitest@4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -8881,7 +8892,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.4 From 191c82932188afdbf19220d1c3e1736f0dbbb516 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 15 Sep 2026 16:54:06 -0700 Subject: [PATCH 3/9] Disclose the bundled glob matcher --- website/src/data/dependencies-npm.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/website/src/data/dependencies-npm.json b/website/src/data/dependencies-npm.json index 370d4ea8c..24aaa5b46 100644 --- a/website/src/data/dependencies-npm.json +++ b/website/src/data/dependencies-npm.json @@ -335,6 +335,13 @@ "author": "Sindre Sorhus", "homepage": "https://github.com/sindresorhus/path-key" }, + { + "name": "picomatch", + "version": "4.0.7", + "license": "MIT", + "author": "Jon Schlinkert (https://github.com/jonschlinkert)", + "homepage": "https://github.com/micromatch/picomatch" + }, { "name": "react", "version": "19.3.0", From acd32410ed3ff9a8f5e3430bde9a85867b1eafaf Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 15 Sep 2026 17:57:25 -0700 Subject: [PATCH 4/9] Simplify dor open dispatch Put the `dor` verb on the take-over gate so `toolTakesOverCaller` states "open never transforms a plain terminal" itself, instead of a defaulted parameter threaded through three predicates plus a guard at the call site. Build the user-Tool `ok` result in one helper shared by the lookup fallback and `resolveOpenTool`. Share the CLI launch round trip between `dor tool` and `dor open`, and hoist the open-rule parser out of `parseToolFile`. Cut the spec paraphrases of rules other sections own and add the verb row to the take-over table. Co-Authored-By: Claude Fable 5.1 --- docs/specs/dor-cli.md | 5 +-- docs/specs/dor-tool.md | 11 ++--- dor/src/commands/open.ts | 40 +++++++++---------- dor/src/commands/tool.ts | 34 ++++++++++------ lib/src/components/Wall.test.tsx | 2 +- lib/src/components/wall/tool-takeover.test.ts | 8 ++-- lib/src/components/wall/tool-takeover.ts | 14 ++++--- lib/src/components/wall/use-dor-control.ts | 12 +++--- lib/src/host/tool-host.ts | 19 ++++----- lib/src/host/tool-open.test.ts | 20 +++++----- lib/src/host/tool-open.ts | 8 ++-- lib/src/host/tool-registry.test.ts | 1 - lib/src/host/tool-registry.ts | 36 +++++++++-------- lib/src/host/tool-user-config.ts | 13 +++++- scripts/spec-word-budgets.json | 2 +- 15 files changed, 123 insertions(+), 102 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 09ca234b0..929f4a78e 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -422,7 +422,6 @@ The spec keeps the behavior help cannot express: | `list` | Filters are ANDed client-side; `--port` filters terminals (browser Surfaces never match) and implies the opt-in detail scan, `--ports` only requests it. **Owns every Workspace read**: `--workspace` narrows to one, `--all` groups every Workspace's rows under its header — **every Workspace keeps its header**, including one a filter emptied, so the text listing and the JSON `workspaces` array name the same Workspaces — `--workspaces` is the overview, and the three cannot be combined. **`--all --json` adds `caller_workspace_ref` / `focused_workspace_ref`** beside the `_surface_ref` pair, which under `--all` names a `surface:N` every Workspace has; the `_surface_id` halves stay unique. **`--workspaces` takes `--json` and nothing else**, by an allowlist, so a flag added to `list` is refused there until it is named. | | `workspace` | **Mutation only** ([dor workspace](#dor-workspace)). | | `skill` | Prints the bundled skill or installs its bootstrap stub; [Agent Skill](#agent-skill) owns the contract. | -| `tool`, `open` | `docs/specs/dor-tool.md` owns Tool inputs and local-file dispatch. | **`await` never prints terminal text.** Stdout is only the resolution cause, the narrative goes to stderr on every outcome, and JSON appears only on a resolution @@ -646,9 +645,9 @@ Source of truth: `buildDorSurfacesInternal` in `lib/src/components/Wall.tsx`; `d ## Dor Tools -**Must route `dor tool` through the Tool launch contract**, including feature gating, approval, explicit-key reuse, and focus-neutral placement (`docs/specs/dor-tool.md` → CLI). Generated help owns syntax. +**Must route `dor tool` and `dor open` through the Tool launch contract**, including feature gating, approval, explicit-key reuse, and focus-neutral placement (`docs/specs/dor-tool.md` → CLI). Generated help owns syntax. -Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `ToolSurfaceResponse` in `dor/src/commands/types.ts`. +Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `openCommand` in `dor/src/commands/open.ts`; `ToolSurfaceResponse` in `dor/src/commands/types.ts`. ## Future diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index 227b31802..f4bcda6e5 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -129,19 +129,19 @@ Source of truth: `ToolPanel` in `lib/src/components/wall/ToolPanel.tsx`; `ToolPa **Must return the Tool Surface handle.** A new Tool follows [Take-over](#take-over), otherwise splitting focus-neutrally. A matching Tool follows [Identity and dedupe](#identity-and-dedupe). -**Must retain `dor tool` as a Surface-producing command on every supported host**, never route it to a native editor. Generated help owns syntax and response types own shape. +**Must retain `dor tool` and `dor open` as Surface-producing commands on every supported host**, never route them to a native editor. Generated help owns syntax and response types own shape. Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `dor/test/snapshots/help/tool.md`; `ToolSurfaceResponse` in `dor/src/commands/types.ts`. ## Opening local files -**Must accept exactly one existing local regular file for `dor open`.** Resolve it with the `$TARGET` rules in Declaring tools. URLs (including `file:`), directories, and Surface handles fail; no native-editor fallback occurs. +**Must accept exactly one existing local regular file for `dor open`**, resolved by the `$TARGET` rules in [Declaring tools](#declaring-tools). **Must select the first matching entry of the user file's ordered `open` list**, whose entries contain `match` and `tool`. `--tool` explicitly selects a user Tool. Every association must name an argument-list Tool in that same user file. Never discover project configuration during this lookup; project `open` rules are ignored with a warning during explicit project-tool lookup. -**Must match patterns without `/` against the canonical filename, and patterns with `/` against both the CWD-relative and canonical absolute paths.** Normalize separators to `/` and use bundled picomatch with POSIX separators, case-sensitive matching, and explicit patterns for dotfiles. A miss names the user config path and suggests `--tool`. +**Must match patterns without `/` against the canonical filename, and patterns with `/` against both the CWD-relative and canonical absolute paths**, separators normalized to `/`, with bundled picomatch: case-sensitive, dotfiles only by explicit pattern. A miss names the user config path and suggests `--tool`. -**Must pass the canonical file path as one input to the selected Tool.** Reuse follows Identity and dedupe; `$TARGET` in the key provides per-file identity. `--fresh` bypasses reuse. **Never transform a plain calling terminal through `dor open`.** Create a focus-neutral split or reveal the existing Tool; an idle match in the caller's Tool pane uses the answer/prompt handshake only for a standalone integrated `dor open` invocation. +**Must pass the canonical file path as the selected Tool's one input.** Reuse follows [Identity and dedupe](#identity-and-dedupe), `$TARGET` in the key providing per-file identity; placement follows [Take-over](#take-over). Source of truth: `openCommand` in `dor/src/commands/open.ts`; `resolveOpenTool` in `lib/src/host/tool-open.ts`; `parseToolFile` in `lib/src/host/tool-registry.ts`; `surface.tool` in `lib/src/components/wall/use-dor-control.ts`. Tests: `lib/src/host/tool-open.test.ts`, `dor/test/cli-output.test.mjs`, `lib/src/components/Wall.test.tsx`. @@ -151,8 +151,9 @@ Source of truth: `openCommand` in `dor/src/commands/open.ts`; `resolveOpenTool` | Condition | Required state | | --- | --- | +| Verb | `dor tool`; `dor open` never transforms a plain terminal, though a keyed match in its own Tool pane reruns there | | Caller | Visible, integrated plain terminal; not closing or dying | -| Command line | OSC 633 reports `dor tool` alone; compound shell syntax rejects takeover | +| Command line | OSC 633 reports the invocation alone; compound shell syntax rejects takeover | | Directory | Resolved Tool CWD equals the caller's reported CWD | | Placement | Neither `--surface` nor `--minimize` supplied | | Helper | No existing auxiliary helper; preserve it by splitting | diff --git a/dor/src/commands/open.ts b/dor/src/commands/open.ts index 744ac0d0c..df0c0674e 100644 --- a/dor/src/commands/open.ts +++ b/dor/src/commands/open.ts @@ -1,16 +1,15 @@ import { buildCommand } from '@stricli/core'; -import type { Command, DorCommandContext } from './types.js'; -import { callerWorkingDirectory, errorMessage, requireControlClient, stringParser, workspaceFlag, workspaceParam, writeStderr, writeStdout } from './shared.js'; -import { renderToolResponse } from './tool.js'; +import type { Command, DorCommandContext, WorkspaceScopedFlags } from './types.js'; +import { callerWorkingDirectory, stringParser, workspaceFlag, workspaceParam } from './shared.js'; +import { dispatchToolSurface } from './tool.js'; -interface OpenFlags { - json?: boolean; - minimize?: boolean; - fresh?: boolean; - surface?: string; - workspace?: string; - cwd?: string; - tool?: string; +interface OpenFlags extends WorkspaceScopedFlags { + readonly json?: boolean; + readonly minimize?: boolean; + readonly fresh?: boolean; + readonly surface?: string; + readonly cwd?: string; + readonly tool?: string; } export const openCommand: Command = { @@ -40,15 +39,16 @@ Opening creates a focus-neutral split or reveals an existing Tool, never taking }, positional: { kind: 'tuple', parameters: [{ parse: stringParser, brief: 'Local file to open.', placeholder: 'file' }] }, }, - async func(this: DorCommandContext, flags: OpenFlags, file: string) { - const client = requireControlClient(this.options, 20_000); - if (client instanceof Error) return client; - try { - const response = await client.toolSurface({ file, tool: flags.tool, cwd: callerWorkingDirectory(flags.cwd, this.options.env), - fresh: flags.fresh === true, minimized: flags.minimize === true, surface: flags.surface, ...workspaceParam(flags.workspace) }); - for (const warning of response.warnings ?? []) writeStderr(this, `${warning}\n`); - writeStdout(this, renderToolResponse(response, flags.json === true)); - } catch (error) { return new Error(errorMessage(error)); } + func(this: DorCommandContext, flags: OpenFlags, file: string) { + return dispatchToolSurface(this, { + file, + tool: flags.tool, + ...workspaceParam(flags.workspace), + fresh: flags.fresh === true, + minimized: flags.minimize === true, + surface: flags.surface, + cwd: callerWorkingDirectory(flags.cwd, this.options.env), + }, flags.json === true); }, }), }; diff --git a/dor/src/commands/tool.ts b/dor/src/commands/tool.ts index a9ba10024..830d71fb5 100644 --- a/dor/src/commands/tool.ts +++ b/dor/src/commands/tool.ts @@ -5,6 +5,7 @@ import type { Command, DorCommandContext, ParseResult, + ToolSurfaceRequest, ToolSurfaceResponse, } from './types.js'; import { @@ -171,28 +172,35 @@ async function runToolCommand(this: DorCommandContext, flags: ToolFlags, ...rest return new Error('dor tool requires a tool name or -- '); } - const client = requireControlClient(this.options, TOOL_TIMEOUT_MS); - if (client instanceof Error) return client; + return dispatchToolSurface(this, { + ...(named ? { name: rest[0], args: rest.slice(1), global: flags.global === true } : { command: rest }), + ...workspaceParam(flags.workspace), + fresh: flags.fresh === true, + minimized: flags.minimize === true, + surface: flags.surface, + cwd: callerWorkingDirectory(flags.cwd, this.options.env), + }, flags.json === true); +} +/** The launch round trip `dor tool` and `dor open` share: one Tool request in, + * its handle out. */ +export async function dispatchToolSurface( + context: DorCommandContext, request: ToolSurfaceRequest, json: boolean, +): Promise { + const client = requireControlClient(context.options, TOOL_TIMEOUT_MS); + if (client instanceof Error) return client; try { - const response = await client.toolSurface({ - ...(named ? { name: rest[0], args: rest.slice(1), global: flags.global === true } : { command: rest }), - ...workspaceParam(flags.workspace), - fresh: flags.fresh === true, - minimized: flags.minimize === true, - surface: flags.surface, - cwd: callerWorkingDirectory(flags.cwd, this.options.env), - }); + const response = await client.toolSurface(request); // Lint output is advisory and must not pollute a `--json` parse. - for (const warning of response.warnings ?? []) writeStderr(this, `${warning}\n`); - writeStdout(this, renderToolResponse(response, flags.json === true)); + for (const warning of response.warnings ?? []) writeStderr(context, `${warning}\n`); + writeStdout(context, renderToolResponse(response, json)); return undefined; } catch (error) { return new Error(errorMessage(error)); } } -export function renderToolResponse(response: ToolSurfaceResponse, json: boolean): string { +function renderToolResponse(response: ToolSurfaceResponse, json: boolean): string { if (json) { return renderJson({ status: response.status, diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index a224fd460..6d225798f 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -1489,7 +1489,7 @@ describe('Wall on the Lath engine', () => { await flush(); terminalRegistry.seedTerminalManualCwd('pane-a', '/repo'); terminalRegistry.applyTerminalSemanticEvents('pane-a', [ - { type: 'commandLine', commandLine: 'dor tool viewer a.md' }, + { type: 'commandLine', commandLine: 'dor open a.md' }, { type: 'commandStart', source: 'osc633_boundaries' }, ]); const respond = vi.fn(); diff --git a/lib/src/components/wall/tool-takeover.test.ts b/lib/src/components/wall/tool-takeover.test.ts index 6f9f6d76c..b5545bb8b 100644 --- a/lib/src/components/wall/tool-takeover.test.ts +++ b/lib/src/components/wall/tool-takeover.test.ts @@ -45,6 +45,7 @@ describe('isNakedToolInvocation', () => { describe('toolTakesOverCaller', () => { const passing: ToolTakeoverGate = { + verb: 'tool', explicitSurface: false, minimized: false, workspaceActive: true, @@ -61,11 +62,9 @@ describe('toolTakesOverCaller', () => { }); it('only permits open to rerun an existing Tool, never take over a terminal', () => { - const opening = { ...passing, rawCommandLine: 'dor open README.md' }; + const opening: ToolTakeoverGate = { ...passing, verb: 'open', rawCommandLine: 'dor open README.md' }; expect(toolTakesOverCaller(opening)).toBe(false); - expect(toolRerunsInCaller(opening, 'open')).toBe(false); - expect(toolRerunsInCaller({ ...opening, kind: 'tool' }, 'open')).toBe(true); - expect(toolRerunsInCaller({ ...opening, kind: 'tool', rawCommandLine: 'dor open README.md && echo done' }, 'open')).toBe(false); + expect(toolRerunsInCaller({ ...opening, kind: 'tool' })).toBe(true); }); it('splits when any condition fails', () => { @@ -107,6 +106,7 @@ describe('toolTakesOverCaller', () => { // command line has finished by then, so it is not among the conditions. describe('after the prompt wait', () => { const passing: ToolTakeoverGate = { + verb: 'tool', explicitSurface: false, minimized: false, workspaceActive: true, diff --git a/lib/src/components/wall/tool-takeover.ts b/lib/src/components/wall/tool-takeover.ts index b4619c5fb..0603287dc 100644 --- a/lib/src/components/wall/tool-takeover.ts +++ b/lib/src/components/wall/tool-takeover.ts @@ -29,6 +29,9 @@ export function isNakedToolInvocation(rawCommandLine: string | null | undefined, /** What the placement rule reads. Every field is already known to the handler. */ export interface ToolTakeoverGate { + /** The `dor` verb the request came from: `open` never transforms a plain + * terminal, but may re-run its own Tool pane. */ + verb: 'tool' | 'open'; /** `--surface`: an explicit placement, which take-over must not override. */ explicitSurface: boolean; /** `--minimize`: a request for a background Surface, which the caller is not. */ @@ -58,8 +61,8 @@ export interface ToolTakeoverGate { * pane whose reported line is this invocation and nothing else. Both placements * need it, and neither can proceed without it. */ -function callerTypedTool(gate: ToolTakeoverGate, verb: 'tool' | 'open' = 'tool'): boolean { - return gate.oscDriven && isNakedToolInvocation(gate.rawCommandLine, verb); +function callerTypedTool(gate: ToolTakeoverGate): boolean { + return gate.oscDriven && isNakedToolInvocation(gate.rawCommandLine, gate.verb); } /** @@ -68,7 +71,8 @@ function callerTypedTool(gate: ToolTakeoverGate, verb: 'tool' | 'open' = 'tool') * (rationale). */ export function toolTakesOverCaller(gate: ToolTakeoverGate): boolean { - return gate.workspaceActive + return gate.verb === 'tool' + && gate.workspaceActive && !gate.explicitSurface && !gate.minimized && callerStillPlaceable(gate) @@ -94,8 +98,8 @@ export function callerStillPlaceable(gate: ToolTakeoverGate): boolean { * nothing to place, and the tool re-runs in its own directory, exactly as an * `adopted` match from any other pane does. */ -export function toolRerunsInCaller(gate: ToolTakeoverGate, verb: 'tool' | 'open' = 'tool'): boolean { - return gate.kind === 'tool' && callerTypedTool(gate, verb); +export function toolRerunsInCaller(gate: ToolTakeoverGate): boolean { + return gate.kind === 'tool' && callerTypedTool(gate); } /** What a re-run re-reads after the prompt wait: the pane survived it, in the diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index 7a8d786f5..5db198398 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -878,7 +878,8 @@ export function useDorControl({ } let toolName = stringParam(params.name)?.trim(); const openFile = stringParam(params.file); - if (openFile !== undefined && (params.name !== undefined || params.command !== undefined || params.args !== undefined)) { + const opening = openFile !== undefined; + if (opening && (params.name !== undefined || params.command !== undefined || params.args !== undefined)) { detail.respond({ ok: false, error: 'open accepts a file and optional tool, not a command' }); return; } @@ -915,6 +916,7 @@ export function useDorControl({ const readCallerGate = (id: string, toolCwd: string): ToolTakeoverGate => { const state = getTerminalPaneState(id); return { + verb: opening ? 'open' : 'tool', explicitSurface: stringParam(params.surface) !== undefined, minimized: booleanParam(params.minimized), workspaceActive: !scope || getActiveWorkspaceId() === scope, @@ -927,7 +929,7 @@ export function useDorControl({ }; }; - if (toolName || openFile !== undefined) { + if (toolName || opening) { // The registry, the closed substitution set, and the trust gate all // live behind this one host call (`dor/commands/types` -> // ToolSurfaceRequest). @@ -936,7 +938,7 @@ export function useDorControl({ detail.respond({ ok: false, error: 'this host cannot read a dormouse.yml; use `dor tool -- `' }); return; } - const lookup = await toolControl(openFile !== undefined + const lookup = await toolControl(opening ? { op: 'open', target: openFile, cwd, tool: stringParam(params.tool) } : { op: 'lookup', name: toolName!, cwd, args: toolArgs, global: booleanParam(params.global) }); if (unavailable()) return; @@ -1102,7 +1104,7 @@ export function useDorControl({ // match. Through the handshake, never `restartSurfaceInPlace`, // whose Ctrl+C would kill the `dor` awaiting this answer. if (match.id === callerId && !surfaceRunsCommand(matchState, matchedCommand, matchedCwd)) { - if (!callerGate || !toolRerunsInCaller(callerGate, openFile !== undefined ? 'open' : 'tool')) { + if (!callerGate || !toolRerunsInCaller(callerGate)) { // Nothing can be typed behind a line that is not this // invocation alone, and there is no survivor to reveal — the // user is sitting in it. Say so instead of reporting a tool @@ -1157,7 +1159,7 @@ export function useDorControl({ // rather than splitting (docs/specs/dor-tool.md -> Take-over). Must stay // below the pending-approval and key-match returns above: both of those // placements win over this one. - if (openFile === undefined && callerId && callerGate && toolTakesOverCaller(callerGate)) { + if (callerId && callerGate && toolTakesOverCaller(callerGate)) { // Answered before the tool starts, because answering is what frees // the shell to run it. respondTool('takeover', { surfaceId: callerId, command, cwd, minimized: false }); diff --git a/lib/src/host/tool-host.ts b/lib/src/host/tool-host.ts index a766bc6f2..f64ebca00 100644 --- a/lib/src/host/tool-host.ts +++ b/lib/src/host/tool-host.ts @@ -12,9 +12,9 @@ import { dirname } from 'node:path'; import type { ToolControlResult, ToolHostRequest } from '../lib/platform/tool-types'; import { resolveUpstreamUrl } from './git-upstream'; import { resolveOpenTool } from './tool-open'; -import { resolveToolInput, type ToolInput } from './tool-input'; +import type { ToolInput } from './tool-input'; import type { ToolEntry } from './tool-registry'; -import { readUserToolFile, userToolConfigPath } from './tool-user-config'; +import { readUserToolFile, resolveUserTool, userToolConfigPath } from './tool-user-config'; import { FileToolTrustStore, MemoryToolTrustStore, @@ -76,24 +76,21 @@ export function createToolHost(options: { stateDir?: string; userConfigPath?: st } try { - if (request.op === 'open') return await resolveOpenTool(request, options.userConfigPath ?? userToolConfigPath()); + const userPath = options.userConfigPath ?? userToolConfigPath(); + if (request.op === 'open') return await resolveOpenTool(request, userPath); const args = request.args ?? []; - const project = request.global ? null : await lookupTool(request.name, request.cwd, trust, { args }); +const project = request.global ? null : await lookupTool(request.name, request.cwd, trust, { args }); if (project?.status === 'ok') { return okResult(project.entry, project.input, { projectRoot: project.projectRoot, path: project.path, warnings: project.file.warnings }); } if (project && project.status !== 'no-file' && project.status !== 'unknown-tool') return project; // A project miss falls through to the user's own Tools, which need no grant. - const path = options.userConfigPath ?? userToolConfigPath(); - const file = await readUserToolFile(path); + const file = await readUserToolFile(userPath); const entry = file?.tools.get(request.name); - if (file && entry) { - const input = await resolveToolInput(entry, { projectRoot: null, cwd: request.cwd, args }); - return okResult(entry, input, { projectRoot: file.dir, path, warnings: file.warnings, scope: 'user' }); - } + if (file && entry) return await resolveUserTool(file, userPath, entry, request.cwd, args); if (project) return project; - return { status: 'unknown-tool', projectRoot: dirname(path), path, names: [...(file?.tools.keys() ?? [])].sort() }; + return { status: 'unknown-tool', projectRoot: dirname(userPath), path: userPath, names: [...(file?.tools.keys() ?? [])].sort() }; } catch (error) { return { status: 'error', message: error instanceof Error ? error.message : String(error) }; } diff --git a/lib/src/host/tool-open.test.ts b/lib/src/host/tool-open.test.ts index faac7c36c..02623a17b 100644 --- a/lib/src/host/tool-open.test.ts +++ b/lib/src/host/tool-open.test.ts @@ -6,12 +6,14 @@ import { createToolHost } from './tool-host'; let root: string; let config: string; +const writeConfig = (yaml: string) => writeFile(config, yaml); +const viewerConfig = (match: string) => `tools:\n viewer:\n run: [view, $TARGET]\nopen:\n - {match: '${match}', tool: viewer}\n`; beforeEach(async () => { root = await realpath(await mkdtemp(join(tmpdir(), 'dor-open-'))); config = join(root, 'user.yml'); await mkdir(join(root, 'docs')); await writeFile(join(root, 'docs', 'README.md'), 'hi'); - await writeFile(config, `tools: + await writeConfig(`tools: special: run: [special, $TARGET] prespawn_dedupe: [$TARGET] @@ -38,11 +40,11 @@ it('uses the first user rule and never discovers project definitions', async () }); }); -it('matches slashless patterns against filenames and preserves spaces and metacharacters', async () => { - await writeFile(config, "tools:\n viewer:\n run: [viewer, $TARGET]\nopen:\n - {match: '*.md', tool: viewer}\n"); +it('matches slashless patterns against the filename', async () => { + await writeConfig(viewerConfig('*.md')); const target = join(root, 'docs', 'a b; $(touch nope).md'); await writeFile(target, 'hi'); - expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'ok', run: ['viewer', target] }); + expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'ok', run: ['view', target] }); }); it.skipIf(process.platform === 'win32')('keys symlink aliases on the same canonical file', async () => { @@ -57,21 +59,17 @@ it.each(['https://example.com/file.md', 'file:///etc/passwd', 'surface:3', 'docs expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'error' }); }); -it('names the user configuration in unmatched-file errors and refuses broken associations', async () => { +it('names the user configuration in unmatched-file errors', async () => { const target = join(root, 'unknown.binary'); await writeFile(target, 'hi'); expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'error', message: expect.stringContaining(config) }); - await writeFile(config, 'open:\n - {match: "*", tool: undeclared}\n'); - expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'error', message: expect.stringContaining('defined in this user file') }); }); - it('matches catch-all rules above the invocation directory and canonical absolute rules', async () => { await mkdir(join(root, 'work')); - await writeFile(config, `tools:\n viewer:\n run: [view, $TARGET]\nopen:\n - {match: '**/*.md', tool: viewer}\n`); + await writeConfig(viewerConfig('**/*.md')); const request = { op: 'open', target: '../docs/README.md', cwd: join(root, 'work') } as const; expect(await host().handle(request)).toMatchObject({ status: 'ok', name: 'viewer' }); - const canonicalPattern = join(root, 'docs').replace(/\\/g, '/') + '/**'; - await writeFile(config, `tools:\n viewer:\n run: [view, $TARGET]\nopen:\n - {match: '${canonicalPattern}', tool: viewer}\n`); + await writeConfig(viewerConfig(join(root, 'docs').replace(/\\/g, '/') + '/**')); expect(await host().handle(request)).toMatchObject({ status: 'ok', name: 'viewer' }); }); diff --git a/lib/src/host/tool-open.ts b/lib/src/host/tool-open.ts index a385b4d8f..a0468a87a 100644 --- a/lib/src/host/tool-open.ts +++ b/lib/src/host/tool-open.ts @@ -1,8 +1,8 @@ import { basename, relative, sep } from 'node:path'; import picomatch from 'picomatch'; import type { ToolLookupResult } from '../lib/platform/tool-types'; -import { resolveLocalToolTarget, resolveToolInput } from './tool-input'; -import { readUserToolFile } from './tool-user-config'; +import { resolveLocalToolTarget } from './tool-input'; +import { readUserToolFile, resolveUserTool } from './tool-user-config'; /** Dispatch is entirely user-owned. Never discover a project file here, even * when its Tool name shadows the rule's selected user Tool. */ @@ -22,7 +22,5 @@ export async function resolveOpenTool( if (!file || !entry) return { status: 'error', message: request.tool ? `no user Tool '${request.tool}' in ${path}` : `no Tool matches '${request.target}'; add an open rule to ${path}, or use dor open --tool ` }; - const input = await resolveToolInput(entry, { cwd: request.cwd, projectRoot: null, args: [target] }); - return { status: 'ok', projectRoot: file.dir, path, name: entry.name, scope: 'user', - ...input, render: entry.render, port: entry.port, warnings: [...file.warnings] }; + return resolveUserTool(file, path, entry, request.cwd, [target]); } diff --git a/lib/src/host/tool-registry.test.ts b/lib/src/host/tool-registry.test.ts index 5e860b8d2..6b2d1e19c 100644 --- a/lib/src/host/tool-registry.test.ts +++ b/lib/src/host/tool-registry.test.ts @@ -200,7 +200,6 @@ it('rejects a shell command with a target-only dedupe key at declaration time', .toThrow('$TARGET in prespawn_dedupe requires an argument-list run'); }); - it('keeps project associations inert and validates user associations at declaration time', () => { expect(parse('open: malformed-but-inert\n').warnings).toEqual([expect.stringContaining('project open rules are ignored')]); expect(() => parse('open: nope\n', USER)).toThrow("'open' must be an ordered list"); diff --git a/lib/src/host/tool-registry.ts b/lib/src/host/tool-registry.ts index bf829928b..c1b9d9f5b 100644 --- a/lib/src/host/tool-registry.ts +++ b/lib/src/host/tool-registry.ts @@ -196,23 +196,11 @@ export function parseToolFile( tools.set(name, { name, run: typeof run === 'string' ? run.trim() : run, render, port, dedupeTemplate }); } - const open: OpenRule[] = []; - if (doc.open !== undefined) { - if (scope === 'repo') warnings.push(`${path}: project open rules are ignored; configure associations in the user file`); - else { - if (!Array.isArray(doc.open)) throw new ToolFileError(`${path}: 'open' must be an ordered list`); - for (const rule of doc.open) { - if (!isRecord(rule) || typeof rule.match !== 'string' || !rule.match || typeof rule.tool !== 'string' - || !tools.has(rule.tool) || Object.keys(rule).some(key => key !== 'match' && key !== 'tool')) { - throw new ToolFileError(`${path}: each open rule needs a match pattern and a tool defined in this user file`); - } - if (typeof tools.get(rule.tool)!.run === 'string') { - throw new ToolFileError(`${path}: open rule for '${rule.tool}' needs an argument-list run to receive the file`); - } - open.push({ match: rule.match, tool: rule.tool }); - } - } + // Associations are user-only (`docs/specs/dor-tool.md` -> Opening local files). + if (scope === 'repo' && doc.open !== undefined) { + warnings.push(`${path}: project open rules are ignored; configure associations in the user file`); } + const open = scope === 'repo' ? [] : parseOpenRules(doc.open, tools, path); return { scope, dir, tools, warnings, open }; } @@ -246,6 +234,22 @@ export function substituteToolTokens(element: string, context: SubstitutionConte }); } +function parseOpenRules(node: unknown, tools: ReadonlyMap, path: string): OpenRule[] { + if (node === undefined) return []; + if (!Array.isArray(node)) throw new ToolFileError(`${path}: 'open' must be an ordered list`); + return node.map((rule: unknown) => { + const entry = isRecord(rule) && typeof rule.tool === 'string' ? tools.get(rule.tool) : undefined; + if (!isRecord(rule) || !entry || typeof rule.match !== 'string' || !rule.match + || Object.keys(rule).some(key => key !== 'match' && key !== 'tool')) { + throw new ToolFileError(`${path}: each open rule needs a match pattern and a tool defined in this user file`); + } + if (typeof entry.run === 'string') { + throw new ToolFileError(`${path}: open rule for '${entry.name}' needs an argument-list run to receive the file`); + } + return { match: rule.match, tool: entry.name }; + }); +} + /** * Render an entry's key for one invocation. Returns `null` when the entry * declared no template — a tool has an identity if and only if it was given diff --git a/lib/src/host/tool-user-config.ts b/lib/src/host/tool-user-config.ts index dfd82dc33..2d23f0672 100644 --- a/lib/src/host/tool-user-config.ts +++ b/lib/src/host/tool-user-config.ts @@ -1,6 +1,8 @@ import { homedir } from 'node:os'; import { dirname, isAbsolute, join } from 'node:path'; -import { parseToolFile, type ToolFile } from './tool-registry'; +import type { ToolLookupResult } from '../lib/platform/tool-types'; +import { resolveToolInput } from './tool-input'; +import { parseToolFile, type ToolEntry, type ToolFile } from './tool-registry'; import { readToolFile } from './tool-trust'; export function userToolConfigPath(): string { @@ -16,3 +18,12 @@ export async function readUserToolFile(path: string): Promise { throw error; } } + +/** The `ok` result for a user Tool: no project root, no trust gate. */ +export async function resolveUserTool( + file: ToolFile, path: string, entry: ToolEntry, cwd: string, args: readonly string[], +): Promise { + const input = await resolveToolInput(entry, { cwd, projectRoot: null, args }); + return { status: 'ok', projectRoot: file.dir, path, name: entry.name, scope: 'user', + ...input, render: entry.render, port: entry.port, warnings: [...file.warnings] }; +} diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 76bc2ca19..d4bf95ad7 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -7,7 +7,7 @@ "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4600, "docs/specs/dor-cli.md": 6000, - "docs/specs/dor-tool.md": 3100, + "docs/specs/dor-tool.md": 3050, "docs/specs/glossary.md": 3000, "docs/specs/layout.md": 8800, "docs/specs/mobile-terminal-ui.md": 1950, From f3a56a500a40b402ef0f6061c606a7ca17491f4c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 15 Sep 2026 18:07:33 -0700 Subject: [PATCH 5/9] Preserve persistence timer tests and document open matching --- docs/specs/dor-tool.md | 2 +- docs/specs/dor-tool.rationale.md | 4 ++++ lib/src/components/Wall.test.tsx | 18 +++++++++--------- scripts/spec-word-budgets.json | 2 +- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index f4bcda6e5..22758f310 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -139,7 +139,7 @@ Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `dor/test/snapshot **Must select the first matching entry of the user file's ordered `open` list**, whose entries contain `match` and `tool`. `--tool` explicitly selects a user Tool. Every association must name an argument-list Tool in that same user file. Never discover project configuration during this lookup; project `open` rules are ignored with a warning during explicit project-tool lookup. -**Must match patterns without `/` against the canonical filename, and patterns with `/` against both the CWD-relative and canonical absolute paths**, separators normalized to `/`, with bundled picomatch: case-sensitive, dotfiles only by explicit pattern. A miss names the user config path and suggests `--tool`. +**Must match patterns without `/` against the canonical filename, and patterns with `/` against both the CWD-relative and canonical absolute paths**, separators normalized to `/`, with bundled picomatch: case-sensitive, dotfiles only by explicit pattern. A miss names the user config path and suggests `--tool`. (rationale) **Must pass the canonical file path as the selected Tool's one input.** Reuse follows [Identity and dedupe](#identity-and-dedupe), `$TARGET` in the key providing per-file identity; placement follows [Take-over](#take-over). diff --git a/docs/specs/dor-tool.rationale.md b/docs/specs/dor-tool.rationale.md index 62b274e64..90a36f6fc 100644 --- a/docs/specs/dor-tool.rationale.md +++ b/docs/specs/dor-tool.rationale.md @@ -55,3 +55,7 @@ A derived URL or browser daemon binding belongs to one execution. Reusing it aft Routing `dor tool` to a native editor on one host would change its result from a Surface handle to a host-specific side effect. Native file opening remains a separate operation. A Workspace transfer carries the live browser binding separately from its durable record. The arrival record can reach disk while the windows coordinate, whereas the content channel stays in memory; reusing the saved-record projection alone would reopen a Tool browser and lose its current page state. Pending approvals and unfinished browser startup still own asynchronous work in the source window, so the move waits for the user to resolve the approval or retry after startup. + +## Opening local files + +The VS Code host supports Node 18, which lacks native glob matching. Bundled picomatch keeps association behavior the same across hosts. Patterns with separators test both the CWD-relative and canonical absolute path: files above the CWD otherwise start with `../` and can miss patterns intended to cover an absolute directory. Canonicalization also gives symlink aliases one matching identity. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 6d225798f..60335ea42 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -3059,29 +3059,29 @@ describe('Wall session persistence: ownership filtering', () => { await act(async () => { root.render(); }); - await waitUntil(0); + await settle(0); await act(async () => { container.querySelector('[data-lath-leaf="pane-a"] [aria-label="Minimize"]')!.click(); }); // Past the debounce, so the commit's own save has landed and the tracker // is clean again. - await waitUntil(1_000); + await settle(1_000); expect(container.querySelector('[data-door-id="pane-a"]')).not.toBeNull(); saveState.mockClear(); // The heartbeat writes only when something marked dirty. - await waitUntil(31_000); + await settle(31_000); expect(saveState).not.toHaveBeenCalled(); // Another Workspace's Session, fanned to this Wall by the adapter. await echo('pane-elsewhere'); - await waitUntil(31_000); + await settle(31_000); expect(saveState).not.toHaveBeenCalled(); // The Door's own Session: its `untouched` flip rides this echo and nothing // else reports it, so the Wall has to hear it. await echo('pane-a'); - await waitUntil(31_000); + await settle(31_000); expect(saveState).toHaveBeenCalled(); } finally { vi.useRealTimers(); @@ -3098,7 +3098,7 @@ describe('Wall session persistence: ownership filtering', () => { root.render(); }); // Past a heartbeat, so the mount's own dirty state has been written off. - await waitUntil(31_000); + await settle(31_000); saveState.mockClear(); // Both stores are Window-global. A change keyed to a foreign Surface must @@ -3106,17 +3106,17 @@ describe('Wall session persistence: ownership filtering', () => { // every idle Workspace, every heartbeat. await act(async () => { setTerminalActivity('pane-elsewhere', { todo: true }); }); await act(async () => { resetTerminalPaneState('pane-elsewhere'); }); - await waitUntil(31_000); + await settle(31_000); expect(saveState, 'foreign Surface').not.toHaveBeenCalled(); await act(async () => { setTerminalActivity('pane-a', { todo: true }); }); - await waitUntil(31_000); + await settle(31_000); expect(saveState, 'own Surface').toHaveBeenCalled(); saveState.mockClear(); // An unkeyed notification is a store-wide reset, which every Wall takes. await act(async () => { clearTerminalActivity(); }); - await waitUntil(31_000); + await settle(31_000); expect(saveState, 'store-wide reset').toHaveBeenCalled(); } finally { vi.useRealTimers(); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index d4bf95ad7..76bc2ca19 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -7,7 +7,7 @@ "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4600, "docs/specs/dor-cli.md": 6000, - "docs/specs/dor-tool.md": 3050, + "docs/specs/dor-tool.md": 3100, "docs/specs/glossary.md": 3000, "docs/specs/layout.md": 8800, "docs/specs/mobile-terminal-ui.md": 1950, From 9274795a0f49980d253d73a7cd33d4ae2e5ba071 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 15 Sep 2026 18:57:19 -0700 Subject: [PATCH 6/9] Cover invalid user open rules and align host lookup --- lib/src/host/tool-host.ts | 2 +- lib/src/host/tool-registry.test.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/src/host/tool-host.ts b/lib/src/host/tool-host.ts index f64ebca00..4ac8e37c8 100644 --- a/lib/src/host/tool-host.ts +++ b/lib/src/host/tool-host.ts @@ -79,7 +79,7 @@ export function createToolHost(options: { stateDir?: string; userConfigPath?: st const userPath = options.userConfigPath ?? userToolConfigPath(); if (request.op === 'open') return await resolveOpenTool(request, userPath); const args = request.args ?? []; -const project = request.global ? null : await lookupTool(request.name, request.cwd, trust, { args }); + const project = request.global ? null : await lookupTool(request.name, request.cwd, trust, { args }); if (project?.status === 'ok') { return okResult(project.entry, project.input, { projectRoot: project.projectRoot, path: project.path, warnings: project.file.warnings }); } diff --git a/lib/src/host/tool-registry.test.ts b/lib/src/host/tool-registry.test.ts index 6b2d1e19c..911ea42d6 100644 --- a/lib/src/host/tool-registry.test.ts +++ b/lib/src/host/tool-registry.test.ts @@ -203,6 +203,10 @@ it('rejects a shell command with a target-only dedupe key at declaration time', it('keeps project associations inert and validates user associations at declaration time', () => { expect(parse('open: malformed-but-inert\n').warnings).toEqual([expect.stringContaining('project open rules are ignored')]); expect(() => parse('open: nope\n', USER)).toThrow("'open' must be an ordered list"); + expect(() => parse('open:\n - {match: "*.md", tool: missing}\n', USER)) + .toThrow('defined in this user file'); + expect(() => parse('tools:\n viewer:\n run: [viewer]\nopen:\n - {match: "*.md", tool: viewer, extra: true}\n', USER)) + .toThrow('defined in this user file'); expect(() => parse('tools:\n viewer:\n run: viewer\nopen:\n - {match: "*.md", tool: viewer}\n', USER)) .toThrow("open rule for 'viewer' needs an argument-list run"); }); From 6c9b46247ca0e24c01b8b745142e13853808f08e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 16 Sep 2026 16:35:24 -0700 Subject: [PATCH 7/9] Fix open associations from symlinked working directories --- docs/specs/dor-tool.md | 2 +- docs/specs/dor-tool.rationale.md | 2 +- lib/src/components/wall/tool-takeover.ts | 4 ++-- lib/src/host/tool-host.ts | 5 ++--- lib/src/host/tool-open.test.ts | 27 ++++++++++++++++++++++++ lib/src/host/tool-open.ts | 4 +++- 6 files changed, 36 insertions(+), 8 deletions(-) diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index fe278dd68..6f73d7a8e 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -151,7 +151,7 @@ Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `dor/test/snapshot **Must select the first matching entry of the user file's ordered `open` list**, whose entries contain `match` and `tool`. `--tool` explicitly selects a user Tool. Every association must name an argument-list Tool in that same user file. Never discover project configuration during this lookup; project `open` rules are ignored with a warning during explicit project-tool lookup. -**Must match patterns without `/` against the canonical filename, and patterns with `/` against both the CWD-relative and canonical absolute paths**, separators normalized to `/`, with bundled picomatch: case-sensitive, dotfiles only by explicit pattern. A miss names the user config path and suggests `--tool`. (rationale) +**Must match patterns without `/` against the canonical filename, and patterns with `/` against both paths relative to the canonical CWD and canonical absolute paths**, separators normalized to `/`, with bundled picomatch: case-sensitive, dotfiles only by explicit pattern. Use the supplied CWD if canonicalization fails; matching never changes the Tool's run directory or `$CWD`. A miss names the user config path and suggests `--tool`. (rationale) **Must pass the canonical file path as the selected Tool's one input.** Reuse follows [Identity and dedupe](#identity-and-dedupe), `$TARGET` in the key providing per-file identity; placement follows [Take-over](#take-over). diff --git a/docs/specs/dor-tool.rationale.md b/docs/specs/dor-tool.rationale.md index f106596b6..657c29c38 100644 --- a/docs/specs/dor-tool.rationale.md +++ b/docs/specs/dor-tool.rationale.md @@ -68,4 +68,4 @@ A Workspace transfer carries the live browser binding separately from its durabl ## Opening local files -The VS Code host supports Node 18, which lacks native glob matching. Bundled picomatch keeps association behavior the same across hosts. Patterns with separators test both the CWD-relative and canonical absolute path: files above the CWD otherwise start with `../` and can miss patterns intended to cover an absolute directory. Canonicalization also gives symlink aliases one matching identity. +The VS Code host supports Node 18, which lacks native glob matching. Bundled picomatch keeps association behavior the same across hosts. Patterns with separators test both the CWD-relative and canonical absolute path: files above the CWD otherwise start with `../` and can miss patterns intended to cover an absolute directory. Canonicalization also gives symlink aliases one matching identity. Canonicalizing only the target mixed physical and logical paths under a symlinked CWD, so relative slash patterns missed files inside that directory. An absolute target can still be opened after its caller's CWD disappears; matching falls back to the supplied directory in that case. diff --git a/lib/src/components/wall/tool-takeover.ts b/lib/src/components/wall/tool-takeover.ts index 0603287dc..40f5fdac9 100644 --- a/lib/src/components/wall/tool-takeover.ts +++ b/lib/src/components/wall/tool-takeover.ts @@ -16,9 +16,9 @@ const COMPOUND_SYNTAX = /[;&|<>()`\n\r]/; /** * Whether the shell reported running exactly one command and that command is - * `dor tool` — the human-intent signal, not a security boundary + * `dor` with the requested verb — the human-intent signal, not a security boundary * (`docs/specs/dor-tool.md` -> Take-over). Case folds on the launcher, which is - * a filename, and not on the verb, which stricli parses case-sensitively. + * a filename; the requested verb must match exactly. */ export function isNakedToolInvocation(rawCommandLine: string | null | undefined, verb: 'tool' | 'open' = 'tool'): boolean { const line = rawCommandLine?.trim(); diff --git a/lib/src/host/tool-host.ts b/lib/src/host/tool-host.ts index 4ac8e37c8..99b4847e7 100644 --- a/lib/src/host/tool-host.ts +++ b/lib/src/host/tool-host.ts @@ -28,11 +28,11 @@ export interface ToolHost { handle(request: ToolHostRequest): Promise; } -/** The one wire shape for a resolved Tool, whichever file declared it. */ +/** The `ok` result for a project Tool after its trust gate. */ function okResult( entry: ToolEntry, input: ToolInput, - source: { projectRoot: string; path: string; warnings: readonly string[]; scope?: 'user' }, + source: { projectRoot: string; path: string; warnings: readonly string[] }, ): ToolControlResult { return { status: 'ok', @@ -40,7 +40,6 @@ function okResult( path: source.path, name: entry.name, ...input, - ...(source.scope ? { scope: source.scope } : {}), render: entry.render, port: entry.port, warnings: [...source.warnings], diff --git a/lib/src/host/tool-open.test.ts b/lib/src/host/tool-open.test.ts index 02623a17b..3124702d5 100644 --- a/lib/src/host/tool-open.test.ts +++ b/lib/src/host/tool-open.test.ts @@ -73,3 +73,30 @@ it('matches catch-all rules above the invocation directory and canonical absolut await writeConfig(viewerConfig(join(root, 'docs').replace(/\\/g, '/') + '/**')); expect(await host().handle(request)).toMatchObject({ status: 'ok', name: 'viewer' }); }); + +it('matches the first specific rule through a symlinked working directory without changing the run directory', async () => { + await writeConfig(`tools: + special: + run: [special, $TARGET, $CWD] + prespawn_dedupe: [$TARGET] + markdown: + run: [markdown, $TARGET] +open: + - {match: docs/README.md, tool: special} + - {match: '**/*.md', tool: markdown} +`); + const cwd = join(root, 'alias'); + await symlink(root, cwd, 'junction'); + const target = join(root, 'docs', 'README.md'); + expect(await host().handle({ op: 'open', target: 'docs/README.md', cwd })).toMatchObject({ + status: 'ok', name: 'special', run: ['special', target, cwd], key: [target], + }); +}); + +it('still matches an absolute target when the working directory no longer exists', async () => { + const target = join(root, 'docs', 'README.md'); + const cwd = join(root, 'missing'); + expect(await host().handle({ op: 'open', target, cwd })).toMatchObject({ + status: 'ok', name: 'markdown', run: ['markdown', target], + }); +}); diff --git a/lib/src/host/tool-open.ts b/lib/src/host/tool-open.ts index a0468a87a..7ebf347ba 100644 --- a/lib/src/host/tool-open.ts +++ b/lib/src/host/tool-open.ts @@ -1,3 +1,4 @@ +import { realpath } from 'node:fs/promises'; import { basename, relative, sep } from 'node:path'; import picomatch from 'picomatch'; import type { ToolLookupResult } from '../lib/platform/tool-types'; @@ -12,7 +13,8 @@ export async function resolveOpenTool( ): Promise { const target = await resolveLocalToolTarget(request.target, request.cwd); const file = await readUserToolFile(path); - const relativePath = relative(request.cwd, target).split(sep).join('/'); + const relativeBase = await realpath(request.cwd).catch(() => request.cwd); + const relativePath = relative(relativeBase, target).split(sep).join('/'); const canonicalPath = target.split(sep).join('/'); const name = request.tool ?? file?.open.find(rule => { const matches = picomatch(rule.match, { windows: false }); From 238f1c6a469cd5ccade1e84e9b304ef2a35630c0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 16 Sep 2026 16:48:48 -0700 Subject: [PATCH 8/9] Align the Tool spec budget after parent integration --- scripts/spec-word-budgets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 95111fcf1..73398cd66 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -7,7 +7,7 @@ "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4600, "docs/specs/dor-cli.md": 6000, - "docs/specs/dor-tool.md": 3450, + "docs/specs/dor-tool.md": 3500, "docs/specs/glossary.md": 3000, "docs/specs/layout.md": 8850, "docs/specs/mobile-terminal-ui.md": 1950, From d7cb82c90d8e29824a3ef3fa79d556a2a5d647a3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 16 Sep 2026 16:51:07 -0700 Subject: [PATCH 9/9] Name malformed Tool association fields accurately --- docs/specs/dor-tool.md | 1 - lib/src/host/tool-registry.test.ts | 7 ++++++- lib/src/host/tool-registry.ts | 9 ++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index b877567dd..45a35a64a 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -89,7 +89,6 @@ Approval layout follows `docs/specs/layout.md` → Pane body. **Must validate a bounded regular, non-symlink grant receipt for the requested key.** Missing, corrupt, or mismatched records grant nothing; a filename alone is never approval. - **Must keep implicit file dispatch user-global and limited to user-global Tools.** Reserved: any future repo `prespawn_*` execution uses the same approval; see scope **dor-tools** under [Future](#future). Source of truth: `createToolHost` in `lib/src/host/tool-host.ts`; `FileToolTrustStore` / `lookupTool` in `lib/src/host/tool-trust.ts`; `resolveUpstreamUrl` in `lib/src/host/git-upstream.ts`; `ToolApproval` in `lib/src/components/wall/ToolApproval.tsx`; `resolveToolApproval` in `lib/src/components/Wall.tsx`; `toolPendingFromParams` in `lib/src/components/wall/browser-surface.ts`. Tests: `lib/src/host/tool-trust.test.ts`, `lib/src/components/Wall.test.tsx`, `lib/src/components/wall/tool-surface.test.ts`. diff --git a/lib/src/host/tool-registry.test.ts b/lib/src/host/tool-registry.test.ts index 082c27652..f5866b30e 100644 --- a/lib/src/host/tool-registry.test.ts +++ b/lib/src/host/tool-registry.test.ts @@ -219,7 +219,12 @@ it('keeps project associations inert and validates user associations at declarat expect(() => parse('open:\n - {match: "*.md", tool: missing}\n', USER)) .toThrow('defined in this user file'); expect(() => parse('tools:\n viewer:\n run: [viewer]\nopen:\n - {match: "*.md", tool: viewer, extra: true}\n', USER)) - .toThrow('defined in this user file'); + .toThrow("unknown field 'extra'"); expect(() => parse('tools:\n viewer:\n run: viewer\nopen:\n - {match: "*.md", tool: viewer}\n', USER)) .toThrow("open rule for 'viewer' needs an argument-list run"); }); + +it('rejects an explicit empty tools block while allowing an absent block', () => { + expect(() => parse('tools:\nopen: []\n', USER)).toThrow("'tools' must be a mapping"); + expect(parse('open: []\n', USER).tools.size).toBe(0); +}); diff --git a/lib/src/host/tool-registry.ts b/lib/src/host/tool-registry.ts index 3ab1e819c..cafcfd03b 100644 --- a/lib/src/host/tool-registry.ts +++ b/lib/src/host/tool-registry.ts @@ -133,7 +133,7 @@ export function parseToolFile( } if (!isRecord(doc)) throw new ToolFileError(`${path}: expected a mapping at the top level`); - const toolsNode = doc.tools ?? {}; + const toolsNode = doc.tools === undefined ? {} : doc.tools; if (!isRecord(toolsNode)) throw new ToolFileError(`${path}: 'tools' must be a mapping of name to entry`); const tools = new Map(); @@ -241,10 +241,13 @@ function parseOpenRules(node: unknown, tools: ReadonlyMap, pa if (!Array.isArray(node)) throw new ToolFileError(`${path}: 'open' must be an ordered list`); return node.map((rule: unknown) => { const entry = isRecord(rule) && typeof rule.tool === 'string' ? tools.get(rule.tool) : undefined; - if (!isRecord(rule) || !entry || typeof rule.match !== 'string' || !rule.match - || Object.keys(rule).some(key => key !== 'match' && key !== 'tool')) { + if (!isRecord(rule) || !entry || typeof rule.match !== 'string' || !rule.match) { throw new ToolFileError(`${path}: each open rule needs a match pattern and a tool defined in this user file`); } + const unknown = Object.keys(rule).find(key => key !== 'match' && key !== 'tool'); + if (unknown !== undefined) { + throw new ToolFileError(`${path}: open rule for '${entry.name}' has an unknown field '${unknown}' (known: match, tool)`); + } if (typeof entry.run === 'string') { throw new ToolFileError(`${path}: open rule for '${entry.name}' needs an argument-list run to receive the file`); }