diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 3fa40780e..359675df9 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -54,6 +54,54 @@ export interface Args { unknownFlags: Map; } +/** + * Long names of every built-in CLI flag recognized by {@link parseArgs}. + * Extension flags that would shadow one of these are rejected at registration + * (see ExtensionAPI.registerFlag), because a built-in branch in parseArgs would + * consume the flag before the extension ever sees it. Keep in sync with the + * flag branches below. + */ +export const BUILTIN_FLAG_NAMES: ReadonlySet = new Set([ + "help", + "version", + "allow-home", + "mode", + "continue", + "resume", + "session", + "fork", + "provider", + "model", + "smol", + "slow", + "plan", + "api-key", + "system-prompt", + "append-system-prompt", + "provider-session-id", + "no-session", + "session-dir", + "models", + "no-tools", + "no-lsp", + "no-pty", + "tools", + "thinking", + "print", + "export", + "hook", + "extension", + "plugin-dir", + "no-extensions", + "no-skills", + "no-rules", + "no-title", + "auto-approve", + "yolo", + "approval-mode", + "skills", + "list-models", +]); export function parseArgs(inputArgs: string[], extensionFlags?: Map): Args { // Work on a copy: the `--option=value` handling below splices the value // into the array, and callers reuse the same argv (the post-extension @@ -216,7 +264,14 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map { - const pipedInput = await readPipedInput(); - if (parsedArgs.fileArgs.length === 0) { - return { pipedInput, fileText: undefined, fileImages: undefined }; - } - const processed = await processFileArguments(parsedArgs.fileArgs, { - autoResizeImages: settingsInstance.get("images.autoResize"), - }); - return { pipedInput, fileText: processed.text, fileImages: processed.images }; - }); + const pipedInput = await logger.time("readPipedInput", readPipedInput); const autoPrint = pipedInput !== undefined && !parsedArgs.print && parsedArgs.mode === undefined; const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined; const mode = parsedArgs.mode || "text"; @@ -964,10 +955,22 @@ export async function runRootCommand( } const initialArgs = applyExtensionFlags(session.extensionRunner, rawArgs) ?? parsedArgs; + // Process @file args from the extension-aware parse, so an extension + // string-flag value such as `--target @notes.md` is consumed as the flag's + // value rather than read as a file into the prompt. File args are not + // needed earlier (session setup depends only on pipedInput/mode). + const processedFiles = + initialArgs.fileArgs.length > 0 + ? await logger.time("processFileArguments", () => + processFileArguments(initialArgs.fileArgs, { + autoResizeImages: settingsInstance.get("images.autoResize"), + }), + ) + : undefined; const { initialMessage, initialImages } = buildInitialMessage({ parsed: initialArgs, - fileText, - fileImages, + fileText: processedFiles?.text, + fileImages: processedFiles?.images, stdinContent: pipedInput, }); diff --git a/packages/coding-agent/test/extension-flag-initial-message.test.ts b/packages/coding-agent/test/extension-flag-initial-message.test.ts index 1a16b08b8..761e0a9d4 100644 --- a/packages/coding-agent/test/extension-flag-initial-message.test.ts +++ b/packages/coding-agent/test/extension-flag-initial-message.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "bun:test"; import { parseArgs } from "../src/cli/args"; import { applyExtensionFlags, type ExtensionFlagSink } from "../src/cli/extension-flags"; import { buildInitialMessage } from "../src/cli/initial-message"; +import { ExtensionRuntime, loadExtensionFromFactory } from "../src/extensibility/extensions/loader"; +import { EventBus } from "../src/utils/event-bus"; // Regression coverage for extension-registered flags leaking into the initial // prompt. The CLI parses argv twice: once at startup (before extensions load, @@ -36,6 +38,34 @@ describe("extension flags vs initial message", () => { expect(parsed.noTools).toBe(true); expect(parsed.messages).toEqual(["do the task"]); }); + it("does not consume a flag-looking string value in space form, keeping command shape (P1#2)", () => { + // `--print` after an extension flag (unknown at startup) must stay the + // built-in print flag in BOTH parses, so the reparse cannot silently flip + // command behavior. Flag-looking values must be passed as `--flag=value`. + const parsed = parseArgs(["--spawn-peer", "--print", "hello"], extFlags); + expect(parsed.unknownFlags.has("spawn-peer")).toBe(false); + expect(parsed.print).toBe(true); + expect(parsed.messages).toEqual(["hello"]); + }); + it("consumes a flag-looking string value in equals form", () => { + const parsed = parseArgs(["--spawn-peer=--print", "hello"], extFlags); + expect(parsed.unknownFlags.get("spawn-peer")).toBe("--print"); + expect(parsed.print).toBeUndefined(); + expect(parsed.messages).toEqual(["hello"]); + }); + it("treats an @-prefixed string value as the flag's value, not a file arg (P1#1)", () => { + const parsed = parseArgs(["--spawn-peer", "@notes.md", "hello"], extFlags); + expect(parsed.unknownFlags.get("spawn-peer")).toBe("@notes.md"); + expect(parsed.fileArgs).toEqual([]); + expect(parsed.messages).toEqual(["hello"]); + }); + it("documents the P1#1 startup-parse leak: without flags, an @-value is misread as a file arg", () => { + // This is the startup parse (extensions not loaded). `runRootCommand` must + // run processFileArguments on the extension-aware parse, not this one, or + // `@notes.md` gets read into the prompt as a file. + const parsed = parseArgs(["--spawn-peer", "@notes.md", "hello"]); + expect(parsed.fileArgs).toEqual(["notes.md"]); + }); it("builds the initial prompt from the real message, not the flag value, when flags are known", () => { const parsed = parseArgs(["--spawn-peer", "reviewer", "review the diff"], extFlags); @@ -129,3 +159,28 @@ describe("applyExtensionFlags (single-parser flag resolution)", () => { expect(runner.values.size).toBe(0); }); }); +describe("registerFlag built-in collision guard (P2#3)", () => { + it("rejects an extension flag that shadows a built-in CLI flag", async () => { + await expect( + loadExtensionFromFactory( + api => { + api.registerFlag("model", { type: "string" }); + }, + process.cwd(), + new EventBus(), + new ExtensionRuntime(), + ), + ).rejects.toThrow(/collides with a built-in/); + }); + it("allows a non-colliding extension flag", async () => { + const ext = await loadExtensionFromFactory( + api => { + api.registerFlag("spawn-peer", { type: "string" }); + }, + process.cwd(), + new EventBus(), + new ExtensionRuntime(), + ); + expect(ext.flags.has("spawn-peer")).toBe(true); + }); +});