Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
],
"dependencies": {
"bun": "^1.4.2",
"commander": "^15.0.0",
"smol-toml": "^1.8.0",
"zod": "^4.5.4"
}
}
96 changes: 43 additions & 53 deletions src/cli/eval.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Command } from "commander";
import { engineIds } from "../config";
import type { EngineId, EngineRunner } from "../engine";
import { runEval } from "../eval";
Expand All @@ -18,63 +19,52 @@ export async function evalCommand(
readonly paths?: ProjectPaths;
} = {},
): Promise<void> {
let sessions: number | undefined;
let since: string | undefined;
let json = false;
let maxBudgetUsd: number | undefined;
let engine: EngineId | undefined;
let yes = false;
const program = new Command()
.exitOverride()
.configureOutput({ writeErr: () => {} })
.option("--sessions <number>")
.option("--since <date>")
.option("--engine <id>")
.option("--max-budget-usd <number>")
.option("-y, --yes")
.option("--json");

program.parse([...arguments_], { from: "user" });

for (
let argumentIndex = 0;
argumentIndex < arguments_.length;
argumentIndex++
) {
const argument = arguments_[argumentIndex];
if (argument === "--json") {
json = true;
} else if (argument === "--yes" || argument === "-y") {
yes = true;
} else if (
argument === "--sessions" &&
argumentIndex + 1 < arguments_.length
) {
argumentIndex++;
const parsed = Number.parseInt(arguments_[argumentIndex] ?? "", 10);
if (!Number.isNaN(parsed)) {
sessions = parsed;
}
} else if (
argument === "--since" &&
argumentIndex + 1 < arguments_.length
) {
argumentIndex++;
since = arguments_[argumentIndex];
} else if (
argument === "--engine" &&
argumentIndex + 1 < arguments_.length
) {
argumentIndex++;
const requested = arguments_[argumentIndex] ?? "";
const known = engineIds.find((candidate) => candidate === requested);
if (!known) {
throw new Error(
`Unknown engine "${requested}". Known engines: ${engineIds.join(", ")}`,
);
}
engine = known;
} else if (
argument === "--max-budget-usd" &&
argumentIndex + 1 < arguments_.length
) {
argumentIndex++;
const parsed = Number.parseFloat(arguments_[argumentIndex] ?? "");
if (!Number.isNaN(parsed)) {
maxBudgetUsd = parsed;
}
const parsedOptions = program.opts<{
readonly sessions?: string;
readonly since?: string;
readonly engine?: string;
readonly maxBudgetUsd?: string;
readonly yes?: boolean;
readonly json?: boolean;
}>();

let engine: EngineId | undefined;
if (parsedOptions.engine !== undefined) {
const known = engineIds.find(
(candidate) => candidate === parsedOptions.engine,
);
if (!known) {
throw new Error(
`Unknown engine "${parsedOptions.engine}". Known engines: ${engineIds.join(", ")}`,
);
}
engine = known;
}

const sessions = parsedOptions.sessions
? Number.parseInt(parsedOptions.sessions, 10)
: undefined;

const maxBudgetUsd = parsedOptions.maxBudgetUsd
? Number.parseFloat(parsedOptions.maxBudgetUsd)
: undefined;

const json = parsedOptions.json ?? false;
const yes = parsedOptions.yes ?? false;
const since = parsedOptions.since;

const sessionLimit = sessions ?? 10;
const budgetLimit = maxBudgetUsd ?? 0.5;
const upperCostUsd = sessionLimit * 2 * budgetLimit;
Expand Down
22 changes: 14 additions & 8 deletions src/cli/hookInput.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
export function isRecord(
value: unknown,
): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
import { z } from "zod";

const hookRecordSchema = z.record(z.string(), z.unknown());

export function parseHookInput(
input: string,
): Readonly<Record<string, unknown>> {
const value: unknown = JSON.parse(input);
if (!isRecord(value)) {
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch {
throw new Error("Hook input must be a JSON object");
}

const result = hookRecordSchema.safeParse(parsed);
if (!result.success) {
throw new Error("Hook input must be a JSON object");
}
return value;

return result.data;
}

export function readHookString(
Expand Down
14 changes: 14 additions & 0 deletions src/cli/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,17 @@ test("rejects an unnamed or unsupported action approval", () => {
parseRunArguments(["fix the test", "--approve", "merge"])
).toThrow("supported action");
});

test("keeps a task word that starts with a dash", () => {
expect(
parseRunArguments(["remove", "the", "--deprecated", "flag", "--approve", "push"]),
).toEqual({
task: "remove the --deprecated flag",
approvedActions: ["push"],
});
});

test("rejects an empty task by name", () => {
expect(() => parseRunArguments([])).toThrow("Run requires a task");
expect(() => parseRunArguments([" "])).toThrow("Run requires a task");
});
61 changes: 39 additions & 22 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,57 @@
import { Command } from "commander";
import {
actionCapabilities,
type ActionCapability,
} from "../config";
import { runHeadlessClone } from "../dispatch";

function collectApprovals(
value: string,
previous: readonly ActionCapability[],
): readonly ActionCapability[] {
const action = actionCapabilities.find(
(candidate) => candidate === value,
);
if (!action) {
throw new Error("Run approval must name a supported action");
}

return [...previous, action];
}

export function parseRunArguments(arguments_: readonly string[]): {
readonly task: string;
readonly approvedActions: readonly ActionCapability[];
} {
const taskParts: string[] = [];
const approvedActions: ActionCapability[] = [];

for (let position = 0; position < arguments_.length; position += 1) {
const value = arguments_[position];
if (value !== "--approve") {
if (value) {
taskParts.push(value);
}
continue;
}
const requested = arguments_[position + 1];
const action = actionCapabilities.find(
(candidate) => candidate === requested,
const program = new Command()
.exitOverride()
.configureOutput({ writeErr: () => {} })
.helpOption(false)
.allowUnknownOption(true)
.allowExcessArguments(true)
.argument("[task...]")
.option(
"--approve <action>",
"Approved action capability",
collectApprovals,
[],
);
if (!action) {
throw new Error("Run approval must name a supported action");
}
approvedActions.push(action);
position += 1;
}

const task = taskParts.join(" ").trim();
program.parse([...arguments_], { from: "user" });

const task = program.args.join(" ").trim();
if (task.length === 0) {
throw new Error("Run requires a task");
}
return { task, approvedActions: [...new Set(approvedActions)] };

const options = program.opts<{
readonly approve: readonly ActionCapability[];
}>();

return {
task,
approvedActions: [...new Set(options.approve)],
};
}

export async function runClone(
Expand Down
10 changes: 10 additions & 0 deletions src/cli/transferEval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ test("rejects conflicting aliases, missing values, negative bounds, and unknown
}
});

test("rejects a value flag given twice instead of taking the last one", () => {
expect(() => parseTransferArguments(["--repo", "a", "--repo", "b"])).toThrow(
"Repeated --repo",
);
expect(() => parseTransferArguments(["--tasks", "1", "--tasks", "2"])).toThrow(
"Repeated --tasks",
);
expect(parseTransferArguments(["--repo", "a"]).repo).toBe("a");
});

test("carries the confirmation bypass instead of discarding it", () => {
expect(parseTransferArguments(["--yes"]).yes).toBeTrue();
expect(parseTransferArguments(["-y"]).yes).toBeTrue();
Expand Down
Loading
Loading