feat(cli): add agent picker and remove Claude default

Separate top-level help and version flags from agent arguments. Require explicit agents in scripts and preserve command argument boundaries.

Validation: bun typecheck, bun run test, targeted runner integration tests, and PTY/source/compiled argv smoke checks.
This commit is contained in:
weishu
2026-09-12 12:03:59 +08:00
parent 23e1cdde78
commit fc2fdfc25b
22 changed files with 687 additions and 147 deletions
+56
View File
@@ -0,0 +1,56 @@
import { basename } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { normalizeCliArgs } from './cliArgs'
describe('CLI argv normalization', () => {
const exec = process.execPath
const entrypoint = '/workspace/cli/src/index.ts'
afterEach(() => { vi.unstubAllGlobals() })
it.each([
[exec],
[basename(exec)],
[exec, entrypoint],
['bun', entrypoint],
[exec, entrypoint, '--']
])('strips the runtime wrapper %j without consuming command arguments', (...prefix) => {
const args = ['codex', '--', '--help', '--version', 'prompt with spaces']
expect(normalizeCliArgs([...prefix, ...args])).toEqual(args)
})
it('preserves a separator in already normalized arguments', () => {
expect(normalizeCliArgs(['claude', '--', '--model', 'literal']))
.toEqual(['claude', '--', '--model', 'literal'])
expect(normalizeCliArgs(['--', '--help'])).toEqual(['--', '--help'])
})
it('preserves runner handoff arguments for compiled and source entrypoints', () => {
const args = ['runner', 'start-sync', '--workspace-root', '/my project']
expect(normalizeCliArgs([exec, ...args])).toEqual(args)
expect(normalizeCliArgs([exec, entrypoint, ...args])).toEqual(args)
})
it('returns an empty command for bare invocations', () => {
expect(normalizeCliArgs([])).toEqual([])
expect(normalizeCliArgs([exec])).toEqual([])
expect(normalizeCliArgs([exec, entrypoint])).toEqual([])
})
it('preserves filenames used as prompts instead of treating them as entrypoints', () => {
vi.stubGlobal('Bun', { main: entrypoint })
expect(normalizeCliArgs([exec, entrypoint, 'prompt.ts', 'another.js']))
.toEqual(['prompt.ts', 'another.js'])
expect(normalizeCliArgs(['prompt.ts'])).toEqual(['prompt.ts'])
})
it('handles Bun virtual entrypoints and compiled process argv', () => {
const main = '/$bunfs/root/hapi'
vi.stubGlobal('Bun', { main })
expect(normalizeCliArgs(['bun', main, 'codex', '--', '--help']))
.toEqual(['codex', '--', '--help'])
expect(normalizeCliArgs(['bun', main, 'prompt.ts'])).toEqual(['prompt.ts'])
expect(normalizeCliArgs([exec, 'prompt.ts'])).toEqual(['prompt.ts'])
expect(normalizeCliArgs(['bun', main])).toEqual([])
})
})
+22 -42
View File
@@ -12,28 +12,12 @@ function isEntrypointPath(value: string, bunMain: string): boolean {
if (!value) {
return false;
}
if (value === bunMain) {
return true;
if (bunMain) {
return value === bunMain;
}
return /\.(c|m)?(ts|js)$/.test(value);
}
function hasRuntimeWrapper(preArgs: string[], execPath: string, execBase: string, bunMain: string): boolean {
if (preArgs.length === 0) {
return false;
}
if (preArgs[0] === 'bun') {
return true;
}
if (preArgs.length < 2) {
return false;
}
if (preArgs[0] !== execPath && preArgs[0] !== execBase) {
return false;
}
return isEntrypointPath(preArgs[1], bunMain);
}
export function normalizeCliArgs(rawArgv: string[]): string[] {
if (!Array.isArray(rawArgv) || rawArgv.length === 0) {
return [];
@@ -42,34 +26,30 @@ export function normalizeCliArgs(rawArgv: string[]): string[] {
const execPath = process.execPath;
const execBase = basename(execPath);
const bunMain = globalThis.Bun?.main ?? '';
const dashIndex = rawArgv.indexOf('--');
let argv = rawArgv.slice();
if (dashIndex >= 0) {
const preArgs = rawArgv.slice(0, dashIndex);
const postArgs = rawArgv.slice(dashIndex + 1);
argv = hasRuntimeWrapper(preArgs, execPath, execBase, bunMain)
? postArgs
: [...preArgs, ...postArgs];
}
const argv = rawArgv;
let startIndex = 0;
while (startIndex < argv.length) {
const value = argv[startIndex] || '';
const nextValue = argv[startIndex + 1] || '';
if (
value === 'bun' &&
(nextValue === bunMain || nextValue === execPath || nextValue === execBase || isEntrypointPath(nextValue, bunMain))
) {
startIndex += 2;
continue;
}
if (value === execPath || value === execBase || isEntrypointPath(value, bunMain)) {
startIndex += 1;
continue;
}
break;
const nextValue = argv[1] || '';
if (argv[0] === 'bun' && (
nextValue === execPath || nextValue === execBase || isEntrypointPath(nextValue, bunMain)
)) {
startIndex += 1;
}
if (argv[startIndex] === execPath || argv[startIndex] === execBase) {
startIndex += 1;
}
// Consume at most one entrypoint. Later filenames, even *.ts / *.js, are
// user arguments, not additional runtime wrappers.
if ((startIndex > 0 || (bunMain && argv[0] === bunMain))
&& isEntrypointPath(argv[startIndex] || '', bunMain)) {
startIndex += 1;
}
// Only a separator immediately after the runtime/entrypoint is a wrapper
// separator. A later `--` belongs to the selected command and its arguments.
if (startIndex > 0 && argv[startIndex] === '--') {
startIndex += 1;
}
return argv.slice(startIndex);
}