@decidio/sdk 0.8.1 → 0.10.0

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/cli.js CHANGED
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, rmdirSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync, statSync, lstatSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, rmdirSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync, statSync, lstatSync, fstatSync, readSync } from "node:fs";
3
3
  import { randomBytes, createHash } from "node:crypto";
4
- import { homedir, tmpdir } from "node:os";
5
- import { join as joinPath, resolve as resolvePath, sep as pathSep, basename, relative, isAbsolute } from "node:path";
4
+ import { homedir, tmpdir, hostname } from "node:os";
5
+ import { join as joinPath, resolve as resolvePath, sep as pathSep, basename, dirname, relative, isAbsolute } from "node:path";
6
6
  import { createServer } from "node:http";
7
7
  import { isIP } from "node:net";
8
- import { execFileSync, spawnSync } from "node:child_process";
8
+ import { execFileSync, spawnSync, spawn } from "node:child_process";
9
9
  import { createInterface } from "node:readline";
10
10
  import { pathToFileURL as toFileUrl, fileURLToPath as fromFileUrl } from "node:url";
11
11
  import { realpathSync as realPath } from "node:fs";
12
12
  import { createRequire } from "node:module";
13
- import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
13
+ import { generateKeypair, assertSafeTransport, redactUrl, signBoundProof } from "./signing.js";
14
14
  import { hardenFile } from "./resume.js";
15
15
  import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, DecidioHttpError, didFromPrivateKey, checkApproval, signConfirm, buildConfirmBody, readConfirmResponse, bindingMaterial, boundScope, idempotencyKey, decisionUrl, decisionIdProblem } from "./index.js";
16
16
  import { gateAuthorizeTwinAware, adapterRequest } from "./adapters/_gate.js";
@@ -76,6 +76,14 @@ if (!OFFLINE_CMD)
76
76
  }
77
77
  const DEFAULT_API = "https://decidio-api.onrender.com";
78
78
  let API = (process.env.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
79
+ let CREDS_FROM = SHELL_ENV.DECIDIO_API_TOKEN ? "shell" : process.env.DECIDIO_API_TOKEN ? ".env" : null;
80
+ if (!OFFLINE_CMD && !["init", "quickstart", "login", "logout", "phone"].includes(ARGV_TAIL[0] ?? "") && !process.env.DECIDIO_API_TOKEN && !process.env.DECIDIO_AGENT_ID) {
81
+ const c = loadConnection(projectRoot(), API);
82
+ if (c) {
83
+ promoteConnection(c);
84
+ CREDS_FROM = "connection";
85
+ }
86
+ }
79
87
  let DEMO = false;
80
88
  function enterDemo(url) { API = url; process.env.DECIDIO_API_URL = url; DEMO = true; }
81
89
  const pinWords = () => (DEMO ? "PINNED to this demo's own key" : "PINNED to your workspace issuer");
@@ -90,7 +98,7 @@ let hostAnnounced = false;
90
98
  async function api(path, init = {}, bearer, soft = false) {
91
99
  if (!hostAnnounced) {
92
100
  hostAnnounced = true;
93
- err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
101
+ err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}${APP_URL_OVERRIDDEN ? ` · links → ${APP_URL} (DECIDIO_APP_URL)` : ""}`);
94
102
  }
95
103
  const auth = bearer ?? TOKEN();
96
104
  if (auth && envUnreadable && !process.env.DECIDIO_API_URL) {
@@ -462,7 +470,24 @@ function block(n, title, rows, tone) {
462
470
  }
463
471
  log(f("└" + "─".repeat(width - 2) + "┘"));
464
472
  }
465
- const APP_URL = API.replace("decidio-api.onrender.com", "app.decidioai.com");
473
+ const APP_URL_SHAPE = /^https?:\/\/[\x21-\x7e]+$/;
474
+ const asciiTrim = (v) => v.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, "");
475
+ export function appUrlFor(api, override = process.env.DECIDIO_APP_URL) {
476
+ const o = asciiTrim(override ?? "");
477
+ if (APP_URL_SHAPE.test(o))
478
+ return o.replace(/\/+$/, "");
479
+ const base = api.replace(/\/+$/, "");
480
+ const known = {
481
+ "https://decidio-api.onrender.com": "https://staging.decidioai.com",
482
+ "https://mcp-staging.decidioai.com": "https://staging.decidioai.com",
483
+ "https://mcp.decidioai.com": "https://app.decidioai.com",
484
+ };
485
+ return known[base] ?? base;
486
+ }
487
+ const APP_URL = appUrlFor(API);
488
+ const APP_URL_OVERRIDDEN = APP_URL !== appUrlFor(API, "");
489
+ if (asciiTrim(process.env.DECIDIO_APP_URL ?? "") && !APP_URL_SHAPE.test(asciiTrim(process.env.DECIDIO_APP_URL ?? "")))
490
+ console.error("⚠ ignoring DECIDIO_APP_URL: it must be a plain http(s) address (printable characters only)");
466
491
  let stdinReadAbandoned = false;
467
492
  let EXPLAIN = false;
468
493
  let currentRl = null;
@@ -835,11 +860,12 @@ async function firstRunFork(signedInAs = null) {
835
860
  return { kind: "stop", code: 1 };
836
861
  }
837
862
  log("");
838
- log(` What next? ${dim("[1/2/3]")}`);
863
+ log(` What next? ${dim("[1/2/3/4]")}`);
839
864
  log(` ${bold("1")} the two-minute tour against your workspace ${dim("npx @decidio/sdk quickstart")}`);
840
865
  log(` ${bold("2")} wire up your own agent now (register, token, .env) ${dim("npx @decidio/sdk init <agentId>")}`);
841
866
  log(` ${bold("3")} sign out of this computer ${dim("npx @decidio/sdk logout")}`);
842
- const b = await pick("1, 2 or 3", /^[123]$/);
867
+ log(` ${bold("4")} put Decidio on your phone (scan a QR here) ${dim("npx @decidio/sdk phone")}`);
868
+ const b = await pick("1, 2, 3 or 4", /^[1234]$/);
843
869
  if (!b) {
844
870
  log(` No answer — you are signed in; run any command above when you are ready.`);
845
871
  return { kind: "stop", code: 0 };
@@ -854,6 +880,11 @@ async function firstRunFork(signedInAs = null) {
854
880
  log("");
855
881
  return { kind: "logout" };
856
882
  }
883
+ if (b === "4") {
884
+ log(` → running: npx @decidio/sdk phone`);
885
+ log("");
886
+ return { kind: "phone" };
887
+ }
857
888
  const here = readEnvFile();
858
889
  if (["DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN"].some((k) => (here[k] ?? "").trim()) && here.DECIDIO_AGENT_ID) {
859
890
  const tour = /^quickstart-/.test(here.DECIDIO_AGENT_ID);
@@ -888,8 +919,73 @@ async function cmdLogin(emailArg) {
888
919
  return 0;
889
920
  }
890
921
  const token = await loginFlow("Signing in", emailArg);
922
+ if (token)
923
+ log(dim(` next: approve from your phone — npx @decidio/sdk phone`));
891
924
  return token ? 0 : 1;
892
925
  }
926
+ const PHONE_MAX_QRS = 3;
927
+ async function cmdPhone() {
928
+ if (!(process.stdin.isTTY && process.stdout.isTTY)) {
929
+ err(`✘ phone setup prints a one-time sign-in QR, so it runs only in an interactive terminal (never into a log or a pipe).`);
930
+ err(` Run it in a terminal: npx @decidio/sdk phone`);
931
+ return 2;
932
+ }
933
+ const session = await acquireSession("Setting up your phone");
934
+ if (!session)
935
+ return 1;
936
+ const who = process.env.DECIDIO_SESSION_TOKEN ? null : loadSession()?.email ?? null;
937
+ log("");
938
+ log(` Put Decidio on your phone? ${dim("[y/n]")}`);
939
+ log(` The QR signs that phone in${who ? ` as ${who}` : ""} - once, within 5 minutes. Whoever scans it first is signed in`);
940
+ log(` as you, so don't show it on a shared screen or a projector.`);
941
+ const yes = (await ask(" › ")).trim().toLowerCase();
942
+ if (yes !== "y") {
943
+ log(` Nothing was made. Run npx @decidio/sdk phone when you are ready.`);
944
+ return 0;
945
+ }
946
+ for (let n = 1; n <= PHONE_MAX_QRS; n++) {
947
+ const r = await api("/demo/session-link", { method: "POST", body: JSON.stringify({ setup: true }) }, session, true);
948
+ if (r.status === 404) {
949
+ log(` ✘ QR phone setup is on the Decidio lab for now. On your company's Decidio, open ${APP_URL}/phone on the phone`);
950
+ log(` and sign in with your company's sign-in (SSO).`);
951
+ return 1;
952
+ }
953
+ if (r.status === 401) {
954
+ log(` ✘ this computer's sign-in is no longer valid - run npx @decidio/sdk logout, then npx @decidio/sdk phone again.`);
955
+ return 1;
956
+ }
957
+ const link = r.status >= 200 && r.status < 300 && typeof r.body?.link === "string" ? String(r.body.link) : null;
958
+ if (!link) {
959
+ log(` ✘ no QR this time (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. Nothing was signed in.`);
960
+ return 1;
961
+ }
962
+ const qr = typeof r.body?.qr === "string" ? String(r.body.qr) : null;
963
+ log("");
964
+ if (qr) {
965
+ log(` Scan with the phone's camera ${dim("(one time · 5 minutes)")}:`);
966
+ for (const l of qr.split("\n"))
967
+ if (l.trim().length)
968
+ log(" " + l);
969
+ }
970
+ else
971
+ log(` Open this on the phone ${dim("(one time · 5 minutes - no QR came back this time)")}:`);
972
+ log(` ${dim(link)}`);
973
+ log(` ${bold("iPhone")} the camera opens the Decidio app, signed in. No app yet? The page walks you through installing it,`);
974
+ log(` then scan again.`);
975
+ log(` ${bold("Android")} opens in the phone's browser, signed in (phone alerts come with the app, iPhone only for now).`);
976
+ log("");
977
+ if (n === PHONE_MAX_QRS) {
978
+ log(` That's ${PHONE_MAX_QRS} - run npx @decidio/sdk phone again for more.`);
979
+ return 0;
980
+ }
981
+ const next = (await ask(` Enter = a fresh QR (this one stops working) · q = done: `)).trim().toLowerCase();
982
+ if (next !== "" || !sharedRlOpen || process.stdin.readableEnded || process.stdin.destroyed) {
983
+ log(` Done.`);
984
+ return 0;
985
+ }
986
+ }
987
+ return 0;
988
+ }
893
989
  const TRUTH_SESSION = "The session token itself stays valid until it expires (up to 30 days) — there is no server-side revocation yet";
894
990
  function cmdLogout() {
895
991
  if (endSession()) {
@@ -2370,14 +2466,198 @@ function hookSegments(command) {
2370
2466
  while (tokens.length && tokens[0].startsWith("-"))
2371
2467
  tokens.shift();
2372
2468
  }
2373
- const program = basename((tokens[0] ?? "").replace(/^["']|["']$/g, "")).toLowerCase();
2374
- return { program, args: tokens.slice(1), raw };
2469
+ const bin = (tokens[0] ?? "").replace(/^["']|["']$/g, "");
2470
+ return { program: basename(bin).toLowerCase(), bin, args: tokens.slice(1), raw };
2375
2471
  });
2376
2472
  }
2377
- const hookFlag = (args, letters, longs = []) => args.some((a) => (/^-[A-Za-z]+$/.test(a) && [...letters].some((l) => a.slice(1).includes(l))) || longs.includes(a));
2378
- const hookSub = (s, i = 0) => (s.args.filter((a) => !a.startsWith("-"))[i] ?? "").toLowerCase();
2473
+ const hookFlag = (args, letters, longs = []) => args.some((a) => (/^-[A-Za-z]+$/.test(a) && [...letters].some((l) => a.slice(1).includes(l))) || longs.includes(a.split("=")[0]));
2474
+ const HOOK_GIT_VALUED = ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"];
2475
+ function hookGitArgs(s) {
2476
+ let i = 0;
2477
+ while (i < s.args.length && s.args[i].startsWith("-"))
2478
+ i += HOOK_GIT_VALUED.includes(s.args[i]) ? 2 : 1;
2479
+ return s.args.slice(i);
2480
+ }
2481
+ function hookGitElsewhere(s, cwdAbs) {
2482
+ for (let i = 0; i < s.args.length && s.args[i].startsWith("-"); i++) {
2483
+ const f = s.args[i];
2484
+ if (f === "-C") {
2485
+ if (resolvePath(cwdAbs, (s.args[i + 1] ?? "").replace(/^["']|["']$/g, "")) !== resolvePath(cwdAbs))
2486
+ return true;
2487
+ i++;
2488
+ }
2489
+ else if (/^--(git-dir|work-tree)(=|$)/.test(f))
2490
+ return true;
2491
+ else if (HOOK_GIT_VALUED.includes(f))
2492
+ i++;
2493
+ }
2494
+ return false;
2495
+ }
2496
+ const hookSub = (s, i = 0) => ((s.program === "git" ? hookGitArgs(s) : s.args).filter((a) => !a.startsWith("-"))[i] ?? "").toLowerCase();
2379
2497
  const HOOK_DB_CLIENTS = ["psql", "pgcli", "mysql", "mysqlsh", "mycli", "mariadb", "sqlite3", "sqlite", "litecli", "mongosh", "mongo", "redis-cli", "clickhouse-client", "clickhouse", "sqlcmd", "bq", "duckdb", "cqlsh", "snowsql", "usql", "dbmate", "goose", "flyway", "liquibase", "atlas", "alembic", "knex", "sequelize", "typeorm", "drizzle-kit", "supabase", "wrangler", "turso", "pscale"];
2498
+ const hookSqlStmt = (t) => /\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX|VIEW|USER|ROLE|EXTENSION)\b/i.test(t) || /\bTRUNCATE\s+(TABLE\s+)?\S/i.test(t) || /\bDELETE\s+FROM\s+\S/i.test(t);
2499
+ const hookUnq = (a) => a.replace(/^["']|["']$/g, "");
2500
+ const HOOK_INTERP = /^(python[0-9.]*|node|deno|bun|ruby|perl|php|tsx|ts-node)(\.exe)?$/;
2501
+ const hookInterp = (s) => HOOK_INTERP.test(s.program) || s.program === "npx" || (s.program === "uv" && s.args[0] === "run")
2502
+ || s.program === "manage.py" || (s.program === "rails" && hookSub(s) === "runner");
2503
+ function hookUnwrap(s) {
2504
+ let args;
2505
+ if (s.program === "uv" && s.args[0] === "run")
2506
+ args = s.args.slice(1);
2507
+ else if (s.program === "npx")
2508
+ args = s.args;
2509
+ else
2510
+ return s;
2511
+ while (args.length && args[0].startsWith("-"))
2512
+ args = args.slice(1);
2513
+ if (!args.length)
2514
+ return s;
2515
+ const bin = hookUnq(args[0]);
2516
+ return { program: basename(bin).toLowerCase(), bin, args: args.slice(1), raw: s.raw };
2517
+ }
2518
+ function hookInline(s0) {
2519
+ const s = hookUnwrap(s0);
2520
+ const django = s.program === "manage.py" || (/^python[0-9.]*(\.exe)?$/.test(s.program) && hookUnq(s.args.find((a) => !a.startsWith("-")) ?? "").endsWith("manage.py"));
2521
+ if (django || (s.program === "rails" && hookSub(s) === "runner"))
2522
+ return /<</.test(s.raw) || s.args.some((a) => ["-c", "--command", "-e", "-"].includes(a));
2523
+ if (!HOOK_INTERP.test(s.program))
2524
+ return false;
2525
+ if (/<</.test(s.raw))
2526
+ return true;
2527
+ if (s.program.startsWith("deno") && s.args[0] === "eval")
2528
+ return true;
2529
+ for (const a of s.args) {
2530
+ if (a === "-")
2531
+ return true;
2532
+ if (a === "-m" || /^-(V|h|-version|-help)$/.test(a))
2533
+ return false;
2534
+ if (/^-[A-Za-z]*[ce]$/.test(a) || a === "--eval" || a === "--command" || (/^(node|bun)/.test(s.program) && (a === "-p" || a === "--print")) || (s.program.startsWith("php") && a === "-r"))
2535
+ return true;
2536
+ if (!a.startsWith("-"))
2537
+ return false;
2538
+ }
2539
+ return true;
2540
+ }
2541
+ function hookExpand(p, at) {
2542
+ const t = hookUnq(p);
2543
+ const m = t.match(/^(~|\$HOME|\$\{HOME\}|\$env:USERPROFILE|\$env:HOME|\$CLAUDE_PROJECT_DIR|\$\{CLAUDE_PROJECT_DIR\}|\$PWD|\$\{PWD\})(?=$|[\\/])/i);
2544
+ const root = !m ? "" : /PROJECT/i.test(m[1]) ? at.projectDir : /PWD/i.test(m[1]) ? at.cwd : at.home;
2545
+ const out = m ? root + t.slice(m[0].length) : t;
2546
+ return /[$`]/.test(out) ? null : out;
2547
+ }
2548
+ function hookSegDirs(segs, at) {
2549
+ let dir = at.cwd;
2550
+ return segs.map((s) => {
2551
+ const here = dir;
2552
+ if (["cd", "pushd", "chdir", "set-location", "sl", "push-location"].includes(s.program)) {
2553
+ const t = s.args.filter((a) => a !== "-P" && a !== "-L" && !/^-(Path|LiteralPath)$/i.test(a))[0];
2554
+ const e = t === undefined ? at.home : t === "-" ? null : hookExpand(t, at);
2555
+ dir = dir === null || e === null ? null : resolvePath(dir, e);
2556
+ }
2557
+ else if (s.program === "popd" || s.program === "pop-location")
2558
+ dir = null;
2559
+ return here;
2560
+ });
2561
+ }
2562
+ const hookNorm = (p) => resolvePath(p).split(pathSep).join("/").toLowerCase().replace(/\/+$/, "");
2563
+ function hookGuardKind(abs, at) {
2564
+ const real = (p) => { try {
2565
+ return realPath(p);
2566
+ }
2567
+ catch {
2568
+ return p;
2569
+ } };
2570
+ const paths = [...new Set([hookNorm(abs), hookNorm(joinPath(real(dirname(abs)), basename(abs)))])];
2571
+ const homes = [...new Set([hookNorm(at.home), hookNorm(real(at.home))])], projects = [...new Set([hookNorm(at.projectDir), hookNorm(real(at.projectDir))])];
2572
+ const settings = (p) => /\/\.claude\/settings[^/]*\.json$/.test(p);
2573
+ for (const p of paths) {
2574
+ for (const h of homes) {
2575
+ if (settings(p) && p.slice(0, p.lastIndexOf("/")) === `${h}/.claude`)
2576
+ return "user settings";
2577
+ if (p.startsWith(`${h}/.claude/plugins/`) && p.slice(h.length + 17).includes("decidio"))
2578
+ return "Decidio plugin";
2579
+ }
2580
+ if (settings(p) && projects.some((r) => p.startsWith(r + "/")))
2581
+ return "project settings";
2582
+ }
2583
+ return null;
2584
+ }
2585
+ function hookGuardDir(abs, at) {
2586
+ const d = hookNorm(abs), h = hookNorm(at.home);
2587
+ return d === `${h}/.claude` || d === `${h}/.claude/plugins` || (basename(d) === ".claude" && d.startsWith(hookNorm(at.projectDir) + "/"));
2588
+ }
2589
+ function hookShowPath(abs, at) {
2590
+ const inside = (root) => { const r = relative(root, abs); return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(pathSep).join("/") : null; };
2591
+ const p = inside(at.projectDir);
2592
+ if (p !== null)
2593
+ return p;
2594
+ const h = inside(at.home);
2595
+ if (h !== null)
2596
+ return "~/" + h;
2597
+ return abs.split(pathSep).join("/");
2598
+ }
2599
+ function hookPlainArgs(args) {
2600
+ const out = [];
2601
+ for (let i = 0; i < args.length; i++) {
2602
+ const a = args[i];
2603
+ if (/^(\d+|&)?(>>?|<)\|?$/.test(a)) {
2604
+ i++;
2605
+ continue;
2606
+ }
2607
+ if (/^(\d+|&)?[<>]/.test(a))
2608
+ continue;
2609
+ out.push(a);
2610
+ }
2611
+ return out;
2612
+ }
2613
+ function hookWriteTargets(s) {
2614
+ const out = [...s.raw.matchAll(/(?:\d+|&)?>>?\|?\s*([^\s;&|<>()]+)/g)].map((m) => m[1]).filter((t) => !t.startsWith("&"));
2615
+ const files = hookPlainArgs(s.args).filter((a) => !a.startsWith("-"));
2616
+ const p = s.program;
2617
+ if (["tee", "sponge", "rm", "unlink", "truncate", "rd", "rmdir", "set-content", "add-content", "out-file", "clear-content", "remove-item", "ri", "del", "erase", "new-item", "ni", "tee-object"].includes(p))
2618
+ out.push(...files);
2619
+ else if (p === "sed" && s.args.some((a) => /^-[A-Za-z]*i/.test(a) || /^--in-place(=|$)/.test(a)))
2620
+ out.push(...files);
2621
+ else if (["mv", "move", "move-item", "mi", "cp", "copy", "copy-item", "cpi"].includes(p)) {
2622
+ const t = s.args.findIndex((a) => a === "-t" || a === "--target-directory");
2623
+ const tdir = t >= 0 ? s.args[t + 1] : s.args.find((a) => a.startsWith("--target-directory="))?.slice(19);
2624
+ const srcs = tdir !== undefined ? files.filter((f) => f !== tdir) : files.slice(0, -1);
2625
+ const dest = tdir ?? files[files.length - 1];
2626
+ if (dest !== undefined)
2627
+ out.push(dest, ...srcs.map((f) => joinPath(hookUnq(dest), basename(hookUnq(f)))));
2628
+ if (["mv", "move", "move-item", "mi"].includes(p))
2629
+ out.push(...srcs);
2630
+ }
2631
+ else if (p === "dd")
2632
+ out.push(...s.args.filter((a) => a.startsWith("of=")).map((a) => a.slice(3)));
2633
+ return out;
2634
+ }
2635
+ function hookGuardHits(segs, at) {
2636
+ const hits = [];
2637
+ const dirs = hookSegDirs(segs, at);
2638
+ segs.forEach((s, i) => {
2639
+ for (const t of hookWriteTargets(s)) {
2640
+ const e = hookExpand(t.replace(/\\/g, "/"), at);
2641
+ const dir = dirs[i];
2642
+ if (e !== null && (dir !== null || isAbsolute(e))) {
2643
+ const abs = resolvePath(dir ?? at.cwd, e);
2644
+ if (hookGuardKind(abs, at) || hookGuardDir(abs, at))
2645
+ hits.push({ key: `path:${abs}`, display: hookShowPath(abs, at) });
2646
+ }
2647
+ else if (/(^|[\\/])\.claude[\\/](settings[^\\/]*\.json|plugins[\\/].*decidio)/i.test(hookUnq(t))) {
2648
+ hits.push({ key: `text:${t}`, display: hookUnq(t) });
2649
+ }
2650
+ }
2651
+ if (s.program === "claude" && /^plugins?$/.test(s.args[0] ?? "") && s.args.some((a) => /^(disable|uninstall|remove|rm)$/.test(a)) && s.args.some((a) => /decidio/i.test(a)))
2652
+ hits.push({ key: `plugin:${s.args.join(" ")}`, display: `claude ${s.args.join(" ")}` });
2653
+ if (/decidio/i.test(s.raw) && s.args.some((a, k) => a === "hook" && s.args[k + 1] === "install") && s.args.some((a) => a === "--cli" || a.startsWith("--cli=")))
2654
+ hits.push({ key: `install:${s.args.join(" ")}`, display: `hook install ${s.args.slice(s.args.indexOf("install") + 1).join(" ")}` });
2655
+ });
2656
+ return hits;
2657
+ }
2380
2658
  const HOOK_RULES = [
2659
+ { category: "decidio-guard", summary: "turns off or changes Decidio's check on this agent: a shell write to .claude/settings*.json, ~/.claude/settings*.json or the Decidio plugin's files (>, >>, tee, sed -i, rm, mv, cp, truncate, Set-Content, Out-File, Remove-Item, Move-Item, Copy-Item), claude plugin disable|uninstall decidio, hook install --cli; a Write/Edit/MultiEdit that removes or alters a Decidio hook, sets disableAllHooks, turns the Decidio plugin off, touches its files or leaves invalid JSON",
2660
+ matches: (segs, _cmd, at) => hookGuardHits(segs, at).length > 0 },
2381
2661
  { category: "delete-recursive", summary: "rm -r/-rf, find -delete / -exec rm, rmdir|rd|del /s, Remove-Item -Recurse, shred",
2382
2662
  matches: (segs) => segs.some((s) => (s.program === "rm" && hookFlag(s.args, "rR", ["--recursive"]))
2383
2663
  || (s.program === "find" && (s.args.includes("-delete") || s.args.some((a, i) => i > 0 && s.args[i - 1] === "-exec" && basename(a) === "rm")))
@@ -2394,10 +2674,10 @@ const HOOK_RULES = [
2394
2674
  || (hookSub(s) === "update-ref" && s.args.includes("-d"))
2395
2675
  || (hookSub(s) === "reflog" && hookSub(s, 1) === "expire")
2396
2676
  || (hookSub(s) === "gc" && s.args.some((a) => a.startsWith("--prune"))))) },
2397
- { category: "sql-destructive", summary: "DROP TABLE|DATABASE|SCHEMA|INDEX|VIEW|USER|ROLE, TRUNCATE, DELETE FROM - sent to a database client (psql, mysql, sqlite3, mongosh, sqlcmd, bq, ...); prisma migrate reset / db push --force-reset; rails db:drop|db:reset",
2677
+ { category: "sql-destructive", summary: "DROP TABLE|DATABASE|SCHEMA|INDEX|VIEW|USER|ROLE, TRUNCATE, DELETE FROM - sent to a database client (psql, mysql, sqlite3, mongosh, sqlcmd, bq, ...) or run by a program (python, node, deno, bun, ruby, perl, php, tsx, ts-node, npx, uv run, manage.py, rails runner) - in its own arguments, or anywhere on the line when it reads code inline (-c, -e, --eval, -, a here-doc, a here-string piped in); prisma migrate reset / db push --force-reset; rails db:drop|db:reset",
2398
2678
  matches: (segs, cmd) => {
2399
2679
  const toDb = segs.some((s) => HOOK_DB_CLIENTS.includes(s.program) || /\bprisma\b/.test(s.raw) || /\.sql\b/.test(s.raw));
2400
- return (toDb && (/\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX|VIEW|USER|ROLE|EXTENSION)\b/i.test(cmd) || /\bTRUNCATE\s+(TABLE\s+)?\S/i.test(cmd) || /\bDELETE\s+FROM\s+\S/i.test(cmd)))
2680
+ return ((toDb || segs.some(hookInline)) && hookSqlStmt(cmd)) || segs.some((s) => hookInterp(s) && hookSqlStmt(s.raw))
2401
2681
  || segs.some((s) => (/\bprisma\b/.test(s.raw) && (/\bmigrate\s+reset\b/.test(s.raw) || /--force-reset\b/.test(s.raw)))
2402
2682
  || (["rails", "rake"].includes(s.program) && /\bdb:(drop|reset|schema:load)\b/.test(s.raw)));
2403
2683
  } },
@@ -2471,11 +2751,207 @@ function hookExtraRules() {
2471
2751
  return { category: r.category, summary: typeof r.summary === "string" ? r.summary : `/${r.pattern}/${r.flags ?? ""}`, matches: (_s, cmd) => { re.lastIndex = 0; return re.test(cmd); } };
2472
2752
  });
2473
2753
  }
2474
- function hookMatch(command) {
2754
+ function hookMatch(command, at, rules = [...HOOK_RULES, ...hookExtraRules()], depth = 0) {
2475
2755
  const segs = hookSegments(command);
2476
- for (const r of [...HOOK_RULES, ...hookExtraRules()])
2477
- if (r.matches(segs, command))
2756
+ for (const r of rules)
2757
+ if (r.matches(segs, command, at))
2478
2758
  return r;
2759
+ if (depth < 2)
2760
+ for (const inner of hookShellInner(command)) {
2761
+ const r = hookMatch(inner, at, rules, depth + 1);
2762
+ if (r)
2763
+ return r;
2764
+ }
2765
+ return null;
2766
+ }
2767
+ function hookSelfDecides(command, depth = 0) {
2768
+ const hit = hookSegments(command).some((s) => {
2769
+ const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
2770
+ const cli = (x) => /^@decidio\/sdk(@\S+)?$/i.test(x) || /^decidio(@\S+)?$/i.test(x)
2771
+ || /(^|[\\/])(decidio-guard|@decidio[\\/]sdk)[\\/](dist[\\/])?cli\.(js|ts)$/i.test(x);
2772
+ const runsCli = s.program === "decidio" || s.program === "decidio.js"
2773
+ || ((["npx", "pnpx", "bunx", "npm", "pnpm", "yarn", "bun", "node", "tsx", "uv", "uvx", "pipx"].includes(s.program) || /^python[0-9.]*$/.test(s.program)) && a.some(cli));
2774
+ const i = a.indexOf("approvals");
2775
+ return runsCli && i >= 0 && a.slice(i + 1).some((x) => /^(approve|reject)$/i.test(x));
2776
+ });
2777
+ return hit || (depth < 2 && hookShellInner(command).some((c) => hookSelfDecides(c, depth + 1)));
2778
+ }
2779
+ function hookShellInner(command) {
2780
+ const out = [];
2781
+ const re = /(?:^|[\s;&|(`])(?:bash|sh|zsh|dash|ksh|pwsh|powershell)(?:\.exe)?((?:\s+-[A-Za-z]+)*?)\s+(?:-[A-Za-z]*c|-Command|-command)\s+/g;
2782
+ for (let m = re.exec(command); m; m = re.exec(command)) {
2783
+ let i = m.index + m[0].length, arg = "";
2784
+ const q = command[i];
2785
+ if (q === "'") {
2786
+ const end = command.indexOf("'", i + 1);
2787
+ arg = command.slice(i + 1, end < 0 ? undefined : end);
2788
+ }
2789
+ else if (q === '"') {
2790
+ for (i += 1; i < command.length && command[i] !== '"'; i++)
2791
+ arg += command[i] === "\\" && (command[i + 1] === '"' || command[i + 1] === "\\") ? command[++i] : command[i];
2792
+ }
2793
+ else
2794
+ arg = command.slice(i).split(/\s/, 1)[0] ?? "";
2795
+ if (arg.trim())
2796
+ out.push(arg);
2797
+ }
2798
+ return out;
2799
+ }
2800
+ const HOOK_FOLLOW_MAX = 256 * 1024;
2801
+ const HOOK_SHELLS = ["bash", "sh", "zsh", "dash", "ksh"];
2802
+ const HOOK_PROGRAM_EXT = /\.(py|js|mjs|cjs|ts|rb)$/i;
2803
+ function hookProjectFile(p, dir, at) {
2804
+ try {
2805
+ const e = hookExpand(p, at);
2806
+ if (e === null)
2807
+ return null;
2808
+ const real = realPath(resolvePath(dir, e));
2809
+ let root = at.projectDir;
2810
+ try {
2811
+ root = realPath(at.projectDir);
2812
+ }
2813
+ catch { }
2814
+ const r = relative(root, real);
2815
+ if (!r || r.startsWith("..") || isAbsolute(r))
2816
+ return null;
2817
+ const st = statSync(real);
2818
+ if (!st.isFile() || st.size > HOOK_FOLLOW_MAX)
2819
+ return null;
2820
+ const buf = readFileSync(real);
2821
+ if (buf.length > HOOK_FOLLOW_MAX || buf.subarray(0, 8192).includes(0))
2822
+ return null;
2823
+ return { rel: r.split(pathSep).join("/"), text: buf.toString("utf8"), sha: createHash("sha256").update(buf).digest("hex") };
2824
+ }
2825
+ catch {
2826
+ return null;
2827
+ }
2828
+ }
2829
+ const hookLines = (text) => {
2830
+ const phys = text.split(/\r?\n/);
2831
+ const out = [];
2832
+ for (let i = 0; i < phys.length; i++) {
2833
+ const n = i + 1;
2834
+ let t = phys[i];
2835
+ while (/\\$/.test(t) && i + 1 < phys.length)
2836
+ t = t.slice(0, -1) + " " + phys[++i];
2837
+ out.push({ n, t });
2838
+ }
2839
+ return out;
2840
+ };
2841
+ function hookScanShell(f, at, rules) {
2842
+ const lines = hookLines(f.text).filter((l) => l.t.trim() && !/^\s*#/.test(l.t));
2843
+ for (const l of lines) {
2844
+ const r = hookMatch(l.t, at, rules);
2845
+ if (r)
2846
+ return { rule: r, rel: f.rel, line: l.n, text: l.t, sha: f.sha };
2847
+ }
2848
+ const r = hookMatch(lines.map((l) => l.t).join("\n"), at, rules);
2849
+ if (!r)
2850
+ return null;
2851
+ const l = lines.find((x) => hookSqlStmt(x.t)) ?? lines[0];
2852
+ return { rule: r, rel: f.rel, line: l.n, text: l.t, sha: f.sha };
2853
+ }
2854
+ function hookScanSql(f, rule) {
2855
+ const lines = hookLines(f.text).filter((l) => l.t.trim() && !/^\s*--/.test(l.t));
2856
+ const l = lines.find((x) => hookSqlStmt(x.t)) ?? (hookSqlStmt(lines.map((x) => x.t).join("\n")) ? lines.find((x) => /\b(DROP|TRUNCATE|DELETE)\b/i.test(x.t)) : undefined);
2857
+ return l ? { rule, rel: f.rel, line: l.n, text: l.t, sha: f.sha } : null;
2858
+ }
2859
+ function hookScanProgram(f, rule) {
2860
+ const l = hookLines(f.text).find((x) => /\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX|VIEW|USER|ROLE|EXTENSION)\b/i.test(x.t) || /\bTRUNCATE\s+(TABLE\s+)?["'`[]?[A-Za-z_]/.test(x.t) || /\btruncate\s+table\s+\S/i.test(x.t));
2861
+ return l ? { rule, rel: f.rel, line: l.n, text: l.t, sha: f.sha } : null;
2862
+ }
2863
+ const HOOK_PM_OWN = new Set(["install", "i", "add", "remove", "rm", "uninstall", "un", "update", "up", "upgrade", "exec", "dlx", "create", "init", "publish", "pack", "link", "ln", "unlink", "why", "list", "ls", "outdated", "audit", "config", "info", "version", "help", "bin", "cache", "global", "set", "workspace", "workspaces", "dedupe", "import", "rebuild", "rb", "prune", "store", "patch", "plugin", "node", "env", "root", "fetch", "licenses", "login", "logout", "owner", "tag", "team", "unplug", "constraints", "explain", "npm", "dist-tag", "deploy", "setup", "doctor", "server"]);
2864
+ function hookPackageScript(s) {
2865
+ const a = hookPlainArgs(s.args).filter((x) => !x.startsWith("-"));
2866
+ if (s.program === "npm") {
2867
+ if (["run", "run-script", "rum", "urn"].includes(a[0] ?? ""))
2868
+ return a[1] ?? null;
2869
+ return { test: "test", t: "test", tst: "test", start: "start", stop: "stop", restart: "restart" }[a[0] ?? ""] ?? null;
2870
+ }
2871
+ if (s.program === "bun")
2872
+ return a[0] === "run" ? a[1] ?? null : null;
2873
+ if (s.program === "pnpm" || s.program === "yarn")
2874
+ return a[0] === "run" ? a[1] ?? null : a[0] && !HOOK_PM_OWN.has(a[0]) ? a[0] : null;
2875
+ return null;
2876
+ }
2877
+ function hookProgramFile(s) {
2878
+ const ext = /^python[0-9.]*(\.exe)?$/.test(s.program) ? /\.py$/i : /^(node|deno|bun|tsx|ts-node)(\.exe)?$/.test(s.program) ? /\.(js|mjs|cjs|ts)$/i : /^ruby(\.exe)?$/.test(s.program) ? /\.rb$/i : null;
2879
+ if (!ext)
2880
+ return null;
2881
+ let a = hookPlainArgs(s.args);
2882
+ if (/^(deno|bun)/.test(s.program) && a[0] === "run")
2883
+ a = a.slice(1);
2884
+ for (const x of a) {
2885
+ if (x === "-" || x === "-m" || /^-[A-Za-z]*[ce]$/.test(x) || x === "--eval" || x === "-p" || x === "--print")
2886
+ return null;
2887
+ if (x.startsWith("-"))
2888
+ continue;
2889
+ return ext.test(hookUnq(x)) ? hookUnq(x) : null;
2890
+ }
2891
+ return null;
2892
+ }
2893
+ function hookFollow(segs, at) {
2894
+ const rules = [...HOOK_RULES, ...hookExtraRules()];
2895
+ const sql = HOOK_RULES.find((r) => r.category === "sql-destructive");
2896
+ const dirs = hookSegDirs(segs, at);
2897
+ for (let i = 0; i < segs.length; i++) {
2898
+ const dir = dirs[i];
2899
+ if (dir === null || dir === undefined)
2900
+ continue;
2901
+ const s = hookUnwrap(segs[i]);
2902
+ const file = (p) => (p ? hookProjectFile(p, dir, at) : null);
2903
+ const redirIn = s.raw.match(/(?:^|[^<])<(?![<(])\s*([^\s<>|&;()]+)/)?.[1];
2904
+ let sh;
2905
+ if (HOOK_SHELLS.includes(s.program)) {
2906
+ const a = hookPlainArgs(s.args);
2907
+ sh = a.some((x) => /^-[A-Za-z]*c[A-Za-z]*$/.test(x)) ? undefined : a.find((x) => !x.startsWith("-")) ?? redirIn;
2908
+ }
2909
+ else if (s.program === "source" || s.program === ".")
2910
+ sh = hookPlainArgs(s.args)[0];
2911
+ else if (/[\\/]/.test(s.bin) && !HOOK_PROGRAM_EXT.test(s.bin) && !/\.sql$/i.test(s.bin))
2912
+ sh = s.bin;
2913
+ if (sh !== undefined) {
2914
+ const f = file(sh);
2915
+ const hit = f && hookScanShell(f, at, rules);
2916
+ if (hit)
2917
+ return hit;
2918
+ continue;
2919
+ }
2920
+ if (HOOK_DB_CLIENTS.includes(s.program)) {
2921
+ const k = s.args.findIndex((x) => x === "-f" || x === "--file");
2922
+ const sqlFile = (s.program === "psql" ? (k >= 0 ? s.args[k + 1] : s.args.find((x) => x.startsWith("--file="))?.slice(7)) : undefined)
2923
+ ?? redirIn ?? (["sqlite3", "sqlite", "duckdb"].includes(s.program) ? s.raw.match(/\.read\s+["']?([^"'\s]+)/)?.[1] : undefined);
2924
+ const f = file(sqlFile);
2925
+ const hit = f && hookScanSql(f, sql);
2926
+ if (hit)
2927
+ return hit;
2928
+ continue;
2929
+ }
2930
+ const script = hookPackageScript(s);
2931
+ if (script) {
2932
+ const f = file("package.json");
2933
+ let body;
2934
+ try {
2935
+ body = f ? JSON.parse(f.text)?.scripts?.[script] : undefined;
2936
+ }
2937
+ catch {
2938
+ body = undefined;
2939
+ }
2940
+ if (f && typeof body === "string") {
2941
+ const r = hookMatch(body, at, rules);
2942
+ if (r) {
2943
+ const n = f.text.split(/\r?\n/).findIndex((l) => l.replace(/\s+/g, "").includes(JSON.stringify(script) + ":"));
2944
+ return { rule: r, rel: f.rel, line: n + 1 || 1, text: body, sha: f.sha };
2945
+ }
2946
+ continue;
2947
+ }
2948
+ }
2949
+ const prog = hookProgramFile(s) ?? (/[\\/]/.test(s.bin) && HOOK_PROGRAM_EXT.test(s.bin) ? s.bin : null);
2950
+ const f = file(prog ?? undefined);
2951
+ const hit = f && hookScanProgram(f, sql);
2952
+ if (hit)
2953
+ return hit;
2954
+ }
2479
2955
  return null;
2480
2956
  }
2481
2957
  function hookRedact(command) {
@@ -2487,21 +2963,29 @@ function hookRedact(command) {
2487
2963
  .replace(/((?:-H|--header)\s+["']?(?:authorization|x-api-key|cookie)\s*:\s*)[^"']+/gi, "$1[REDACTED]")
2488
2964
  .replace(/\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk_(?:live|test)_[A-Za-z0-9]{10,})\b/g, "[REDACTED]");
2489
2965
  }
2490
- function hookSubject(category, segs, cwdAbs, projectDir) {
2491
- const textOnly = (why) => ({ fingerprint: null, display: `text-only (${why})` });
2492
- const relOrAbs = (p) => { const r = relative(projectDir, p); return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(pathSep).join("/") : p; };
2493
- const nonFlags = (s) => s.args.filter((a) => !a.startsWith("-"));
2494
- const git = () => {
2966
+ const hookGitMemo = new Map();
2967
+ function hookGitRead(cwdAbs) {
2968
+ const hit = hookGitMemo.get(cwdAbs);
2969
+ if (hit)
2970
+ return hit;
2971
+ const run = (args) => {
2495
2972
  try {
2496
- const run = (args) => execFileSync("git", args, { cwd: cwdAbs, timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
2497
- const head = run(["rev-parse", "HEAD"]);
2498
- const branch = run(["rev-parse", "--abbrev-ref", "HEAD"]);
2499
- return /^[0-9a-f]{40}$/.test(head) && branch ? { head, branch } : null;
2973
+ return execFileSync("git", args, { cwd: cwdAbs, timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim() || null;
2500
2974
  }
2501
2975
  catch {
2502
2976
  return null;
2503
2977
  }
2504
2978
  };
2979
+ const head = run(["rev-parse", "HEAD"]);
2980
+ const out = { head: head && /^[0-9a-f]{40}$/.test(head) ? head : null, branch: head ? run(["rev-parse", "--abbrev-ref", "HEAD"]) : null, origin: run(["remote", "get-url", "origin"]) };
2981
+ hookGitMemo.set(cwdAbs, out);
2982
+ return out;
2983
+ }
2984
+ function hookSubject(category, segs, cwdAbs, projectDir, at) {
2985
+ const textOnly = (why) => ({ fingerprint: null, display: `text-only (${why})` });
2986
+ const relOrAbs = (p) => { const r = relative(projectDir, p); return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(pathSep).join("/") : p; };
2987
+ const nonFlags = (s) => s.args.filter((a) => !a.startsWith("-"));
2988
+ const git = () => { const g = hookGitRead(cwdAbs); return g.head && g.branch ? { head: g.head, branch: g.branch } : null; };
2505
2989
  const firstOf = (programs) => segs.findIndex((s) => programs.includes(s.program));
2506
2990
  const movedTree = (i) => segs.slice(0, i).some((s) => s.program === "cd" || s.program === "pushd");
2507
2991
  if (category === "git-rewrite" || category === "infra-apply") {
@@ -2510,11 +2994,13 @@ function hookSubject(category, segs, cwdAbs, projectDir) {
2510
2994
  return textOnly("no subject");
2511
2995
  if (movedTree(i))
2512
2996
  return textOnly("a cd on the same line moves the call to another directory");
2997
+ if (category === "git-rewrite" && hookGitElsewhere(segs[i], cwdAbs))
2998
+ return textOnly("git -C names another directory");
2513
2999
  const g = git();
2514
3000
  if (!g)
2515
3001
  return textOnly("not a git repository here");
2516
3002
  const seg = segs[i];
2517
- const args = nonFlags(seg);
3003
+ const args = category === "git-rewrite" ? hookGitArgs(seg).filter((a) => !a.startsWith("-")) : nonFlags(seg);
2518
3004
  const extra = category === "git-rewrite" ? args.slice(1).join(" ") : [relOrAbs(cwdAbs), ...seg.args.filter((a) => /^(-var-file|--var-file|-plan|--plan)/.test(a) || /\.(tfplan|tfvars)$/.test(a))].join(" ");
2519
3005
  return { fingerprint: `${category}:${g.head}:${g.branch}:${extra}`, display: `HEAD ${g.head.slice(0, 7)} on ${g.branch}${extra ? ` · ${hookRedact(extra).slice(0, 120)}` : ""}` };
2520
3006
  }
@@ -2533,17 +3019,245 @@ function hookSubject(category, segs, cwdAbs, projectDir) {
2533
3019
  if (category === "sql-destructive") {
2534
3020
  const stIdx = segs.findIndex((s) => /\b(DROP|TRUNCATE|DELETE)\b/i.test(s.raw));
2535
3021
  const dbIdx = firstOf(HOOK_DB_CLIENTS);
2536
- const client = dbIdx >= 0 ? segs[dbIdx] : stIdx >= 0 ? segs[stIdx] : null;
3022
+ const own = segs.findIndex((s) => hookInterp(s) && hookSqlStmt(s.raw));
3023
+ const inIdx = dbIdx >= 0 ? -1 : own >= 0 ? own : segs.findIndex(hookInline);
3024
+ const client = dbIdx >= 0 ? segs[dbIdx] : inIdx >= 0 ? segs[inIdx] : stIdx >= 0 ? segs[stIdx] : null;
2537
3025
  if (!client)
2538
3026
  return textOnly("no statement");
2539
3027
  const stmtSeg = stIdx >= 0 ? segs[stIdx] : client;
2540
3028
  const stmt = (stmtSeg.raw.match(/\b(DROP\s+\w+[^;'"]*|TRUNCATE[^;'"]*|DELETE\s+FROM[^;'"]*)/i)?.[1] ?? stmtSeg.raw).replace(/\s+/g, " ").trim();
2541
3029
  const stmtAt = client.args.findIndex((a) => /^["']?(DROP|TRUNCATE|DELETE)\b/i.test(a));
2542
- const target = (stmtAt >= 0 ? client.args.slice(0, stmtAt) : client.args).filter((a) => !a.startsWith("-")).join(" ");
3030
+ const target = (stmtAt >= 0 ? client.args.slice(0, stmtAt) : client.args).filter((a) => !a.startsWith("-") && !/^["'@]+$/.test(a)).join(" ");
2543
3031
  return { fingerprint: `sql:${client.program}:${target}:${stmt.toUpperCase()}`, display: hookRedact(`${client.program}${target ? ` ${target}` : ""}: ${stmt}`).slice(0, 160) };
2544
3032
  }
3033
+ if (category === "decidio-guard" && at) {
3034
+ const hits = hookGuardHits(segs, at);
3035
+ if (!hits.length)
3036
+ return textOnly("no subject");
3037
+ return { fingerprint: `guard:${[...new Set(hits.map((h) => h.key))].sort().join("\0")}`, display: hookRedact([...new Set(hits.map((h) => h.display))].join(", ")).slice(0, 160) };
3038
+ }
2545
3039
  return textOnly("text binds this category");
2546
3040
  }
3041
+ const hookIsOurs = (h) => typeof h?.command === "string" && /\bhook (pretool|posttool)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command);
3042
+ const hookSorted = (v) => Array.isArray(v) ? v.map(hookSorted) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, hookSorted(v[k])])) : v;
3043
+ function hookOurHandlers(settings) {
3044
+ const out = [];
3045
+ const hooks = settings && typeof settings === "object" ? settings.hooks : undefined;
3046
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
3047
+ return out;
3048
+ for (const [event, list] of Object.entries(hooks))
3049
+ if (Array.isArray(list))
3050
+ for (const g of list)
3051
+ if (g && typeof g === "object" && Array.isArray(g.hooks))
3052
+ for (const h of g.hooks)
3053
+ if (hookIsOurs(h)) {
3054
+ const matcher = typeof g.matcher === "string" ? g.matcher : "";
3055
+ out.push({ event, matcher, iff: typeof h.if === "string" ? h.if : "", key: `${event}\0${matcher}\0${JSON.stringify(hookSorted(h))}` });
3056
+ }
3057
+ return out;
3058
+ }
3059
+ function hookFindEdit(text, from) {
3060
+ if (text.includes(from))
3061
+ return from;
3062
+ const fold = (s) => s.replace(/[‘’]/g, "'").replace(/[“”]/g, '"');
3063
+ const i = fold(text).indexOf(fold(from));
3064
+ return i < 0 ? null : text.slice(i, i + from.length);
3065
+ }
3066
+ function hookFileGuard(tool, ti, at) {
3067
+ const t = (ti && typeof ti === "object" ? ti : {});
3068
+ if (typeof t.file_path !== "string" || !t.file_path)
3069
+ return null;
3070
+ const abs = resolvePath(at.cwd, t.file_path);
3071
+ const kind = hookGuardKind(abs, at);
3072
+ if (!kind)
3073
+ return null;
3074
+ const display = hookShowPath(abs, at);
3075
+ const sha = (s) => createHash("sha256").update(s, "utf8").digest("hex");
3076
+ let before = null, unreadable = false;
3077
+ try {
3078
+ before = existsSync(abs) ? readFileSync(abs, "utf8") : null;
3079
+ }
3080
+ catch {
3081
+ unreadable = true;
3082
+ }
3083
+ let after;
3084
+ if (tool === "Write") {
3085
+ if (typeof t.content !== "string")
3086
+ return null;
3087
+ after = t.content;
3088
+ }
3089
+ else {
3090
+ const edits = tool === "Edit" ? [t] : Array.isArray(t.edits) ? t.edits : null;
3091
+ if (!edits || !edits.length)
3092
+ return null;
3093
+ if (unreadable)
3094
+ return { abs, display, changes: ["the current file could not be read, so the change cannot be checked"], sha: sha(`unreadable\n${JSON.stringify(edits)}`) };
3095
+ let text = before;
3096
+ for (const raw of edits) {
3097
+ const e = (raw && typeof raw === "object" ? raw : {});
3098
+ const from = e.old_string, to = e.new_string;
3099
+ if (typeof from !== "string" || typeof to !== "string")
3100
+ return null;
3101
+ if (text === null) {
3102
+ if (from !== "")
3103
+ return null;
3104
+ text = to;
3105
+ continue;
3106
+ }
3107
+ if (from === "") {
3108
+ text = text.trim() === "" ? to : to + text;
3109
+ continue;
3110
+ }
3111
+ const found = hookFindEdit(text, from);
3112
+ if (found === null)
3113
+ return null;
3114
+ text = e.replace_all === true ? text.split(found).join(to) : text.replace(found, () => to);
3115
+ }
3116
+ after = text ?? "";
3117
+ }
3118
+ const changes = [];
3119
+ if (kind === "Decidio plugin")
3120
+ changes.push("changes the Decidio plugin's own files");
3121
+ else {
3122
+ let parsed, valid = true;
3123
+ try {
3124
+ parsed = JSON.parse(after);
3125
+ }
3126
+ catch {
3127
+ valid = false;
3128
+ }
3129
+ const obj = valid && !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
3130
+ if (!obj)
3131
+ changes.push(valid ? "leaves the file something other than a JSON object" : "leaves the file invalid JSON");
3132
+ let prev = {};
3133
+ if (unreadable)
3134
+ changes.push("the current file could not be read, so what it removes cannot be checked");
3135
+ else if (before !== null) {
3136
+ try {
3137
+ prev = JSON.parse(before);
3138
+ }
3139
+ catch {
3140
+ prev = {};
3141
+ }
3142
+ }
3143
+ const now = obj ? parsed : {};
3144
+ const nowHandlers = hookOurHandlers(now);
3145
+ const kept = new Set(nowHandlers.map((h) => h.key)), slots = new Set(nowHandlers.map((h) => `${h.event}\0${h.matcher}\0${h.iff}`));
3146
+ const lost = hookOurHandlers(prev).filter((h) => !kept.has(h.key));
3147
+ const verb = (h) => slots.has(`${h.event}\0${h.matcher}\0${h.iff}`) ? "changes" : "removes";
3148
+ if (lost.length === 1)
3149
+ changes.push(`${verb(lost[0])} Decidio's ${lost[0].event} hook${lost[0].matcher ? ` (${lost[0].matcher}${lost[0].iff ? `, ${lost[0].iff}` : ""})` : ""}`);
3150
+ else if (lost.length) {
3151
+ const removed = lost.filter((h) => verb(h) === "removes").length;
3152
+ changes.push(`${removed === lost.length ? "removes" : removed ? "removes or changes" : "changes"} ${lost.length} of Decidio's hooks (${[...new Set(lost.map((h) => h.event))].join(", ")})`);
3153
+ }
3154
+ if (now.disableAllHooks === true && prev?.disableAllHooks !== true)
3155
+ changes.push("sets disableAllHooks");
3156
+ const ep = (o) => (o && typeof o === "object" && o.enabledPlugins && typeof o.enabledPlugins === "object" && !Array.isArray(o.enabledPlugins) ? o.enabledPlugins : {});
3157
+ const off = Object.keys({ ...ep(prev), ...ep(now) }).filter((k) => /decidio/i.test(k) && (ep(now)[k] === false || (ep(prev)[k] === true && ep(now)[k] !== true)));
3158
+ if (off.length)
3159
+ changes.push(`turns the Decidio plugin off (enabledPlugins ${off.join(", ")})`);
3160
+ }
3161
+ return changes.length ? { abs, display, changes, sha: sha(after) } : null;
3162
+ }
3163
+ const HOOK_MODES = ["default", "acceptEdits", "plan", "auto", "dontAsk", "bypassPermissions"];
3164
+ function hookRepoName(url) {
3165
+ const u = url.trim();
3166
+ let host, path;
3167
+ const scheme = u.match(/^[a-z][a-z0-9+.-]*:\/\/(.*)$/i);
3168
+ if (scheme) {
3169
+ const rest = scheme[1];
3170
+ const slash = rest.indexOf("/");
3171
+ if (slash < 0)
3172
+ return null;
3173
+ const auth = rest.slice(0, slash);
3174
+ host = auth.slice(auth.lastIndexOf("@") + 1).replace(/:\d*$/, "");
3175
+ path = rest.slice(slash + 1);
3176
+ }
3177
+ else {
3178
+ const scp = u.match(/^(?:[^@/]+@)?([^:/\\]+):(?!\/\/)(.+)$/);
3179
+ if (!scp || /^[a-z]:[\\/]/i.test(u))
3180
+ return null;
3181
+ host = scp[1];
3182
+ path = scp[2];
3183
+ }
3184
+ path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
3185
+ if (!host || !path)
3186
+ return null;
3187
+ host = host.toLowerCase();
3188
+ return hookRedact(host === "github.com" ? path : `${host}/${path}`).slice(0, 200);
3189
+ }
3190
+ function hookCardGit(segs, at) {
3191
+ if (segs?.some((s) => ["cd", "pushd", "popd", "chdir", "set-location", "sl", "push-location", "pop-location"].includes(s.program) || (s.program === "git" && hookGitElsewhere(s, at.cwd))))
3192
+ return {};
3193
+ const g = hookGitRead(at.cwd);
3194
+ const repo = g.origin ? hookRepoName(g.origin) : null;
3195
+ return { ...(repo ? { repo } : {}), ...(g.branch && g.branch !== "HEAD" ? { branch: hookRedact(g.branch).slice(0, 200) } : {}), ...(g.head ? { commit: g.head.slice(0, 7) } : {}) };
3196
+ }
3197
+ const HOOK_ASK_TAIL = 512 * 1024;
3198
+ function hookAsked(transcriptPath) {
3199
+ if (process.env.DECIDIO_HOOK_SHARE_PROMPT === "0" || typeof transcriptPath !== "string" || !transcriptPath)
3200
+ return null;
3201
+ try {
3202
+ let text;
3203
+ const fd = openSync(transcriptPath, "r");
3204
+ try {
3205
+ const size = fstatSync(fd).size, start = Math.max(0, size - HOOK_ASK_TAIL);
3206
+ const buf = Buffer.alloc(size - start);
3207
+ let got = 0;
3208
+ while (got < buf.length) {
3209
+ const n = readSync(fd, buf, got, buf.length - got, start + got);
3210
+ if (!n)
3211
+ break;
3212
+ got += n;
3213
+ }
3214
+ text = buf.subarray(0, got).toString("utf8");
3215
+ if (start > 0)
3216
+ text = text.slice(text.indexOf("\n") + 1);
3217
+ }
3218
+ finally {
3219
+ closeSync(fd);
3220
+ }
3221
+ const notPrompt = /^\s*<(task-notification|bash-input|bash-stdout|bash-stderr|local-command-stdout|local-command-stderr)>/;
3222
+ const prompts = [];
3223
+ for (const line of text.split("\n")) {
3224
+ if (!line.includes('"last-prompt"') && !line.includes('"external"'))
3225
+ continue;
3226
+ let o;
3227
+ try {
3228
+ o = JSON.parse(line);
3229
+ }
3230
+ catch {
3231
+ continue;
3232
+ }
3233
+ if (o?.type === "last-prompt" && typeof o.lastPrompt === "string" && o.lastPrompt.trim()) {
3234
+ if (!notPrompt.test(o.lastPrompt))
3235
+ prompts.push(o.lastPrompt);
3236
+ }
3237
+ else if (o?.type === "user" && o.userType === "external" && !o.isMeta && !o.isSidechain && !o.isCompactSummary && !o.isVisibleInTranscriptOnly) {
3238
+ const c = o.message?.content;
3239
+ const said = typeof c === "string" ? c : Array.isArray(c) ? c.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n") : "";
3240
+ if (said.trim() && !notPrompt.test(said))
3241
+ prompts.push(said);
3242
+ }
3243
+ }
3244
+ const asked = prompts[prompts.length - 1];
3245
+ if (asked === undefined)
3246
+ return null;
3247
+ let before;
3248
+ if (asked.length < 40)
3249
+ for (let i = prompts.length - 2; i >= 0; i--)
3250
+ if (prompts[i] !== asked) {
3251
+ before = prompts[i];
3252
+ break;
3253
+ }
3254
+ const cut = (s) => { const r = hookRedact(s); const c = r.slice(0, 1000); return /[\uD800-\uDBFF]$/.test(c) ? c.slice(0, -1) : c; };
3255
+ return { asked: cut(asked), ...(before !== undefined ? { askedBefore: cut(before) } : {}), askedLength: asked.length };
3256
+ }
3257
+ catch {
3258
+ return null;
3259
+ }
3260
+ }
2547
3261
  async function readHookStdin() {
2548
3262
  if (process.stdin.isTTY)
2549
3263
  return { ok: false, why: "no hook input on stdin - this command is run BY Claude Code with the tool call as JSON on stdin (set it up with: npx @decidio/sdk hook install)" };
@@ -2574,11 +3288,11 @@ async function readHookStdin() {
2574
3288
  function hookConfig() {
2575
3289
  const token = process.env.DECIDIO_API_TOKEN;
2576
3290
  if (!token)
2577
- return { missing: "DECIDIO_API_TOKEN is not set - no agent token in this folder's .env or the environment" };
3291
+ return { missing: "this project is not connected to Decidio (no connection, no agent token in this folder's .env or the environment)" };
2578
3292
  return {
2579
3293
  apiUrl: API, agentId: process.env.DECIDIO_AGENT_ID || defaultAgentId(), apiToken: token, sourceSystem: "claude-code",
2580
3294
  agentKey: process.env.DECIDIO_AGENT_KEY || undefined, agentDid: process.env.DECIDIO_AGENT_DID || undefined, workspaceId: process.env.DECIDIO_WORKSPACE_ID || undefined,
2581
- appUrl: process.env.DECIDIO_APP_URL || APP_URL,
3295
+ appUrl: APP_URL,
2582
3296
  };
2583
3297
  }
2584
3298
  const hookStateDir = () => (process.env.CLAUDE_PLUGIN_DATA ? joinPath(process.env.CLAUDE_PLUGIN_DATA, "hooks") : joinPath(sessionDir(), "hooks"));
@@ -2694,51 +3408,81 @@ async function cmdHookPretool() {
2694
3408
  return deny(`Decidio hook: ${read.why} - refusing this tool call (fail closed).`);
2695
3409
  const input = read.input;
2696
3410
  const tool = typeof input.tool_name === "string" ? input.tool_name : "";
2697
- const command = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
2698
- if (!["Bash", "PowerShell"].includes(tool) || !command.trim())
2699
- return 0;
2700
- let rule;
2701
- try {
2702
- rule = hookMatch(command);
2703
- }
2704
- catch (e) {
2705
- return deny(`Decidio hook: ${e?.message ?? e} - every command is refused until it is fixed.`);
3411
+ const projectDir = resolvePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
3412
+ const cwdAbs = resolvePath(typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd());
3413
+ const at = { cwd: cwdAbs, projectDir, home: homedir() };
3414
+ let g;
3415
+ if (tool === "Bash" || tool === "PowerShell") {
3416
+ const command = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
3417
+ if (!command.trim())
3418
+ return 0;
3419
+ if (hookSelfDecides(command))
3420
+ return deny("Decidio: an agent can't decide an approval - `approvals approve|reject` is the person's, and the person decides on their phone, the web app, Slack or Teams. Nothing ran. Tell the user which decision is waiting; never try another way to decide it.", "Decidio: the agent tried to decide an approval - refused");
3421
+ const segs = hookSegments(command);
3422
+ let rule, followed = null;
3423
+ try {
3424
+ rule = hookMatch(command, at);
3425
+ if (!rule) {
3426
+ followed = hookFollow(segs, at);
3427
+ rule = followed?.rule ?? null;
3428
+ }
3429
+ }
3430
+ catch (e) {
3431
+ return deny(`Decidio hook: ${e?.message ?? e} - every command is refused until it is fixed.`);
3432
+ }
3433
+ if (!rule)
3434
+ return 0;
3435
+ const canonical = command.replace(/\s+/g, " ").trim();
3436
+ const base = hookSubject(rule.category, segs, cwdAbs, projectDir, at);
3437
+ const subject = followed ? { fingerprint: `${base.fingerprint ?? ""}\nfile:${followed.rel}:${followed.sha}`, display: followed.rel } : base;
3438
+ g = { category: rule.category, isFile: false, text: canonical, binding: canonical + "\n" + (subject.fingerprint ?? ""), subject, identity: { command: canonical, subject: subject.fingerprint ?? "" }, segs,
3439
+ ...(followed ? { scriptLine: hookRedact(`${followed.rel}:${followed.line}: ${followed.text.replace(/\s+/g, " ").trim()}`).slice(0, 160) } : {}) };
3440
+ }
3441
+ else if (tool === "Write" || tool === "Edit" || tool === "MultiEdit") {
3442
+ const fc = hookFileGuard(tool, input.tool_input, at);
3443
+ if (!fc)
3444
+ return 0;
3445
+ const what = fc.changes.join("; ");
3446
+ g = { category: "decidio-guard", isFile: true, text: `${tool} ${fc.display} — ${what}`, binding: `${tool}\n${fc.abs}\n${fc.sha}`, subject: { fingerprint: fc.sha, display: `${fc.display}: ${what}` }, identity: { path: fc.abs, after: fc.sha }, segs: null };
2706
3447
  }
2707
- if (!rule)
3448
+ else
2708
3449
  return 0;
2709
- const short = hookShort(hookRedact(command.replace(/\s+/g, " ").trim()));
2710
- const projectDir = resolvePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
3450
+ const w = g.isFile
3451
+ ? { noun: "edit", run: "make", ran: "Nothing was written.", anew: "If the user wants a different change to this file, that is a new request and may be asked." }
3452
+ : { noun: "command", run: "run", ran: "Nothing ran.", anew: "If the user changes what it would act on (a new commit, a different path), that is a new request and may be asked." };
3453
+ const short = hookShort(hookRedact(g.text.replace(/\s+/g, " ").trim()));
2711
3454
  const cfg = hookConfig();
2712
3455
  if ("missing" in cfg)
2713
- return deny(`Decidio: "${short}" is a ${rule.category} command that needs a person's approval, but this project has no Decidio agent credentials (${cfg.missing}). Nothing ran. Ask the user to run \`npx @decidio/sdk init\` in ${projectDir}, then retry the same command.`, `Decidio: no agent credentials in ${projectDir} - run npx @decidio/sdk init`);
2714
- const canonical = command.replace(/\s+/g, " ").trim();
2715
- const cwdAbs = resolvePath(typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd());
3456
+ return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval, but this project is not connected to Decidio yet. ${w.ran} Run \`npx @decidio/sdk connect\` in ${projectDir} and show the user the link it prints (they allow it on the web), then run \`npx @decidio/sdk connect --wait\`; once it says connected, retry the same ${w.noun}.`, `Decidio: this project is not connected - run npx @decidio/sdk connect`);
2716
3457
  const rel = relative(projectDir, cwdAbs);
2717
3458
  const cwdRel = !rel ? "." : rel.startsWith("..") || isAbsolute(rel) ? cwdAbs : rel;
2718
3459
  const session = typeof input.session_id === "string" ? input.session_id : "";
2719
- const subject = hookSubject(rule.category, hookSegments(command), cwdAbs, projectDir);
2720
3460
  let material;
2721
3461
  try {
2722
3462
  material = bindingMaterial(cfg);
2723
3463
  }
2724
3464
  catch (e) {
2725
- return deny(`Decidio hook: this command cannot be bound to an approval (${e?.message ?? e}) - refusing to run it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
3465
+ return deny(`Decidio hook: this ${w.noun} cannot be bound to an approval (${e?.message ?? e}) - refusing to ${w.run} it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
2726
3466
  }
2727
3467
  if (!material)
2728
- return deny(`Decidio hook: this command cannot be bound to an approval (no key material (DECIDIO_API_TOKEN, or DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID)) - refusing to run it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
2729
- const ctx = { action: "shellExec", amount: 0, scope: boundScope(material, canonical + "\n" + (subject.fingerprint ?? "")),
2730
- context: { command: hookRedact(canonical).slice(0, 400), cwd: cwdRel.slice(0, 200), category: rule.category, host: "claude-code", tool, subject: hookRedact(subject.display).slice(0, 200) } };
2731
- const request = adapterRequest(ctx);
3468
+ return deny(`Decidio hook: this ${w.noun} cannot be bound to an approval (no key material (DECIDIO_API_TOKEN, or DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID)) - refusing to ${w.run} it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
3469
+ const committed = { command: hookRedact(g.text).slice(0, 400), cwd: cwdRel.slice(0, 200), category: g.category, host: "claude-code", tool, subject: hookRedact(g.subject.display).slice(0, 200), ...(g.scriptLine ? { scriptLine: g.scriptLine } : {}) };
3470
+ const card = {
3471
+ ...(typeof input.permission_mode === "string" && HOOK_MODES.includes(input.permission_mode) ? { mode: input.permission_mode } : {}),
3472
+ ...hookCardGit(g.segs, at), ...(hookAsked(input.transcript_path) ?? {}),
3473
+ };
3474
+ const ctx = { action: "shellExec", amount: 0, scope: boundScope(material, g.binding), context: { ...committed, ...card } };
3475
+ const request = adapterRequest({ ...ctx, context: committed });
2732
3476
  let key;
2733
3477
  try {
2734
- key = idempotencyKey(material, { host: "claude-code", tool, command: canonical, cwd: cwdRel, session, subject: subject.fingerprint ?? "" });
3478
+ key = idempotencyKey(material, { host: "claude-code", tool, ...(g.isFile ? {} : { cwd: cwdRel }), session, ...g.identity });
2735
3479
  }
2736
3480
  catch (e) {
2737
- return deny(`Decidio hook: this command cannot be bound to an approval (${e?.message ?? e}) - refusing to run it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
3481
+ return deny(`Decidio hook: this ${w.noun} cannot be bound to an approval (${e?.message ?? e}) - refusing to ${w.run} it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
2738
3482
  }
2739
3483
  let auth;
2740
3484
  try {
2741
- let a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key });
3485
+ let a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key, request });
2742
3486
  if (a.decision === "duplicate") {
2743
3487
  const twin = a.duplicateOf;
2744
3488
  const label = twin.displayNumber ?? twin.decisionId;
@@ -2746,38 +3490,39 @@ async function cmdHookPretool() {
2746
3490
  const why = twin.reason ? ` ("${twin.reason}")` : "";
2747
3491
  const stored = readJustification(twin.decisionId);
2748
3492
  if (!stored) {
2749
- return deny(`Decidio: "${short}" looks like a duplicate of ${label}, which a person refused${when}${why}. Nothing ran. If this change is different, ask the user what changed, then run \`npx @decidio/sdk hook justify ${twin.decisionId} "<their words>"\` and retry EXACTLY the same command; the approver will see their justification beside the earlier refusal. A different commit or path is a new request on its own.`, `Decidio: ${label} was refused${when} - a justification is needed to ask again`);
3493
+ return deny(`Decidio: "${short}" looks like a duplicate of ${label}, which a person refused${when}${why}. ${w.ran} If this change is different, ask the user what changed, then run \`npx @decidio/sdk hook justify ${twin.decisionId} "<their words>"\` and retry EXACTLY the same ${w.noun}; the approver will see their justification beside the earlier refusal. A different commit or path is a new request on its own.`, `Decidio: ${label} was refused${when} - a justification is needed to ask again`);
2750
3494
  }
2751
- a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key, justification: stored });
3495
+ a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key, request, justification: stored });
2752
3496
  if (a.decision === "duplicate")
2753
- return deny(`Decidio: "${short}" is still a duplicate of ${label} and the stored justification was not accepted (${a.duplicateOf.reason ?? "no reason given"}). Nothing ran.`);
3497
+ return deny(`Decidio: "${short}" is still a duplicate of ${label} and the stored justification was not accepted (${a.duplicateOf.reason ?? "no reason given"}). ${w.ran}`);
2754
3498
  clearJustification(twin.decisionId);
2755
3499
  }
2756
3500
  auth = a;
2757
3501
  }
2758
3502
  catch (e) {
2759
- return deny(`Decidio hook: the approval gate could not be asked about "${short}" (${hookErr(e)}) - refusing to run it (fail closed). Nothing ran. ${e instanceof DecidioRateLimitError ? "The workspace's governed-action allowance for this hour is used up; try later." : `Check the network and ${projectDir}/.env (\`npx @decidio/sdk doctor\`), then retry the same command.`}`);
3503
+ return deny(`Decidio hook: the approval gate could not be asked about "${short}" (${hookErr(e)}) - refusing to ${w.run} it (fail closed). ${w.ran} ${e instanceof DecidioRateLimitError ? "The workspace's governed-action allowance for this hour is used up; try later." : `Check the network and ${projectDir}/.env (\`npx @decidio/sdk doctor\`), then retry the same ${w.noun}.`}`);
2760
3504
  }
2761
3505
  const where = decisionUrl(cfg.appUrl, auth.decisionId);
2762
3506
  if (auth.decision === "block")
2763
- return deny(`Decidio BLOCKED "${short}" by policy (decision ${auth.decisionId}${auth.reason ? `: ${auth.reason}` : ""}). Do not run it, or a variant of it; tell the user.`, `Decidio blocked ${auth.decisionId}`);
3507
+ return deny(`Decidio BLOCKED "${short}" by policy (decision ${auth.decisionId}${auth.reason ? `: ${auth.reason}` : ""}). Do not ${w.run} it, or a variant of it; tell the user.`, `Decidio blocked ${auth.decisionId}`);
2764
3508
  if (auth.decision === "proceed") {
2765
- writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: auth.receiptId ?? null, tool, category: rule.category, at: new Date().toISOString() });
2766
- return allow(`Decidio: "${short}" is authorized by policy (decision ${auth.decisionId}${auth.receiptId ? `, receipt ${auth.receiptId}` : ""}, sealed). This covers this one execution only.`);
3509
+ writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: auth.receiptId ?? null, tool, category: g.category, at: new Date().toISOString() });
3510
+ err(`decidio hook: "${short}" is authorized by policy (decision ${auth.decisionId}${auth.receiptId ? `, receipt ${auth.receiptId}` : ""}, sealed) - no output, so Claude Code's own permission check still decides; this covers this one execution only.`);
3511
+ return 0;
2767
3512
  }
2768
3513
  const holdRaw = Number(process.env.DECIDIO_HOOK_HOLD_MS ?? "90000");
2769
3514
  const held = await hookHold(cfg, auth.decisionId, Number.isFinite(holdRaw) && holdRaw >= 0 ? Math.min(holdRaw, HOOK_HOLD_CAP_MS) : 90_000);
2770
3515
  const st = held.status;
2771
3516
  if (!st)
2772
- return deny(`Decidio: "${short}" is routed for approval as decision ${auth.decisionId}, but its status could not be read (${held.lastError ?? "no answer"}). Nothing ran. When the gate is reachable, run \`npx @decidio/sdk hook wait ${auth.decisionId}\` and then retry EXACTLY the same command.`);
3517
+ return deny(`Decidio: "${short}" is routed for approval as decision ${auth.decisionId}, but its status could not be read (${held.lastError ?? "no answer"}). ${w.ran} When the gate is reachable, run \`npx @decidio/sdk hook wait ${auth.decisionId}\` and then retry EXACTLY the same ${w.noun}.`);
2773
3518
  if (st.state === "pending")
2774
- return deny(`Decidio: "${short}" needs a person's approval - decision ${auth.decisionId} is in their queue${where ? ` (${where})` : ""}. Nothing ran. To continue: run \`npx @decidio/sdk hook wait ${auth.decisionId}\` (it returns when they decide), then retry EXACTLY the same command - not a variant of it, and do not work around this gate.`, `Decidio: ${auth.decisionId} awaits approval${where ? ` - ${where}` : ""}`);
3519
+ return deny(`Decidio: "${short}" needs a person's approval - decision ${auth.decisionId} is in their queue${where ? ` (${where})` : ""}. ${w.ran} To continue: run \`npx @decidio/sdk hook wait ${auth.decisionId}\` (it returns when they decide), then retry EXACTLY the same ${w.noun} - not a variant of it, and do not work around this gate.`, `Decidio: ${auth.decisionId} awaits approval${where ? ` - ${where}` : ""}`);
2775
3520
  if (st.state === "rejected" || st.state === "blocked")
2776
- return deny(`Decidio: "${short}" was ${st.state} (decision ${auth.decisionId}${st.reason ? `: ${st.reason}` : ""}) for this exact change (${hookRedact(subject.display)}). Do not retry it or a variant of it; tell the user. ${subject.fingerprint ? "If the user changes what it would act on (a new commit, a different path), that is a new request and may be asked." : ""}`.trim(), `Decidio: ${auth.decisionId} ${st.state}`);
3521
+ return deny(`Decidio: "${short}" was ${st.state} (decision ${auth.decisionId}${st.reason ? `: ${st.reason}` : ""}) for this exact change (${hookRedact(g.subject.display)}). Do not retry it or a variant of it; tell the user. ${g.subject.fingerprint ? w.anew : ""}`.trim(), `Decidio: ${auth.decisionId} ${st.state}`);
2777
3522
  const chk = await checkApproval(cfg, auth.decisionId, request, { status: st });
2778
3523
  if (!chk.ok)
2779
- return deny(`Decidio: "${short}" is NOT cleared to run - ${chk.reason ?? chk.code} (decision ${auth.decisionId}). Nothing ran.${chk.code === "spent" || chk.code === "expired" ? " Retry the same command to ask for a fresh approval." : ""}`);
2780
- writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: st.receiptId ?? auth.receiptId ?? null, tool, category: rule.category, at: new Date().toISOString() });
3524
+ return deny(`Decidio: "${short}" is NOT cleared to ${w.run} - ${chk.reason ?? chk.code} (decision ${auth.decisionId}). ${w.ran}${chk.code === "spent" || chk.code === "expired" ? ` Retry the same ${w.noun} to ask for a fresh approval.` : ""}`);
3525
+ writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: st.receiptId ?? auth.receiptId ?? null, tool, category: g.category, at: new Date().toISOString() });
2781
3526
  return allow(`Decidio: "${short}" is approved (decision ${auth.decisionId}${st.receiptId ?? auth.receiptId ? `, receipt ${st.receiptId ?? auth.receiptId}` : ""}). This approval covers this one execution only; its record is sealed.`);
2782
3527
  }
2783
3528
  catch (e) {
@@ -2849,7 +3594,7 @@ async function cmdHookWait(id, timeoutSec) {
2849
3594
  }
2850
3595
  const cfg = hookConfig();
2851
3596
  if ("missing" in cfg) {
2852
- err(`✘ ${cfg.missing} - run \`npx @decidio/sdk init\` in this project first.`);
3597
+ err(`✘ ${cfg.missing} - connect this project first: npx @decidio/sdk connect`);
2853
3598
  return 1;
2854
3599
  }
2855
3600
  const where = decisionUrl(cfg.appUrl, id);
@@ -2920,6 +3665,14 @@ function cmdHookRules() {
2920
3665
  log(`Add your own: DECIDIO_HOOK_EXTRA_RULES=<file.json> with [{ "category": "deploy", "pattern": "^make deploy\\\\b", "summary": "make deploy" }] - each a regex over the whole command.`);
2921
3666
  log(`The list is a client-side filter; your workspace's policy decides each request the hook sends. Hold: DECIDIO_HOOK_HOLD_MS (90000; capped at ${HOOK_HOLD_CAP_MS}, below the hook's 150 s timeout).`);
2922
3667
  log(`Each request is bound to its SUBJECT as well as its text: a push to the branch and commit it would push, a delete to the paths, a statement to its database - so a refusal means "not this", and a new commit or a different path is a new request.`);
3668
+ log(`A file the command runs is followed ONE level - inside the project only (its real path: a symlink out of it is not followed), text, at most ${HOOK_FOLLOW_MAX / 1024} KB - and an edited file is a new request:`);
3669
+ log(` shell scripts bash|sh|zsh|dash|ksh <f>, source <f>, . <f>, ./<f> - every line through the list above`);
3670
+ log(` SQL files psql -f <f>, <database client> ... < <f>, sqlite3 <db> ".read <f>" - DROP, TRUNCATE, DELETE FROM`);
3671
+ log(` package.json npm run <x>, npm test|start, pnpm [run] <x>, yarn [run] <x>, bun run <x> - the script's text through the list above`);
3672
+ log(` program files python <f>.py, node|deno|bun|tsx|ts-node <f>.js|mjs|cjs|ts, ruby <f>.rb run directly - DROP and TRUNCATE only, never DELETE FROM`);
3673
+ log(` Not followed: a file outside the project or a symlink out of it, a file that calls another file, binaries, files over ${HOOK_FOLLOW_MAX / 1024} KB, SQL built at run time, library calls (drop_all, dropDatabase). The file never leaves this machine: the request carries the matched line.`);
3674
+ log(`decidio-guard on Claude Code's Write, Edit and MultiEdit (.claude/settings*.json, ~/.claude/settings*.json, the Decidio plugin's files): asked only when the file after the change removes or alters a Decidio hook, sets disableAllHooks, turns the Decidio plugin off, touches the plugin's files or is not valid JSON - adding a permission or another hook is not asked. A SHELL write to those files is asked about even when it only adds a permission: its effect can't be computed.`);
3675
+ log(`A policy that auto-approves a request gets no output from the hook, so Claude Code's own permission check still decides; only a person's approval of the exact call answers "allow".`);
2923
3676
  return 0;
2924
3677
  }
2925
3678
  function hookRunner() {
@@ -2940,8 +3693,10 @@ function hookRunner() {
2940
3693
  return `npx tsx ${q(real)}`;
2941
3694
  return `node ${q(real)}`;
2942
3695
  }
3696
+ const HOOK_FILE_TOOLS = ["Write", "Edit", "MultiEdit"];
3697
+ const HOOK_GUARD_PATHS = ["**/.claude/settings*.json", "~/.claude/settings*.json", "~/.claude/plugins/**"];
2943
3698
  async function cmdHookInstall(opts) {
2944
- const settingsPath = joinPath(".claude", opts.project ? "settings.json" : "settings.local.json");
3699
+ const settingsPath = joinPath(opts.dir ?? ".", ".claude", opts.project ? "settings.json" : "settings.local.json");
2945
3700
  let settings = {};
2946
3701
  if (existsSync(settingsPath)) {
2947
3702
  try {
@@ -2963,40 +3718,412 @@ async function cmdHookInstall(opts) {
2963
3718
  const cliAt = opts.cli ? resolvePath(opts.cli).split(pathSep).join("/") : "";
2964
3719
  const runner = opts.cli ? (/\.ts$/.test(opts.cli) ? `npx tsx "${cliAt}"` : `node "${cliAt}"`) : hookRunner();
2965
3720
  const hooks = (settings.hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {});
2966
- const place = (event, command, extra) => {
3721
+ const place = (event, matcher, iff, command, extra) => {
2967
3722
  const list = Array.isArray(hooks[event]) ? hooks[event] : (hooks[event] = []);
2968
- const entry = { matcher: "Bash|PowerShell", hooks: [{ type: "command", command, ...extra }] };
2969
- const i = list.findIndex((e) => Array.isArray(e?.hooks) && e.hooks.some((h) => typeof h?.command === "string" && /\bhook (pretool|posttool)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command)));
2970
- if (i >= 0) {
2971
- const same = JSON.stringify(list[i]) === JSON.stringify(entry);
2972
- list[i] = entry;
2973
- return same ? "kept" : "updated";
2974
- }
2975
- list.push(entry);
2976
- return "added";
3723
+ const want = { type: "command", ...(iff ? { if: iff } : {}), command, ...extra };
3724
+ let state = "added";
3725
+ for (const g of list) {
3726
+ if (!g || typeof g !== "object" || g.matcher !== matcher || !Array.isArray(g.hooks))
3727
+ continue;
3728
+ g.hooks = g.hooks.flatMap((h) => {
3729
+ if (!hookIsOurs(h) || (typeof h.if === "string" ? h.if : undefined) !== iff)
3730
+ return [h];
3731
+ if (state !== "added") {
3732
+ state = "updated";
3733
+ return [];
3734
+ }
3735
+ state = JSON.stringify(h) === JSON.stringify(want) ? "kept" : "updated";
3736
+ return [want];
3737
+ });
3738
+ }
3739
+ hooks[event] = list.filter((g) => !(g && typeof g === "object" && Array.isArray(g.hooks) && g.hooks.length === 0));
3740
+ if (state === "added")
3741
+ hooks[event].push({ matcher, hooks: [want] });
3742
+ return state;
2977
3743
  };
2978
- const pre = place("PreToolUse", `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool`, { timeout: 150, onFailure: "block" });
2979
- const post = place("PostToolUse", `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`, { timeout: 30 });
2980
- mkdirSync(".claude", { recursive: true });
3744
+ const preLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool`, postLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`;
3745
+ const pre = place("PreToolUse", "Bash|PowerShell", undefined, preLine, { timeout: 150, onFailure: "block" });
3746
+ const post = place("PostToolUse", "Bash|PowerShell", undefined, postLine, { timeout: 30 });
3747
+ const guard = { added: 0, updated: 0, kept: 0 };
3748
+ for (const t of HOOK_FILE_TOOLS)
3749
+ for (const p of HOOK_GUARD_PATHS) {
3750
+ guard[place("PreToolUse", t, `${t}(${p})`, preLine, { timeout: 150, onFailure: "block" })]++;
3751
+ guard[place("PostToolUse", t, `${t}(${p})`, postLine, { timeout: 30 })]++;
3752
+ }
3753
+ mkdirSync(dirname(settingsPath), { recursive: true });
2981
3754
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
3755
+ lastHookInstall = { settingsPath, pre, post };
3756
+ if (opts.quiet)
3757
+ return 0;
2982
3758
  log(`✔ ${settingsPath.split(pathSep).join("/")}: PreToolUse hook ${pre}, PostToolUse hook ${post} (matcher Bash|PowerShell${opts.project ? "; this file is shared with everyone who clones the project" : "; this file is yours alone - Claude Code keeps it out of git"}).`);
3759
+ log(`✔ the guard on Claude Code's own settings - ${HOOK_FILE_TOOLS.join(", ")} on ${HOOK_GUARD_PATHS.join(", ")}, PreToolUse and PostToolUse: ${Object.entries(guard).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(", ")}.`);
2983
3760
  log(` Runs: ${runner} hook pretool|posttool - the pretool hook times out at 150 s and BLOCKS the call if it fails (fail closed).`);
2984
3761
  const env = readEnvFile();
2985
3762
  const token = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
2986
- if (token)
3763
+ if (token && CREDS_FROM === "connection")
3764
+ log(`✔ credentials: this project's Decidio connection (${process.env.DECIDIO_AGENT_ID}) - kept outside the repository, owner-only.`);
3765
+ else if (token)
2987
3766
  log(`✔ agent credentials: ${ENV_PATH} holds DECIDIO_API_TOKEN${(process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) ? " and the agent's signing key" : ""} for "${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || defaultAgentId()}".`);
2988
3767
  else
2989
- log(`⚠ no agent credentials here (no DECIDIO_API_TOKEN in ${ENV_PATH}) - until there are, EVERY gated command is refused. Run: npx @decidio/sdk init ${defaultAgentId()}`);
3768
+ log(`⚠ not connected yet - until this project is, EVERY gated command is refused. Run: npx @decidio/sdk connect (or, for your own agent: npx @decidio/sdk init ${defaultAgentId()})`);
2990
3769
  log(` Gated: ${HOOK_RULES.map((r) => r.category).join(", ")} (npx @decidio/sdk hook rules lists the patterns; DECIDIO_HOOK_EXTRA_RULES adds yours).`);
2991
3770
  log(` Approver: anyone in your workspace with a role other than the agent's owner (the person who ran init) - on the phone app, the web app, Slack or Teams.`);
2992
3771
  log(` Nobody yet? Invite one: npx @decidio/sdk hook install --approver <email> (single-use link; share by WhatsApp, SMS or email)`);
2993
3772
  log(` Flow: a gated command is held up to ${Math.min(Number(process.env.DECIDIO_HOOK_HOLD_MS ?? 90_000) || 90_000, HOOK_HOLD_CAP_MS) / 1000} s for the approver, then refused with its decision id; \`npx @decidio/sdk hook wait <id>\` returns when they decide,`);
2994
3773
  log(` and the SAME command retried then runs - once. A lab workspace allows 30 governed actions an hour; a gated command spends two (ask, retry).`);
2995
- log(` Reload: restart Claude Code, or run /hooks in it - hooks are read when a session starts.`);
3774
+ log(` Reload: none needed - Claude Code picks up the settings file by itself. If a gated command is not asked about, open /hooks once (or restart Claude Code).`);
2996
3775
  if (opts.approver !== undefined)
2997
3776
  return inviteApprover(opts.approver);
2998
3777
  return 0;
2999
3778
  }
3779
+ function connectHostLabel(h) { return h === "claude-code" ? "Claude Code" : h; }
3780
+ function connectDir() { return joinPath(sessionDir(), "connections"); }
3781
+ function realOrSame(p) { try {
3782
+ return realPath(p);
3783
+ }
3784
+ catch {
3785
+ return resolvePath(p);
3786
+ } }
3787
+ const sameDir = (a, b) => (process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b);
3788
+ function projectRoot() {
3789
+ try {
3790
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
3791
+ const top = String(r.stdout ?? "").trim();
3792
+ if (r.status === 0 && top)
3793
+ return realOrSame(top);
3794
+ }
3795
+ catch { }
3796
+ return realOrSame(SHELL_ENV.CLAUDE_PROJECT_DIR || process.cwd());
3797
+ }
3798
+ function connectionKey(root) { return createHash("sha256").update(`${API}\n${process.platform === "win32" ? root.toLowerCase() : root}`, "utf8").digest("hex").slice(0, 20); }
3799
+ function connectionFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.json`); }
3800
+ function pendingConnectFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.pending.json`); }
3801
+ function readJsonQuiet(f) {
3802
+ try {
3803
+ return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null;
3804
+ }
3805
+ catch {
3806
+ return null;
3807
+ }
3808
+ }
3809
+ function loadConnection(root, api) {
3810
+ const c = connectionRecord(root, api);
3811
+ return c && !c.disconnected && c.token ? c : null;
3812
+ }
3813
+ function connectionRecord(root, api) {
3814
+ const c = readJsonQuiet(connectionFile(root));
3815
+ return c && c.v === 1 && c.api === api && c.root === root && typeof c.token === "string" && typeof c.agentId === "string" && typeof c.key === "string" && typeof c.did === "string" ? c : null;
3816
+ }
3817
+ function promoteConnection(c) {
3818
+ process.env.DECIDIO_API_TOKEN = c.token;
3819
+ process.env.DECIDIO_AGENT_ID = c.agentId;
3820
+ process.env.DECIDIO_AGENT_DID = c.did;
3821
+ process.env.DECIDIO_AGENT_KEY = c.key;
3822
+ process.env.DECIDIO_WORKSPACE_ID = c.workspaceId;
3823
+ process.env.DECIDIO_TOKEN_JTI = c.jti;
3824
+ if (c.appUrl && process.env.DECIDIO_APP_URL === undefined)
3825
+ process.env.DECIDIO_APP_URL = c.appUrl;
3826
+ }
3827
+ function inCloudSession() { return SHELL_ENV.CLAUDE_CODE_REMOTE === "true"; }
3828
+ function machineId() {
3829
+ const f = joinPath(sessionDir(), "machine-id");
3830
+ let cur = "";
3831
+ try {
3832
+ cur = existsSync(f) ? readFileSync(f, "utf8").trim() : "";
3833
+ }
3834
+ catch { }
3835
+ if (/^[0-9a-f]{8,32}$/.test(cur))
3836
+ return cur;
3837
+ const id = randomBytes(8).toString("hex");
3838
+ mkdirSync(sessionDir(), { recursive: true });
3839
+ writeFileSync(f, id + "\n");
3840
+ return id;
3841
+ }
3842
+ function machineLabel(cloud) {
3843
+ if (cloud)
3844
+ return `cloud-${machineId().slice(0, 4)}`;
3845
+ const h = (() => { try {
3846
+ return hostname();
3847
+ }
3848
+ catch {
3849
+ return "";
3850
+ } })().split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
3851
+ return (h || "machine").slice(0, 40);
3852
+ }
3853
+ function projectLabel(root) {
3854
+ try {
3855
+ const r = spawnSync("git", ["-C", root, "config", "--get", "remote.origin.url"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
3856
+ const m = String(r.stdout ?? "").trim().match(/([^/:\\]+?)(?:\.git)?\/?$/);
3857
+ if (r.status === 0 && m?.[1])
3858
+ return m[1].slice(0, 80);
3859
+ }
3860
+ catch { }
3861
+ try {
3862
+ const pj = JSON.parse(readFileSync(joinPath(root, "package.json"), "utf8"));
3863
+ if (typeof pj?.name === "string" && pj.name)
3864
+ return pj.name.replace(/^@[^/]+\//, "").slice(0, 80);
3865
+ }
3866
+ catch { }
3867
+ return (basename(root) || "project").slice(0, 80);
3868
+ }
3869
+ function localDesktop() {
3870
+ if (inCloudSession() || SHELL_ENV.CI || SHELL_ENV.SSH_CONNECTION || SHELL_ENV.SSH_TTY || SHELL_ENV.CODESPACES || SHELL_ENV.GITPOD_WORKSPACE_ID || SHELL_ENV.DECIDIO_NO_BROWSER)
3871
+ return false;
3872
+ if (process.platform === "darwin" || process.platform === "win32")
3873
+ return true;
3874
+ return !!(SHELL_ENV.DISPLAY || SHELL_ENV.WAYLAND_DISPLAY);
3875
+ }
3876
+ function openInBrowser(url) {
3877
+ if (!/^https?:\/\/[^\s"'<>`]+$/.test(url))
3878
+ return false;
3879
+ try {
3880
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
3881
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
3882
+ child.on("error", () => { });
3883
+ child.unref();
3884
+ return true;
3885
+ }
3886
+ catch {
3887
+ return false;
3888
+ }
3889
+ }
3890
+ const CONNECT_WAIT_S = () => { const n = Number(process.env.DECIDIO_CONNECT_WAIT_S ?? 90); return Number.isFinite(n) && n >= 1 && n <= 110 ? n : 90; };
3891
+ let lastHookInstall = null;
3892
+ async function startConnect(root, host) {
3893
+ const prior = connectionRecord(root, API);
3894
+ const kp = prior && !inCloudSession() ? { did: prior.did, privateKeyBase64: prior.key } : generateKeypair();
3895
+ const cloud = inCloudSession();
3896
+ const project = projectLabel(root).trim() || "project", machine = machineLabel(cloud).trim() || "machine";
3897
+ const proof = signBoundProof(kp.privateKeyBase64, { agentId: "connect", did: kp.did, action: "connect.start", amount: 0, scope: [host, project, machine, cloud ? "cloud" : "local"].join("\n") });
3898
+ const r = await api("/connect/start", { method: "POST", body: JSON.stringify({ host, project, machine, did: kp.did, cloud, proof }) }, "", true);
3899
+ if (r.status === 0) {
3900
+ err(`✘ couldn't reach Decidio at ${API} - check your connection and run npx @decidio/sdk connect again.`);
3901
+ return null;
3902
+ }
3903
+ if (r.status === 404 || r.status === 401) {
3904
+ err(`✘ ${r.body?.message ?? "Connecting a coding assistant is available in the Decidio lab only."} (${API})`);
3905
+ return null;
3906
+ }
3907
+ if (r.status === 429) {
3908
+ err(`✘ ${r.body?.message ?? "Too many tries - wait a minute and try again."}`);
3909
+ return null;
3910
+ }
3911
+ if (r.status !== 201 || typeof r.body?.pollSecret !== "string" || typeof r.body?.link !== "string") {
3912
+ err(`✘ couldn't start a connection (${r.status}${r.body?.message ? `: ${r.body.message}` : ""}). Nothing was saved.`);
3913
+ return null;
3914
+ }
3915
+ const p = { v: 1, api: API, root, project, host, machine, cloud, did: kp.did, key: kp.privateKeyBase64, code: String(r.body.code), link: String(r.body.link),
3916
+ pollSecret: r.body.pollSecret, expiresAt: String(r.body.expiresAt), interval: Number(r.body.interval) || 2, startedAt: new Date().toISOString() };
3917
+ mkdirSync(connectDir(), { recursive: true });
3918
+ writeSecretFile(pendingConnectFile(root), JSON.stringify(p) + "\n");
3919
+ if (typeof r.body.qr === "string")
3920
+ pendingQr = r.body.qr;
3921
+ return p;
3922
+ }
3923
+ let pendingQr = null;
3924
+ function readPendingConnect(root) {
3925
+ const p = readJsonQuiet(pendingConnectFile(root));
3926
+ return p && p.v === 1 && p.api === API && p.root === root && typeof p.pollSecret === "string" ? p : null;
3927
+ }
3928
+ function dropFile(f) { try {
3929
+ rmSync(f, { force: true });
3930
+ }
3931
+ catch { } }
3932
+ const minutesLeft = (iso) => Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 60_000));
3933
+ function showConnectLink(p, opened, tty) {
3934
+ const title = `Connect ${connectHostLabel(p.host)} to Decidio`;
3935
+ log(`${brand()} ${bold(title)}`);
3936
+ log(` Open: ${link(p.link)}`);
3937
+ log(` Code: ${bold(p.code)} ${dim("(the page shows the same code - only allow it if they match)")}`);
3938
+ log(dim(` ${p.project} · ${p.machine} · the link works for ${minutesLeft(p.expiresAt) || 1} more minute${minutesLeft(p.expiresAt) === 1 ? "" : "s"}${opened ? " · opened in your browser" : ""}`));
3939
+ if (tty && pendingQr) {
3940
+ log("");
3941
+ for (const line of pendingQr.split("\n"))
3942
+ if (line.trim())
3943
+ log(` ${line}`);
3944
+ log(dim(" Scan with your phone's camera to allow it there."));
3945
+ }
3946
+ }
3947
+ async function cmdConnect(opts) {
3948
+ const host = (opts.host ?? "claude-code");
3949
+ if (host !== "claude-code") {
3950
+ err(`✘ connect knows Claude Code today (--host claude-code); "${opts.host}" is not supported yet.`);
3951
+ return 2;
3952
+ }
3953
+ try {
3954
+ assertSafeTransport(API, true);
3955
+ }
3956
+ catch (e) {
3957
+ err(`✘ ${String(e?.message ?? e).replace(/^\[decidio\] /, "")}`);
3958
+ return 1;
3959
+ }
3960
+ const root = projectRoot();
3961
+ const tty = !!(process.stdin.isTTY && process.stdout.isTTY);
3962
+ if (CREDS_FROM === "shell" || CREDS_FROM === ".env") {
3963
+ log(`• this project already has Decidio credentials ${CREDS_FROM === "shell" ? "from your shell" : `in ${ENV_PATH}`} for "${process.env.DECIDIO_AGENT_ID ?? defaultAgentId()}" - the hook uses those, so connect leaves them alone.`);
3964
+ log(dim(` To use a connection instead, remove DECIDIO_API_TOKEN / DECIDIO_AGENT_ID from ${CREDS_FROM === "shell" ? "your shell" : ENV_PATH} and run npx @decidio/sdk connect again.`));
3965
+ return finishConnect(root, null, opts, false);
3966
+ }
3967
+ const existing = loadConnection(root, API);
3968
+ if (existing && !opts.force && new Date(existing.expiresAt).getTime() > Date.now())
3969
+ return finishConnect(root, existing, opts, false);
3970
+ if (opts.wait)
3971
+ return waitConnect(root, opts, false);
3972
+ let p = readPendingConnect(root);
3973
+ if (!p || p.host !== host || new Date(p.expiresAt).getTime() <= Date.now() + 15_000)
3974
+ p = await startConnect(root, host);
3975
+ if (!p)
3976
+ return 1;
3977
+ const opened = !opts.noBrowser && localDesktop() && openInBrowser(p.link);
3978
+ showConnectLink(p, opened, tty);
3979
+ if (tty) {
3980
+ log("");
3981
+ return waitConnect(root, opts, true);
3982
+ }
3983
+ log("");
3984
+ log(`Next: allow it at the link above, then run ${bold("npx @decidio/sdk connect --wait")} - it finishes by itself (nothing else to type).`);
3985
+ log(dim("Assistants: show the person the link and the code above, then run npx @decidio/sdk connect --wait."));
3986
+ return 0;
3987
+ }
3988
+ async function waitConnect(root, opts, interactive) {
3989
+ const p = readPendingConnect(root);
3990
+ if (!p) {
3991
+ const c = loadConnection(root, API);
3992
+ if (c)
3993
+ return finishConnect(root, c, opts, false);
3994
+ err("✘ no connection is in progress for this project - start one: npx @decidio/sdk connect");
3995
+ return 1;
3996
+ }
3997
+ const deadline = interactive ? new Date(p.expiresAt).getTime() + 5_000 : Math.min(new Date(p.expiresAt).getTime() + 5_000, Date.now() + CONNECT_WAIT_S() * 1000);
3998
+ const spin = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3999
+ let i = 0, misses = 0;
4000
+ if (!interactive)
4001
+ err(`· waiting for the connection to be allowed at ${p.link} (code ${p.code}; up to ${Math.round((deadline - Date.now()) / 1000)} s)`);
4002
+ while (Date.now() < deadline) {
4003
+ const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret }, body: "{}" }, "", true);
4004
+ if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
4005
+ if (interactive && TTY_OUT)
4006
+ process.stderr.write("\r\x1b[K");
4007
+ const c = { v: 1, api: API, root, project: p.project, host: p.host, machine: p.machine, cloud: p.cloud, agentId: String(r.body.agentId), did: p.did, key: p.key,
4008
+ token: r.body.token, jti: String(r.body.jti), expiresAt: String(r.body.expiresAt), workspaceId: String(r.body.workspaceId), workspaceName: r.body.workspace?.name ?? null,
4009
+ approver: r.body.approver?.name ?? null, approvedBy: r.body.approvedBy?.name ?? null, appUrl: typeof r.body.appUrl === "string" ? r.body.appUrl : null, connectedAt: new Date().toISOString() };
4010
+ mkdirSync(connectDir(), { recursive: true });
4011
+ try {
4012
+ writeSecretFile(connectionFile(root), JSON.stringify(c) + "\n");
4013
+ }
4014
+ catch (e) {
4015
+ err(`✘ connected, but this machine's credentials could not be written (${e?.message ?? e}) - run npx @decidio/sdk connect again (the server keeps nothing to collect twice).`);
4016
+ return 1;
4017
+ }
4018
+ dropFile(pendingConnectFile(root));
4019
+ return finishConnect(root, c, opts, true);
4020
+ }
4021
+ if (r.status === 403 && r.body?.status === "denied") {
4022
+ dropFile(pendingConnectFile(root));
4023
+ if (interactive && TTY_OUT)
4024
+ process.stderr.write("\r\x1b[K");
4025
+ err("✘ the connection was refused on the web (Not me). Nothing was saved.");
4026
+ return 3;
4027
+ }
4028
+ if ((r.status === 404 || r.status === 410) && (r.body?.status === "expired" || r.body?.status === "collected")) {
4029
+ dropFile(pendingConnectFile(root));
4030
+ if (interactive && TTY_OUT)
4031
+ process.stderr.write("\r\x1b[K");
4032
+ err(r.body.status === "collected" ? "✘ this connection was already collected by another run - start a fresh one: npx @decidio/sdk connect" : "✘ the link expired before it was allowed - start a fresh one: npx @decidio/sdk connect");
4033
+ return 3;
4034
+ }
4035
+ if (r.status === 0 && ++misses > 10) {
4036
+ err(`✘ lost touch with Decidio at ${API} - check your connection, then run npx @decidio/sdk connect --wait again.`);
4037
+ return 1;
4038
+ }
4039
+ if (interactive && TTY_OUT)
4040
+ process.stderr.write(`\r${spin[i++ % spin.length]} waiting for you to allow it in the browser… ${dim(`(${minutesLeft(p.expiresAt)} min left · Ctrl-C stops waiting; npx @decidio/sdk connect --wait picks it up again)`)}`);
4041
+ await new Promise((res) => setTimeout(res, (r.status === 429 ? 2 : 1) * Math.max(1, p.interval) * 1000));
4042
+ }
4043
+ if (interactive && TTY_OUT)
4044
+ process.stderr.write("\r\x1b[K");
4045
+ log(`• still waiting for it to be allowed: ${link(p.link)} (code ${p.code}, ${minutesLeft(p.expiresAt)} min left). Run npx @decidio/sdk connect --wait again once it is.`);
4046
+ return 1;
4047
+ }
4048
+ async function finishConnect(root, c, opts, fresh) {
4049
+ if (c) {
4050
+ promoteConnection(c);
4051
+ CREDS_FROM = "connection";
4052
+ }
4053
+ const cloud = inCloudSession();
4054
+ const shared = opts.team || cloud;
4055
+ const plugin = !cloud && decidioPluginEnabled(root);
4056
+ if (!plugin) {
4057
+ const rc = await cmdHookInstall({ project: shared, dir: root, quiet: true });
4058
+ if (rc !== 0)
4059
+ return rc;
4060
+ }
4061
+ const probe = await api("/agent/status/doctor-scope-probe", {}, process.env.DECIDIO_API_TOKEN, true);
4062
+ const verified = probe.status === 404;
4063
+ const settings = lastHookInstall ? relative(root, lastHookInstall.settingsPath).split(pathSep).join("/") || lastHookInstall.settingsPath : ".claude/settings.json";
4064
+ const hostName = connectHostLabel(c?.host ?? "claude-code");
4065
+ if (c) {
4066
+ log(`✔ ${fresh ? "Connected to Decidio" : "Already connected to Decidio"}${c.workspaceName ? ` - workspace ${c.workspaceName}` : ""}${fresh && c.approvedBy ? `, allowed by ${c.approvedBy}` : ""}`);
4067
+ log(`✔ Approvals go to ${c.approver ?? "the approver you picked"} - on the phone app, Slack, Teams or the web`);
4068
+ }
4069
+ if (!verified) {
4070
+ err(probe.status === 401 || probe.status === 403
4071
+ ? `✘ Decidio refused this connection's token (${probe.status}) - it was revoked or has expired. Reconnect: npx @decidio/sdk connect --force`
4072
+ : `⚠ couldn't check the token just now (${probe.status || "no answer"}) - the hooks are on; a gated command will say if anything is wrong.`);
4073
+ if (probe.status === 401 || probe.status === 403)
4074
+ return 1;
4075
+ }
4076
+ const changed = !plugin && lastHookInstall && (lastHookInstall.pre !== "kept" || lastHookInstall.post !== "kept");
4077
+ log(plugin
4078
+ ? `✔ ${hostName} asks before destructive commands in this project (the Decidio plugin's hooks)`
4079
+ : `✔ ${hostName} asks before destructive commands in this project (${settings})`);
4080
+ if (changed)
4081
+ log(dim(` Claude Code picks the settings change up by itself - if a destructive command is not asked about, open /hooks once (or restart it).`));
4082
+ if (c)
4083
+ log(dim(` Credentials: ${connectionFile(root)} (outside the repository, owner-only) · until ${c.expiresAt.slice(0, 10)}${cloud ? " · this cloud machine only - a fresh one refuses destructive commands until it connects again" : ""}`));
4084
+ if (!plugin && (shared || process.platform === "win32") && !sameDir(realOrSame(process.cwd()), root))
4085
+ log(dim(` Claude Code reads ${settings} from the folder it starts in - start it at ${root}.`));
4086
+ if (cloud && !opts.team)
4087
+ log(`• A cloud session reads only the repository's ${settings}, so the hooks are there - not committed. Ask the person before committing it: committed, every cloud session of this repository asks too, and so does every teammate's clone (a teammate connects with npx @decidio/sdk connect).`);
4088
+ else if (shared)
4089
+ log(`• Commit ${settings}: every clone then asks before destructive commands (a teammate connects with npx @decidio/sdk connect).`);
4090
+ if (fresh)
4091
+ log(dim(` Try it: ask ${hostName} to delete a scratch folder with rm -rf - the approval reaches ${c?.approver ?? "the approver"} on their phone.`));
4092
+ return 0;
4093
+ }
4094
+ const DECIDIO_PLUGIN = "decidio-approvals";
4095
+ function decidioPluginEnabled(root) {
4096
+ const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
4097
+ for (const f of [joinPath(root, ".claude", "settings.local.json"), joinPath(root, ".claude", "settings.json"), joinPath(home, ".claude", "settings.json")]) {
4098
+ const ep = readJsonQuiet(f)?.enabledPlugins;
4099
+ if (!ep || typeof ep !== "object" || Array.isArray(ep))
4100
+ continue;
4101
+ const named = Object.entries(ep).find(([k]) => k.split("@")[0] === DECIDIO_PLUGIN);
4102
+ if (named)
4103
+ return named[1] === true;
4104
+ }
4105
+ return false;
4106
+ }
4107
+ async function cmdDisconnect() {
4108
+ const root = projectRoot();
4109
+ const c = loadConnection(root, API);
4110
+ dropFile(pendingConnectFile(root));
4111
+ if (!c) {
4112
+ log("• this project isn't connected on this machine - nothing to do.");
4113
+ return 0;
4114
+ }
4115
+ try {
4116
+ writeSecretFile(connectionFile(root), JSON.stringify({ ...c, token: "", jti: "", disconnected: true }) + "\n");
4117
+ }
4118
+ catch (e) {
4119
+ err(`✘ couldn't update ${connectionFile(root)} (${e?.message ?? e}) - delete it by hand to disconnect.`);
4120
+ return 1;
4121
+ }
4122
+ log(`✔ disconnected this project on this machine - its token is deleted.`);
4123
+ log(` Destructive commands are now refused until you connect again (the hooks stay on: switching Decidio off needs a person's approval).`);
4124
+ log(dim(` "${c.agentId}" stays listed in Decidio until its token expires (${c.expiresAt.slice(0, 10)}); to end it now, revoke it on the Agents page${c.appUrl ? `: ${c.appUrl.replace(/\/+$/, "")}/connect/agents` : ""}.`));
4125
+ return 0;
4126
+ }
3000
4127
  async function inviteApprover(email) {
3001
4128
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
3002
4129
  err(`✘ --approver needs an email address (got "${email}")`);
@@ -3683,6 +4810,303 @@ async function cmdGithubRequest(environment, justification) {
3683
4810
  err(`✘ Decidio refused the request (${r.status}${b.code ? ` ${b.code}` : ""}): ${String(b.error ?? "no reason given")}`);
3684
4811
  return 1;
3685
4812
  }
4813
+ const DEPLOY_MARK = { deployed: "✔", refused: "✘", blocked: "✘", stopped: "✘", failed: "✘", checks_failed: "✘", checks_unreadable: "✘", superseded: "·", expired: "·", silent: "·" };
4814
+ export function deployLines(r) {
4815
+ const mark = DEPLOY_MARK[r.state] ?? "…";
4816
+ const when = (r.deployedAt ?? r.requestedAt).slice(0, 16).replace("T", " ");
4817
+ const head = `${mark} ${(r.dNumber ?? r.decisionId.slice(-6)).padEnd(8)} ${r.environment.padEnd(12)} ${r.sha.slice(0, 7)} ${r.ref ? `(${r.ref}) ` : ""}${r.line} ${when} UTC`;
4818
+ const out = [head];
4819
+ if (r.checks && r.state !== "deployed")
4820
+ out.push(` ${r.checks}`);
4821
+ if (r.range)
4822
+ out.push(r.range.fromSha ? ` changed since ${r.range.fromSha.slice(0, 7)}${r.range.compareUrl ? `: ${r.range.compareUrl}` : ""} (rollback target: ${r.range.fromSha.slice(0, 7)})` : " the first deploy through Decidio for this environment");
4823
+ return out;
4824
+ }
4825
+ export function deployChanges(before, after) {
4826
+ const was = new Map(before.map((r) => [r.decisionId, r]));
4827
+ const out = [];
4828
+ for (const r of [...after].reverse()) {
4829
+ const p = was.get(r.decisionId);
4830
+ const name = `${r.dNumber ?? r.decisionId.slice(-6)} ${r.environment} ${r.sha.slice(0, 7)}`;
4831
+ if (!p)
4832
+ out.push(`+ ${name}: ${r.line}`);
4833
+ else if (p.line !== r.line)
4834
+ out.push(`→ ${name}: ${p.line} → ${r.line}`);
4835
+ }
4836
+ return out;
4837
+ }
4838
+ async function cmdDeploys(ref, o) {
4839
+ if (ref && !/^(D[·.-]?\d{4,}|c[a-z0-9]{20,40})$/i.test(ref)) {
4840
+ err(`✘ "${ref}" is not a deploy's record id (D·0042) or decision id.`);
4841
+ return 2;
4842
+ }
4843
+ if (o.repo && !/^[\w.-]+\/[\w.-]+$/.test(o.repo)) {
4844
+ err(`✘ --repo takes owner/name.`);
4845
+ return 2;
4846
+ }
4847
+ if (o.limit && !/^\d{1,2}$/.test(o.limit)) {
4848
+ err(`✘ --limit takes a number from 1 to 50.`);
4849
+ return 2;
4850
+ }
4851
+ if (ref && (o.watch || o.environment || o.repo || o.limit)) {
4852
+ err(`✘ one deploy (${ref}) takes no --watch, --environment, --repo or --limit - refusing to ignore a flag you meant to matter.`);
4853
+ return 2;
4854
+ }
4855
+ if (o.watch && o.json) {
4856
+ err(`✘ --watch and --json do not go together (--json is a single read, for a script).`);
4857
+ return 2;
4858
+ }
4859
+ const qs = new URLSearchParams();
4860
+ if (o.environment)
4861
+ qs.set("environment", o.environment);
4862
+ if (o.repo)
4863
+ qs.set("repository", o.repo);
4864
+ if (o.limit)
4865
+ qs.set("limit", o.limit);
4866
+ const path = ref ? `/api/github/deploys/${encodeURIComponent(ref)}` : `/api/github/deploys${qs.size ? `?${qs}` : ""}`;
4867
+ let bearer;
4868
+ let r = await api(path);
4869
+ if (r.status === 401 || r.status === 403) {
4870
+ const session = await acquireSession("Reading deploys");
4871
+ if (!session)
4872
+ return 1;
4873
+ bearer = session;
4874
+ r = await api(path, {}, bearer);
4875
+ }
4876
+ if (r.status === 404) {
4877
+ err(`✘ ${ref ? `no deploy "${ref}" you can read in this workspace` : "GitHub deploy approvals are not set up here"}.`);
4878
+ return 1;
4879
+ }
4880
+ if (r.status !== 200) {
4881
+ err(`✘ could not read deploys (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
4882
+ return 1;
4883
+ }
4884
+ if (ref) {
4885
+ const d = r.body?.deploy;
4886
+ if (o.json) {
4887
+ log(JSON.stringify(d, null, 2));
4888
+ return 0;
4889
+ }
4890
+ for (const l of deployLines(d))
4891
+ log(l);
4892
+ const s = d?.shipping;
4893
+ if (s) {
4894
+ log("");
4895
+ if (s.line)
4896
+ log(` ${s.line}`);
4897
+ for (const p of s.pulls ?? [])
4898
+ log(` #${p.number} ${s.redacted ? "pull request" : p.title}${p.author ? ` - ${p.author}` : ""} · ${p.approvedBy?.length ? `approved by ${p.approvedBy.join(", ")}` : p.approvals ? `approved on GitHub (${p.approvals})` : "no approving review on GitHub"}${p.risky?.length ? ` · ${p.risky.join(", ")}` : ""}`);
4899
+ if (s.redacted)
4900
+ log(dim(" titles and names are shown to the people on this request"));
4901
+ if (s.directPushes)
4902
+ log(` ${s.directPushes} direct push${s.directPushes === 1 ? "" : "es"} with no pull request - files not read`);
4903
+ }
4904
+ log(dim(` ${link(d.recordUrl)}${d.runUrl ? ` · run: ${link(d.runUrl)}` : ""}`));
4905
+ return 0;
4906
+ }
4907
+ let rows = Array.isArray(r.body?.deploys) ? r.body.deploys : [];
4908
+ if (o.json) {
4909
+ log(JSON.stringify(rows, null, 2));
4910
+ return 0;
4911
+ }
4912
+ if (!rows.length)
4913
+ log("No deploy requests you can read yet.");
4914
+ for (const row of rows)
4915
+ for (const l of deployLines(row))
4916
+ log(l);
4917
+ if (!o.watch)
4918
+ return 0;
4919
+ log(dim("\nWatching for changes every 10 s - Ctrl+C to stop."));
4920
+ for (;;) {
4921
+ await new Promise((res) => setTimeout(res, 10_000));
4922
+ const next = await api(path, {}, bearer, true);
4923
+ if (next.status !== 200 || !Array.isArray(next.body?.deploys))
4924
+ continue;
4925
+ for (const l of deployChanges(rows, next.body.deploys))
4926
+ log(`${new Date().toISOString().slice(11, 19)} ${l}`);
4927
+ rows = next.body.deploys;
4928
+ }
4929
+ }
4930
+ export function parseWhen(s, now = new Date()) {
4931
+ if (!s)
4932
+ return null;
4933
+ if (s === "now")
4934
+ return now;
4935
+ const m = /^(\d{1,4})([mhd])$/.exec(s);
4936
+ if (m)
4937
+ return new Date(now.getTime() + Number(m[1]) * (m[2] === "m" ? 60_000 : m[2] === "h" ? 3_600_000 : 86_400_000));
4938
+ return /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(Date.parse(s)) ? new Date(s) : null;
4939
+ }
4940
+ export function controlsLines(b) {
4941
+ const t = (iso) => (iso ? iso.slice(0, 16).replace("T", " ") + " UTC" : "until lifted");
4942
+ const out = [];
4943
+ for (const h of b.halts ?? [])
4944
+ out.push(`${h.inForce ? "■" : "□"} ${h.kind.padEnd(6)} ${h.id} ${h.scope.join(", ")} ${h.inForce ? "in force" : `from ${t(h.startsAt)}`} - ${t(h.endsAt)} (${h.reasonWords ?? h.reason})`);
4945
+ for (const g of b.grants ?? [])
4946
+ out.push(`${g.state === "active" ? "▲" : "△"} grant ${g.id} ${g.scope.join(", ")} ${g.state === "active" ? `active until ${t(g.endsAt)}` : `asked for (${g.minutes} min) - ${g.dNumber ?? g.decisionId}`}`);
4947
+ if (!out.length)
4948
+ out.push("No halts, freezes or firefighter grants in force or scheduled.");
4949
+ return out;
4950
+ }
4951
+ async function sessionCall(purpose, path, init = {}) {
4952
+ let r = await api(path, init);
4953
+ if (r.status === 401 || (r.status === 403 && r.body?.code === "SIGN_IN")) {
4954
+ const session = await acquireSession(purpose);
4955
+ if (!session)
4956
+ return null;
4957
+ r = await api(path, init, session);
4958
+ }
4959
+ return r;
4960
+ }
4961
+ const scopeOf = (v) => (v ? v.split(",").map((s) => s.trim()).filter(Boolean) : null);
4962
+ async function cmdControls(json) {
4963
+ const r = await sessionCall("Reading controls", "/api/controls");
4964
+ if (!r)
4965
+ return 1;
4966
+ if (r.status !== 200) {
4967
+ err(`✘ could not read controls (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
4968
+ return 1;
4969
+ }
4970
+ if (json) {
4971
+ log(JSON.stringify(r.body, null, 2));
4972
+ return 0;
4973
+ }
4974
+ for (const l of controlsLines(r.body))
4975
+ log(l);
4976
+ return 0;
4977
+ }
4978
+ async function cmdHalt(kind, o) {
4979
+ const scope = scopeOf(o.scope);
4980
+ if (!scope) {
4981
+ err(`✘ ${kind} needs --scope (owner/repo, owner/repo:environment, agent:<id>, deploys or *; comma-separated).`);
4982
+ return 2;
4983
+ }
4984
+ if (!o.reason) {
4985
+ err(`✘ ${kind} needs --reason (incident, release_freeze, maintenance, security, audit or other).`);
4986
+ return 2;
4987
+ }
4988
+ const now = new Date();
4989
+ const from = kind === "freeze" ? parseWhen(o.from ?? "now", now) : null, until = parseWhen(o.until, now);
4990
+ if ((kind === "freeze" && (!from || !until)) || (o.until && !until)) {
4991
+ err(`✘ --from and --until take an ISO date, now, or a duration like 30m, 2h or 1d${kind === "freeze" ? " (a freeze needs both)" : ""}.`);
4992
+ return 2;
4993
+ }
4994
+ const r = await sessionCall(`Setting a ${kind}`, "/api/controls/halts", { method: "POST", body: JSON.stringify({ kind, scope, reason: o.reason, startsAt: from?.toISOString() ?? null, endsAt: until?.toISOString() ?? null }) });
4995
+ if (!r)
4996
+ return 1;
4997
+ if (r.status !== 201) {
4998
+ err(`✘ ${kind} not set (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
4999
+ return 1;
5000
+ }
5001
+ log(`✔ ${kind} ${r.body.id} set: ${scope.join(", ")} - ${kind === "freeze" ? `from ${r.body.startsAt} ` : "now "}${r.body.endsAt ? `until ${r.body.endsAt}` : "until lifted"}. Lift it with: npx @decidio/sdk lift ${r.body.id}`);
5002
+ return 0;
5003
+ }
5004
+ async function cmdLift(id) {
5005
+ if (!id || !/^[a-z0-9]{10,40}$/.test(id)) {
5006
+ err("✘ lift takes a halt's or freeze's id (npx @decidio/sdk controls lists them).");
5007
+ return 2;
5008
+ }
5009
+ const r = await sessionCall("Lifting a halt", `/api/controls/halts/${encodeURIComponent(id)}/lift`, { method: "POST", body: "{}" });
5010
+ if (!r)
5011
+ return 1;
5012
+ if (r.status !== 200) {
5013
+ err(`✘ not lifted (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
5014
+ return 1;
5015
+ }
5016
+ log(r.body.lifted ? `✔ ${id} lifted.` : `• ${id} was already lifted or over - nothing changed.`);
5017
+ return 0;
5018
+ }
5019
+ async function cmdFirefighter(sub, id, o) {
5020
+ if (sub === "end") {
5021
+ if (!id || !/^[a-z0-9]{10,40}$/.test(id)) {
5022
+ err("✘ firefighter end takes the grant's id.");
5023
+ return 2;
5024
+ }
5025
+ const r = await sessionCall("Ending a firefighter grant", `/api/firefighter/${encodeURIComponent(id)}/end`, { method: "POST", body: "{}" });
5026
+ if (!r)
5027
+ return 1;
5028
+ if (r.status !== 200) {
5029
+ err(`✘ not ended (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
5030
+ return 1;
5031
+ }
5032
+ log(r.body.ended ? `✔ grant ${id} ended - its close-out review follows.` : `• grant ${id} was not active - nothing changed.`);
5033
+ return 0;
5034
+ }
5035
+ if (sub) {
5036
+ err(`✘ firefighter takes --scope --minutes --reason, or: firefighter end <id>.`);
5037
+ return 2;
5038
+ }
5039
+ const scope = scopeOf(o.scope), minutes = o.minutes && /^\d{1,3}$/.test(o.minutes) ? Number(o.minutes) : null;
5040
+ if (!scope || !minutes || !o.reason) {
5041
+ err("✘ firefighter needs --scope (systems), --minutes (15 to 240) and --reason (what is on fire).");
5042
+ return 2;
5043
+ }
5044
+ const r = await sessionCall("Asking for a firefighter grant", "/api/firefighter", { method: "POST", body: JSON.stringify({ scope, minutes, reason: o.reason }) });
5045
+ if (!r)
5046
+ return 1;
5047
+ if (r.status !== 201) {
5048
+ err(`✘ not asked (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
5049
+ return 1;
5050
+ }
5051
+ log(`✔ firefighter grant asked for (${r.body.displayNumber ?? r.body.decisionId}): the executive role approves it - never you. Once approved it is active for ${minutes} minutes.`);
5052
+ log(dim(` ${link(r.body.url)}`));
5053
+ return 0;
5054
+ }
5055
+ function originIs(repo) {
5056
+ try {
5057
+ const url = execFileSync("git", ["remote", "get-url", "origin"], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5_000 }).trim().toLowerCase().replace(/\.git$/, "");
5058
+ return url.endsWith(`/${repo.toLowerCase()}`) || url.endsWith(`:${repo.toLowerCase()}`);
5059
+ }
5060
+ catch {
5061
+ return false;
5062
+ }
5063
+ }
5064
+ async function cmdRollback(o) {
5065
+ if (!o.repo || !/^[\w.-]+\/[\w.-]+$/.test(o.repo) || !o.environment || !/^[A-Za-z0-9._-]{1,64}$/.test(o.environment)) {
5066
+ err("✘ rollback needs --repo owner/name and --environment <name>.");
5067
+ return 2;
5068
+ }
5069
+ const q = `repository=${encodeURIComponent(o.repo)}&environment=${encodeURIComponent(o.environment)}`;
5070
+ const t = await sessionCall("Asking for a rollback", `/api/github/rollback?${q}`);
5071
+ if (!t)
5072
+ return 1;
5073
+ if (t.status !== 200) {
5074
+ err(`✘ no rollback (${t.status}): ${String(t.body?.error ?? "no reason given")}`);
5075
+ return 1;
5076
+ }
5077
+ const sha = String(t.body.sha), branch = String(t.body.branch);
5078
+ if (!/^[0-9a-f]{40}$/.test(sha) || !/^decidio-rollback\/[A-Za-z0-9._-]+$/.test(branch)) {
5079
+ err("✘ the server's rollback target is not one this CLI can push.");
5080
+ return 1;
5081
+ }
5082
+ if (!o.noPush) {
5083
+ if (!originIs(o.repo)) {
5084
+ err(`✘ run this inside a checkout of ${o.repo}, or push the branch yourself and add --no-push:\n ${t.body.push}`);
5085
+ return 1;
5086
+ }
5087
+ const git = (args) => spawnSync("git", args, { stdio: ["ignore", "inherit", "inherit"], timeout: 120_000 });
5088
+ if (spawnSync("git", ["cat-file", "-e", `${sha}^{commit}`], { stdio: "ignore", timeout: 10_000 }).status !== 0)
5089
+ git(["fetch", "--quiet", "origin"]);
5090
+ const pushed = git(["push", "origin", `${sha}:refs/heads/${branch}`]);
5091
+ if (pushed.status !== 0) {
5092
+ err(`✘ git could not push ${branch} - nothing was asked. Push it yourself, then: npx @decidio/sdk rollback --repo ${o.repo} --environment ${o.environment} --no-push`);
5093
+ return 1;
5094
+ }
5095
+ log(dim(` pushed ${branch} at ${sha.slice(0, 7)}`));
5096
+ }
5097
+ const r = await sessionCall("Asking for a rollback", "/api/github/rollback", { method: "POST", body: JSON.stringify({ repository: o.repo, environment: o.environment }) });
5098
+ if (!r)
5099
+ return 1;
5100
+ if (r.status !== 201) {
5101
+ err(`✘ no rollback (${r.status}): ${String(r.body?.error ?? "no reason given")}`);
5102
+ return 1;
5103
+ }
5104
+ log(r.body.verdict === "proceed" ? `✔ rolling ${o.environment} back to ${sha.slice(0, 7)} under your firefighter grant (${r.body.displayNumber}).`
5105
+ : r.body.verdict === "block" ? `✘ the rollback was blocked (${r.body.displayNumber}) - a halt or a policy stops it.`
5106
+ : `✔ rollback of ${o.environment} to ${sha.slice(0, 7)} asked for (${r.body.displayNumber}) - it deploys from ${branch} once a teammate approves.`);
5107
+ log(dim(` ${link(r.body.url)}`));
5108
+ return r.body.verdict === "block" ? 1 : 0;
5109
+ }
3686
5110
  async function cmdGithubVerify(decisionArg) {
3687
5111
  const decision = (decisionArg ?? process.env.DECIDIO_DECISION ?? "").trim();
3688
5112
  if (!GH_DECISION_RE.test(decision)) {
@@ -3718,7 +5142,7 @@ function plainRepoPath(root, rel) {
3718
5142
  }
3719
5143
  return true;
3720
5144
  }
3721
- export const DECIDIO_ACTION = "OmniTwin/decidio-actions@c4506075600edef9576db821c4de756c300c15fe # v1.0.1";
5145
+ export const DECIDIO_ACTION = "OmniTwin/decidio-actions@f0b43fcb8f8ac5e15d9ad43679e333ffa7038ae2 # v1.3.1";
3722
5146
  export const CHECKOUT_ACTION = "actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0";
3723
5147
  export function githubRequestWorkflow(api, environment, branch = "main", deployWorkflow = ".github/workflows/deploy.yml") {
3724
5148
  return `# Written by npx @decidio/sdk github init. Asks a teammate to approve each deploy; Decidio starts the deploy workflow once
@@ -3763,13 +5187,16 @@ export function githubDeployWorkflow(api, environment) {
3763
5187
  # approval (once, for this commit and environment) before anything deploys. Restrict who may run it to the Decidio app in the
3764
5188
  # repository's workflow execution protections.
3765
5189
  name: Deploy
3766
- run-name: Decidio \${{ inputs.decidio }}
5190
+ run-name: \${{ inputs.title || format('Decidio {0}', inputs.decidio) }}
3767
5191
  on:
3768
5192
  workflow_dispatch:
3769
5193
  inputs:
3770
5194
  decidio:
3771
5195
  description: Set by Decidio
3772
5196
  required: true
5197
+ title:
5198
+ description: Set by Decidio (the run's name - its record, environment and commit)
5199
+ required: false
3773
5200
  permissions:
3774
5201
  id-token: write
3775
5202
  contents: read
@@ -3862,7 +5289,7 @@ async function cmdGithubInit(opts) {
3862
5289
  log(` ${line}`);
3863
5290
  }
3864
5291
  const agentsRel = "AGENTS.md", agentsAbs = joinPath(root, agentsRel);
3865
- const appUrl = (process.env.DECIDIO_APP_URL || apiUrl.replace(/^https:\/\/(decidio-api\.onrender\.com|mcp\.decidioai\.com)$/, "https://app.decidioai.com")).replace(/\/$/, "");
5292
+ const appUrl = appUrlFor(apiUrl);
3866
5293
  const section = agentsMdSection(appUrl);
3867
5294
  if (!plainRepoPath(root, agentsRel))
3868
5295
  err(`✘ ${agentsRel} is a symbolic link - nothing was written through it`);
@@ -3886,6 +5313,16 @@ async function cmdGithubInit(opts) {
3886
5313
  log(` 3. Commit the workflows and push - that push asks for nothing (it changes only these files).`);
3887
5314
  log(` 4. Check the connection: Check the connection in Decidio, or gh workflow run decidio-request.yml - it asks for nothing.`);
3888
5315
  log(` Your next push to ${branch} asks a teammate.`);
5316
+ const sameBox = apiUrl === API.replace(/\/$/, "");
5317
+ log(` 5. Approve from your phone: ${sameBox ? "" : `DECIDIO_API_URL=${apiUrl} `}npx @decidio/sdk phone ${dim("(a QR in your terminal signs it in - the Decidio lab)")}`);
5318
+ if (sameBox && process.stdin.isTTY && process.stdout.isTTY) {
5319
+ log("");
5320
+ log(` Set up your phone to approve now? ${dim("[y/n]")}`);
5321
+ if ((await ask(" › ")).trim().toLowerCase() === "y") {
5322
+ log("");
5323
+ await cmdPhone();
5324
+ }
5325
+ }
3889
5326
  return 0;
3890
5327
  }
3891
5328
  const USAGE = `✦ Decidio — the SDK command line
@@ -3927,6 +5364,12 @@ SIGN IN (once)
3927
5364
  npx @decidio/sdk logout forget the stored sign-in on this computer (the token itself stays valid
3928
5365
  until it expires — there is no server-side revocation yet)
3929
5366
 
5367
+ YOUR PHONE
5368
+ npx @decidio/sdk phone put Decidio on your phone: sign in (once), then - only on your y - a QR in this
5369
+ terminal signs the phone in (one time, 5 minutes). iPhone: the app opens, or the page
5370
+ walks you through installing it; Android: the phone's browser. Enter = a fresh QR.
5371
+ Terminal only; the Decidio lab for now (on your company's Decidio: SSO on the phone)
5372
+
3930
5373
  YOUR AGENT AND ITS TOKENS
3931
5374
  npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
3932
5375
  (agentId defaults to this folder's name; any name works - it is how the
@@ -3960,6 +5403,17 @@ APPROVALS AND RECEIPTS
3960
5403
  (never the receipt's own issuer field; --allow-unpinned: structural check only)
3961
5404
 
3962
5405
  CODING AGENTS (Claude Code)
5406
+ npx @decidio/sdk connect connect this project's coding assistant in one tap: prints a link and a short
5407
+ code (a QR and the browser too, on a real terminal); the workspace owner allows
5408
+ it on the web, then this machine gets an ask-only credential (kept outside the
5409
+ repository) and the hooks below switch on. Your assistant can run it for you.
5410
+ npx @decidio/sdk connect --wait finish a connect started by an assistant: returns once it is allowed, within 90 s
5411
+ (exit 0 connected · 3 refused or expired · 1 still waiting, run it again)
5412
+ --team: the shared .claude/settings.json every clone reads (default: yours
5413
+ alone; a cloud session reads only the shared file, so there it is always that
5414
+ one) · --no-browser · --force: a fresh token for this machine
5415
+ npx @decidio/sdk disconnect delete this machine's connection for this project (destructive commands are then
5416
+ refused until you connect again; the hooks stay on)
3963
5417
  npx @decidio/sdk hook install [--project] [--approver <email>] [--cli <path>]
3964
5418
  a person approves Claude Code's destructive shell commands before they run
3965
5419
  (rm -rf, git push --force, DROP TABLE, terraform apply, kubectl delete,
@@ -4015,7 +5469,7 @@ GITHUB DEPLOY APPROVALS (the Decidio GitHub App)
4015
5469
  in a repository's checkout: writes the workflow that asks for approval
4016
5470
  (.github/workflows/decidio-request.yml) and the deploy workflow when there is none
4017
5471
  (an existing one is never changed: init prints the steps to add), both using
4018
- Decidio's GitHub Action pinned to its v1.0.1 commit; adds a link to Decidio for agents to AGENTS.md (--agents-md creates it) -
5472
+ Decidio's GitHub Action pinned to its v1.3.1 commit; adds a link to Decidio for agents to AGENTS.md (--agents-md creates it) -
4019
5473
  never a decision's text
4020
5474
  npx @decidio/sdk github request --environment <name> [--justification "<what changed>"]
4021
5475
  inside a GitHub Actions job (permissions id-token: write): ask a teammate to approve
@@ -4024,6 +5478,28 @@ GITHUB DEPLOY APPROVALS (the Decidio GitHub App)
4024
5478
  commit and environment, and writes sha= to $GITHUB_OUTPUT; anything else exits 1 -
4025
5479
  do not deploy (DECIDIO_DECISION carries the decidio input)
4026
5480
 
5481
+ npx @decidio/sdk deploys [D·0042] [--environment <name>] [--repo <owner/name>] [--limit N] [--watch] [--json]
5482
+ what's deployed where: the recent deploy requests you may read, each with the words its
5483
+ commit shows on GitHub; a deployed one names the previous deployed commit and a compare
5484
+ link - what that deploy changed, and the rollback target; one deploy (its D-number) adds
5485
+ the pull requests it shipped and who approved them; --watch follows changes
5486
+
5487
+ EMERGENCY CONTROLS
5488
+ npx @decidio/sdk controls [--json] the halts, freezes and firefighter grants in force or scheduled
5489
+ npx @decidio/sdk halt --scope <systems> --reason <incident|release_freeze|maintenance|security|audit|other> [--until 2h|ISO]
5490
+ stop everything in scope now (owner/repo, owner/repo:environment, agent:<id>, deploys, *)
5491
+ - the workspace owner or the executive role only; until lifted unless --until
5492
+ npx @decidio/sdk freeze --scope <systems> --reason <...> --from now|ISO --until 2d|ISO
5493
+ a change freeze for a window (a halt scheduled ahead)
5494
+ npx @decidio/sdk lift <id> lift a halt or end a freeze now
5495
+ npx @decidio/sdk firefighter --scope <systems> --minutes <15-240> --reason "<what is on fire>"
5496
+ ask for break-glass authority: the executive role approves it (never you); while active,
5497
+ your own requests to those systems go ahead and are reviewed when it ends
5498
+ npx @decidio/sdk firefighter end <id> end your grant early
5499
+ npx @decidio/sdk rollback --repo <owner/name> --environment <name> [--no-push]
5500
+ ask to deploy the environment's previous approved commit (approved like any deploy):
5501
+ run it in a checkout - your git pushes a branch at that commit, the deploy runs there
5502
+
4027
5503
  DEVELOPMENT
4028
5504
  npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
4029
5505
  npx @decidio/sdk --version print the CLI version
@@ -4042,7 +5518,8 @@ NOTES
4042
5518
  Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
4043
5519
  3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
4044
5520
  receipt written — pass --auto-approve-demo for a run that completes unattended), or
4045
- hook wait: the approver said no. hook pretool/posttool always exit 0 (their answer is the JSON).
5521
+ hook wait: the approver said no, or connect --wait: the connection was refused or expired (1 there: still
5522
+ waiting - run it again). hook pretool/posttool always exit 0 (their answer is the JSON).
4046
5523
  Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
4047
5524
  pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
4048
5525
  Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
@@ -4159,6 +5636,8 @@ async function main(argv) {
4159
5636
  return pick.code;
4160
5637
  if (pick.kind === "logout")
4161
5638
  return cmdLogout();
5639
+ if (pick.kind === "phone")
5640
+ return cmdPhone();
4162
5641
  if (pick.kind === "init")
4163
5642
  return cmdInit(pick.agentId, {});
4164
5643
  return cmdQuickstart({ demo: pick.kind === "demo" });
@@ -4180,7 +5659,10 @@ async function main(argv) {
4180
5659
  quickstart: { flags: ["--explain", "--auto-approve-demo", "--keep-agent", "--demo"], valued: ["--phone"] },
4181
5660
  login: { flags: [] },
4182
5661
  logout: { flags: [] },
5662
+ phone: { flags: [] },
4183
5663
  init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
5664
+ connect: { flags: ["--wait", "--no-browser", "--team", "--force"], valued: ["--host"] },
5665
+ disconnect: { flags: [] },
4184
5666
  doctor: { flags: [] },
4185
5667
  approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
4186
5668
  receipt: { flags: ["--json"] },
@@ -4189,6 +5671,13 @@ async function main(argv) {
4189
5671
  dev: { flags: [], valued: ["--target", "--port"] },
4190
5672
  hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli"] },
4191
5673
  github: { flags: ["--agents-md"], valued: ["--environment", "--justification", "--decision", "--workflow", "--api"] },
5674
+ deploys: { flags: ["--watch", "--json"], valued: ["--environment", "--repo", "--limit"] },
5675
+ controls: { flags: ["--json"] },
5676
+ halt: { flags: [], valued: ["--scope", "--reason", "--until"] },
5677
+ freeze: { flags: [], valued: ["--scope", "--reason", "--from", "--until"] },
5678
+ lift: { flags: [] },
5679
+ firefighter: { flags: [], valued: ["--scope", "--minutes", "--reason"] },
5680
+ rollback: { flags: ["--no-push"], valued: ["--repo", "--environment"] },
4192
5681
  decisions: { flags: ["--dry-run"], valued: ["--adr"] },
4193
5682
  };
4194
5683
  const spec = KNOWN[cmd];
@@ -4223,7 +5712,7 @@ async function main(argv) {
4223
5712
  return 2;
4224
5713
  }
4225
5714
  }
4226
- const MAX_POS = { login: 1, logout: 0, quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0, hook: 2, github: 1, decisions: 1 };
5715
+ const MAX_POS = { login: 1, logout: 0, phone: 0, quickstart: 1, init: 1, connect: 0, disconnect: 0, doctor: 0, receipt: 1, tokens: 2, dev: 0, hook: 2, github: 1, decisions: 1 };
4227
5716
  const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || (cmd === "hook" && positionals[0] === "justify") || cmd === "verify";
4228
5717
  if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
4229
5718
  err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
@@ -4248,6 +5737,7 @@ async function main(argv) {
4248
5737
  switch (cmd) {
4249
5738
  case "login": return cmdLogin(argsIn.find((x) => !x.startsWith("-")));
4250
5739
  case "logout": return cmdLogout();
5740
+ case "phone": return cmdPhone();
4251
5741
  case "quickstart": {
4252
5742
  if (argsIn.includes("cleanup")) {
4253
5743
  const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
@@ -4380,6 +5870,8 @@ async function main(argv) {
4380
5870
  }
4381
5871
  return cmdContext(sub, pos.slice(1), { title: valuedOpt(argsIn, "--title"), trigger: valuedOpt(argsIn, "--trigger"), team: valuedOpt(argsIn, "--team"), unit: valuedOpt(argsIn, "--unit"), org: argsIn.includes("--org"), here: argsIn.includes("--here"), pack: valuedOpt(argsIn, "--pack"), chatgpt: argsIn.includes("--chatgpt"), json: argsIn.includes("--json") });
4382
5872
  }
5873
+ case "connect": return cmdConnect({ wait: argsIn.includes("--wait"), host: valuedOpt(argsIn, "--host"), noBrowser: argsIn.includes("--no-browser"), team: argsIn.includes("--team"), force: argsIn.includes("--force") });
5874
+ case "disconnect": return cmdDisconnect();
4383
5875
  case "hook": {
4384
5876
  const VALUED = ["--timeout", "--approver", "--cli"];
4385
5877
  const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
@@ -4412,6 +5904,31 @@ async function main(argv) {
4412
5904
  return 2;
4413
5905
  }
4414
5906
  }
5907
+ case "deploys": {
5908
+ const VALUED = ["--environment", "--repo", "--limit"];
5909
+ const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
5910
+ if (pos.length > 1) {
5911
+ err(`✘ deploys takes at most one deploy id.\n\n${USAGE}`);
5912
+ return 2;
5913
+ }
5914
+ return cmdDeploys(pos[0], { environment: valuedOpt(argsIn, "--environment"), repo: valuedOpt(argsIn, "--repo"), limit: valuedOpt(argsIn, "--limit"), watch: argsIn.includes("--watch"), json: argsIn.includes("--json") });
5915
+ }
5916
+ case "controls": return cmdControls(argsIn.includes("--json"));
5917
+ case "halt":
5918
+ case "freeze": {
5919
+ if (argsIn.some((x, i) => !x.startsWith("-") && !["--scope", "--reason", "--until", "--from"].includes(argsIn[i - 1] ?? ""))) {
5920
+ err(`✘ ${cmd} takes only flags.\n\n${USAGE}`);
5921
+ return 2;
5922
+ }
5923
+ return cmdHalt(cmd, { scope: valuedOpt(argsIn, "--scope"), reason: valuedOpt(argsIn, "--reason"), from: valuedOpt(argsIn, "--from"), until: valuedOpt(argsIn, "--until") });
5924
+ }
5925
+ case "lift": return cmdLift(argsIn.find((x) => !x.startsWith("-")));
5926
+ case "firefighter": {
5927
+ const VALUED = ["--scope", "--minutes", "--reason"];
5928
+ const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
5929
+ return cmdFirefighter(pos[0], pos[1], { scope: valuedOpt(argsIn, "--scope"), minutes: valuedOpt(argsIn, "--minutes"), reason: valuedOpt(argsIn, "--reason") });
5930
+ }
5931
+ case "rollback": return cmdRollback({ repo: valuedOpt(argsIn, "--repo"), environment: valuedOpt(argsIn, "--environment"), noPush: argsIn.includes("--no-push") });
4415
5932
  case "decisions": {
4416
5933
  const sub = argsIn.find((x, i) => !x.startsWith("-") && argsIn[i - 1] !== "--adr");
4417
5934
  if (sub !== "import") {