@decidio/sdk 0.6.4 → 0.8.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/CHANGELOG.md +167 -0
- package/README.md +104 -24
- package/dist/adapters/_gate.d.ts +47 -2
- package/dist/adapters/_gate.js +49 -3
- package/dist/adapters/langgraph.d.ts +5 -11
- package/dist/adapters/langgraph.js +57 -6
- package/dist/adapters/openai.d.ts +40 -10
- package/dist/adapters/openai.js +19 -11
- package/dist/adapters/temporal.d.ts +21 -2
- package/dist/adapters/temporal.js +18 -5
- package/dist/cli.d.ts +59 -0
- package/dist/cli.js +2095 -194
- package/dist/demo-gate.js +6 -4
- package/dist/index.d.ts +36 -4
- package/dist/index.js +50 -5
- package/dist/inngest.d.ts +5 -2
- package/dist/inngest.js +23 -15
- package/dist/resume.d.ts +108 -5
- package/dist/resume.js +215 -109
- package/dist/signing.d.ts +40 -0
- package/dist/signing.js +43 -1
- package/openapi.yaml +65 -6
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync } from "node:fs";
|
|
3
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, rmdirSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync, statSync, lstatSync } from "node:fs";
|
|
3
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
4
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
|
-
import { join as joinPath, resolve as resolvePath, sep as pathSep, basename } from "node:path";
|
|
5
|
+
import { join as joinPath, resolve as resolvePath, sep as pathSep, basename, relative, isAbsolute } from "node:path";
|
|
6
6
|
import { createServer } from "node:http";
|
|
7
|
+
import { isIP } from "node:net";
|
|
7
8
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
8
9
|
import { createInterface } from "node:readline";
|
|
9
10
|
import { pathToFileURL as toFileUrl, fileURLToPath as fromFileUrl } from "node:url";
|
|
@@ -11,8 +12,9 @@ import { realpathSync as realPath } from "node:fs";
|
|
|
11
12
|
import { createRequire } from "node:module";
|
|
12
13
|
import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
|
|
13
14
|
import { hardenFile } from "./resume.js";
|
|
14
|
-
import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, didFromPrivateKey } from "./index.js";
|
|
15
|
-
import {
|
|
15
|
+
import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, DecidioHttpError, didFromPrivateKey, checkApproval, signConfirm, buildConfirmBody, readConfirmResponse, bindingMaterial, boundScope, idempotencyKey, decisionUrl, decisionIdProblem } from "./index.js";
|
|
16
|
+
import { gateAuthorizeTwinAware, adapterRequest } from "./adapters/_gate.js";
|
|
17
|
+
import { verifyReceipt, runVerify, pinRequiredMessage, builtInAnchor, KNOWN_ISSUERS } from "@decidio/verify";
|
|
16
18
|
import { startDemoGate } from "./demo-gate.js";
|
|
17
19
|
const VERSION = (() => {
|
|
18
20
|
try {
|
|
@@ -50,6 +52,10 @@ const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", undefined].in
|
|
|
50
52
|
|| wantsHelp(ARGV_TAIL[0]) || wantsHelp(ARGV_TAIL[1]) || helpFlagAnywhere(ARGV_TAIL);
|
|
51
53
|
if (!OFFLINE_CMD) {
|
|
52
54
|
readEnvFile();
|
|
55
|
+
if (envUnreadable && ARGV_TAIL[0] === "hook" && ARGV_TAIL[1] === "pretool") {
|
|
56
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Decidio hook: this project's .env exists but could not be read, so the agent's credentials are unknown - refusing this tool call (fail closed). Ask the user to fix the file's permissions." } }) + "\n");
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
53
59
|
if (envUnreadable) {
|
|
54
60
|
console.error(`✘ .env exists here but could not be read, so this command cannot know what is already configured in this directory.`);
|
|
55
61
|
console.error(` Treating it as empty would risk overwriting an agent key that cannot be recovered. Fix the file's permissions (or move it aside) and run the command again. Nothing was changed.`);
|
|
@@ -63,16 +69,19 @@ if (!OFFLINE_CMD)
|
|
|
63
69
|
console.error("⚠ ignoring DECIDIO_SESSION_TOKEN found in .env — session tokens belong in your shell or CI secret store, never on disk. Remove that line; export it in the shell instead.");
|
|
64
70
|
continue;
|
|
65
71
|
}
|
|
72
|
+
if (!k.startsWith("DECIDIO_"))
|
|
73
|
+
continue;
|
|
66
74
|
if (process.env[k] === undefined)
|
|
67
75
|
process.env[k] = v;
|
|
68
76
|
}
|
|
69
|
-
|
|
77
|
+
const DEFAULT_API = "https://decidio-api.onrender.com";
|
|
78
|
+
let API = (process.env.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
|
|
70
79
|
let DEMO = false;
|
|
71
80
|
function enterDemo(url) { API = url; process.env.DECIDIO_API_URL = url; DEMO = true; }
|
|
72
81
|
const pinWords = () => (DEMO ? "PINNED to this demo's own key" : "PINNED to your workspace issuer");
|
|
73
82
|
const issuerNote = () => (DEMO
|
|
74
83
|
? "· a key this demo generated for this run — real tours pin the host's published DID at https://decidio-api.onrender.com/api/did"
|
|
75
|
-
: `· your trust anchor —
|
|
84
|
+
: `· your trust anchor — named by this server over the signed-in connection, never read from the receipt; cross-check ${API}/api/did`);
|
|
76
85
|
const TOKEN = () => process.env.DECIDIO_API_TOKEN;
|
|
77
86
|
const ENV_PATH = ".env";
|
|
78
87
|
const log = (...a) => console.log(...a);
|
|
@@ -509,14 +518,15 @@ if (process.env.DECIDIO_DEBUG_INPUT && process.stdin.isTTY) {
|
|
|
509
518
|
}
|
|
510
519
|
function terminalId() {
|
|
511
520
|
for (const k of ["WT_SESSION", "VSCODE_IPC_HOOK_CLI", "TERM_SESSION_ID", "ITERM_SESSION_ID", "TMUX_PANE", "KONSOLE_DBUS_SESSION", "GNOME_TERMINAL_SCREEN", "WINDOWID"]) {
|
|
512
|
-
const v =
|
|
521
|
+
const v = SHELL_ENV[k];
|
|
513
522
|
if (v)
|
|
514
523
|
return `${k}=${v}`;
|
|
515
524
|
}
|
|
516
525
|
return "";
|
|
517
526
|
}
|
|
518
527
|
function sessionDir() {
|
|
519
|
-
const
|
|
528
|
+
const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
|
|
529
|
+
const base = process.platform === "win32" ? (SHELL_ENV.LOCALAPPDATA ?? joinPath(home, "AppData", "Local")) : (SHELL_ENV.XDG_CONFIG_HOME ?? joinPath(home, ".config"));
|
|
520
530
|
return joinPath(base, "decidio");
|
|
521
531
|
}
|
|
522
532
|
const sessionFile = () => joinPath(sessionDir(), "session.json");
|
|
@@ -572,6 +582,140 @@ function endSession() {
|
|
|
572
582
|
return false;
|
|
573
583
|
}
|
|
574
584
|
}
|
|
585
|
+
const issuersFile = () => joinPath(sessionDir(), "issuers.json");
|
|
586
|
+
const DID_KEY_SHAPE = /^did:key:z[1-9A-HJ-NP-Za-km-z]{40,60}$/;
|
|
587
|
+
const STORE_HOST_LABELS = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
|
588
|
+
export function issuerStoreKey(url) {
|
|
589
|
+
const raw = url.trim();
|
|
590
|
+
const low = raw.replace(/\/+$/, "").toLowerCase();
|
|
591
|
+
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]*)(?:[/?#]|$)/.exec(raw);
|
|
592
|
+
if (!m)
|
|
593
|
+
return low;
|
|
594
|
+
const scheme = m[1].toLowerCase();
|
|
595
|
+
if (scheme !== "http" && scheme !== "https")
|
|
596
|
+
return low;
|
|
597
|
+
let authority = m[2];
|
|
598
|
+
const at = authority.lastIndexOf("@");
|
|
599
|
+
if (at >= 0)
|
|
600
|
+
authority = authority.slice(at + 1);
|
|
601
|
+
let host, portStr = "";
|
|
602
|
+
if (authority.startsWith("[")) {
|
|
603
|
+
const rb = authority.indexOf("]");
|
|
604
|
+
if (rb < 0)
|
|
605
|
+
return low;
|
|
606
|
+
const inner = authority.slice(1, rb).toLowerCase();
|
|
607
|
+
const rest = authority.slice(rb + 1);
|
|
608
|
+
if (isIP(inner) !== 6)
|
|
609
|
+
return low;
|
|
610
|
+
if (rest !== "") {
|
|
611
|
+
if (!/^:\d+$/.test(rest))
|
|
612
|
+
return low;
|
|
613
|
+
portStr = rest.slice(1);
|
|
614
|
+
}
|
|
615
|
+
host = `[${inner}]`;
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
const c = authority.lastIndexOf(":");
|
|
619
|
+
if (c >= 0) {
|
|
620
|
+
const p = authority.slice(c + 1);
|
|
621
|
+
if (!/^\d+$/.test(p))
|
|
622
|
+
return low;
|
|
623
|
+
portStr = p;
|
|
624
|
+
host = authority.slice(0, c).toLowerCase();
|
|
625
|
+
}
|
|
626
|
+
else
|
|
627
|
+
host = authority.toLowerCase();
|
|
628
|
+
if (!STORE_HOST_LABELS.test(host))
|
|
629
|
+
return low;
|
|
630
|
+
}
|
|
631
|
+
const port = portStr === "" ? null : Number(portStr);
|
|
632
|
+
const dflt = scheme === "https" ? 443 : 80;
|
|
633
|
+
return port !== null && port !== dflt ? `${scheme}://${host}:${port}` : `${scheme}://${host}`;
|
|
634
|
+
}
|
|
635
|
+
function readIssuerStore() {
|
|
636
|
+
const f = issuersFile();
|
|
637
|
+
if (!existsSync(f))
|
|
638
|
+
return {};
|
|
639
|
+
const rawText = readFileSync(f, "utf8");
|
|
640
|
+
const s = JSON.parse(rawText);
|
|
641
|
+
const vLiteral = /"v"\s*:\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/.exec(rawText)?.[1] ?? null;
|
|
642
|
+
const hosts = s?.v === 1 && vLiteral === "1" && s.hosts && typeof s.hosts === "object" && !Array.isArray(s.hosts) ? s.hosts : null;
|
|
643
|
+
if (!hosts)
|
|
644
|
+
throw new Error("not a v1 issuer store");
|
|
645
|
+
const out = {};
|
|
646
|
+
for (const [h, e] of Object.entries(hosts)) {
|
|
647
|
+
if (typeof e?.did !== "string" || !DID_KEY_SHAPE.test(e.did) || typeof e?.firstSeen !== "string")
|
|
648
|
+
throw new Error(`malformed entry for ${h}`);
|
|
649
|
+
const k = issuerStoreKey(h);
|
|
650
|
+
if (out[k] && out[k].did !== e.did)
|
|
651
|
+
throw new Error(`two entries for ${k} name different DIDs`);
|
|
652
|
+
out[k] ??= { did: e.did, firstSeen: e.firstSeen };
|
|
653
|
+
}
|
|
654
|
+
return out;
|
|
655
|
+
}
|
|
656
|
+
function rememberedIssuer(host = API) { return readIssuerStore()[issuerStoreKey(host)] ?? null; }
|
|
657
|
+
const verifyHost = () => (SHELL_ENV.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
|
|
658
|
+
function warnIssuerChanged(known, seen, via) {
|
|
659
|
+
const key = issuerStoreKey(API);
|
|
660
|
+
err(`⚠ THE ISSUER DID OF ${key} HAS CHANGED — it was NOT adopted.`);
|
|
661
|
+
err(` remembered (first seen ${known.firstSeen.slice(0, 10)}): ${known.did}`);
|
|
662
|
+
err(` ${via} now says: ${seen}`);
|
|
663
|
+
err(` Receipts from this host are still verified against the REMEMBERED DID. A new DID means a new signing key: a planned`);
|
|
664
|
+
err(` rotation, another deployment behind the same address, or someone answering in its place. Confirm it with the host's`);
|
|
665
|
+
err(` operator out of band before trusting it; to adopt it, remove the "${key}" entry from ${issuersFile()} and sign in again.`);
|
|
666
|
+
}
|
|
667
|
+
async function rememberHostIssuer() {
|
|
668
|
+
if (DEMO)
|
|
669
|
+
return;
|
|
670
|
+
let store;
|
|
671
|
+
try {
|
|
672
|
+
store = readIssuerStore();
|
|
673
|
+
}
|
|
674
|
+
catch (e) {
|
|
675
|
+
err(`⚠ the issuer DIDs this computer remembers (${issuersFile()}) could not be read (${e?.message ?? e}) — left as found, nothing new remembered. Repair the file or move it aside.`);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const r = await api("/api/did", {}, "", true);
|
|
679
|
+
const did = typeof r.body?.did === "string" && DID_KEY_SHAPE.test(r.body.did) ? r.body.did : null;
|
|
680
|
+
if (!did) {
|
|
681
|
+
log(dim(` (${API}/api/did did not answer with a DID (${r.status || "no response"}), so this host's issuer is not remembered yet — verify needs --issuer for its receipts until a later sign-in remembers it)`));
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const key = issuerStoreKey(API);
|
|
685
|
+
const known = store[key];
|
|
686
|
+
if (known?.did === did)
|
|
687
|
+
return;
|
|
688
|
+
if (known) {
|
|
689
|
+
warnIssuerChanged(known, did, `${API}/api/did`);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
store[key] = { did, firstSeen: new Date().toISOString() };
|
|
693
|
+
try {
|
|
694
|
+
mkdirSync(sessionDir(), { recursive: true });
|
|
695
|
+
writeSecretFile(issuersFile(), JSON.stringify({ v: 1, hosts: store }, null, 2) + "\n");
|
|
696
|
+
}
|
|
697
|
+
catch (e) {
|
|
698
|
+
err(`⚠ could not remember this host's issuer DID (${issuersFile()}: ${e?.message ?? e}) — verify will need --issuer for its receipts.`);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
log(` ✔ remembered this host's issuer DID (from ${API}/api/did) — verify pins its receipts to it, no --issuer needed: ${did}`);
|
|
702
|
+
}
|
|
703
|
+
function hostAnchor(body) {
|
|
704
|
+
const named = typeof body?.expectedIssuer === "string" && DID_KEY_SHAPE.test(body.expectedIssuer) ? body.expectedIssuer : null;
|
|
705
|
+
if (DEMO)
|
|
706
|
+
return named;
|
|
707
|
+
let mem;
|
|
708
|
+
try {
|
|
709
|
+
mem = rememberedIssuer();
|
|
710
|
+
}
|
|
711
|
+
catch (e) {
|
|
712
|
+
err(`⚠ the issuer DIDs this computer remembers (${issuersFile()}) could not be read (${e?.message ?? e}) — no trust anchor until it is repaired or moved aside.`);
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
if (mem && named && mem.did !== named)
|
|
716
|
+
warnIssuerChanged(mem, named, "this server's verify response");
|
|
717
|
+
return mem?.did ?? named;
|
|
718
|
+
}
|
|
575
719
|
async function loginFlow(purpose, emailArg) {
|
|
576
720
|
log(purpose === "Signing in" ? `Sign in once — kept on this computer for 30 days, or until this terminal closes, logout, or cleanup.` : `${purpose} needs your Decidio workspace sign-in — once: kept on this computer for 30 days, or until this terminal closes, logout, or cleanup.`);
|
|
577
721
|
const email = emailArg ?? await ask(" email: ");
|
|
@@ -585,6 +729,7 @@ async function loginFlow(purpose, emailArg) {
|
|
|
585
729
|
if (r.status >= 200 && r.status < 300 && r.body?.token) {
|
|
586
730
|
const s = saveSession(String(r.body.token), email);
|
|
587
731
|
log(` ✔ signed in as ${email} until ${s.expiresAt.slice(0, 10)}${s.terminal ? "" : dim(" (no terminal id here: the sign-in ends on logout or cleanup, not on close)")}`);
|
|
732
|
+
await rememberHostIssuer();
|
|
588
733
|
return s.token;
|
|
589
734
|
}
|
|
590
735
|
log(` ✘ password sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. No problem — the emailed link works for every account:`);
|
|
@@ -610,6 +755,7 @@ async function loginFlow(purpose, emailArg) {
|
|
|
610
755
|
if (r.status >= 200 && r.status < 300 && r.body?.token) {
|
|
611
756
|
const s = saveSession(String(r.body.token), email);
|
|
612
757
|
log(` ✔ signed in as ${email} until ${s.expiresAt.slice(0, 10)}${s.terminal ? "" : dim(" (no terminal id here: the sign-in ends on logout or cleanup, not on close)")}`);
|
|
758
|
+
await rememberHostIssuer();
|
|
613
759
|
return s.token;
|
|
614
760
|
}
|
|
615
761
|
if (isLink) {
|
|
@@ -772,7 +918,9 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
772
918
|
return 1;
|
|
773
919
|
}
|
|
774
920
|
const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
|
|
775
|
-
const
|
|
921
|
+
const soxEnv = (process.env.DECIDIO_SOX_RELEVANT ?? "").trim().toLowerCase();
|
|
922
|
+
const soxRelevant = soxEnv === "yes" || soxEnv === "true" ? true : soxEnv === "no" || soxEnv === "false" ? false : undefined;
|
|
923
|
+
const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}), ...(soxRelevant !== undefined ? { soxRelevant } : {}) });
|
|
776
924
|
let envPrepared = false;
|
|
777
925
|
const prepareEnvForWrite = () => {
|
|
778
926
|
if (envPrepared)
|
|
@@ -818,6 +966,12 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
818
966
|
prepareEnvForWrite();
|
|
819
967
|
upsertEnv({ DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: did ?? localDid, DECIDIO_AGENT_KEY: localKey });
|
|
820
968
|
}
|
|
969
|
+
else if (r.status === 400 && r.body?.code === "OWNER_REQUIRED") {
|
|
970
|
+
err(`✘ ${r.body?.message ?? "an agent needs a human owner"}`);
|
|
971
|
+
err(` Sign in as a person so you own it: unset DECIDIO_API_TOKEN (the operator token names no one) and re-run init — it asks you to sign in.`);
|
|
972
|
+
err(` Or have a workspace owner register it with an ownerUserId (POST /api/agents/register) or set one under Connect > Agents.`);
|
|
973
|
+
return 1;
|
|
974
|
+
}
|
|
821
975
|
else {
|
|
822
976
|
err(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
|
|
823
977
|
err(` Fix the cause above and re-run — init is safe to repeat.`);
|
|
@@ -897,6 +1051,7 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
897
1051
|
log(`✔ wrote ${ENV_PATH} (owner-only where the OS supports it) — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
|
|
898
1052
|
if (privateKey)
|
|
899
1053
|
log(` (the agent's private signing key is in there — for production, move it to your secret manager)`);
|
|
1054
|
+
await rememberHostIssuer();
|
|
900
1055
|
log("");
|
|
901
1056
|
log("Next: protect one function and watch it route. Two steps:");
|
|
902
1057
|
log("");
|
|
@@ -1151,19 +1306,42 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
1151
1306
|
}
|
|
1152
1307
|
const name = receiptFileName(id);
|
|
1153
1308
|
const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
|
|
1154
|
-
const
|
|
1309
|
+
const issuer = hostAnchor(r.body);
|
|
1310
|
+
const local = issuer ? verifyReceipt(r.body.credential, issuer) : null;
|
|
1311
|
+
const serverInvalid = r.body?.valid === false;
|
|
1312
|
+
const localInvalid = !!local && !local.valid;
|
|
1313
|
+
const bad = serverInvalid || localInvalid;
|
|
1314
|
+
const say = jsonMode ? err : log;
|
|
1315
|
+
const wrote = writeReceiptFile(name, nextBytes, say);
|
|
1155
1316
|
if (wrote === "conflict")
|
|
1156
1317
|
return 1;
|
|
1157
1318
|
if (wrote === "written")
|
|
1158
|
-
(
|
|
1319
|
+
say(bad ? `• wrote ${name} — kept for inspection only: it does NOT verify (below)` : `✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
|
|
1159
1320
|
const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
|
|
1160
1321
|
const conf = detail.body?.record?.confirmation;
|
|
1161
|
-
const issuer = r.body?.issuer;
|
|
1162
1322
|
const resolved = r.body?.resolved;
|
|
1323
|
+
const badSaid = () => {
|
|
1324
|
+
if (serverInvalid) {
|
|
1325
|
+
err(`✘ the server's own check says this record does NOT verify: ${r.body?.reason ?? "no reason given"}`);
|
|
1326
|
+
err(` (signature ${r.body?.signatureValid === false ? "INVALID" : "valid"} · content bound ${r.body?.contentBound === false ? "NO" : "yes"} · matches the stored record ${r.body?.matchesRecord === false ? "NO" : "yes"} · stored payload ${r.body?.payloadMatches === false ? "ALTERED" : "intact"})`);
|
|
1327
|
+
}
|
|
1328
|
+
if (localInvalid)
|
|
1329
|
+
err(`✘ this receipt does NOT verify against this Decidio's issuer DID (${issuer}): ${local.reason ?? "no reason given"}`);
|
|
1330
|
+
err(` Do not rely on ${name}. Report it to the operator of ${API} — a sealed record is immutable, so this is tampering, a defect, or an unannounced key change (see any warning above), never a normal state.`);
|
|
1331
|
+
};
|
|
1163
1332
|
if (jsonMode) {
|
|
1164
|
-
|
|
1333
|
+
const valid = bad ? false : local?.valid === true ? true : null;
|
|
1334
|
+
log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null, valid, ...(bad ? { reason: serverInvalid ? r.body?.reason ?? null : local?.reason ?? null } : {}), ...(resolved ? { resolved } : {}) }, null, 2));
|
|
1335
|
+
if (bad) {
|
|
1336
|
+
badSaid();
|
|
1337
|
+
return 1;
|
|
1338
|
+
}
|
|
1165
1339
|
return 0;
|
|
1166
1340
|
}
|
|
1341
|
+
if (bad) {
|
|
1342
|
+
badSaid();
|
|
1343
|
+
return 1;
|
|
1344
|
+
}
|
|
1167
1345
|
if (conf?.status)
|
|
1168
1346
|
log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
|
|
1169
1347
|
if (resolved && Object.keys(resolved).length && !process.stdout.isTTY) {
|
|
@@ -1176,19 +1354,37 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
1176
1354
|
}
|
|
1177
1355
|
log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
|
|
1178
1356
|
if (issuer) {
|
|
1179
|
-
|
|
1357
|
+
const vHost = verifyHost();
|
|
1358
|
+
let plain = false, via = "";
|
|
1359
|
+
try {
|
|
1360
|
+
const mem = rememberedIssuer(vHost);
|
|
1361
|
+
const claimed = String(r.body.credential?.issuer ?? "");
|
|
1362
|
+
plain = (mem ? mem.did : KNOWN_ISSUERS.find((k) => k.did === claimed)?.did) === issuer;
|
|
1363
|
+
via = mem ? "remembered on this computer" : "built into the verifier";
|
|
1364
|
+
}
|
|
1365
|
+
catch {
|
|
1366
|
+
plain = false;
|
|
1367
|
+
}
|
|
1368
|
+
if (plain) {
|
|
1369
|
+
log(` npx @decidio/sdk verify ${name}`);
|
|
1370
|
+
log(` No --issuer needed here: it pins to this Decidio's issuer (${via}), which the server also named:`);
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
log(` Record your workspace's issuer DID — copy this line, and pin it forever:`);
|
|
1374
|
+
}
|
|
1180
1375
|
log(``);
|
|
1181
1376
|
log(`${issuer}`);
|
|
1182
1377
|
log(``);
|
|
1183
1378
|
log(` Cross-check it out of band — the same DID is published by this Decidio host at ${API}/api/did (public, no sign-in).`);
|
|
1184
|
-
log(` Then verify, pinned to it (the same DID, inline — copy from the line above, not this one
|
|
1185
|
-
log(` which your terminal may wrap):`);
|
|
1379
|
+
log(plain ? ` Anywhere else, pin it explicitly (copy the DID from the line above — this one may wrap):` : ` Then verify, pinned to it (the same DID, inline — copy from the line above, not this one, which your terminal may wrap):`);
|
|
1186
1380
|
log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
|
|
1187
|
-
|
|
1188
|
-
|
|
1381
|
+
if (!plain && issuerStoreKey(vHost) !== issuerStoreKey(API))
|
|
1382
|
+
log(` (verify reads no .env, so a flagless run pins to the DID for ${vHost}, not ${API} - export DECIDIO_API_URL=${API} in your shell to verify this host's receipts without --issuer.)`);
|
|
1383
|
+
else if (!plain)
|
|
1384
|
+
log(` Or sign in once (npx @decidio/sdk login): this computer then remembers the DID, and verify needs no --issuer.`);
|
|
1189
1385
|
}
|
|
1190
1386
|
else {
|
|
1191
|
-
log(` To also prove WHO issued it, pin your
|
|
1387
|
+
log(` To also prove WHO issued it, pin your Decidio's issuer DID — published at ${API}/api/did (public, no sign-in), never read off the receipt:`);
|
|
1192
1388
|
log(` npx @decidio/sdk verify --issuer <did:key:...> ${name}`);
|
|
1193
1389
|
}
|
|
1194
1390
|
return 0;
|
|
@@ -1237,7 +1433,7 @@ function cmdVerify(argsIn) {
|
|
|
1237
1433
|
}
|
|
1238
1434
|
}
|
|
1239
1435
|
if (!files.length) {
|
|
1240
|
-
err("usage: npx @decidio/sdk verify --issuer <did:key> <receipt.json> [more.json ...] (or --allow-unpinned)");
|
|
1436
|
+
err("usage: npx @decidio/sdk verify [--issuer <did:key>] <receipt.json> [more.json ...] (or --allow-unpinned)");
|
|
1241
1437
|
err(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
|
|
1242
1438
|
return 2;
|
|
1243
1439
|
}
|
|
@@ -1245,10 +1441,6 @@ function cmdVerify(argsIn) {
|
|
|
1245
1441
|
err("✘ --allow-unpinned and --issuer together — an unpinned check and a pinned one are different questions; ask one.");
|
|
1246
1442
|
return 2;
|
|
1247
1443
|
}
|
|
1248
|
-
if (expectedIssuer === undefined && !allowUnpinned) {
|
|
1249
|
-
err(pinRequiredMessage("npx @decidio/sdk verify"));
|
|
1250
|
-
return 2;
|
|
1251
|
-
}
|
|
1252
1444
|
const texts = new Map();
|
|
1253
1445
|
for (const f of files) {
|
|
1254
1446
|
try {
|
|
@@ -1259,7 +1451,34 @@ function cmdVerify(argsIn) {
|
|
|
1259
1451
|
return 1;
|
|
1260
1452
|
}
|
|
1261
1453
|
}
|
|
1262
|
-
const
|
|
1454
|
+
const read = (f) => texts.get(f);
|
|
1455
|
+
let anchor = expectedIssuer !== undefined ? "--issuer" : undefined;
|
|
1456
|
+
if (expectedIssuer === undefined && !allowUnpinned) {
|
|
1457
|
+
let mem;
|
|
1458
|
+
try {
|
|
1459
|
+
mem = rememberedIssuer(verifyHost());
|
|
1460
|
+
}
|
|
1461
|
+
catch (e) {
|
|
1462
|
+
err(`✘ the issuer DIDs this computer remembers (${issuersFile()}) could not be read (${e?.message ?? e}) — refusing to fall back to another trust anchor.`);
|
|
1463
|
+
err(` Pass --issuer <did:key> for this run, or repair the file (or move it aside and sign in again). Nothing was verified.`);
|
|
1464
|
+
return 2;
|
|
1465
|
+
}
|
|
1466
|
+
if (mem) {
|
|
1467
|
+
expectedIssuer = mem.did;
|
|
1468
|
+
anchor = `remembered for ${issuerStoreKey(verifyHost())} since ${mem.firstSeen.slice(0, 10)}`;
|
|
1469
|
+
}
|
|
1470
|
+
else {
|
|
1471
|
+
const pick = builtInAnchor(files, read);
|
|
1472
|
+
if ("refusal" in pick) {
|
|
1473
|
+
err(pinRequiredMessage("npx @decidio/sdk verify", pick.refusal));
|
|
1474
|
+
err(` Nothing is remembered for ${issuerStoreKey(verifyHost())} on this computer: signing in once (npx @decidio/sdk login) remembers its DID, and then no --issuer is needed.`);
|
|
1475
|
+
return 2;
|
|
1476
|
+
}
|
|
1477
|
+
expectedIssuer = pick.anchor.did;
|
|
1478
|
+
anchor = `built in: ${pick.anchor.label}, ${pick.anchor.host}`;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
const { ok, lines } = runVerify(files, expectedIssuer, read, anchor);
|
|
1263
1482
|
for (const l of lines)
|
|
1264
1483
|
log(l);
|
|
1265
1484
|
return ok ? 0 : 1;
|
|
@@ -1705,12 +1924,13 @@ async function cmdQuickstartInner(opts = {}) {
|
|
|
1705
1924
|
const wrote = writeReceiptFile(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
|
|
1706
1925
|
if (wrote === "conflict")
|
|
1707
1926
|
return failTour(1);
|
|
1708
|
-
const issuer = rec.body
|
|
1927
|
+
const issuer = hostAnchor(rec.body) ?? undefined;
|
|
1709
1928
|
const v = verifyReceipt(rec.body.credential, issuer);
|
|
1710
|
-
const
|
|
1711
|
-
|
|
1929
|
+
const serverInvalid = rec.body?.valid === false;
|
|
1930
|
+
const kindLine = `${v.valid && !serverInvalid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
|
|
1931
|
+
if (!v.valid || !issuer || serverInvalid) {
|
|
1712
1932
|
log(` ${kindLine}`);
|
|
1713
|
-
err(` ✘ the denial's receipt did not verify PINNED (${!issuer ? "no issuer
|
|
1933
|
+
err(` ✘ the denial's receipt did not verify PINNED (${!issuer ? "no trust anchor: the server named no issuer and none is remembered for this host" : !v.valid ? v.reason ?? "no reason given" : `the server's own check says it does NOT verify: ${rec.body?.reason ?? "no reason given"}`}) — the tour did not complete.`);
|
|
1714
1934
|
return failTour(1);
|
|
1715
1935
|
}
|
|
1716
1936
|
block("④", "The record", [
|
|
@@ -1780,19 +2000,24 @@ async function cmdQuickstartInner(opts = {}) {
|
|
|
1780
2000
|
const fname = receiptFileName(decisionId);
|
|
1781
2001
|
if (writeReceiptFile(fname, JSON.stringify(rec.body.credential, null, 2) + "\n") === "conflict")
|
|
1782
2002
|
return failTour(1);
|
|
1783
|
-
const issuer = rec.body
|
|
2003
|
+
const issuer = hostAnchor(rec.body) ?? undefined;
|
|
1784
2004
|
const v = verifyReceipt(rec.body.credential, issuer);
|
|
1785
|
-
const
|
|
1786
|
-
|
|
2005
|
+
const serverInvalid = rec.body?.valid === false;
|
|
2006
|
+
const kindLine = `${v.valid && !serverInvalid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
|
|
2007
|
+
if (!v.valid || !issuer || serverInvalid)
|
|
1787
2008
|
log(` ${kindLine}`);
|
|
1788
2009
|
if (!v.valid) {
|
|
1789
2010
|
err(` ✘ the receipt did not verify (${v.reason ?? "no reason given"}) — the tour did not complete.`);
|
|
1790
2011
|
return failTour(1);
|
|
1791
2012
|
}
|
|
2013
|
+
if (serverInvalid) {
|
|
2014
|
+
err(` ✘ the server's own check says this record does NOT verify (${rec.body?.reason ?? "no reason given"}) — the tour did not complete.`);
|
|
2015
|
+
return failTour(1);
|
|
2016
|
+
}
|
|
1792
2017
|
if (!issuer) {
|
|
1793
|
-
err(` ⚠ the server
|
|
1794
|
-
err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
|
|
1795
|
-
err(`
|
|
2018
|
+
err(` ⚠ the server named no issuer (expectedIssuer) and no DID is remembered for this host, so this check ran UNPINNED —`);
|
|
2019
|
+
err(` it proves the receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
|
|
2020
|
+
err(` Take your Decidio's issuer DID from ${API}/api/did (public, no sign-in) — never from the receipt — then re-check:`);
|
|
1796
2021
|
err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
|
|
1797
2022
|
err(` ✘ the tour does not count an UNPINNED check as success.`);
|
|
1798
2023
|
return failTour(1);
|
|
@@ -2132,174 +2357,1763 @@ function cmdDev(target, port) {
|
|
|
2132
2357
|
}).listen(port, "127.0.0.1");
|
|
2133
2358
|
return 0;
|
|
2134
2359
|
}
|
|
2135
|
-
const
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
explicit — this CLI never acts, signs you in, or writes files unless you ask it to.
|
|
2152
|
-
|
|
2153
|
-
THE TOUR
|
|
2154
|
-
npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
|
|
2155
|
-
(interactive: y approves, n rejects - Enter is not an answer. Headless it stops
|
|
2156
|
-
at the routed decision; --auto-approve-demo for CI, labeled
|
|
2157
|
-
automated; ends with a keep-or-cleanup choice; --explain prints
|
|
2158
|
-
the long narration of each step; --phone web makes the QR open
|
|
2159
|
-
the phone web rendering even when the Decidio app is installed)
|
|
2160
|
-
npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials (the inert
|
|
2161
|
-
registration row and the permanent sealed record stay)
|
|
2162
|
-
(and end the stored sign-in)
|
|
2163
|
-
npx @decidio/sdk quickstart --auto-approve-demo headless tour; cleans up after itself
|
|
2164
|
-
npx @decidio/sdk quickstart --demo the same tour against a LOCAL stand-in of the API — no account needed; the
|
|
2165
|
-
web/phone door is a hosted demo page; the receipt is the one thing kept
|
|
2166
|
-
(--keep-agent is refused here: nothing of the demo's can be kept)
|
|
2167
|
-
|
|
2168
|
-
SIGN IN (once)
|
|
2169
|
-
npx @decidio/sdk login [email] sign in ONCE: the CLI emails you an 8-character code, you type it; the sign-in
|
|
2170
|
-
is kept on this computer 30 days, or until this terminal closes, logout, or cleanup
|
|
2171
|
-
npx @decidio/sdk logout forget the stored sign-in on this computer (the token itself stays valid
|
|
2172
|
-
until it expires — there is no server-side revocation yet)
|
|
2173
|
-
|
|
2174
|
-
YOUR AGENT AND ITS TOKENS
|
|
2175
|
-
npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
|
|
2176
|
-
(agentId defaults to this folder's name; any name works - it is how the
|
|
2177
|
-
workspace shows the request and the key .env is written under)
|
|
2178
|
-
--unique append a random suffix (collision-proof samples)
|
|
2179
|
-
--force replace a different agent already in this .env
|
|
2180
|
-
(its .env is backed up first)
|
|
2181
|
-
--expires-days N agent-token lifetime (default 90)
|
|
2182
|
-
npx @decidio/sdk doctor check config, connectivity, and what your token can do
|
|
2183
|
-
npx @decidio/sdk tokens list [--mine] agent tokens with status (active/expired/REVOKED) by
|
|
2184
|
-
jti — token values are never re-shown; the list is
|
|
2185
|
-
workspace-wide, --mine narrows to this directory's agent
|
|
2186
|
-
npx @decidio/sdk tokens revoke <jti> kill a token now instead of waiting out its lifetime
|
|
2187
|
-
|
|
2188
|
-
APPROVALS AND RECEIPTS
|
|
2189
|
-
npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
|
|
2190
|
-
npx @decidio/sdk approvals approve <id> [reason]
|
|
2191
|
-
npx @decidio/sdk approvals reject <id> [reason]
|
|
2192
|
-
the reason is FREE TEXT, sent exactly as typed. It seals
|
|
2193
|
-
immutably; personal or sensitive values are tokenized at
|
|
2194
|
-
rest, so the STORED text can differ (see: receipt <id>).
|
|
2195
|
-
If it starts with a dash, put it after -- or use --reason:
|
|
2196
|
-
... approve <id> -- "-50% cut, per the board"
|
|
2197
|
-
... approve <id> --reason "-50% cut, per the board"
|
|
2198
|
-
npx @decidio/sdk receipt <id> [--json] download a sealed decision's Authority Receipt to a .json FILE; --json prints
|
|
2199
|
-
a summary to stdout (file name, evidence, issuer, the identities you may see) -
|
|
2200
|
-
the credential itself is the file
|
|
2201
|
-
npx @decidio/sdk verify --issuer <did> <receipt.json> [more...] verify receipts OFFLINE, pinned to your
|
|
2202
|
-
workspace's issuer (--allow-unpinned: structural check only)
|
|
2203
|
-
|
|
2204
|
-
DEVELOPMENT
|
|
2205
|
-
npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
|
|
2206
|
-
npx @decidio/sdk --version print the CLI version
|
|
2207
|
-
|
|
2208
|
-
NOTES
|
|
2209
|
-
Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
|
|
2210
|
-
DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
|
|
2211
|
-
fully offline — it touches no network and reads no .env.
|
|
2212
|
-
Update check: network commands (and the bare command on a terminal) ask the public npm registry once a day
|
|
2213
|
-
whether a newer version exists. If so,
|
|
2214
|
-
an interactive run asks "Upgrade now? [y/n]" first (y upgrades the way you installed - global, or in the
|
|
2215
|
-
project for a local install; under npx it prints the @latest form - and re-runs your command; n exits); a headless
|
|
2216
|
-
run prints the notice and continues (DECIDIO_REQUIRE_LATEST=1 makes it exit). DECIDIO_NO_UPDATE_CHECK=1 skips
|
|
2217
|
-
the check. Nothing is sent to Decidio; verify never checks.
|
|
2218
|
-
Help: --help (or -h) after any command prints this page - one page for every command.
|
|
2219
|
-
Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
|
|
2220
|
-
3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
|
|
2221
|
-
receipt written — pass --auto-approve-demo for a run that completes unattended).
|
|
2222
|
-
Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
|
|
2223
|
-
pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
|
|
2224
|
-
Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
|
|
2225
|
-
(quickstart, durable resume, workers, adapters).`;
|
|
2226
|
-
const GATED_CMDS = new Set(["quickstart", "init", "login", "doctor", "approvals", "receipt", "tokens"]);
|
|
2227
|
-
function semverNewer(a, b) {
|
|
2228
|
-
const pa = a.split(".").map((x) => parseInt(x, 10)), pb = b.split(".").map((x) => parseInt(x, 10));
|
|
2229
|
-
if (pa.length < 3 || pb.length < 3 || pa.some((x) => Number.isNaN(x)) || pb.some((x) => Number.isNaN(x)))
|
|
2230
|
-
return false;
|
|
2231
|
-
for (let i = 0; i < 3; i++)
|
|
2232
|
-
if (pa[i] !== pb[i])
|
|
2233
|
-
return pa[i] > pb[i];
|
|
2234
|
-
return false;
|
|
2360
|
+
const HOOK_HOLD_CAP_MS = 120_000;
|
|
2361
|
+
function hookSegments(command) {
|
|
2362
|
+
const WRAPPERS = new Set(["sudo", "doas", "env", "nohup", "time", "command", "exec", "builtin", "xargs", "nice", "ionice", "timeout", "stdbuf", "caffeinate"]);
|
|
2363
|
+
return command.split(/\s*(?:\|\||&&|\||;|\r?\n)\s*/).map((raw) => raw.trim()).filter(Boolean).map((raw) => {
|
|
2364
|
+
const tokens = raw.split(/\s+/).filter(Boolean);
|
|
2365
|
+
while (tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]) || WRAPPERS.has(tokens[0].toLowerCase()))) {
|
|
2366
|
+
const w = tokens.shift().toLowerCase();
|
|
2367
|
+
if (w === "timeout" && tokens.length && /^\d/.test(tokens[0]))
|
|
2368
|
+
tokens.shift();
|
|
2369
|
+
if (WRAPPERS.has(w))
|
|
2370
|
+
while (tokens.length && tokens[0].startsWith("-"))
|
|
2371
|
+
tokens.shift();
|
|
2372
|
+
}
|
|
2373
|
+
const program = basename((tokens[0] ?? "").replace(/^["']|["']$/g, "")).toLowerCase();
|
|
2374
|
+
return { program, args: tokens.slice(1), raw };
|
|
2375
|
+
});
|
|
2235
2376
|
}
|
|
2236
|
-
|
|
2237
|
-
|
|
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();
|
|
2379
|
+
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"];
|
|
2380
|
+
const HOOK_RULES = [
|
|
2381
|
+
{ category: "delete-recursive", summary: "rm -r/-rf, find -delete / -exec rm, rmdir|rd|del /s, Remove-Item -Recurse, shred",
|
|
2382
|
+
matches: (segs) => segs.some((s) => (s.program === "rm" && hookFlag(s.args, "rR", ["--recursive"]))
|
|
2383
|
+
|| (s.program === "find" && (s.args.includes("-delete") || s.args.some((a, i) => i > 0 && s.args[i - 1] === "-exec" && basename(a) === "rm")))
|
|
2384
|
+
|| ((s.program === "rmdir" || s.program === "rd" || s.program === "del") && s.args.some((a) => /^\/s$/i.test(a)))
|
|
2385
|
+
|| (s.program === "remove-item" && s.args.some((a) => /^-recurse$/i.test(a)))
|
|
2386
|
+
|| s.program === "shred") },
|
|
2387
|
+
{ category: "git-rewrite", summary: "git push --force|-f|+ref|:ref|--delete|--mirror|--prune, reset --hard, branch -D, clean -f|-x, stash drop|clear, filter-branch|filter-repo, update-ref -d, reflog expire, gc --prune",
|
|
2388
|
+
matches: (segs) => segs.some((s) => s.program === "git" && ((hookSub(s) === "push" && (hookFlag(s.args, "fd", ["--force", "--force-with-lease", "--force-if-includes", "--delete", "--mirror", "--prune"]) || s.args.some((a) => /^\+\S/.test(a) || /^:\S/.test(a) || /^\S+:\s*$/.test(a))))
|
|
2389
|
+
|| (hookSub(s) === "reset" && s.args.includes("--hard"))
|
|
2390
|
+
|| (hookSub(s) === "branch" && (s.args.some((a) => /^-[a-zA-Z]*D/.test(a)) || (s.args.includes("--delete") && s.args.includes("--force"))))
|
|
2391
|
+
|| (hookSub(s) === "clean" && hookFlag(s.args, "fxX", ["--force"]))
|
|
2392
|
+
|| (hookSub(s) === "stash" && ["drop", "clear"].includes(hookSub(s, 1)))
|
|
2393
|
+
|| ["filter-branch", "filter-repo"].includes(hookSub(s))
|
|
2394
|
+
|| (hookSub(s) === "update-ref" && s.args.includes("-d"))
|
|
2395
|
+
|| (hookSub(s) === "reflog" && hookSub(s, 1) === "expire")
|
|
2396
|
+
|| (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",
|
|
2398
|
+
matches: (segs, cmd) => {
|
|
2399
|
+
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)))
|
|
2401
|
+
|| segs.some((s) => (/\bprisma\b/.test(s.raw) && (/\bmigrate\s+reset\b/.test(s.raw) || /--force-reset\b/.test(s.raw)))
|
|
2402
|
+
|| (["rails", "rake"].includes(s.program) && /\bdb:(drop|reset|schema:load)\b/.test(s.raw)));
|
|
2403
|
+
} },
|
|
2404
|
+
{ category: "infra-apply", summary: "terraform|tofu apply|destroy, pulumi up|destroy, cdk deploy|destroy, sam|serverless deploy|remove, kubectl delete|drain|replace|apply --prune, helm uninstall|rollback, aws|gcloud|az|doctl|fly|heroku|vercel … delete|terminate|destroy|purge|remove|rm|rb",
|
|
2405
|
+
matches: (segs) => segs.some((s) => (["terraform", "tofu"].includes(s.program) && ["apply", "destroy"].includes(hookSub(s)))
|
|
2406
|
+
|| (s.program === "pulumi" && ["up", "destroy"].includes(hookSub(s)))
|
|
2407
|
+
|| (s.program === "cdk" && ["deploy", "destroy"].includes(hookSub(s)))
|
|
2408
|
+
|| (s.program === "sam" && ["deploy", "delete"].includes(hookSub(s)))
|
|
2409
|
+
|| (["serverless", "sls"].includes(s.program) && ["deploy", "remove"].includes(hookSub(s)))
|
|
2410
|
+
|| (s.program === "kubectl" && (["delete", "drain", "replace"].includes(hookSub(s)) || (hookSub(s) === "apply" && s.args.includes("--prune"))))
|
|
2411
|
+
|| (s.program === "helm" && ["uninstall", "delete", "rollback"].includes(hookSub(s)))
|
|
2412
|
+
|| (["aws", "gcloud", "az", "doctl", "fly", "flyctl", "heroku", "vercel", "oci", "ibmcloud"].includes(s.program)
|
|
2413
|
+
&& s.args.some((a) => /^(delete|terminate|destroy|purge|remove|deregister|rm|rb|apps:destroy|pg:reset)(-[a-z-]+)?$/i.test(a)))) },
|
|
2414
|
+
{ category: "containers", summary: "docker|podman rm|rmi|kill, system|volume|image|container|network prune|rm, compose down -v|--volumes|--rmi",
|
|
2415
|
+
matches: (segs) => segs.some((s) => {
|
|
2416
|
+
const compose = (s.program === "docker-compose" && hookSub(s) === "down") || (["docker", "podman"].includes(s.program) && hookSub(s) === "compose" && hookSub(s, 1) === "down");
|
|
2417
|
+
return (["docker", "podman", "nerdctl"].includes(s.program) && (["rm", "rmi", "kill"].includes(hookSub(s))
|
|
2418
|
+
|| (["system", "volume", "image", "container", "network", "builder"].includes(hookSub(s)) && ["prune", "rm"].includes(hookSub(s, 1)))))
|
|
2419
|
+
|| (compose && (hookFlag(s.args, "v", ["--volumes"]) || s.args.some((a) => a.startsWith("--rmi"))));
|
|
2420
|
+
}) },
|
|
2421
|
+
{ category: "publish", summary: "npm|pnpm|yarn|bun publish|unpublish|deprecate|dist-tag|owner, cargo publish|yank, gem push|yank, twine upload, poetry publish, dotnet|nuget push|delete, mvn|gradle deploy|publish, docker push, gh release create|delete|upload, gh repo delete|archive, gh api -X DELETE|PUT|PATCH",
|
|
2422
|
+
matches: (segs) => segs.some((s) => (["npm", "pnpm", "yarn", "bun"].includes(s.program) && ["publish", "unpublish", "deprecate", "dist-tag", "owner"].includes(hookSub(s)))
|
|
2423
|
+
|| (s.program === "cargo" && ["publish", "yank"].includes(hookSub(s)))
|
|
2424
|
+
|| (s.program === "gem" && ["push", "yank"].includes(hookSub(s)))
|
|
2425
|
+
|| (s.program === "twine" && hookSub(s) === "upload") || (/^python[0-9.]*$/.test(s.program) && s.args[0] === "-m" && s.args[1] === "twine")
|
|
2426
|
+
|| (s.program === "poetry" && hookSub(s) === "publish")
|
|
2427
|
+
|| (s.program === "dotnet" && hookSub(s) === "nuget" && ["push", "delete"].includes(hookSub(s, 1))) || (s.program === "nuget" && ["push", "delete"].includes(hookSub(s)))
|
|
2428
|
+
|| (["mvn", "gradle", "gradlew"].includes(s.program) && s.args.some((a) => /^(deploy|publish|publishToMavenCentral|uploadArchives)$/.test(a)))
|
|
2429
|
+
|| (["docker", "podman"].includes(s.program) && hookSub(s) === "push")
|
|
2430
|
+
|| (s.program === "gh" && ((hookSub(s) === "release" && ["create", "delete", "upload"].includes(hookSub(s, 1)))
|
|
2431
|
+
|| (hookSub(s) === "repo" && ["delete", "archive"].includes(hookSub(s, 1)))
|
|
2432
|
+
|| (hookSub(s) === "api" && s.args.some((a, i) => /^(-X|--method)$/.test(a) && /^(DELETE|PUT|PATCH)$/i.test(s.args[i + 1] ?? "")))))) },
|
|
2433
|
+
{ category: "pipe-to-shell", summary: "curl|wget … | sh|bash|zsh|python|node, bash <(curl …), iex (irm …)",
|
|
2434
|
+
matches: (segs, cmd) => segs.some((s, i) => i > 0
|
|
2435
|
+
&& ["sh", "bash", "zsh", "fish", "dash", "ksh", "node", "perl", "ruby", "pwsh", "powershell", "python", "python3"].includes(s.program)
|
|
2436
|
+
&& ["curl", "wget", "fetch", "http", "https", "invoke-webrequest", "iwr", "irm", "invoke-restmethod"].includes(segs[i - 1].program))
|
|
2437
|
+
|| /\b(sh|bash|zsh)\s+<\(\s*(curl|wget)\b/.test(cmd) || /\biex\b.*\b(irm|iwr|Invoke-RestMethod|Invoke-WebRequest|DownloadString)\b/i.test(cmd) },
|
|
2438
|
+
{ category: "system", summary: "mkfs, dd of=/dev/…, > /dev/sd…, shutdown|reboot|halt|poweroff, systemctl disable|mask, crontab -r, chmod|chown -R on /, iptables -F, diskutil erase…, format X:, Format-Volume, Clear-Disk, a fork bomb",
|
|
2439
|
+
matches: (segs, cmd) => segs.some((s) => /^mkfs(\.|$)/.test(s.program) || (s.program === "dd" && s.args.some((a) => /^of=\/dev\//.test(a)))
|
|
2440
|
+
|| ["shutdown", "reboot", "halt", "poweroff"].includes(s.program) || (s.program === "systemctl" && ["disable", "mask"].includes(hookSub(s)))
|
|
2441
|
+
|| (s.program === "crontab" && s.args.includes("-r"))
|
|
2442
|
+
|| (["chmod", "chown"].includes(s.program) && hookFlag(s.args, "R", ["--recursive"]) && s.args.some((a) => /^\/\*?$/.test(a)))
|
|
2443
|
+
|| (s.program === "iptables" && (s.args.includes("-F") || s.args.includes("--flush")))
|
|
2444
|
+
|| (s.program === "diskutil" && /^erase/i.test(hookSub(s))) || (s.program === "format" && s.args.some((a) => /^[a-z]:$/i.test(a)))
|
|
2445
|
+
|| ["format-volume", "clear-disk", "initialize-disk"].includes(s.program))
|
|
2446
|
+
|| />\s*\/dev\/(sd|nvme|hd|disk|mmcblk)/.test(cmd) || /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(cmd) },
|
|
2447
|
+
];
|
|
2448
|
+
function hookExtraRules() {
|
|
2449
|
+
const file = process.env.DECIDIO_HOOK_EXTRA_RULES;
|
|
2450
|
+
if (!file)
|
|
2451
|
+
return [];
|
|
2452
|
+
let parsed;
|
|
2238
2453
|
try {
|
|
2239
|
-
|
|
2240
|
-
if (c && typeof c.latest === "string" && Date.now() - Number(c.checkedAt) < 24 * 3600 * 1000)
|
|
2241
|
-
return c.latest;
|
|
2454
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
2242
2455
|
}
|
|
2243
|
-
catch {
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2456
|
+
catch (e) {
|
|
2457
|
+
throw new Error(`DECIDIO_HOOK_EXTRA_RULES (${file}) could not be read as JSON (${e?.message ?? e})`);
|
|
2458
|
+
}
|
|
2459
|
+
if (!Array.isArray(parsed))
|
|
2460
|
+
throw new Error(`DECIDIO_HOOK_EXTRA_RULES (${file}) must be a JSON array of { category, pattern, flags?, summary? }`);
|
|
2461
|
+
return parsed.map((r, i) => {
|
|
2462
|
+
if (!r || typeof r.category !== "string" || !/^[a-z0-9-]{1,40}$/.test(r.category) || typeof r.pattern !== "string")
|
|
2463
|
+
throw new Error(`DECIDIO_HOOK_EXTRA_RULES (${file}) entry ${i} needs a category ([a-z0-9-]) and a pattern`);
|
|
2464
|
+
let re;
|
|
2465
|
+
try {
|
|
2466
|
+
re = new RegExp(r.pattern, typeof r.flags === "string" ? r.flags.replace(/[^gimsuy]/g, "") : "");
|
|
2467
|
+
}
|
|
2468
|
+
catch (e) {
|
|
2469
|
+
throw new Error(`DECIDIO_HOOK_EXTRA_RULES (${file}) entry ${i}: ${e?.message ?? e}`);
|
|
2470
|
+
}
|
|
2471
|
+
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
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
function hookMatch(command) {
|
|
2475
|
+
const segs = hookSegments(command);
|
|
2476
|
+
for (const r of [...HOOK_RULES, ...hookExtraRules()])
|
|
2477
|
+
if (r.matches(segs, command))
|
|
2478
|
+
return r;
|
|
2479
|
+
return null;
|
|
2480
|
+
}
|
|
2481
|
+
function hookRedact(command) {
|
|
2482
|
+
return command
|
|
2483
|
+
.replace(/(\b(?:bearer|basic)\s+)[A-Za-z0-9._~+/=-]{8,}/gi, "$1[REDACTED]")
|
|
2484
|
+
.replace(/(\b[A-Za-z0-9_]*(?:token|password|passwd|pwd|secret|api[_-]?key|access[_-]?key|private[_-]?key|auth|credentials?)\s*[=:]\s*)(["']?)[^\s"';&|]+\2/gi, "$1$2[REDACTED]$2")
|
|
2485
|
+
.replace(/(:\/\/[^/\s:@]+:)[^@\s/]+(@)/g, "$1[REDACTED]$2")
|
|
2486
|
+
.replace(/((?:^|\s)(?:-u|--user|--password-stdin|-p)\s+["']?[^\s:"']+:)[^\s"']+/g, "$1[REDACTED]")
|
|
2487
|
+
.replace(/((?:-H|--header)\s+["']?(?:authorization|x-api-key|cookie)\s*:\s*)[^"']+/gi, "$1[REDACTED]")
|
|
2488
|
+
.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
|
+
}
|
|
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 = () => {
|
|
2495
|
+
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;
|
|
2500
|
+
}
|
|
2501
|
+
catch {
|
|
2251
2502
|
return null;
|
|
2252
|
-
const body = await r.json();
|
|
2253
|
-
const latest = typeof body?.version === "string" && /^\d+\.\d+\.\d+$/.test(body.version) ? body.version : null;
|
|
2254
|
-
if (latest) {
|
|
2255
|
-
try {
|
|
2256
|
-
mkdirSync(sessionDir(), { recursive: true });
|
|
2257
|
-
writeFileSync(cache, JSON.stringify({ checkedAt: Date.now(), latest }));
|
|
2258
|
-
}
|
|
2259
|
-
catch { }
|
|
2260
2503
|
}
|
|
2261
|
-
|
|
2504
|
+
};
|
|
2505
|
+
const firstOf = (programs) => segs.findIndex((s) => programs.includes(s.program));
|
|
2506
|
+
const movedTree = (i) => segs.slice(0, i).some((s) => s.program === "cd" || s.program === "pushd");
|
|
2507
|
+
if (category === "git-rewrite" || category === "infra-apply") {
|
|
2508
|
+
const i = category === "git-rewrite" ? firstOf(["git"]) : firstOf(["terraform", "tofu", "pulumi", "cdk", "sam", "serverless", "sls", "kubectl", "helm", "aws", "gcloud", "az", "doctl", "fly", "flyctl", "heroku", "vercel", "oci", "ibmcloud"]);
|
|
2509
|
+
if (i < 0)
|
|
2510
|
+
return textOnly("no subject");
|
|
2511
|
+
if (movedTree(i))
|
|
2512
|
+
return textOnly("a cd on the same line moves the call to another directory");
|
|
2513
|
+
const g = git();
|
|
2514
|
+
if (!g)
|
|
2515
|
+
return textOnly("not a git repository here");
|
|
2516
|
+
const seg = segs[i];
|
|
2517
|
+
const args = nonFlags(seg);
|
|
2518
|
+
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
|
+
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
|
+
}
|
|
2521
|
+
if (category === "delete-recursive") {
|
|
2522
|
+
const i = firstOf(["rm", "find", "rmdir", "rd", "del", "remove-item", "shred"]);
|
|
2523
|
+
if (i < 0)
|
|
2524
|
+
return textOnly("no subject");
|
|
2525
|
+
if (movedTree(i))
|
|
2526
|
+
return textOnly("a cd on the same line moves the call to another directory");
|
|
2527
|
+
const targets = nonFlags(segs[i]).filter((a) => !/^[{}();\\]+$/.test(a) && !(segs[i].program === "find" && /^(-|\{\})/.test(a))).map((a) => resolvePath(cwdAbs, a.replace(/^["']|["']$/g, "")));
|
|
2528
|
+
if (!targets.length)
|
|
2529
|
+
return textOnly("no path named");
|
|
2530
|
+
const sorted = [...new Set(targets)].sort();
|
|
2531
|
+
return { fingerprint: `paths:${sorted.join("\0")}`, display: hookRedact(sorted.map(relOrAbs).join(", ")).slice(0, 160) };
|
|
2532
|
+
}
|
|
2533
|
+
if (category === "sql-destructive") {
|
|
2534
|
+
const stIdx = segs.findIndex((s) => /\b(DROP|TRUNCATE|DELETE)\b/i.test(s.raw));
|
|
2535
|
+
const dbIdx = firstOf(HOOK_DB_CLIENTS);
|
|
2536
|
+
const client = dbIdx >= 0 ? segs[dbIdx] : stIdx >= 0 ? segs[stIdx] : null;
|
|
2537
|
+
if (!client)
|
|
2538
|
+
return textOnly("no statement");
|
|
2539
|
+
const stmtSeg = stIdx >= 0 ? segs[stIdx] : client;
|
|
2540
|
+
const stmt = (stmtSeg.raw.match(/\b(DROP\s+\w+[^;'"]*|TRUNCATE[^;'"]*|DELETE\s+FROM[^;'"]*)/i)?.[1] ?? stmtSeg.raw).replace(/\s+/g, " ").trim();
|
|
2541
|
+
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(" ");
|
|
2543
|
+
return { fingerprint: `sql:${client.program}:${target}:${stmt.toUpperCase()}`, display: hookRedact(`${client.program}${target ? ` ${target}` : ""}: ${stmt}`).slice(0, 160) };
|
|
2544
|
+
}
|
|
2545
|
+
return textOnly("text binds this category");
|
|
2546
|
+
}
|
|
2547
|
+
async function readHookStdin() {
|
|
2548
|
+
if (process.stdin.isTTY)
|
|
2549
|
+
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)" };
|
|
2550
|
+
let raw = "";
|
|
2551
|
+
try {
|
|
2552
|
+
process.stdin.setEncoding("utf8");
|
|
2553
|
+
for await (const chunk of process.stdin) {
|
|
2554
|
+
raw += chunk;
|
|
2555
|
+
if (raw.length > 4_000_000)
|
|
2556
|
+
break;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
catch (e) {
|
|
2560
|
+
return { ok: false, why: `stdin could not be read (${e?.message ?? e})` };
|
|
2561
|
+
}
|
|
2562
|
+
if (!raw.trim())
|
|
2563
|
+
return { ok: false, why: "empty hook input on stdin" };
|
|
2564
|
+
try {
|
|
2565
|
+
const v = JSON.parse(raw);
|
|
2566
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
2567
|
+
return { ok: false, why: "the hook input is not a JSON object" };
|
|
2568
|
+
return { ok: true, input: v };
|
|
2262
2569
|
}
|
|
2263
2570
|
catch {
|
|
2264
|
-
return
|
|
2571
|
+
return { ok: false, why: "the hook input is not JSON" };
|
|
2265
2572
|
}
|
|
2266
2573
|
}
|
|
2267
|
-
|
|
2268
|
-
const
|
|
2269
|
-
if (!
|
|
2270
|
-
return
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2574
|
+
function hookConfig() {
|
|
2575
|
+
const token = process.env.DECIDIO_API_TOKEN;
|
|
2576
|
+
if (!token)
|
|
2577
|
+
return { missing: "DECIDIO_API_TOKEN is not set - no agent token in this folder's .env or the environment" };
|
|
2578
|
+
return {
|
|
2579
|
+
apiUrl: API, agentId: process.env.DECIDIO_AGENT_ID || defaultAgentId(), apiToken: token, sourceSystem: "claude-code",
|
|
2580
|
+
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,
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
const hookStateDir = () => (process.env.CLAUDE_PLUGIN_DATA ? joinPath(process.env.CLAUDE_PLUGIN_DATA, "hooks") : joinPath(sessionDir(), "hooks"));
|
|
2585
|
+
const hookStatePath = (toolUseId) => joinPath(hookStateDir(), toolUseId.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120) + ".json");
|
|
2586
|
+
function writeHookState(toolUseId, s) {
|
|
2587
|
+
if (typeof toolUseId !== "string" || !toolUseId)
|
|
2588
|
+
return;
|
|
2589
|
+
try {
|
|
2590
|
+
mkdirSync(hookStateDir(), { recursive: true });
|
|
2591
|
+
writeFileSync(hookStatePath(toolUseId), JSON.stringify(s) + "\n");
|
|
2592
|
+
}
|
|
2593
|
+
catch (e) {
|
|
2594
|
+
err(`decidio hook: could not keep the approval ${s.decisionId} for posttool (${e?.message ?? e}) - the execution will not be reported; the approval lapses on its own`);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
const JUSTIFY_MAX = 500;
|
|
2598
|
+
const justifyPath = (decisionId) => joinPath(hookStateDir(), "justify-" + decisionId.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120) + ".json");
|
|
2599
|
+
function readJustification(decisionId) {
|
|
2600
|
+
try {
|
|
2601
|
+
const p = justifyPath(decisionId);
|
|
2602
|
+
if (!existsSync(p))
|
|
2603
|
+
return null;
|
|
2604
|
+
if (statSync(p).mtimeMs < Date.now() - 24 * 3600_000) {
|
|
2605
|
+
rmSync(p, { force: true });
|
|
2279
2606
|
return null;
|
|
2280
|
-
const norm = (s) => (process.platform === "win32" ? s.toLowerCase() : s);
|
|
2281
|
-
const root = resolvePath(m[1]);
|
|
2282
|
-
const cwd = norm(process.cwd());
|
|
2283
|
-
return cwd === norm(root) || cwd.startsWith(norm(root) + pathSep) ? root : null;
|
|
2284
|
-
})();
|
|
2285
|
-
const viaAlias = /[\\/]decidio$/.test(argv[1] ?? "");
|
|
2286
|
-
const pkgName = viaAlias ? "decidio" : "@decidio/sdk";
|
|
2287
|
-
const upgradeCmd = underNpx ? `npx ${pkgName}@latest ${argv.slice(2).join(" ")}` : localRoot ? `npm i ${pkgName}@latest` : `npm i -g ${pkgName}@latest`;
|
|
2288
|
-
const notice = `${latest} is available (you have ${VERSION}).`;
|
|
2289
|
-
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
2290
|
-
err(`⚠ ${notice} Upgrade: ${upgradeCmd}`);
|
|
2291
|
-
if (SHELL_ENV.DECIDIO_REQUIRE_LATEST) {
|
|
2292
|
-
err(`✘ DECIDIO_REQUIRE_LATEST is set — not running a stale version.`);
|
|
2293
|
-
return 1;
|
|
2294
2607
|
}
|
|
2295
|
-
|
|
2608
|
+
const j = JSON.parse(readFileSync(p, "utf8"));
|
|
2609
|
+
return typeof j?.justification === "string" && j.justification.trim() ? j.justification : null;
|
|
2296
2610
|
}
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
err(`✘ not upgraded — run this when ready: ${upgradeCmd}`);
|
|
2300
|
-
return 1;
|
|
2611
|
+
catch {
|
|
2612
|
+
return null;
|
|
2301
2613
|
}
|
|
2302
|
-
|
|
2614
|
+
}
|
|
2615
|
+
const clearJustification = (decisionId) => { try {
|
|
2616
|
+
rmSync(justifyPath(decisionId), { force: true });
|
|
2617
|
+
}
|
|
2618
|
+
catch { } };
|
|
2619
|
+
function justificationProblem(text) {
|
|
2620
|
+
if (!text.trim())
|
|
2621
|
+
return "a justification cannot be empty";
|
|
2622
|
+
if (text.length > JUSTIFY_MAX)
|
|
2623
|
+
return `keep the justification to ${JUSTIFY_MAX} characters`;
|
|
2624
|
+
if (/[\r\n]/.test(text))
|
|
2625
|
+
return "a justification is one line";
|
|
2626
|
+
return null;
|
|
2627
|
+
}
|
|
2628
|
+
function sweepHookState() {
|
|
2629
|
+
try {
|
|
2630
|
+
const dir = hookStateDir();
|
|
2631
|
+
if (!existsSync(dir))
|
|
2632
|
+
return;
|
|
2633
|
+
const cutoff = Date.now() - 24 * 3600_000;
|
|
2634
|
+
for (const f of readdirSync(dir)) {
|
|
2635
|
+
const p = joinPath(dir, f);
|
|
2636
|
+
try {
|
|
2637
|
+
if (statSync(p).mtimeMs < cutoff)
|
|
2638
|
+
rmSync(p, { force: true });
|
|
2639
|
+
}
|
|
2640
|
+
catch { }
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
catch { }
|
|
2644
|
+
}
|
|
2645
|
+
const hookErr = (e) => e instanceof DecidioHttpError ? `HTTP ${e.status} from ${e.endpoint}` : String(e?.cause?.code ?? e?.message ?? e);
|
|
2646
|
+
const hookShort = (cmd) => (cmd.length > 120 ? cmd.slice(0, 117) + "..." : cmd);
|
|
2647
|
+
async function hookStatus(cfg, decisionId, waitSec) {
|
|
2648
|
+
assertSafeTransport(cfg.apiUrl, true);
|
|
2649
|
+
const path = `/agent/status/${encodeURIComponent(decisionId)}?wait=${waitSec}`;
|
|
2650
|
+
const r = await fetch(cfg.apiUrl.replace(/\/$/, "") + path, { headers: { authorization: `Bearer ${cfg.apiToken}` }, signal: AbortSignal.timeout((waitSec + 20) * 1000) });
|
|
2651
|
+
if (!r.ok)
|
|
2652
|
+
throw new DecidioHttpError(r.status, path, await r.json().catch(() => undefined));
|
|
2653
|
+
const body = await r.json();
|
|
2654
|
+
if (!body || typeof body !== "object" || typeof body.state !== "string")
|
|
2655
|
+
throw new Error("the gate answered a status this SDK does not understand");
|
|
2656
|
+
return body;
|
|
2657
|
+
}
|
|
2658
|
+
async function hookHold(cfg, decisionId, holdMs) {
|
|
2659
|
+
const deadline = Date.now() + holdMs;
|
|
2660
|
+
let status = null, lastError = null;
|
|
2661
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)));
|
|
2662
|
+
for (;;) {
|
|
2663
|
+
const left = deadline - Date.now();
|
|
2664
|
+
if (left < 1000)
|
|
2665
|
+
break;
|
|
2666
|
+
const t0 = Date.now();
|
|
2667
|
+
try {
|
|
2668
|
+
status = await hookStatus(cfg, decisionId, Math.min(30, Math.floor(left / 1000)));
|
|
2669
|
+
lastError = null;
|
|
2670
|
+
}
|
|
2671
|
+
catch (e) {
|
|
2672
|
+
lastError = hookErr(e);
|
|
2673
|
+
const transient = (e instanceof DecidioHttpError && (e.status >= 500 || e.status === 408 || e.status === 429))
|
|
2674
|
+
|| (!(e instanceof DecidioHttpError) && (e?.name === "AbortError" || e?.name === "TimeoutError" || typeof e?.cause?.code === "string" || /fetch failed/i.test(String(e?.message ?? ""))));
|
|
2675
|
+
if (!transient)
|
|
2676
|
+
break;
|
|
2677
|
+
await sleep(Math.min(2000, deadline - Date.now()));
|
|
2678
|
+
continue;
|
|
2679
|
+
}
|
|
2680
|
+
if (status.state !== "pending")
|
|
2681
|
+
break;
|
|
2682
|
+
if (Date.now() - t0 < 500)
|
|
2683
|
+
await sleep(Math.min(1000, deadline - Date.now()));
|
|
2684
|
+
}
|
|
2685
|
+
return { status, lastError };
|
|
2686
|
+
}
|
|
2687
|
+
async function cmdHookPretool() {
|
|
2688
|
+
const out = (o) => { process.stdout.write(JSON.stringify(o) + "\n"); return 0; };
|
|
2689
|
+
const deny = (reason, user) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason }, ...(user ? { systemMessage: user } : {}) });
|
|
2690
|
+
const allow = (reason) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: reason } });
|
|
2691
|
+
try {
|
|
2692
|
+
const read = await readHookStdin();
|
|
2693
|
+
if (!read.ok)
|
|
2694
|
+
return deny(`Decidio hook: ${read.why} - refusing this tool call (fail closed).`);
|
|
2695
|
+
const input = read.input;
|
|
2696
|
+
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.`);
|
|
2706
|
+
}
|
|
2707
|
+
if (!rule)
|
|
2708
|
+
return 0;
|
|
2709
|
+
const short = hookShort(hookRedact(command.replace(/\s+/g, " ").trim()));
|
|
2710
|
+
const projectDir = resolvePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
2711
|
+
const cfg = hookConfig();
|
|
2712
|
+
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());
|
|
2716
|
+
const rel = relative(projectDir, cwdAbs);
|
|
2717
|
+
const cwdRel = !rel ? "." : rel.startsWith("..") || isAbsolute(rel) ? cwdAbs : rel;
|
|
2718
|
+
const session = typeof input.session_id === "string" ? input.session_id : "";
|
|
2719
|
+
const subject = hookSubject(rule.category, hookSegments(command), cwdAbs, projectDir);
|
|
2720
|
+
let material;
|
|
2721
|
+
try {
|
|
2722
|
+
material = bindingMaterial(cfg);
|
|
2723
|
+
}
|
|
2724
|
+
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\`.`);
|
|
2726
|
+
}
|
|
2727
|
+
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);
|
|
2732
|
+
let key;
|
|
2733
|
+
try {
|
|
2734
|
+
key = idempotencyKey(material, { host: "claude-code", tool, command: canonical, cwd: cwdRel, session, subject: subject.fingerprint ?? "" });
|
|
2735
|
+
}
|
|
2736
|
+
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\`.`);
|
|
2738
|
+
}
|
|
2739
|
+
let auth;
|
|
2740
|
+
try {
|
|
2741
|
+
let a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key });
|
|
2742
|
+
if (a.decision === "duplicate") {
|
|
2743
|
+
const twin = a.duplicateOf;
|
|
2744
|
+
const label = twin.displayNumber ?? twin.decisionId;
|
|
2745
|
+
const when = twin.refusedAt ? ` on ${String(twin.refusedAt).slice(0, 10)}` : "";
|
|
2746
|
+
const why = twin.reason ? ` ("${twin.reason}")` : "";
|
|
2747
|
+
const stored = readJustification(twin.decisionId);
|
|
2748
|
+
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`);
|
|
2750
|
+
}
|
|
2751
|
+
a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key, justification: stored });
|
|
2752
|
+
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.`);
|
|
2754
|
+
clearJustification(twin.decisionId);
|
|
2755
|
+
}
|
|
2756
|
+
auth = a;
|
|
2757
|
+
}
|
|
2758
|
+
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.`}`);
|
|
2760
|
+
}
|
|
2761
|
+
const where = decisionUrl(cfg.appUrl, auth.decisionId);
|
|
2762
|
+
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}`);
|
|
2764
|
+
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.`);
|
|
2767
|
+
}
|
|
2768
|
+
const holdRaw = Number(process.env.DECIDIO_HOOK_HOLD_MS ?? "90000");
|
|
2769
|
+
const held = await hookHold(cfg, auth.decisionId, Number.isFinite(holdRaw) && holdRaw >= 0 ? Math.min(holdRaw, HOOK_HOLD_CAP_MS) : 90_000);
|
|
2770
|
+
const st = held.status;
|
|
2771
|
+
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.`);
|
|
2773
|
+
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}` : ""}`);
|
|
2775
|
+
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}`);
|
|
2777
|
+
const chk = await checkApproval(cfg, auth.decisionId, request, { status: st });
|
|
2778
|
+
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() });
|
|
2781
|
+
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
|
+
}
|
|
2783
|
+
catch (e) {
|
|
2784
|
+
return deny(`Decidio hook: unexpected failure (${e?.message ?? e}) - refusing this tool call (fail closed). Nothing ran.`);
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
async function cmdHookPosttool() {
|
|
2788
|
+
try {
|
|
2789
|
+
const read = await readHookStdin();
|
|
2790
|
+
if (!read.ok) {
|
|
2791
|
+
err(`decidio hook posttool: ${read.why}`);
|
|
2792
|
+
return 0;
|
|
2793
|
+
}
|
|
2794
|
+
const id = typeof read.input.tool_use_id === "string" ? read.input.tool_use_id : "";
|
|
2795
|
+
if (!id)
|
|
2796
|
+
return 0;
|
|
2797
|
+
const path = hookStatePath(id);
|
|
2798
|
+
sweepHookState();
|
|
2799
|
+
if (!existsSync(path))
|
|
2800
|
+
return 0;
|
|
2801
|
+
let s;
|
|
2802
|
+
try {
|
|
2803
|
+
s = JSON.parse(readFileSync(path, "utf8"));
|
|
2804
|
+
}
|
|
2805
|
+
catch {
|
|
2806
|
+
rmSync(path, { force: true });
|
|
2807
|
+
return 0;
|
|
2808
|
+
}
|
|
2809
|
+
rmSync(path, { force: true });
|
|
2810
|
+
if (!s || s.v !== 1 || typeof s.decisionId !== "string")
|
|
2811
|
+
return 0;
|
|
2812
|
+
const cfg = hookConfig();
|
|
2813
|
+
if ("missing" in cfg) {
|
|
2814
|
+
err(`decidio hook posttool: ${cfg.missing} - the execution of ${s.decisionId} is not reported`);
|
|
2815
|
+
return 0;
|
|
2816
|
+
}
|
|
2817
|
+
const resp = read.input.tool_response;
|
|
2818
|
+
const interrupted = !!(resp && typeof resp === "object" && resp.interrupted);
|
|
2819
|
+
const captured = { host: "claude-code", tool: s.tool, category: s.category, interrupted, ok: !interrupted };
|
|
2820
|
+
const body = buildConfirmBody(cfg, s.decisionId, captured, signConfirm(s.decisionId, cfg), "wrapper");
|
|
2821
|
+
assertSafeTransport(cfg.apiUrl, true);
|
|
2822
|
+
const r = await fetch(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiToken}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(20_000) });
|
|
2823
|
+
const parsed = await r.json().catch(() => null);
|
|
2824
|
+
if (!r.ok) {
|
|
2825
|
+
err(`decidio hook posttool: the execution of ${s.decisionId} could not be recorded (HTTP ${r.status}${parsed?.message ? `: ${parsed.message}` : ""}) - the approval is not marked spent; it lapses on its own`);
|
|
2826
|
+
return 0;
|
|
2827
|
+
}
|
|
2828
|
+
const ev = readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: captured });
|
|
2829
|
+
const line = ev.reportRecorded
|
|
2830
|
+
? `Decidio recorded the execution of ${s.decisionId} (evidence tier ${ev.evidenceTier ?? "recorded"}); that approval is now spent - the next destructive command needs its own.`
|
|
2831
|
+
: `Decidio did not record the execution of ${s.decisionId}: ${ev.diagnostic ?? "no report recorded"}.`;
|
|
2832
|
+
err(`decidio hook: ${line}`);
|
|
2833
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: line } }) + "\n");
|
|
2834
|
+
}
|
|
2835
|
+
catch (e) {
|
|
2836
|
+
err(`decidio hook posttool: ${e?.message ?? e}`);
|
|
2837
|
+
}
|
|
2838
|
+
return 0;
|
|
2839
|
+
}
|
|
2840
|
+
async function cmdHookWait(id, timeoutSec) {
|
|
2841
|
+
if (!id) {
|
|
2842
|
+
err("✘ hook wait needs the decision id the hook named: npx @decidio/sdk hook wait <decision-id> [--timeout 600]");
|
|
2843
|
+
return 2;
|
|
2844
|
+
}
|
|
2845
|
+
const problem = decisionIdProblem(id);
|
|
2846
|
+
if (problem) {
|
|
2847
|
+
err(`✘ "${id}" ${problem}`);
|
|
2848
|
+
return 2;
|
|
2849
|
+
}
|
|
2850
|
+
const cfg = hookConfig();
|
|
2851
|
+
if ("missing" in cfg) {
|
|
2852
|
+
err(`✘ ${cfg.missing} - run \`npx @decidio/sdk init\` in this project first.`);
|
|
2853
|
+
return 1;
|
|
2854
|
+
}
|
|
2855
|
+
const where = decisionUrl(cfg.appUrl, id);
|
|
2856
|
+
err(`· decidio → ${API}`);
|
|
2857
|
+
err(`· waiting for ${id}${where ? ` - approve at ${where}` : ""} (up to ${timeoutSec}s; Ctrl-C stops waiting, the request stays open)`);
|
|
2858
|
+
const held = await hookHold(cfg, id, timeoutSec * 1000);
|
|
2859
|
+
const st = held.status;
|
|
2860
|
+
if (!st) {
|
|
2861
|
+
err(`✘ could not read ${id}: ${held.lastError ?? "no answer"}`);
|
|
2862
|
+
return 1;
|
|
2863
|
+
}
|
|
2864
|
+
log(st.state);
|
|
2865
|
+
if (st.state === "pending") {
|
|
2866
|
+
err(`· still pending after ${timeoutSec}s - run this again${where ? `, or approve at ${where}` : ""}.`);
|
|
2867
|
+
return 1;
|
|
2868
|
+
}
|
|
2869
|
+
if (st.state === "rejected" || st.state === "blocked") {
|
|
2870
|
+
err(`✘ ${id} was ${st.state}${st.reason ? `: ${st.reason}` : ""} - do not retry the command.`);
|
|
2871
|
+
return 3;
|
|
2872
|
+
}
|
|
2873
|
+
err(`✔ ${id} ${st.state}${st.spent ? " - but already spent (an execution is recorded on it): the retry will ask again" : ""}${st.grantExpiresAt ? ` - valid until ${st.grantExpiresAt}` : ""}. Retry exactly the same command now.`);
|
|
2874
|
+
return 0;
|
|
2875
|
+
}
|
|
2876
|
+
function cmdHookJustify(id, text) {
|
|
2877
|
+
if (!id) {
|
|
2878
|
+
err(`usage: npx @decidio/sdk hook justify <decisionId> "<what changed, in the user's words>"`);
|
|
2879
|
+
return 2;
|
|
2880
|
+
}
|
|
2881
|
+
const idP = decisionIdProblem(id);
|
|
2882
|
+
if (idP) {
|
|
2883
|
+
err(`✘ ${idP}`);
|
|
2884
|
+
return 2;
|
|
2885
|
+
}
|
|
2886
|
+
const tp = justificationProblem(text);
|
|
2887
|
+
if (tp) {
|
|
2888
|
+
err(`✘ ${tp}.`);
|
|
2889
|
+
return 2;
|
|
2890
|
+
}
|
|
2891
|
+
try {
|
|
2892
|
+
mkdirSync(hookStateDir(), { recursive: true });
|
|
2893
|
+
writeFileSync(justifyPath(id), JSON.stringify({ v: 1, decisionId: id, justification: text.trim(), at: new Date().toISOString() }) + "\n");
|
|
2894
|
+
}
|
|
2895
|
+
catch (e) {
|
|
2896
|
+
err(`✘ could not store the justification (${e?.message ?? e}).`);
|
|
2897
|
+
return 1;
|
|
2898
|
+
}
|
|
2899
|
+
log(`✔ justification stored for ${id}. Retry EXACTLY the same command within a day: the request goes to the approver with these words beside the earlier refusal, and the justification is used once.`);
|
|
2900
|
+
return 0;
|
|
2901
|
+
}
|
|
2902
|
+
function cmdHookRules() {
|
|
2903
|
+
let extras = [];
|
|
2904
|
+
try {
|
|
2905
|
+
extras = hookExtraRules();
|
|
2906
|
+
}
|
|
2907
|
+
catch (e) {
|
|
2908
|
+
err(`✘ ${e?.message ?? e} - while this stands, the hook refuses EVERY command.`);
|
|
2909
|
+
return 1;
|
|
2910
|
+
}
|
|
2911
|
+
log(`Commands the Decidio hook gates (a person approves before they run; everything else runs as usual):`);
|
|
2912
|
+
for (const r of HOOK_RULES)
|
|
2913
|
+
log(` ${r.category.padEnd(18)} ${r.summary}`);
|
|
2914
|
+
if (extras.length) {
|
|
2915
|
+
log(`From DECIDIO_HOOK_EXTRA_RULES (${process.env.DECIDIO_HOOK_EXTRA_RULES}):`);
|
|
2916
|
+
for (const r of extras)
|
|
2917
|
+
log(` ${r.category.padEnd(18)} ${r.summary}`);
|
|
2918
|
+
}
|
|
2919
|
+
else
|
|
2920
|
+
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
|
+
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
|
+
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.`);
|
|
2923
|
+
return 0;
|
|
2924
|
+
}
|
|
2925
|
+
function hookRunner() {
|
|
2926
|
+
const q = (p) => `"${p.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
2927
|
+
const entry = process.argv[1] ?? "";
|
|
2928
|
+
if (!entry || /[\\/]_npx[\\/]/.test(entry))
|
|
2929
|
+
return `npx -y @decidio/sdk@${VERSION}`;
|
|
2930
|
+
let real = entry;
|
|
2931
|
+
try {
|
|
2932
|
+
real = realPath(entry);
|
|
2933
|
+
}
|
|
2934
|
+
catch { }
|
|
2935
|
+
const localRoot = resolvePath("node_modules") + pathSep;
|
|
2936
|
+
if (real.startsWith(localRoot))
|
|
2937
|
+
real = "$CLAUDE_PROJECT_DIR/node_modules/" + real.slice(localRoot.length).split(pathSep).join("/");
|
|
2938
|
+
real = real.split(pathSep).join("/");
|
|
2939
|
+
if (/\.ts$/.test(real))
|
|
2940
|
+
return `npx tsx ${q(real)}`;
|
|
2941
|
+
return `node ${q(real)}`;
|
|
2942
|
+
}
|
|
2943
|
+
async function cmdHookInstall(opts) {
|
|
2944
|
+
const settingsPath = joinPath(".claude", opts.project ? "settings.json" : "settings.local.json");
|
|
2945
|
+
let settings = {};
|
|
2946
|
+
if (existsSync(settingsPath)) {
|
|
2947
|
+
try {
|
|
2948
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
2949
|
+
}
|
|
2950
|
+
catch (e) {
|
|
2951
|
+
err(`✘ ${settingsPath} exists but is not valid JSON (${e?.message ?? e}) - fix it first; nothing was written.`);
|
|
2952
|
+
return 1;
|
|
2953
|
+
}
|
|
2954
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
2955
|
+
err(`✘ ${settingsPath} is not a JSON object - nothing was written.`);
|
|
2956
|
+
return 1;
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
if (opts.cli !== undefined && !opts.cli) {
|
|
2960
|
+
err("✘ --cli needs the path of the CLI to run from the hook (e.g. --cli packages/decidio-guard/dist/cli.js)");
|
|
2961
|
+
return 2;
|
|
2962
|
+
}
|
|
2963
|
+
const cliAt = opts.cli ? resolvePath(opts.cli).split(pathSep).join("/") : "";
|
|
2964
|
+
const runner = opts.cli ? (/\.ts$/.test(opts.cli) ? `npx tsx "${cliAt}"` : `node "${cliAt}"`) : hookRunner();
|
|
2965
|
+
const hooks = (settings.hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {});
|
|
2966
|
+
const place = (event, command, extra) => {
|
|
2967
|
+
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";
|
|
2977
|
+
};
|
|
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 });
|
|
2981
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
2982
|
+
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"}).`);
|
|
2983
|
+
log(` Runs: ${runner} hook pretool|posttool - the pretool hook times out at 150 s and BLOCKS the call if it fails (fail closed).`);
|
|
2984
|
+
const env = readEnvFile();
|
|
2985
|
+
const token = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
|
|
2986
|
+
if (token)
|
|
2987
|
+
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
|
+
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()}`);
|
|
2990
|
+
log(` Gated: ${HOOK_RULES.map((r) => r.category).join(", ")} (npx @decidio/sdk hook rules lists the patterns; DECIDIO_HOOK_EXTRA_RULES adds yours).`);
|
|
2991
|
+
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
|
+
log(` Nobody yet? Invite one: npx @decidio/sdk hook install --approver <email> (single-use link; share by WhatsApp, SMS or email)`);
|
|
2993
|
+
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
|
+
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.`);
|
|
2996
|
+
if (opts.approver !== undefined)
|
|
2997
|
+
return inviteApprover(opts.approver);
|
|
2998
|
+
return 0;
|
|
2999
|
+
}
|
|
3000
|
+
async function inviteApprover(email) {
|
|
3001
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
3002
|
+
err(`✘ --approver needs an email address (got "${email}")`);
|
|
3003
|
+
return 2;
|
|
3004
|
+
}
|
|
3005
|
+
const session = await acquireSession("Inviting an approver");
|
|
3006
|
+
if (!session)
|
|
3007
|
+
return 1;
|
|
3008
|
+
const r = await api("/demo/invite", { method: "POST", body: JSON.stringify({}) }, session);
|
|
3009
|
+
if (r.status !== 200 || typeof r.body?.link !== "string") {
|
|
3010
|
+
err(`✘ could not create an invite (HTTP ${r.status}${r.body?.error ? `: ${r.body.error}` : ""}) - only the workspace owner can invite on a lab workspace; elsewhere add the approver in the app's Roles screen.`);
|
|
3011
|
+
return 1;
|
|
3012
|
+
}
|
|
3013
|
+
const link = String(r.body.link);
|
|
3014
|
+
const until = typeof r.body.expiresAt === "string" ? r.body.expiresAt.slice(0, 10) : "in 7 days";
|
|
3015
|
+
const text = `I'm using Decidio to route my coding agent's risky commands to a person for approval - I'd like that person to be you. This link opens my workspace for you (single use, expires ${until}): ${link}`;
|
|
3016
|
+
log(`✔ invite created (single use, expires ${until}). Send it to ${email} by whichever channel they read - these carry the invite, nothing else:`);
|
|
3017
|
+
log(` WhatsApp https://wa.me/?text=${encodeURIComponent(text)}`);
|
|
3018
|
+
log(` SMS sms:?&body=${encodeURIComponent(text)}`);
|
|
3019
|
+
log(` Email mailto:${encodeURIComponent(email)}?subject=${encodeURIComponent("Approve my coding agent's risky commands")}&body=${encodeURIComponent(text)}`);
|
|
3020
|
+
log(` Link ${link}`);
|
|
3021
|
+
log(` Opening it signs them into the web app (the phone build at the same address; the app stores can wait) as a recognized approver in your workspace.`);
|
|
3022
|
+
return 0;
|
|
3023
|
+
}
|
|
3024
|
+
const PACK_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
3025
|
+
const PACK_SKILL_PREFIX = "decidio-";
|
|
3026
|
+
const PACK_IGNORE_ENTRY = ".claude/skills/decidio-*/";
|
|
3027
|
+
const PACK_RULES_IGNORE_ENTRY = ".claude/rules/decidio-*.md";
|
|
3028
|
+
function claudeHome() {
|
|
3029
|
+
return process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
|
|
3030
|
+
}
|
|
3031
|
+
function ensurePackSkillsIgnored() {
|
|
3032
|
+
try {
|
|
3033
|
+
if (lstatSync(".gitignore").isSymbolicLink()) {
|
|
3034
|
+
err(`· .gitignore is a symbolic link - add ${PACK_IGNORE_ENTRY} and ${PACK_RULES_IGNORE_ENTRY} to it yourself`);
|
|
3035
|
+
return;
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
catch { }
|
|
3039
|
+
const creating = !existsSync(".gitignore");
|
|
3040
|
+
let existing = creating ? "" : readFileSync(".gitignore", "utf8");
|
|
3041
|
+
for (const entry of [PACK_IGNORE_ENTRY, PACK_RULES_IGNORE_ENTRY]) {
|
|
3042
|
+
if (existing.split(/\r?\n/).some((l) => l.trim() === entry))
|
|
3043
|
+
continue;
|
|
3044
|
+
appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + entry + "\n");
|
|
3045
|
+
existing = readFileSync(".gitignore", "utf8");
|
|
3046
|
+
log(`✔ ${creating && entry === PACK_IGNORE_ENTRY ? "wrote" : "added"} ${entry} to .gitignore (a pulled pack is yours to read, not the repository's to carry)`);
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
const yamlQuote = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
3050
|
+
const mdCell = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
|
|
3051
|
+
export function packScopeLabel(scope) {
|
|
3052
|
+
if (!scope || typeof scope !== "object")
|
|
3053
|
+
return null;
|
|
3054
|
+
if (scope.kind === "org")
|
|
3055
|
+
return "the organisation";
|
|
3056
|
+
if (scope.kind === "personal")
|
|
3057
|
+
return "yours";
|
|
3058
|
+
if (scope.kind === "unit") {
|
|
3059
|
+
const type = typeof scope.unitType === "string" && /^[a-z_]{1,20}$/.test(scope.unitType) ? (scope.unitType === "project" ? "program" : scope.unitType) : "unit";
|
|
3060
|
+
const name = typeof scope.unitName === "string" && scope.unitName.trim() ? ` "${scope.unitName.replace(/["\r\n]/g, "'").slice(0, 80)}"` : "";
|
|
3061
|
+
return `${type}${name}`;
|
|
3062
|
+
}
|
|
3063
|
+
return null;
|
|
3064
|
+
}
|
|
3065
|
+
export function hereQuery(cwd = process.cwd()) {
|
|
3066
|
+
const cfg = joinPath(cwd, ".decidio.json");
|
|
3067
|
+
try {
|
|
3068
|
+
if (lstatSync(cfg).isSymbolicLink())
|
|
3069
|
+
return { error: ".decidio.json is a symbolic link - replace it with the file itself (npx @decidio/sdk context bind --unit <id> writes it)" };
|
|
3070
|
+
}
|
|
3071
|
+
catch { }
|
|
3072
|
+
if (existsSync(cfg)) {
|
|
3073
|
+
let unit;
|
|
3074
|
+
try {
|
|
3075
|
+
unit = JSON.parse(readFileSync(cfg, "utf8"))?.unit;
|
|
3076
|
+
}
|
|
3077
|
+
catch {
|
|
3078
|
+
return { error: ".decidio.json is not JSON - it holds {\"unit\": \"<unit id>\"} (npx @decidio/sdk context bind --unit <id> writes it)" };
|
|
3079
|
+
}
|
|
3080
|
+
if (typeof unit !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(unit))
|
|
3081
|
+
return { error: ".decidio.json's \"unit\" is not a unit id (npx @decidio/sdk context bind --unit <id> writes it)" };
|
|
3082
|
+
return { unit };
|
|
3083
|
+
}
|
|
3084
|
+
let remote = "";
|
|
3085
|
+
try {
|
|
3086
|
+
remote = execFileSync("git", ["remote", "get-url", "origin"], { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5_000 }).trim();
|
|
3087
|
+
}
|
|
3088
|
+
catch {
|
|
3089
|
+
return {};
|
|
3090
|
+
}
|
|
3091
|
+
const m = /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/(?:[^@/]+@)?github\.com\/)([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?\/?$/.exec(remote);
|
|
3092
|
+
return m ? { repo: `${m[1]}/${m[2]}` } : {};
|
|
3093
|
+
}
|
|
3094
|
+
export function plainProjectPath(parts) {
|
|
3095
|
+
let p = ".claude";
|
|
3096
|
+
for (const part of ["", ...parts]) {
|
|
3097
|
+
if (part)
|
|
3098
|
+
p = joinPath(p, part);
|
|
3099
|
+
try {
|
|
3100
|
+
if (lstatSync(p).isSymbolicLink())
|
|
3101
|
+
return false;
|
|
3102
|
+
}
|
|
3103
|
+
catch { }
|
|
3104
|
+
}
|
|
3105
|
+
return true;
|
|
3106
|
+
}
|
|
3107
|
+
export function isPackFileOf(text, slug, kind) {
|
|
3108
|
+
if (kind === "skill")
|
|
3109
|
+
return text.startsWith(`---\nname: ${PACK_SKILL_PREFIX}${slug}\n`) && /\n# .*\(Decidio context pack\)\n/.test(text);
|
|
3110
|
+
return /^# .*\(Decidio context pack - read first\)\n/.test(text);
|
|
3111
|
+
}
|
|
3112
|
+
function gitTracked(path) {
|
|
3113
|
+
try {
|
|
3114
|
+
execFileSync("git", ["ls-files", "--error-unmatch", "--", path], { stdio: "ignore", timeout: 5_000 });
|
|
3115
|
+
return true;
|
|
3116
|
+
}
|
|
3117
|
+
catch {
|
|
3118
|
+
return false;
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
export function packSkillMarkdown(idx) {
|
|
3122
|
+
const name = PACK_SKILL_PREFIX + String(idx.slug);
|
|
3123
|
+
const rows = Array.isArray(idx.rows) ? idx.rows : [];
|
|
3124
|
+
const description = `Use when the user's words match "${String(idx.trigger).replace(/"/g, "'")}" - the Decidio context pack "${String(idx.title).replace(/"/g, "'")}": the sealed decisions in force on the subject, by record id.`;
|
|
3125
|
+
const table = rows.length
|
|
3126
|
+
? ["| Record | Title | Type | Status | Lifecycle | Decided | Link |", "| --- | --- | --- | --- | --- | --- | --- |",
|
|
3127
|
+
...rows.map((r) => `| ${mdCell(r.recordId)} | ${mdCell(r.title)} | ${mdCell(r.type)} | ${mdCell(r.status)} | ${mdCell(r.lifecycle?.state)}${r.lifecycle?.inEffect === false ? " (not in effect)" : ""} | ${mdCell(String(r.decidedAt ?? "").slice(0, 10))} | ${mdCell(r.url)} |`)].join("\n")
|
|
3128
|
+
: "_No entries you can read yet._";
|
|
3129
|
+
const omitted = Number(idx.omitted ?? 0);
|
|
3130
|
+
return [
|
|
3131
|
+
"---", `name: ${name}`, `description: ${yamlQuote(description)}`, "---", "",
|
|
3132
|
+
`# ${String(idx.title)} (Decidio context pack)`, "",
|
|
3133
|
+
...(packScopeLabel(idx.scope) ? [`**Scope:** ${packScopeLabel(idx.scope)}`, ""] : []),
|
|
3134
|
+
`**When to use:** ${String(idx.trigger)}`, "",
|
|
3135
|
+
"These are sealed Decidio decisions the pack's owner curated for this subject. Treat them as the record of what was decided and cite them by record id. Role tokens such as [COUNTERPARTY_1] are protective placeholders: never guess or restate what they stand for. To read one in full, call Decidio's `get_decision` with its record id (the Decidio connector) or open its link; `get_context_pack` returns this list live. When a decision's lifecycle is not \"in_effect\", say so before relying on it.", "",
|
|
3136
|
+
table, "",
|
|
3137
|
+
`_Pulled ${String(idx.asOf)} · pack hash ${String(idx.hash)} · ${rows.length} ${rows.length === 1 ? "entry" : "entries"} shown${omitted ? `, ${omitted} you cannot read omitted` : ""}. Refresh with \`npx @decidio/sdk context pull\`._`, "",
|
|
3138
|
+
].join("\n");
|
|
3139
|
+
}
|
|
3140
|
+
export function packRulesMarkdown(idx) {
|
|
3141
|
+
const rows = Array.isArray(idx.rows) ? idx.rows : [];
|
|
3142
|
+
const table = rows.length
|
|
3143
|
+
? ["| Record | Title | Type | Status | Lifecycle | Decided | Link |", "| --- | --- | --- | --- | --- | --- | --- |",
|
|
3144
|
+
...rows.map((r) => `| ${mdCell(r.recordId)} | ${mdCell(r.title)} | ${mdCell(r.type)} | ${mdCell(r.status)} | ${mdCell(r.lifecycle?.state)}${r.lifecycle?.inEffect === false ? " (not in effect)" : ""} | ${mdCell(String(r.decidedAt ?? "").slice(0, 10))} | ${mdCell(r.url)} |`)].join("\n")
|
|
3145
|
+
: "_No entries you can read yet._";
|
|
3146
|
+
const omitted = Number(idx.omitted ?? 0);
|
|
3147
|
+
return [
|
|
3148
|
+
`# ${String(idx.title)} (Decidio context pack - read first)`, "",
|
|
3149
|
+
...(packScopeLabel(idx.scope) ? [`**Scope:** ${packScopeLabel(idx.scope)}`, ""] : []),
|
|
3150
|
+
`**Read this on every question in this project.** The pack's owner says it applies "${String(idx.trigger).replace(/"/g, "'")}".`, "",
|
|
3151
|
+
"These are sealed Decidio decisions the pack's owner curated as standing context: the record of what was decided. Cite them by record id. Role tokens such as [COUNTERPARTY_1] are protective placeholders: never guess or restate what they stand for; for the full record open the link, or call get_decision on the Decidio connector. The other Decidio packs (the skills pulled beside this file) build on this one.", "",
|
|
3152
|
+
table, "",
|
|
3153
|
+
`_Pulled ${String(idx.asOf)} · pack hash ${String(idx.hash)} · ${rows.length} ${rows.length === 1 ? "entry" : "entries"} shown${omitted ? `, ${omitted} you cannot read omitted` : ""}. Refresh with \`npx @decidio/sdk context pull\`._`, "",
|
|
3154
|
+
].join("\n");
|
|
3155
|
+
}
|
|
3156
|
+
function packChatgptBlock(idx) {
|
|
3157
|
+
const rows = Array.isArray(idx.rows) ? idx.rows : [];
|
|
3158
|
+
return [
|
|
3159
|
+
`## Decidio context pack: ${String(idx.title)}${idx.always ? " - READ FIRST, on every question" : ""}`,
|
|
3160
|
+
`When to use: ${idx.always ? "always - read this before the other packs; the owner says " : ""}"${String(idx.trigger).replace(/"/g, "'")}"`,
|
|
3161
|
+
"These are sealed decisions, by record id; cite them, never guess what a role token like [COUNTERPARTY_1] stands for. For the full record call get_context_pack / get_decision on the Decidio connector, or open the link.",
|
|
3162
|
+
...rows.map((r) => `- ${mdCell(r.recordId)} · ${mdCell(r.title)} · ${mdCell(r.type)} · ${mdCell(r.lifecycle?.state)} · ${mdCell(r.url)}`),
|
|
3163
|
+
`(pulled ${String(idx.asOf)}, hash ${String(idx.hash)}${Number(idx.omitted ?? 0) ? `, ${idx.omitted} you cannot read omitted` : ""})`,
|
|
3164
|
+
].join("\n");
|
|
3165
|
+
}
|
|
3166
|
+
async function cmdContext(sub, pos, flags) {
|
|
3167
|
+
const usage = () => { err(`usage: npx @decidio/sdk context list | create <slug> --title "…" --trigger "…" [--unit <unitId> | --org] | add <slug> <record…> | remove <slug> <record…> | trigger <slug> "<phrase>" | always <slug> on|off | delete <slug> | bind --unit <unitId> | pull [--here] [--pack <slug>] [--chatgpt]`); return 2; };
|
|
3168
|
+
const slug = pos[0];
|
|
3169
|
+
if (!sub)
|
|
3170
|
+
return usage();
|
|
3171
|
+
if (["create", "add", "remove", "trigger", "always", "delete"].includes(sub)) {
|
|
3172
|
+
if (!slug)
|
|
3173
|
+
return usage();
|
|
3174
|
+
if (!PACK_SLUG_RE.test(slug)) {
|
|
3175
|
+
err(`✘ a pack slug is 1-40 lower-case letters, digits or dashes, starting with a letter or digit (got "${slug}").`);
|
|
3176
|
+
return 2;
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
if (sub === "create" && (!flags.title || !flags.trigger)) {
|
|
3180
|
+
err(`✘ create needs --title "…" and --trigger "…" (the trigger is the user's own words for WHEN a model should use the pack, e.g. "whenever we discuss the ACME application").`);
|
|
3181
|
+
return 2;
|
|
3182
|
+
}
|
|
3183
|
+
if ((sub === "add" || sub === "remove") && pos.length < 2) {
|
|
3184
|
+
err(`✘ ${sub} needs at least one record (a D·number or decision id) after the slug.`);
|
|
3185
|
+
return 2;
|
|
3186
|
+
}
|
|
3187
|
+
if (sub === "trigger" && !pos[1]) {
|
|
3188
|
+
err(`✘ trigger needs the phrase: npx @decidio/sdk context trigger ${slug} "whenever we discuss …"`);
|
|
3189
|
+
return 2;
|
|
3190
|
+
}
|
|
3191
|
+
if (sub === "always" && !["on", "off"].includes(pos[1] ?? "")) {
|
|
3192
|
+
err(`✘ always takes on or off: npx @decidio/sdk context always ${slug} on (read on every question, as a Claude Code rules file) · off (fires on its trigger only)`);
|
|
3193
|
+
return 2;
|
|
3194
|
+
}
|
|
3195
|
+
if (sub === "pull" && flags.pack && !PACK_SLUG_RE.test(flags.pack)) {
|
|
3196
|
+
err(`✘ --pack names a pack by its slug (got "${flags.pack}").`);
|
|
3197
|
+
return 2;
|
|
3198
|
+
}
|
|
3199
|
+
const unitFlag = flags.unit ?? flags.team;
|
|
3200
|
+
if (sub === "create" && ((flags.unit && flags.team) || (unitFlag && flags.org))) {
|
|
3201
|
+
err(`✘ create takes one scope: --unit <unitId> (or --team), or --org - not both.`);
|
|
3202
|
+
return 2;
|
|
3203
|
+
}
|
|
3204
|
+
if (unitFlag !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(unitFlag)) {
|
|
3205
|
+
err(`✘ --unit names a unit by its id (got "${unitFlag}").`);
|
|
3206
|
+
return 2;
|
|
3207
|
+
}
|
|
3208
|
+
if (sub === "bind" && !flags.unit) {
|
|
3209
|
+
err(`✘ bind needs --unit <unitId>: it writes .decidio.json here, so \`context pull --here\` pulls that unit's packs.`);
|
|
3210
|
+
return 2;
|
|
3211
|
+
}
|
|
3212
|
+
if (!["list", "create", "add", "remove", "trigger", "always", "delete", "pull", "bind"].includes(sub))
|
|
3213
|
+
return usage();
|
|
3214
|
+
const session = await acquireSession("Context packs");
|
|
3215
|
+
if (!session)
|
|
3216
|
+
return 1;
|
|
3217
|
+
const call = async (method, path, body) => api(path, { method, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }, session);
|
|
3218
|
+
const refused = (r) => {
|
|
3219
|
+
const words = r.body && typeof r.body === "object" && typeof r.body.error === "string" ? r.body.error : `HTTP ${r.status}`;
|
|
3220
|
+
err(`✘ ${words}${r.body?.code ? ` (${r.body.code})` : ""}`);
|
|
3221
|
+
return 1;
|
|
3222
|
+
};
|
|
3223
|
+
const showPack = (p) => log(` ${p.slug} ${packScopeLabel(p.scope) ?? (p.audience === "team" ? `team ${p.teamUnitId ?? ""}`.trim() : p.audience === "org" ? "the organisation" : "personal")} ${p.entries} ${p.entries === 1 ? "entry" : "entries"} "${p.title}"${p.always ? " · read on every question" : ""}\n when: ${p.trigger}`);
|
|
3224
|
+
if (sub === "list") {
|
|
3225
|
+
const r = await call("GET", "/api/context-packs");
|
|
3226
|
+
if (r.status !== 200)
|
|
3227
|
+
return refused(r);
|
|
3228
|
+
const packs = Array.isArray(r.body?.packs) ? r.body.packs : [];
|
|
3229
|
+
if (flags.json) {
|
|
3230
|
+
log(JSON.stringify({ packs }, null, 2));
|
|
3231
|
+
return 0;
|
|
3232
|
+
}
|
|
3233
|
+
if (!packs.length) {
|
|
3234
|
+
log(`No context packs yet. Create one: npx @decidio/sdk context create <slug> --title "…" --trigger "whenever we discuss …"`);
|
|
3235
|
+
return 0;
|
|
3236
|
+
}
|
|
3237
|
+
log(`${packs.length} context pack${packs.length === 1 ? "" : "s"}:`);
|
|
3238
|
+
packs.forEach(showPack);
|
|
3239
|
+
return 0;
|
|
3240
|
+
}
|
|
3241
|
+
if (sub === "create") {
|
|
3242
|
+
const r = await call("POST", "/api/context-packs", { slug, title: flags.title, trigger: flags.trigger, ...(unitFlag ? { audience: "team", teamUnitId: unitFlag } : flags.org ? { audience: "org" } : { audience: "personal" }) });
|
|
3243
|
+
if (r.status !== 201)
|
|
3244
|
+
return refused(r);
|
|
3245
|
+
if (flags.json) {
|
|
3246
|
+
log(JSON.stringify(r.body, null, 2));
|
|
3247
|
+
return 0;
|
|
3248
|
+
}
|
|
3249
|
+
log(`✔ created pack ${slug}`);
|
|
3250
|
+
showPack(r.body.pack);
|
|
3251
|
+
log(` Its title and trigger are shared with the model IN CLEAR, every turn${flags.team ? ", and with everyone in the unit" : ""} - that is how the model knows when to use it. Keep people's names out of them; decisions inside stay tokenized.`);
|
|
3252
|
+
log(` Add decisions: npx @decidio/sdk context add ${slug} D·0412 D·0418 then: npx @decidio/sdk context pull`);
|
|
3253
|
+
return 0;
|
|
3254
|
+
}
|
|
3255
|
+
if (sub === "add" || sub === "remove") {
|
|
3256
|
+
const r = await call(sub === "add" ? "POST" : "DELETE", `/api/context-packs/${slug}/entries`, { ids: pos.slice(1) });
|
|
3257
|
+
if (r.status !== 200)
|
|
3258
|
+
return refused(r);
|
|
3259
|
+
if (flags.json) {
|
|
3260
|
+
log(JSON.stringify(r.body, null, 2));
|
|
3261
|
+
return 0;
|
|
3262
|
+
}
|
|
3263
|
+
if (sub === "add")
|
|
3264
|
+
log(`✔ ${r.body.added?.length ?? 0} added to ${slug} (${r.body.pack?.entries ?? "?"} entries now)${(r.body.added?.length ?? 0) < pos.length - 1 ? " - the rest were already in it" : ""}`);
|
|
3265
|
+
else
|
|
3266
|
+
log(`✔ ${r.body.removed ?? 0} removed from ${slug} (${r.body.pack?.entries ?? "?"} entries now)`);
|
|
3267
|
+
return 0;
|
|
3268
|
+
}
|
|
3269
|
+
if (sub === "trigger") {
|
|
3270
|
+
const r = await call("PATCH", `/api/context-packs/${slug}`, { trigger: pos.slice(1).join(" ") });
|
|
3271
|
+
if (r.status !== 200)
|
|
3272
|
+
return refused(r);
|
|
3273
|
+
if (flags.json) {
|
|
3274
|
+
log(JSON.stringify(r.body, null, 2));
|
|
3275
|
+
return 0;
|
|
3276
|
+
}
|
|
3277
|
+
log(`✔ ${slug} now fires: ${r.body.pack?.trigger}`);
|
|
3278
|
+
log(` Re-pull to update the skill files: npx @decidio/sdk context pull`);
|
|
3279
|
+
return 0;
|
|
3280
|
+
}
|
|
3281
|
+
if (sub === "always") {
|
|
3282
|
+
const on = pos[1] === "on";
|
|
3283
|
+
const r = await call("PATCH", `/api/context-packs/${slug}`, { always: on });
|
|
3284
|
+
if (r.status !== 200)
|
|
3285
|
+
return refused(r);
|
|
3286
|
+
if (flags.json) {
|
|
3287
|
+
log(JSON.stringify(r.body, null, 2));
|
|
3288
|
+
return 0;
|
|
3289
|
+
}
|
|
3290
|
+
if (on) {
|
|
3291
|
+
log(`✔ ${slug} is now read on every question: pull writes it as a Claude Code rules file (.claude/rules/${PACK_SKILL_PREFIX}${slug}.md, loaded whole at launch), not a skill. Keep it short - it is in context every turn.`);
|
|
3292
|
+
}
|
|
3293
|
+
else
|
|
3294
|
+
log(`✔ ${slug} now fires on its trigger only (pull writes it back as a skill and removes the rules file)`);
|
|
3295
|
+
log(` Re-pull to update the files: npx @decidio/sdk context pull`);
|
|
3296
|
+
return 0;
|
|
3297
|
+
}
|
|
3298
|
+
if (sub === "delete") {
|
|
3299
|
+
const r = await call("DELETE", `/api/context-packs/${slug}`);
|
|
3300
|
+
if (r.status !== 200)
|
|
3301
|
+
return refused(r);
|
|
3302
|
+
log(`✔ deleted pack ${slug} (its pulled skill files stay until you remove them: .claude/skills/${PACK_SKILL_PREFIX}${slug}/ here or in ${joinPath(claudeHome(), ".claude", "skills")}; an always-pack's .claude/rules/${PACK_SKILL_PREFIX}${slug}.md likewise)`);
|
|
3303
|
+
return 0;
|
|
3304
|
+
}
|
|
3305
|
+
if (sub === "bind") {
|
|
3306
|
+
const r = await call("GET", `/api/context-packs/here?unit=${encodeURIComponent(flags.unit)}`);
|
|
3307
|
+
if (r.status !== 200)
|
|
3308
|
+
return refused(r);
|
|
3309
|
+
const tmpCfg = `.decidio.json.${process.pid}.tmp`;
|
|
3310
|
+
writeFileSync(tmpCfg, JSON.stringify({ unit: flags.unit }, null, 2) + "\n", { flag: "wx" });
|
|
3311
|
+
renameSync(tmpCfg, ".decidio.json");
|
|
3312
|
+
const chain = Array.isArray(r.body?.scope?.chain) ? r.body.scope.chain : [];
|
|
3313
|
+
log(`✔ wrote .decidio.json: this folder is ${chain.length ? chain.map((u) => packScopeLabel({ kind: "unit", unitType: u.type, unitName: u.name })).join(" → ") : `unit ${flags.unit}`}`);
|
|
3314
|
+
log(` Commit it so the team shares the scope; then: npx @decidio/sdk context pull --here`);
|
|
3315
|
+
return 0;
|
|
3316
|
+
}
|
|
3317
|
+
let hereScope = null;
|
|
3318
|
+
let r;
|
|
3319
|
+
if (flags.here) {
|
|
3320
|
+
const q = hereQuery();
|
|
3321
|
+
if (q.error) {
|
|
3322
|
+
err(`✘ ${q.error}`);
|
|
3323
|
+
return 1;
|
|
3324
|
+
}
|
|
3325
|
+
r = await call("GET", `/api/context-packs/here${q.unit ? `?unit=${encodeURIComponent(q.unit)}` : q.repo ? `?repo=${encodeURIComponent(q.repo)}` : ""}`);
|
|
3326
|
+
if (r.status !== 200)
|
|
3327
|
+
return refused(r);
|
|
3328
|
+
hereScope = r.body?.scope ?? null;
|
|
3329
|
+
}
|
|
3330
|
+
else {
|
|
3331
|
+
r = await call("GET", "/api/context-packs");
|
|
3332
|
+
if (r.status !== 200)
|
|
3333
|
+
return refused(r);
|
|
3334
|
+
}
|
|
3335
|
+
let packs = Array.isArray(r.body?.packs) ? r.body.packs : [];
|
|
3336
|
+
if (flags.pack) {
|
|
3337
|
+
packs = packs.filter((p) => p.slug === flags.pack);
|
|
3338
|
+
if (!packs.length) {
|
|
3339
|
+
err(`✘ no context pack of yours is called "${flags.pack}" (npx @decidio/sdk context list shows them).`);
|
|
3340
|
+
return 1;
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
if (!packs.length && !flags.here) {
|
|
3344
|
+
log(`No context packs to pull. Create one: npx @decidio/sdk context create <slug> --title "…" --trigger "whenever we discuss …"`);
|
|
3345
|
+
return 0;
|
|
3346
|
+
}
|
|
3347
|
+
packs = [...packs.filter((p) => p?.always === true), ...packs.filter((p) => p?.always !== true)];
|
|
3348
|
+
const written = [];
|
|
3349
|
+
let wroteProject = false;
|
|
3350
|
+
for (const p of packs) {
|
|
3351
|
+
if (typeof p?.slug !== "string" || !PACK_SLUG_RE.test(p.slug)) {
|
|
3352
|
+
err(`✘ refusing a pack whose slug is not a slug (${JSON.stringify(String(p?.slug)).slice(0, 60)}) - nothing written for it.`);
|
|
3353
|
+
continue;
|
|
3354
|
+
}
|
|
3355
|
+
const ix = await call("GET", `/api/context-packs/${p.slug}/index`);
|
|
3356
|
+
if (ix.status !== 200)
|
|
3357
|
+
return refused(ix);
|
|
3358
|
+
const idx = ix.body;
|
|
3359
|
+
const always = p.always === true;
|
|
3360
|
+
idx.scope = p.scope ?? null;
|
|
3361
|
+
if (flags.chatgpt) {
|
|
3362
|
+
log(packChatgptBlock({ ...idx, always }));
|
|
3363
|
+
log("");
|
|
3364
|
+
continue;
|
|
3365
|
+
}
|
|
3366
|
+
const project = p.audience === "team" || p.audience === "org";
|
|
3367
|
+
if (project && (!plainProjectPath(["skills", PACK_SKILL_PREFIX + p.slug, "SKILL.md"]) || !plainProjectPath(["rules", PACK_SKILL_PREFIX + p.slug + ".md"]))) {
|
|
3368
|
+
err(`✘ refusing to write pack ${p.slug}: a path under .claude is a symbolic link - nothing written for it.`);
|
|
3369
|
+
continue;
|
|
3370
|
+
}
|
|
3371
|
+
const base = project ? ".claude" : joinPath(claudeHome(), ".claude");
|
|
3372
|
+
const dir = joinPath(base, "skills", PACK_SKILL_PREFIX + p.slug);
|
|
3373
|
+
const rules = joinPath(base, "rules", PACK_SKILL_PREFIX + p.slug + ".md");
|
|
3374
|
+
let path;
|
|
3375
|
+
if (always) {
|
|
3376
|
+
mkdirSync(joinPath(base, "rules"), { recursive: true });
|
|
3377
|
+
writeFileSync(rules, packRulesMarkdown({ ...idx, always }));
|
|
3378
|
+
rmSync(joinPath(dir, "SKILL.md"), { force: true });
|
|
3379
|
+
try {
|
|
3380
|
+
rmdirSync(dir);
|
|
3381
|
+
}
|
|
3382
|
+
catch { }
|
|
3383
|
+
path = rules;
|
|
3384
|
+
}
|
|
3385
|
+
else {
|
|
3386
|
+
mkdirSync(dir, { recursive: true });
|
|
3387
|
+
writeFileSync(joinPath(dir, "SKILL.md"), packSkillMarkdown(idx));
|
|
3388
|
+
rmSync(rules, { force: true });
|
|
3389
|
+
path = joinPath(dir, "SKILL.md");
|
|
3390
|
+
}
|
|
3391
|
+
if (project)
|
|
3392
|
+
wroteProject = true;
|
|
3393
|
+
written.push({ slug: p.slug, path, rows: Array.isArray(idx.rows) ? idx.rows.length : 0, omitted: Number(idx.omitted ?? 0), hash: String(idx.hash), always });
|
|
3394
|
+
}
|
|
3395
|
+
if (flags.chatgpt) {
|
|
3396
|
+
err(`· paste each block above into the ChatGPT Project's instructions (or wherever that host keeps standing context); the Decidio connector's get_context_pack returns the same list live.`);
|
|
3397
|
+
return 0;
|
|
3398
|
+
}
|
|
3399
|
+
if (wroteProject)
|
|
3400
|
+
ensurePackSkillsIgnored();
|
|
3401
|
+
const removed = [];
|
|
3402
|
+
if (flags.here && !flags.pack) {
|
|
3403
|
+
const keep = new Set(written.map((w) => w.slug));
|
|
3404
|
+
const skillsDir = joinPath(".claude", "skills"), rulesDir = joinPath(".claude", "rules");
|
|
3405
|
+
const readText = (path) => { try {
|
|
3406
|
+
return readFileSync(path, "utf8");
|
|
3407
|
+
}
|
|
3408
|
+
catch {
|
|
3409
|
+
return "";
|
|
3410
|
+
} };
|
|
3411
|
+
if (existsSync(skillsDir) && plainProjectPath(["skills"]))
|
|
3412
|
+
for (const d of readdirSync(skillsDir)) {
|
|
3413
|
+
const slugOf = d.startsWith(PACK_SKILL_PREFIX) ? d.slice(PACK_SKILL_PREFIX.length) : "";
|
|
3414
|
+
const file = joinPath(skillsDir, d, "SKILL.md");
|
|
3415
|
+
if (!PACK_SLUG_RE.test(slugOf) || keep.has(slugOf) || !plainProjectPath(["skills", d, "SKILL.md"]) || !existsSync(file))
|
|
3416
|
+
continue;
|
|
3417
|
+
if (!isPackFileOf(readText(file), slugOf, "skill") || gitTracked(file))
|
|
3418
|
+
continue;
|
|
3419
|
+
rmSync(file, { force: true });
|
|
3420
|
+
try {
|
|
3421
|
+
rmdirSync(joinPath(skillsDir, d));
|
|
3422
|
+
}
|
|
3423
|
+
catch { }
|
|
3424
|
+
removed.push(slugOf);
|
|
3425
|
+
}
|
|
3426
|
+
if (existsSync(rulesDir) && plainProjectPath(["rules"]))
|
|
3427
|
+
for (const f of readdirSync(rulesDir)) {
|
|
3428
|
+
const m = /^decidio-([a-z0-9][a-z0-9-]{0,39})\.md$/.exec(f);
|
|
3429
|
+
const file = joinPath(rulesDir, f);
|
|
3430
|
+
if (!m || keep.has(m[1]) || !plainProjectPath(["rules", f]) || !isPackFileOf(readText(file), m[1], "rules") || gitTracked(file))
|
|
3431
|
+
continue;
|
|
3432
|
+
rmSync(file, { force: true });
|
|
3433
|
+
if (!removed.includes(m[1]))
|
|
3434
|
+
removed.push(m[1]);
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
if (flags.json) {
|
|
3438
|
+
log(JSON.stringify({ written, ...(flags.here ? { scope: hereScope, removed } : {}) }, null, 2));
|
|
3439
|
+
return 0;
|
|
3440
|
+
}
|
|
3441
|
+
if (flags.here) {
|
|
3442
|
+
const chain = Array.isArray(hereScope?.chain) ? hereScope.chain : [];
|
|
3443
|
+
const where = chain.length ? chain.map((u) => packScopeLabel({ kind: "unit", unitType: u.type, unitName: u.name })).join(" → ") : hereScope?.source === "repo" ? "a repository in no unit of Decidio" : "no unit";
|
|
3444
|
+
log(`Here: the organisation → ${where}${!chain.length ? " (the organisation's packs and your own)" : ""}`);
|
|
3445
|
+
if (!written.length)
|
|
3446
|
+
log(`No context packs apply here.`);
|
|
3447
|
+
}
|
|
3448
|
+
for (const w of written)
|
|
3449
|
+
log(`✔ ${w.path.split(pathSep).join("/")} (${w.rows} ${w.rows === 1 ? "entry" : "entries"}${w.omitted ? `, ${w.omitted} you cannot read omitted` : ""}; hash ${w.hash}${w.always ? "; read on every question" : ""})`);
|
|
3450
|
+
if (removed.length)
|
|
3451
|
+
log(`✔ removed ${removed.length} pack file${removed.length === 1 ? "" : "s"} that ${removed.length === 1 ? "does" : "do"} not apply here: ${removed.join(", ")}`);
|
|
3452
|
+
log(` Claude Code loads each pack's trigger line every turn and the list only when the trigger applies${written.some((w) => w.always) ? "; an always-pack (.claude/rules) is loaded whole at launch" : ""}. Re-run after curating.`);
|
|
3453
|
+
return 0;
|
|
3454
|
+
}
|
|
3455
|
+
const ADR_TAKE = new Set(["accepted", "approved", "adopted", "decided"]);
|
|
3456
|
+
export function parseAdr(text) {
|
|
3457
|
+
let body = text.replace(/\r\n/g, "\n");
|
|
3458
|
+
let status = null;
|
|
3459
|
+
const fm = /^---\n([\s\S]*?)\n---\n/.exec(body);
|
|
3460
|
+
if (fm) {
|
|
3461
|
+
const st = /^status:\s*["']?([A-Za-z]+)/im.exec(fm[1]);
|
|
3462
|
+
if (st)
|
|
3463
|
+
status = st[1].toLowerCase();
|
|
3464
|
+
body = body.slice(fm[0].length);
|
|
3465
|
+
}
|
|
3466
|
+
const titleLine = /^#\s+(.+)$/m.exec(body);
|
|
3467
|
+
if (!titleLine)
|
|
3468
|
+
return null;
|
|
3469
|
+
const title = titleLine[1].replace(/^(?:ADR[-\s]?)?\d+[:.\-)]?\s*/i, "").replace(/[*_`]/g, "").trim().slice(0, 200);
|
|
3470
|
+
if (!title)
|
|
3471
|
+
return null;
|
|
3472
|
+
const sections = new Map();
|
|
3473
|
+
const parts = body.split(/^##\s+/m).slice(1);
|
|
3474
|
+
for (const part of parts) {
|
|
3475
|
+
const nl = part.indexOf("\n");
|
|
3476
|
+
const head = (nl < 0 ? part : part.slice(0, nl)).trim().toLowerCase();
|
|
3477
|
+
sections.set(head, (nl < 0 ? "" : part.slice(nl + 1)).trim());
|
|
3478
|
+
}
|
|
3479
|
+
const pick = (...names) => { for (const [h, v] of sections)
|
|
3480
|
+
if (names.some((n) => h === n || h.startsWith(n)))
|
|
3481
|
+
return v; return ""; };
|
|
3482
|
+
if (!status) {
|
|
3483
|
+
const inline = /^\s*\**status\**\s*:\s*\**\s*([A-Za-z]+)/im.exec(body);
|
|
3484
|
+
const sec = pick("status").split("\n").map((l) => l.replace(/[*_`\-]/g, "").trim()).find(Boolean);
|
|
3485
|
+
status = (inline?.[1] ?? sec?.split(/\s+/)[0] ?? "").toLowerCase() || null;
|
|
3486
|
+
}
|
|
3487
|
+
const clean = (t, max) => t.replace(/\n{3,}/g, "\n\n").trim().slice(0, max);
|
|
3488
|
+
const consequences = pick("consequences");
|
|
3489
|
+
const context = pick("context and problem statement", "context");
|
|
3490
|
+
const rationale = clean([context, consequences ? `Consequences: ${consequences}` : ""].filter(Boolean).join("\n\n"), 4000);
|
|
3491
|
+
const decided = clean(pick("decision outcome", "decision"), 4000);
|
|
3492
|
+
const alternatives = pick("considered options", "options considered", "alternatives", "options").split("\n")
|
|
3493
|
+
.map((l) => /^\s*(?:[-*+]|\d+\.)\s+(.+)$/.exec(l)?.[1]?.replace(/[*_`]/g, "").trim() ?? "").filter(Boolean).slice(0, 30).map((x) => x.slice(0, 1000));
|
|
3494
|
+
return { title, status, decided, rationale, alternatives };
|
|
3495
|
+
}
|
|
3496
|
+
async function cmdDecisionsImport(dir, dryRun) {
|
|
3497
|
+
if (!dir) {
|
|
3498
|
+
err("✘ import needs --adr <folder of ADR markdown files>, e.g. --adr docs/adr");
|
|
3499
|
+
return 2;
|
|
3500
|
+
}
|
|
3501
|
+
const abs = resolvePath(dir);
|
|
3502
|
+
let files;
|
|
3503
|
+
try {
|
|
3504
|
+
files = readdirSync(abs).filter((f) => /\.md$/i.test(f) && !/^(readme|index|template)/i.test(f)).sort().slice(0, 200);
|
|
3505
|
+
}
|
|
3506
|
+
catch {
|
|
3507
|
+
err(`✘ ${dir} is not a folder this command can read.`);
|
|
3508
|
+
return 1;
|
|
3509
|
+
}
|
|
3510
|
+
let repoKey = "";
|
|
3511
|
+
try {
|
|
3512
|
+
repoKey = execFileSync("git", ["remote", "get-url", "origin"], { cwd: abs, timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
|
|
3513
|
+
}
|
|
3514
|
+
catch {
|
|
3515
|
+
repoKey = "";
|
|
3516
|
+
}
|
|
3517
|
+
let root = abs;
|
|
3518
|
+
try {
|
|
3519
|
+
root = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: abs, timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim() || abs;
|
|
3520
|
+
}
|
|
3521
|
+
catch {
|
|
3522
|
+
root = abs;
|
|
3523
|
+
}
|
|
3524
|
+
const take = [], skipped = [];
|
|
3525
|
+
for (const f of files) {
|
|
3526
|
+
const p = joinPath(abs, f);
|
|
3527
|
+
try {
|
|
3528
|
+
if (lstatSync(p).isSymbolicLink() || statSync(p).size > 200_000) {
|
|
3529
|
+
skipped.push(`${f} (not a plain file under 200 KB)`);
|
|
3530
|
+
continue;
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
catch {
|
|
3534
|
+
continue;
|
|
3535
|
+
}
|
|
3536
|
+
const adr = parseAdr(readFileSync(p, "utf8"));
|
|
3537
|
+
if (!adr) {
|
|
3538
|
+
skipped.push(`${f} (no "# title")`);
|
|
3539
|
+
continue;
|
|
3540
|
+
}
|
|
3541
|
+
if (adr.status && !ADR_TAKE.has(adr.status)) {
|
|
3542
|
+
skipped.push(`${f} (${adr.status})`);
|
|
3543
|
+
continue;
|
|
3544
|
+
}
|
|
3545
|
+
if (!adr.decided && !adr.rationale) {
|
|
3546
|
+
skipped.push(`${f} (no decision or context section)`);
|
|
3547
|
+
continue;
|
|
3548
|
+
}
|
|
3549
|
+
const rel = relative(root, p).split(pathSep).join("/");
|
|
3550
|
+
take.push({ file: f, adr, key: `adr:${createHash("sha256").update(`${repoKey || root}\u0000${rel}`).digest("hex").slice(0, 16)}` });
|
|
3551
|
+
}
|
|
3552
|
+
if (dryRun) {
|
|
3553
|
+
for (const t of take)
|
|
3554
|
+
log(`would import ${t.file}: "${t.adr.title}"${t.adr.alternatives.length ? ` (${t.adr.alternatives.length} options considered)` : ""}`);
|
|
3555
|
+
for (const s of skipped)
|
|
3556
|
+
log(`would skip ${s}`);
|
|
3557
|
+
log(`${take.length} to import as drafts, ${skipped.length} skipped. Nothing was sent.`);
|
|
3558
|
+
return 0;
|
|
3559
|
+
}
|
|
3560
|
+
if (!take.length) {
|
|
3561
|
+
log(`Nothing to import (${skipped.length} skipped${skipped.length ? `: ${skipped.slice(0, 5).join("; ")}` : ""}).`);
|
|
3562
|
+
return 0;
|
|
3563
|
+
}
|
|
3564
|
+
const session = await acquireSession("Importing decision records");
|
|
3565
|
+
if (!session)
|
|
3566
|
+
return 1;
|
|
3567
|
+
const call = async (method, path, body) => api(path, { method, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }, session);
|
|
3568
|
+
const me = await call("GET", "/api/me");
|
|
3569
|
+
const userId = typeof me.body?.userId === "string" ? me.body.userId : null;
|
|
3570
|
+
if (!userId) {
|
|
3571
|
+
err("✘ sign in as yourself first (npx @decidio/sdk login) - an imported draft is owned by the person who imports it.");
|
|
3572
|
+
return 1;
|
|
3573
|
+
}
|
|
3574
|
+
let made = 0, again = 0, failed = 0;
|
|
3575
|
+
for (const t of take) {
|
|
3576
|
+
const c = await call("POST", "/api/decisions", { type: "deliberation", title: t.adr.title, ownerId: userId, draft: true, sourceSystem: "adr", sourceExternalId: t.key, tags: ["adr"] });
|
|
3577
|
+
if (c.status === 409 && c.body?.code === "ALREADY_IMPORTED") {
|
|
3578
|
+
again++;
|
|
3579
|
+
log(`· ${t.file}: imported before - kept as it is`);
|
|
3580
|
+
continue;
|
|
3581
|
+
}
|
|
3582
|
+
const id = typeof c.body?.id === "string" ? c.body.id : null;
|
|
3583
|
+
if (c.status !== 201 || !id) {
|
|
3584
|
+
failed++;
|
|
3585
|
+
err(`✘ ${t.file}: ${c.body?.message ?? c.body?.error ?? `HTTP ${c.status}`}`);
|
|
3586
|
+
continue;
|
|
3587
|
+
}
|
|
3588
|
+
const body = {};
|
|
3589
|
+
if (t.adr.decided)
|
|
3590
|
+
body.decided = t.adr.decided;
|
|
3591
|
+
if (t.adr.rationale)
|
|
3592
|
+
body.rationale = t.adr.rationale;
|
|
3593
|
+
if (t.adr.alternatives.length)
|
|
3594
|
+
body.alternatives = t.adr.alternatives;
|
|
3595
|
+
const w = await call("POST", `/api/decisions/${encodeURIComponent(id)}/complete`, body);
|
|
3596
|
+
if (w.status >= 300) {
|
|
3597
|
+
failed++;
|
|
3598
|
+
err(`✘ ${t.file}: the draft was made but its text was not saved (${w.body?.message ?? `HTTP ${w.status}`}) - open it in Decidio`);
|
|
3599
|
+
continue;
|
|
3600
|
+
}
|
|
3601
|
+
made++;
|
|
3602
|
+
log(`✔ ${t.file} → a draft: "${t.adr.title}"`);
|
|
3603
|
+
}
|
|
3604
|
+
log(`${made} imported as drafts, ${again} imported before, ${failed} failed, ${skipped.length} skipped. Review and seal them in Decidio (the Queue): only sealed decisions are cited.`);
|
|
3605
|
+
return failed ? 1 : 0;
|
|
3606
|
+
}
|
|
3607
|
+
const GH_ENV_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
3608
|
+
const GH_DECISION_RE = /^[A-Za-z0-9_-]{8,64}$/;
|
|
3609
|
+
const GH_WORKFLOW_RE = /^\.github\/workflows\/[A-Za-z0-9._-]{1,100}\.ya?ml$/;
|
|
3610
|
+
const GH_JUSTIFICATION_MAX = 280;
|
|
3611
|
+
export const AGENTS_MD_START = "<!-- decidio:start -->";
|
|
3612
|
+
export const AGENTS_MD_END = "<!-- decidio:end -->";
|
|
3613
|
+
async function actionsOidcToken() {
|
|
3614
|
+
const url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL, tok = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
3615
|
+
if (!url || !tok) {
|
|
3616
|
+
err("✘ no GitHub OIDC token here: run this inside a GitHub Actions job whose workflow sets `permissions: id-token: write`.");
|
|
3617
|
+
return null;
|
|
3618
|
+
}
|
|
3619
|
+
const aud = (process.env.DECIDIO_GITHUB_OIDC_AUDIENCE ?? "decidio").trim() || "decidio";
|
|
3620
|
+
let u;
|
|
3621
|
+
try {
|
|
3622
|
+
u = new URL(url);
|
|
3623
|
+
}
|
|
3624
|
+
catch {
|
|
3625
|
+
err("✘ ACTIONS_ID_TOKEN_REQUEST_URL is not a URL - nothing was sent.");
|
|
3626
|
+
return null;
|
|
3627
|
+
}
|
|
3628
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost"))) {
|
|
3629
|
+
err("✘ GitHub's token service must be https - nothing was sent.");
|
|
3630
|
+
return null;
|
|
3631
|
+
}
|
|
3632
|
+
u.searchParams.set("audience", aud);
|
|
3633
|
+
try {
|
|
3634
|
+
const r = await fetch(u, { headers: { authorization: `bearer ${tok}`, accept: "application/json" } });
|
|
3635
|
+
const j = await r.json().catch(() => null);
|
|
3636
|
+
if (!r.ok || typeof j?.value !== "string" || !j.value) {
|
|
3637
|
+
err(`✘ GitHub did not issue an OIDC token (${r.status}) - nothing was sent to Decidio.`);
|
|
3638
|
+
return null;
|
|
3639
|
+
}
|
|
3640
|
+
return j.value;
|
|
3641
|
+
}
|
|
3642
|
+
catch (e) {
|
|
3643
|
+
err(`✘ could not reach GitHub's token service (${e?.cause?.code ?? e?.message ?? "network error"}) - nothing was sent to Decidio.`);
|
|
3644
|
+
return null;
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
async function cmdGithubRequest(environment, justification) {
|
|
3648
|
+
if (!environment || !GH_ENV_RE.test(environment)) {
|
|
3649
|
+
err("✘ request needs --environment <name>: one of the environments the repository was bound with (letters, digits, . _ -).");
|
|
3650
|
+
return 2;
|
|
3651
|
+
}
|
|
3652
|
+
if (justification !== undefined && (!justification.trim() || justification.length > GH_JUSTIFICATION_MAX || /[\r\n]/.test(justification))) {
|
|
3653
|
+
err(`✘ --justification is one line of up to ${GH_JUSTIFICATION_MAX} characters.`);
|
|
3654
|
+
return 2;
|
|
3655
|
+
}
|
|
3656
|
+
const token = await actionsOidcToken();
|
|
3657
|
+
if (!token)
|
|
3658
|
+
return 1;
|
|
3659
|
+
const r = await api("/api/github/runs/request", { method: "POST", headers: { "x-decidio-client": `sdk/${VERSION}` }, body: JSON.stringify({ environment, ...(justification ? { justification: justification.trim() } : {}) }) }, token);
|
|
3660
|
+
const b = (r.body && typeof r.body === "object" ? r.body : {});
|
|
3661
|
+
const ref = `${b.displayNumber ?? b.decisionId ?? ""}${b.url ? ` ${b.url}` : ""}`.trim();
|
|
3662
|
+
if (r.status === 200 && (b.decision === "route" || b.decision === "proceed")) {
|
|
3663
|
+
log(b.decision === "proceed"
|
|
3664
|
+
? `✔ ${environment}: no approval needed - Decidio starts the deploy (${ref})`
|
|
3665
|
+
: `✔ asked: a teammate approves ${environment} in Decidio, then Decidio starts the deploy. This job is done (${ref})`);
|
|
3666
|
+
if (b.existing)
|
|
3667
|
+
log(" An approval request for this commit was already open - that one stands.");
|
|
3668
|
+
return 0;
|
|
3669
|
+
}
|
|
3670
|
+
if (r.status === 200 && b.decision === "duplicate") {
|
|
3671
|
+
err(`✘ ${String(b.error ?? "a person refused this commit before")}`);
|
|
3672
|
+
err(` To ask again, say what changed: npx @decidio/sdk github request --environment ${environment} --justification "<what changed>"`);
|
|
3673
|
+
return 1;
|
|
3674
|
+
}
|
|
3675
|
+
if (r.status === 200 && b.decision === "block") {
|
|
3676
|
+
err(`✘ the workspace's policy blocks this deploy (${ref}).`);
|
|
3677
|
+
return 1;
|
|
3678
|
+
}
|
|
3679
|
+
err(`✘ Decidio refused the request (${r.status}${b.code ? ` ${b.code}` : ""}): ${String(b.error ?? "no reason given")}`);
|
|
3680
|
+
return 1;
|
|
3681
|
+
}
|
|
3682
|
+
async function cmdGithubVerify(decisionArg) {
|
|
3683
|
+
const decision = (decisionArg ?? process.env.DECIDIO_DECISION ?? "").trim();
|
|
3684
|
+
if (!GH_DECISION_RE.test(decision)) {
|
|
3685
|
+
err("✘ verify needs the decision id Decidio dispatched: set DECIDIO_DECISION to ${{ inputs.decidio }} (or pass --decision). Do not deploy.");
|
|
3686
|
+
return 2;
|
|
3687
|
+
}
|
|
3688
|
+
const token = await actionsOidcToken();
|
|
3689
|
+
if (!token) {
|
|
3690
|
+
err(" Do not deploy.");
|
|
3691
|
+
return 1;
|
|
3692
|
+
}
|
|
3693
|
+
const r = await api("/api/github/runs/verify", { method: "POST", headers: { "x-decidio-client": `sdk/${VERSION}` }, body: JSON.stringify({ decision }) }, token);
|
|
3694
|
+
const b = (r.body && typeof r.body === "object" ? r.body : {});
|
|
3695
|
+
if (r.status === 200 && b.ok === true && typeof b.sha === "string" && /^[0-9a-f]{40}$/.test(b.sha)) {
|
|
3696
|
+
const out = process.env.GITHUB_OUTPUT;
|
|
3697
|
+
if (out)
|
|
3698
|
+
appendFileSync(out, `sha=${b.sha}\ndecision=${decision}\n`);
|
|
3699
|
+
log(`✔ approved: deploy ${b.sha.slice(0, 12)} to ${String(b.environment ?? "")} (${b.displayNumber ?? decision}). This approval is now used.`);
|
|
3700
|
+
return 0;
|
|
3701
|
+
}
|
|
3702
|
+
err(`✘ not approved - do not deploy (${r.status}${b.code ? ` ${b.code}` : ""}): ${String(b.error ?? "no reason given")}`);
|
|
3703
|
+
return 1;
|
|
3704
|
+
}
|
|
3705
|
+
function plainRepoPath(root, rel) {
|
|
3706
|
+
let p = root;
|
|
3707
|
+
for (const part of rel.split("/")) {
|
|
3708
|
+
p = joinPath(p, part);
|
|
3709
|
+
try {
|
|
3710
|
+
if (lstatSync(p).isSymbolicLink())
|
|
3711
|
+
return false;
|
|
3712
|
+
}
|
|
3713
|
+
catch { }
|
|
3714
|
+
}
|
|
3715
|
+
return true;
|
|
3716
|
+
}
|
|
3717
|
+
export const DECIDIO_ACTION = "OmniTwin/decidio-actions@e40ba8e75a223f2475038d38a16df5a5885afde4 # v1.0.0";
|
|
3718
|
+
export const CHECKOUT_ACTION = "actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0";
|
|
3719
|
+
export function githubRequestWorkflow(api, environment, branch = "main") {
|
|
3720
|
+
return `# Written by npx @decidio/sdk github init. Asks a teammate to approve each deploy; Decidio starts the deploy workflow once
|
|
3721
|
+
# approved. No repository secret: the job's own OIDC token is the credential.
|
|
3722
|
+
name: Ask Decidio to deploy
|
|
3723
|
+
on:
|
|
3724
|
+
push:
|
|
3725
|
+
branches: [${branch}]
|
|
3726
|
+
permissions:
|
|
3727
|
+
id-token: write
|
|
3728
|
+
contents: read
|
|
3729
|
+
jobs:
|
|
3730
|
+
ask:
|
|
3731
|
+
runs-on: ubuntu-latest
|
|
3732
|
+
steps:
|
|
3733
|
+
- uses: ${DECIDIO_ACTION}
|
|
3734
|
+
with:
|
|
3735
|
+
step: request
|
|
3736
|
+
environment: ${environment}
|
|
3737
|
+
api-url: ${api}
|
|
3738
|
+
`;
|
|
3739
|
+
}
|
|
3740
|
+
export function githubVerifySteps(api) {
|
|
3741
|
+
return ` - id: decidio
|
|
3742
|
+
uses: ${DECIDIO_ACTION}
|
|
3743
|
+
with:
|
|
3744
|
+
step: verify
|
|
3745
|
+
decision: \${{ inputs.decidio }}
|
|
3746
|
+
api-url: ${api}
|
|
3747
|
+
- uses: ${CHECKOUT_ACTION}
|
|
3748
|
+
with:
|
|
3749
|
+
ref: \${{ steps.decidio.outputs.sha }}
|
|
3750
|
+
`;
|
|
3751
|
+
}
|
|
3752
|
+
export function githubDeployWorkflow(api, environment) {
|
|
3753
|
+
return `# Written by npx @decidio/sdk github init. Decidio starts this workflow once the deploy is approved; its first step proves the
|
|
3754
|
+
# approval (once, for this commit and environment) before anything deploys. Restrict who may run it to the Decidio app in the
|
|
3755
|
+
# repository's workflow execution protections.
|
|
3756
|
+
name: Deploy
|
|
3757
|
+
run-name: Decidio \${{ inputs.decidio }}
|
|
3758
|
+
on:
|
|
3759
|
+
workflow_dispatch:
|
|
3760
|
+
inputs:
|
|
3761
|
+
decidio:
|
|
3762
|
+
description: Set by Decidio
|
|
3763
|
+
required: true
|
|
3764
|
+
permissions:
|
|
3765
|
+
id-token: write
|
|
3766
|
+
contents: read
|
|
3767
|
+
jobs:
|
|
3768
|
+
deploy:
|
|
3769
|
+
runs-on: ubuntu-latest
|
|
3770
|
+
environment: ${environment}
|
|
3771
|
+
steps:
|
|
3772
|
+
${githubVerifySteps(api)} - name: Deploy
|
|
3773
|
+
run: ./deploy.sh # your deploy command
|
|
3774
|
+
`;
|
|
3775
|
+
}
|
|
3776
|
+
export function agentsMdSection(appUrl) {
|
|
3777
|
+
return `${AGENTS_MD_START}
|
|
3778
|
+
## Decisions (Decidio)
|
|
3779
|
+
|
|
3780
|
+
This repository's sealed decisions live in Decidio (${appUrl}); sign in to read the ones you may see. An agent connected to
|
|
3781
|
+
Decidio's MCP server calls \`check_plan\` with its plan before acting: it answers which decisions the plan would contradict.
|
|
3782
|
+
Nothing of a decision is kept in this file.
|
|
3783
|
+
${AGENTS_MD_END}
|
|
3784
|
+
`;
|
|
3785
|
+
}
|
|
3786
|
+
export function withAgentsMdSection(text, section) {
|
|
3787
|
+
const m = new RegExp(`${AGENTS_MD_START}\\r?\\n## Decisions \\(Decidio\\)\\r?\\n`).exec(text);
|
|
3788
|
+
const a = m ? m.index : -1, b = m ? text.indexOf(AGENTS_MD_END, a + m[0].length) : -1;
|
|
3789
|
+
if (a >= 0 && b > a)
|
|
3790
|
+
return text.slice(0, a) + section.trimEnd() + text.slice(b + AGENTS_MD_END.length);
|
|
3791
|
+
return `${text}${text && !text.endsWith("\n") ? "\n" : ""}${text ? "\n" : ""}${section}`;
|
|
3792
|
+
}
|
|
3793
|
+
async function cmdGithubInit(opts) {
|
|
3794
|
+
const environment = opts.environment ?? "production", workflow = opts.workflow ?? ".github/workflows/deploy.yml";
|
|
3795
|
+
if (!GH_ENV_RE.test(environment)) {
|
|
3796
|
+
err("✘ --environment is the environment the deploy job declares (letters, digits, . _ -).");
|
|
3797
|
+
return 2;
|
|
3798
|
+
}
|
|
3799
|
+
if (!GH_WORKFLOW_RE.test(workflow)) {
|
|
3800
|
+
err("✘ --workflow is the deploy workflow's path, .github/workflows/<name>.yml");
|
|
3801
|
+
return 2;
|
|
3802
|
+
}
|
|
3803
|
+
const apiUrl = (opts.api ?? process.env.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
|
|
3804
|
+
try {
|
|
3805
|
+
assertSafeTransport(apiUrl, true);
|
|
3806
|
+
}
|
|
3807
|
+
catch (e) {
|
|
3808
|
+
err(`✘ ${String(e?.message ?? e).replace(/^\[decidio\] /, "")}`);
|
|
3809
|
+
return 2;
|
|
3810
|
+
}
|
|
3811
|
+
if (!/^https?:\/\/[A-Za-z0-9.-]+(?::\d+)?$/.test(apiUrl)) {
|
|
3812
|
+
err("✘ --api is the Decidio API's origin, e.g. https://decidio-api.onrender.com");
|
|
3813
|
+
return 2;
|
|
3814
|
+
}
|
|
3815
|
+
let root;
|
|
3816
|
+
try {
|
|
3817
|
+
root = execFileSync("git", ["rev-parse", "--show-toplevel"], { timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
|
|
3818
|
+
}
|
|
3819
|
+
catch {
|
|
3820
|
+
err("✘ run this inside the repository's checkout (git could not find its root).");
|
|
3821
|
+
return 1;
|
|
3822
|
+
}
|
|
3823
|
+
if (!root) {
|
|
3824
|
+
err("✘ run this inside the repository's checkout (git could not find its root).");
|
|
3825
|
+
return 1;
|
|
3826
|
+
}
|
|
3827
|
+
let branch = "main";
|
|
3828
|
+
try {
|
|
3829
|
+
const head = execFileSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: root, timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim().replace(/^origin\//, "");
|
|
3830
|
+
if (/^[A-Za-z0-9._\/-]{1,100}$/.test(head) && !head.includes(".."))
|
|
3831
|
+
branch = head;
|
|
3832
|
+
}
|
|
3833
|
+
catch { }
|
|
3834
|
+
const write = (rel, text) => {
|
|
3835
|
+
if (!plainRepoPath(root, rel))
|
|
3836
|
+
return "refused";
|
|
3837
|
+
const abs = joinPath(root, rel);
|
|
3838
|
+
if (existsSync(abs))
|
|
3839
|
+
return "kept";
|
|
3840
|
+
mkdirSync(joinPath(abs, ".."), { recursive: true });
|
|
3841
|
+
writeFileSync(abs, text, { flag: "wx" });
|
|
3842
|
+
return "written";
|
|
3843
|
+
};
|
|
3844
|
+
const said = (rel, w, what) => w === "written" ? log(`✔ wrote ${rel} - ${what}`) : w === "kept" ? log(`· ${rel} already exists - kept as it is`) : err(`✘ ${rel}: a symbolic link is on its path - nothing was written through it`);
|
|
3845
|
+
const reqRel = ".github/workflows/decidio-request.yml";
|
|
3846
|
+
said(reqRel, write(reqRel, githubRequestWorkflow(apiUrl, environment, branch)), `asks for approval on every push to ${branch} (edit the trigger to suit)`);
|
|
3847
|
+
const dep = write(workflow, githubDeployWorkflow(apiUrl, environment));
|
|
3848
|
+
said(workflow, dep, "the deploy Decidio starts once approved (replace ./deploy.sh with your deploy)");
|
|
3849
|
+
if (dep === "kept") {
|
|
3850
|
+
log(` Your deploy workflow must: run on workflow_dispatch with an input named decidio; set permissions id-token: write;`);
|
|
3851
|
+
log(` declare environment: ${environment}; and make these its FIRST steps (they check out the approved commit):`);
|
|
3852
|
+
for (const line of githubVerifySteps(apiUrl).trimEnd().split("\n"))
|
|
3853
|
+
log(` ${line}`);
|
|
3854
|
+
}
|
|
3855
|
+
const agentsRel = "AGENTS.md", agentsAbs = joinPath(root, agentsRel);
|
|
3856
|
+
const appUrl = (process.env.DECIDIO_APP_URL || apiUrl.replace(/^https:\/\/(decidio-api\.onrender\.com|mcp\.decidioai\.com)$/, "https://app.decidioai.com")).replace(/\/$/, "");
|
|
3857
|
+
const section = agentsMdSection(appUrl);
|
|
3858
|
+
if (!plainRepoPath(root, agentsRel))
|
|
3859
|
+
err(`✘ ${agentsRel} is a symbolic link - nothing was written through it`);
|
|
3860
|
+
else if (existsSync(agentsAbs) || opts.agentsMd) {
|
|
3861
|
+
const before = existsSync(agentsAbs) ? readFileSync(agentsAbs, "utf8") : "";
|
|
3862
|
+
const after = withAgentsMdSection(before, section);
|
|
3863
|
+
if (after !== before) {
|
|
3864
|
+
writeFileSync(agentsAbs, after);
|
|
3865
|
+
log(`✔ ${agentsRel}: a link to Decidio for agents (no decision text)`);
|
|
3866
|
+
}
|
|
3867
|
+
else
|
|
3868
|
+
log(`· ${agentsRel} already links to Decidio`);
|
|
3869
|
+
}
|
|
3870
|
+
else
|
|
3871
|
+
log(`· no AGENTS.md here - add --agents-md to create one with a link to Decidio for agents (no decision text)`);
|
|
3872
|
+
log("");
|
|
3873
|
+
log("Next:");
|
|
3874
|
+
log(` 1. In Decidio: Connect → GitHub approvals → bind this repository with ${workflow} and the environment ${environment}.`);
|
|
3875
|
+
log(` 2. In the repository's settings: restrict who may run ${workflow} to the Decidio app (workflow execution protections),`);
|
|
3876
|
+
log(` so a deploy runs only when Decidio starts it.`);
|
|
3877
|
+
log(` 3. Commit the workflows and push: the first push to ${branch} asks a teammate.`);
|
|
3878
|
+
return 0;
|
|
3879
|
+
}
|
|
3880
|
+
const USAGE = `✦ Decidio — the SDK command line
|
|
3881
|
+
|
|
3882
|
+
The wrapper IS the SDK: guard.protect(fn, describe) ships in this package (import { guard } from "@decidio/sdk").
|
|
3883
|
+
This command line is the tour, the sign-in, and the token admin around it.
|
|
3884
|
+
|
|
3885
|
+
New here? See the whole thing work in about two minutes:
|
|
3886
|
+
|
|
3887
|
+
npx @decidio/sdk quickstart
|
|
3888
|
+
|
|
3889
|
+
With no sign-in on this computer it first asks ONE question - do you have an invite? - and, if you do,
|
|
3890
|
+
what next: the tour, or wiring up your own agent. Every answer shows the command it stands for. The bare
|
|
3891
|
+
npx @decidio/sdk on a terminal asks the same (skipping the invite question when you are signed in).
|
|
3892
|
+
|
|
3893
|
+
It gates a demo action, routes it to YOU for approval, executes on your yes, and hands you a
|
|
3894
|
+
cryptographically verifiable receipt of the DECISION (execution evidence then accrues on the
|
|
3895
|
+
hosted record — the receipt seals who authorized what, not what ran afterward). Guided and
|
|
3896
|
+
explicit — this CLI never acts, signs you in, or writes files unless you ask it to.
|
|
3897
|
+
|
|
3898
|
+
THE TOUR
|
|
3899
|
+
npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
|
|
3900
|
+
(interactive: y approves, n rejects - Enter is not an answer. Headless it stops
|
|
3901
|
+
at the routed decision; --auto-approve-demo for CI, labeled
|
|
3902
|
+
automated; ends with a keep-or-cleanup choice; --explain prints
|
|
3903
|
+
the long narration of each step; --phone web makes the QR open
|
|
3904
|
+
the phone web rendering even when the Decidio app is installed)
|
|
3905
|
+
npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials (the inert
|
|
3906
|
+
registration row and the permanent sealed record stay)
|
|
3907
|
+
(and end the stored sign-in)
|
|
3908
|
+
npx @decidio/sdk quickstart --auto-approve-demo headless tour; cleans up after itself
|
|
3909
|
+
npx @decidio/sdk quickstart --demo the same tour against a LOCAL stand-in of the API — no account needed; the
|
|
3910
|
+
web/phone door is a hosted demo page; the receipt is the one thing kept
|
|
3911
|
+
(--keep-agent is refused here: nothing of the demo's can be kept)
|
|
3912
|
+
|
|
3913
|
+
SIGN IN (once)
|
|
3914
|
+
npx @decidio/sdk login [email] sign in ONCE: the CLI emails you an 8-character code, you type it; the sign-in
|
|
3915
|
+
is kept on this computer 30 days, or until this terminal closes, logout, or cleanup
|
|
3916
|
+
npx @decidio/sdk logout forget the stored sign-in on this computer (the token itself stays valid
|
|
3917
|
+
until it expires — there is no server-side revocation yet)
|
|
3918
|
+
|
|
3919
|
+
YOUR AGENT AND ITS TOKENS
|
|
3920
|
+
npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
|
|
3921
|
+
(agentId defaults to this folder's name; any name works - it is how the
|
|
3922
|
+
workspace shows the request and the key .env is written under)
|
|
3923
|
+
--unique append a random suffix (collision-proof samples)
|
|
3924
|
+
--force replace a different agent already in this .env
|
|
3925
|
+
(its .env is backed up first)
|
|
3926
|
+
--expires-days N agent-token lifetime (default 90)
|
|
3927
|
+
npx @decidio/sdk doctor check config, connectivity, and what your token can do
|
|
3928
|
+
npx @decidio/sdk tokens list [--mine] agent tokens with status (active/expired/REVOKED) by
|
|
3929
|
+
jti — token values are never re-shown; the list is
|
|
3930
|
+
workspace-wide, --mine narrows to this directory's agent
|
|
3931
|
+
npx @decidio/sdk tokens revoke <jti> kill a token now instead of waiting out its lifetime
|
|
3932
|
+
|
|
3933
|
+
APPROVALS AND RECEIPTS
|
|
3934
|
+
npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
|
|
3935
|
+
npx @decidio/sdk approvals approve <id> [reason]
|
|
3936
|
+
npx @decidio/sdk approvals reject <id> [reason]
|
|
3937
|
+
the reason is FREE TEXT, sent exactly as typed. It seals
|
|
3938
|
+
immutably; personal or sensitive values are tokenized at
|
|
3939
|
+
rest, so the STORED text can differ (see: receipt <id>).
|
|
3940
|
+
If it starts with a dash, put it after -- or use --reason:
|
|
3941
|
+
... approve <id> -- "-50% cut, per the board"
|
|
3942
|
+
... approve <id> --reason "-50% cut, per the board"
|
|
3943
|
+
npx @decidio/sdk receipt <id> [--json] download a sealed decision's Authority Receipt to a .json FILE; --json prints
|
|
3944
|
+
a summary to stdout (file name, evidence, issuer, the identities you may see) -
|
|
3945
|
+
the credential itself is the file
|
|
3946
|
+
npx @decidio/sdk verify [--issuer <did>] <receipt.json> [more...] verify receipts OFFLINE, pinned to a trust
|
|
3947
|
+
anchor it prints first: --issuer if given, else the DID this computer remembered
|
|
3948
|
+
for your Decidio when you signed in, else the built-in hosted-service issuer
|
|
3949
|
+
(never the receipt's own issuer field; --allow-unpinned: structural check only)
|
|
3950
|
+
|
|
3951
|
+
CODING AGENTS (Claude Code)
|
|
3952
|
+
npx @decidio/sdk hook install [--project] [--approver <email>] [--cli <path>]
|
|
3953
|
+
a person approves Claude Code's destructive shell commands before they run
|
|
3954
|
+
(rm -rf, git push --force, DROP TABLE, terraform apply, kubectl delete,
|
|
3955
|
+
npm publish, curl | sh, ...): writes the PreToolUse/PostToolUse hooks into
|
|
3956
|
+
.claude/settings.local.json (--project: the shared .claude/settings.json;
|
|
3957
|
+
--cli: run that CLI file from the hook instead of this one). --approver
|
|
3958
|
+
mints a single-use invite and prints WhatsApp / SMS / email share links.
|
|
3959
|
+
npx @decidio/sdk hook wait <decision-id> [--timeout 600]
|
|
3960
|
+
wait for the approver's decision the hook named, then retry the command
|
|
3961
|
+
(exit 0 approved · 3 rejected or blocked · 1 still pending)
|
|
3962
|
+
npx @decidio/sdk hook justify <decision-id> "<what changed>"
|
|
3963
|
+
when the hook says a command looks like a duplicate of a request a person already
|
|
3964
|
+
refused: store the user's one-line reason it is different, then retry the same
|
|
3965
|
+
command - the request reaches the approver with that reason beside the refusal
|
|
3966
|
+
npx @decidio/sdk hook rules the command patterns the hook gates (DECIDIO_HOOK_EXTRA_RULES adds yours)
|
|
3967
|
+
npx @decidio/sdk hook pretool|posttool the hooks themselves: run by Claude Code with the tool call as JSON on
|
|
3968
|
+
stdin, never by hand. A gated command is held up to DECIDIO_HOOK_HOLD_MS
|
|
3969
|
+
(90 s; at most 120 s) for the approver, then refused with its decision id; the SAME command
|
|
3970
|
+
retried after approval runs once - its execution is recorded and that
|
|
3971
|
+
approval is spent. No credentials, no gate, no answer = refused (fail closed).
|
|
3972
|
+
|
|
3973
|
+
CONTEXT PACKS (Claude Code, Claude.ai, ChatGPT)
|
|
3974
|
+
npx @decidio/sdk context create <slug> --title "ACME application" --trigger "whenever we discuss the ACME application"
|
|
3975
|
+
a pack: YOUR curated list of sealed decisions and the words that tell a model when
|
|
3976
|
+
to use it. The title and trigger are shared with the model IN CLEAR, every turn
|
|
3977
|
+
(that is how it matches); the decisions inside stay tokenized. --team <unitId>
|
|
3978
|
+
makes it the unit's: readable by its people, its words shared with all of them,
|
|
3979
|
+
holding only decisions the whole unit may read
|
|
3980
|
+
npx @decidio/sdk context add <slug> D·0412 D·0418 add sealed decisions (D·numbers or ids) · remove takes them out
|
|
3981
|
+
npx @decidio/sdk context trigger <slug> "<phrase>" change when it fires · delete <slug> · list [--json]
|
|
3982
|
+
npx @decidio/sdk context always <slug> on|off a FOUNDATION pack (how we work, the architecture): read on every
|
|
3983
|
+
question, whatever the trigger - up to two per workspace, 20 records each, so the
|
|
3984
|
+
other packs can build on it without repeating it. Pulled as a Claude Code rules
|
|
3985
|
+
file (.claude/rules/decidio-<slug>.md, loaded whole at launch); the connector's
|
|
3986
|
+
list_packs inlines it for ChatGPT and Claude.ai.
|
|
3987
|
+
npx @decidio/sdk context pull [--here] [--pack <slug>] [--chatgpt]
|
|
3988
|
+
npx @decidio/sdk context bind --unit <unitId> (this folder's unit, in .decidio.json: --here pulls its packs)
|
|
3989
|
+
write each pack as a Claude Code skill: a team pack to .claude/skills/decidio-<slug>/
|
|
3990
|
+
(git-ignored - each reader pulls their own), a personal one to ~/.claude/skills/.
|
|
3991
|
+
The trigger is the skill's one-line description; the decisions load only when it
|
|
3992
|
+
applies. --chatgpt prints paste-ready blocks instead (a ChatGPT Project's
|
|
3993
|
+
instructions), always-packs first; the connector returns the same lists live.
|
|
3994
|
+
|
|
3995
|
+
DECISIONS FROM A REPOSITORY
|
|
3996
|
+
npx @decidio/sdk decisions import --adr <folder> [--dry-run]
|
|
3997
|
+
each ACCEPTED architecture decision record (MADR or Nygard markdown) becomes a DRAFT
|
|
3998
|
+
decision you review and seal in Decidio - never sealed here; proposed, rejected,
|
|
3999
|
+
superseded and deprecated ones are listed, not imported; a re-run finds earlier
|
|
4000
|
+
imports (keyed by repository and path); --dry-run sends nothing
|
|
4001
|
+
|
|
4002
|
+
GITHUB DEPLOY APPROVALS (the Decidio GitHub App)
|
|
4003
|
+
npx @decidio/sdk github init [--environment production] [--workflow .github/workflows/deploy.yml] [--api <origin>] [--agents-md]
|
|
4004
|
+
in a repository's checkout: writes the workflow that asks for approval
|
|
4005
|
+
(.github/workflows/decidio-request.yml) and the deploy workflow when there is none
|
|
4006
|
+
(an existing one is never changed: init prints the steps to add), both using
|
|
4007
|
+
Decidio's GitHub Action pinned to its v1.0.0 commit; adds a link to Decidio for agents to AGENTS.md (--agents-md creates it) -
|
|
4008
|
+
never a decision's text
|
|
4009
|
+
npx @decidio/sdk github request --environment <name> [--justification "<what changed>"]
|
|
4010
|
+
inside a GitHub Actions job (permissions id-token: write): ask a teammate to approve
|
|
4011
|
+
deploying this commit; the job ends at once - Decidio starts the deploy when approved
|
|
4012
|
+
npx @decidio/sdk github verify [--decision <id>] the deploy job's first step: proves the approval once for this run,
|
|
4013
|
+
commit and environment, and writes sha= to $GITHUB_OUTPUT; anything else exits 1 -
|
|
4014
|
+
do not deploy (DECIDIO_DECISION carries the decidio input)
|
|
4015
|
+
|
|
4016
|
+
DEVELOPMENT
|
|
4017
|
+
npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
|
|
4018
|
+
npx @decidio/sdk --version print the CLI version
|
|
4019
|
+
|
|
4020
|
+
NOTES
|
|
4021
|
+
Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
|
|
4022
|
+
DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
|
|
4023
|
+
fully offline — it touches no network and reads no .env (only the issuer DIDs remembered at sign-in).
|
|
4024
|
+
Update check: network commands (and the bare command on a terminal) ask the public npm registry once a day
|
|
4025
|
+
whether a newer version exists. If so,
|
|
4026
|
+
an interactive run asks "Upgrade now? [y/n]" first (y upgrades the way you installed - global, or in the
|
|
4027
|
+
project for a local install; under npx it prints the @latest form - and re-runs your command; n exits); a headless
|
|
4028
|
+
run prints the notice and continues (DECIDIO_REQUIRE_LATEST=1 makes it exit). DECIDIO_NO_UPDATE_CHECK=1 skips
|
|
4029
|
+
the check. Nothing is sent to Decidio; verify never checks.
|
|
4030
|
+
Help: --help (or -h) after any command prints this page - one page for every command.
|
|
4031
|
+
Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
|
|
4032
|
+
3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
|
|
4033
|
+
receipt written — pass --auto-approve-demo for a run that completes unattended), or
|
|
4034
|
+
hook wait: the approver said no. hook pretool/posttool always exit 0 (their answer is the JSON).
|
|
4035
|
+
Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
|
|
4036
|
+
pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
|
|
4037
|
+
Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
|
|
4038
|
+
(quickstart, durable resume, workers, adapters).`;
|
|
4039
|
+
const GATED_CMDS = new Set(["quickstart", "init", "login", "doctor", "approvals", "receipt", "tokens", "context"]);
|
|
4040
|
+
function semverNewer(a, b) {
|
|
4041
|
+
const parse = (s) => (/^\d+\.\d+\.\d+$/.test(s) ? s.split(".").map((x) => parseInt(x, 10)) : null);
|
|
4042
|
+
const pa = parse(a), pb = parse(b);
|
|
4043
|
+
if (!pa || !pb)
|
|
4044
|
+
return false;
|
|
4045
|
+
for (let i = 0; i < 3; i++)
|
|
4046
|
+
if (pa[i] !== pb[i])
|
|
4047
|
+
return pa[i] > pb[i];
|
|
4048
|
+
return false;
|
|
4049
|
+
}
|
|
4050
|
+
async function latestPublished() {
|
|
4051
|
+
const cache = joinPath(sessionDir(), "update-check.json");
|
|
4052
|
+
try {
|
|
4053
|
+
const c = JSON.parse(readFileSync(cache, "utf8"));
|
|
4054
|
+
if (c && typeof c.latest === "string" && /^\d+\.\d+\.\d+$/.test(c.latest) && Date.now() - Number(c.checkedAt) < 24 * 3600 * 1000)
|
|
4055
|
+
return c.latest;
|
|
4056
|
+
}
|
|
4057
|
+
catch { }
|
|
4058
|
+
const reg = (SHELL_ENV.DECIDIO_UPDATE_REGISTRY ?? "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
4059
|
+
try {
|
|
4060
|
+
const ctl = new AbortController();
|
|
4061
|
+
const t = setTimeout(() => ctl.abort(), 2000);
|
|
4062
|
+
const r = await fetch(`${reg}/@decidio%2fsdk/latest`, { signal: ctl.signal });
|
|
4063
|
+
clearTimeout(t);
|
|
4064
|
+
if (!r.ok)
|
|
4065
|
+
return null;
|
|
4066
|
+
const body = await r.json();
|
|
4067
|
+
const latest = typeof body?.version === "string" && /^\d+\.\d+\.\d+$/.test(body.version) ? body.version : null;
|
|
4068
|
+
if (latest) {
|
|
4069
|
+
try {
|
|
4070
|
+
mkdirSync(sessionDir(), { recursive: true });
|
|
4071
|
+
writeFileSync(cache, JSON.stringify({ checkedAt: Date.now(), latest }));
|
|
4072
|
+
}
|
|
4073
|
+
catch { }
|
|
4074
|
+
}
|
|
4075
|
+
return latest;
|
|
4076
|
+
}
|
|
4077
|
+
catch {
|
|
4078
|
+
return null;
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4081
|
+
async function updateGate(cmd, argv) {
|
|
4082
|
+
const gatedHere = cmd ? GATED_CMDS.has(cmd) : !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
4083
|
+
if (!gatedHere || SHELL_ENV.DECIDIO_NO_UPDATE_CHECK || SHELL_ENV.DECIDIO_UPDATE_REEXEC || VERSION === "unknown" || VERSION.includes("-"))
|
|
4084
|
+
return null;
|
|
4085
|
+
const latest = await latestPublished();
|
|
4086
|
+
if (!latest || !semverNewer(latest, VERSION))
|
|
4087
|
+
return null;
|
|
4088
|
+
const entry = process.argv[1] ?? "";
|
|
4089
|
+
const underNpx = /[\\/]_npx[\\/]/.test(entry);
|
|
4090
|
+
const localRoot = (() => {
|
|
4091
|
+
const m = /^(.*?)[\\/]node_modules[\\/]@decidio[\\/]sdk[\\/]/.exec(entry);
|
|
4092
|
+
if (!m)
|
|
4093
|
+
return null;
|
|
4094
|
+
const norm = (s) => (process.platform === "win32" ? s.toLowerCase() : s);
|
|
4095
|
+
const root = resolvePath(m[1]);
|
|
4096
|
+
const cwd = norm(process.cwd());
|
|
4097
|
+
return cwd === norm(root) || cwd.startsWith(norm(root) + pathSep) ? root : null;
|
|
4098
|
+
})();
|
|
4099
|
+
const viaAlias = /[\\/]decidio$/.test(argv[1] ?? "");
|
|
4100
|
+
const pkgName = viaAlias ? "decidio" : "@decidio/sdk";
|
|
4101
|
+
const upgradeCmd = underNpx ? `npx ${pkgName}@latest ${argv.slice(2).join(" ")}` : localRoot ? `npm i ${pkgName}@latest` : `npm i -g ${pkgName}@latest`;
|
|
4102
|
+
const notice = `${latest} is available (you have ${VERSION}).`;
|
|
4103
|
+
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
4104
|
+
err(`⚠ ${notice} Upgrade: ${upgradeCmd}`);
|
|
4105
|
+
if (SHELL_ENV.DECIDIO_REQUIRE_LATEST) {
|
|
4106
|
+
err(`✘ DECIDIO_REQUIRE_LATEST is set — not running a stale version.`);
|
|
4107
|
+
return 1;
|
|
4108
|
+
}
|
|
4109
|
+
return null;
|
|
4110
|
+
}
|
|
4111
|
+
const a = (await ask(`${notice} Upgrade now? [y/n]: `)).toLowerCase();
|
|
4112
|
+
if (a !== "y" && a !== "yes") {
|
|
4113
|
+
err(`✘ not upgraded — run this when ready: ${upgradeCmd}`);
|
|
4114
|
+
return 1;
|
|
4115
|
+
}
|
|
4116
|
+
if (underNpx) {
|
|
2303
4117
|
err(` Under npx there is nothing installed to upgrade — re-run as: ${upgradeCmd}`);
|
|
2304
4118
|
return 1;
|
|
2305
4119
|
}
|
|
@@ -2362,6 +4176,9 @@ async function main(argv) {
|
|
|
2362
4176
|
verify: { flags: ["--allow-unpinned"], valued: ["--issuer"] },
|
|
2363
4177
|
tokens: { flags: ["--mine"] },
|
|
2364
4178
|
dev: { flags: [], valued: ["--target", "--port"] },
|
|
4179
|
+
hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli"] },
|
|
4180
|
+
github: { flags: ["--agents-md"], valued: ["--environment", "--justification", "--decision", "--workflow", "--api"] },
|
|
4181
|
+
decisions: { flags: ["--dry-run"], valued: ["--adr"] },
|
|
2365
4182
|
};
|
|
2366
4183
|
const spec = KNOWN[cmd];
|
|
2367
4184
|
if (spec) {
|
|
@@ -2395,8 +4212,8 @@ async function main(argv) {
|
|
|
2395
4212
|
return 2;
|
|
2396
4213
|
}
|
|
2397
4214
|
}
|
|
2398
|
-
const MAX_POS = { login: 1, logout: 0, quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
|
|
2399
|
-
const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || cmd === "verify";
|
|
4215
|
+
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 };
|
|
4216
|
+
const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || (cmd === "hook" && positionals[0] === "justify") || cmd === "verify";
|
|
2400
4217
|
if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
|
|
2401
4218
|
err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
|
|
2402
4219
|
return 2;
|
|
@@ -2537,6 +4354,83 @@ async function main(argv) {
|
|
|
2537
4354
|
}
|
|
2538
4355
|
return cmdDev(target, Number(portRaw));
|
|
2539
4356
|
}
|
|
4357
|
+
case "context": {
|
|
4358
|
+
const VALUED = ["--title", "--trigger", "--team", "--unit", "--pack"];
|
|
4359
|
+
const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
|
|
4360
|
+
const sub = pos[0];
|
|
4361
|
+
const allowed = { list: ["--json"], create: ["--title", "--trigger", "--team", "--unit", "--org", "--json"], add: ["--json"], remove: ["--json"], trigger: ["--json"], always: ["--json"], delete: [], bind: ["--unit"], pull: ["--pack", "--here", "--chatgpt", "--json"] };
|
|
4362
|
+
const given = argsIn.filter((x) => x.startsWith("--")).map((x) => x.split("=")[0]);
|
|
4363
|
+
if (sub && allowed[sub]) {
|
|
4364
|
+
const stray = given.find((f) => !allowed[sub].includes(f));
|
|
4365
|
+
if (stray) {
|
|
4366
|
+
err(`✘ unknown option "${stray}" for \`context ${sub}\` — refusing to guess (a flag you meant to matter must never be silently ignored).`);
|
|
4367
|
+
return 2;
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4370
|
+
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") });
|
|
4371
|
+
}
|
|
4372
|
+
case "hook": {
|
|
4373
|
+
const VALUED = ["--timeout", "--approver", "--cli"];
|
|
4374
|
+
const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
|
|
4375
|
+
const sub = pos[0];
|
|
4376
|
+
const allowed = { pretool: [], posttool: [], wait: ["--timeout"], install: ["--project", "--approver", "--cli"], rules: [], justify: [] };
|
|
4377
|
+
const given = argsIn.filter((x) => x.startsWith("--")).map((x) => x.split("=")[0]);
|
|
4378
|
+
if (sub && allowed[sub]) {
|
|
4379
|
+
const stray = given.find((f) => !allowed[sub].includes(f));
|
|
4380
|
+
if (stray) {
|
|
4381
|
+
err(`✘ unknown option "${stray}" for \`hook ${sub}\` — refusing to guess (a flag you meant to matter must never be silently ignored).`);
|
|
4382
|
+
return 2;
|
|
4383
|
+
}
|
|
4384
|
+
}
|
|
4385
|
+
switch (sub) {
|
|
4386
|
+
case "pretool": return cmdHookPretool();
|
|
4387
|
+
case "posttool": return cmdHookPosttool();
|
|
4388
|
+
case "wait": {
|
|
4389
|
+
const t = valuedOpt(argsIn, "--timeout") ?? "600";
|
|
4390
|
+
if (!/^\d+$/.test(t) || Number(t) < 1 || Number(t) > 86_400) {
|
|
4391
|
+
err(`✘ --timeout needs a whole number of seconds from 1 to 86400 (got "${t}").`);
|
|
4392
|
+
return 2;
|
|
4393
|
+
}
|
|
4394
|
+
return cmdHookWait(pos[1], Number(t));
|
|
4395
|
+
}
|
|
4396
|
+
case "install": return cmdHookInstall({ project: argsIn.includes("--project"), approver: valuedOpt(argsIn, "--approver"), cli: valuedOpt(argsIn, "--cli") });
|
|
4397
|
+
case "rules": return cmdHookRules();
|
|
4398
|
+
case "justify": return cmdHookJustify(pos[1], pos.slice(2).join(" "));
|
|
4399
|
+
default:
|
|
4400
|
+
err(`✘ hook needs a subcommand: install | wait <decision-id> | justify <decision-id> "<what changed>" | rules (pretool and posttool are run by Claude Code).\n\n${USAGE}`);
|
|
4401
|
+
return 2;
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
case "decisions": {
|
|
4405
|
+
const sub = argsIn.find((x, i) => !x.startsWith("-") && argsIn[i - 1] !== "--adr");
|
|
4406
|
+
if (sub !== "import") {
|
|
4407
|
+
err(`✘ decisions needs a subcommand: import --adr <folder> [--dry-run].\n\n${USAGE}`);
|
|
4408
|
+
return 2;
|
|
4409
|
+
}
|
|
4410
|
+
return cmdDecisionsImport(valuedOpt(argsIn, "--adr"), argsIn.includes("--dry-run"));
|
|
4411
|
+
}
|
|
4412
|
+
case "github": {
|
|
4413
|
+
const VALUED = ["--environment", "--justification", "--decision", "--workflow", "--api"];
|
|
4414
|
+
const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
|
|
4415
|
+
const sub = pos[0];
|
|
4416
|
+
const allowed = { request: ["--environment", "--justification"], verify: ["--decision"], init: ["--environment", "--workflow", "--api", "--agents-md"] };
|
|
4417
|
+
const given = argsIn.filter((x) => x.startsWith("--")).map((x) => x.split("=")[0]);
|
|
4418
|
+
if (sub && allowed[sub]) {
|
|
4419
|
+
const stray = given.find((f) => !allowed[sub].includes(f));
|
|
4420
|
+
if (stray) {
|
|
4421
|
+
err(`✘ unknown option "${stray}" for \`github ${sub}\` — refusing to guess (a flag you meant to matter must never be silently ignored).`);
|
|
4422
|
+
return 2;
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
switch (sub) {
|
|
4426
|
+
case "request": return cmdGithubRequest(valuedOpt(argsIn, "--environment"), valuedOpt(argsIn, "--justification"));
|
|
4427
|
+
case "verify": return cmdGithubVerify(valuedOpt(argsIn, "--decision"));
|
|
4428
|
+
case "init": return cmdGithubInit({ environment: valuedOpt(argsIn, "--environment"), workflow: valuedOpt(argsIn, "--workflow"), api: valuedOpt(argsIn, "--api"), agentsMd: argsIn.includes("--agents-md") });
|
|
4429
|
+
default:
|
|
4430
|
+
err(`✘ github needs a subcommand: init | request --environment <name> | verify.\n\n${USAGE}`);
|
|
4431
|
+
return 2;
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
2540
4434
|
default:
|
|
2541
4435
|
if (cmd.startsWith("-"))
|
|
2542
4436
|
err(`✘ unknown command "${cmd}" — flags go AFTER the command (e.g. npx @decidio/sdk approvals ${cmd}).\n\n${USAGE}`);
|
|
@@ -2568,4 +4462,11 @@ if (invokedAsProgram)
|
|
|
2568
4462
|
await new Promise((r) => process.stderr.write("", () => r()));
|
|
2569
4463
|
process.exit(code);
|
|
2570
4464
|
}
|
|
2571
|
-
}).catch((e) => {
|
|
4465
|
+
}).catch((e) => {
|
|
4466
|
+
if (ARGV_TAIL[0] === "hook" && ARGV_TAIL[1] === "pretool") {
|
|
4467
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: `Decidio hook: unexpected failure (${e?.message ?? e}) - refusing this tool call (fail closed). Nothing ran.` } }) + "\n");
|
|
4468
|
+
process.exit(0);
|
|
4469
|
+
}
|
|
4470
|
+
console.error(`✘ unexpected error: ${e?.message ?? e}`);
|
|
4471
|
+
process.exit(1);
|
|
4472
|
+
});
|