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
13 changes: 0 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 14 additions & 3 deletions src/cli/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,16 @@ function isKnownOption(arg: string): boolean {
}

/**
* Validate that all options in args are known
* Validate that all global options (before the first command token) are known.
* Stops at the first non-option argument so subcommand-specific options
* (e.g. --scope, --payment-required, -o/--output) are never checked here.
* @throws ClientError if unknown option is found
*/
export function validateOptions(args: string[]): void {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg) continue;

// Only check arguments that start with -
if (arg.startsWith('-')) {
if (!isKnownOption(arg)) {
throw new ClientError(`Unknown option: ${arg}`);
Expand All @@ -143,12 +144,17 @@ export function validateOptions(args: string[]): void {
if (optionTakesValue(arg) && !arg.includes('=') && i + 1 < args.length) {
i++;
}
} else {
// Stop at the first non-option argument (command token).
// Options after this point are subcommand-specific and are handled by Commander.
break;
}
}
}

/**
* Validate argument values (--schema-mode, --timeout, etc.)
* Validate argument values (--schema-mode, --timeout, etc.) for global options only.
* Stops at the first non-option argument so subcommand-specific options are ignored.
* @throws ClientError if invalid value is found
*/
export function validateArgValues(args: string[]): void {
Expand All @@ -157,6 +163,11 @@ export function validateArgValues(args: string[]): void {
const nextArg = args[i + 1];
if (!arg) continue;

if (!arg.startsWith('-')) {
// Stop at the first non-option argument (command token)
break;
}

// Validate --schema-mode value
if (arg === '--schema-mode' && nextArg) {
if (!VALID_SCHEMA_MODES.includes(nextArg)) {
Expand Down
87 changes: 86 additions & 1 deletion test/unit/cli/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
* Tests for argument parsing utilities
*/

import { parseCommandArgs, getVerboseFromEnv, getJsonFromEnv } from '../../../src/cli/parser.js';
import {
parseCommandArgs,
getVerboseFromEnv,
getJsonFromEnv,
validateOptions,
validateArgValues,
} from '../../../src/cli/parser.js';
import { ClientError } from '../../../src/lib/errors.js';

describe('parseCommandArgs', () => {
Expand Down Expand Up @@ -313,3 +319,82 @@ describe('getJsonFromEnv', () => {
expect(getJsonFromEnv()).toBe(false);
});
});

describe('validateOptions', () => {
it('should not throw for known global options', () => {
expect(() => validateOptions(['--verbose', '--json'])).not.toThrow();
expect(() => validateOptions(['--json', '--verbose'])).not.toThrow();
expect(() => validateOptions(['-j'])).not.toThrow();
});

it('should not throw for known value options with separate values', () => {
expect(() => validateOptions(['--header', 'Authorization: Bearer token'])).not.toThrow();
expect(() => validateOptions(['--timeout', '30'])).not.toThrow();
expect(() => validateOptions(['--profile', 'personal'])).not.toThrow();
});

it('should not throw for subcommand-specific options after a command token', () => {
// --scope appears after 'login' command token — must not be rejected
expect(() => validateOptions(['login', 'mcp.apify.com', '--scope', 'read'])).not.toThrow();
// --payment-required, --amount, --expiry for x402 sign
expect(() =>
validateOptions(['x402', 'sign', '--payment-required', 'data', '--amount', '1.0'])
).not.toThrow();
// -o/--output, --max-size for resources-read
expect(() =>
validateOptions(['@session', 'resources-read', 'uri', '-o', 'out.txt', '--max-size', '1024'])
).not.toThrow();
});

it('should not throw for unknown options that appear after @session (non-option token)', () => {
expect(() =>
validateOptions(['--json', '@mysession', '--unknown-subcommand-flag'])
).not.toThrow();
});

it('should throw for unknown options that appear before any command token', () => {
// No command token at all
expect(() => validateOptions(['--unknown'])).toThrow(ClientError);
expect(() => validateOptions(['--unknown'])).toThrow('Unknown option: --unknown');
// Unknown option before a command token
expect(() => validateOptions(['--bad-flag', 'login'])).toThrow(ClientError);
expect(() => validateOptions(['--bad-flag', 'login'])).toThrow('Unknown option: --bad-flag');
});

it('should accept empty args array', () => {
expect(() => validateOptions([])).not.toThrow();
});
});

describe('validateArgValues', () => {
it('should not throw for valid --schema-mode values', () => {
expect(() => validateArgValues(['--schema-mode', 'strict'])).not.toThrow();
expect(() => validateArgValues(['--schema-mode', 'compatible'])).not.toThrow();
expect(() => validateArgValues(['--schema-mode', 'ignore'])).not.toThrow();
});

it('should throw for invalid --schema-mode value before command token', () => {
expect(() => validateArgValues(['--schema-mode', 'bad'])).toThrow(ClientError);
expect(() => validateArgValues(['--schema-mode', 'bad'])).toThrow('Invalid --schema-mode value');
});

it('should not validate --schema-mode value after command token', () => {
// Even an invalid value is not checked once we are past a command token
expect(() =>
validateArgValues(['connect', 'example.com', '--schema-mode', 'bad'])
).not.toThrow();
});

it('should throw for invalid --timeout value before command token', () => {
expect(() => validateArgValues(['--timeout', 'notanumber'])).toThrow(ClientError);
expect(() => validateArgValues(['--timeout', 'notanumber'])).toThrow(
'Invalid --timeout value'
);
});

it('should not validate --timeout after command token', () => {
expect(() =>
validateArgValues(['connect', 'example.com', '--timeout', 'notanumber'])
).not.toThrow();
});
});
Loading