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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
102 changes: 102 additions & 0 deletions src/__tests__/cli-help.test.ts
Original file line number Diff line number Diff line change
@@ -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<RunResult> {
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<DaemonResponse> => {
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/);
});
42 changes: 36 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<void> {
type CliDeps = {
sendToDaemon: typeof sendToDaemon;
};

const DEFAULT_CLI_DEPS: CliDeps = {
sendToDaemon,
};

export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): Promise<void> {
const parsed = parseArgs(argv);
for (const warning of parsed.warnings) {
process.stderr.write(`Warning: ${warning}\n`);
Expand All @@ -19,9 +27,31 @@ export async function runCli(argv: string[]): Promise<void> {
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;
Expand All @@ -34,7 +64,7 @@ export async function runCli(argv: string[]): Promise<void> {
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: [],
Expand All @@ -47,7 +77,7 @@ export async function runCli(argv: string[]): Promise<void> {
return;
}

const response = await sendToDaemon({
const response = await deps.sendToDaemon({
session: sessionName,
command: command!,
positionals,
Expand Down
20 changes: 19 additions & 1 deletion src/utils/__tests__/args.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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:/);
});
5 changes: 5 additions & 0 deletions src/utils/args.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AppError } from './errors.ts';
import {
buildCommandUsageText,
buildUsageText,
getCommandSchema,
getFlagDefinition,
Expand Down Expand Up @@ -201,3 +202,7 @@ export function toDaemonFlags(flags: CliFlags): Omit<CliFlags, 'json' | 'help' |
export function usage(): string {
return buildUsageText();
}

export function usageForCommand(command: string): string | null {
return buildCommandUsageText(command);
}
54 changes: 46 additions & 8 deletions src/utils/command-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,18 +569,12 @@ CLI to control iOS and Android devices for AI agents.

const helpFlags = FLAG_DEFINITIONS
.filter((definition) => 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}
`;
}

Expand All @@ -589,3 +583,47 @@ const USAGE_TEXT = renderUsageText();
export function buildUsageText(): string {
return USAGE_TEXT;
}

function listHelpFlags(keys: ReadonlySet<FlagKey>): 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<FlagKey>(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')}
`;
}