diff --git a/packages/coding-agent/src/cli/experimental/auth.ts b/packages/coding-agent/src/cli/experimental/auth.ts new file mode 100644 index 000000000..acfb6ab25 --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/auth.ts @@ -0,0 +1,21 @@ +export type AuthInput = + | { readonly type: "token"; readonly token: string } + | { readonly type: "file"; readonly path: string }; + +export interface RawAuthOptions { + readonly authToken?: string; + readonly authTokenFile?: string; +} + +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: [] }; + } + if (options.authTokenFile !== undefined) { + return { auth: { type: "file", path: options.authTokenFile }, errors: [] }; + } + return { errors: [] }; +} diff --git a/packages/coding-agent/src/cli/experimental/command.ts b/packages/coding-agent/src/cli/experimental/command.ts new file mode 100644 index 000000000..9499daf4a --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/command.ts @@ -0,0 +1,142 @@ +import { type AuthInput, parseAuthInput } from "./auth.ts"; +import { parseTransportAddress, type TransportAddress } from "./transport-address.ts"; + +interface CommandBase { + readonly command: "combined" | "server" | "client"; + readonly auth?: AuthInput; + readonly remainingArgs: readonly string[]; +} + +export interface CombinedCommand extends CommandBase { + readonly command: "combined"; + readonly listen?: readonly TransportAddress[]; +} + +export interface ServerCommand extends CommandBase { + readonly command: "server"; + readonly listen?: readonly TransportAddress[]; +} + +export interface ClientCommand extends CommandBase { + readonly command: "client"; + readonly connect?: TransportAddress; +} + +export type Command = CombinedCommand | ServerCommand | ClientCommand; + +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"]); + +interface RawCommandOptions { + authToken?: string; + authTokenFile?: string; + listenValues: string[]; + connectValue?: string; + remainingArgs: 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: RawCommandOptions; errors: string[] } { + const raw: RawCommandOptions = { listenValues: [], remainingArgs: [] }; + const errors: string[] = []; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]!; + if (argument === "--") { + raw.remainingArgs.push(...argv.slice(index)); + break; + } + + const { option, inlineValue } = splitOption(argument); + if (!VALUE_OPTIONS.has(option)) { + raw.remainingArgs.push(...argv.slice(index)); + break; + } + + 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 "--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": + if (raw.authToken !== undefined) errors.push("--auth-token may only be specified once"); + else raw.authToken = value; + break; + case "--auth-token-file": + if (raw.authTokenFile !== undefined) errors.push("--auth-token-file may only be specified once"); + else raw.authTokenFile = value; + break; + } + } + return { raw, errors }; +} + +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); + const listen = raw.listenValues.flatMap((value) => { + const result = parseTransportAddress(value, "--listen"); + if (result.error) errors.push(result.error); + return result.address ? [result.address] : []; + }); + const connectResult = raw.connectValue ? parseTransportAddress(raw.connectValue, "--connect") : undefined; + if (connectResult?.error) errors.push(connectResult.error); + const { auth, errors: authErrors } = parseAuthInput(raw); + errors.push(...authErrors); + if (commandName === "client" && raw.listenValues.length > 0) { + errors.push("--listen is only valid for combined or server 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 (commandName === "client") { + return { + ok: true, + command: { + ...common, + command: commandName, + ...(connectResult?.address === undefined ? {} : { connect: connectResult.address }), + }, + }; + } + return { + ok: true, + command: { + ...common, + command: commandName, + ...(listen.length === 0 ? {} : { listen }), + }, + }; +} 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..9d4afacc1 --- /dev/null +++ b/packages/coding-agent/src/cli/experimental/transport-address.ts @@ -0,0 +1,48 @@ +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" }; + } + 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); + } 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" }; + } + return { address: { transport: "unix", path } }; +} diff --git a/packages/coding-agent/test/experimental-cli-command.test.ts b/packages/coding-agent/test/experimental-cli-command.test.ts new file mode 100644 index 000000000..b211447d6 --- /dev/null +++ b/packages/coding-agent/test/experimental-cli-command.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "vitest"; +import { parseCommand } from "../src/cli/experimental/command.ts"; + +describe("parseCommand", () => { + test("selects combined mode and preserves existing CLI arguments", () => { + expect( + parseCommand([ + "--cwd", + "/workspace", + "--provider=anthropic", + "--model", + "claude-sonnet", + "--thinking=high", + "inspect", + "the project", + ]), + ).toEqual({ + ok: true, + command: { + command: "combined", + remainingArgs: [ + "--cwd", + "/workspace", + "--provider=anthropic", + "--model", + "claude-sonnet", + "--thinking=high", + "inspect", + "the project", + ], + }, + }); + }); + + test("parses repeatable server listeners and preserves other arguments", () => { + expect( + parseCommand([ + "server", + "--listen", + "unix:///tmp/pi.sock", + "--listen=unix:///tmp/pi-admin.sock", + "--model", + "claude-sonnet", + ]), + ).toEqual({ + ok: true, + command: { + command: "server", + listen: [ + { transport: "unix", path: "/tmp/pi.sock" }, + { transport: "unix", path: "/tmp/pi-admin.sock" }, + ], + remainingArgs: ["--model", "claude-sonnet"], + }, + }); + }); + + 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, + command: { + command: "client", + connect: { transport: "unix", path: "/tmp/pi.sock" }, + remainingArgs: ["hello"], + }, + }); + }); + + test.each([ + [["--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(parseCommand(argv)).toEqual({ + ok: true, + command: { command: "combined", auth, remainingArgs: [] }, + }); + }); + + test.each([[[]], [["server"]], [["client"]]] as const)( + "permits omitted authentication for later environment/default resolution", + (argv) => { + expect(parseCommand(argv)).toEqual({ + ok: true, + command: { command: argv[0] ?? "combined", remainingArgs: [] }, + }); + }, + ); + + test("preserves unknown options, @file arguments, and the positional separator", () => { + expect(parseCommand(["--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([ + [ + ["--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:"'], + [["--listen"], "--listen requires a value"], + [["--connect="], "--connect requires a value"], + ] as const)("rejects invalid experimental input %j", (argv, error) => { + 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( + parseCommand([ + "client", + "--listen", + "ws://localhost:8080", + "--auth-token", + "secret", + "--auth-token-file", + "/tmp/token", + ]), + ).toEqual({ + ok: false, + 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 command names after the first argument as existing CLI arguments", () => { + expect(parseCommand(["--cwd", "/workspace", "server"])).toEqual({ + ok: true, + command: { command: "combined", remainingArgs: ["--cwd", "/workspace", "server"] }, + }); + }); +});