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
4 changes: 3 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type { OutputMode, X402SchemePreference } from '../lib/index.js';
import { X402_SCHEME_PREFERENCES } from '../lib/index.js';
import {
extractOptions,
preProcessSkillArgv,
preProcessX402Argv,
getVerboseFromEnv,
getJsonFromEnv,
Expand Down Expand Up @@ -173,7 +174,8 @@ function getOptionsFromCommand(command: Command): HandlerOptions {
async function main(): Promise<void> {
// Disambiguate `--x402 <non-scheme>` (URL, @session, etc.) so Commander's
// greedy [optional] arg parser doesn't eat the next positional as the value.
process.argv = preProcessX402Argv(process.argv);
// Then accept `mcpc --skill` as an undocumented alias of `mcpc help --skill`.
process.argv = preProcessSkillArgv(preProcessX402Argv(process.argv));
const args = process.argv.slice(2);

// Set up cleanup handlers for graceful shutdown
Expand Down
26 changes: 26 additions & 0 deletions src/cli/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,32 @@ export function preProcessX402Argv(argv: string[]): string[] {
return out;
}

/**
* Rewrite a bare `mcpc --skill` into `mcpc help --skill`.
*
* `--skill` is an undocumented alias — agents reach for the flag form first, and
* an unknown-option error there is a dead end. Only an invocation with no
* positional token at all is rewritten, so `mcpc @s --skill`, `mcpc help --skill`
* and friends keep their existing behaviour (including their errors). `--help`
* also wins, so it keeps printing usage rather than the guide.
*/
export function preProcessSkillArgv(argv: string[]): string[] {
const args = argv.slice(2);
if (!args.includes('--skill') || hasPositionalArg(args)) return argv;
if (args.includes('--help') || args.includes('-h')) return argv;
return [...argv.slice(0, 2), 'help', ...args];
}

/** Whether `args` contains a non-option token (a command, session, or value). */
function hasPositionalArg(args: string[]): boolean {
for (let i = 0; i < args.length; i++) {
const arg = args[i] as string;
if (!arg.startsWith('-')) return true;
if (optionTakesValue(arg) && !arg.includes('=')) i++; // skip option value
}
return false;
}

// Global options that take a value (not boolean flags)
const GLOBAL_OPTIONS_WITH_VALUES = ['--timeout', '--profile', '--max-chars'];

Expand Down
28 changes: 28 additions & 0 deletions test/e2e/suites/basic/help.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,34 @@ assert_contains "$STDOUT" "name: mcpc"
assert_contains "$STDOUT" "Mental model"
test_pass

# Test: mcpc --skill is an undocumented alias printing the exact same guide.
# Kept out of --help output on purpose (one documented way to print the guide),
# but agents reach for the bare flag, so it must not be a dead end.
test_case "--skill matches help --skill"
run_mcpc --skill
assert_success
SKILL_FLAG_OUTPUT="$STDOUT"
assert_contains "$SKILL_FLAG_OUTPUT" "name: mcpc"
run_mcpc help --skill
assert_success
assert_eq "$SKILL_FLAG_OUTPUT" "$STDOUT" "mcpc --skill should match mcpc help --skill"
test_pass

# Test: the top-level --skill alias stays out of the documented options
test_case "--skill is not listed as a top-level option"
run_mcpc --help
assert_success
assert_not_contains "$STDOUT" "Print the agent skill"
assert_contains "$STDOUT" "mcpc help --skill"
test_pass

# Test: --skill with a command is not silently treated as the guide
test_case "--skill with a command name errors"
run_mcpc --skill connect
assert_failure
assert_not_contains "$STDOUT" "Mental model"
test_pass

# Test: mcpc help (no args) still shows the overview, not the guide
test_case "help with no args shows the command overview"
run_mcpc help
Expand Down
52 changes: 52 additions & 0 deletions test/unit/cli/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
suggestCommand,
normalizeSlashCommand,
normalizeSlashCommandArgs,
preProcessSkillArgv,
preProcessX402Argv,
} from '../../../src/cli/parser.js';
import { ClientError } from '../../../src/lib/errors.js';
Expand Down Expand Up @@ -734,3 +735,54 @@ describe('preProcessX402Argv', () => {
).toEqual([...head, '--x402=auto', 'mcp.apify.com', 'other', '--x402', 'exact']);
});
});

describe('preProcessSkillArgv', () => {
// Mirrors process.argv where indices 0 and 1 are node + script path.
const head = ['node', 'mcpc'];

it('rewrites bare `--skill` into `help --skill`', () => {
expect(preProcessSkillArgv([...head, '--skill'])).toEqual([...head, 'help', '--skill']);
});

it('keeps global flags around the rewritten command', () => {
expect(preProcessSkillArgv([...head, '--verbose', '--skill'])).toEqual([
...head,
'help',
'--verbose',
'--skill',
]);
expect(preProcessSkillArgv([...head, '--skill', '--timeout', '5'])).toEqual([
...head,
'help',
'--skill',
'--timeout',
'5',
]);
});

it('leaves `help --skill` untouched', () => {
expect(preProcessSkillArgv([...head, 'help', '--skill'])).toEqual([...head, 'help', '--skill']);
});

it('leaves argv with any other positional untouched', () => {
expect(preProcessSkillArgv([...head, '@s', '--skill'])).toEqual([...head, '@s', '--skill']);
expect(preProcessSkillArgv([...head, '--skill', 'connect'])).toEqual([
...head,
'--skill',
'connect',
]);
});

it('lets `--help` win over `--skill`', () => {
expect(preProcessSkillArgv([...head, '--skill', '--help'])).toEqual([
...head,
'--skill',
'--help',
]);
expect(preProcessSkillArgv([...head, '-h', '--skill'])).toEqual([...head, '-h', '--skill']);
});

it('leaves argv without `--skill` untouched', () => {
expect(preProcessSkillArgv([...head, 'help'])).toEqual([...head, 'help']);
});
});
Loading