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.

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