Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,11 @@ guarantees are whole-matrix decisions, not local edits.
The perfect-shape refactor is complete and merged. Its end-state:

- Two derivation registries. One `CommandDescriptor` per command
(`src/core/command-descriptor/registry.ts`) is the single declaration site from which the public
catalog, capability matrix, daemon command registry, batch allowlist, MCP tools, CLI schema, and
the Node client surface are *derived* by parity-tested projection; the dispatch `switch` became a
total map keyed on the command-name union (a missing handler is a compile error). One
(`src/core/command-descriptor/registry.ts`) is the single declaration site from which the
capability matrix, daemon command registry, batch allowlist, timeout policy, MCP exposure list, and
capability-checked CLI command list are *derived* by parity-tested projection. The command catalog
still owns identity, command families still own surface metadata/CLI schema, and the Node client
surface remains a deferred derivation target. One
`PlatformPlugin` per platform family (`src/core/platform-plugin/`) stops core/daemon from branching
on platform, with the Apple plugin the first instance. See
[ADR 0008](docs/adr/0008-command-descriptor-registry.md).
Expand Down
6 changes: 5 additions & 1 deletion docs/adr/0008-command-descriptor-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ Each derived table must be asserted **byte-for-byte equivalent** to the hand-aut
**before** the hand table is deleted. The principal risk is the import-cycle inversion: `command-catalog.ts`
has ~95 importers and the family facet currently imports `AgentDeviceClient`, so the descriptor module must
own the `Input`/`Result` types and the client must be derived as a view type, enforced by a lint boundary.
Until this lands and the registry tests pin it, the hand-authored tables remain the source of truth.
As of 2026-07, the descriptor is live for the daemon registry, capability matrix, structured-batch
allowlist, daemon-client timeout policy, MCP exposure list, and capability-checked CLI command list.
The catalog still owns command identity and the command family facet still owns surface metadata, so
future migration steps should keep deleting one live hand-maintained table at a time rather than
introducing a parallel manifest.

This ADR owns the decision and its constraints; the roadmap that prototyped it has been retired, with
the delivered end-state recorded in [CONTEXT.md](../../CONTEXT.md) (Architecture).
51 changes: 0 additions & 51 deletions src/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,49 +98,6 @@ export type ClientBackedCliCommandName =
| typeof LOCAL_CLI_COMMANDS.metro
| typeof LOCAL_CLI_COMMANDS.session;

const MCP_UNEXPOSED_CLI_COMMANDS = commandSet(
LOCAL_CLI_COMMANDS.auth,
LOCAL_CLI_COMMANDS.cdp,
LOCAL_CLI_COMMANDS.connect,
LOCAL_CLI_COMMANDS.connection,
LOCAL_CLI_COMMANDS.disconnect,
LOCAL_CLI_COMMANDS.mcp,
LOCAL_CLI_COMMANDS.proxy,
LOCAL_CLI_COMMANDS.reactDevtools,
LOCAL_CLI_COMMANDS.web,
PUBLIC_COMMANDS.prepare,
);

const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet(
LOCAL_CLI_COMMANDS.auth,
LOCAL_CLI_COMMANDS.cdp,
LOCAL_CLI_COMMANDS.connect,
LOCAL_CLI_COMMANDS.connection,
LOCAL_CLI_COMMANDS.debug,
LOCAL_CLI_COMMANDS.disconnect,
LOCAL_CLI_COMMANDS.mcp,
LOCAL_CLI_COMMANDS.metro,
LOCAL_CLI_COMMANDS.proxy,
LOCAL_CLI_COMMANDS.reactDevtools,
LOCAL_CLI_COMMANDS.session,
LOCAL_CLI_COMMANDS.web,
PUBLIC_COMMANDS.artifacts,
PUBLIC_COMMANDS.appState,
PUBLIC_COMMANDS.prepare,
PUBLIC_COMMANDS.batch,
PUBLIC_COMMANDS.capabilities,
PUBLIC_COMMANDS.devices,
PUBLIC_COMMANDS.doctor,
PUBLIC_COMMANDS.gesture,
PUBLIC_COMMANDS.replay,
PUBLIC_COMMANDS.test,
PUBLIC_COMMANDS.trace,
);

function commandSet(...commands: readonly string[]): ReadonlySet<string> {
return new Set(commands);
}

export function listCliCommandNames(): CliCommandName[] {
return [...Object.values(PUBLIC_COMMANDS), ...Object.values(LOCAL_CLI_COMMANDS)].sort();
}
Expand All @@ -161,11 +118,3 @@ export function isClientBackedCliCommandName(
command === LOCAL_CLI_COMMANDS.session
);
}

export function listMcpExposedCommandNames(): CliCommandName[] {
return listCliCommandNames().filter((command) => !MCP_UNEXPOSED_CLI_COMMANDS.has(command));
}

export function listCapabilityCheckedCommandNames(): CliCommandName[] {
return listCliCommandNames().filter((command) => !CAPABILITY_EXEMPT_CLI_COMMANDS.has(command));
}
2 changes: 1 addition & 1 deletion src/commands/__tests__/command-surface-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { listMcpExposedCommandNames } from '../../command-catalog.ts';
import { listMcpExposedCommandNames } from '../../core/command-descriptor/registry.ts';
import {
listCommandMetadata,
listCommandMetadataNames,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/command-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { listMcpExposedCommandNames } from '../command-catalog.ts';
import { listMcpExposedCommandNames } from '../core/command-descriptor/registry.ts';
import type { CommandMetadata } from './command-contract.ts';
import { listCommandFamilyMetadata, type CommandFamilyCommandName } from './family/registry.ts';

Expand Down
45 changes: 43 additions & 2 deletions src/core/command-descriptor/__tests__/parity.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { STRUCTURED_BATCH_COMMAND_NAMES } from '../../../batch-policy.ts';
import { PUBLIC_COMMANDS } from '../../../command-catalog.ts';
import { listCliCommandNames, PUBLIC_COMMANDS } from '../../../command-catalog.ts';
import { BASE_COMMAND_CAPABILITY_MATRIX } from '../../capabilities.ts';
import {
DAEMON_COMMAND_DESCRIPTORS,
type DaemonCommandDescriptor,
} from '../../../daemon/daemon-command-registry.ts';
import type { DaemonRequest } from '../../../daemon/types.ts';
import { deriveDaemonCommandDescriptors, deriveStructuredBatchCommandNames } from '../derive.ts';
import { commandDescriptors } from '../registry.ts';
import {
commandDescriptors,
listCapabilityCheckedCommandNames,
listMcpExposedCommandNames,
} from '../registry.ts';

// Function-valued traits cannot be deep-equaled across re-authored closures, so
// (mirroring daemon-command-registry.test.ts) they are compared by presence and
Expand Down Expand Up @@ -174,3 +178,40 @@ test('structured-batch allowlist is built from descriptors', () => {
assert.ok(!batchable.has(excluded), `${excluded} is not batchable`);
}
});

test('MCP exposure list is built from descriptors', () => {
const cliCommands = new Set<string>(listCliCommandNames());
const expected = commandDescriptors
.filter((descriptor) => descriptor.mcpExposed && cliCommands.has(descriptor.name))
.map((descriptor) => descriptor.name)
.sort();
const expectedNames = new Set<string>(expected);

assert.deepEqual(listMcpExposedCommandNames(), expected);
assert.ok(expectedNames.has('debug'), 'local debug command stays MCP-exposed');
assert.ok(expectedNames.has('metro'), 'local metro command stays MCP-exposed');
assert.ok(expectedNames.has('session'), 'local session command stays MCP-exposed');
assert.equal(expectedNames.has(PUBLIC_COMMANDS.prepare), false, 'prepare stays out of MCP');
assert.equal(expectedNames.has('auth'), false, 'schema-only auth command stays out of MCP');
});

test('capability-checked command list is built from descriptor capabilities', () => {
const cliCommands = new Set<string>(listCliCommandNames());
const expected = commandDescriptors
.filter(
(descriptor) =>
'capability' in descriptor && descriptor.capability && cliCommands.has(descriptor.name),
)
.map((descriptor) => descriptor.name)
.sort();
const expectedNames = new Set<string>(expected);

assert.deepEqual(listCapabilityCheckedCommandNames(), expected);
assert.ok(expectedNames.has(PUBLIC_COMMANDS.snapshot), 'snapshot remains capability-checked');
assert.equal(
expectedNames.has(PUBLIC_COMMANDS.capabilities),
false,
'control-plane capabilities command stays capability-exempt',
);
assert.equal(expectedNames.has('debug'), false, 'local debug command stays capability-exempt');
});
61 changes: 57 additions & 4 deletions src/core/command-descriptor/registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
INTERNAL_COMMANDS,
listMcpExposedCommandNames,
listCliCommandNames,
type CliCommandName,
PUBLIC_COMMANDS,
} from '../../command-catalog.ts';
import type { CommandCapability } from '../capabilities.ts';
Expand All @@ -15,6 +16,10 @@ import { resolvePostActionObservationSupport } from './post-action-observation.t
import type { PostActionObservationSupport } from './post-action-observation.ts';
import type { CommandDescriptor, CommandTimeoutPolicy } from './types.ts';

type RawCommandDescriptor = Omit<CommandDescriptor, 'mcpExposed'> & {
mcpExposed?: boolean;
};

// ---------------------------------------------------------------------------
// Daemon request-policy trait bundles — copied VERBATIM from
// src/daemon/daemon-command-registry.ts (DAEMON_COMMAND_DESCRIPTORS).
Expand Down Expand Up @@ -396,6 +401,7 @@ const RAW_COMMAND_DESCRIPTORS = [
onTimeout: 'reset-daemon',
},
batchable: false,
mcpExposed: false,
},
{
name: PUBLIC_COMMANDS.batch,
Expand Down Expand Up @@ -694,9 +700,26 @@ const RAW_COMMAND_DESCRIPTORS = [
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: true,
},
] as const satisfies readonly Omit<CommandDescriptor, 'mcpExposed'>[];

const MCP_EXPOSED_COMMAND_NAMES = new Set<string>(listMcpExposedCommandNames());
// -- local client-backed CLI/MCP commands (no daemon route/capability) --
{
name: 'debug',
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
},
{
name: 'metro',
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
},
{
name: 'session',
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
batchable: false,
},
] as const satisfies readonly RawCommandDescriptor[];

const CLI_COMMAND_NAMES = new Set<string>(listCliCommandNames());

/**
* The additive single source of truth (ADR-0008, Phase 1 step 1). Proven
Expand All @@ -708,16 +731,46 @@ const MCP_EXPOSED_COMMAND_NAMES = new Set<string>(listMcpExposedCommandNames());
*/
export const commandDescriptors = RAW_COMMAND_DESCRIPTORS.map((descriptor) => ({
...descriptor,
mcpExposed: MCP_EXPOSED_COMMAND_NAMES.has(descriptor.name),
mcpExposed: resolveMcpExposure(descriptor),
})) satisfies readonly CommandDescriptor[];

/** The literal union of every registered command name. */
export type Command = (typeof commandDescriptors)[number]['name'];

export function listMcpExposedCommandNames(): CliCommandName[] {
return commandDescriptors
.filter((descriptor) => isMcpExposedCliCommand(descriptor))
.map((descriptor) => descriptor.name as CliCommandName)
.sort();
}

export function listCapabilityCheckedCommandNames(): CliCommandName[] {
return commandDescriptors
.filter((descriptor) => isCapabilityCheckedCliCommand(descriptor))
.map((descriptor) => descriptor.name as CliCommandName)
.sort();
}

const COMMAND_DESCRIPTOR_BY_NAME: ReadonlyMap<string, CommandDescriptor> = new Map(
commandDescriptors.map((descriptor) => [descriptor.name, descriptor]),
);

function isCliCommandName(command: string): command is CliCommandName {
return CLI_COMMAND_NAMES.has(command);
}

function resolveMcpExposure(descriptor: RawCommandDescriptor): boolean {
return descriptor.mcpExposed ?? CLI_COMMAND_NAMES.has(descriptor.name);
}

function isMcpExposedCliCommand(descriptor: CommandDescriptor): boolean {
return descriptor.mcpExposed && isCliCommandName(descriptor.name);
}

function isCapabilityCheckedCliCommand(descriptor: CommandDescriptor): boolean {
return Boolean(descriptor.capability) && isCliCommandName(descriptor.name);
}

const TIMEOUT_POLICY_BY_COMMAND: ReadonlyMap<string, CommandTimeoutPolicy> = new Map(
commandDescriptors.map((descriptor) => [descriptor.name, descriptor.timeoutPolicy]),
);
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/__tests__/router.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { setImmediate } from 'node:timers/promises';
import { test } from 'vitest';
import { listMcpExposedCommandNames } from '../../command-catalog.ts';
import { listMcpExposedCommandNames } from '../../core/command-descriptor/registry.ts';
import { handleMcpMessage } from '../router.ts';
import { createMcpPayloadQueue, handleMcpPayload } from '../server.ts';

Expand Down
2 changes: 1 addition & 1 deletion src/utils/__tests__/command-schema-guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import { listCapabilityCommands } from '../../core/capabilities.ts';
import {
INTERNAL_COMMANDS,
isKnownCliCommandName,
listCapabilityCheckedCommandNames,
listCliCommandNames,
SPECIAL_CLI_COMMANDS,
} from '../../command-catalog.ts';
import { listCapabilityCheckedCommandNames } from '../../core/command-descriptor/registry.ts';
import { getCliCommandSchema } from '../command-schema.ts';

test('every public capability command has a parser schema entry', () => {
Expand Down
Loading