great-cto 2.9.2 → 2.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/leash.js ADDED
@@ -0,0 +1,289 @@
1
+ // `great-cto leash <subcommand>` — install, start, status, kill, update.
2
+ //
3
+ // Distribution model: we track llm-leash by *git repository* (not PyPI) so
4
+ // every push to https://github.com/avelikiy/llm-leash main is one
5
+ // `great-cto leash update` away. The repo is cloned to ~/.great_cto/llm-leash
6
+ // and installed as editable (`pip install -e .`). Updates run `git pull` +
7
+ // `pip install -e . --upgrade`.
8
+ //
9
+ // Three sources of truth:
10
+ // 1. Installed SHA = git rev-parse HEAD in ~/.great_cto/llm-leash
11
+ // 2. Latest SHA = GitHub commits API
12
+ // 3. Pinned SHA = .great_cto/leash.json → "pinned_sha" (optional)
13
+ //
14
+ // If pinned_sha is set, update() refuses to bump past it without --force.
15
+ import { spawnSync } from "node:child_process";
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { log, success, warn, error, cyan, dim, bold } from "./ui.js";
20
+ const REPO_URL = "https://github.com/avelikiy/llm-leash.git";
21
+ const REPO_API = "https://api.github.com/repos/avelikiy/llm-leash";
22
+ const INSTALL_ROOT = join(homedir(), ".great_cto", "llm-leash");
23
+ const CONFIG_PATH = join(homedir(), ".great_cto", "leash.json");
24
+ // ── public API ────────────────────────────────────────────────────────────────
25
+ export async function runLeash(argv) {
26
+ const sub = argv[0];
27
+ switch (sub) {
28
+ case undefined:
29
+ case "help":
30
+ case "--help":
31
+ case "-h":
32
+ printHelp();
33
+ return { exitCode: 0 };
34
+ case "install":
35
+ return install();
36
+ case "update":
37
+ return update(argv.includes("--force"));
38
+ case "status":
39
+ return status();
40
+ case "start":
41
+ return startProxy(argv.slice(1));
42
+ case "kill":
43
+ return killAll();
44
+ case "uninstall":
45
+ return uninstall();
46
+ default:
47
+ error(`great-cto leash: unknown subcommand '${sub}'`);
48
+ printHelp();
49
+ return { exitCode: 2 };
50
+ }
51
+ }
52
+ // ── subcommands ───────────────────────────────────────────────────────────────
53
+ function printHelp() {
54
+ log(bold("great-cto leash") + " — runtime governance for LLM agents (https://github.com/avelikiy/llm-leash)");
55
+ log("");
56
+ log(" " + cyan("install") + " clone the repo, install as editable, write default config");
57
+ log(" " + cyan("update") + " git pull + reinstall (auto-pulls latest commits from main)");
58
+ log(" " + cyan("status") + " installed version vs GitHub latest, last audit-log entry");
59
+ log(" " + cyan("start") + " start the HTTP proxy on :8765 (env-var deployment)");
60
+ log(" " + cyan("kill") + " fire kill switch — stops all in-flight LLM calls (<300 ms)");
61
+ log(" " + cyan("uninstall") + " remove ~/.great_cto/llm-leash (config left intact)");
62
+ log("");
63
+ log(dim(" Config: " + CONFIG_PATH));
64
+ log(dim(" Install dir: " + INSTALL_ROOT));
65
+ }
66
+ function install() {
67
+ if (!hasGit()) {
68
+ error("git is required. Install git first: https://git-scm.com/downloads");
69
+ return { exitCode: 1 };
70
+ }
71
+ if (!hasPython()) {
72
+ error("python3 is required. Install Python 3.10+ first: https://www.python.org/downloads/");
73
+ return { exitCode: 1 };
74
+ }
75
+ mkdirSync(join(homedir(), ".great_cto"), { recursive: true });
76
+ if (existsSync(INSTALL_ROOT)) {
77
+ warn(`llm-leash already cloned at ${INSTALL_ROOT}.`);
78
+ log(` Run ${cyan("great-cto leash update")} to pull latest.`);
79
+ return { exitCode: 0 };
80
+ }
81
+ log(dim(` cloning ${REPO_URL} → ${INSTALL_ROOT}`));
82
+ const cloneResult = spawnSync("git", ["clone", REPO_URL, INSTALL_ROOT], {
83
+ stdio: ["ignore", "pipe", "pipe"], timeout: 120_000,
84
+ });
85
+ if (cloneResult.status !== 0) {
86
+ error(`git clone failed: ${cloneResult.stderr?.toString() || "unknown"}`);
87
+ return { exitCode: 1 };
88
+ }
89
+ log(dim(` pip install -e .`));
90
+ const pipResult = spawnSync(pythonCmd(), ["-m", "pip", "install", "-e", INSTALL_ROOT, "--quiet"], {
91
+ stdio: ["ignore", "pipe", "pipe"], timeout: 240_000,
92
+ });
93
+ if (pipResult.status !== 0) {
94
+ warn("pip install reported errors — leash CLI may not be on PATH yet:");
95
+ warn(pipResult.stderr?.toString() || "");
96
+ log(` Try: ${cyan(`${pythonCmd()} -m pip install -e ${INSTALL_ROOT}`)}`);
97
+ }
98
+ // Default config (only if absent — never clobber user changes)
99
+ if (!existsSync(CONFIG_PATH)) {
100
+ const defaults = {
101
+ enabled: true,
102
+ install_root: INSTALL_ROOT,
103
+ audit_path: join(homedir(), ".leash", "audit.jsonl"),
104
+ proxy_url: "http://localhost:8765",
105
+ daily_cap_usd: 50,
106
+ monthly_cap_usd: 500,
107
+ };
108
+ writeFileSync(CONFIG_PATH, JSON.stringify(defaults, null, 2));
109
+ success(`wrote ${CONFIG_PATH}`);
110
+ }
111
+ const sha = getInstalledSha();
112
+ success(`llm-leash installed at ${INSTALL_ROOT}${sha ? ` (HEAD: ${sha})` : ""}`);
113
+ log("");
114
+ log("Next: " + cyan("great-cto leash status") + " — verify proxy reachable");
115
+ log(" " + cyan("great-cto leash start") + " — start HTTP proxy on :8765");
116
+ return { exitCode: 0 };
117
+ }
118
+ function update(force) {
119
+ if (!existsSync(INSTALL_ROOT)) {
120
+ warn("llm-leash not installed. Run `great-cto leash install` first.");
121
+ return { exitCode: 1 };
122
+ }
123
+ const cfg = readConfig();
124
+ const beforeSha = getInstalledSha();
125
+ if (cfg.pinned_sha && !force) {
126
+ log(dim(` pinned to ${cfg.pinned_sha} in ${CONFIG_PATH} — checkout pinned commit`));
127
+ const co = spawnSync("git", ["-C", INSTALL_ROOT, "fetch", "--quiet"], { stdio: "ignore", timeout: 60_000 });
128
+ if (co.status !== 0) {
129
+ error("git fetch failed");
130
+ return { exitCode: 1 };
131
+ }
132
+ const reset = spawnSync("git", ["-C", INSTALL_ROOT, "reset", "--hard", cfg.pinned_sha], {
133
+ stdio: ["ignore", "pipe", "pipe"], timeout: 30_000,
134
+ });
135
+ if (reset.status !== 0) {
136
+ error(`reset to pinned ${cfg.pinned_sha} failed: ${reset.stderr?.toString()}`);
137
+ return { exitCode: 1 };
138
+ }
139
+ }
140
+ else {
141
+ log(dim(` git pull origin main`));
142
+ const pull = spawnSync("git", ["-C", INSTALL_ROOT, "pull", "--ff-only", "origin", "main"], {
143
+ stdio: ["ignore", "pipe", "pipe"], timeout: 60_000,
144
+ });
145
+ if (pull.status !== 0) {
146
+ error(`git pull failed: ${pull.stderr?.toString()}`);
147
+ log(` Try: ${cyan(`cd ${INSTALL_ROOT} && git status`)}`);
148
+ return { exitCode: 1 };
149
+ }
150
+ }
151
+ const afterSha = getInstalledSha();
152
+ if (beforeSha === afterSha) {
153
+ log(dim(` already at latest (${afterSha})`));
154
+ return { exitCode: 0 };
155
+ }
156
+ log(dim(` pip install -e . --upgrade`));
157
+ const pip = spawnSync(pythonCmd(), ["-m", "pip", "install", "-e", INSTALL_ROOT, "--upgrade", "--quiet"], {
158
+ stdio: ["ignore", "pipe", "pipe"], timeout: 240_000,
159
+ });
160
+ if (pip.status !== 0) {
161
+ warn("pip reinstall reported errors:");
162
+ warn(pip.stderr?.toString() || "");
163
+ }
164
+ success(`llm-leash: ${beforeSha} → ${afterSha}`);
165
+ // Persist last-known SHA for the version-check hook
166
+ if (afterSha)
167
+ writeVersionCache(afterSha);
168
+ return { exitCode: 0 };
169
+ }
170
+ async function status() {
171
+ const installed = existsSync(INSTALL_ROOT);
172
+ if (!installed) {
173
+ log(bold("llm-leash:") + " " + dim("not installed"));
174
+ log(` Install: ${cyan("great-cto leash install")}`);
175
+ return { exitCode: 0 };
176
+ }
177
+ const cfg = readConfig();
178
+ const head = getInstalledSha() || "?";
179
+ const latest = await fetchLatestSha();
180
+ log(bold("llm-leash:") + " installed at " + dim(INSTALL_ROOT));
181
+ log(` Installed HEAD : ${head}`);
182
+ log(` GitHub latest : ${latest || dim("unknown (network?)")}`);
183
+ log(` Config : ${CONFIG_PATH}`);
184
+ log(` Audit log : ${cfg.audit_path || dim("default")}`);
185
+ log(` Daily cap : ${cfg.daily_cap_usd ? "$" + cfg.daily_cap_usd : dim("not set")}`);
186
+ log(` Pinned SHA : ${cfg.pinned_sha || dim("none — track main")}`);
187
+ if (latest && latest !== head) {
188
+ log("");
189
+ warn(`Update available. Run ${cyan("great-cto leash update")} to bump.`);
190
+ }
191
+ return { exitCode: 0 };
192
+ }
193
+ function startProxy(extraArgs) {
194
+ if (!existsSync(INSTALL_ROOT)) {
195
+ warn("llm-leash not installed. Run `great-cto leash install` first.");
196
+ return { exitCode: 1 };
197
+ }
198
+ log(`Starting llm-leash proxy on http://localhost:8765 …`);
199
+ log(dim(` set ANTHROPIC_BASE_URL=http://localhost:8765 to route via leash`));
200
+ const r = spawnSync(pythonCmd(), ["-m", "leash.proxy", ...extraArgs], {
201
+ stdio: "inherit",
202
+ });
203
+ return { exitCode: r.status ?? 0 };
204
+ }
205
+ function killAll() {
206
+ const r = spawnSync("leash", ["kill", "--all", "--reason", "cli"], {
207
+ stdio: ["ignore", "pipe", "pipe"], timeout: 5000,
208
+ });
209
+ if (r.status === 0) {
210
+ success("kill switch fired");
211
+ return { exitCode: 0 };
212
+ }
213
+ // Fall back to python -m
214
+ const r2 = spawnSync(pythonCmd(), ["-m", "leash", "kill", "--all", "--reason", "cli"], {
215
+ stdio: "inherit", timeout: 5000,
216
+ });
217
+ return { exitCode: r2.status ?? 1 };
218
+ }
219
+ function uninstall() {
220
+ if (!existsSync(INSTALL_ROOT)) {
221
+ log(dim("nothing to remove"));
222
+ return { exitCode: 0 };
223
+ }
224
+ const r = spawnSync("rm", ["-rf", INSTALL_ROOT], { stdio: "inherit", timeout: 30_000 });
225
+ if (r.status === 0)
226
+ success(`removed ${INSTALL_ROOT}`);
227
+ log(dim(`config left intact at ${CONFIG_PATH}`));
228
+ return { exitCode: r.status ?? 0 };
229
+ }
230
+ // ── helpers ───────────────────────────────────────────────────────────────────
231
+ function hasGit() {
232
+ return spawnSync("git", ["--version"], { stdio: "ignore", timeout: 3000 }).status === 0;
233
+ }
234
+ function hasPython() {
235
+ return spawnSync(pythonCmd(), ["--version"], { stdio: "ignore", timeout: 3000 }).status === 0;
236
+ }
237
+ function pythonCmd() {
238
+ // Prefer python3, fall back to python
239
+ if (spawnSync("python3", ["--version"], { stdio: "ignore", timeout: 2000 }).status === 0)
240
+ return "python3";
241
+ return "python";
242
+ }
243
+ function getInstalledSha() {
244
+ if (!existsSync(INSTALL_ROOT))
245
+ return null;
246
+ const r = spawnSync("git", ["-C", INSTALL_ROOT, "rev-parse", "--short", "HEAD"], {
247
+ stdio: ["ignore", "pipe", "ignore"], timeout: 3000,
248
+ });
249
+ if (r.status !== 0)
250
+ return null;
251
+ return r.stdout.toString().trim();
252
+ }
253
+ async function fetchLatestSha() {
254
+ try {
255
+ const res = await fetch(`${REPO_API}/commits/main`, {
256
+ headers: {
257
+ "Accept": "application/vnd.github+json",
258
+ "User-Agent": "great-cto-leash",
259
+ },
260
+ // Node 20 has native AbortController + fetch; cap at 6 s.
261
+ signal: AbortSignal.timeout(6000),
262
+ });
263
+ if (!res.ok)
264
+ return null;
265
+ const j = await res.json();
266
+ return j.sha?.slice(0, 7) || null;
267
+ }
268
+ catch {
269
+ return null;
270
+ }
271
+ }
272
+ function readConfig() {
273
+ try {
274
+ if (existsSync(CONFIG_PATH))
275
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
276
+ }
277
+ catch { /* ignore */ }
278
+ return {};
279
+ }
280
+ function writeVersionCache(sha) {
281
+ const cache = join(homedir(), ".great_cto", "leash-version.json");
282
+ try {
283
+ writeFileSync(cache, JSON.stringify({
284
+ installed_sha: sha,
285
+ last_checked: new Date().toISOString(),
286
+ }, null, 2));
287
+ }
288
+ catch { /* best-effort */ }
289
+ }
package/dist/main.js CHANGED
@@ -10,16 +10,17 @@
10
10
  // 7. bootstrap .great_cto/PROJECT.md
11
11
  // 8. print next steps
12
12
  import { resolve } from "node:path";
13
- import { banner, bold, cyan, dim, error, green, log, step, warn, yellow, confirm } from "./ui.js";
13
+ import { banner, bold, cyan, dim, error, green, log, step, success, warn, yellow, confirm } from "./ui.js";
14
14
  import { detect } from "./detect.js";
15
15
  import { pickArchetype, suggestCompliance } from "./archetypes.js";
16
16
  import { install, findInstalledVersions } from "./installer.js";
17
17
  import { enableGreatCto } from "./settings.js";
18
18
  import { bootstrap } from "./bootstrap.js";
19
19
  import { shouldUseLlmFallback, suggestArchetypeFromLlm } from "./llm-fallback.js";
20
- import { readFileSync } from "node:fs";
20
+ import { readFileSync, copyFileSync, chmodSync, existsSync as fsExistsSync } from "node:fs";
21
21
  import { dirname, join } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
+ import { homedir } from "node:os";
23
24
  function getCliVersion() {
24
25
  try {
25
26
  const here = dirname(fileURLToPath(import.meta.url));
@@ -94,6 +95,8 @@ function parseArgs(argv) {
94
95
  args.command = "webhook";
95
96
  else if (a === "report")
96
97
  args.command = "report";
98
+ else if (a === "leash")
99
+ args.command = "leash";
97
100
  // Slash-commands surfaced as CLI subcommands so users get a clear hint
98
101
  // instead of a confusing usage error. These work only in the chat plugin.
99
102
  else if (a === "start" || a === "audit" || a === "inbox" || a === "digest" ||
@@ -110,8 +113,14 @@ function parseArgs(argv) {
110
113
  args.dir = a.slice("--dir=".length);
111
114
  else if (a === "--dir")
112
115
  args.dir = argv[++i] ?? args.dir;
113
- else if (a === "init" || a === "help" || a === "version") {
114
- args.command = a;
116
+ else if (a === "init" || a === "install" || a === "help" || a === "version") {
117
+ // `install` is an alias for `init`. Both run the same flow; only
118
+ // difference: `install` upgrades llm-leash to latest on every run,
119
+ // while `init` is silent-skip when already installed.
120
+ args.command = (a === "install" ? "init" : a);
121
+ if (a === "install") {
122
+ args._fromInstall = true;
123
+ }
115
124
  }
116
125
  else if (!a.startsWith("-") && args.command === "init" && i === 0) {
117
126
  // First positional that isn't a recognised subcommand → unknown
@@ -345,7 +354,8 @@ function printHelp() {
345
354
  log(`${bold("great-cto")} — one-command install for the great_cto Claude Code plugin
346
355
 
347
356
  ${bold("Usage:")}
348
- npx great-cto [init] [options]
357
+ npx great-cto install [options] Same as init; also upgrades llm-leash
358
+ npx great-cto [init] [options] Detect + bootstrap; installs llm-leash if absent
349
359
  npx great-cto board [--port 3141] [--no-open]
350
360
  npx great-cto register [--dir PATH]
351
361
  npx great-cto scan [path] [--severity LVL] [--scanner NAME] [--sarif FILE]
@@ -746,6 +756,14 @@ async function runInit(args) {
746
756
  if (!bs.created) {
747
757
  log(` ${dim("PROJECT.md already exists at")} ${bs.projectMdPath} ${dim("— kept as-is")}`);
748
758
  }
759
+ // ── 6. install pre-push git hook ─────────────────────────
760
+ installPrePushHook(args.dir);
761
+ // ── 7. install / update llm-leash (runtime governance) ───
762
+ // `init` is idempotent (silent skip when present). `install` always
763
+ // upgrades to the latest commit on llm-leash main. Both best-effort:
764
+ // missing git/python doesn't fail the flow.
765
+ const fromInstall = args._fromInstall === true;
766
+ await tryInstallLeash(fromInstall);
749
767
  // ── done ─────────────────────────────────────────────────
750
768
  log("");
751
769
  log(green(bold("✓ great_cto is ready.")));
@@ -761,6 +779,71 @@ async function runInit(args) {
761
779
  log("");
762
780
  return 0;
763
781
  }
782
+ /**
783
+ * Copy scripts/hooks/pre-push.sh from the installed plugin into the project's
784
+ * .git/hooks/pre-push so that future pushes are scanned for private project
785
+ * name leaks. Best-effort — never throws.
786
+ */
787
+ function installPrePushHook(projectDir) {
788
+ try {
789
+ const gitHooksDir = join(projectDir, ".git", "hooks");
790
+ if (!fsExistsSync(gitHooksDir))
791
+ return; // not a git repo — skip silently
792
+ const dest = join(gitHooksDir, "pre-push");
793
+ if (fsExistsSync(dest)) {
794
+ log(` ${dim("pre-push hook already present — skipped")}`);
795
+ return;
796
+ }
797
+ // Locate source: dist/main.js → ../../scripts/hooks/pre-push.sh
798
+ const here = dirname(fileURLToPath(import.meta.url));
799
+ const src = join(here, "..", "..", "scripts", "hooks", "pre-push.sh");
800
+ if (!fsExistsSync(src)) {
801
+ warn("pre-push hook source not found — skipping hook installation");
802
+ return;
803
+ }
804
+ copyFileSync(src, dest);
805
+ chmodSync(dest, 0o755);
806
+ success("installed pre-push hook (blocks private project name leaks)");
807
+ }
808
+ catch {
809
+ // Best-effort: hook failure must never block init
810
+ }
811
+ }
812
+ /**
813
+ * Best-effort llm-leash install — runs after bootstrap so every great-cto
814
+ * init turns on runtime governance for free.
815
+ *
816
+ * forceUpdate=false (called from `init`) — silent-skip if installed
817
+ * forceUpdate=true (called from `install`) — git pull + reinstall
818
+ *
819
+ * Never throws. Opt out via env: GREAT_CTO_SKIP_LEASH=1
820
+ */
821
+ async function tryInstallLeash(forceUpdate = false) {
822
+ if (process.env.GREAT_CTO_SKIP_LEASH === "1") {
823
+ log(` ${dim("skipped llm-leash install (GREAT_CTO_SKIP_LEASH=1)")}`);
824
+ return;
825
+ }
826
+ try {
827
+ const { runLeash } = await import("./leash.js");
828
+ const { existsSync } = await import("node:fs");
829
+ const installRoot = join(homedir(), ".great_cto", "llm-leash");
830
+ if (existsSync(installRoot)) {
831
+ if (forceUpdate) {
832
+ log(` ${dim("updating llm-leash to latest …")}`);
833
+ await runLeash(["update"]);
834
+ }
835
+ else {
836
+ log(` ${dim("llm-leash already installed — skipped")}`);
837
+ }
838
+ return;
839
+ }
840
+ log(` ${dim("installing llm-leash (runtime governance) …")}`);
841
+ await runLeash(["install"]);
842
+ }
843
+ catch {
844
+ warn("llm-leash install failed — run `great-cto leash install` manually later");
845
+ }
846
+ }
764
847
  async function main() {
765
848
  const rawArgv = process.argv.slice(2);
766
849
  const args = parseArgs(rawArgv);
@@ -886,6 +969,19 @@ async function main() {
886
969
  process.exit(2);
887
970
  }
888
971
  }
972
+ if (args.command === "leash") {
973
+ try {
974
+ const { runLeash } = await import("./leash.js");
975
+ // rawArgv[0] is "leash" — pass the rest as subcommand + flags
976
+ const leashArgs = rawArgv.slice(rawArgv.indexOf("leash") + 1);
977
+ const result = await runLeash(leashArgs);
978
+ process.exit(result.exitCode);
979
+ }
980
+ catch (e) {
981
+ error(e.message);
982
+ process.exit(2);
983
+ }
984
+ }
889
985
  if (args.command === "chat-only-hint") {
890
986
  const tried = args._slashTried || "<command>";
891
987
  error(`'${tried}' is a chat slash command, not a CLI subcommand.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.9.2",
3
+ "version": "2.9.3",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -70,6 +70,7 @@
70
70
  "index.mjs",
71
71
  "dist/",
72
72
  "agentshield-rules/",
73
+ "postinstall.mjs",
73
74
  "README.md"
74
75
  ],
75
76
  "type": "module",
@@ -77,6 +78,7 @@
77
78
  "build": "tsc",
78
79
  "test": "npm run build && node --test tests/*.test.mjs tests/**/*.test.mjs",
79
80
  "test:e2e": "npm run build && node ../../tests/run-archetype-e2e.mjs",
81
+ "postinstall": "node postinstall.mjs",
80
82
  "prepublishOnly": "npm run build"
81
83
  },
82
84
  "devDependencies": {
@@ -86,4 +88,4 @@
86
88
  "engines": {
87
89
  "node": ">=18.17.0"
88
90
  }
89
- }
91
+ }
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * great-cto postinstall — best-effort one-time setup that runs when the npm
4
+ * package is installed globally or as a dependency.
5
+ *
6
+ * Currently does one thing: install llm-leash (https://github.com/avelikiy/llm-leash)
7
+ * for runtime governance — budget caps, audit log, kill switch, HITL gates.
8
+ *
9
+ * Design rules:
10
+ * - NEVER fail the npm install. All errors swallowed.
11
+ * - Idempotent — skips if ~/.great_cto/llm-leash already cloned.
12
+ * - Honors GREAT_CTO_SKIP_LEASH=1 to opt out (CI envs, restricted machines).
13
+ * - Skips on CI by default unless GREAT_CTO_FORCE_LEASH=1 — npm install in
14
+ * CI shouldn't trigger 30s of git clone + pip install per build.
15
+ * - Skips if `npm install` was invoked with --ignore-scripts (npm sets
16
+ * `npm_config_ignore_scripts=true` — actually no, it just doesn't run
17
+ * scripts; we can't detect it from inside).
18
+ * - Detached output — postinstall noise is intentional and short.
19
+ *
20
+ * The "real" install path remains `great-cto leash install`. This hook just
21
+ * makes the common case (one-shot `npm install -g great-cto`) feel zero-config.
22
+ */
23
+
24
+ import { existsSync } from 'node:fs';
25
+ import { spawnSync } from 'node:child_process';
26
+ import { homedir } from 'node:os';
27
+ import path from 'node:path';
28
+
29
+ const INSTALL_ROOT = path.join(homedir(), '.great_cto', 'llm-leash');
30
+
31
+ function main() {
32
+ // ── opt-outs ─────────────────────────────────────────────────────────────
33
+ if (process.env.GREAT_CTO_SKIP_LEASH === '1') {
34
+ return; // silent
35
+ }
36
+
37
+ // Skip in CI unless explicitly forced — CI builds get no benefit from
38
+ // having leash installed in the runner's home dir, and the latency hurts.
39
+ const inCI = process.env.CI === 'true' || process.env.CI === '1';
40
+ if (inCI && process.env.GREAT_CTO_FORCE_LEASH !== '1') {
41
+ return;
42
+ }
43
+
44
+ // Already installed — fast exit
45
+ if (existsSync(INSTALL_ROOT)) {
46
+ return;
47
+ }
48
+
49
+ // Need git + python3 — bail silently if either is missing
50
+ if (!hasCommand('git') || !hasCommand('python3')) {
51
+ console.log('[great-cto] llm-leash skipped — git or python3 not on PATH');
52
+ console.log('[great-cto] run `great-cto leash install` later to enable runtime governance');
53
+ return;
54
+ }
55
+
56
+ // Locate the bundled dist/main.js — postinstall runs with cwd=package root
57
+ const here = path.dirname(new URL(import.meta.url).pathname);
58
+ const cli = path.join(here, 'dist', 'main.js');
59
+ if (!existsSync(cli)) {
60
+ return; // package built incorrectly — fail-safe
61
+ }
62
+
63
+ console.log('[great-cto] installing llm-leash for runtime governance (~30s) …');
64
+ console.log('[great-cto] opt out next time: GREAT_CTO_SKIP_LEASH=1 npm install -g great-cto');
65
+
66
+ const result = spawnSync(process.execPath, [cli, 'leash', 'install'], {
67
+ stdio: 'inherit',
68
+ timeout: 300_000,
69
+ env: { ...process.env, NO_COLOR: process.env.NO_COLOR || '1' },
70
+ });
71
+
72
+ if (result.status !== 0) {
73
+ console.log('[great-cto] llm-leash install hit an issue — run `great-cto leash install` later');
74
+ }
75
+ }
76
+
77
+ function hasCommand(cmd) {
78
+ try {
79
+ const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', timeout: 3000 });
80
+ return r.status === 0;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ try { main(); } catch { /* never fail npm install */ }