Skip to content
103 linesCodeBlameRaw
1#!/usr/bin/env node
2// A stand-in for the g1t CLI in the hook tests: writes each call to
3// FAKE_G1T_LOG as a JSON line and answers as FAKE_G1T_* say.
4//
5// It is as strict as the real CLI, or stricter: a call that is not in
6// cli-contract.json (a command, a flag, a value, a missing argument) is
7// refused with exit code 2, as the real CLI refuses what its parser does
8// not take. crates/g1t's tests parse everything in cli-contract.json with
9// the real parser, so what this accepts, the real CLI accepts too.
10import { appendFileSync, readFileSync } from "node:fs";
11import { resolve } from "node:path";
12import { fileURLToPath } from "node:url";
13
14export const CONTRACT = JSON.parse(readFileSync(new URL("./cli-contract.json", import.meta.url), "utf8"));
15
16/** Why the real CLI would refuse `args`, or null when it takes them. */
17export function refusal(args, contract = CONTRACT) {
18 const command = contract.commands
19 .filter((c) => c.command.every((word, i) => args[i] === word))
20 .sort((a, b) => b.command.length - a.command.length)[0];
21 if (!command || args.length === 0) return `not a command: ${args.join(" ")}`;
22 const name = command.command.join(" ") || "g1t";
23 const rest = args.slice(command.command.length);
24 const seen = new Map();
25 const positionals = [];
26 const check = (flag, type, value) => {
27 if (Array.isArray(type) && !type.includes(value)) return `${flag} takes ${type.join(", ")}, not ${value}`;
28 if (type === "number" && !/^\d+$/.test(value)) return `${flag} takes a number, not ${value}`;
29 return null;
30 };
31 for (let i = 0; i < rest.length; i++) {
32 const token = rest[i];
33 if (token === "--" && command.dash_dash) {
34 positionals.push(...rest.slice(i + 1));
35 break;
36 }
37 if (token.startsWith("-") && token !== "-") {
38 // --flag=value: the value is taken as it is, even one that starts with a dash.
39 const eq = token.startsWith("--") ? token.indexOf("=") : -1;
40 const flag = eq > 0 ? token.slice(0, eq) : token;
41 const type = command.flags[flag];
42 if (type === undefined) return `${name} takes no ${flag}`;
43 if (seen.has(flag) && !(command.repeatable ?? []).includes(flag)) return `${flag} given twice`;
44 let value = null;
45 if (eq > 0) {
46 if (type === "switch") return `${flag} takes no value`;
47 value = token.slice(eq + 1);
48 } else if (type === "json") {
49 if (rest[i + 1] !== undefined && !rest[i + 1].startsWith("-")) value = rest[++i];
50 } else if (type !== "switch") {
51 value = rest[++i];
52 if (value === undefined) return `${flag} needs a value`;
53 // As clap does: a value after a space may not look like a flag.
54 if (value.startsWith("-") && value !== "-") return `unexpected argument '${value}' found (use ${flag}=VALUE)`;
55 }
56 if (value !== null && type !== "json") {
57 const wrong = check(flag, type, value);
58 if (wrong) return wrong;
59 }
60 seen.set(flag, value);
61 continue;
62 }
63 positionals.push(token);
64 }
65 const want = command.positionals ?? [];
66 if (positionals.length !== want.length) return `${name} takes ${want.length} argument(s), given ${positionals.length}`;
67 for (const [i, kind] of want.entries()) {
68 const wrong = check("an argument", kind, positionals[i]);
69 if (wrong) return wrong;
70 }
71 for (const flag of command.required ?? []) if (!seen.has(flag)) return `${name} needs ${flag}`;
72 if (command.one_of && !command.one_of.some((flag) => seen.has(flag))) return `${name} needs one of ${command.one_of.join(", ")}`;
73 return null;
74}
75
76function main() {
77 const args = process.argv.slice(2);
78 appendFileSync(process.env.FAKE_G1T_LOG, `${JSON.stringify({ args, session: process.env.G1T_SESSION ?? null })}\n`);
79 const refused = refusal(args);
80 if (refused) {
81 process.stderr.write(`g1t: ${refused} See --help.\n`);
82 process.exit(2);
83 }
84 if (args[0] === "--version") {
85 process.stdout.write(`g1t ${process.env.FAKE_G1T_VERSION ?? CONTRACT.minimum_version}\n`);
86 process.exit(0);
87 }
88 const exit = Number(process.env.FAKE_G1T_EXIT ?? 0);
89 if (exit) process.exit(exit);
90 if (args[0] === "session" && args[1] === "start") {
91 // A subagent's session has an id of its own, from its harness id.
92 const harness = args.find((a) => a.startsWith("--harness-session-id="))?.split("=")[1] ?? args[args.indexOf("--harness-session-id") + 1] ?? "";
93 const id = args.some((a) => a === "--parent" || a.startsWith("--parent=")) ? `ses_sub_${harness.split("/agent/")[1]}` : "ses_fake01";
94 process.stdout.write(JSON.stringify({ id, state: "active" }));
95 }
96 // The session as it stands: titled when FAKE_G1T_TITLE says so.
97 if (args[0] === "session" && args[1] === "heartbeat" && args.includes("--json")) {
98 process.stdout.write(JSON.stringify({ id: process.env.G1T_SESSION, state: "active", title: process.env.FAKE_G1T_TITLE || null }));
99 }
100 if (args[0] === "work" && args[1] === "context") process.stdout.write(`#${args[2]} The issue's title\nDefinition of done: it works.\n`);
101}
102
103if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();