From f9a5c75f208cd3a825631294852e7452f580dfe4 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sat, 1 Aug 2026 03:19:39 +0300 Subject: [PATCH 1/9] feat(coding-agent): add experimental CLI option parser --- packages/coding-agent/src/cli/args.ts | 9 +- .../src/cli/experimental/auth-options.ts | 43 ++++ .../src/cli/experimental/options.ts | 205 ++++++++++++++++++ .../coding-agent/src/cli/thinking-level.ts | 7 + .../coding-agent/src/core/model-resolver.ts | 2 +- .../test/experimental-cli-options.test.ts | 124 +++++++++++ 6 files changed, 383 insertions(+), 7 deletions(-) create mode 100644 packages/coding-agent/src/cli/experimental/auth-options.ts create mode 100644 packages/coding-agent/src/cli/experimental/options.ts create mode 100644 packages/coding-agent/src/cli/thinking-level.ts create mode 100644 packages/coding-agent/test/experimental-cli-options.test.ts diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 3a1954213..abd14ff7f 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -7,6 +7,9 @@ import chalk from "chalk"; import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR } from "../config.ts"; import type { ExtensionFlag } from "../core/extensions/types.ts"; import type { UiMode } from "../core/settings-manager.ts"; +import { isValidThinkingLevel, VALID_THINKING_LEVELS } from "./thinking-level.ts"; + +export { isValidThinkingLevel } from "./thinking-level.ts"; export type Mode = "text" | "json" | "rpc"; @@ -56,12 +59,6 @@ export interface Args { diagnostics: Array<{ type: "warning" | "error"; message: string }>; } -const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; - -export function isValidThinkingLevel(level: string): level is ThinkingLevel { - return VALID_THINKING_LEVELS.includes(level as ThinkingLevel); -} - export function parseArgs(args: string[]): Args { const result: Args = { messages: [], diff --git a/packages/coding-agent/src/cli/experimental/auth-options.ts b/packages/coding-agent/src/cli/experimental/auth-options.ts new file mode 100644 index 000000000..d8784371c --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/auth-options.ts @@ -0,0 +1,43 @@ +export type ClientAuthInput = + | { readonly type: "token"; readonly token: string } + | { readonly type: "file"; readonly path: string }; + +export type ServerAuthInput = ClientAuthInput | { readonly type: "generated"; readonly writeTo: string }; + +export interface RawAuthOptions { + readonly authToken?: string; + readonly authTokenFile?: string; + readonly writeAuthToken?: string; +} + +export function parseAuthInput( + options: RawAuthOptions, + role: "combined" | "server", +): { auth?: ServerAuthInput; errors: string[] }; +export function parseAuthInput(options: RawAuthOptions, role: "client"): { auth?: ClientAuthInput; errors: string[] }; +export function parseAuthInput( + options: RawAuthOptions, + role: "combined" | "server" | "client", +): { auth?: ServerAuthInput; errors: string[] } { + const configured = [options.authToken, options.authTokenFile, options.writeAuthToken].filter( + (value): value is string => value !== undefined, + ); + if (configured.length > 1) { + return { + errors: ["--auth-token, --auth-token-file, and --write-auth-token are mutually exclusive"], + }; + } + if (options.writeAuthToken !== undefined) { + if (role === "client") { + return { errors: ["--write-auth-token is only valid for combined or server mode"] }; + } + return { auth: { type: "generated", writeTo: options.writeAuthToken }, errors: [] }; + } + if (options.authToken !== undefined) { + return { auth: { type: "token", token: options.authToken }, errors: [] }; + } + if (options.authTokenFile !== undefined) { + return { auth: { type: "file", path: options.authTokenFile }, errors: [] }; + } + return { errors: [] }; +} diff --git a/packages/coding-agent/src/cli/experimental/options.ts b/packages/coding-agent/src/cli/experimental/options.ts new file mode 100644 index 000000000..2bd849f9b --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/options.ts @@ -0,0 +1,205 @@ +import { posix } from "node:path"; +import type { ThinkingLevel } from "@earendil-works/pi-protocol"; +import { isValidThinkingLevel, VALID_THINKING_LEVELS } from "../thinking-level.ts"; +import { type ClientAuthInput, parseAuthInput, type ServerAuthInput } from "./auth-options.ts"; + +interface CommonOptions { + readonly socketPath?: string; + readonly cwd?: string; + readonly provider?: string; + readonly model?: string; + readonly thinking?: ThinkingLevel; + readonly help: boolean; + readonly version: boolean; +} + +export interface ExperimentalCombinedOptions extends CommonOptions { + readonly role: "combined"; + readonly auth?: ServerAuthInput; + readonly sessionId?: string; + readonly initialPrompt?: string; +} + +export interface ExperimentalServerOptions extends CommonOptions { + readonly role: "server"; + readonly auth?: ServerAuthInput; +} + +export interface ExperimentalClientOptions extends CommonOptions { + readonly role: "client"; + readonly auth?: ClientAuthInput; + readonly sessionId?: string; + readonly initialPrompt?: string; +} + +export type ExperimentalCliOptions = + | ExperimentalCombinedOptions + | ExperimentalServerOptions + | ExperimentalClientOptions; + +export type ExperimentalCliParseResult = + | { readonly ok: true; readonly options: ExperimentalCliOptions } + | { readonly ok: false; readonly errors: readonly string[] }; + +const VALUE_OPTIONS = new Set([ + "--socket", + "--auth-token", + "--auth-token-file", + "--write-auth-token", + "--session", + "--cwd", + "--provider", + "--model", + "--thinking", +]); + +interface RawOptions { + authToken?: string; + authTokenFile?: string; + writeAuthToken?: string; + socketPath?: string; + sessionId?: string; + cwd?: string; + provider?: string; + model?: string; + thinking?: ThinkingLevel; + help: boolean; + version: boolean; + positionals: string[]; +} + +function splitOption(argument: string): { option: string; inlineValue?: string } { + const equals = argument.indexOf("="); + return equals === -1 + ? { option: argument } + : { option: argument.slice(0, equals), inlineValue: argument.slice(equals + 1) }; +} + +function parseRawOptions(argv: readonly string[]): { raw: RawOptions; errors: string[] } { + const raw: RawOptions = { help: false, version: false, positionals: [] }; + const errors: string[] = []; + let positionalsOnly = false; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]!; + if (positionalsOnly) { + raw.positionals.push(argument); + continue; + } + if (argument === "--") { + positionalsOnly = true; + continue; + } + if (argument.startsWith("@")) { + errors.push("@file arguments are not supported in experimental mode"); + continue; + } + if (!argument.startsWith("-")) { + raw.positionals.push(argument); + continue; + } + + const { option, inlineValue } = splitOption(argument); + if (option === "--help" || option === "-h" || option === "--version" || option === "-v") { + if (inlineValue !== undefined) { + errors.push(`${option} does not take a value`); + continue; + } + if (option === "--help" || option === "-h") raw.help = true; + else raw.version = true; + continue; + } + if (!VALUE_OPTIONS.has(option)) { + errors.push(`Unknown option: ${option}`); + continue; + } + + let value = inlineValue; + if (value === undefined) { + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith("-")) { + value = next; + index++; + } + } + if (value === undefined || value === "") { + errors.push(`${option} requires a value`); + continue; + } + + switch (option) { + case "--socket": + raw.socketPath = value; + break; + case "--auth-token": + raw.authToken = value; + break; + case "--auth-token-file": + raw.authTokenFile = value; + break; + case "--write-auth-token": + raw.writeAuthToken = value; + break; + case "--session": + raw.sessionId = value; + break; + case "--cwd": + raw.cwd = value; + break; + case "--provider": + raw.provider = value; + break; + case "--model": + raw.model = value; + break; + case "--thinking": + if (isValidThinkingLevel(value)) raw.thinking = value; + else errors.push(`Invalid thinking level "${value}". Valid values: ${VALID_THINKING_LEVELS.join(", ")}`); + break; + } + } + return { raw, errors }; +} + +export function parseExperimentalCliOptions(argv: readonly string[]): ExperimentalCliParseResult { + const [candidate, ...rest] = argv; + const role = candidate === "server" || candidate === "client" ? candidate : "combined"; + const { raw, errors } = parseRawOptions(role === "combined" ? argv : rest); + + if (raw.provider !== undefined && raw.model === undefined) errors.push("--provider requires --model"); + if (raw.socketPath !== undefined && !posix.isAbsolute(raw.socketPath)) { + errors.push("--socket requires an absolute Unix socket path"); + } + const authErrors = role === "client" ? parseAuthInput(raw, "client").errors : parseAuthInput(raw, role).errors; + errors.push(...authErrors); + if (role === "server") { + if (raw.sessionId !== undefined) errors.push("--session is only valid for combined or client mode"); + if (raw.positionals.length > 0) errors.push("An initial prompt is only valid for combined or client mode"); + } + if (errors.length > 0) return { ok: false, errors }; + + const common = { + ...(raw.socketPath === undefined ? {} : { socketPath: raw.socketPath }), + ...(raw.cwd === undefined ? {} : { cwd: raw.cwd }), + ...(raw.provider === undefined ? {} : { provider: raw.provider }), + ...(raw.model === undefined ? {} : { model: raw.model }), + ...(raw.thinking === undefined ? {} : { thinking: raw.thinking }), + help: raw.help, + version: raw.version, + }; + if (role === "server") { + const { auth } = parseAuthInput(raw, "server"); + return { ok: true, options: { role, ...common, ...(auth === undefined ? {} : { auth }) } }; + } + const roleOptions = { + ...common, + ...(raw.sessionId === undefined ? {} : { sessionId: raw.sessionId }), + ...(raw.positionals.length === 0 ? {} : { initialPrompt: raw.positionals.join(" ") }), + }; + if (role === "client") { + const { auth } = parseAuthInput(raw, "client"); + return { ok: true, options: { role, ...roleOptions, ...(auth === undefined ? {} : { auth }) } }; + } + const { auth } = parseAuthInput(raw, "combined"); + return { ok: true, options: { role, ...roleOptions, ...(auth === undefined ? {} : { auth }) } }; +} diff --git a/packages/coding-agent/src/cli/thinking-level.ts b/packages/coding-agent/src/cli/thinking-level.ts new file mode 100644 index 000000000..737c36a87 --- /dev/null +++ b/packages/coding-agent/src/cli/thinking-level.ts @@ -0,0 +1,7 @@ +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; + +export const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; + +export function isValidThinkingLevel(level: string): level is ThinkingLevel { + return (VALID_THINKING_LEVELS as readonly string[]).includes(level); +} diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index b5c21aa4c..6403d567b 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -6,7 +6,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@earendil-works/pi-ai"; import chalk from "chalk"; import { minimatch } from "minimatch"; -import { isValidThinkingLevel } from "../cli/args.ts"; +import { isValidThinkingLevel } from "../cli/thinking-level.ts"; import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; import type { ModelRuntime } from "./model-runtime.ts"; diff --git a/packages/coding-agent/test/experimental-cli-options.test.ts b/packages/coding-agent/test/experimental-cli-options.test.ts new file mode 100644 index 000000000..98c5d9139 --- /dev/null +++ b/packages/coding-agent/test/experimental-cli-options.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "vitest"; +import { parseExperimentalCliOptions } from "../src/cli/experimental/options.ts"; + +describe("parseExperimentalCliOptions", () => { + test("selects combined mode when no role is present", () => { + expect(parseExperimentalCliOptions(["--cwd", "/workspace", "inspect", "the project"])).toEqual({ + ok: true, + options: { + role: "combined", + cwd: "/workspace", + initialPrompt: "inspect the project", + help: false, + version: false, + }, + }); + }); + + test.each(["server", "client"] as const)("selects explicit %s mode", (role) => { + const result = parseExperimentalCliOptions([role, "--socket", "/tmp/pi.sock"]); + expect(result).toMatchObject({ ok: true, options: { role, socketPath: "/tmp/pi.sock" } }); + }); + + test("parses shared model options and inline values", () => { + const result = parseExperimentalCliOptions([ + "--provider=anthropic", + "--model", + "claude-sonnet", + "--thinking=high", + "--session", + "session-1", + ]); + expect(result).toMatchObject({ + ok: true, + options: { + role: "combined", + provider: "anthropic", + model: "claude-sonnet", + thinking: "high", + sessionId: "session-1", + }, + }); + }); + + test.each([ + [["--auth-token", "secret"], { type: "token", token: "secret" }], + [["--auth-token-file", "/tmp/token"], { type: "file", path: "/tmp/token" }], + [["--write-auth-token", "/tmp/token"], { type: "generated", writeTo: "/tmp/token" }], + ] as const)("parses server authentication source %j", (argv, auth) => { + const result = parseExperimentalCliOptions(argv); + expect(result).toMatchObject({ ok: true, options: { role: "combined", auth } }); + }); + + test("accepts token and token-file authentication in client mode", () => { + expect(parseExperimentalCliOptions(["client", "--auth-token", "secret"])).toMatchObject({ + ok: true, + options: { role: "client", auth: { type: "token", token: "secret" } }, + }); + expect(parseExperimentalCliOptions(["client", "--auth-token-file", "/tmp/token"])).toMatchObject({ + ok: true, + options: { role: "client", auth: { type: "file", path: "/tmp/token" } }, + }); + }); + + test("supports help, version, and positional-only prompts", () => { + expect(parseExperimentalCliOptions(["-h", "-v", "--", "--review", "this"])).toEqual({ + ok: true, + options: { + role: "combined", + help: true, + version: true, + initialPrompt: "--review this", + }, + }); + }); + + test.each([[[]], [["server"]], [["client"]]] as const)( + "permits omitted authentication for later environment/default resolution", + (argv) => { + const result = parseExperimentalCliOptions(argv); + expect(result.ok).toBe(true); + if (result.ok) expect("auth" in result.options).toBe(false); + }, + ); + + test.each([ + [ + ["--auth-token", "secret", "--auth-token-file", "/tmp/token"], + "--auth-token, --auth-token-file, and --write-auth-token are mutually exclusive", + ], + [ + ["--auth-token", "secret", "--write-auth-token", "/tmp/token"], + "--auth-token, --auth-token-file, and --write-auth-token are mutually exclusive", + ], + [["client", "--write-auth-token", "/tmp/token"], "--write-auth-token is only valid for combined or server mode"], + [["server", "--session", "session-1"], "--session is only valid for combined or client mode"], + [["server", "hello"], "An initial prompt is only valid for combined or client mode"], + [["--provider", "anthropic"], "--provider requires --model"], + [["--thinking", "extreme"], 'Invalid thinking level "extreme"'], + [["--socket", "relative.sock"], "--socket requires an absolute Unix socket path"], + [["--unknown"], "Unknown option: --unknown"], + [["-x"], "Unknown option: -x"], + [["--model"], "--model requires a value"], + [["--model="], "--model requires a value"], + [["@prompt.md"], "@file arguments are not supported in experimental mode"], + ] as const)("rejects invalid input %j", (argv, error) => { + const result = parseExperimentalCliOptions(argv); + expect(result).toMatchObject({ ok: false }); + if (!result.ok) expect(result.errors).toContainEqual(expect.stringContaining(error)); + }); + + test("reports independent errors together", () => { + expect(parseExperimentalCliOptions(["server", "--provider", "anthropic", "hello"])).toEqual({ + ok: false, + errors: ["--provider requires --model", "An initial prompt is only valid for combined or client mode"], + }); + }); + + test("treats role names after the first argument as a combined-mode prompt", () => { + expect(parseExperimentalCliOptions(["--cwd", "/workspace", "server"])).toMatchObject({ + ok: true, + options: { role: "combined", initialPrompt: "server" }, + }); + }); +}); From 1ea4ccad0472817fd117ecf03bd8e84ba72bfd5d Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sat, 1 Aug 2026 10:48:18 +0300 Subject: [PATCH 2/9] refactor(coding-agent): generalize experimental endpoints --- .../src/cli/experimental/auth-options.ts | 30 +------ .../src/cli/experimental/endpoint.ts | 36 ++++++++ .../src/cli/experimental/options.ts | 85 +++++++++++++------ .../test/experimental-cli-options.test.ts | 43 +++++++--- 4 files changed, 130 insertions(+), 64 deletions(-) create mode 100644 packages/coding-agent/src/cli/experimental/endpoint.ts diff --git a/packages/coding-agent/src/cli/experimental/auth-options.ts b/packages/coding-agent/src/cli/experimental/auth-options.ts index d8784371c..acfb6ab25 100644 --- a/packages/coding-agent/src/cli/experimental/auth-options.ts +++ b/packages/coding-agent/src/cli/experimental/auth-options.ts @@ -1,37 +1,15 @@ -export type ClientAuthInput = +export type AuthInput = | { readonly type: "token"; readonly token: string } | { readonly type: "file"; readonly path: string }; -export type ServerAuthInput = ClientAuthInput | { readonly type: "generated"; readonly writeTo: string }; - export interface RawAuthOptions { readonly authToken?: string; readonly authTokenFile?: string; - readonly writeAuthToken?: string; } -export function parseAuthInput( - options: RawAuthOptions, - role: "combined" | "server", -): { auth?: ServerAuthInput; errors: string[] }; -export function parseAuthInput(options: RawAuthOptions, role: "client"): { auth?: ClientAuthInput; errors: string[] }; -export function parseAuthInput( - options: RawAuthOptions, - role: "combined" | "server" | "client", -): { auth?: ServerAuthInput; errors: string[] } { - const configured = [options.authToken, options.authTokenFile, options.writeAuthToken].filter( - (value): value is string => value !== undefined, - ); - if (configured.length > 1) { - return { - errors: ["--auth-token, --auth-token-file, and --write-auth-token are mutually exclusive"], - }; - } - if (options.writeAuthToken !== undefined) { - if (role === "client") { - return { errors: ["--write-auth-token is only valid for combined or server mode"] }; - } - return { auth: { type: "generated", writeTo: options.writeAuthToken }, errors: [] }; +export function parseAuthInput(options: RawAuthOptions): { auth?: AuthInput; errors: string[] } { + if (options.authToken !== undefined && options.authTokenFile !== undefined) { + return { errors: ["--auth-token and --auth-token-file are mutually exclusive"] }; } if (options.authToken !== undefined) { return { auth: { type: "token", token: options.authToken }, errors: [] }; diff --git a/packages/coding-agent/src/cli/experimental/endpoint.ts b/packages/coding-agent/src/cli/experimental/endpoint.ts new file mode 100644 index 000000000..495d47d4b --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/endpoint.ts @@ -0,0 +1,36 @@ +import { posix } from "node:path"; + +export interface UnixEndpoint { + readonly transport: "unix"; + readonly path: string; +} + +export type ExperimentalEndpoint = UnixEndpoint; + +export function parseExperimentalEndpoint( + value: string, + option: "--listen" | "--connect", +): { endpoint?: ExperimentalEndpoint; error?: string } { + let url: URL; + try { + url = new URL(value); + } catch { + return { error: `Invalid ${option} endpoint "${value}"` }; + } + if (url.protocol !== "unix:") { + return { error: `Unsupported ${option} transport "${url.protocol}"` }; + } + if (url.hostname || url.port || url.username || url.password) { + return { error: "Unix endpoint must not include an authority" }; + } + let path: string; + try { + path = decodeURIComponent(url.pathname); + } catch { + return { error: `Invalid ${option} endpoint "${value}"` }; + } + if (!posix.isAbsolute(path)) { + return { error: "Unix endpoint requires an absolute path" }; + } + return { endpoint: { transport: "unix", path } }; +} diff --git a/packages/coding-agent/src/cli/experimental/options.ts b/packages/coding-agent/src/cli/experimental/options.ts index 2bd849f9b..1e51c1373 100644 --- a/packages/coding-agent/src/cli/experimental/options.ts +++ b/packages/coding-agent/src/cli/experimental/options.ts @@ -1,10 +1,9 @@ -import { posix } from "node:path"; import type { ThinkingLevel } from "@earendil-works/pi-protocol"; import { isValidThinkingLevel, VALID_THINKING_LEVELS } from "../thinking-level.ts"; -import { type ClientAuthInput, parseAuthInput, type ServerAuthInput } from "./auth-options.ts"; +import { type AuthInput, parseAuthInput } from "./auth-options.ts"; +import { type ExperimentalEndpoint, parseExperimentalEndpoint } from "./endpoint.ts"; interface CommonOptions { - readonly socketPath?: string; readonly cwd?: string; readonly provider?: string; readonly model?: string; @@ -15,19 +14,22 @@ interface CommonOptions { export interface ExperimentalCombinedOptions extends CommonOptions { readonly role: "combined"; - readonly auth?: ServerAuthInput; + readonly listen?: readonly ExperimentalEndpoint[]; + readonly auth?: AuthInput; readonly sessionId?: string; readonly initialPrompt?: string; } export interface ExperimentalServerOptions extends CommonOptions { readonly role: "server"; - readonly auth?: ServerAuthInput; + readonly listen?: readonly ExperimentalEndpoint[]; + readonly auth?: AuthInput; } export interface ExperimentalClientOptions extends CommonOptions { readonly role: "client"; - readonly auth?: ClientAuthInput; + readonly connect?: ExperimentalEndpoint; + readonly auth?: AuthInput; readonly sessionId?: string; readonly initialPrompt?: string; } @@ -42,10 +44,10 @@ export type ExperimentalCliParseResult = | { readonly ok: false; readonly errors: readonly string[] }; const VALUE_OPTIONS = new Set([ - "--socket", + "--listen", + "--connect", "--auth-token", "--auth-token-file", - "--write-auth-token", "--session", "--cwd", "--provider", @@ -56,8 +58,8 @@ const VALUE_OPTIONS = new Set([ interface RawOptions { authToken?: string; authTokenFile?: string; - writeAuthToken?: string; - socketPath?: string; + listenValues: string[]; + connectValue?: string; sessionId?: string; cwd?: string; provider?: string; @@ -76,7 +78,7 @@ function splitOption(argument: string): { option: string; inlineValue?: string } } function parseRawOptions(argv: readonly string[]): { raw: RawOptions; errors: string[] } { - const raw: RawOptions = { help: false, version: false, positionals: [] }; + const raw: RawOptions = { listenValues: [], help: false, version: false, positionals: [] }; const errors: string[] = []; let positionalsOnly = false; @@ -128,8 +130,12 @@ function parseRawOptions(argv: readonly string[]): { raw: RawOptions; errors: st } switch (option) { - case "--socket": - raw.socketPath = value; + case "--listen": + raw.listenValues.push(value); + break; + case "--connect": + if (raw.connectValue !== undefined) errors.push("--connect may only be specified once"); + raw.connectValue = value; break; case "--auth-token": raw.authToken = value; @@ -137,9 +143,6 @@ function parseRawOptions(argv: readonly string[]): { raw: RawOptions; errors: st case "--auth-token-file": raw.authTokenFile = value; break; - case "--write-auth-token": - raw.writeAuthToken = value; - break; case "--session": raw.sessionId = value; break; @@ -167,11 +170,18 @@ export function parseExperimentalCliOptions(argv: readonly string[]): Experiment const { raw, errors } = parseRawOptions(role === "combined" ? argv : rest); if (raw.provider !== undefined && raw.model === undefined) errors.push("--provider requires --model"); - if (raw.socketPath !== undefined && !posix.isAbsolute(raw.socketPath)) { - errors.push("--socket requires an absolute Unix socket path"); + const listen = raw.listenValues.flatMap((value) => { + const result = parseExperimentalEndpoint(value, "--listen"); + if (result.error) errors.push(result.error); + return result.endpoint ? [result.endpoint] : []; + }); + const connectResult = raw.connectValue ? parseExperimentalEndpoint(raw.connectValue, "--connect") : undefined; + if (connectResult?.error) errors.push(connectResult.error); + errors.push(...parseAuthInput(raw).errors); + if (role === "client" && raw.listenValues.length > 0) { + errors.push("--listen is only valid for combined or server mode"); } - const authErrors = role === "client" ? parseAuthInput(raw, "client").errors : parseAuthInput(raw, role).errors; - errors.push(...authErrors); + if (role !== "client" && raw.connectValue !== undefined) errors.push("--connect is only valid for client mode"); if (role === "server") { if (raw.sessionId !== undefined) errors.push("--session is only valid for combined or client mode"); if (raw.positionals.length > 0) errors.push("An initial prompt is only valid for combined or client mode"); @@ -179,7 +189,6 @@ export function parseExperimentalCliOptions(argv: readonly string[]): Experiment if (errors.length > 0) return { ok: false, errors }; const common = { - ...(raw.socketPath === undefined ? {} : { socketPath: raw.socketPath }), ...(raw.cwd === undefined ? {} : { cwd: raw.cwd }), ...(raw.provider === undefined ? {} : { provider: raw.provider }), ...(raw.model === undefined ? {} : { model: raw.model }), @@ -187,9 +196,17 @@ export function parseExperimentalCliOptions(argv: readonly string[]): Experiment help: raw.help, version: raw.version, }; + const { auth } = parseAuthInput(raw); if (role === "server") { - const { auth } = parseAuthInput(raw, "server"); - return { ok: true, options: { role, ...common, ...(auth === undefined ? {} : { auth }) } }; + return { + ok: true, + options: { + role, + ...common, + ...(listen.length === 0 ? {} : { listen }), + ...(auth === undefined ? {} : { auth }), + }, + }; } const roleOptions = { ...common, @@ -197,9 +214,23 @@ export function parseExperimentalCliOptions(argv: readonly string[]): Experiment ...(raw.positionals.length === 0 ? {} : { initialPrompt: raw.positionals.join(" ") }), }; if (role === "client") { - const { auth } = parseAuthInput(raw, "client"); - return { ok: true, options: { role, ...roleOptions, ...(auth === undefined ? {} : { auth }) } }; + return { + ok: true, + options: { + role, + ...roleOptions, + ...(connectResult?.endpoint === undefined ? {} : { connect: connectResult.endpoint }), + ...(auth === undefined ? {} : { auth }), + }, + }; } - const { auth } = parseAuthInput(raw, "combined"); - return { ok: true, options: { role, ...roleOptions, ...(auth === undefined ? {} : { auth }) } }; + return { + ok: true, + options: { + role, + ...roleOptions, + ...(listen.length === 0 ? {} : { listen }), + ...(auth === undefined ? {} : { auth }), + }, + }; } diff --git a/packages/coding-agent/test/experimental-cli-options.test.ts b/packages/coding-agent/test/experimental-cli-options.test.ts index 98c5d9139..cc02d060e 100644 --- a/packages/coding-agent/test/experimental-cli-options.test.ts +++ b/packages/coding-agent/test/experimental-cli-options.test.ts @@ -15,9 +15,31 @@ describe("parseExperimentalCliOptions", () => { }); }); - test.each(["server", "client"] as const)("selects explicit %s mode", (role) => { - const result = parseExperimentalCliOptions([role, "--socket", "/tmp/pi.sock"]); - expect(result).toMatchObject({ ok: true, options: { role, socketPath: "/tmp/pi.sock" } }); + test("parses repeatable server listeners", () => { + const result = parseExperimentalCliOptions([ + "server", + "--listen", + "unix:///tmp/pi.sock", + "--listen=unix:///tmp/pi-admin.sock", + ]); + expect(result).toMatchObject({ + ok: true, + options: { + role: "server", + listen: [ + { transport: "unix", path: "/tmp/pi.sock" }, + { transport: "unix", path: "/tmp/pi-admin.sock" }, + ], + }, + }); + }); + + test("parses a client connection endpoint", () => { + const result = parseExperimentalCliOptions(["client", "--connect", "unix:///tmp/pi.sock"]); + expect(result).toMatchObject({ + ok: true, + options: { role: "client", connect: { transport: "unix", path: "/tmp/pi.sock" } }, + }); }); test("parses shared model options and inline values", () => { @@ -44,7 +66,6 @@ describe("parseExperimentalCliOptions", () => { test.each([ [["--auth-token", "secret"], { type: "token", token: "secret" }], [["--auth-token-file", "/tmp/token"], { type: "file", path: "/tmp/token" }], - [["--write-auth-token", "/tmp/token"], { type: "generated", writeTo: "/tmp/token" }], ] as const)("parses server authentication source %j", (argv, auth) => { const result = parseExperimentalCliOptions(argv); expect(result).toMatchObject({ ok: true, options: { role: "combined", auth } }); @@ -85,18 +106,18 @@ describe("parseExperimentalCliOptions", () => { test.each([ [ ["--auth-token", "secret", "--auth-token-file", "/tmp/token"], - "--auth-token, --auth-token-file, and --write-auth-token are mutually exclusive", + "--auth-token and --auth-token-file are mutually exclusive", ], - [ - ["--auth-token", "secret", "--write-auth-token", "/tmp/token"], - "--auth-token, --auth-token-file, and --write-auth-token are mutually exclusive", - ], - [["client", "--write-auth-token", "/tmp/token"], "--write-auth-token is only valid for combined or server mode"], [["server", "--session", "session-1"], "--session is only valid for combined or client mode"], [["server", "hello"], "An initial prompt is only valid for combined or client mode"], [["--provider", "anthropic"], "--provider requires --model"], [["--thinking", "extreme"], 'Invalid thinking level "extreme"'], - [["--socket", "relative.sock"], "--socket requires an absolute Unix socket path"], + [["--listen", "/tmp/pi.sock"], 'Invalid --listen endpoint "/tmp/pi.sock"'], + [["--listen", "ws://localhost:8080"], 'Unsupported --listen transport "ws:"'], + [["--listen", "unix://relative.sock"], "Unix endpoint must not include an authority"], + [["client", "--listen", "unix:///tmp/pi.sock"], "--listen is only valid for combined or server mode"], + [["server", "--connect", "unix:///tmp/pi.sock"], "--connect is only valid for client mode"], + [["client", "--connect", "ws://localhost:8080"], 'Unsupported --connect transport "ws:"'], [["--unknown"], "Unknown option: --unknown"], [["-x"], "Unknown option: -x"], [["--model"], "--model requires a value"], From 0ac010b746cebcc0b54e3a38c1c920349b1b1a65 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sat, 1 Aug 2026 12:47:12 +0300 Subject: [PATCH 3/9] refactor(coding-agent): rename transport endpoint to address --- .../src/cli/experimental/endpoint.ts | 36 ------------------- .../src/cli/experimental/options.ts | 16 ++++----- .../src/cli/experimental/transport-address.ts | 36 +++++++++++++++++++ .../test/experimental-cli-options.test.ts | 6 ++-- 4 files changed, 47 insertions(+), 47 deletions(-) delete mode 100644 packages/coding-agent/src/cli/experimental/endpoint.ts create mode 100644 packages/coding-agent/src/cli/experimental/transport-address.ts diff --git a/packages/coding-agent/src/cli/experimental/endpoint.ts b/packages/coding-agent/src/cli/experimental/endpoint.ts deleted file mode 100644 index 495d47d4b..000000000 --- a/packages/coding-agent/src/cli/experimental/endpoint.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { posix } from "node:path"; - -export interface UnixEndpoint { - readonly transport: "unix"; - readonly path: string; -} - -export type ExperimentalEndpoint = UnixEndpoint; - -export function parseExperimentalEndpoint( - value: string, - option: "--listen" | "--connect", -): { endpoint?: ExperimentalEndpoint; error?: string } { - let url: URL; - try { - url = new URL(value); - } catch { - return { error: `Invalid ${option} endpoint "${value}"` }; - } - if (url.protocol !== "unix:") { - return { error: `Unsupported ${option} transport "${url.protocol}"` }; - } - if (url.hostname || url.port || url.username || url.password) { - return { error: "Unix endpoint must not include an authority" }; - } - let path: string; - try { - path = decodeURIComponent(url.pathname); - } catch { - return { error: `Invalid ${option} endpoint "${value}"` }; - } - if (!posix.isAbsolute(path)) { - return { error: "Unix endpoint requires an absolute path" }; - } - return { endpoint: { transport: "unix", path } }; -} diff --git a/packages/coding-agent/src/cli/experimental/options.ts b/packages/coding-agent/src/cli/experimental/options.ts index 1e51c1373..9715f31c9 100644 --- a/packages/coding-agent/src/cli/experimental/options.ts +++ b/packages/coding-agent/src/cli/experimental/options.ts @@ -1,7 +1,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-protocol"; import { isValidThinkingLevel, VALID_THINKING_LEVELS } from "../thinking-level.ts"; import { type AuthInput, parseAuthInput } from "./auth-options.ts"; -import { type ExperimentalEndpoint, parseExperimentalEndpoint } from "./endpoint.ts"; +import { type TransportAddress, parseTransportAddress } from "./transport-address.ts"; interface CommonOptions { readonly cwd?: string; @@ -14,7 +14,7 @@ interface CommonOptions { export interface ExperimentalCombinedOptions extends CommonOptions { readonly role: "combined"; - readonly listen?: readonly ExperimentalEndpoint[]; + readonly listen?: readonly TransportAddress[]; readonly auth?: AuthInput; readonly sessionId?: string; readonly initialPrompt?: string; @@ -22,13 +22,13 @@ export interface ExperimentalCombinedOptions extends CommonOptions { export interface ExperimentalServerOptions extends CommonOptions { readonly role: "server"; - readonly listen?: readonly ExperimentalEndpoint[]; + readonly listen?: readonly TransportAddress[]; readonly auth?: AuthInput; } export interface ExperimentalClientOptions extends CommonOptions { readonly role: "client"; - readonly connect?: ExperimentalEndpoint; + readonly connect?: TransportAddress; readonly auth?: AuthInput; readonly sessionId?: string; readonly initialPrompt?: string; @@ -171,11 +171,11 @@ export function parseExperimentalCliOptions(argv: readonly string[]): Experiment if (raw.provider !== undefined && raw.model === undefined) errors.push("--provider requires --model"); const listen = raw.listenValues.flatMap((value) => { - const result = parseExperimentalEndpoint(value, "--listen"); + const result = parseTransportAddress(value, "--listen"); if (result.error) errors.push(result.error); - return result.endpoint ? [result.endpoint] : []; + return result.address ? [result.address] : []; }); - const connectResult = raw.connectValue ? parseExperimentalEndpoint(raw.connectValue, "--connect") : undefined; + const connectResult = raw.connectValue ? parseTransportAddress(raw.connectValue, "--connect") : undefined; if (connectResult?.error) errors.push(connectResult.error); errors.push(...parseAuthInput(raw).errors); if (role === "client" && raw.listenValues.length > 0) { @@ -219,7 +219,7 @@ export function parseExperimentalCliOptions(argv: readonly string[]): Experiment options: { role, ...roleOptions, - ...(connectResult?.endpoint === undefined ? {} : { connect: connectResult.endpoint }), + ...(connectResult?.address === undefined ? {} : { connect: connectResult.address }), ...(auth === undefined ? {} : { auth }), }, }; diff --git a/packages/coding-agent/src/cli/experimental/transport-address.ts b/packages/coding-agent/src/cli/experimental/transport-address.ts new file mode 100644 index 000000000..3fc6150f9 --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/transport-address.ts @@ -0,0 +1,36 @@ +import { posix } from "node:path"; + +export interface UnixTransportAddress { + readonly transport: "unix"; + readonly path: string; +} + +export type TransportAddress = UnixTransportAddress; + +export function parseTransportAddress( + value: string, + option: "--listen" | "--connect", +): { address?: TransportAddress; error?: string } { + let url: URL; + try { + url = new URL(value); + } catch { + return { error: `Invalid ${option} address "${value}"` }; + } + if (url.protocol !== "unix:") { + return { error: `Unsupported ${option} transport "${url.protocol}"` }; + } + if (url.hostname || url.port || url.username || url.password) { + return { error: "Unix transport address must not include an authority" }; + } + let path: string; + try { + path = decodeURIComponent(url.pathname); + } catch { + return { error: `Invalid ${option} address "${value}"` }; + } + if (!posix.isAbsolute(path)) { + return { error: "Unix transport address requires an absolute path" }; + } + return { address: { transport: "unix", path } }; +} diff --git a/packages/coding-agent/test/experimental-cli-options.test.ts b/packages/coding-agent/test/experimental-cli-options.test.ts index cc02d060e..3ed0cfc02 100644 --- a/packages/coding-agent/test/experimental-cli-options.test.ts +++ b/packages/coding-agent/test/experimental-cli-options.test.ts @@ -34,7 +34,7 @@ describe("parseExperimentalCliOptions", () => { }); }); - test("parses a client connection endpoint", () => { + test("parses a client transport address", () => { const result = parseExperimentalCliOptions(["client", "--connect", "unix:///tmp/pi.sock"]); expect(result).toMatchObject({ ok: true, @@ -112,9 +112,9 @@ describe("parseExperimentalCliOptions", () => { [["server", "hello"], "An initial prompt is only valid for combined or client mode"], [["--provider", "anthropic"], "--provider requires --model"], [["--thinking", "extreme"], 'Invalid thinking level "extreme"'], - [["--listen", "/tmp/pi.sock"], 'Invalid --listen endpoint "/tmp/pi.sock"'], + [["--listen", "/tmp/pi.sock"], 'Invalid --listen address "/tmp/pi.sock"'], [["--listen", "ws://localhost:8080"], 'Unsupported --listen transport "ws:"'], - [["--listen", "unix://relative.sock"], "Unix endpoint must not include an authority"], + [["--listen", "unix://relative.sock"], "Unix transport address must not include an authority"], [["client", "--listen", "unix:///tmp/pi.sock"], "--listen is only valid for combined or server mode"], [["server", "--connect", "unix:///tmp/pi.sock"], "--connect is only valid for client mode"], [["client", "--connect", "ws://localhost:8080"], 'Unsupported --connect transport "ws:"'], From c95e877e2d0f08148536711ac913191acca8e1a7 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sat, 1 Aug 2026 13:17:51 +0300 Subject: [PATCH 4/9] refactor(coding-agent): keep thinking validation parser-local --- packages/coding-agent/src/cli/args.ts | 9 ++++++--- packages/coding-agent/src/cli/experimental/options.ts | 7 ++++++- packages/coding-agent/src/cli/thinking-level.ts | 7 ------- packages/coding-agent/src/core/model-resolver.ts | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) delete mode 100644 packages/coding-agent/src/cli/thinking-level.ts diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index abd14ff7f..3a1954213 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -7,9 +7,6 @@ import chalk from "chalk"; import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR } from "../config.ts"; import type { ExtensionFlag } from "../core/extensions/types.ts"; import type { UiMode } from "../core/settings-manager.ts"; -import { isValidThinkingLevel, VALID_THINKING_LEVELS } from "./thinking-level.ts"; - -export { isValidThinkingLevel } from "./thinking-level.ts"; export type Mode = "text" | "json" | "rpc"; @@ -59,6 +56,12 @@ export interface Args { diagnostics: Array<{ type: "warning" | "error"; message: string }>; } +const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; + +export function isValidThinkingLevel(level: string): level is ThinkingLevel { + return VALID_THINKING_LEVELS.includes(level as ThinkingLevel); +} + export function parseArgs(args: string[]): Args { const result: Args = { messages: [], diff --git a/packages/coding-agent/src/cli/experimental/options.ts b/packages/coding-agent/src/cli/experimental/options.ts index 9715f31c9..e3a73140b 100644 --- a/packages/coding-agent/src/cli/experimental/options.ts +++ b/packages/coding-agent/src/cli/experimental/options.ts @@ -1,5 +1,4 @@ import type { ThinkingLevel } from "@earendil-works/pi-protocol"; -import { isValidThinkingLevel, VALID_THINKING_LEVELS } from "../thinking-level.ts"; import { type AuthInput, parseAuthInput } from "./auth-options.ts"; import { type TransportAddress, parseTransportAddress } from "./transport-address.ts"; @@ -43,6 +42,12 @@ export type ExperimentalCliParseResult = | { readonly ok: true; readonly options: ExperimentalCliOptions } | { readonly ok: false; readonly errors: readonly string[] }; +const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; + +function isValidThinkingLevel(level: string): level is ThinkingLevel { + return VALID_THINKING_LEVELS.includes(level as ThinkingLevel); +} + const VALUE_OPTIONS = new Set([ "--listen", "--connect", diff --git a/packages/coding-agent/src/cli/thinking-level.ts b/packages/coding-agent/src/cli/thinking-level.ts deleted file mode 100644 index 737c36a87..000000000 --- a/packages/coding-agent/src/cli/thinking-level.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; - -export const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; - -export function isValidThinkingLevel(level: string): level is ThinkingLevel { - return (VALID_THINKING_LEVELS as readonly string[]).includes(level); -} diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 6403d567b..b5c21aa4c 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -6,7 +6,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@earendil-works/pi-ai"; import chalk from "chalk"; import { minimatch } from "minimatch"; -import { isValidThinkingLevel } from "../cli/thinking-level.ts"; +import { isValidThinkingLevel } from "../cli/args.ts"; import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; import type { ModelRuntime } from "./model-runtime.ts"; From 15fb81a6f3a60a808bf8187428d82136b1ba79d3 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sat, 1 Aug 2026 13:26:04 +0300 Subject: [PATCH 5/9] refactor(coding-agent): limit experimental parser to transport options --- .../src/cli/experimental/options.ts | 144 +++------------- .../test/experimental-cli-options.test.ts | 160 +++++++++--------- 2 files changed, 103 insertions(+), 201 deletions(-) diff --git a/packages/coding-agent/src/cli/experimental/options.ts b/packages/coding-agent/src/cli/experimental/options.ts index e3a73140b..b037e0886 100644 --- a/packages/coding-agent/src/cli/experimental/options.ts +++ b/packages/coding-agent/src/cli/experimental/options.ts @@ -1,36 +1,25 @@ -import type { ThinkingLevel } from "@earendil-works/pi-protocol"; import { type AuthInput, parseAuthInput } from "./auth-options.ts"; -import { type TransportAddress, parseTransportAddress } from "./transport-address.ts"; +import { parseTransportAddress, type TransportAddress } from "./transport-address.ts"; -interface CommonOptions { - readonly cwd?: string; - readonly provider?: string; - readonly model?: string; - readonly thinking?: ThinkingLevel; - readonly help: boolean; - readonly version: boolean; +interface ExperimentalOptionsBase { + readonly role: "combined" | "server" | "client"; + readonly auth?: AuthInput; + readonly remainingArgs: readonly string[]; } -export interface ExperimentalCombinedOptions extends CommonOptions { +export interface ExperimentalCombinedOptions extends ExperimentalOptionsBase { readonly role: "combined"; readonly listen?: readonly TransportAddress[]; - readonly auth?: AuthInput; - readonly sessionId?: string; - readonly initialPrompt?: string; } -export interface ExperimentalServerOptions extends CommonOptions { +export interface ExperimentalServerOptions extends ExperimentalOptionsBase { readonly role: "server"; readonly listen?: readonly TransportAddress[]; - readonly auth?: AuthInput; } -export interface ExperimentalClientOptions extends CommonOptions { +export interface ExperimentalClientOptions extends ExperimentalOptionsBase { readonly role: "client"; readonly connect?: TransportAddress; - readonly auth?: AuthInput; - readonly sessionId?: string; - readonly initialPrompt?: string; } export type ExperimentalCliOptions = @@ -42,37 +31,14 @@ export type ExperimentalCliParseResult = | { readonly ok: true; readonly options: ExperimentalCliOptions } | { readonly ok: false; readonly errors: readonly string[] }; -const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; +const VALUE_OPTIONS = new Set(["--listen", "--connect", "--auth-token", "--auth-token-file"]); -function isValidThinkingLevel(level: string): level is ThinkingLevel { - return VALID_THINKING_LEVELS.includes(level as ThinkingLevel); -} - -const VALUE_OPTIONS = new Set([ - "--listen", - "--connect", - "--auth-token", - "--auth-token-file", - "--session", - "--cwd", - "--provider", - "--model", - "--thinking", -]); - -interface RawOptions { +interface RawExperimentalOptions { authToken?: string; authTokenFile?: string; listenValues: string[]; connectValue?: string; - sessionId?: string; - cwd?: string; - provider?: string; - model?: string; - thinking?: ThinkingLevel; - help: boolean; - version: boolean; - positionals: string[]; + remainingArgs: string[]; } function splitOption(argument: string): { option: string; inlineValue?: string } { @@ -82,42 +48,20 @@ function splitOption(argument: string): { option: string; inlineValue?: string } : { option: argument.slice(0, equals), inlineValue: argument.slice(equals + 1) }; } -function parseRawOptions(argv: readonly string[]): { raw: RawOptions; errors: string[] } { - const raw: RawOptions = { listenValues: [], help: false, version: false, positionals: [] }; +function parseRawOptions(argv: readonly string[]): { raw: RawExperimentalOptions; errors: string[] } { + const raw: RawExperimentalOptions = { listenValues: [], remainingArgs: [] }; const errors: string[] = []; - let positionalsOnly = false; for (let index = 0; index < argv.length; index++) { const argument = argv[index]!; - if (positionalsOnly) { - raw.positionals.push(argument); - continue; - } if (argument === "--") { - positionalsOnly = true; - continue; - } - if (argument.startsWith("@")) { - errors.push("@file arguments are not supported in experimental mode"); - continue; - } - if (!argument.startsWith("-")) { - raw.positionals.push(argument); - continue; + raw.remainingArgs.push(...argv.slice(index)); + break; } const { option, inlineValue } = splitOption(argument); - if (option === "--help" || option === "-h" || option === "--version" || option === "-v") { - if (inlineValue !== undefined) { - errors.push(`${option} does not take a value`); - continue; - } - if (option === "--help" || option === "-h") raw.help = true; - else raw.version = true; - continue; - } if (!VALUE_OPTIONS.has(option)) { - errors.push(`Unknown option: ${option}`); + raw.remainingArgs.push(argument); continue; } @@ -148,22 +92,6 @@ function parseRawOptions(argv: readonly string[]): { raw: RawOptions; errors: st case "--auth-token-file": raw.authTokenFile = value; break; - case "--session": - raw.sessionId = value; - break; - case "--cwd": - raw.cwd = value; - break; - case "--provider": - raw.provider = value; - break; - case "--model": - raw.model = value; - break; - case "--thinking": - if (isValidThinkingLevel(value)) raw.thinking = value; - else errors.push(`Invalid thinking level "${value}". Valid values: ${VALID_THINKING_LEVELS.join(", ")}`); - break; } } return { raw, errors }; @@ -173,8 +101,6 @@ export function parseExperimentalCliOptions(argv: readonly string[]): Experiment const [candidate, ...rest] = argv; const role = candidate === "server" || candidate === "client" ? candidate : "combined"; const { raw, errors } = parseRawOptions(role === "combined" ? argv : rest); - - if (raw.provider !== undefined && raw.model === undefined) errors.push("--provider requires --model"); const listen = raw.listenValues.flatMap((value) => { const result = parseTransportAddress(value, "--listen"); if (result.error) errors.push(result.error); @@ -182,60 +108,34 @@ export function parseExperimentalCliOptions(argv: readonly string[]): Experiment }); const connectResult = raw.connectValue ? parseTransportAddress(raw.connectValue, "--connect") : undefined; if (connectResult?.error) errors.push(connectResult.error); - errors.push(...parseAuthInput(raw).errors); + const { auth, errors: authErrors } = parseAuthInput(raw); + errors.push(...authErrors); if (role === "client" && raw.listenValues.length > 0) { errors.push("--listen is only valid for combined or server mode"); } if (role !== "client" && raw.connectValue !== undefined) errors.push("--connect is only valid for client mode"); - if (role === "server") { - if (raw.sessionId !== undefined) errors.push("--session is only valid for combined or client mode"); - if (raw.positionals.length > 0) errors.push("An initial prompt is only valid for combined or client mode"); - } if (errors.length > 0) return { ok: false, errors }; const common = { - ...(raw.cwd === undefined ? {} : { cwd: raw.cwd }), - ...(raw.provider === undefined ? {} : { provider: raw.provider }), - ...(raw.model === undefined ? {} : { model: raw.model }), - ...(raw.thinking === undefined ? {} : { thinking: raw.thinking }), - help: raw.help, - version: raw.version, - }; - const { auth } = parseAuthInput(raw); - if (role === "server") { - return { - ok: true, - options: { - role, - ...common, - ...(listen.length === 0 ? {} : { listen }), - ...(auth === undefined ? {} : { auth }), - }, - }; - } - const roleOptions = { - ...common, - ...(raw.sessionId === undefined ? {} : { sessionId: raw.sessionId }), - ...(raw.positionals.length === 0 ? {} : { initialPrompt: raw.positionals.join(" ") }), + remainingArgs: raw.remainingArgs, + ...(auth === undefined ? {} : { auth }), }; if (role === "client") { return { ok: true, options: { + ...common, role, - ...roleOptions, ...(connectResult?.address === undefined ? {} : { connect: connectResult.address }), - ...(auth === undefined ? {} : { auth }), }, }; } return { ok: true, options: { + ...common, role, - ...roleOptions, ...(listen.length === 0 ? {} : { listen }), - ...(auth === undefined ? {} : { auth }), }, }; } diff --git a/packages/coding-agent/test/experimental-cli-options.test.ts b/packages/coding-agent/test/experimental-cli-options.test.ts index 3ed0cfc02..c299084fb 100644 --- a/packages/coding-agent/test/experimental-cli-options.test.ts +++ b/packages/coding-agent/test/experimental-cli-options.test.ts @@ -2,27 +2,47 @@ import { describe, expect, test } from "vitest"; import { parseExperimentalCliOptions } from "../src/cli/experimental/options.ts"; describe("parseExperimentalCliOptions", () => { - test("selects combined mode when no role is present", () => { - expect(parseExperimentalCliOptions(["--cwd", "/workspace", "inspect", "the project"])).toEqual({ + test("selects combined mode and preserves existing CLI arguments", () => { + expect( + parseExperimentalCliOptions([ + "--cwd", + "/workspace", + "--provider=anthropic", + "--model", + "claude-sonnet", + "--thinking=high", + "inspect", + "the project", + ]), + ).toEqual({ ok: true, options: { role: "combined", - cwd: "/workspace", - initialPrompt: "inspect the project", - help: false, - version: false, + remainingArgs: [ + "--cwd", + "/workspace", + "--provider=anthropic", + "--model", + "claude-sonnet", + "--thinking=high", + "inspect", + "the project", + ], }, }); }); - test("parses repeatable server listeners", () => { - const result = parseExperimentalCliOptions([ - "server", - "--listen", - "unix:///tmp/pi.sock", - "--listen=unix:///tmp/pi-admin.sock", - ]); - expect(result).toMatchObject({ + test("parses repeatable server listeners and preserves other arguments", () => { + expect( + parseExperimentalCliOptions([ + "server", + "--listen", + "unix:///tmp/pi.sock", + "--model", + "claude-sonnet", + "--listen=unix:///tmp/pi-admin.sock", + ]), + ).toEqual({ ok: true, options: { role: "server", @@ -30,35 +50,18 @@ describe("parseExperimentalCliOptions", () => { { transport: "unix", path: "/tmp/pi.sock" }, { transport: "unix", path: "/tmp/pi-admin.sock" }, ], + remainingArgs: ["--model", "claude-sonnet"], }, }); }); test("parses a client transport address", () => { - const result = parseExperimentalCliOptions(["client", "--connect", "unix:///tmp/pi.sock"]); - expect(result).toMatchObject({ - ok: true, - options: { role: "client", connect: { transport: "unix", path: "/tmp/pi.sock" } }, - }); - }); - - test("parses shared model options and inline values", () => { - const result = parseExperimentalCliOptions([ - "--provider=anthropic", - "--model", - "claude-sonnet", - "--thinking=high", - "--session", - "session-1", - ]); - expect(result).toMatchObject({ + expect(parseExperimentalCliOptions(["client", "--connect", "unix:///tmp/pi.sock", "hello"])).toEqual({ ok: true, options: { - role: "combined", - provider: "anthropic", - model: "claude-sonnet", - thinking: "high", - sessionId: "session-1", + role: "client", + connect: { transport: "unix", path: "/tmp/pi.sock" }, + remainingArgs: ["hello"], }, }); }); @@ -66,80 +69,79 @@ describe("parseExperimentalCliOptions", () => { test.each([ [["--auth-token", "secret"], { type: "token", token: "secret" }], [["--auth-token-file", "/tmp/token"], { type: "file", path: "/tmp/token" }], - ] as const)("parses server authentication source %j", (argv, auth) => { - const result = parseExperimentalCliOptions(argv); - expect(result).toMatchObject({ ok: true, options: { role: "combined", auth } }); - }); - - test("accepts token and token-file authentication in client mode", () => { - expect(parseExperimentalCliOptions(["client", "--auth-token", "secret"])).toMatchObject({ + ] as const)("parses authentication source %j", (argv, auth) => { + expect(parseExperimentalCliOptions(argv)).toEqual({ ok: true, - options: { role: "client", auth: { type: "token", token: "secret" } }, - }); - expect(parseExperimentalCliOptions(["client", "--auth-token-file", "/tmp/token"])).toMatchObject({ - ok: true, - options: { role: "client", auth: { type: "file", path: "/tmp/token" } }, - }); - }); - - test("supports help, version, and positional-only prompts", () => { - expect(parseExperimentalCliOptions(["-h", "-v", "--", "--review", "this"])).toEqual({ - ok: true, - options: { - role: "combined", - help: true, - version: true, - initialPrompt: "--review this", - }, + options: { role: "combined", auth, remainingArgs: [] }, }); }); test.each([[[]], [["server"]], [["client"]]] as const)( "permits omitted authentication for later environment/default resolution", (argv) => { - const result = parseExperimentalCliOptions(argv); - expect(result.ok).toBe(true); - if (result.ok) expect("auth" in result.options).toBe(false); + expect(parseExperimentalCliOptions(argv)).toEqual({ + ok: true, + options: { role: argv[0] ?? "combined", remainingArgs: [] }, + }); }, ); + test("preserves unknown options, @file arguments, and the positional separator", () => { + expect(parseExperimentalCliOptions(["--unknown", "@prompt.md", "--", "--listen", "unix:///tmp/pi.sock"])).toEqual( + { + ok: true, + options: { + role: "combined", + remainingArgs: ["--unknown", "@prompt.md", "--", "--listen", "unix:///tmp/pi.sock"], + }, + }, + ); + }); + test.each([ [ ["--auth-token", "secret", "--auth-token-file", "/tmp/token"], "--auth-token and --auth-token-file are mutually exclusive", ], - [["server", "--session", "session-1"], "--session is only valid for combined or client mode"], - [["server", "hello"], "An initial prompt is only valid for combined or client mode"], - [["--provider", "anthropic"], "--provider requires --model"], - [["--thinking", "extreme"], 'Invalid thinking level "extreme"'], [["--listen", "/tmp/pi.sock"], 'Invalid --listen address "/tmp/pi.sock"'], [["--listen", "ws://localhost:8080"], 'Unsupported --listen transport "ws:"'], [["--listen", "unix://relative.sock"], "Unix transport address must not include an authority"], [["client", "--listen", "unix:///tmp/pi.sock"], "--listen is only valid for combined or server mode"], [["server", "--connect", "unix:///tmp/pi.sock"], "--connect is only valid for client mode"], [["client", "--connect", "ws://localhost:8080"], 'Unsupported --connect transport "ws:"'], - [["--unknown"], "Unknown option: --unknown"], - [["-x"], "Unknown option: -x"], - [["--model"], "--model requires a value"], - [["--model="], "--model requires a value"], - [["@prompt.md"], "@file arguments are not supported in experimental mode"], - ] as const)("rejects invalid input %j", (argv, error) => { + [["--listen"], "--listen requires a value"], + [["--connect="], "--connect requires a value"], + ] as const)("rejects invalid experimental input %j", (argv, error) => { const result = parseExperimentalCliOptions(argv); expect(result).toMatchObject({ ok: false }); if (!result.ok) expect(result.errors).toContainEqual(expect.stringContaining(error)); }); - test("reports independent errors together", () => { - expect(parseExperimentalCliOptions(["server", "--provider", "anthropic", "hello"])).toEqual({ + test("reports independent experimental errors together", () => { + expect( + parseExperimentalCliOptions([ + "client", + "--listen", + "ws://localhost:8080", + "--auth-token", + "secret", + "--auth-token-file", + "/tmp/token", + ]), + ).toEqual({ ok: false, - errors: ["--provider requires --model", "An initial prompt is only valid for combined or client mode"], + errors: [ + 'Unsupported --listen transport "ws:"', + "--auth-token and --auth-token-file are mutually exclusive", + "--listen is only valid for combined or server mode", + ], }); }); - test("treats role names after the first argument as a combined-mode prompt", () => { - expect(parseExperimentalCliOptions(["--cwd", "/workspace", "server"])).toMatchObject({ + test("treats role names after the first argument as existing CLI arguments", () => { + expect(parseExperimentalCliOptions(["--cwd", "/workspace", "server"])).toEqual({ ok: true, - options: { role: "combined", initialPrompt: "server" }, + options: { role: "combined", remainingArgs: ["--cwd", "/workspace", "server"] }, }); }); }); From 68ad320af13d699279b690d3e3f6eb23a85b9c2b Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sat, 1 Aug 2026 14:31:28 +0300 Subject: [PATCH 6/9] refactor(coding-agent): model experimental CLI commands --- .../experimental/{auth-options.ts => auth.ts} | 0 .../experimental/{options.ts => command.ts} | 55 +++++++++---------- ...st.ts => experimental-cli-command.test.ts} | 54 +++++++++--------- 3 files changed, 53 insertions(+), 56 deletions(-) rename packages/coding-agent/src/cli/experimental/{auth-options.ts => auth.ts} (100%) rename packages/coding-agent/src/cli/experimental/{options.ts => command.ts} (66%) rename packages/coding-agent/test/{experimental-cli-options.test.ts => experimental-cli-command.test.ts} (71%) diff --git a/packages/coding-agent/src/cli/experimental/auth-options.ts b/packages/coding-agent/src/cli/experimental/auth.ts similarity index 100% rename from packages/coding-agent/src/cli/experimental/auth-options.ts rename to packages/coding-agent/src/cli/experimental/auth.ts diff --git a/packages/coding-agent/src/cli/experimental/options.ts b/packages/coding-agent/src/cli/experimental/command.ts similarity index 66% rename from packages/coding-agent/src/cli/experimental/options.ts rename to packages/coding-agent/src/cli/experimental/command.ts index b037e0886..7cabe3982 100644 --- a/packages/coding-agent/src/cli/experimental/options.ts +++ b/packages/coding-agent/src/cli/experimental/command.ts @@ -1,39 +1,36 @@ -import { type AuthInput, parseAuthInput } from "./auth-options.ts"; +import { type AuthInput, parseAuthInput } from "./auth.ts"; import { parseTransportAddress, type TransportAddress } from "./transport-address.ts"; -interface ExperimentalOptionsBase { - readonly role: "combined" | "server" | "client"; +interface CommandBase { + readonly command: "combined" | "server" | "client"; readonly auth?: AuthInput; readonly remainingArgs: readonly string[]; } -export interface ExperimentalCombinedOptions extends ExperimentalOptionsBase { - readonly role: "combined"; +export interface CombinedCommand extends CommandBase { + readonly command: "combined"; readonly listen?: readonly TransportAddress[]; } -export interface ExperimentalServerOptions extends ExperimentalOptionsBase { - readonly role: "server"; +export interface ServerCommand extends CommandBase { + readonly command: "server"; readonly listen?: readonly TransportAddress[]; } -export interface ExperimentalClientOptions extends ExperimentalOptionsBase { - readonly role: "client"; +export interface ClientCommand extends CommandBase { + readonly command: "client"; readonly connect?: TransportAddress; } -export type ExperimentalCliOptions = - | ExperimentalCombinedOptions - | ExperimentalServerOptions - | ExperimentalClientOptions; +export type CliCommand = CombinedCommand | ServerCommand | ClientCommand; -export type ExperimentalCliParseResult = - | { readonly ok: true; readonly options: ExperimentalCliOptions } +export type CliCommandParseResult = + | { readonly ok: true; readonly command: CliCommand } | { readonly ok: false; readonly errors: readonly string[] }; const VALUE_OPTIONS = new Set(["--listen", "--connect", "--auth-token", "--auth-token-file"]); -interface RawExperimentalOptions { +interface RawCommandOptions { authToken?: string; authTokenFile?: string; listenValues: string[]; @@ -48,8 +45,8 @@ function splitOption(argument: string): { option: string; inlineValue?: string } : { option: argument.slice(0, equals), inlineValue: argument.slice(equals + 1) }; } -function parseRawOptions(argv: readonly string[]): { raw: RawExperimentalOptions; errors: string[] } { - const raw: RawExperimentalOptions = { listenValues: [], remainingArgs: [] }; +function parseRawOptions(argv: readonly string[]): { raw: RawCommandOptions; errors: string[] } { + const raw: RawCommandOptions = { listenValues: [], remainingArgs: [] }; const errors: string[] = []; for (let index = 0; index < argv.length; index++) { @@ -97,10 +94,10 @@ function parseRawOptions(argv: readonly string[]): { raw: RawExperimentalOptions return { raw, errors }; } -export function parseExperimentalCliOptions(argv: readonly string[]): ExperimentalCliParseResult { +export function parseCliCommand(argv: readonly string[]): CliCommandParseResult { const [candidate, ...rest] = argv; - const role = candidate === "server" || candidate === "client" ? candidate : "combined"; - const { raw, errors } = parseRawOptions(role === "combined" ? argv : rest); + const commandName = candidate === "server" || candidate === "client" ? candidate : "combined"; + const { raw, errors } = parseRawOptions(commandName === "combined" ? argv : rest); const listen = raw.listenValues.flatMap((value) => { const result = parseTransportAddress(value, "--listen"); if (result.error) errors.push(result.error); @@ -110,31 +107,33 @@ export function parseExperimentalCliOptions(argv: readonly string[]): Experiment if (connectResult?.error) errors.push(connectResult.error); const { auth, errors: authErrors } = parseAuthInput(raw); errors.push(...authErrors); - if (role === "client" && raw.listenValues.length > 0) { + if (commandName === "client" && raw.listenValues.length > 0) { errors.push("--listen is only valid for combined or server mode"); } - if (role !== "client" && raw.connectValue !== undefined) errors.push("--connect is only valid for client mode"); + if (commandName !== "client" && raw.connectValue !== undefined) { + errors.push("--connect is only valid for client mode"); + } if (errors.length > 0) return { ok: false, errors }; const common = { remainingArgs: raw.remainingArgs, ...(auth === undefined ? {} : { auth }), }; - if (role === "client") { + if (commandName === "client") { return { ok: true, - options: { + command: { ...common, - role, + command: commandName, ...(connectResult?.address === undefined ? {} : { connect: connectResult.address }), }, }; } return { ok: true, - options: { + command: { ...common, - role, + command: commandName, ...(listen.length === 0 ? {} : { listen }), }, }; diff --git a/packages/coding-agent/test/experimental-cli-options.test.ts b/packages/coding-agent/test/experimental-cli-command.test.ts similarity index 71% rename from packages/coding-agent/test/experimental-cli-options.test.ts rename to packages/coding-agent/test/experimental-cli-command.test.ts index c299084fb..027ab294a 100644 --- a/packages/coding-agent/test/experimental-cli-options.test.ts +++ b/packages/coding-agent/test/experimental-cli-command.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "vitest"; -import { parseExperimentalCliOptions } from "../src/cli/experimental/options.ts"; +import { parseCliCommand } from "../src/cli/experimental/command.ts"; -describe("parseExperimentalCliOptions", () => { +describe("parseCliCommand", () => { test("selects combined mode and preserves existing CLI arguments", () => { expect( - parseExperimentalCliOptions([ + parseCliCommand([ "--cwd", "/workspace", "--provider=anthropic", @@ -16,8 +16,8 @@ describe("parseExperimentalCliOptions", () => { ]), ).toEqual({ ok: true, - options: { - role: "combined", + command: { + command: "combined", remainingArgs: [ "--cwd", "/workspace", @@ -34,7 +34,7 @@ describe("parseExperimentalCliOptions", () => { test("parses repeatable server listeners and preserves other arguments", () => { expect( - parseExperimentalCliOptions([ + parseCliCommand([ "server", "--listen", "unix:///tmp/pi.sock", @@ -44,8 +44,8 @@ describe("parseExperimentalCliOptions", () => { ]), ).toEqual({ ok: true, - options: { - role: "server", + command: { + command: "server", listen: [ { transport: "unix", path: "/tmp/pi.sock" }, { transport: "unix", path: "/tmp/pi-admin.sock" }, @@ -56,10 +56,10 @@ describe("parseExperimentalCliOptions", () => { }); test("parses a client transport address", () => { - expect(parseExperimentalCliOptions(["client", "--connect", "unix:///tmp/pi.sock", "hello"])).toEqual({ + expect(parseCliCommand(["client", "--connect", "unix:///tmp/pi.sock", "hello"])).toEqual({ ok: true, - options: { - role: "client", + command: { + command: "client", connect: { transport: "unix", path: "/tmp/pi.sock" }, remainingArgs: ["hello"], }, @@ -70,32 +70,30 @@ describe("parseExperimentalCliOptions", () => { [["--auth-token", "secret"], { type: "token", token: "secret" }], [["--auth-token-file", "/tmp/token"], { type: "file", path: "/tmp/token" }], ] as const)("parses authentication source %j", (argv, auth) => { - expect(parseExperimentalCliOptions(argv)).toEqual({ + expect(parseCliCommand(argv)).toEqual({ ok: true, - options: { role: "combined", auth, remainingArgs: [] }, + command: { command: "combined", auth, remainingArgs: [] }, }); }); test.each([[[]], [["server"]], [["client"]]] as const)( "permits omitted authentication for later environment/default resolution", (argv) => { - expect(parseExperimentalCliOptions(argv)).toEqual({ + expect(parseCliCommand(argv)).toEqual({ ok: true, - options: { role: argv[0] ?? "combined", remainingArgs: [] }, + command: { command: argv[0] ?? "combined", remainingArgs: [] }, }); }, ); test("preserves unknown options, @file arguments, and the positional separator", () => { - expect(parseExperimentalCliOptions(["--unknown", "@prompt.md", "--", "--listen", "unix:///tmp/pi.sock"])).toEqual( - { - ok: true, - options: { - role: "combined", - remainingArgs: ["--unknown", "@prompt.md", "--", "--listen", "unix:///tmp/pi.sock"], - }, + expect(parseCliCommand(["--unknown", "@prompt.md", "--", "--listen", "unix:///tmp/pi.sock"])).toEqual({ + ok: true, + command: { + command: "combined", + remainingArgs: ["--unknown", "@prompt.md", "--", "--listen", "unix:///tmp/pi.sock"], }, - ); + }); }); test.each([ @@ -112,14 +110,14 @@ describe("parseExperimentalCliOptions", () => { [["--listen"], "--listen requires a value"], [["--connect="], "--connect requires a value"], ] as const)("rejects invalid experimental input %j", (argv, error) => { - const result = parseExperimentalCliOptions(argv); + const result = parseCliCommand(argv); expect(result).toMatchObject({ ok: false }); if (!result.ok) expect(result.errors).toContainEqual(expect.stringContaining(error)); }); test("reports independent experimental errors together", () => { expect( - parseExperimentalCliOptions([ + parseCliCommand([ "client", "--listen", "ws://localhost:8080", @@ -138,10 +136,10 @@ describe("parseExperimentalCliOptions", () => { }); }); - test("treats role names after the first argument as existing CLI arguments", () => { - expect(parseExperimentalCliOptions(["--cwd", "/workspace", "server"])).toEqual({ + test("treats command names after the first argument as existing CLI arguments", () => { + expect(parseCliCommand(["--cwd", "/workspace", "server"])).toEqual({ ok: true, - options: { role: "combined", remainingArgs: ["--cwd", "/workspace", "server"] }, + command: { command: "combined", remainingArgs: ["--cwd", "/workspace", "server"] }, }); }); }); From c2923b641c73aef4d5d484b9544922d05028b962 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sat, 1 Aug 2026 20:52:01 +0300 Subject: [PATCH 7/9] refactor(coding-agent): simplify experimental command names --- .../src/cli/experimental/command.ts | 8 +++---- .../test/experimental-cli-command.test.ts | 22 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/cli/experimental/command.ts b/packages/coding-agent/src/cli/experimental/command.ts index 7cabe3982..63feca782 100644 --- a/packages/coding-agent/src/cli/experimental/command.ts +++ b/packages/coding-agent/src/cli/experimental/command.ts @@ -22,10 +22,10 @@ export interface ClientCommand extends CommandBase { readonly connect?: TransportAddress; } -export type CliCommand = CombinedCommand | ServerCommand | ClientCommand; +export type Command = CombinedCommand | ServerCommand | ClientCommand; -export type CliCommandParseResult = - | { readonly ok: true; readonly command: CliCommand } +export type CommandParseResult = + | { readonly ok: true; readonly command: Command } | { readonly ok: false; readonly errors: readonly string[] }; const VALUE_OPTIONS = new Set(["--listen", "--connect", "--auth-token", "--auth-token-file"]); @@ -94,7 +94,7 @@ function parseRawOptions(argv: readonly string[]): { raw: RawCommandOptions; err return { raw, errors }; } -export function parseCliCommand(argv: readonly string[]): CliCommandParseResult { +export function parseCommand(argv: readonly string[]): CommandParseResult { const [candidate, ...rest] = argv; const commandName = candidate === "server" || candidate === "client" ? candidate : "combined"; const { raw, errors } = parseRawOptions(commandName === "combined" ? argv : rest); diff --git a/packages/coding-agent/test/experimental-cli-command.test.ts b/packages/coding-agent/test/experimental-cli-command.test.ts index 027ab294a..f1676ac09 100644 --- a/packages/coding-agent/test/experimental-cli-command.test.ts +++ b/packages/coding-agent/test/experimental-cli-command.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "vitest"; -import { parseCliCommand } from "../src/cli/experimental/command.ts"; +import { parseCommand } from "../src/cli/experimental/command.ts"; -describe("parseCliCommand", () => { +describe("parseCommand", () => { test("selects combined mode and preserves existing CLI arguments", () => { expect( - parseCliCommand([ + parseCommand([ "--cwd", "/workspace", "--provider=anthropic", @@ -34,7 +34,7 @@ describe("parseCliCommand", () => { test("parses repeatable server listeners and preserves other arguments", () => { expect( - parseCliCommand([ + parseCommand([ "server", "--listen", "unix:///tmp/pi.sock", @@ -56,7 +56,7 @@ describe("parseCliCommand", () => { }); test("parses a client transport address", () => { - expect(parseCliCommand(["client", "--connect", "unix:///tmp/pi.sock", "hello"])).toEqual({ + expect(parseCommand(["client", "--connect", "unix:///tmp/pi.sock", "hello"])).toEqual({ ok: true, command: { command: "client", @@ -70,7 +70,7 @@ describe("parseCliCommand", () => { [["--auth-token", "secret"], { type: "token", token: "secret" }], [["--auth-token-file", "/tmp/token"], { type: "file", path: "/tmp/token" }], ] as const)("parses authentication source %j", (argv, auth) => { - expect(parseCliCommand(argv)).toEqual({ + expect(parseCommand(argv)).toEqual({ ok: true, command: { command: "combined", auth, remainingArgs: [] }, }); @@ -79,7 +79,7 @@ describe("parseCliCommand", () => { test.each([[[]], [["server"]], [["client"]]] as const)( "permits omitted authentication for later environment/default resolution", (argv) => { - expect(parseCliCommand(argv)).toEqual({ + expect(parseCommand(argv)).toEqual({ ok: true, command: { command: argv[0] ?? "combined", remainingArgs: [] }, }); @@ -87,7 +87,7 @@ describe("parseCliCommand", () => { ); test("preserves unknown options, @file arguments, and the positional separator", () => { - expect(parseCliCommand(["--unknown", "@prompt.md", "--", "--listen", "unix:///tmp/pi.sock"])).toEqual({ + expect(parseCommand(["--unknown", "@prompt.md", "--", "--listen", "unix:///tmp/pi.sock"])).toEqual({ ok: true, command: { command: "combined", @@ -110,14 +110,14 @@ describe("parseCliCommand", () => { [["--listen"], "--listen requires a value"], [["--connect="], "--connect requires a value"], ] as const)("rejects invalid experimental input %j", (argv, error) => { - const result = parseCliCommand(argv); + const result = parseCommand(argv); expect(result).toMatchObject({ ok: false }); if (!result.ok) expect(result.errors).toContainEqual(expect.stringContaining(error)); }); test("reports independent experimental errors together", () => { expect( - parseCliCommand([ + parseCommand([ "client", "--listen", "ws://localhost:8080", @@ -137,7 +137,7 @@ describe("parseCliCommand", () => { }); test("treats command names after the first argument as existing CLI arguments", () => { - expect(parseCliCommand(["--cwd", "/workspace", "server"])).toEqual({ + expect(parseCommand(["--cwd", "/workspace", "server"])).toEqual({ ok: true, command: { command: "combined", remainingArgs: ["--cwd", "/workspace", "server"] }, }); From b0944d75cfe2d797c41ed532fa1dade05167cda4 Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sat, 1 Aug 2026 20:59:09 +0300 Subject: [PATCH 8/9] fix(coding-agent): validate Unix transport URLs --- .../src/cli/experimental/transport-address.ts | 9 +++++++++ .../coding-agent/test/experimental-cli-command.test.ts | 3 +++ 2 files changed, 12 insertions(+) diff --git a/packages/coding-agent/src/cli/experimental/transport-address.ts b/packages/coding-agent/src/cli/experimental/transport-address.ts index 3fc6150f9..8217b3dfd 100644 --- a/packages/coding-agent/src/cli/experimental/transport-address.ts +++ b/packages/coding-agent/src/cli/experimental/transport-address.ts @@ -23,6 +23,15 @@ export function parseTransportAddress( if (url.hostname || url.port || url.username || url.password) { return { error: "Unix transport address must not include an authority" }; } + if ( + !value.startsWith("unix:///") || + value.startsWith("unix:////") || + value.includes("?") || + value.includes("#") || + url.href !== value + ) { + return { error: `Invalid ${option} address "${value}"` }; + } let path: string; try { path = decodeURIComponent(url.pathname); diff --git a/packages/coding-agent/test/experimental-cli-command.test.ts b/packages/coding-agent/test/experimental-cli-command.test.ts index f1676ac09..0cbf348ef 100644 --- a/packages/coding-agent/test/experimental-cli-command.test.ts +++ b/packages/coding-agent/test/experimental-cli-command.test.ts @@ -104,6 +104,9 @@ describe("parseCommand", () => { [["--listen", "/tmp/pi.sock"], 'Invalid --listen address "/tmp/pi.sock"'], [["--listen", "ws://localhost:8080"], 'Unsupported --listen transport "ws:"'], [["--listen", "unix://relative.sock"], "Unix transport address must not include an authority"], + [["--listen", "unix:///tmp/pi.sock?wrong=value"], 'Invalid --listen address "unix:///tmp/pi.sock?wrong=value"'], + [["--listen", "unix:///tmp/pi.sock#fragment"], 'Invalid --listen address "unix:///tmp/pi.sock#fragment"'], + [["--listen", "unix:/tmp/pi.sock"], 'Invalid --listen address "unix:/tmp/pi.sock"'], [["client", "--listen", "unix:///tmp/pi.sock"], "--listen is only valid for combined or server mode"], [["server", "--connect", "unix:///tmp/pi.sock"], "--connect is only valid for client mode"], [["client", "--connect", "ws://localhost:8080"], 'Unsupported --connect transport "ws:"'], From 15b6617fac803d062195b90bff0d3013020ffbbf Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sat, 1 Aug 2026 21:53:55 +0300 Subject: [PATCH 9/9] fix(coding-agent): preserve existing CLI option values --- .../src/cli/experimental/command.ts | 10 +++-- .../src/cli/experimental/transport-address.ts | 3 ++ .../test/experimental-cli-command.test.ts | 38 ++++++++++++++++++- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/cli/experimental/command.ts b/packages/coding-agent/src/cli/experimental/command.ts index 63feca782..9499daf4a 100644 --- a/packages/coding-agent/src/cli/experimental/command.ts +++ b/packages/coding-agent/src/cli/experimental/command.ts @@ -58,8 +58,8 @@ function parseRawOptions(argv: readonly string[]): { raw: RawCommandOptions; err const { option, inlineValue } = splitOption(argument); if (!VALUE_OPTIONS.has(option)) { - raw.remainingArgs.push(argument); - continue; + raw.remainingArgs.push(...argv.slice(index)); + break; } let value = inlineValue; @@ -84,10 +84,12 @@ function parseRawOptions(argv: readonly string[]): { raw: RawCommandOptions; err raw.connectValue = value; break; case "--auth-token": - raw.authToken = value; + if (raw.authToken !== undefined) errors.push("--auth-token may only be specified once"); + else raw.authToken = value; break; case "--auth-token-file": - raw.authTokenFile = value; + if (raw.authTokenFile !== undefined) errors.push("--auth-token-file may only be specified once"); + else raw.authTokenFile = value; break; } } diff --git a/packages/coding-agent/src/cli/experimental/transport-address.ts b/packages/coding-agent/src/cli/experimental/transport-address.ts index 8217b3dfd..9d4afacc1 100644 --- a/packages/coding-agent/src/cli/experimental/transport-address.ts +++ b/packages/coding-agent/src/cli/experimental/transport-address.ts @@ -38,6 +38,9 @@ export function parseTransportAddress( } catch { return { error: `Invalid ${option} address "${value}"` }; } + if (path.includes("\0")) { + return { error: `Invalid ${option} address "${value}"` }; + } if (!posix.isAbsolute(path)) { return { error: "Unix transport address requires an absolute path" }; } diff --git a/packages/coding-agent/test/experimental-cli-command.test.ts b/packages/coding-agent/test/experimental-cli-command.test.ts index 0cbf348ef..b211447d6 100644 --- a/packages/coding-agent/test/experimental-cli-command.test.ts +++ b/packages/coding-agent/test/experimental-cli-command.test.ts @@ -38,9 +38,9 @@ describe("parseCommand", () => { "server", "--listen", "unix:///tmp/pi.sock", + "--listen=unix:///tmp/pi-admin.sock", "--model", "claude-sonnet", - "--listen=unix:///tmp/pi-admin.sock", ]), ).toEqual({ ok: true, @@ -55,6 +55,36 @@ describe("parseCommand", () => { }); }); + test("preserves existing option values that look like experimental options", () => { + expect(parseCommand(["--system-prompt", "--listen", "unix:///tmp/pi.sock"])).toEqual({ + ok: true, + command: { + command: "combined", + remainingArgs: ["--system-prompt", "--listen", "unix:///tmp/pi.sock"], + }, + }); + }); + + test("preserves experimental-looking arguments after the existing option prefix begins", () => { + expect( + parseCommand([ + "server", + "--listen", + "unix:///tmp/pi.sock", + "--model", + "claude-sonnet", + "--listen=unix:///tmp/second.sock", + ]), + ).toEqual({ + ok: true, + command: { + command: "server", + listen: [{ transport: "unix", path: "/tmp/pi.sock" }], + remainingArgs: ["--model", "claude-sonnet", "--listen=unix:///tmp/second.sock"], + }, + }); + }); + test("parses a client transport address", () => { expect(parseCommand(["client", "--connect", "unix:///tmp/pi.sock", "hello"])).toEqual({ ok: true, @@ -101,12 +131,18 @@ describe("parseCommand", () => { ["--auth-token", "secret", "--auth-token-file", "/tmp/token"], "--auth-token and --auth-token-file are mutually exclusive", ], + [["--auth-token", "first", "--auth-token", "second"], "--auth-token may only be specified once"], + [ + ["--auth-token-file", "/tmp/first", "--auth-token-file=/tmp/second"], + "--auth-token-file may only be specified once", + ], [["--listen", "/tmp/pi.sock"], 'Invalid --listen address "/tmp/pi.sock"'], [["--listen", "ws://localhost:8080"], 'Unsupported --listen transport "ws:"'], [["--listen", "unix://relative.sock"], "Unix transport address must not include an authority"], [["--listen", "unix:///tmp/pi.sock?wrong=value"], 'Invalid --listen address "unix:///tmp/pi.sock?wrong=value"'], [["--listen", "unix:///tmp/pi.sock#fragment"], 'Invalid --listen address "unix:///tmp/pi.sock#fragment"'], [["--listen", "unix:/tmp/pi.sock"], 'Invalid --listen address "unix:/tmp/pi.sock"'], + [["--listen", "unix:///tmp/%00pi.sock"], 'Invalid --listen address "unix:///tmp/%00pi.sock"'], [["client", "--listen", "unix:///tmp/pi.sock"], "--listen is only valid for combined or server mode"], [["server", "--connect", "unix:///tmp/pi.sock"], "--connect is only valid for client mode"], [["client", "--connect", "ws://localhost:8080"], 'Unsupported --connect transport "ws:"'],