diff --git a/package.json b/package.json index c12abd4ef..e30710a4e 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prepack": "pnpm build:node && pnpm build:axsnapshot", "typecheck": "tsc -p tsconfig.json", "test": "node --test", - "test:unit": "node --test src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts src/utils/**/__tests__/*.test.ts", + "test:unit": "node --test src/__tests__/*.test.ts src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts src/utils/**/__tests__/*.test.ts", "test:smoke": "node --test test/integration/smoke-*.test.ts", "test:integration": "node --test test/integration/*.test.ts" }, diff --git a/src/__tests__/cli-help.test.ts b/src/__tests__/cli-help.test.ts new file mode 100644 index 000000000..1bb5350d0 --- /dev/null +++ b/src/__tests__/cli-help.test.ts @@ -0,0 +1,102 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runCli } from '../cli.ts'; +import type { DaemonResponse } from '../daemon-client.ts'; + +class ExitSignal extends Error { + public readonly code: number; + + constructor(code: number) { + super(`EXIT_${code}`); + this.code = code; + } +} + +type RunResult = { + code: number | null; + stdout: string; + stderr: string; + daemonCalls: number; +}; + +async function runCliCapture(argv: string[]): Promise { + let daemonCalls = 0; + let stdout = ''; + let stderr = ''; + let code: number | null = null; + + const originalExit = process.exit; + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const originalStderrWrite = process.stderr.write.bind(process.stderr); + + (process as any).exit = ((nextCode?: number) => { + throw new ExitSignal(nextCode ?? 0); + }) as typeof process.exit; + (process.stdout as any).write = ((chunk: unknown) => { + stdout += String(chunk); + return true; + }) as typeof process.stdout.write; + (process.stderr as any).write = ((chunk: unknown) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + + const sendToDaemon = async (): Promise => { + daemonCalls += 1; + return { ok: true, data: {} }; + }; + + try { + await runCli(argv, { sendToDaemon }); + } catch (error) { + if (error instanceof ExitSignal) code = error.code; + else throw error; + } finally { + process.exit = originalExit; + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } + + return { code, stdout, stderr, daemonCalls }; +} + +test('help appstate prints command help and skips daemon dispatch', async () => { + const result = await runCliCapture(['help', 'appstate']); + assert.equal(result.code, 0); + assert.equal(result.daemonCalls, 0); + assert.match(result.stdout, /Show foreground app\/activity/); + assert.doesNotMatch(result.stdout, /Command flags:/); + assert.match(result.stdout, /Global flags:/); +}); + +test('appstate --help prints command help and skips daemon dispatch', async () => { + const result = await runCliCapture(['appstate', '--help']); + assert.equal(result.code, 0); + assert.equal(result.daemonCalls, 0); + assert.match(result.stdout, /Usage:\n agent-device appstate/); + assert.match(result.stdout, /Global flags:/); +}); + +test('help unknown command prints error plus global usage and skips daemon dispatch', async () => { + const result = await runCliCapture(['help', 'not-a-command']); + assert.equal(result.code, 1); + assert.equal(result.daemonCalls, 0); + assert.match(result.stderr, /Error \(INVALID_ARGS\): Unknown command: not-a-command/); + assert.match(result.stdout, /Commands:/); + assert.match(result.stdout, /Flags:/); +}); + +test('unknown command --help prints error plus global usage and skips daemon dispatch', async () => { + const result = await runCliCapture(['not-a-command', '--help']); + assert.equal(result.code, 1); + assert.equal(result.daemonCalls, 0); + assert.match(result.stderr, /Error \(INVALID_ARGS\): Unknown command: not-a-command/); + assert.match(result.stdout, /Commands:/); +}); + +test('help rejects multiple positional commands and skips daemon dispatch', async () => { + const result = await runCliCapture(['help', 'appstate', 'extra']); + assert.equal(result.code, 1); + assert.equal(result.daemonCalls, 0); + assert.match(result.stderr, /Error \(INVALID_ARGS\): help accepts at most one command/); +}); diff --git a/src/cli.ts b/src/cli.ts index 0c1197cbf..428689e7e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,4 @@ -import { parseArgs, toDaemonFlags, usage } from './utils/args.ts'; +import { parseArgs, toDaemonFlags, usage, usageForCommand } from './utils/args.ts'; import { asAppError, AppError } from './utils/errors.ts'; import { formatSnapshotText, printHumanError, printJson } from './utils/output.ts'; import { readVersion } from './utils/version.ts'; @@ -8,7 +8,15 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -export async function runCli(argv: string[]): Promise { +type CliDeps = { + sendToDaemon: typeof sendToDaemon; +}; + +const DEFAULT_CLI_DEPS: CliDeps = { + sendToDaemon, +}; + +export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): Promise { const parsed = parseArgs(argv); for (const warning of parsed.warnings) { process.stderr.write(`Warning: ${warning}\n`); @@ -19,9 +27,31 @@ export async function runCli(argv: string[]): Promise { process.exit(0); } - if (parsed.flags.help || !parsed.command) { + const isHelpAlias = parsed.command === 'help'; + const isHelpFlag = parsed.flags.help; + if (isHelpAlias || isHelpFlag) { + if (isHelpAlias && parsed.positionals.length > 1) { + printHumanError(new AppError('INVALID_ARGS', 'help accepts at most one command.')); + process.exit(1); + } + const helpTarget = isHelpAlias ? parsed.positionals[0] : parsed.command; + if (!helpTarget) { + process.stdout.write(`${usage()}\n`); + process.exit(0); + } + const commandHelp = usageForCommand(helpTarget); + if (commandHelp) { + process.stdout.write(commandHelp); + process.exit(0); + } + printHumanError(new AppError('INVALID_ARGS', `Unknown command: ${helpTarget}`)); + process.stdout.write(`${usage()}\n`); + process.exit(1); + } + + if (!parsed.command) { process.stdout.write(`${usage()}\n`); - process.exit(parsed.flags.help ? 0 : 1); + process.exit(1); } const { command, positionals, flags } = parsed; @@ -34,7 +64,7 @@ export async function runCli(argv: string[]): Promise { if (sub !== 'list') { throw new AppError('INVALID_ARGS', 'session only supports list'); } - const response = await sendToDaemon({ + const response = await deps.sendToDaemon({ session: sessionName, command: 'session_list', positionals: [], @@ -47,7 +77,7 @@ export async function runCli(argv: string[]): Promise { return; } - const response = await sendToDaemon({ + const response = await deps.sendToDaemon({ session: sessionName, command: command!, positionals, diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index b499f4974..6aeb537b5 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { parseArgs, usage } from '../args.ts'; +import { parseArgs, usage, usageForCommand } from '../args.ts'; import { AppError } from '../errors.ts'; import { getCliCommandNames, getSchemaCapabilityKeys } from '../command-schema.ts'; import { listCapabilityCommands } from '../../core/capabilities.ts'; @@ -173,3 +173,21 @@ test('usage includes swipe and press series options', () => { assert.match(help, /--pattern one-way\|ping-pong/); assert.match(help, /--interval-ms/); }); + +test('command usage shows command and global flags separately', () => { + const help = usageForCommand('swipe'); + if (help === null) throw new Error('Expected command help text'); + assert.match(help, /Swipe coordinates with optional repeat pattern/); + assert.match(help, /Command flags:/); + assert.match(help, /--pattern one-way\|ping-pong/); + assert.match(help, /Global flags:/); + assert.match(help, /--platform ios\|android/); +}); + +test('command usage shows no command flags when unsupported', () => { + const help = usageForCommand('appstate'); + if (help === null) throw new Error('Expected command help text'); + assert.match(help, /Show foreground app\/activity/); + assert.doesNotMatch(help, /Command flags:/); + assert.match(help, /Global flags:/); +}); diff --git a/src/utils/args.ts b/src/utils/args.ts index 861405660..7192734d0 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -1,5 +1,6 @@ import { AppError } from './errors.ts'; import { + buildCommandUsageText, buildUsageText, getCommandSchema, getFlagDefinition, @@ -201,3 +202,7 @@ export function toDaemonFlags(flags: CliFlags): Omit definition.usageLabel && definition.usageDescription); - const maxFlagLabel = Math.max(...helpFlags.map((flag) => (flag.usageLabel ?? '').length)) + 2; - const flagLines: string[] = ['Flags:']; - for (const flag of helpFlags) { - flagLines.push( - ` ${(flag.usageLabel ?? '').padEnd(maxFlagLabel)}${flag.usageDescription ?? ''}`, - ); - } + const flagsSection = renderFlagSection('Flags:', helpFlags); return `${header} ${commandLines.join('\n')} -${flagLines.join('\n')} +${flagsSection} `; } @@ -589,3 +583,47 @@ const USAGE_TEXT = renderUsageText(); export function buildUsageText(): string { return USAGE_TEXT; } + +function listHelpFlags(keys: ReadonlySet): FlagDefinition[] { + return FLAG_DEFINITIONS.filter( + (definition) => + keys.has(definition.key) && + definition.usageLabel !== undefined && + definition.usageDescription !== undefined, + ); +} + +function renderFlagSection(title: string, definitions: FlagDefinition[]): string { + if (definitions.length === 0) { + return `${title}\n (none)`; + } + const maxFlagLabel = Math.max(...definitions.map((flag) => (flag.usageLabel ?? '').length)) + 2; + const lines = [title]; + for (const flag of definitions) { + lines.push(` ${(flag.usageLabel ?? '').padEnd(maxFlagLabel)}${flag.usageDescription ?? ''}`); + } + return lines.join('\n'); +} + +export function buildCommandUsageText(commandName: string): string | null { + const schema = getCommandSchema(commandName); + if (!schema) return null; + const usage = buildCommandUsage(commandName, schema); + const commandFlags = listHelpFlags(new Set(schema.allowedFlags)); + const globalFlags = listHelpFlags(GLOBAL_FLAG_KEYS); + const sections: string[] = []; + if (commandFlags.length > 0) { + sections.push(renderFlagSection('Command flags:', commandFlags)); + } + sections.push(renderFlagSection('Global flags:', globalFlags)); + + return `agent-device ${usage} + +${schema.description} + +Usage: + agent-device ${usage} + +${sections.join('\n\n')} +`; +}