diff --git a/package-lock.json b/package-lock.json index cf75a7e..83f53a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,7 +85,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2755,7 +2754,6 @@ "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2854,7 +2852,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3369,7 +3366,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3954,7 +3950,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5484,7 +5479,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5841,7 +5835,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -6692,7 +6685,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -7737,7 +7729,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12199,7 +12190,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12538,7 +12528,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13180,7 +13169,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -13311,7 +13299,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/cli/parser.ts b/src/cli/parser.ts index fcbdf24..5961693 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -126,7 +126,9 @@ 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 { @@ -134,7 +136,6 @@ export function validateOptions(args: string[]): void { 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}`); @@ -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 { @@ -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)) { diff --git a/test/unit/cli/parser.test.ts b/test/unit/cli/parser.test.ts index 32626c5..161b7ee 100644 --- a/test/unit/cli/parser.test.ts +++ b/test/unit/cli/parser.test.ts @@ -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', () => { @@ -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(); + }); +});