| 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. |
| 10 | import { appendFileSync, readFileSync } from "node:fs"; |
| 11 | import { resolve } from "node:path"; |
| 12 | import { fileURLToPath } from "node:url"; |
| 13 | |
| 14 | export 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. */ |
| 17 | export 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 | |
| 76 | function 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 | |
| 103 | if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); |