diff --git a/package.json b/package.json index a36f9dd50..c1f0e9581 100644 --- a/package.json +++ b/package.json @@ -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)", diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 2c60d5332..73f37ef17 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -12,7 +12,7 @@ export function buildIntegrationProgressModel({ root = process.cwd() } = {}) { const handlerTestDir = path.join(root, 'src/daemon/handlers/__tests__'); const providerScenarioDir = path.join(root, 'test/integration/provider-scenarios'); const commandContractFiles = listFiles(path.join(root, 'src/commands'), (file) => - file.endsWith(`${path.sep}index.ts`), + isCommandContractSource(file), ); const clientCommandMethods = readClientCommandMethods(commandContractFiles); @@ -301,6 +301,14 @@ function listFiles(dir, predicate) { }); } +function isCommandContractSource(file) { + return ( + file.endsWith('.ts') && + !file.endsWith('.test.ts') && + !file.includes(`${path.sep}__tests__${path.sep}`) + ); +} + function summarizeFiles(files) { let lines = 0; let tests = 0; diff --git a/src/commands/__tests__/command-surface-metadata.test.ts b/src/commands/__tests__/command-surface-metadata.test.ts index dda5fcf07..b79a361ab 100644 --- a/src/commands/__tests__/command-surface-metadata.test.ts +++ b/src/commands/__tests__/command-surface-metadata.test.ts @@ -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', () => { @@ -37,3 +45,56 @@ 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`, + ); + } +}); diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index 386dd752a..0d3fd930c 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -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 | --steps-file ]', listUsageOverride: 'batch --steps | --steps-file ', @@ -24,7 +25,7 @@ export const batchCliSchemas = { }, } as const satisfies Record; -export const batchCliReaders = { +const batchCliReaders = { batch: ((_positionals, flags) => ({ ...commonInputFromFlags(flags), steps: flags.batchSteps ?? [], @@ -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'; diff --git a/src/commands/capture/alert.ts b/src/commands/capture/alert.ts new file mode 100644 index 000000000..73d7a5bb3 --- /dev/null +++ b/src/commands/capture/alert.ts @@ -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 { + 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.'); +} diff --git a/src/commands/capture/diff.ts b/src/commands/capture/diff.ts new file mode 100644 index 000000000..00f851148 --- /dev/null +++ b/src/commands/capture/diff.ts @@ -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 [current.png] [--out ] [--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, +}); diff --git a/src/commands/capture/index.ts b/src/commands/capture/index.ts index 2ff2f806c..fcaf460a6 100644 --- a/src/commands/capture/index.ts +++ b/src/commands/capture/index.ts @@ -1,364 +1,38 @@ -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { - AlertCommandOptions, - CaptureScreenshotOptions, - WaitCommandOptions, -} from '../../client-types.ts'; -import type { AlertAction } from '../../alert-contract.ts'; -import { ALERT_ACTIONS } from '../../alert-contract.ts'; -import { parseWaitPositionals } from '../../core/wait-positionals.ts'; -import { SESSION_SURFACES } from '../../core/session-surface.ts'; +import { defineCommandFamilyFromFacets } from '../family/types.ts'; +import { alertCommandFacet, alertCliReader, alertDaemonWriter } from './alert.ts'; +import { diffCommandFacet, diffCliReader } from './diff.ts'; import { - SCREENSHOT_COMMAND_FLAG_KEYS, - screenshotFlagsFromOptions, - screenshotOptionsFromFlags, -} from '../../contracts/screenshot.ts'; -import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; -import { SELECTOR_SNAPSHOT_FLAGS, SNAPSHOT_FLAGS, type CliFlags } from '../../utils/cli-flags.ts'; -import { AppError } from '../../utils/errors.ts'; -import { tryParseSelectorChain } from '../../utils/selectors-parse.ts'; -import { - booleanField, - compactRecord, - enumField, - integerField, - jsonSchemaField, - optionalEnum, - requiredField, - stringField, -} from '../command-input.ts'; -import { defineExecutableCommand } from '../command-contract.ts'; -import { defineFieldCommandMetadata } from '../field-command-contract.ts'; -import { WAIT_KIND_VALUES } from './wait-command-contract.ts'; -import { - commonInputFromFlags, - direct, - optionalNumber, - optionalString, - readFiniteNumber, - request, - requiredDaemonString, - selectionOptionsFromFlags, - selectorSnapshotOptionsFromFlags, -} from '../cli-grammar/common.ts'; -import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; -import { - SETTINGS_COMMAND_NAME, - settingsCliReader as settingsCliReaderImpl, - settingsCliSchema, - settingsCommandDefinition, - settingsCommandMetadata, - settingsDaemonWriter as settingsDaemonWriterImpl, -} from './settings.ts'; - -const SNAPSHOT_COMMAND_NAME = 'snapshot'; -const SCREENSHOT_COMMAND_NAME = 'screenshot'; -const DIFF_COMMAND_NAME = 'diff'; -const WAIT_COMMAND_NAME = 'wait'; -const ALERT_COMMAND_NAME = 'alert'; - -const snapshotCommandDescription = 'Capture an accessibility snapshot.'; -const screenshotCommandDescription = 'Capture a screenshot.'; -const diffCommandDescription = 'Diff accessibility snapshots.'; -const waitCommandDescription = 'Wait for duration, text, ref, or selector.'; -const alertCommandDescription = 'Inspect or handle platform alerts.'; - -const snapshotCommandMetadata = defineFieldCommandMetadata( - SNAPSHOT_COMMAND_NAME, - snapshotCommandDescription, - { - interactiveOnly: booleanField(), - depth: integerField(), - scope: stringField(), - raw: booleanField(), - forceFull: booleanField(), - timeoutMs: integerField('Maximum wall-clock time for the snapshot command.'), - }, -); - -const screenshotCommandMetadata = defineFieldCommandMetadata( - SCREENSHOT_COMMAND_NAME, - screenshotCommandDescription, - { - path: stringField('Output path.'), - overlayRefs: booleanField(), - fullscreen: booleanField(), - maxSize: integerField(), - stabilize: booleanField(), - surface: enumField(SESSION_SURFACES), - }, -); - -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 waitCommandMetadata = defineFieldCommandMetadata(WAIT_COMMAND_NAME, waitCommandDescription, { - kind: enumField(WAIT_KIND_VALUES), - durationMs: integerField(), - text: stringField(), - ref: stringField(), - selector: stringField(), - timeoutMs: integerField(), - depth: integerField(), - scope: stringField(), - raw: booleanField(), -}); - -const alertCommandMetadata = defineFieldCommandMetadata( - ALERT_COMMAND_NAME, - alertCommandDescription, - { - action: enumField(ALERT_ACTIONS), - timeoutMs: integerField(), - }, -); - -export const captureCommandMetadata = [ - snapshotCommandMetadata, - screenshotCommandMetadata, - diffCommandMetadata, - waitCommandMetadata, - alertCommandMetadata, - settingsCommandMetadata, -] as const; - -const snapshotCommandDefinition = defineExecutableCommand( - snapshotCommandMetadata, - (client, input) => client.capture.snapshot(input), -); - -const screenshotCommandDefinition = defineExecutableCommand( - screenshotCommandMetadata, - (client, input) => client.capture.screenshot(input), -); - -const diffCommandDefinition = defineExecutableCommand(diffCommandMetadata, (client, input) => - client.capture.diff(input), -); - -const waitCommandDefinition = defineExecutableCommand(waitCommandMetadata, (client, input) => - client.command.wait(waitInputToOptions(input)), -); - -const alertCommandDefinition = defineExecutableCommand(alertCommandMetadata, (client, input) => - client.command.alert(input), -); - -export const captureCommandDefinitions = [ - snapshotCommandDefinition, - screenshotCommandDefinition, - diffCommandDefinition, - waitCommandDefinition, - alertCommandDefinition, - settingsCommandDefinition, + screenshotCommandFacet, + screenshotCliReader, + screenshotDaemonWriter, +} from './screenshot.ts'; +import { settingsCliReader, settingsCommandFacet, settingsDaemonWriter } from './settings.ts'; +import { snapshotCommandFacet, snapshotCliReader } from './snapshot.ts'; +import { waitCommandFacet, waitCliReader, waitDaemonWriter } from './wait.ts'; + +const captureCommandFacets = [ + snapshotCommandFacet, + screenshotCommandFacet, + diffCommandFacet, + waitCommandFacet, + alertCommandFacet, + settingsCommandFacet, ] as const; -const snapshotCliSchema = { - usageOverride: - 'snapshot [--diff] [-i] [-d ] [-s ] [--raw] [--force-full] [--timeout ]', - helpDescription: - 'Capture accessibility tree or diff against the previous session baseline. For iOS raw-coordinate fallback after a no-op ref press, inspect rects with snapshot -i --json, press the rect center, then verify with diff snapshot -i or snapshot --diff.', - summary: 'Capture accessibility tree or diff against the previous session baseline', - allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull', 'timeoutMs'], -} as const satisfies CommandSchemaOverride; - -const diffCliSchema = { - usageOverride: - 'diff snapshot | diff screenshot --baseline [current.png] [--out ] [--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 satisfies CommandSchemaOverride; - -const screenshotCliSchema = { - helpDescription: - 'Capture screenshot (macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, or --no-stabilize for low-latency Android capture loops)', - summary: 'Capture screenshot with optional desktop, downscale, or ref overlay modes', - positionalArgs: ['path?'], - allowedFlags: SCREENSHOT_COMMAND_FLAG_KEYS, -} as const satisfies CommandSchemaOverride; - -const waitCliSchema = { - usageOverride: 'wait |text |@ref| [timeoutMs]', - positionalArgs: ['durationOrSelector', 'timeoutMs?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], -} as const satisfies CommandSchemaOverride; - -const alertCliSchema = { - usageOverride: 'alert [get|accept|dismiss|wait] [timeout]', - positionalArgs: ['action?', 'timeout?'], -} as const satisfies CommandSchemaOverride; - -export const captureCliSchemas = { - [SNAPSHOT_COMMAND_NAME]: snapshotCliSchema, - [SCREENSHOT_COMMAND_NAME]: screenshotCliSchema, - [DIFF_COMMAND_NAME]: diffCliSchema, - [WAIT_COMMAND_NAME]: waitCliSchema, - [ALERT_COMMAND_NAME]: alertCliSchema, - [SETTINGS_COMMAND_NAME]: settingsCliSchema, -} as const satisfies Record; - -function waitInputToOptions(input: Record): WaitCommandOptions { - optionalEnum(input, 'kind', WAIT_KIND_VALUES); - const options = { ...input }; - delete options.kind; - return options as WaitCommandOptions & { kind?: never }; -} - -export const captureCliReaders = { - snapshot: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - interactiveOnly: flags.snapshotInteractiveOnly, - depth: flags.snapshotDepth, - scope: flags.snapshotScope, - raw: flags.snapshotRaw, - forceFull: flags.snapshotForceFull, - timeoutMs: flags.timeoutMs, - }), - screenshot: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - path: positionals[0] ?? flags.out, - ...screenshotOptionsFromFlags(flags), - }), - diff: (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, - }; - }, - wait: (positionals, flags) => readWaitOptionsFromPositionals(positionals, flags), - alert: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - ...readAlertInput(positionals), - }), - settings: settingsCliReaderImpl, -} satisfies Record; - -export const snapshotCliReader = captureCliReaders.snapshot; -export const screenshotCliReader = captureCliReaders.screenshot; -export const diffCliReader = captureCliReaders.diff; -export const waitCliReader = captureCliReaders.wait; -export const alertCliReader = captureCliReaders.alert; -export const settingsCliReader = captureCliReaders.settings; - -export const captureDaemonWriters = { - snapshot: direct(PUBLIC_COMMANDS.snapshot), - screenshot: (input) => - request(PUBLIC_COMMANDS.screenshot, optionalString(input.path), { - ...input, - ...screenshotFlagsFromOptions(input as CaptureScreenshotOptions), - }), - diff: direct(PUBLIC_COMMANDS.diff, (input) => [ - requiredDaemonString(input.kind, 'diff requires kind'), - ]), - wait: direct(PUBLIC_COMMANDS.wait, (input) => waitPositionals(input as WaitCommandOptions)), - alert: direct(PUBLIC_COMMANDS.alert, (input) => alertPositionals(input as AlertCommandOptions)), - settings: settingsDaemonWriterImpl, -} satisfies Record; - -export const screenshotDaemonWriter = captureDaemonWriters.screenshot; -export const waitDaemonWriter = captureDaemonWriters.wait; -export const alertDaemonWriter = captureDaemonWriters.alert; -export const settingsDaemonWriter = captureDaemonWriters.settings; - -function readWaitOptionsFromPositionals( - positionals: string[], - flags: CliFlags, -): WaitCommandOptions { - const parsed = parseWaitPositionals(positionals); - if (!parsed) { - throw new AppError( - 'INVALID_ARGS', - 'wait requires , text , @ref, or [timeoutMs].', - ); - } - const base = { - ...selectionOptionsFromFlags(flags), - ...selectorSnapshotOptionsFromFlags(flags), - }; - if (parsed.kind === 'sleep') return { ...base, durationMs: parsed.durationMs }; - if (parsed.kind === 'text') { - if (!parsed.text) throw new AppError('INVALID_ARGS', 'wait requires text.'); - return { ...base, text: parsed.text, ...readTimeoutOption(parsed.timeoutMs) }; - } - if (parsed.kind === 'ref') { - return { ...base, ref: parsed.rawRef, ...readTimeoutOption(parsed.timeoutMs) }; - } - return { - ...base, - selector: parsed.selectorExpression, - ...readTimeoutOption(parsed.timeoutMs), - }; -} - -// fallow-ignore-next-line complexity -function waitPositionals(options: WaitCommandOptions): string[] { - const targets = [ - options.durationMs !== undefined ? 'durationMs' : undefined, - options.text !== undefined ? 'text' : undefined, - options.ref !== undefined ? 'ref' : undefined, - options.selector !== undefined ? 'selector' : undefined, - ].filter(Boolean); - if (targets.length !== 1) { - throw new AppError( - 'INVALID_ARGS', - 'wait command requires exactly one of durationMs, text, ref, or selector.', - ); - } - if (options.durationMs !== undefined) return [String(options.durationMs)]; - const timeout = optionalNumber(options.timeoutMs); - if (options.text !== undefined) return ['text', options.text, ...timeout]; - if (options.ref !== undefined) return [options.ref, ...timeout]; - const selector = options.selector!; - if (!tryParseSelectorChain(selector)) { - throw new AppError('INVALID_ARGS', `Invalid wait selector: ${selector}`); - } - return [selector, ...timeout]; -} - -function alertPositionals(input: AlertCommandOptions): string[] { - return [input.action ?? 'get', ...optionalNumber(input.timeoutMs)]; -} - -function readAlertInput(positionals: string[]): Record { - 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.'); -} +export const captureCommandFamily = defineCommandFamilyFromFacets({ + name: 'capture', + commands: captureCommandFacets, +}); -function readTimeoutOption(timeoutMs: number | null): { timeoutMs?: number } { - return timeoutMs === null ? {} : { timeoutMs }; -} +export { + alertCliReader, + alertDaemonWriter, + diffCliReader, + screenshotCliReader, + screenshotDaemonWriter, + settingsCliReader, + settingsDaemonWriter, + snapshotCliReader, + waitCliReader, + waitDaemonWriter, +}; diff --git a/src/commands/capture/screenshot.ts b/src/commands/capture/screenshot.ts new file mode 100644 index 000000000..5f9b9aa1c --- /dev/null +++ b/src/commands/capture/screenshot.ts @@ -0,0 +1,65 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { CaptureScreenshotOptions } from '../../client-types.ts'; +import { SESSION_SURFACES } from '../../core/session-surface.ts'; +import { + SCREENSHOT_COMMAND_FLAG_KEYS, + screenshotFlagsFromOptions, + screenshotOptionsFromFlags, +} from '../../contracts/screenshot.ts'; +import { booleanField, enumField, integerField, stringField } from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { commonInputFromFlags, optionalString, request } 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 SCREENSHOT_COMMAND_NAME = 'screenshot'; + +const screenshotCommandDescription = 'Capture a screenshot.'; + +const screenshotCommandMetadata = defineFieldCommandMetadata( + SCREENSHOT_COMMAND_NAME, + screenshotCommandDescription, + { + path: stringField('Output path.'), + overlayRefs: booleanField(), + fullscreen: booleanField(), + maxSize: integerField(), + stabilize: booleanField(), + surface: enumField(SESSION_SURFACES), + }, +); + +const screenshotCommandDefinition = defineExecutableCommand( + screenshotCommandMetadata, + (client, input) => client.capture.screenshot(input), +); + +const screenshotCliSchema = { + helpDescription: + 'Capture screenshot (macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, or --no-stabilize for low-latency Android capture loops)', + summary: 'Capture screenshot with optional desktop, downscale, or ref overlay modes', + positionalArgs: ['path?'], + allowedFlags: SCREENSHOT_COMMAND_FLAG_KEYS, +} as const; + +export const screenshotCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + path: positionals[0] ?? flags.out, + ...screenshotOptionsFromFlags(flags), +}); + +export const screenshotDaemonWriter: DaemonWriter = (input) => + request(PUBLIC_COMMANDS.screenshot, optionalString(input.path), { + ...input, + ...screenshotFlagsFromOptions(input as CaptureScreenshotOptions), + }); + +export const screenshotCommandFacet = defineCommandFacet({ + name: SCREENSHOT_COMMAND_NAME, + metadata: screenshotCommandMetadata, + definition: screenshotCommandDefinition, + cliSchema: screenshotCliSchema, + cliReader: screenshotCliReader, + daemonWriter: screenshotDaemonWriter, +}); diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index 751a5bd44..1c6922092 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -15,12 +15,13 @@ import { setOf, } 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'; -export const SETTINGS_COMMAND_NAME = 'settings'; +const SETTINGS_COMMAND_NAME = 'settings'; const settingsCommandDescription = 'Change OS settings and app permissions.'; -export const settingsCommandMetadata = defineFieldCommandMetadata( +const settingsCommandMetadata = defineFieldCommandMetadata( SETTINGS_COMMAND_NAME, settingsCommandDescription, { @@ -34,12 +35,12 @@ export const settingsCommandMetadata = defineFieldCommandMetadata( }, ); -export const settingsCommandDefinition = defineExecutableCommand( +const settingsCommandDefinition = defineExecutableCommand( settingsCommandMetadata, (client, input) => client.settings.update(input as SettingsUpdateOptions), ); -export const settingsCliSchema = { +const settingsCliSchema = { usageOverride: SETTINGS_USAGE_OVERRIDE, listUsageOverride: 'settings [area] [options]', helpDescription: @@ -55,6 +56,15 @@ export const settingsDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.setting settingsPositionals(input as SettingsUpdateOptions), ); +export const settingsCommandFacet = defineCommandFacet({ + name: SETTINGS_COMMAND_NAME, + metadata: settingsCommandMetadata, + definition: settingsCommandDefinition, + cliSchema: settingsCliSchema, + cliReader: settingsCliReader, + daemonWriter: settingsDaemonWriter, +}); + // fallow-ignore-next-line complexity function readSettingsOptionsFromPositionals( positionals: string[], diff --git a/src/commands/capture/snapshot.ts b/src/commands/capture/snapshot.ts new file mode 100644 index 000000000..a4a779e9f --- /dev/null +++ b/src/commands/capture/snapshot.ts @@ -0,0 +1,62 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { SNAPSHOT_FLAGS } from '../../utils/cli-flags.ts'; +import { booleanField, integerField, stringField } from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { commonInputFromFlags, direct } 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 { captureCliOutputFormatters } from './output.ts'; + +const SNAPSHOT_COMMAND_NAME = 'snapshot'; + +const snapshotCommandDescription = 'Capture an accessibility snapshot.'; + +const snapshotCommandMetadata = defineFieldCommandMetadata( + SNAPSHOT_COMMAND_NAME, + snapshotCommandDescription, + { + interactiveOnly: booleanField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), + forceFull: booleanField(), + timeoutMs: integerField('Maximum wall-clock time for the snapshot command.'), + }, +); + +const snapshotCommandDefinition = defineExecutableCommand( + snapshotCommandMetadata, + (client, input) => client.capture.snapshot(input), +); + +const snapshotCliSchema = { + usageOverride: + 'snapshot [--diff] [-i] [-d ] [-s ] [--raw] [--force-full] [--timeout ]', + helpDescription: + 'Capture accessibility tree or diff against the previous session baseline. For iOS raw-coordinate fallback after a no-op ref press, inspect rects with snapshot -i --json, press the rect center, then verify with diff snapshot -i or snapshot --diff.', + summary: 'Capture accessibility tree or diff against the previous session baseline', + allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull', 'timeoutMs'], +} as const; + +export const snapshotCliReader: CliReader = (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + interactiveOnly: flags.snapshotInteractiveOnly, + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + forceFull: flags.snapshotForceFull, + timeoutMs: flags.timeoutMs, +}); + +const snapshotDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.snapshot); + +export const snapshotCommandFacet = defineCommandFacet({ + name: SNAPSHOT_COMMAND_NAME, + metadata: snapshotCommandMetadata, + definition: snapshotCommandDefinition, + cliSchema: snapshotCliSchema, + cliReader: snapshotCliReader, + daemonWriter: snapshotDaemonWriter, + cliOutputFormatter: captureCliOutputFormatters.snapshot, +}); diff --git a/src/commands/capture/wait.ts b/src/commands/capture/wait.ts new file mode 100644 index 000000000..2b74b714d --- /dev/null +++ b/src/commands/capture/wait.ts @@ -0,0 +1,135 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { WaitCommandOptions } from '../../client-types.ts'; +import { parseWaitPositionals } from '../../core/wait-positionals.ts'; +import { SELECTOR_SNAPSHOT_FLAGS, type CliFlags } from '../../utils/cli-flags.ts'; +import { AppError } from '../../utils/errors.ts'; +import { tryParseSelectorChain } from '../../utils/selectors-parse.ts'; +import { + booleanField, + enumField, + integerField, + optionalEnum, + stringField, +} from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { + direct, + optionalNumber, + selectionOptionsFromFlags, + selectorSnapshotOptionsFromFlags, +} 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 { WAIT_KIND_VALUES } from './wait-command-contract.ts'; + +const WAIT_COMMAND_NAME = 'wait'; + +const waitCommandDescription = 'Wait for duration, text, ref, or selector.'; + +const waitCommandMetadata = defineFieldCommandMetadata(WAIT_COMMAND_NAME, waitCommandDescription, { + kind: enumField(WAIT_KIND_VALUES), + durationMs: integerField(), + text: stringField(), + ref: stringField(), + selector: stringField(), + timeoutMs: integerField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), +}); + +const waitCommandDefinition = defineExecutableCommand(waitCommandMetadata, (client, input) => + client.command.wait(waitInputToOptions(input)), +); + +const waitCliSchema = { + usageOverride: 'wait |text |@ref| [timeoutMs]', + positionalArgs: ['durationOrSelector', 'timeoutMs?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], +} as const; + +export const waitCliReader: CliReader = (positionals, flags) => + readWaitOptionsFromPositionals(positionals, flags); + +export const waitDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.wait, (input) => + waitPositionals(input as WaitCommandOptions), +); + +export const waitCommandFacet = defineCommandFacet({ + name: WAIT_COMMAND_NAME, + metadata: waitCommandMetadata, + definition: waitCommandDefinition, + cliSchema: waitCliSchema, + cliReader: waitCliReader, + daemonWriter: waitDaemonWriter, + cliOutputFormatter: messageOutput, +}); + +function waitInputToOptions(input: Record): WaitCommandOptions { + optionalEnum(input, 'kind', WAIT_KIND_VALUES); + const options = { ...input }; + delete options.kind; + return options as WaitCommandOptions & { kind?: never }; +} + +function readWaitOptionsFromPositionals( + positionals: string[], + flags: CliFlags, +): WaitCommandOptions { + const parsed = parseWaitPositionals(positionals); + if (!parsed) { + throw new AppError( + 'INVALID_ARGS', + 'wait requires , text , @ref, or [timeoutMs].', + ); + } + const base = { + ...selectionOptionsFromFlags(flags), + ...selectorSnapshotOptionsFromFlags(flags), + }; + if (parsed.kind === 'sleep') return { ...base, durationMs: parsed.durationMs }; + if (parsed.kind === 'text') { + if (!parsed.text) throw new AppError('INVALID_ARGS', 'wait requires text.'); + return { ...base, text: parsed.text, ...readTimeoutOption(parsed.timeoutMs) }; + } + if (parsed.kind === 'ref') { + return { ...base, ref: parsed.rawRef, ...readTimeoutOption(parsed.timeoutMs) }; + } + return { + ...base, + selector: parsed.selectorExpression, + ...readTimeoutOption(parsed.timeoutMs), + }; +} + +// fallow-ignore-next-line complexity +function waitPositionals(options: WaitCommandOptions): string[] { + const targets = [ + options.durationMs !== undefined ? 'durationMs' : undefined, + options.text !== undefined ? 'text' : undefined, + options.ref !== undefined ? 'ref' : undefined, + options.selector !== undefined ? 'selector' : undefined, + ].filter(Boolean); + if (targets.length !== 1) { + throw new AppError( + 'INVALID_ARGS', + 'wait command requires exactly one of durationMs, text, ref, or selector.', + ); + } + if (options.durationMs !== undefined) return [String(options.durationMs)]; + const timeout = optionalNumber(options.timeoutMs); + if (options.text !== undefined) return ['text', options.text, ...timeout]; + if (options.ref !== undefined) return [options.ref, ...timeout]; + const selector = options.selector!; + if (!tryParseSelectorChain(selector)) { + throw new AppError('INVALID_ARGS', `Invalid wait selector: ${selector}`); + } + return [selector, ...timeout]; +} + +function readTimeoutOption(timeoutMs: number | null): { timeoutMs?: number } { + return timeoutMs === null ? {} : { timeoutMs }; +} diff --git a/src/commands/cli-grammar/registry.ts b/src/commands/cli-grammar/registry.ts index 7ff476ef2..4ef8df70e 100644 --- a/src/commands/cli-grammar/registry.ts +++ b/src/commands/cli-grammar/registry.ts @@ -1,44 +1,17 @@ import type { CliFlags } from '../../utils/cli-flags.ts'; -import { batchCliReaders } from '../batch/index.ts'; -import { captureCliReaders } from '../capture/index.ts'; -import { debuggingCliReaders } from '../debugging/index.ts'; -import type { CliReader } from './types.ts'; import type { CommandName } from '../command-metadata.ts'; -import { - gestureCliReaders, - interactionCliReaders, - selectorCliReaders as interactionSelectorCliReaders, -} from '../interaction/index.ts'; -import { appCliReaders } from '../management/index.ts'; -import { metroCliReaders } from '../metro/index.ts'; -import { observabilityCliReaders } from '../observability/index.ts'; -import { perfCliReaders } from '../perf/index.ts'; -import { reactNativeCliReaders } from '../react-native/index.ts'; -import { recordingCliReaders } from '../recording/index.ts'; -import { replayCliReaders } from '../replay/index.ts'; -import { systemCliReaders } from '../system/index.ts'; +import { listCommandFamilyCliReaders } from '../family/registry.ts'; -const cliReaders = { - ...appCliReaders, - ...captureCliReaders, - ...interactionCliReaders, - ...gestureCliReaders, - ...interactionSelectorCliReaders, - ...observabilityCliReaders, - ...perfCliReaders, - ...debuggingCliReaders, - ...reactNativeCliReaders, - ...recordingCliReaders, - ...replayCliReaders, - ...systemCliReaders, - ...metroCliReaders, - ...batchCliReaders, -} satisfies Record; +const cliReaders = listCommandFamilyCliReaders(); export function readInputFromCli( command: CommandName, positionals: string[], flags: CliFlags, ): Record { - return cliReaders[command](positionals, flags); + const reader = cliReaders[command]; + if (!reader) { + throw new Error(`Missing CLI reader for command: ${command}`); + } + return reader(positionals, flags); } diff --git a/src/commands/cli-output.ts b/src/commands/cli-output.ts index a4aa58a31..d10f8e047 100644 --- a/src/commands/cli-output.ts +++ b/src/commands/cli-output.ts @@ -1,29 +1,11 @@ -import { batchCliOutputFormatters } from './batch/output.ts'; -import { captureCliOutputFormatters } from './capture/output.ts'; +import { listCommandFamilyCliOutputFormatters } from './family/registry.ts'; import type { CliOutput } from './command-contract.ts'; -import { debuggingCliOutputFormatters } from './debugging/output.ts'; -import { interactionCliOutputFormatters } from './interaction/output.ts'; -import { managementCliOutputFormatters } from './management/output.ts'; -import { metroCliOutputFormatters } from './metro/output.ts'; -import { observabilityCliOutputFormatters } from './observability/output.ts'; -import { perfCliOutputFormatters } from './perf/output.ts'; import type { CliOutputFormatter } from './output-common.ts'; -import { recordingCliOutputFormatters } from './recording/output.ts'; -import { systemCliOutputFormatters } from './system/output.ts'; import type { CommandName } from './command-metadata.ts'; -const cliOutputFormatters: Partial> = { - ...managementCliOutputFormatters, - ...captureCliOutputFormatters, - ...systemCliOutputFormatters, - ...interactionCliOutputFormatters, - ...observabilityCliOutputFormatters, - ...perfCliOutputFormatters, - ...debuggingCliOutputFormatters, - ...batchCliOutputFormatters, - ...recordingCliOutputFormatters, - ...metroCliOutputFormatters, -}; +const cliOutputFormatters = listCommandFamilyCliOutputFormatters() as Partial< + Record +>; export function formatCliOutput(params: { name: CommandName; diff --git a/src/commands/client-command-facets.ts b/src/commands/client-command-facets.ts deleted file mode 100644 index 6dcd05b93..000000000 --- a/src/commands/client-command-facets.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { debuggingCommandDefinitions, debuggingCommandMetadata } from './debugging/index.ts'; -import { captureCommandDefinitions, captureCommandMetadata } from './capture/index.ts'; -import { managementCommandDefinitions, managementCommandMetadata } from './management/index.ts'; -import { metroCommandDefinition, metroCommandMetadata } from './metro/index.ts'; -import { - observabilityCommandDefinitions, - observabilityCommandMetadata, -} from './observability/index.ts'; -import { perfCommandDefinitions, perfCommandMetadataList } from './perf/index.ts'; -import { reactNativeCommandDefinition, reactNativeCommandMetadata } from './react-native/index.ts'; -import { recordingCommandDefinitions, recordingCommandMetadata } from './recording/index.ts'; -import { replayCommandDefinitions, replayCommandMetadataList } from './replay/index.ts'; -import { systemCommandDefinitions, systemCommandMetadata } from './system/index.ts'; - -const clientCommandFamilyFacets = [ - { - metadata: managementCommandMetadata, - definitions: managementCommandDefinitions, - }, - { - metadata: captureCommandMetadata, - definitions: captureCommandDefinitions, - }, - { - metadata: systemCommandMetadata, - definitions: systemCommandDefinitions, - }, - { - metadata: [reactNativeCommandMetadata], - definitions: [reactNativeCommandDefinition], - }, - { - metadata: replayCommandMetadataList, - definitions: replayCommandDefinitions, - }, - { - metadata: observabilityCommandMetadata, - definitions: observabilityCommandDefinitions, - }, - { - metadata: perfCommandMetadataList, - definitions: perfCommandDefinitions, - }, - { - metadata: debuggingCommandMetadata, - definitions: debuggingCommandDefinitions, - }, - { - metadata: recordingCommandMetadata, - definitions: recordingCommandDefinitions, - }, - { - metadata: [metroCommandMetadata], - definitions: [metroCommandDefinition], - }, -] as const; - -export const clientCommandMetadata = readClientCommandMetadata(clientCommandFamilyFacets); - -export const clientCommandDefinitions = readClientCommandDefinitions(clientCommandFamilyFacets); - -function readClientCommandMetadata< - const TFacets extends readonly { metadata: readonly unknown[] }[], ->(facets: TFacets): Array { - return facets.flatMap((family) => [...family.metadata]) as Array< - TFacets[number]['metadata'][number] - >; -} - -function readClientCommandDefinitions< - const TFacets extends readonly { definitions: readonly unknown[] }[], ->(facets: TFacets): Array { - return facets.flatMap((family) => [...family.definitions]) as Array< - TFacets[number]['definitions'][number] - >; -} diff --git a/src/commands/command-metadata.ts b/src/commands/command-metadata.ts index 4b7bc14d5..94dc676d3 100644 --- a/src/commands/command-metadata.ts +++ b/src/commands/command-metadata.ts @@ -1,21 +1,15 @@ import { listMcpExposedCommandNames } from '../command-catalog.ts'; -import { batchCommandMetadata } from './batch/index.ts'; -import { clientCommandMetadata } from './client-command-facets.ts'; import type { CommandMetadata } from './command-contract.ts'; -import { interactionCommandMetadata } from './interaction/index.ts'; +import { listCommandFamilyMetadata, type CommandFamilyCommandName } from './family/registry.ts'; -const commandMetadata = [ - ...interactionCommandMetadata, - ...clientCommandMetadata, - batchCommandMetadata, -] as const; - -export type CommandName = (typeof commandMetadata)[number]['name']; +export type CommandName = CommandFamilyCommandName; type AnyCommandMetadata = CommandMetadata; +const commandMetadata = listCommandFamilyMetadata(); + const commandMetadataMap: ReadonlyMap = new Map( - commandMetadata.map((definition) => [definition.name, definition as AnyCommandMetadata]), + commandMetadata.map((definition) => [definition.name, definition]), ); export function listCommandMetadata(): AnyCommandMetadata[] { diff --git a/src/commands/command-projection.ts b/src/commands/command-projection.ts index 83f48ff89..803d30296 100644 --- a/src/commands/command-projection.ts +++ b/src/commands/command-projection.ts @@ -1,35 +1,13 @@ import { createBatchDaemonWriter, type BatchCommandName } from './batch/index.ts'; -import { captureDaemonWriters } from './capture/index.ts'; import type { CommandInput, DaemonCommandRequest, DaemonWriter } from './cli-grammar/types.ts'; -import { - gestureDaemonWriters, - interactionDaemonWriters, - selectorDaemonWriters, -} from './interaction/index.ts'; -import { appDaemonWriters } from './management/index.ts'; -import { observabilityDaemonWriters } from './observability/index.ts'; -import { perfDaemonWriters } from './perf/index.ts'; -import { reactNativeDaemonWriters } from './react-native/index.ts'; -import { recordingDaemonWriters } from './recording/index.ts'; -import { replayDaemonWriters } from './replay/index.ts'; -import { systemDaemonWriters } from './system/index.ts'; import { findCommandMetadata } from './command-metadata.ts'; +import { listCommandFamilyDaemonWriters } from './family/registry.ts'; import { AppError } from '../utils/errors.ts'; -const daemonWriters = { - ...appDaemonWriters, - ...captureDaemonWriters, - ...interactionDaemonWriters, - ...gestureDaemonWriters, - ...selectorDaemonWriters, - ...observabilityDaemonWriters, - ...perfDaemonWriters, - ...reactNativeDaemonWriters, - ...recordingDaemonWriters, - ...replayDaemonWriters, - ...systemDaemonWriters, +const daemonWriters: Record = { + ...listCommandFamilyDaemonWriters(), batch: createBatchDaemonWriter(prepareBatchDaemonCommandRequest), -} satisfies Record; +}; export type DaemonCommandName = keyof typeof daemonWriters; @@ -65,5 +43,9 @@ export function prepareDaemonCommandRequest( command: DaemonCommandName, input: CommandInput, ): DaemonCommandRequest { - return daemonWriters[command](input); + const writer = daemonWriters[command]; + if (!writer) { + throw new Error(`Missing daemon writer for command: ${command}`); + } + return writer(input); } diff --git a/src/commands/command-surface.ts b/src/commands/command-surface.ts index 6e2d8f8f9..d3db68ab6 100644 --- a/src/commands/command-surface.ts +++ b/src/commands/command-surface.ts @@ -1,27 +1,13 @@ import type { AgentDeviceClient } from '../client-types.ts'; -import { batchCommandDefinition } from './batch/index.ts'; -import { clientCommandDefinitions } from './client-command-facets.ts'; -import type { JsonSchema } from './command-contract.ts'; -import { interactionCommandDefinitions } from './interaction/index.ts'; +import { listCommandFamilyDefinitions, type CommandFamilyDefinition } from './family/registry.ts'; import type { BatchCommandName } from './command-projection.ts'; import type { CommandName } from './command-metadata.ts'; -type AnyExecutableCommand = { - name: string; - description: string; - inputSchema: JsonSchema; - invoke: (client: AgentDeviceClient, input: unknown) => Promise; -}; - -const commandSurface = [ - ...interactionCommandDefinitions, - ...clientCommandDefinitions, - batchCommandDefinition, -] as const; +const commandSurface = listCommandFamilyDefinitions(); export type { BatchCommandName, CommandName }; -const commandMap: ReadonlyMap = new Map( +const commandMap: ReadonlyMap = new Map( commandSurface.map((definition) => [definition.name, definition]), ); @@ -37,6 +23,6 @@ export function listExecutableCommandNames(): CommandName[] { return [...commandMap.keys()].sort(); } -function getCommandDefinition(name: CommandName): AnyExecutableCommand { +function getCommandDefinition(name: CommandName): CommandFamilyDefinition { return commandMap.get(name)!; } diff --git a/src/commands/debugging/index.ts b/src/commands/debugging/index.ts index c0eee4fa7..9000c1b8f 100644 --- a/src/commands/debugging/index.ts +++ b/src/commands/debugging/index.ts @@ -1,10 +1,12 @@ 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 { defineExecutableCommand } from '../command-contract.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { commonInputFromFlags } from '../cli-grammar/common.ts'; import type { CliReader } from '../cli-grammar/types.ts'; +import { debuggingCliOutputFormatters } from './output.ts'; const DEBUG_COMMAND_NAME = 'debug'; const DEBUG_ACTION_VALUES = ['symbols'] as const; @@ -23,14 +25,14 @@ export const debugCommandMetadata = defineFieldCommandMetadata( }, ); -export const debuggingCommandMetadata = [debugCommandMetadata] as const; +const debuggingCommandMetadata = [debugCommandMetadata] as const; export const debugCommandDefinition = defineExecutableCommand( debugCommandMetadata, (client, input) => client.debug.symbols(input), ); -export const debuggingCommandDefinitions = [debugCommandDefinition] as const; +const debuggingCommandDefinitions = [debugCommandDefinition] as const; const debugCliSchema = { usageOverride: @@ -44,7 +46,7 @@ const debugCliSchema = { allowedFlags: ['artifact', 'dsym', 'searchPath', 'out'], } as const satisfies CommandSchemaOverride; -export const debuggingCliSchemas = { +const debuggingCliSchemas = { [DEBUG_COMMAND_NAME]: debugCliSchema, } as const satisfies Record; @@ -57,10 +59,19 @@ export const debugCliReader: CliReader = (positionals, flags) => ({ out: flags.out, }); -export const debuggingCliReaders = { +const debuggingCliReaders = { debug: debugCliReader, } satisfies Record; +export const debuggingCommandFamily = defineCommandFamily({ + name: 'debugging', + metadata: debuggingCommandMetadata, + definitions: debuggingCommandDefinitions, + cliSchemas: debuggingCliSchemas, + cliReaders: debuggingCliReaders, + cliOutputFormatters: debuggingCliOutputFormatters, +}); + function readDebugAction(value: string | undefined): 'symbols' { if (value === 'symbols') return value; throw new AppError( diff --git a/src/commands/family/registry.ts b/src/commands/family/registry.ts new file mode 100644 index 000000000..50530bb26 --- /dev/null +++ b/src/commands/family/registry.ts @@ -0,0 +1,85 @@ +import { batchCommandFamily } from '../batch/index.ts'; +import { captureCommandFamily } from '../capture/index.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { debuggingCommandFamily } from '../debugging/index.ts'; +import { interactionCommandFamily } from '../interaction/index.ts'; +import { managementCommandFamily } from '../management/index.ts'; +import { metroCommandFamily } from '../metro/index.ts'; +import { observabilityCommandFamily } from '../observability/index.ts'; +import type { CliOutputFormatter } from '../output-common.ts'; +import { perfCommandFamily } from '../perf/index.ts'; +import { reactNativeCommandFamily } from '../react-native/index.ts'; +import { recordingCommandFamily } from '../recording/index.ts'; +import { replayCommandFamily } from '../replay/index.ts'; +import { systemCommandFamily } from '../system/index.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { type CommandFamilyFacet } from './types.ts'; + +type CommandFamilyRecordMap = { + cliSchemas: CommandSchemaOverride; + cliReaders: CliReader; + daemonWriters: DaemonWriter; + cliOutputFormatters: CliOutputFormatter; +}; + +export const commandFamilies = [ + interactionCommandFamily, + managementCommandFamily, + captureCommandFamily, + systemCommandFamily, + reactNativeCommandFamily, + replayCommandFamily, + observabilityCommandFamily, + perfCommandFamily, + debuggingCommandFamily, + recordingCommandFamily, + metroCommandFamily, + batchCommandFamily, +] as const satisfies readonly CommandFamilyFacet[]; + +export type CommandFamilyCommandName = (typeof commandFamilies)[number]['metadata'][number]['name']; +export type CommandFamilyMetadata = (typeof commandFamilies)[number]['metadata'][number]; +export type CommandFamilyDefinition = (typeof commandFamilies)[number]['definitions'][number]; + +export function listCommandFamilyMetadata(): CommandFamilyMetadata[] { + return commandFamilies.flatMap((family) => [...family.metadata]); +} + +export function listCommandFamilyDefinitions(): CommandFamilyDefinition[] { + return commandFamilies.flatMap((family) => [...family.definitions]); +} + +export function listCommandFamilyCliSchemas(): Record { + return mergeFamilyRecords('cliSchemas'); +} + +export function listCommandFamilyCliReaders(): Record { + return mergeFamilyRecords('cliReaders') as Record; +} + +export function listCommandFamilyDaemonWriters(): Record { + return mergeFamilyRecords('daemonWriters'); +} + +export function listCommandFamilyCliOutputFormatters(): Record { + return mergeFamilyRecords('cliOutputFormatters'); +} + +function mergeFamilyRecords( + key: TKey, +): Record { + const records: Record = {}; + for (const family of commandFamilies) { + const record = (family as CommandFamilyFacet)[key] as + | Readonly> + | undefined; + if (!record) continue; + for (const [command, value] of Object.entries(record)) { + if (Object.hasOwn(records, command)) { + throw new Error(`Duplicate ${String(key)} command family entry: ${command}`); + } + records[command] = value; + } + } + return records; +} diff --git a/src/commands/family/types.ts b/src/commands/family/types.ts new file mode 100644 index 000000000..992e59e9c --- /dev/null +++ b/src/commands/family/types.ts @@ -0,0 +1,98 @@ +import type { AgentDeviceClient } from '../../client-types.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import type { CommandMetadata, JsonSchema } from '../command-contract.ts'; +import type { CliOutputFormatter } from '../output-common.ts'; + +export type AnyCommandMetadata = CommandMetadata; + +export type AnyCommandDefinition = { + name: Name; + description: string; + inputSchema: JsonSchema; + invoke: (client: AgentDeviceClient, input: unknown) => Promise; +}; + +export type CommandFamilyFacet = { + name: string; + clientSurface?: boolean; + metadata: readonly AnyCommandMetadata[]; + definitions: readonly AnyCommandDefinition[]; + cliSchemas?: Readonly>>; + cliReaders: Readonly>; + daemonWriters?: Readonly>; + cliOutputFormatters?: Readonly>>; +}; + +export type CommandFacet = { + name: TCommandName; + metadata: AnyCommandMetadata; + definition: AnyCommandDefinition; + cliSchema?: CommandSchemaOverride; + cliReader: CliReader; + daemonWriter?: DaemonWriter; + cliOutputFormatter?: CliOutputFormatter; +}; + +type CommandFacetMetadata = { + readonly [K in keyof TCommands]: TCommands[K]['metadata']; +}; + +type CommandFacetDefinitions = { + readonly [K in keyof TCommands]: TCommands[K]['definition']; +}; + +type CommandFacetName = TCommands[number]['name']; + +type CommandFamilyMetadataName = + TMetadata[number]['name']; + +export function defineCommandFamily< + const TMetadata extends readonly AnyCommandMetadata[], + const TDefinitions extends readonly AnyCommandDefinition>[], + const TFamily extends CommandFamilyFacet> & { + metadata: TMetadata; + definitions: TDefinitions; + }, +>(family: TFamily): TFamily { + return family; +} + +export function defineCommandFacet< + const TCommandName extends string, + const TCommand extends CommandFacet, +>(command: TCommand): TCommand { + return command; +} + +export function defineCommandFamilyFromFacets< + const TFamilyName extends string, + const TCommands extends readonly CommandFacet[], +>(family: { name: TFamilyName; clientSurface?: boolean; commands: TCommands }) { + const cliSchemas: Record = {}; + const cliReaders: Record = {}; + const daemonWriters: Record = {}; + const cliOutputFormatters: Record = {}; + + 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.cliOutputFormatter) { + cliOutputFormatters[command.name] = command.cliOutputFormatter; + } + } + + return defineCommandFamily({ + name: family.name, + clientSurface: family.clientSurface, + metadata: family.commands.map((command) => command.metadata) as CommandFacetMetadata, + definitions: family.commands.map( + (command) => command.definition, + ) as CommandFacetDefinitions, + cliSchemas, + cliReaders: cliReaders as Record, CliReader>, + daemonWriters, + cliOutputFormatters, + }); +} diff --git a/src/commands/interaction/index.ts b/src/commands/interaction/index.ts index 69ee0e1cb..f3d0682c4 100644 --- a/src/commands/interaction/index.ts +++ b/src/commands/interaction/index.ts @@ -19,6 +19,7 @@ import type { } from '../../client-types.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; import { REPEATED_TOUCH_FLAGS, SELECTOR_SNAPSHOT_FLAGS } from '../../utils/cli-flags.ts'; +import { defineCommandFamily } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { commonToClientOptions, @@ -41,13 +42,12 @@ import { type SwipeGestureInput, type TransformInput, } from './metadata.ts'; +import { gestureCliReaders, gestureDaemonWriters } from './gesture.ts'; +import { interactionCliReaders, interactionDaemonWriters } from './interactions.ts'; +import { interactionCliOutputFormatters } from './output.ts'; +import { selectorCliReaders, selectorDaemonWriters } from './selectors.ts'; -export { gestureCliReaders, gestureDaemonWriters } from './gesture.ts'; -export { interactionCliReaders, interactionDaemonWriters } from './interactions.ts'; -export { interactionCommandMetadata } from './metadata.ts'; -export { selectorCliReaders, selectorDaemonWriters } from './selectors.ts'; - -export const interactionCliSchemas = { +const interactionCliSchemas = { get: { usageOverride: 'get text|attrs <@ref|selector>', positionalArgs: ['subcommand', 'target'], @@ -128,7 +128,7 @@ export const interactionCliSchemas = { type InteractionCommandMetadata = (typeof interactionCommandMetadata)[number]; type InteractionCommandName = InteractionCommandMetadata['name']; -export const interactionCommandDefinitions = [ +const interactionCommandDefinitions = [ defineExecutableCommand(metadata('click'), (client, input) => client.interactions.click(toClickOptions(input)), ), @@ -180,6 +180,25 @@ export const interactionCommandDefinitions = [ }), ] as const; +export const interactionCommandFamily = defineCommandFamily({ + name: 'interaction', + clientSurface: false, + metadata: interactionCommandMetadata, + definitions: interactionCommandDefinitions, + cliSchemas: interactionCliSchemas, + cliReaders: { + ...interactionCliReaders, + ...gestureCliReaders, + ...selectorCliReaders, + }, + daemonWriters: { + ...interactionDaemonWriters, + ...gestureDaemonWriters, + ...selectorDaemonWriters, + }, + cliOutputFormatters: interactionCliOutputFormatters, +}); + function metadata( name: TName, ): Extract { diff --git a/src/commands/management/index.ts b/src/commands/management/index.ts index 6be0fea9c..846c2d146 100644 --- a/src/commands/management/index.ts +++ b/src/commands/management/index.ts @@ -11,6 +11,7 @@ import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types import { AppError } from '../../utils/errors.ts'; import { parseGitHubActionsArtifactInstallSourceSpec } from '../../utils/install-source-config.ts'; import { assertResolvedAppsFilter } from './app-inventory-contract.ts'; +import { defineCommandFamily } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { booleanField, @@ -37,6 +38,7 @@ import { import type { CliReader, DaemonWriter, CommandInput } from '../cli-grammar/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { DEFAULT_APPS_FILTER } from '../../contracts/app-inventory.ts'; +import { managementCliOutputFormatters } from './output.ts'; const PREPARE_ACTION_VALUES = ['ios-runner'] as const; @@ -56,7 +58,7 @@ const managementCommandDescriptions = { 'trigger-app-event': 'Trigger an app-defined event.', } as const; -export const managementCommandMetadata = [ +const managementCommandMetadata = [ defineFieldCommandMetadata('devices', managementCommandDescriptions.devices, {}), defineFieldCommandMetadata('boot', managementCommandDescriptions.boot, { headless: booleanField('Boot without showing simulator UI when supported.'), @@ -134,7 +136,7 @@ export const managementCommandMetadata = [ type ManagementCommandMetadata = (typeof managementCommandMetadata)[number]; type ManagementCommandName = ManagementCommandMetadata['name']; -export const managementCommandDefinitions = [ +const managementCommandDefinitions = [ defineExecutableCommand(metadata('devices'), (client, input) => client.devices.list(input)), defineExecutableCommand(metadata('boot'), (client, input) => client.devices.boot(input)), defineExecutableCommand(metadata('shutdown'), (client, input) => client.devices.shutdown(input)), @@ -160,7 +162,7 @@ export const managementCommandDefinitions = [ defineExecutableCommand(metadata('prepare'), (client, input) => client.command.prepare(input)), ] as const; -export const managementCliSchemas = { +const managementCliSchemas = { boot: { summary: 'Boot target device/simulator', allowedFlags: ['headless'], @@ -261,7 +263,7 @@ function withoutApp(input: AppCloseOptions & { shutdown?: boolean }): { shutdown return rest; } -export const appCliReaders = { +const appCliReaders = { devices: (_positionals, flags) => commonInputFromFlags(flags), apps: (_positionals, flags) => ({ ...commonInputFromFlags(flags), @@ -322,7 +324,7 @@ export const appCliReaders = { }), } satisfies Record; -export const appDaemonWriters = { +const appDaemonWriters = { devices: direct(PUBLIC_COMMANDS.devices), boot: direct(PUBLIC_COMMANDS.boot), shutdown: direct(PUBLIC_COMMANDS.shutdown), @@ -347,6 +349,16 @@ export const appDaemonWriters = { ), } satisfies Record; +export const managementCommandFamily = defineCommandFamily({ + name: 'management', + metadata: managementCommandMetadata, + definitions: managementCommandDefinitions, + cliSchemas: managementCliSchemas, + cliReaders: appCliReaders, + daemonWriters: appDaemonWriters, + cliOutputFormatters: managementCliOutputFormatters, +}); + function installInputFromCli( positionals: string[], flags: CliFlags, diff --git a/src/commands/metro/index.ts b/src/commands/metro/index.ts index e8a08e7b0..aac8fab47 100644 --- a/src/commands/metro/index.ts +++ b/src/commands/metro/index.ts @@ -16,10 +16,12 @@ import { stringField, stringSchema, } from '../command-input.ts'; +import { defineCommandFamily } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import type { CliReader } from '../cli-grammar/types.ts'; import { METRO_PREPARE_FLAGS, METRO_RELOAD_FLAGS } from '../../utils/cli-flags.ts'; +import { metroCliOutputFormatters } from './output.ts'; const METRO_COMMAND_NAME = 'metro'; const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; @@ -78,7 +80,7 @@ const metroCliSchema = { allowedFlags: [...METRO_RELOAD_FLAGS, ...METRO_PREPARE_FLAGS], } as const satisfies CommandSchemaOverride; -export const metroCliSchemas = { +const metroCliSchemas = { [METRO_COMMAND_NAME]: metroCliSchema, } as const satisfies Record; @@ -128,10 +130,19 @@ export const metroCliReader: CliReader = (positionals, flags) => { }; }; -export const metroCliReaders = { +const metroCliReaders = { metro: metroCliReader, } satisfies Record; +export const metroCommandFamily = defineCommandFamily({ + name: 'metro', + metadata: [metroCommandMetadata], + definitions: [metroCommandDefinition], + cliSchemas: metroCliSchemas, + cliReaders: metroCliReaders, + cliOutputFormatters: metroCliOutputFormatters, +}); + function toMetroPrepareOptions(input: MetroInput): MetroPrepareOptions { return { projectRoot: input.projectRoot, diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index dd543f219..60b647ce2 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -3,6 +3,7 @@ import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../contracts. import { AppError } from '../../utils/errors.ts'; import { parseStringMember } from '../../utils/string-enum.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineCommandFamily } from '../family/types.ts'; import { booleanField, enumField, integerField, stringField } from '../command-input.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; @@ -16,6 +17,7 @@ import { request, } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { observabilityCliOutputFormatters } from './output.ts'; const LOGS_COMMAND_NAME = 'logs'; const NETWORK_COMMAND_NAME = 'network'; @@ -44,7 +46,7 @@ export const networkCommandMetadata = defineFieldCommandMetadata( }, ); -export const observabilityCommandMetadata = [logsCommandMetadata, networkCommandMetadata] as const; +const observabilityCommandMetadata = [logsCommandMetadata, networkCommandMetadata] as const; export const logsCommandDefinition = defineExecutableCommand(logsCommandMetadata, (client, input) => client.observability.logs(input), @@ -55,10 +57,7 @@ export const networkCommandDefinition = defineExecutableCommand( (client, input) => client.observability.network(input), ); -export const observabilityCommandDefinitions = [ - logsCommandDefinition, - networkCommandDefinition, -] as const; +const observabilityCommandDefinitions = [logsCommandDefinition, networkCommandDefinition] as const; const logsCliSchema = { usageOverride: @@ -81,7 +80,7 @@ const networkCliSchema = { allowedFlags: ['networkInclude'], } as const satisfies CommandSchemaOverride; -export const observabilityCliSchemas = { +const observabilityCliSchemas = { [LOGS_COMMAND_NAME]: logsCliSchema, [NETWORK_COMMAND_NAME]: networkCliSchema, } as const satisfies Record; @@ -100,7 +99,7 @@ export const networkCliReader: CliReader = (positionals, flags) => ({ include: flags.networkInclude ?? readNetworkInclude(positionals[2]), }); -export const observabilityCliReaders = { +const observabilityCliReaders = { logs: logsCliReader, network: networkCliReader, } satisfies Record; @@ -115,11 +114,21 @@ export const networkDaemonWriter: DaemonWriter = (input) => networkInclude: input.include, }); -export const observabilityDaemonWriters = { +const observabilityDaemonWriters = { logs: logsDaemonWriter, network: networkDaemonWriter, } satisfies Record; +export const observabilityCommandFamily = defineCommandFamily({ + name: 'observability', + metadata: observabilityCommandMetadata, + definitions: observabilityCommandDefinitions, + cliSchemas: observabilityCliSchemas, + cliReaders: observabilityCliReaders, + daemonWriters: observabilityDaemonWriters, + cliOutputFormatters: observabilityCliOutputFormatters, +}); + function logsPositionals(input: { action?: string; message?: string }): string[] { return [input.action ?? 'path', ...optionalString(input.message)]; } diff --git a/src/commands/perf/index.ts b/src/commands/perf/index.ts index ca68370b8..ccf17d8db 100644 --- a/src/commands/perf/index.ts +++ b/src/commands/perf/index.ts @@ -2,6 +2,7 @@ import type { PerfOptions } from '../../client-types.ts'; import { AppError } from '../../utils/errors.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; import { enumField, stringField } from '../command-input.ts'; +import { defineCommandFamily } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { @@ -24,6 +25,7 @@ import { } from './perf-command-contract.ts'; import { commonInputFromFlags, direct, optionalString } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { perfCliOutputFormatters } from './output.ts'; const PERF_COMMAND_NAME = 'perf'; @@ -43,13 +45,13 @@ export const perfCommandMetadata = defineFieldCommandMetadata( }, ); -export const perfCommandMetadataList = [perfCommandMetadata] as const; +const perfCommandMetadataList = [perfCommandMetadata] as const; export const perfCommandDefinition = defineExecutableCommand(perfCommandMetadata, (client, input) => client.observability.perf(input), ); -export const perfCommandDefinitions = [perfCommandDefinition] as const; +const perfCommandDefinitions = [perfCommandDefinition] as const; const perfCliSchema = { usageOverride: @@ -62,7 +64,7 @@ const perfCliSchema = { allowedFlags: ['kind', 'perfTemplate', 'out'], } as const satisfies CommandSchemaOverride; -export const perfCliSchemas = { +const perfCliSchemas = { [PERF_COMMAND_NAME]: perfCliSchema, } as const satisfies Record; @@ -75,7 +77,7 @@ export const perfCliReader: CliReader = (positionals, flags) => ({ }), }); -export const perfCliReaders = { +const perfCliReaders = { perf: perfCliReader, } satisfies Record; @@ -83,10 +85,20 @@ export const perfDaemonWriter: DaemonWriter = direct(PERF_COMMAND_NAME, (input) perfPositionals(input as PerfOptions), ); -export const perfDaemonWriters = { +const perfDaemonWriters = { perf: perfDaemonWriter, } satisfies Record; +export const perfCommandFamily = defineCommandFamily({ + name: 'perf', + metadata: perfCommandMetadataList, + definitions: perfCommandDefinitions, + cliSchemas: perfCliSchemas, + cliReaders: perfCliReaders, + daemonWriters: perfDaemonWriters, + cliOutputFormatters: perfCliOutputFormatters, +}); + function perfPositionals(input: PerfOptions): string[] { const area = input.area ?? (input.action ? 'metrics' : undefined); if (area === 'cpu') { diff --git a/src/commands/react-native/index.ts b/src/commands/react-native/index.ts index b2b7c40e8..19b036a8d 100644 --- a/src/commands/react-native/index.ts +++ b/src/commands/react-native/index.ts @@ -1,5 +1,6 @@ import { AppError } from '../../utils/errors.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineCommandFamily } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { enumField, requiredField } from '../command-input.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; @@ -30,7 +31,7 @@ const reactNativeCliSchema = { positionalArgs: ['dismiss-overlay'], } as const satisfies CommandSchemaOverride; -export const reactNativeCliSchemas = { +const reactNativeCliSchemas = { [REACT_NATIVE_COMMAND_NAME]: reactNativeCliSchema, } as const satisfies Record; @@ -39,7 +40,7 @@ export const reactNativeCliReader: CliReader = (positionals, flags) => ({ action: readReactNativeAction(positionals[0]), }); -export const reactNativeCliReaders = { +const reactNativeCliReaders = { 'react-native': reactNativeCliReader, } satisfies Record; @@ -47,10 +48,19 @@ export const reactNativeDaemonWriter: DaemonWriter = direct(REACT_NATIVE_COMMAND requiredDaemonString(input.action, 'react-native requires action'), ]); -export const reactNativeDaemonWriters = { +const reactNativeDaemonWriters = { 'react-native': reactNativeDaemonWriter, } satisfies Record; +export const reactNativeCommandFamily = defineCommandFamily({ + name: 'react-native', + metadata: [reactNativeCommandMetadata], + definitions: [reactNativeCommandDefinition], + cliSchemas: reactNativeCliSchemas, + cliReaders: reactNativeCliReaders, + daemonWriters: reactNativeDaemonWriters, +}); + function readReactNativeAction(value: string | undefined): 'dismiss-overlay' { if (value === 'dismiss-overlay') return value; throw new AppError('INVALID_ARGS', 'react-native supports only: dismiss-overlay'); diff --git a/src/commands/recording/index.ts b/src/commands/recording/index.ts index 98a9792e7..0f4196fd3 100644 --- a/src/commands/recording/index.ts +++ b/src/commands/recording/index.ts @@ -2,6 +2,7 @@ import type { RecordOptions } from '../../client-types.ts'; import { RECORDING_EXPORT_QUALITIES } from '../../core/recording-export-quality.ts'; import { AppError } from '../../utils/errors.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineCommandFamily } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { booleanField, @@ -13,6 +14,7 @@ import { import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { commonInputFromFlags, direct, optionalString } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { recordingCliOutputFormatters } from './output.ts'; const RECORD_COMMAND_NAME = 'record'; const TRACE_COMMAND_NAME = 'trace'; @@ -43,7 +45,7 @@ export const traceCommandMetadata = defineFieldCommandMetadata( }, ); -export const recordingCommandMetadata = [recordCommandMetadata, traceCommandMetadata] as const; +const recordingCommandMetadata = [recordCommandMetadata, traceCommandMetadata] as const; export const recordCommandDefinition = defineExecutableCommand( recordCommandMetadata, @@ -55,10 +57,7 @@ export const traceCommandDefinition = defineExecutableCommand( (client, input) => client.recording.trace(input), ); -export const recordingCommandDefinitions = [ - recordCommandDefinition, - traceCommandDefinition, -] as const; +const recordingCommandDefinitions = [recordCommandDefinition, traceCommandDefinition] as const; const recordCliSchema = { usageOverride: @@ -80,7 +79,7 @@ const traceCliSchema = { positionalArgs: ['start|stop', 'path?'], } as const satisfies CommandSchemaOverride; -export const recordingCliSchemas = { +const recordingCliSchemas = { [RECORD_COMMAND_NAME]: recordCliSchema, [TRACE_COMMAND_NAME]: traceCliSchema, } as const satisfies Record; @@ -101,7 +100,7 @@ export const traceCliReader: CliReader = (positionals, flags) => ({ path: positionals[1], }); -export const recordingCliReaders = { +const recordingCliReaders = { record: recordCliReader, trace: traceCliReader, } satisfies Record; @@ -114,11 +113,21 @@ export const traceDaemonWriter: DaemonWriter = direct(TRACE_COMMAND_NAME, (input recordingPositionals(input as RecordOptions), ); -export const recordingDaemonWriters = { +const recordingDaemonWriters = { record: recordDaemonWriter, trace: traceDaemonWriter, } satisfies Record; +export const recordingCommandFamily = defineCommandFamily({ + name: 'recording', + metadata: recordingCommandMetadata, + definitions: recordingCommandDefinitions, + cliSchemas: recordingCliSchemas, + cliReaders: recordingCliReaders, + daemonWriters: recordingDaemonWriters, + cliOutputFormatters: recordingCliOutputFormatters, +}); + function recordingPositionals(input: RecordOptions): string[] { return [input.action, ...optionalString(input.path)]; } diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index 7fac101c0..25c441180 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -1,4 +1,5 @@ import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineCommandFamily } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { booleanField, @@ -57,7 +58,7 @@ export const testCommandMetadata = defineFieldCommandMetadata( }, ); -export const replayCommandMetadataList = [replayCommandMetadata, testCommandMetadata] as const; +const replayCommandMetadataList = [replayCommandMetadata, testCommandMetadata] as const; export const replayCommandDefinition = defineExecutableCommand( replayCommandMetadata, @@ -68,7 +69,7 @@ export const testCommandDefinition = defineExecutableCommand(testCommandMetadata client.replay.test(input), ); -export const replayCommandDefinitions = [replayCommandDefinition, testCommandDefinition] as const; +const replayCommandDefinitions = [replayCommandDefinition, testCommandDefinition] as const; const replayCliSchema = { usageOverride: 'replay | replay export [--format maestro] [--out ]', @@ -101,7 +102,7 @@ const testCliSchema = { ], } as const satisfies CommandSchemaOverride; -export const replayCliSchemas = { +const replayCliSchemas = { [REPLAY_COMMAND_NAME]: replayCliSchema, [TEST_COMMAND_NAME]: testCliSchema, } as const satisfies Record; @@ -148,16 +149,25 @@ export const testDaemonWriter: DaemonWriter = (input) => replayShellEnv: collectReplayClientShellEnv(process.env), }); -export const replayCliReaders = { +const replayCliReaders = { replay: replayCliReader, test: testCliReader, } satisfies Record; -export const replayDaemonWriters = { +const replayDaemonWriters = { replay: replayDaemonWriter, test: testDaemonWriter, } satisfies Record; +export const replayCommandFamily = defineCommandFamily({ + name: 'replay', + metadata: replayCommandMetadataList, + definitions: replayCommandDefinitions, + cliSchemas: replayCliSchemas, + cliReaders: replayCliReaders, + daemonWriters: replayDaemonWriters, +}); + function readReplayBackend(input: CommandInput): string | undefined { return input.backend ?? (input.maestro === true ? 'maestro' : undefined); } diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index acaa4146c..06fbfcf35 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -4,6 +4,7 @@ import { BACK_MODES } from '../../core/back-mode.ts'; import { parseDeviceRotation, DEVICE_ROTATIONS } from '../../core/device-rotation.ts'; import { AppError } from '../../utils/errors.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineCommandFamily } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { compactRecord, enumField, requiredField, stringField } from '../command-input.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; @@ -15,6 +16,7 @@ import { requiredDaemonString, } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { systemCliOutputFormatters } from './output.ts'; const APPSTATE_COMMAND_NAME = 'appstate'; const BACK_COMMAND_NAME = 'back'; @@ -82,7 +84,7 @@ const clipboardCommandMetadata = defineFieldCommandMetadata( }, ); -export const systemCommandMetadata = [ +const systemCommandMetadata = [ appStateCommandMetadata, backCommandMetadata, homeCommandMetadata, @@ -124,7 +126,7 @@ const clipboardCommandDefinition = defineExecutableCommand( (client, input) => client.command.clipboard(input as ClipboardCommandOptions), ); -export const systemCommandDefinitions = [ +const systemCommandDefinitions = [ appStateCommandDefinition, backCommandDefinition, homeCommandDefinition, @@ -165,7 +167,7 @@ const clipboardCliSchema = { allowsExtraPositionals: true, } as const satisfies CommandSchemaOverride; -export const systemCliSchemas = { +const systemCliSchemas = { [APPSTATE_COMMAND_NAME]: appStateCliSchema, [BACK_COMMAND_NAME]: backCliSchema, [ROTATE_COMMAND_NAME]: rotateCliSchema, @@ -197,7 +199,7 @@ export const clipboardCliReader: CliReader = (positionals, flags) => ({ ...readClipboardInput(positionals), }); -export const systemCliReaders = { +const systemCliReaders = { appstate: appStateCliReader, home: homeCliReader, 'app-switcher': appSwitcherCliReader, @@ -228,7 +230,7 @@ export const clipboardDaemonWriter: DaemonWriter = direct(CLIPBOARD_COMMAND_NAME clipboardPositionals(input as ClipboardCommandOptions), ); -export const systemDaemonWriters = { +const systemDaemonWriters = { appstate: appStateDaemonWriter, back: backDaemonWriter, home: homeDaemonWriter, @@ -238,6 +240,16 @@ export const systemDaemonWriters = { clipboard: clipboardDaemonWriter, } satisfies Record; +export const systemCommandFamily = defineCommandFamily({ + name: 'system', + metadata: systemCommandMetadata, + definitions: systemCommandDefinitions, + cliSchemas: systemCliSchemas, + cliReaders: systemCliReaders, + daemonWriters: systemDaemonWriters, + cliOutputFormatters: systemCliOutputFormatters, +}); + function readBackMode(value: unknown): BackMode | undefined { return value === 'in-app' || value === 'system' ? value : undefined; } diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 18da3d3a2..df36943aa 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -1,16 +1,5 @@ import type { CommandName } from '../commands/command-metadata.ts'; -import { batchCliSchemas } from '../commands/batch/index.ts'; -import { captureCliSchemas } from '../commands/capture/index.ts'; -import { debuggingCliSchemas } from '../commands/debugging/index.ts'; -import { interactionCliSchemas } from '../commands/interaction/index.ts'; -import { managementCliSchemas } from '../commands/management/index.ts'; -import { metroCliSchemas } from '../commands/metro/index.ts'; -import { observabilityCliSchemas } from '../commands/observability/index.ts'; -import { perfCliSchemas } from '../commands/perf/index.ts'; -import { reactNativeCliSchemas } from '../commands/react-native/index.ts'; -import { recordingCliSchemas } from '../commands/recording/index.ts'; -import { replayCliSchemas } from '../commands/replay/index.ts'; -import { systemCliSchemas } from '../commands/system/index.ts'; +import { listCommandFamilyCliSchemas } from '../commands/family/registry.ts'; import type { LocalCliCommandName } from '../command-catalog.ts'; import type { CommandSchema, CommandSchemaOverride } from './cli-command-schema-types.ts'; import { COMMON_COMMAND_SUPPORTED_FLAG_KEYS, METRO_PREPARE_FLAGS } from './cli-flags.ts'; @@ -125,20 +114,9 @@ Use web setup to install or reuse the pinned backend. Use web doctor after setup }, } as const satisfies Record; -const CLI_COMMAND_OVERRIDES = { - ...managementCliSchemas, - ...captureCliSchemas, - ...systemCliSchemas, - ...interactionCliSchemas, - ...observabilityCliSchemas, - ...perfCliSchemas, - ...debuggingCliSchemas, - ...metroCliSchemas, - ...replayCliSchemas, - ...batchCliSchemas, - ...recordingCliSchemas, - ...reactNativeCliSchemas, -} as const satisfies Partial>; +const CLI_COMMAND_OVERRIDES = listCommandFamilyCliSchemas() as Partial< + Record +>; export function getSchemaOnlyCliCommandSchema(command: string): CommandSchema | undefined { return Object.hasOwn(SCHEMA_ONLY_CLI_COMMAND_SCHEMAS, command)