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
23 changes: 23 additions & 0 deletions src/commands/__tests__/command-surface-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import {
listCommandFamilyMetadata,
} from '../family/registry.ts';
import { listExecutableCommandNames } from '../command-surface.ts';
import { defineExecutableCommand } from '../command-contract.ts';
import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts';
import { defineFieldCommandMetadata } from '../field-command-contract.ts';

test('MCP exposed command names have metadata and executable command definitions', () => {
const mcpExposedNames = listMcpExposedCommandNames().sort();
Expand Down Expand Up @@ -98,3 +101,23 @@ test('command family facets keep daemon writers as an explicit projection subset
);
}
});

test('command family facets reject duplicate daemon writer keys', () => {
const metadata = defineFieldCommandMetadata('example', 'Example command.', {});
const definition = defineExecutableCommand(metadata, async () => ({}));
const writer = () => ({ command: 'example', positionals: [], options: {} });

const facet = defineCommandFacet({
name: 'example',
metadata,
definition,
cliReader: () => ({}),
daemonWriter: writer,
extraDaemonWriters: { example: writer },
});

assert.throws(
() => defineCommandFamilyFromFacets({ name: 'test', commands: [facet] }),
/Duplicate command family daemon writer: example/,
);
});
55 changes: 28 additions & 27 deletions src/commands/batch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { BatchRunOptions } from '../../client-types.ts';
import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts';
import { commonInputFromFlags } from '../cli-grammar/common.ts';
import type { CliReader } from '../cli-grammar/types.ts';
import { defineCommandFamily } from '../family/types.ts';
import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts';
import { defineExecutableCommand } from '../command-contract.ts';
import { commonToClientOptions } from '../command-input.ts';
import { batchCliOutputFormatters } from './output.ts';
Expand All @@ -15,34 +15,35 @@ const batchCommandDefinition = defineExecutableCommand(batchCommandMetadata, (cl
client.batch.run(toBatchOptions(input)),
);

const batchCliSchemas = {
batch: {
usageOverride: 'batch [--steps <json> | --steps-file <path>]',
listUsageOverride: 'batch --steps <json> | --steps-file <path>',
helpDescription: 'Execute multiple commands in one daemon request',
summary: 'Run multiple commands',
allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'],
},
} as const satisfies Record<string, CommandSchemaOverride>;

const batchCliReaders = {
batch: ((_positionals, flags) => ({
...commonInputFromFlags(flags),
steps: flags.batchSteps ?? [],
onError: flags.batchOnError,
maxSteps: flags.batchMaxSteps,
out: flags.out,
})) satisfies CliReader,
} as const;

export const batchCommandFamily = defineCommandFamily({
const batchCliSchema = {
usageOverride: 'batch [--steps <json> | --steps-file <path>]',
listUsageOverride: 'batch --steps <json> | --steps-file <path>',
helpDescription: 'Execute multiple commands in one daemon request',
summary: 'Run multiple commands',
allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'],
} as const satisfies CommandSchemaOverride;

const batchCliReader: CliReader = (_positionals, flags) => ({
...commonInputFromFlags(flags),
steps: flags.batchSteps ?? [],
onError: flags.batchOnError,
maxSteps: flags.batchMaxSteps,
out: flags.out,
});

const batchCommandFacet = defineCommandFacet({
name: 'batch',
metadata: batchCommandMetadata,
definition: batchCommandDefinition,
cliSchema: batchCliSchema,
cliReader: batchCliReader,
cliOutputFormatter: batchCliOutputFormatters.batch,
});

export const batchCommandFamily = defineCommandFamilyFromFacets({
name: 'batch',
clientSurface: false,
metadata: [batchCommandMetadata],
definitions: [batchCommandDefinition],
cliSchemas: batchCliSchemas,
cliReaders: batchCliReaders,
cliOutputFormatters: batchCliOutputFormatters,
commands: [batchCommandFacet],
});

export { createBatchDaemonWriter };
Expand Down
29 changes: 11 additions & 18 deletions src/commands/debugging/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AppError } from '../../utils/errors.ts';
import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts';
import { enumField, requiredField, stringField } from '../command-input.ts';
import { defineCommandFamily } from '../family/types.ts';
import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts';
import { defineExecutableCommand } from '../command-contract.ts';
import { defineFieldCommandMetadata } from '../field-command-contract.ts';
import { commonInputFromFlags } from '../cli-grammar/common.ts';
Expand All @@ -25,15 +25,11 @@ export const debugCommandMetadata = defineFieldCommandMetadata(
},
);

const debuggingCommandMetadata = [debugCommandMetadata] as const;

export const debugCommandDefinition = defineExecutableCommand(
debugCommandMetadata,
(client, input) => client.debug.symbols(input),
);

const debuggingCommandDefinitions = [debugCommandDefinition] as const;

const debugCliSchema = {
usageOverride:
'debug symbols --artifact <crash.ips|crash.log> (--dsym <App.dSYM> | --search-path <dir>) [--out <symbolicated>]',
Expand All @@ -46,10 +42,6 @@ const debugCliSchema = {
allowedFlags: ['artifact', 'dsym', 'searchPath', 'out'],
} as const satisfies CommandSchemaOverride;

const debuggingCliSchemas = {
[DEBUG_COMMAND_NAME]: debugCliSchema,
} as const satisfies Record<string, CommandSchemaOverride>;

export const debugCliReader: CliReader = (positionals, flags) => ({
...commonInputFromFlags(flags),
action: readDebugAction(positionals[0]),
Expand All @@ -59,17 +51,18 @@ export const debugCliReader: CliReader = (positionals, flags) => ({
out: flags.out,
});

const debuggingCliReaders = {
debug: debugCliReader,
} satisfies Record<string, CliReader>;
const debugCommandFacet = defineCommandFacet({
name: DEBUG_COMMAND_NAME,
metadata: debugCommandMetadata,
definition: debugCommandDefinition,
cliSchema: debugCliSchema,
cliReader: debugCliReader,
cliOutputFormatter: debuggingCliOutputFormatters.debug,
});

export const debuggingCommandFamily = defineCommandFamily({
export const debuggingCommandFamily = defineCommandFamilyFromFacets({
name: 'debugging',
metadata: debuggingCommandMetadata,
definitions: debuggingCommandDefinitions,
cliSchemas: debuggingCliSchemas,
cliReaders: debuggingCliReaders,
cliOutputFormatters: debuggingCliOutputFormatters,
commands: [debugCommandFacet],
});

function readDebugAction(value: string | undefined): 'symbols' {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/family/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ function mergeFamilyRecords<TKey extends keyof CommandFamilyRecordMap>(
): Record<string, CommandFamilyRecordMap[TKey]> {
const records: Record<string, CommandFamilyRecordMap[TKey]> = {};
for (const family of commandFamilies) {
const record = (family as CommandFamilyFacet)[key] as
const record = family[key] as
| Readonly<Record<string, CommandFamilyRecordMap[TKey]>>
| undefined;
if (!record) continue;
Expand Down
62 changes: 40 additions & 22 deletions src/commands/family/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type CommandFacet<TCommandName extends string = string> = {
cliSchema?: CommandSchemaOverride;
cliReader: CliReader;
daemonWriter?: DaemonWriter;
extraDaemonWriters?: Readonly<Record<string, DaemonWriter>>;
cliOutputFormatter?: CliOutputFormatter;
};

Expand All @@ -44,20 +45,6 @@ type CommandFacetDefinitions<TCommands extends readonly CommandFacet[]> = {

type CommandFacetName<TCommands extends readonly CommandFacet[]> = TCommands[number]['name'];

type CommandFamilyMetadataName<TMetadata extends readonly AnyCommandMetadata[]> =
TMetadata[number]['name'];

export function defineCommandFamily<
const TMetadata extends readonly AnyCommandMetadata[],
const TDefinitions extends readonly AnyCommandDefinition<CommandFamilyMetadataName<TMetadata>>[],
const TFamily extends CommandFamilyFacet<CommandFamilyMetadataName<TMetadata>> & {
metadata: TMetadata;
definitions: TDefinitions;
},
>(family: TFamily): TFamily {
return family;
}

export function defineCommandFacet<
const TCommandName extends string,
const TCommand extends CommandFacet<TCommandName>,
Expand All @@ -75,24 +62,55 @@ export function defineCommandFamilyFromFacets<
const cliOutputFormatters: Record<string, CliOutputFormatter> = {};

for (const command of family.commands) {
if (command.cliSchema) cliSchemas[command.name] = command.cliSchema;
cliReaders[command.name] = command.cliReader;
if (command.daemonWriter) daemonWriters[command.name] = command.daemonWriter;
if (command.cliSchema) {
addRecordEntry(cliSchemas, 'CLI schema', command.name, command.cliSchema);
}
addRecordEntry(cliReaders, 'CLI reader', command.name, command.cliReader);
if (command.daemonWriter) {
addRecordEntry(daemonWriters, 'daemon writer', command.name, command.daemonWriter);
}
if (command.extraDaemonWriters) {
for (const [name, writer] of Object.entries(command.extraDaemonWriters)) {
addRecordEntry(daemonWriters, 'daemon writer', name, writer);
}
}
if (command.cliOutputFormatter) {
cliOutputFormatters[command.name] = command.cliOutputFormatter;
addRecordEntry(
cliOutputFormatters,
'CLI output formatter',
command.name,
command.cliOutputFormatter,
);
}
}

return defineCommandFamily({
return {
name: family.name,
clientSurface: family.clientSurface,
metadata: family.commands.map((command) => command.metadata) as CommandFacetMetadata<TCommands>,
definitions: family.commands.map(
(command) => command.definition,
) as CommandFacetDefinitions<TCommands>,
cliSchemas,
cliSchemas: cliSchemas as Partial<Record<CommandFacetName<TCommands>, CommandSchemaOverride>>,
cliReaders: cliReaders as Record<CommandFacetName<TCommands>, CliReader>,
daemonWriters,
cliOutputFormatters,
});
cliOutputFormatters: cliOutputFormatters as Partial<
Record<CommandFacetName<TCommands>, CliOutputFormatter>
>,
} satisfies CommandFamilyFacet<CommandFacetName<TCommands>> & {
metadata: CommandFacetMetadata<TCommands>;
definitions: CommandFacetDefinitions<TCommands>;
};
}

function addRecordEntry<TValue>(
record: Record<string, TValue>,
label: string,
name: string,
value: TValue,
): void {
if (Object.hasOwn(record, name)) {
throw new Error(`Duplicate command family ${label}: ${name}`);
}
record[name] = value;
}
Loading
Loading