Skip to content
Open
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@
"perf:ios": "node --experimental-strip-types scripts/perf/run.ts --platform ios",
"perf:android": "node --experimental-strip-types scripts/perf/run.ts --platform android",
"lint": "oxlint . --deny-warnings",
"format": "oxfmt --write src test skills package.json tsconfig.json tsconfig.lib.json rslib.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
"format:check": "oxfmt --check src test skills package.json tsconfig.json tsconfig.lib.json rslib.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
"format": "node ./node_modules/oxfmt/bin/oxfmt --write src test skills package.json tsconfig.json tsconfig.lib.json rslib.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
"format:check": "node ./node_modules/oxfmt/bin/oxfmt --check src test skills package.json tsconfig.json tsconfig.lib.json rslib.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
"fallow": "fallow audit --base origin/main",
"fallow:all": "fallow --summary",
"fallow:baseline": "(fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary || true) && (fallow health --save-baseline fallow-baselines/health.json --summary || true)",
Expand Down
59 changes: 59 additions & 0 deletions src/commands/__tests__/command-surface-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import {
listCommandMetadataNames,
listMcpCommandMetadata,
} from '../command-metadata.ts';
import {
commandFamilies,
listCommandFamilyCliReaders,
listCommandFamilyCliSchemas,
listCommandFamilyDaemonWriters,
listCommandFamilyDefinitions,
listCommandFamilyMetadata,
} from '../family/registry.ts';
import { listExecutableCommandNames } from '../command-surface.ts';

test('MCP exposed command names have metadata and executable command definitions', () => {
Expand Down Expand Up @@ -37,3 +45,54 @@ test('common command input accepts web platform selector', () => {
assert.deepEqual(platformSchema?.enum, ['ios', 'macos', 'android', 'linux', 'web', 'apple']);
assert.equal(input.platform, 'web');
});

test('command family facets expose one complete metadata and executable surface', () => {
const familyNames = commandFamilies.map((family) => family.name);
assert.deepEqual(familyNames, [...new Set(familyNames)], 'command family names must be unique');

const metadataNames = listCommandFamilyMetadata()
.map((metadata) => metadata.name)
.sort();
const definitionNames = listCommandFamilyDefinitions()
.map((definition) => definition.name)
.sort();

assert.deepEqual(definitionNames, metadataNames);
assert.deepEqual(metadataNames, listCommandMetadataNames());
assert.deepEqual(definitionNames, listExecutableCommandNames());
});

test('command family facets expose CLI schema and reader coverage centrally', () => {
const metadataNames = listCommandFamilyMetadata()
.map((metadata) => metadata.name)
.sort();
const cliSchemaNames = Object.keys(listCommandFamilyCliSchemas()).sort();
const cliReaderNames = Object.keys(listCommandFamilyCliReaders()).sort();
const metadataNameSet = new Set(metadataNames);

assert.deepEqual(cliReaderNames, metadataNames);
for (const name of cliSchemaNames) {
assert.ok(metadataNameSet.has(name), `${name} CLI schema must belong to command metadata`);
}
});

test('command family facets keep daemon writers as an explicit projection subset', () => {
const writerNames = Object.keys(listCommandFamilyDaemonWriters()).sort();
const metadataNames = new Set(listCommandFamilyMetadata().map((metadata) => metadata.name));
const projectionAliases = new Set([
'gesture-fling',
'gesture-pan',
'gesture-pinch',
'gesture-rotate',
'gesture-swipe',
'gesture-transform',
]);

assert.ok(writerNames.length > 0);
for (const name of writerNames) {
assert.ok(
metadataNames.has(name) || projectionAliases.has(name),
`${name} daemon writer must belong to command metadata or projection aliases`,
);
}
});
23 changes: 17 additions & 6 deletions src/commands/batch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@ 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 { defineExecutableCommand } from '../command-contract.ts';
import { commonToClientOptions } from '../command-input.ts';
import { batchCliOutputFormatters } from './output.ts';
import { createBatchCommandMetadata, type BatchInput } from './metadata.ts';
import { createBatchDaemonWriter } from './projection.ts';

export const batchCommandMetadata = createBatchCommandMetadata();
const batchCommandMetadata = createBatchCommandMetadata();

export const batchCommandDefinition = defineExecutableCommand(
batchCommandMetadata,
(client, input) => client.batch.run(toBatchOptions(input)),
const batchCommandDefinition = defineExecutableCommand(batchCommandMetadata, (client, input) =>
client.batch.run(toBatchOptions(input)),
);

export const batchCliSchemas = {
const batchCliSchemas = {
batch: {
usageOverride: 'batch [--steps <json> | --steps-file <path>]',
listUsageOverride: 'batch --steps <json> | --steps-file <path>',
Expand All @@ -24,7 +25,7 @@ export const batchCliSchemas = {
},
} as const satisfies Record<string, CommandSchemaOverride>;

export const batchCliReaders = {
const batchCliReaders = {
batch: ((_positionals, flags) => ({
...commonInputFromFlags(flags),
steps: flags.batchSteps ?? [],
Expand All @@ -34,6 +35,16 @@ export const batchCliReaders = {
})) satisfies CliReader,
} as const;

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

export { createBatchDaemonWriter };
export type { BatchCommandName } from './projection.ts';

Expand Down
84 changes: 84 additions & 0 deletions src/commands/capture/alert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { ALERT_ACTIONS, type AlertAction } from '../../alert-contract.ts';
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
import type { AlertCommandOptions } from '../../client-types.ts';
import { compactRecord, enumField, integerField } from '../command-input.ts';
import { defineExecutableCommand } from '../command-contract.ts';
import {
commonInputFromFlags,
direct,
optionalNumber,
readFiniteNumber,
} from '../cli-grammar/common.ts';
import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts';
import { defineCommandFacet } from '../family/types.ts';
import { defineFieldCommandMetadata } from '../field-command-contract.ts';
import { messageOutput } from '../output-common.ts';
import { AppError } from '../../utils/errors.ts';

const ALERT_COMMAND_NAME = 'alert';

const alertCommandDescription = 'Inspect or handle platform alerts.';

const alertCommandMetadata = defineFieldCommandMetadata(
ALERT_COMMAND_NAME,
alertCommandDescription,
{
action: enumField(ALERT_ACTIONS),
timeoutMs: integerField(),
},
);

const alertCommandDefinition = defineExecutableCommand(alertCommandMetadata, (client, input) =>
client.command.alert(input),
);

const alertCliSchema = {
usageOverride: 'alert [get|accept|dismiss|wait] [timeout]',
positionalArgs: ['action?', 'timeout?'],
} as const;

export const alertCliReader: CliReader = (positionals, flags) => ({
...commonInputFromFlags(flags),
...readAlertInput(positionals),
});

export const alertDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.alert, (input) =>
alertPositionals(input as AlertCommandOptions),
);

export const alertCommandFacet = defineCommandFacet({
name: ALERT_COMMAND_NAME,
metadata: alertCommandMetadata,
definition: alertCommandDefinition,
cliSchema: alertCliSchema,
cliReader: alertCliReader,
daemonWriter: alertDaemonWriter,
cliOutputFormatter: messageOutput,
});

function alertPositionals(input: AlertCommandOptions): string[] {
return [input.action ?? 'get', ...optionalNumber(input.timeoutMs)];
}

function readAlertInput(positionals: string[]): Record<string, unknown> {
if (positionals.length > 2) {
throw new AppError('INVALID_ARGS', 'alert accepts at most action and timeout arguments.');
}
const action = readAlertAction(positionals[0]);
const timeoutMs = readFiniteNumber(positionals[1], 'alert timeout');
return compactRecord({ action, timeoutMs });
}

function readAlertAction(value: string | undefined): AlertAction | undefined {
const action = value?.toLowerCase();
if (
action === undefined ||
action === 'get' ||
action === 'accept' ||
action === 'dismiss' ||
action === 'wait'
) {
return action;
}
throw new AppError('INVALID_ARGS', 'alert action must be get, accept, dismiss, or wait.');
}
69 changes: 69 additions & 0 deletions src/commands/capture/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
import { SNAPSHOT_FLAGS } from '../../utils/cli-flags.ts';
import { AppError } from '../../utils/errors.ts';
import {
booleanField,
integerField,
jsonSchemaField,
requiredField,
stringField,
} from '../command-input.ts';
import { defineExecutableCommand } from '../command-contract.ts';
import { commonInputFromFlags, direct, requiredDaemonString } from '../cli-grammar/common.ts';
import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts';
import { defineCommandFacet } from '../family/types.ts';
import { defineFieldCommandMetadata } from '../field-command-contract.ts';

const DIFF_COMMAND_NAME = 'diff';

const diffCommandDescription = 'Diff accessibility snapshots.';

const diffCommandMetadata = defineFieldCommandMetadata(DIFF_COMMAND_NAME, diffCommandDescription, {
kind: requiredField(jsonSchemaField<'snapshot'>({ type: 'string', const: 'snapshot' })),
out: stringField(),
interactiveOnly: booleanField(),
depth: integerField(),
scope: stringField(),
raw: booleanField(),
});

const diffCommandDefinition = defineExecutableCommand(diffCommandMetadata, (client, input) =>
client.capture.diff(input),
);

const diffCliSchema = {
usageOverride:
'diff snapshot | diff screenshot --baseline <path> [current.png] [--out <diff.png>] [--threshold <0-1>] [--overlay-refs]',
helpDescription: 'Diff accessibility snapshot or compare screenshots pixel-by-pixel',
summary: 'Diff snapshot or screenshot',
positionalArgs: ['kind', 'current?'],
allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'],
} as const;

export const diffCliReader: CliReader = (positionals, flags) => {
if (positionals[0] !== 'snapshot') {
throw new AppError('INVALID_ARGS', 'Only diff snapshot is available through this parser.');
}
return {
...commonInputFromFlags(flags),
kind: 'snapshot',
out: flags.out,
interactiveOnly: flags.snapshotInteractiveOnly,
depth: flags.snapshotDepth,
scope: flags.snapshotScope,
raw: flags.snapshotRaw,
};
};

const diffDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.diff, (input) => [
requiredDaemonString(input.kind, 'diff requires kind'),
]);

export const diffCommandFacet = defineCommandFacet({
name: DIFF_COMMAND_NAME,
metadata: diffCommandMetadata,
definition: diffCommandDefinition,
cliSchema: diffCliSchema,
cliReader: diffCliReader,
daemonWriter: diffDaemonWriter,
});
Loading