Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5ad6971
Add local-file-only dor open dispatch through user Tools
nedtwigg Sep 15, 2026
cbfeb97
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 15, 2026
e372ab0
Support file glob matching on all supported hosts
nedtwigg Sep 15, 2026
191c829
Disclose the bundled glob matcher
nedtwigg Sep 15, 2026
46eb215
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
acd3241
Simplify dor open dispatch
nedtwigg Sep 16, 2026
f3a56a5
Preserve persistence timer tests and document open matching
nedtwigg Sep 16, 2026
9813411
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
1a77a88
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
229a30d
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
469e86a
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
9274795
Cover invalid user open rules and align host lookup
nedtwigg Sep 16, 2026
a762373
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
0fa5ab7
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
359d38f
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
5ce2b30
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
65dc881
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
6c9b462
Fix open associations from symlinked working directories
nedtwigg Sep 16, 2026
062a832
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
0a0d4ed
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
238f1c6
Align the Tool spec budget after parent integration
nedtwigg Sep 16, 2026
d7cb82c
Name malformed Tool association fields accurately
nedtwigg Sep 16, 2026
185c108
Merge branch 'dor-tool-inputs' into dor-open-dispatch
nedtwigg Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/specs/dor-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -645,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

Expand Down
25 changes: 18 additions & 7 deletions docs/specs/dor-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ 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.

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).
Comment thread
nedtwigg marked this conversation as resolved.

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`.

Expand Down Expand Up @@ -140,18 +140,31 @@ Source of truth: `TerminalPane` in `lib/src/components/TerminalPane.tsx`; `focus

**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`**, 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 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).

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)

| 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 pane of the active Workspace; 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 |
Expand Down Expand Up @@ -207,10 +220,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 <local-file>` 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.
Expand Down
4 changes: 4 additions & 0 deletions docs/specs/dor-tool.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,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. 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.
3 changes: 3 additions & 0 deletions dor/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -90,6 +91,7 @@ const COMMANDS = [
splitCommand,
ensureCommand,
toolCommand,
openCommand,
versionCommand,
skillCommand,
sendCommand,
Expand All @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions dor/src/commands/open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { buildCommand } from '@stricli/core';
import type { Command, DorCommandContext, WorkspaceScopedFlags } from './types.js';
import { callerWorkingDirectory, stringParser, workspaceFlag, workspaceParam } from './shared.js';
import { dispatchToolSurface } from './tool.js';

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 = {
name: 'open',
command: buildCommand<OpenFlags, [string], DorCommandContext>({
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 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.

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' }] },
},
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);
},
}),
};
32 changes: 20 additions & 12 deletions dor/src/commands/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
Command,
DorCommandContext,
ParseResult,
ToolSurfaceRequest,
ToolSurfaceResponse,
} from './types.js';
import {
Expand Down Expand Up @@ -160,21 +161,28 @@ async function runToolCommand(this: DorCommandContext, flags: ToolFlags, ...rest
return new Error('dor tool requires a tool name or -- <command...>');
}

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<void | Error> {
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));
Expand Down
3 changes: 3 additions & 0 deletions dor/src/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>`). */
name?: string;
args?: string[];
Expand Down
23 changes: 22 additions & 1 deletion dor/test/cli-output.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
}
});
2 changes: 2 additions & 0 deletions dor/test/snapshots/help/dor.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ USAGE
dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] [--workspace ref] -- <command>...
dor tool [--global] [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] [--workspace ref] <name> [args...]
dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] [--workspace ref] -- <command>...
dor open [--json] [--minimize] [--fresh] [--surface id|ref] [--workspace ref] [--cwd path] [--tool name] <file>
dor version [--json]
dor skill [--install] [--json]
dor send <surface> ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] [--workspace ref]
Expand All @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions dor/test/snapshots/help/open.md
Original file line number Diff line number Diff line change
@@ -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] <file>
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 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.

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.

```
6 changes: 4 additions & 2 deletions lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading