mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge pull request #7411 from earendil-works/feat/experimental-cli-parser
feat(coding-agent): add experimental CLI option parser
This commit is contained in:
commit
aa0ec808b9
4 changed files with 395 additions and 0 deletions
21
packages/coding-agent/src/cli/experimental/auth.ts
Normal file
21
packages/coding-agent/src/cli/experimental/auth.ts
Normal file
|
|
@ -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: [] };
|
||||
}
|
||||
142
packages/coding-agent/src/cli/experimental/command.ts
Normal file
142
packages/coding-agent/src/cli/experimental/command.ts
Normal file
|
|
@ -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 }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 } };
|
||||
}
|
||||
184
packages/coding-agent/test/experimental-cli-command.test.ts
Normal file
184
packages/coding-agent/test/experimental-cli-command.test.ts
Normal file
|
|
@ -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"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue