From 90ba442177ad8eccec483829eaeb195d8ed5c245 Mon Sep 17 00:00:00 2001 From: truffle Date: Wed, 29 Apr 2026 02:08:45 +0000 Subject: [PATCH] fix(cli): preserve --raw atoms verbatim in run handler (#9622) Atoms in `args["--"]` are raw passthrough per yargs `populate--` semantics: the user typed `--` to opt out of further parsing, so the assembler must not synthesize quote bytes around them. Re-quoting raw atoms breaks leading-dash inputs like kilo run -- "- Who are you?" by emitting `"- Who are you?"` (literal quotes) into the model prompt. Atoms before `--` keep the existing wrap-quote behavior from #4979 so shell-bound multi-word positionals still preserve their boundaries. Extracted the message assembler to `cli/cmd/run-message.ts` so it can be unit-tested without pulling in the full handler's dependency tree. --- packages/opencode/src/cli/cmd/run-message.ts | 11 ++++++ packages/opencode/src/cli/cmd/run.ts | 5 ++- packages/opencode/test/cli/cmd/run.test.ts | 36 ++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/run-message.ts create mode 100644 packages/opencode/test/cli/cmd/run.test.ts diff --git a/packages/opencode/src/cli/cmd/run-message.ts b/packages/opencode/src/cli/cmd/run-message.ts new file mode 100644 index 00000000000..f3882ff910e --- /dev/null +++ b/packages/opencode/src/cli/cmd/run-message.ts @@ -0,0 +1,11 @@ +// Atoms before `--` are positional shell arguments where re-quoting around +// embedded spaces preserves the user's word-binding intent (PR #4979). +// Atoms in `args["--"]` are raw passthrough per yargs `populate--` semantics: +// the user typed `--` to opt out of further parsing, so the assembler must +// not synthesize quote bytes around them. Re-quoting raw atoms breaks +// leading-dash inputs like `kilo run -- "- Who are you?"` (#9622) by +// emitting `"- Who are you?"` (literal quotes) into the model prompt. +export function buildRunMessage(positionals: string[], dashDash?: string[]): string { + const quoted = positionals.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) + return [...quoted, ...(dashDash ?? [])].join(" ") +} diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index b8650e9a5b1..b5cdffee2d3 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "url" import { UI } from "../ui" import { cmd } from "./cmd" import { Flag } from "@opencode-ai/core/flag/flag" +import { buildRunMessage } from "./run-message" import { bootstrap } from "../bootstrap" import { EOL } from "os" import { text as streamText } from "node:stream/consumers" @@ -300,9 +301,7 @@ export const RunCommand = cmd({ ) }, handler: async (args) => { - let message = [...args.message, ...(args["--"] || [])] - .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) - .join(" ") + let message = buildRunMessage(args.message, args["--"]) const directory = (() => { if (!args.dir) return undefined diff --git a/packages/opencode/test/cli/cmd/run.test.ts b/packages/opencode/test/cli/cmd/run.test.ts new file mode 100644 index 00000000000..6059d466771 --- /dev/null +++ b/packages/opencode/test/cli/cmd/run.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import { buildRunMessage } from "../../../src/cli/cmd/run-message" + +describe("buildRunMessage", () => { + test("preserves shell-bound multi-word positionals via wrap-quote (PR #4979)", () => { + expect(buildRunMessage(["hello", "world foo", "bar"], undefined)).toBe('hello "world foo" bar') + }) + + test("does not quote single-word positionals", () => { + expect(buildRunMessage(["hello", "world"], undefined)).toBe("hello world") + }) + + test("escapes embedded double quotes inside positionals", () => { + expect(buildRunMessage(['say "hi"'], undefined)).toBe('"say \\"hi\\""') + }) + + test("passes args['--'] through verbatim without wrap-quote (#9622)", () => { + // `kilo run -- "- Who are you?"` — yargs+populate-- captures the leading-dash + // phrase as a single atom in args["--"]. The assembler must NOT wrap it, + // because the user typed `--` precisely to opt out of further parsing. + expect(buildRunMessage([], ["- Who are you?"])).toBe("- Who are you?") + }) + + test("does not synthesize quote bytes around dashDash atoms even when they contain spaces", () => { + expect(buildRunMessage([], ["one two", "three"])).toBe("one two three") + }) + + test("combines positionals and dashDash with appropriate quoting per source", () => { + expect(buildRunMessage(["pre", "fix arg"], ["raw arg", "tail"])).toBe('pre "fix arg" raw arg tail') + }) + + test("handles undefined and empty dashDash identically", () => { + expect(buildRunMessage(["x"], undefined)).toBe("x") + expect(buildRunMessage(["x"], [])).toBe("x") + }) +})