Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

120 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/** Whether version a (1.2.3) is older than b. */
17const olderThan = (a, b) => {
18 const [pa, pb] = [a, b].map((v) => String(v).split(".").map(Number));
19 for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0);
20 return false;
21};
22
23/**
24 * Why the real CLI would refuse `args`, or null when it takes them. A CLI
25 * of `version` refuses a flag its command's `since` says is newer; with no
26 * version, it is the newest.
27 */
28export function refusal(args, contract = CONTRACT, version = null) {
29 const command = contract.commands
30 .filter((c) => c.command.every((word, i) => args[i] === word))
31 .sort((a, b) => b.command.length - a.command.length)[0];
32 if (!command || args.length === 0) return `not a command: ${args.join(" ")}`;
33 const name = command.command.join(" ") || "g1t";
34 const rest = args.slice(command.command.length);
35 const seen = new Map();
36 const positionals = [];
37 const check = (flag, type, value) => {
38 if (Array.isArray(type) && !type.includes(value)) return `${flag} takes ${type.join(", ")}, not ${value}`;
39 if (type === "number" && !/^\d+$/.test(value)) return `${flag} takes a number, not ${value}`;
40 return null;
41 };
42 for (let i = 0; i < rest.length; i++) {
43 const token = rest[i];
44 if (token === "--" && command.dash_dash) {
45 positionals.push(...rest.slice(i + 1));
46 break;
47 }
48 if (token.startsWith("-") && token !== "-") {
49 // --flag=value: the value is taken as it is, even one that starts with a dash.
50 const eq = token.startsWith("--") ? token.indexOf("=") : -1;
51 const flag = eq > 0 ? token.slice(0, eq) : token;
52 const type = command.flags[flag];
53 if (type === undefined) return `${name} takes no ${flag}`;
54 if (version && command.since?.[flag] && olderThan(version, command.since[flag])) return `${name} takes no ${flag} (the CLI is ${version})`;
55 if (seen.has(flag) && !(command.repeatable ?? []).includes(flag)) return `${flag} given twice`;
56 let value = null;
57 if (eq > 0) {
58 if (type === "switch") return `${flag} takes no value`;
59 value = token.slice(eq + 1);
60 } else if (type === "json") {
61 if (rest[i + 1] !== undefined && !rest[i + 1].startsWith("-")) value = rest[++i];
62 } else if (type !== "switch") {
63 value = rest[++i];
64 if (value === undefined) return `${flag} needs a value`;
65 // As clap does: a value after a space may not look like a flag.
66 if (value.startsWith("-") && value !== "-") return `unexpected argument '${value}' found (use ${flag}=VALUE)`;
67 }
68 if (value !== null && type !== "json") {
69 const wrong = check(flag, type, value);
70 if (wrong) return wrong;
71 }
72 seen.set(flag, value);
73 continue;
74 }
75 positionals.push(token);
76 }
77 const want = command.positionals ?? [];
78 if (positionals.length !== want.length) return `${name} takes ${want.length} argument(s), given ${positionals.length}`;
79 for (const [i, kind] of want.entries()) {
80 const wrong = check("an argument", kind, positionals[i]);
81 if (wrong) return wrong;
82 }
83 for (const flag of command.required ?? []) if (!seen.has(flag)) return `${name} needs ${flag}`;
84 if (command.one_of && !command.one_of.some((flag) => seen.has(flag))) return `${name} needs one of ${command.one_of.join(", ")}`;
85 return null;
86}
87
88function main() {
89 const args = process.argv.slice(2);
90 // `touch` only when the caller left the CLI to guess touched files from the whole working copy.
91 const guesses = process.env.G1T_TOUCH === "0" ? {} : { touch: process.env.G1T_TOUCH ?? "unset" };
92 appendFileSync(process.env.FAKE_G1T_LOG, `${JSON.stringify({ args, session: process.env.G1T_SESSION ?? null, ...guesses })}\n`);
93 const refused = refusal(args, CONTRACT, process.env.FAKE_G1T_VERSION ?? CONTRACT.minimum_version);
94 if (refused) {
95 process.stderr.write(`g1t: ${refused} See --help.\n`);
96 process.exit(2);
97 }
98 if (args[0] === "--version") {
99 process.stdout.write(`g1t ${process.env.FAKE_G1T_VERSION ?? CONTRACT.minimum_version}\n`);
100 process.exit(0);
101 }
102 if (args[0] === "api" && args[1] === "get_session" && process.env.FAKE_G1T_UPDATES) {
103 process.stdout.write(JSON.stringify({ updates: JSON.parse(process.env.FAKE_G1T_UPDATES) }));
104 }
105 const exit = Number(process.env.FAKE_G1T_EXIT ?? 0);
106 if (exit) process.exit(exit);
107 if (args[0] === "session" && args[1] === "start") {
108 // A subagent's session has an id of its own, from its harness id.
109 const harness = args.find((a) => a.startsWith("--harness-session-id="))?.split("=")[1] ?? args[args.indexOf("--harness-session-id") + 1] ?? "";
110 const id = args.some((a) => a === "--parent" || a.startsWith("--parent=")) ? `ses_sub_${harness.split("/agent/")[1]}` : "ses_fake01";
111 process.stdout.write(JSON.stringify({ id, state: "active" }));
112 }
113 // The session as it stands: titled when FAKE_G1T_TITLE says so.
114 if (args[0] === "session" && args[1] === "heartbeat" && args.includes("--json")) {
115 process.stdout.write(JSON.stringify({ id: process.env.G1T_SESSION, state: "active", title: process.env.FAKE_G1T_TITLE || null }));
116 }
117 if (args[0] === "work" && args[1] === "view") process.stdout.write(`#${args[2]} The work item's title\nDefinition of done: it works.\n`);
118}
119
120if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();