@decidio/sdk 0.9.0 → 0.11.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 +83 -0
- package/README.md +2 -2
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +860 -38
- package/openapi.yaml +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, rmdirSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync, statSync, lstatSync, fstatSync, readSync } from "node:fs";
|
|
3
3
|
import { randomBytes, createHash } from "node:crypto";
|
|
4
|
-
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { homedir, tmpdir, hostname } from "node:os";
|
|
5
5
|
import { join as joinPath, resolve as resolvePath, sep as pathSep, basename, dirname, relative, isAbsolute } from "node:path";
|
|
6
6
|
import { createServer } from "node:http";
|
|
7
7
|
import { isIP } from "node:net";
|
|
8
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
8
|
+
import { execFileSync, spawnSync, spawn } from "node:child_process";
|
|
9
9
|
import { createInterface } from "node:readline";
|
|
10
10
|
import { pathToFileURL as toFileUrl, fileURLToPath as fromFileUrl } from "node:url";
|
|
11
11
|
import { realpathSync as realPath } from "node:fs";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
|
-
import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
|
|
13
|
+
import { generateKeypair, assertSafeTransport, redactUrl, signBoundProof } from "./signing.js";
|
|
14
14
|
import { hardenFile } from "./resume.js";
|
|
15
15
|
import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, DecidioHttpError, didFromPrivateKey, checkApproval, signConfirm, buildConfirmBody, readConfirmResponse, bindingMaterial, boundScope, idempotencyKey, decisionUrl, decisionIdProblem } from "./index.js";
|
|
16
16
|
import { gateAuthorizeTwinAware, adapterRequest } from "./adapters/_gate.js";
|
|
@@ -76,6 +76,22 @@ if (!OFFLINE_CMD)
|
|
|
76
76
|
}
|
|
77
77
|
const DEFAULT_API = "https://decidio-api.onrender.com";
|
|
78
78
|
let API = (process.env.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
|
|
79
|
+
const COMPUTER = "*";
|
|
80
|
+
const EVERY_PROJECT = "every project";
|
|
81
|
+
let CONNECTED_APPROVER = null;
|
|
82
|
+
let CONNECTED_UNANSWERED = false;
|
|
83
|
+
let CONNECTED_APP_URL = null;
|
|
84
|
+
let CONNECTED_WORKSPACE = null;
|
|
85
|
+
let GUARD_PAUSABLE = true;
|
|
86
|
+
let connectNotice = null;
|
|
87
|
+
let CREDS_FROM = SHELL_ENV.DECIDIO_API_TOKEN ? "shell" : process.env.DECIDIO_API_TOKEN ? ".env" : null;
|
|
88
|
+
if (!OFFLINE_CMD && !["init", "quickstart", "login", "logout", "phone"].includes(ARGV_TAIL[0] ?? "") && !process.env.DECIDIO_API_TOKEN && !process.env.DECIDIO_AGENT_ID) {
|
|
89
|
+
const c = activeConnection(projectRoot(), API);
|
|
90
|
+
if (c) {
|
|
91
|
+
promoteConnection(c);
|
|
92
|
+
CREDS_FROM = "connection";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
79
95
|
let DEMO = false;
|
|
80
96
|
function enterDemo(url) { API = url; process.env.DECIDIO_API_URL = url; DEMO = true; }
|
|
81
97
|
const pinWords = () => (DEMO ? "PINNED to this demo's own key" : "PINNED to your workspace issuer");
|
|
@@ -90,7 +106,7 @@ let hostAnnounced = false;
|
|
|
90
106
|
async function api(path, init = {}, bearer, soft = false) {
|
|
91
107
|
if (!hostAnnounced) {
|
|
92
108
|
hostAnnounced = true;
|
|
93
|
-
err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
|
|
109
|
+
err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}${APP_URL_OVERRIDDEN ? ` · links → ${APP_URL} (DECIDIO_APP_URL)` : ""}`);
|
|
94
110
|
}
|
|
95
111
|
const auth = bearer ?? TOKEN();
|
|
96
112
|
if (auth && envUnreadable && !process.env.DECIDIO_API_URL) {
|
|
@@ -462,7 +478,24 @@ function block(n, title, rows, tone) {
|
|
|
462
478
|
}
|
|
463
479
|
log(f("└" + "─".repeat(width - 2) + "┘"));
|
|
464
480
|
}
|
|
465
|
-
const
|
|
481
|
+
const APP_URL_SHAPE = /^https?:\/\/[\x21-\x7e]+$/;
|
|
482
|
+
const asciiTrim = (v) => v.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, "");
|
|
483
|
+
export function appUrlFor(api, override = process.env.DECIDIO_APP_URL) {
|
|
484
|
+
const o = asciiTrim(override ?? "");
|
|
485
|
+
if (APP_URL_SHAPE.test(o))
|
|
486
|
+
return o.replace(/\/+$/, "");
|
|
487
|
+
const base = api.replace(/\/+$/, "");
|
|
488
|
+
const known = {
|
|
489
|
+
"https://decidio-api.onrender.com": "https://staging.decidioai.com",
|
|
490
|
+
"https://mcp-staging.decidioai.com": "https://staging.decidioai.com",
|
|
491
|
+
"https://mcp.decidioai.com": "https://app.decidioai.com",
|
|
492
|
+
};
|
|
493
|
+
return known[base] ?? base;
|
|
494
|
+
}
|
|
495
|
+
const APP_URL = appUrlFor(API);
|
|
496
|
+
const APP_URL_OVERRIDDEN = APP_URL !== appUrlFor(API, "");
|
|
497
|
+
if (asciiTrim(process.env.DECIDIO_APP_URL ?? "") && !APP_URL_SHAPE.test(asciiTrim(process.env.DECIDIO_APP_URL ?? "")))
|
|
498
|
+
console.error("⚠ ignoring DECIDIO_APP_URL: it must be a plain http(s) address (printable characters only)");
|
|
466
499
|
let stdinReadAbandoned = false;
|
|
467
500
|
let EXPLAIN = false;
|
|
468
501
|
let currentRl = null;
|
|
@@ -2739,6 +2772,14 @@ function hookMatch(command, at, rules = [...HOOK_RULES, ...hookExtraRules()], de
|
|
|
2739
2772
|
}
|
|
2740
2773
|
return null;
|
|
2741
2774
|
}
|
|
2775
|
+
function hookEditsGuard(command, at) {
|
|
2776
|
+
try {
|
|
2777
|
+
return !!hookMatch(command, at, HOOK_RULES.filter((r) => r.category === "decidio-guard"));
|
|
2778
|
+
}
|
|
2779
|
+
catch {
|
|
2780
|
+
return true;
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2742
2783
|
function hookSelfDecides(command, depth = 0) {
|
|
2743
2784
|
const hit = hookSegments(command).some((s) => {
|
|
2744
2785
|
const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
|
|
@@ -3013,7 +3054,7 @@ function hookSubject(category, segs, cwdAbs, projectDir, at) {
|
|
|
3013
3054
|
}
|
|
3014
3055
|
return textOnly("text binds this category");
|
|
3015
3056
|
}
|
|
3016
|
-
const hookIsOurs = (h) => typeof h?.command === "string" && /\bhook (pretool|posttool)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command);
|
|
3057
|
+
const hookIsOurs = (h) => typeof h?.command === "string" && /\bhook (pretool|posttool|session|prompt)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command);
|
|
3017
3058
|
const hookSorted = (v) => Array.isArray(v) ? v.map(hookSorted) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, hookSorted(v[k])])) : v;
|
|
3018
3059
|
function hookOurHandlers(settings) {
|
|
3019
3060
|
const out = [];
|
|
@@ -3263,11 +3304,11 @@ async function readHookStdin() {
|
|
|
3263
3304
|
function hookConfig() {
|
|
3264
3305
|
const token = process.env.DECIDIO_API_TOKEN;
|
|
3265
3306
|
if (!token)
|
|
3266
|
-
return { missing: "
|
|
3307
|
+
return { missing: "this project is not connected to Decidio (no connection, no agent token in this folder's .env or the environment)" };
|
|
3267
3308
|
return {
|
|
3268
3309
|
apiUrl: API, agentId: process.env.DECIDIO_AGENT_ID || defaultAgentId(), apiToken: token, sourceSystem: "claude-code",
|
|
3269
3310
|
agentKey: process.env.DECIDIO_AGENT_KEY || undefined, agentDid: process.env.DECIDIO_AGENT_DID || undefined, workspaceId: process.env.DECIDIO_WORKSPACE_ID || undefined,
|
|
3270
|
-
appUrl:
|
|
3311
|
+
appUrl: APP_URL,
|
|
3271
3312
|
};
|
|
3272
3313
|
}
|
|
3273
3314
|
const hookStateDir = () => (process.env.CLAUDE_PLUGIN_DATA ? joinPath(process.env.CLAUDE_PLUGIN_DATA, "hooks") : joinPath(sessionDir(), "hooks"));
|
|
@@ -3373,8 +3414,91 @@ async function hookHold(cfg, decisionId, holdMs) {
|
|
|
3373
3414
|
}
|
|
3374
3415
|
return { status, lastError };
|
|
3375
3416
|
}
|
|
3376
|
-
|
|
3377
|
-
|
|
3417
|
+
const ORGANISATION_KEEPS_ON = "Your organisation keeps Decidio on for every session.";
|
|
3418
|
+
const GUARD_PAUSE_HOURS = 12;
|
|
3419
|
+
export function guardWords(prompt) {
|
|
3420
|
+
const m = typeof prompt === "string" ? /^\s*decidio\s+(pause|start)\s*[.!]?\s*$/i.exec(prompt) : null;
|
|
3421
|
+
return m ? m[1].toLowerCase() : null;
|
|
3422
|
+
}
|
|
3423
|
+
const guardSessionKey = (material, session) => idempotencyKey(material, { purpose: "guard-pause", host: "claude-code", session });
|
|
3424
|
+
async function cmdHookPrompt() {
|
|
3425
|
+
const block = (reason) => { process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n"); return 0; };
|
|
3426
|
+
let action = null;
|
|
3427
|
+
try {
|
|
3428
|
+
const read = await readHookStdin();
|
|
3429
|
+
if (!read.ok)
|
|
3430
|
+
return 0;
|
|
3431
|
+
const input = read.input;
|
|
3432
|
+
action = guardWords(input.prompt);
|
|
3433
|
+
if (!action)
|
|
3434
|
+
return 0;
|
|
3435
|
+
const not = action === "pause" ? "the guard stays on" : "nothing changed";
|
|
3436
|
+
if (input.source !== "user")
|
|
3437
|
+
return block(`Decidio: "decidio ${action}" works only on a prompt you type yourself - ${not}.`);
|
|
3438
|
+
const session = typeof input.session_id === "string" ? input.session_id : "";
|
|
3439
|
+
if (!session)
|
|
3440
|
+
return block(`Decidio: this session has no id to pause the guard for - ${not}.`);
|
|
3441
|
+
const cfg = hookConfig();
|
|
3442
|
+
if ("missing" in cfg)
|
|
3443
|
+
return block(`Decidio isn't connected on this computer, so there is no guard to ${action === "pause" ? "pause" : "resume"} here.`);
|
|
3444
|
+
const material = bindingMaterial(cfg);
|
|
3445
|
+
if (!material)
|
|
3446
|
+
return block(`Decidio: this computer's credential can't key the pause - ${not}. Ask the person to run npx @decidio/sdk doctor.`);
|
|
3447
|
+
assertSafeTransport(cfg.apiUrl, true);
|
|
3448
|
+
const r = await fetch(cfg.apiUrl.replace(/\/$/, "") + "/agent/guard", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiToken}` },
|
|
3449
|
+
body: JSON.stringify({ session: guardSessionKey(material, session), action }), signal: AbortSignal.timeout(10_000) });
|
|
3450
|
+
const body = await r.json().catch(() => null);
|
|
3451
|
+
if (r.status === 403 && body?.code === "ORGANISATION_KEEPS_ON") {
|
|
3452
|
+
notePause(session, false);
|
|
3453
|
+
return block(ORGANISATION_KEEPS_ON);
|
|
3454
|
+
}
|
|
3455
|
+
if (r.ok && body?.state === "paused") {
|
|
3456
|
+
notePause(session, true);
|
|
3457
|
+
return block(`Decidio guard paused for this session: destructive commands run without a person's approval - each one is recorded in your workspace's audit log - until you type "decidio start" (or for ${GUARD_PAUSE_HOURS} hours). Decidio still asks before a command edits Claude Code's settings to switch its check off.`);
|
|
3458
|
+
}
|
|
3459
|
+
if (r.ok && body?.state === "on") {
|
|
3460
|
+
notePause(session, false);
|
|
3461
|
+
return block(body.resumed ? "Decidio guard is back on for this session." : "Decidio guard is on for this session (it wasn't paused).");
|
|
3462
|
+
}
|
|
3463
|
+
return block(`Decidio couldn't ${action} the guard just now (${r.status}) - ${action === "pause" ? "it stays on" : "if it was paused, type \"decidio start\" again"}.`);
|
|
3464
|
+
}
|
|
3465
|
+
catch (e) {
|
|
3466
|
+
if (!action)
|
|
3467
|
+
return 0;
|
|
3468
|
+
return block(`Decidio couldn't ${action} the guard just now (${String(e?.message ?? e).replace(/^\[decidio\] /, "").slice(0, 160)}) - ${action === "pause" ? "it stays on" : "if it was paused, type \"decidio start\" again"}.`);
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
function hookForgesPrompt(command, depth = 0) {
|
|
3472
|
+
const hit = hookSegments(command).some((s) => {
|
|
3473
|
+
const a = s.args.map((x) => x.replace(/^["']|["']$/g, ""));
|
|
3474
|
+
const cli = (x) => /^@decidio\/sdk(@\S+)?$/i.test(x) || /^decidio(@\S+)?$/i.test(x) || /(^|[\\/])(decidio-guard|@decidio[\\/]sdk)[\\/](dist[\\/])?cli\.(js|ts)$/i.test(x);
|
|
3475
|
+
const runsCli = s.program === "decidio" || s.program === "decidio.js"
|
|
3476
|
+
|| ((["npx", "pnpx", "bunx", "npm", "pnpm", "yarn", "bun", "node", "tsx", "uv", "uvx", "pipx"].includes(s.program) || /^python[0-9.]*$/.test(s.program)) && a.some(cli));
|
|
3477
|
+
const i = a.indexOf("hook");
|
|
3478
|
+
return runsCli && i >= 0 && a.slice(i + 1).some((x) => x.toLowerCase() === "prompt");
|
|
3479
|
+
});
|
|
3480
|
+
return hit || (depth < 2 && hookShellInner(command).some((c) => hookForgesPrompt(c, depth + 1)));
|
|
3481
|
+
}
|
|
3482
|
+
function cmdHookSession() {
|
|
3483
|
+
try {
|
|
3484
|
+
const cfg = hookConfig();
|
|
3485
|
+
const msg = "missing" in cfg
|
|
3486
|
+
? "Decidio approvals are on here, but this computer isn't connected yet - the first destructive command shows a link to connect it (once, for every project on this computer)."
|
|
3487
|
+
: `Decidio guard is on for this session${CONNECTED_WORKSPACE ? ` (workspace ${CONNECTED_WORKSPACE})` : ""}: deleting files, force-pushing, dropping tables, infrastructure changes and publishing wait for ${CONNECTED_APPROVER ?? "a person"}'s OK; everything else runs as usual. `
|
|
3488
|
+
+ (GUARD_PAUSABLE ? `Type "decidio pause" to pause it for this session, "decidio start" to resume.` : ORGANISATION_KEEPS_ON);
|
|
3489
|
+
process.stdout.write(JSON.stringify({ systemMessage: msg, hookSpecificOutput: { hookEventName: "SessionStart",
|
|
3490
|
+
additionalContext: "Decidio's hook asks a person before destructive shell commands in this project. When it refuses one, tell the user what its message says (who must approve, or the link that connects this computer) and follow it; never work around it." } }) + "\n");
|
|
3491
|
+
}
|
|
3492
|
+
catch { }
|
|
3493
|
+
return 0;
|
|
3494
|
+
}
|
|
3495
|
+
async function cmdHookPretool(opts = { unconnected: "hold" }) {
|
|
3496
|
+
const out = (o) => {
|
|
3497
|
+
if (connectNotice)
|
|
3498
|
+
o = { ...o, systemMessage: o?.systemMessage ? `${connectNotice} ${o.systemMessage}` : connectNotice };
|
|
3499
|
+
process.stdout.write(JSON.stringify(o) + "\n");
|
|
3500
|
+
return 0;
|
|
3501
|
+
};
|
|
3378
3502
|
const deny = (reason, user) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason }, ...(user ? { systemMessage: user } : {}) });
|
|
3379
3503
|
const allow = (reason) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: reason } });
|
|
3380
3504
|
try {
|
|
@@ -3391,6 +3515,8 @@ async function cmdHookPretool() {
|
|
|
3391
3515
|
const command = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
|
|
3392
3516
|
if (!command.trim())
|
|
3393
3517
|
return 0;
|
|
3518
|
+
if (hookForgesPrompt(command))
|
|
3519
|
+
return deny("Decidio: an agent can't pause or resume Decidio's guard - only the person does, by typing \"decidio pause\" or \"decidio start\" themselves. Nothing ran. Tell the user if they want it paused.");
|
|
3394
3520
|
if (hookSelfDecides(command))
|
|
3395
3521
|
return deny("Decidio: an agent can't decide an approval - `approvals approve|reject` is the person's, and the person decides on their phone, the web app, Slack or Teams. Nothing ran. Tell the user which decision is waiting; never try another way to decide it.", "Decidio: the agent tried to decide an approval - refused");
|
|
3396
3522
|
const segs = hookSegments(command);
|
|
@@ -3410,7 +3536,8 @@ async function cmdHookPretool() {
|
|
|
3410
3536
|
const canonical = command.replace(/\s+/g, " ").trim();
|
|
3411
3537
|
const base = hookSubject(rule.category, segs, cwdAbs, projectDir, at);
|
|
3412
3538
|
const subject = followed ? { fingerprint: `${base.fingerprint ?? ""}\nfile:${followed.rel}:${followed.sha}`, display: followed.rel } : base;
|
|
3413
|
-
|
|
3539
|
+
const offSwitch = rule.category === "decidio-guard" || hookEditsGuard(command, at);
|
|
3540
|
+
g = { category: rule.category, isFile: false, text: canonical, binding: canonical + "\n" + (subject.fingerprint ?? ""), subject, identity: { command: canonical, subject: subject.fingerprint ?? "" }, segs, offSwitch,
|
|
3414
3541
|
...(followed ? { scriptLine: hookRedact(`${followed.rel}:${followed.line}: ${followed.text.replace(/\s+/g, " ").trim()}`).slice(0, 160) } : {}) };
|
|
3415
3542
|
}
|
|
3416
3543
|
else if (tool === "Write" || tool === "Edit" || tool === "MultiEdit") {
|
|
@@ -3418,7 +3545,7 @@ async function cmdHookPretool() {
|
|
|
3418
3545
|
if (!fc)
|
|
3419
3546
|
return 0;
|
|
3420
3547
|
const what = fc.changes.join("; ");
|
|
3421
|
-
g = { category: "decidio-guard", isFile: true, text: `${tool} ${fc.display} — ${what}`, binding: `${tool}\n${fc.abs}\n${fc.sha}`, subject: { fingerprint: fc.sha, display: `${fc.display}: ${what}` }, identity: { path: fc.abs, after: fc.sha }, segs: null };
|
|
3548
|
+
g = { category: "decidio-guard", isFile: true, text: `${tool} ${fc.display} — ${what}`, binding: `${tool}\n${fc.abs}\n${fc.sha}`, subject: { fingerprint: fc.sha, display: `${fc.display}: ${what}` }, identity: { path: fc.abs, after: fc.sha }, segs: null, offSwitch: true };
|
|
3422
3549
|
}
|
|
3423
3550
|
else
|
|
3424
3551
|
return 0;
|
|
@@ -3426,9 +3553,23 @@ async function cmdHookPretool() {
|
|
|
3426
3553
|
? { noun: "edit", run: "make", ran: "Nothing was written.", anew: "If the user wants a different change to this file, that is a new request and may be asked." }
|
|
3427
3554
|
: { noun: "command", run: "run", ran: "Nothing ran.", anew: "If the user changes what it would act on (a new commit, a different path), that is a new request and may be asked." };
|
|
3428
3555
|
const short = hookShort(hookRedact(g.text.replace(/\s+/g, " ").trim()));
|
|
3429
|
-
|
|
3556
|
+
let cfg = hookConfig();
|
|
3557
|
+
if ("missing" in cfg) {
|
|
3558
|
+
const got = await hookProvision();
|
|
3559
|
+
if (!got.ok) {
|
|
3560
|
+
if (opts.unconnected === "tell" && !g.offSwitch) {
|
|
3561
|
+
const told = toldOnce(typeof input.session_id === "string" ? input.session_id : "", g.binding);
|
|
3562
|
+
if (told === "passed")
|
|
3563
|
+
return out({ systemMessage: `Decidio: "${short}" ran without Decidio's approval - this computer isn't connected, and this repository lets a computer that isn't connected through after one notice.` });
|
|
3564
|
+
if (told === "noted")
|
|
3565
|
+
return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval - ${got.reason} ${w.ran} This repository lets a computer that isn't connected run it after this one notice: if you can't connect, tell the user it would run WITHOUT Decidio's approval, and retry the same ${w.noun} only if they want that.`, got.user);
|
|
3566
|
+
}
|
|
3567
|
+
return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval - ${got.reason} ${w.ran}`, got.user);
|
|
3568
|
+
}
|
|
3569
|
+
cfg = hookConfig();
|
|
3570
|
+
}
|
|
3430
3571
|
if ("missing" in cfg)
|
|
3431
|
-
return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval, but this project
|
|
3572
|
+
return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval, but this project is not connected to Decidio yet. ${w.ran} Run \`npx @decidio/sdk connect\` in ${projectDir} and show the user the link it prints (they allow it on the web), then run \`npx @decidio/sdk connect --wait\`; once it says connected, retry the same ${w.noun}.`, `Decidio: this project is not connected - run npx @decidio/sdk connect`);
|
|
3432
3573
|
const rel = relative(projectDir, cwdAbs);
|
|
3433
3574
|
const cwdRel = !rel ? "." : rel.startsWith("..") || isAbsolute(rel) ? cwdAbs : rel;
|
|
3434
3575
|
const session = typeof input.session_id === "string" ? input.session_id : "";
|
|
@@ -3439,6 +3580,13 @@ async function cmdHookPretool() {
|
|
|
3439
3580
|
catch (e) {
|
|
3440
3581
|
return deny(`Decidio hook: this ${w.noun} cannot be bound to an approval (${e?.message ?? e}) - refusing to ${w.run} it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
|
|
3441
3582
|
}
|
|
3583
|
+
if (material && session && !g.offSwitch) {
|
|
3584
|
+
const paused = existsSync(pauseNote(session)) ? await guardPausedNow(cfg, guardSessionKey(material, session), g.category) : "on";
|
|
3585
|
+
if (paused === "on")
|
|
3586
|
+
notePause(session, false);
|
|
3587
|
+
if (paused === "paused")
|
|
3588
|
+
return out({ systemMessage: `Decidio guard is paused for this session: "${short}" ran without a person's approval (recorded). Type "decidio start" to resume.` });
|
|
3589
|
+
}
|
|
3442
3590
|
if (!material)
|
|
3443
3591
|
return deny(`Decidio hook: this ${w.noun} cannot be bound to an approval (no key material (DECIDIO_API_TOKEN, or DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID)) - refusing to ${w.run} it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
|
|
3444
3592
|
const committed = { command: hookRedact(g.text).slice(0, 400), cwd: cwdRel.slice(0, 200), category: g.category, host: "claude-code", tool, subject: hookRedact(g.subject.display).slice(0, 200), ...(g.scriptLine ? { scriptLine: g.scriptLine } : {}) };
|
|
@@ -3483,7 +3631,7 @@ async function cmdHookPretool() {
|
|
|
3483
3631
|
if (auth.decision === "proceed") {
|
|
3484
3632
|
writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: auth.receiptId ?? null, tool, category: g.category, at: new Date().toISOString() });
|
|
3485
3633
|
err(`decidio hook: "${short}" is authorized by policy (decision ${auth.decisionId}${auth.receiptId ? `, receipt ${auth.receiptId}` : ""}, sealed) - no output, so Claude Code's own permission check still decides; this covers this one execution only.`);
|
|
3486
|
-
return 0;
|
|
3634
|
+
return connectNotice ? out({}) : 0;
|
|
3487
3635
|
}
|
|
3488
3636
|
const holdRaw = Number(process.env.DECIDIO_HOOK_HOLD_MS ?? "90000");
|
|
3489
3637
|
const held = await hookHold(cfg, auth.decisionId, Number.isFinite(holdRaw) && holdRaw >= 0 ? Math.min(holdRaw, HOOK_HOLD_CAP_MS) : 90_000);
|
|
@@ -3491,7 +3639,7 @@ async function cmdHookPretool() {
|
|
|
3491
3639
|
if (!st)
|
|
3492
3640
|
return deny(`Decidio: "${short}" is routed for approval as decision ${auth.decisionId}, but its status could not be read (${held.lastError ?? "no answer"}). ${w.ran} When the gate is reachable, run \`npx @decidio/sdk hook wait ${auth.decisionId}\` and then retry EXACTLY the same ${w.noun}.`);
|
|
3493
3641
|
if (st.state === "pending")
|
|
3494
|
-
return deny(`Decidio: "${short}" needs a person's approval - decision ${auth.decisionId} is in their queue${where ? ` (${where})` : ""}. ${w.ran} To continue: run \`npx @decidio/sdk hook wait ${auth.decisionId}\` (it returns when they decide), then retry EXACTLY the same ${w.noun} - not a variant of it, and do not work around this gate.`, `Decidio: ${auth.decisionId}
|
|
3642
|
+
return deny(`Decidio: "${short}" needs a person's approval - decision ${auth.decisionId} is in ${CONNECTED_APPROVER ? `${CONNECTED_APPROVER}'s` : "their"} queue${where ? ` (${where})` : ""}; they see it on their phone, Slack, Teams or the web. ${w.ran} Tell the user it is waiting for ${CONNECTED_APPROVER ?? "the approver"}.${CONNECTED_UNANSWERED ? ` If nobody has said yet whether another person approves this computer's requests, tell the user: the workspace owner answers "Should another person approve this?" once on the Decidio Agents page${CONNECTED_APP_URL ? ` (${CONNECTED_APP_URL}/connect/agents#coding-assistants)` : ""}. A request made before the answer keeps it: its owner can't approve that one - another approver can, or ask again in a new session.` : ""} To continue: run \`npx @decidio/sdk hook wait ${auth.decisionId}\` (it returns when they decide), then retry EXACTLY the same ${w.noun} - not a variant of it, and do not work around this gate.`, `Decidio: waiting for ${CONNECTED_APPROVER ?? "a person"}'s approval (${auth.decisionId})${where ? ` - ${where}` : ""}. ${w.ran} Claude continues once it is approved.`);
|
|
3495
3643
|
if (st.state === "rejected" || st.state === "blocked")
|
|
3496
3644
|
return deny(`Decidio: "${short}" was ${st.state} (decision ${auth.decisionId}${st.reason ? `: ${st.reason}` : ""}) for this exact change (${hookRedact(g.subject.display)}). Do not retry it or a variant of it; tell the user. ${g.subject.fingerprint ? w.anew : ""}`.trim(), `Decidio: ${auth.decisionId} ${st.state}`);
|
|
3497
3645
|
const chk = await checkApproval(cfg, auth.decisionId, request, { status: st });
|
|
@@ -3569,7 +3717,7 @@ async function cmdHookWait(id, timeoutSec) {
|
|
|
3569
3717
|
}
|
|
3570
3718
|
const cfg = hookConfig();
|
|
3571
3719
|
if ("missing" in cfg) {
|
|
3572
|
-
err(`✘ ${cfg.missing} -
|
|
3720
|
+
err(`✘ ${cfg.missing} - connect this project first: npx @decidio/sdk connect`);
|
|
3573
3721
|
return 1;
|
|
3574
3722
|
}
|
|
3575
3723
|
const where = decisionUrl(cfg.appUrl, id);
|
|
@@ -3650,10 +3798,10 @@ function cmdHookRules() {
|
|
|
3650
3798
|
log(`A policy that auto-approves a request gets no output from the hook, so Claude Code's own permission check still decides; only a person's approval of the exact call answers "allow".`);
|
|
3651
3799
|
return 0;
|
|
3652
3800
|
}
|
|
3653
|
-
function hookRunner() {
|
|
3801
|
+
function hookRunner(portable = false) {
|
|
3654
3802
|
const q = (p) => `"${p.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
3655
3803
|
const entry = process.argv[1] ?? "";
|
|
3656
|
-
if (!entry || /[\\/]_npx[\\/]/.test(entry))
|
|
3804
|
+
if (portable || !entry || /[\\/]_npx[\\/]/.test(entry))
|
|
3657
3805
|
return `npx -y @decidio/sdk@${VERSION}`;
|
|
3658
3806
|
let real = entry;
|
|
3659
3807
|
try {
|
|
@@ -3662,16 +3810,17 @@ function hookRunner() {
|
|
|
3662
3810
|
catch { }
|
|
3663
3811
|
const localRoot = resolvePath("node_modules") + pathSep;
|
|
3664
3812
|
if (real.startsWith(localRoot))
|
|
3665
|
-
|
|
3813
|
+
return `node "$CLAUDE_PROJECT_DIR/node_modules/${real.slice(localRoot.length).split(pathSep).join("/").replace(/(["\\$`])/g, "\\$1")}"`;
|
|
3666
3814
|
real = real.split(pathSep).join("/");
|
|
3667
3815
|
if (/\.ts$/.test(real))
|
|
3668
3816
|
return `npx tsx ${q(real)}`;
|
|
3669
3817
|
return `node ${q(real)}`;
|
|
3670
3818
|
}
|
|
3819
|
+
const hookPromptLine = (runner) => `cd "$CLAUDE_PROJECT_DIR" && IN=$(cat) && case "$IN" in *[Dd][Ee][Cc][Ii][Dd][Ii][Oo]*) printf '%s' "$IN" | ${runner} hook prompt;; esac`;
|
|
3671
3820
|
const HOOK_FILE_TOOLS = ["Write", "Edit", "MultiEdit"];
|
|
3672
3821
|
const HOOK_GUARD_PATHS = ["**/.claude/settings*.json", "~/.claude/settings*.json", "~/.claude/plugins/**"];
|
|
3673
3822
|
async function cmdHookInstall(opts) {
|
|
3674
|
-
const settingsPath = joinPath(".claude", opts.project ? "settings.json" : "settings.local.json");
|
|
3823
|
+
const settingsPath = joinPath(opts.dir ?? ".", ".claude", opts.project ? "settings.json" : "settings.local.json");
|
|
3675
3824
|
let settings = {};
|
|
3676
3825
|
if (existsSync(settingsPath)) {
|
|
3677
3826
|
try {
|
|
@@ -3691,14 +3840,14 @@ async function cmdHookInstall(opts) {
|
|
|
3691
3840
|
return 2;
|
|
3692
3841
|
}
|
|
3693
3842
|
const cliAt = opts.cli ? resolvePath(opts.cli).split(pathSep).join("/") : "";
|
|
3694
|
-
const runner = opts.cli ? (/\.ts$/.test(opts.cli) ? `npx tsx "${cliAt}"` : `node "${cliAt}"`) : hookRunner();
|
|
3843
|
+
const runner = opts.cli ? (/\.ts$/.test(opts.cli) ? `npx tsx "${cliAt}"` : `node "${cliAt}"`) : hookRunner(opts.portable === true);
|
|
3695
3844
|
const hooks = (settings.hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {});
|
|
3696
3845
|
const place = (event, matcher, iff, command, extra) => {
|
|
3697
3846
|
const list = Array.isArray(hooks[event]) ? hooks[event] : (hooks[event] = []);
|
|
3698
3847
|
const want = { type: "command", ...(iff ? { if: iff } : {}), command, ...extra };
|
|
3699
3848
|
let state = "added";
|
|
3700
3849
|
for (const g of list) {
|
|
3701
|
-
if (!g || typeof g !== "object" || g.matcher !== matcher || !Array.isArray(g.hooks))
|
|
3850
|
+
if (!g || typeof g !== "object" || (g.matcher ?? "") !== matcher || !Array.isArray(g.hooks))
|
|
3702
3851
|
continue;
|
|
3703
3852
|
g.hooks = g.hooks.flatMap((h) => {
|
|
3704
3853
|
if (!hookIsOurs(h) || (typeof h.if === "string" ? h.if : undefined) !== iff)
|
|
@@ -3713,39 +3862,684 @@ async function cmdHookInstall(opts) {
|
|
|
3713
3862
|
}
|
|
3714
3863
|
hooks[event] = list.filter((g) => !(g && typeof g === "object" && Array.isArray(g.hooks) && g.hooks.length === 0));
|
|
3715
3864
|
if (state === "added")
|
|
3716
|
-
hooks[event].push({ matcher, hooks: [want] });
|
|
3865
|
+
hooks[event].push(matcher ? { matcher, hooks: [want] } : { hooks: [want] });
|
|
3717
3866
|
return state;
|
|
3718
3867
|
};
|
|
3719
|
-
const preLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool`, postLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`;
|
|
3868
|
+
const preLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool${opts.unconnected === "tell" ? " --unconnected=tell" : ""}`, postLine = `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`;
|
|
3720
3869
|
const pre = place("PreToolUse", "Bash|PowerShell", undefined, preLine, { timeout: 150, onFailure: "block" });
|
|
3721
3870
|
const post = place("PostToolUse", "Bash|PowerShell", undefined, postLine, { timeout: 30 });
|
|
3871
|
+
place("SessionStart", "startup|resume", undefined, `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook session`, { timeout: 15 });
|
|
3872
|
+
place("UserPromptSubmit", "", undefined, hookPromptLine(runner), { timeout: 15 });
|
|
3722
3873
|
const guard = { added: 0, updated: 0, kept: 0 };
|
|
3723
3874
|
for (const t of HOOK_FILE_TOOLS)
|
|
3724
3875
|
for (const p of HOOK_GUARD_PATHS) {
|
|
3725
3876
|
guard[place("PreToolUse", t, `${t}(${p})`, preLine, { timeout: 150, onFailure: "block" })]++;
|
|
3726
3877
|
guard[place("PostToolUse", t, `${t}(${p})`, postLine, { timeout: 30 })]++;
|
|
3727
3878
|
}
|
|
3728
|
-
mkdirSync(
|
|
3729
|
-
|
|
3879
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
3880
|
+
const target = realOrSame(settingsPath), tmpSettings = `${target}.decidio-${process.pid}.tmp`;
|
|
3881
|
+
writeFileSync(tmpSettings, JSON.stringify(settings, null, 2) + "\n", { mode: existsSync(target) ? statSync(target).mode & 0o777 : 0o644 });
|
|
3882
|
+
renameSync(tmpSettings, target);
|
|
3883
|
+
lastHookInstall = { settingsPath, pre, post };
|
|
3884
|
+
if (opts.quiet)
|
|
3885
|
+
return 0;
|
|
3730
3886
|
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"}).`);
|
|
3731
3887
|
log(`✔ the guard on Claude Code's own settings - ${HOOK_FILE_TOOLS.join(", ")} on ${HOOK_GUARD_PATHS.join(", ")}, PreToolUse and PostToolUse: ${Object.entries(guard).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(", ")}.`);
|
|
3732
3888
|
log(` Runs: ${runner} hook pretool|posttool - the pretool hook times out at 150 s and BLOCKS the call if it fails (fail closed).`);
|
|
3733
3889
|
const env = readEnvFile();
|
|
3734
3890
|
const token = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
|
|
3735
|
-
if (token)
|
|
3891
|
+
if (token && CREDS_FROM === "connection")
|
|
3892
|
+
log(`✔ credentials: this project's Decidio connection (${process.env.DECIDIO_AGENT_ID}) - kept outside the repository, owner-only.`);
|
|
3893
|
+
else if (token)
|
|
3736
3894
|
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()}".`);
|
|
3737
3895
|
else
|
|
3738
|
-
log(`⚠
|
|
3896
|
+
log(`⚠ not connected yet - until this project is, EVERY gated command is refused. Run: npx @decidio/sdk connect (or, for your own agent: npx @decidio/sdk init ${defaultAgentId()})`);
|
|
3739
3897
|
log(` Gated: ${HOOK_RULES.map((r) => r.category).join(", ")} (npx @decidio/sdk hook rules lists the patterns; DECIDIO_HOOK_EXTRA_RULES adds yours).`);
|
|
3740
3898
|
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.`);
|
|
3741
3899
|
log(` Nobody yet? Invite one: npx @decidio/sdk hook install --approver <email> (single-use link; share by WhatsApp, SMS or email)`);
|
|
3742
3900
|
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,`);
|
|
3743
3901
|
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).`);
|
|
3744
|
-
log(` Reload:
|
|
3902
|
+
log(` Reload: none needed - Claude Code picks up the settings file by itself. If a gated command is not asked about, open /hooks once (or restart Claude Code).`);
|
|
3745
3903
|
if (opts.approver !== undefined)
|
|
3746
3904
|
return inviteApprover(opts.approver);
|
|
3747
3905
|
return 0;
|
|
3748
3906
|
}
|
|
3907
|
+
function connectHostLabel(h) { return h === "claude-code" ? "Claude Code" : h; }
|
|
3908
|
+
function connectDir() { return joinPath(sessionDir(), "connections"); }
|
|
3909
|
+
function realOrSame(p) { try {
|
|
3910
|
+
return realPath(p);
|
|
3911
|
+
}
|
|
3912
|
+
catch {
|
|
3913
|
+
return resolvePath(p);
|
|
3914
|
+
} }
|
|
3915
|
+
const sameDir = (a, b) => (process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
3916
|
+
function projectRoot() {
|
|
3917
|
+
try {
|
|
3918
|
+
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
3919
|
+
const top = String(r.stdout ?? "").trim();
|
|
3920
|
+
if (r.status === 0 && top)
|
|
3921
|
+
return realOrSame(top);
|
|
3922
|
+
}
|
|
3923
|
+
catch { }
|
|
3924
|
+
return realOrSame(SHELL_ENV.CLAUDE_PROJECT_DIR || process.cwd());
|
|
3925
|
+
}
|
|
3926
|
+
function connectionKey(root) { return createHash("sha256").update(`${API}\n${root === COMPUTER ? "computer" : process.platform === "win32" ? root.toLowerCase() : root}`, "utf8").digest("hex").slice(0, 20); }
|
|
3927
|
+
function connectionFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.json`); }
|
|
3928
|
+
function pendingConnectFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.pending.json`); }
|
|
3929
|
+
function readJsonQuiet(f) {
|
|
3930
|
+
try {
|
|
3931
|
+
return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null;
|
|
3932
|
+
}
|
|
3933
|
+
catch {
|
|
3934
|
+
return null;
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
function activeConnection(root, api) { return loadConnection(root, api) ?? loadConnection(COMPUTER, api); }
|
|
3938
|
+
const connectScope = (root) => (connectionRecord(root, API) ? root : COMPUTER);
|
|
3939
|
+
function loadConnection(root, api) {
|
|
3940
|
+
const c = connectionRecord(root, api);
|
|
3941
|
+
return c && !c.disconnected && c.token ? c : null;
|
|
3942
|
+
}
|
|
3943
|
+
function connectionRecord(root, api) {
|
|
3944
|
+
const c = readJsonQuiet(connectionFile(root));
|
|
3945
|
+
return c && c.v === 1 && c.api === api && c.root === root && typeof c.token === "string" && typeof c.agentId === "string" && typeof c.key === "string" && typeof c.did === "string" ? c : null;
|
|
3946
|
+
}
|
|
3947
|
+
function printableName(v) {
|
|
3948
|
+
return typeof v === "string" ? v.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g, "").trim().slice(0, 60) || null : null;
|
|
3949
|
+
}
|
|
3950
|
+
function promoteConnection(c) {
|
|
3951
|
+
CONNECTED_APPROVER = printableName(c.approver);
|
|
3952
|
+
CONNECTED_UNANSWERED = c.answered === false;
|
|
3953
|
+
CONNECTED_APP_URL = typeof c.appUrl === "string" && /^https?:\/\/[^\s"<>]+$/.test(c.appUrl) ? c.appUrl.replace(/\/+$/, "") : null;
|
|
3954
|
+
CONNECTED_WORKSPACE = printableName(c.workspaceName);
|
|
3955
|
+
GUARD_PAUSABLE = c.guardPausable !== false;
|
|
3956
|
+
process.env.DECIDIO_API_TOKEN = c.token;
|
|
3957
|
+
process.env.DECIDIO_AGENT_ID = c.agentId;
|
|
3958
|
+
process.env.DECIDIO_AGENT_DID = c.did;
|
|
3959
|
+
process.env.DECIDIO_AGENT_KEY = c.key;
|
|
3960
|
+
process.env.DECIDIO_WORKSPACE_ID = c.workspaceId;
|
|
3961
|
+
process.env.DECIDIO_TOKEN_JTI = c.jti;
|
|
3962
|
+
if (c.appUrl && process.env.DECIDIO_APP_URL === undefined)
|
|
3963
|
+
process.env.DECIDIO_APP_URL = c.appUrl;
|
|
3964
|
+
}
|
|
3965
|
+
function inCloudSession() { return SHELL_ENV.CLAUDE_CODE_REMOTE === "true"; }
|
|
3966
|
+
function machineId() {
|
|
3967
|
+
const f = joinPath(sessionDir(), "machine-id");
|
|
3968
|
+
let cur = "";
|
|
3969
|
+
try {
|
|
3970
|
+
cur = existsSync(f) ? readFileSync(f, "utf8").trim() : "";
|
|
3971
|
+
}
|
|
3972
|
+
catch { }
|
|
3973
|
+
if (/^[0-9a-f]{8,32}$/.test(cur))
|
|
3974
|
+
return cur;
|
|
3975
|
+
const id = randomBytes(8).toString("hex");
|
|
3976
|
+
mkdirSync(sessionDir(), { recursive: true });
|
|
3977
|
+
writeFileSync(f, id + "\n");
|
|
3978
|
+
return id;
|
|
3979
|
+
}
|
|
3980
|
+
function machineLabel(cloud) {
|
|
3981
|
+
if (cloud)
|
|
3982
|
+
return `cloud-${machineId().slice(0, 4)}`;
|
|
3983
|
+
const h = (() => { try {
|
|
3984
|
+
return hostname();
|
|
3985
|
+
}
|
|
3986
|
+
catch {
|
|
3987
|
+
return "";
|
|
3988
|
+
} })().split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3989
|
+
return (h || "machine").slice(0, 40);
|
|
3990
|
+
}
|
|
3991
|
+
function projectLabel(root) {
|
|
3992
|
+
try {
|
|
3993
|
+
const r = spawnSync("git", ["-C", root, "config", "--get", "remote.origin.url"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
3994
|
+
const m = String(r.stdout ?? "").trim().match(/([^/:\\]+?)(?:\.git)?\/?$/);
|
|
3995
|
+
if (r.status === 0 && m?.[1])
|
|
3996
|
+
return m[1].slice(0, 80);
|
|
3997
|
+
}
|
|
3998
|
+
catch { }
|
|
3999
|
+
try {
|
|
4000
|
+
const pj = JSON.parse(readFileSync(joinPath(root, "package.json"), "utf8"));
|
|
4001
|
+
if (typeof pj?.name === "string" && pj.name)
|
|
4002
|
+
return pj.name.replace(/^@[^/]+\//, "").slice(0, 80);
|
|
4003
|
+
}
|
|
4004
|
+
catch { }
|
|
4005
|
+
return (basename(root) || "project").slice(0, 80);
|
|
4006
|
+
}
|
|
4007
|
+
function localDesktop() {
|
|
4008
|
+
if (inCloudSession() || SHELL_ENV.CI || SHELL_ENV.SSH_CONNECTION || SHELL_ENV.SSH_TTY || SHELL_ENV.CODESPACES || SHELL_ENV.GITPOD_WORKSPACE_ID || SHELL_ENV.DECIDIO_NO_BROWSER)
|
|
4009
|
+
return false;
|
|
4010
|
+
if (process.platform === "darwin" || process.platform === "win32")
|
|
4011
|
+
return true;
|
|
4012
|
+
return !!(SHELL_ENV.DISPLAY || SHELL_ENV.WAYLAND_DISPLAY);
|
|
4013
|
+
}
|
|
4014
|
+
function openInBrowser(url) {
|
|
4015
|
+
if (!/^https?:\/\/[^\s"'<>`]+$/.test(url))
|
|
4016
|
+
return false;
|
|
4017
|
+
try {
|
|
4018
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
|
|
4019
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
4020
|
+
child.on("error", () => { });
|
|
4021
|
+
child.unref();
|
|
4022
|
+
return true;
|
|
4023
|
+
}
|
|
4024
|
+
catch {
|
|
4025
|
+
return false;
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
const CONNECT_WAIT_S = () => { const n = Number(process.env.DECIDIO_CONNECT_WAIT_S ?? 90); return Number.isFinite(n) && n >= 1 && n <= 110 ? n : 90; };
|
|
4029
|
+
let lastHookInstall = null;
|
|
4030
|
+
let connectError = "";
|
|
4031
|
+
async function startConnect(root, host, quiet = false) {
|
|
4032
|
+
const fail = (m) => { connectError = m; if (!quiet)
|
|
4033
|
+
err(`✘ ${m}`); return null; };
|
|
4034
|
+
const prior = connectionRecord(root, API);
|
|
4035
|
+
const kp = prior && !inCloudSession() ? { did: prior.did, privateKeyBase64: prior.key } : generateKeypair();
|
|
4036
|
+
const cloud = inCloudSession();
|
|
4037
|
+
const project = (root === COMPUTER ? EVERY_PROJECT : projectLabel(root)).trim() || "project", machine = machineLabel(cloud).trim() || "machine";
|
|
4038
|
+
const proof = signBoundProof(kp.privateKeyBase64, { agentId: "connect", did: kp.did, action: "connect.start", amount: 0, scope: [host, project, machine, cloud ? "cloud" : "local"].join("\n") });
|
|
4039
|
+
const r = await api("/connect/start", { method: "POST", body: JSON.stringify({ host, project, machine, did: kp.did, cloud, proof }) }, "", true);
|
|
4040
|
+
if (r.status === 0)
|
|
4041
|
+
return fail(`couldn't reach Decidio at ${API} - check your connection and run npx @decidio/sdk connect again.`);
|
|
4042
|
+
if (r.status === 404 || r.status === 401)
|
|
4043
|
+
return fail(`${r.body?.message ?? "Connecting a coding assistant isn't switched on for this Decidio yet - it is on in the Decidio lab."} (${API})`);
|
|
4044
|
+
if (r.status === 429)
|
|
4045
|
+
return fail(`${r.body?.message ?? "Too many tries - wait a minute and try again."}`);
|
|
4046
|
+
if (r.status !== 201 || typeof r.body?.pollSecret !== "string" || typeof r.body?.link !== "string")
|
|
4047
|
+
return fail(`couldn't start a connection (${r.status}${r.body?.message ? `: ${r.body.message}` : ""}). Nothing was saved.`);
|
|
4048
|
+
const p = { v: 1, api: API, root, project, host, machine, cloud, did: kp.did, key: kp.privateKeyBase64, code: String(r.body.code), link: String(r.body.link),
|
|
4049
|
+
pollSecret: r.body.pollSecret, expiresAt: String(r.body.expiresAt), interval: Number(r.body.interval) || 2, startedAt: new Date().toISOString() };
|
|
4050
|
+
mkdirSync(connectDir(), { recursive: true });
|
|
4051
|
+
writeSecretFile(pendingConnectFile(root), JSON.stringify(p) + "\n");
|
|
4052
|
+
if (typeof r.body.qr === "string")
|
|
4053
|
+
pendingQr = r.body.qr;
|
|
4054
|
+
return p;
|
|
4055
|
+
}
|
|
4056
|
+
let pendingQr = null;
|
|
4057
|
+
function readPendingConnect(root) {
|
|
4058
|
+
const p = readJsonQuiet(pendingConnectFile(root));
|
|
4059
|
+
return p && p.v === 1 && p.api === API && p.root === root && typeof p.pollSecret === "string" ? p : null;
|
|
4060
|
+
}
|
|
4061
|
+
async function hookProvision() {
|
|
4062
|
+
const scope = COMPUTER;
|
|
4063
|
+
if (process.env.DECIDIO_AGENT_ID) {
|
|
4064
|
+
const named = printableName(process.env.DECIDIO_AGENT_ID) ?? "(unprintable)";
|
|
4065
|
+
return { ok: false, reason: `this project names the Decidio agent "${named}" (in its .env or the shell) but has no token for it. Ask the user to check it with npx @decidio/sdk doctor, or to remove DECIDIO_AGENT_ID to use this computer's connection.`, user: `Decidio: "${named}" has no token here` };
|
|
4066
|
+
}
|
|
4067
|
+
const own = (SHELL_ENV.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
|
|
4068
|
+
if (API !== own) {
|
|
4069
|
+
const host = printableName(redactUrl(API)) ?? "(unprintable)";
|
|
4070
|
+
return { ok: false, reason: `this project's .env points Decidio at ${host}; the hook won't connect this computer to a server a repository names. If the user trusts it, they run npx @decidio/sdk connect themselves.`, user: `Decidio: not connecting to ${host} (named by this project)` };
|
|
4071
|
+
}
|
|
4072
|
+
try {
|
|
4073
|
+
assertSafeTransport(API, true);
|
|
4074
|
+
}
|
|
4075
|
+
catch (e) {
|
|
4076
|
+
return { ok: false, reason: `${String(e?.message ?? e).replace(/^\[decidio\] /, "")} - the connection's secret would cross in clear.`, user: "Decidio: refusing a plaintext connection" };
|
|
4077
|
+
}
|
|
4078
|
+
const linkWords = (p, fresh) => {
|
|
4079
|
+
const mins = minutesLeft(p.expiresAt) || 1;
|
|
4080
|
+
return { ok: false,
|
|
4081
|
+
reason: `this computer isn't connected to Decidio yet${fresh ? "" : " (its link is still waiting to be allowed)"}. If Decidio's connect_device tool is available to you (the Decidio connector), tell the user you are connecting this computer to their Decidio workspace, call connect_device with the code ${p.code}, run the command it returns, then retry. Otherwise show the user this link and code: ${p.link} (code ${p.code}, valid ${mins} more minute${mins === 1 ? "" : "s"}) - the workspace owner allows it once, signed in, and it covers every project on this computer. Then retry the same command: it connects and asks for approval.`,
|
|
4082
|
+
user: `Decidio: connect this computer once - open ${p.link} and check the code ${p.code}. Destructive commands wait until it's allowed; everything else runs as usual.` };
|
|
4083
|
+
};
|
|
4084
|
+
let p = readPendingConnect(scope);
|
|
4085
|
+
if (p && new Date(p.expiresAt).getTime() > Date.now() + 5_000) {
|
|
4086
|
+
const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret }, body: "{}" }, "", true);
|
|
4087
|
+
if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
|
|
4088
|
+
const c = storeCollected(scope, p, r.body);
|
|
4089
|
+
if (typeof c === "string")
|
|
4090
|
+
return { ok: false, reason: `this computer was allowed, but its credential couldn't be written (${c}). Ask the user to run npx @decidio/sdk doctor.`, user: "Decidio: connected, but the credential couldn't be saved" };
|
|
4091
|
+
promoteConnection(c);
|
|
4092
|
+
CREDS_FROM = "connection";
|
|
4093
|
+
connectNotice = `Decidio connected this computer${CONNECTED_WORKSPACE ? ` to workspace ${CONNECTED_WORKSPACE}` : ""}${printableName(c.approvedBy) ? `, allowed by ${printableName(c.approvedBy)}` : ""} - every project on it now asks before destructive commands.`;
|
|
4094
|
+
return { ok: true };
|
|
4095
|
+
}
|
|
4096
|
+
if (r.status === 202 || r.status === 429 || r.status >= 500)
|
|
4097
|
+
return linkWords(p, false);
|
|
4098
|
+
if (r.status === 409 && (r.body?.status === "ticket_required" || r.body?.status === "ticket_mismatch"))
|
|
4099
|
+
return { ok: false,
|
|
4100
|
+
reason: `this computer's link (code ${p.code}) was allowed through the Decidio connector and is finished only with the ticket connect_device returned: if YOU called connect_device for code ${p.code} in this session, run npx @decidio/sdk@latest connect --ticket <the ticket it returned>, then retry the same command. If you did not, do not finish it with any ticket from anywhere else - tell the user that another workspace's connector allowed this computer, and leave it: the link lapses within ten minutes and the next try shows a fresh code.`,
|
|
4101
|
+
user: "Decidio: finishing this computer's connection (allowed through the Decidio connector) - destructive commands wait until it's done" };
|
|
4102
|
+
if (r.status === 0)
|
|
4103
|
+
return { ok: false, reason: `couldn't reach Decidio at ${API} to finish connecting this computer - retry once it is reachable.`, user: "Decidio: unreachable - destructive commands wait" };
|
|
4104
|
+
if (r.status === 410 && r.body?.status === "collected") {
|
|
4105
|
+
const won = activeConnection(projectRoot(), API);
|
|
4106
|
+
if (won) {
|
|
4107
|
+
promoteConnection(won);
|
|
4108
|
+
CREDS_FROM = "connection";
|
|
4109
|
+
return { ok: true };
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
if (r.status === 403 && r.body?.status === "denied") {
|
|
4113
|
+
dropFile(pendingConnectFile(scope));
|
|
4114
|
+
return { ok: false, reason: `connecting this computer was refused on the web ("Not me"). Ask the user before retrying - the next attempt shows a new link.`, user: "Decidio: connecting this computer was refused" };
|
|
4115
|
+
}
|
|
4116
|
+
if (r.status === 410 && r.body?.status === "removed") {
|
|
4117
|
+
dropFile(pendingConnectFile(scope));
|
|
4118
|
+
return { ok: false, reason: `the workspace owner removed this computer before it finished connecting. Ask the user before retrying - the next attempt shows a new link, and only the owner's Allow on the web brings it back.`, user: "Decidio: the workspace owner removed this computer" };
|
|
4119
|
+
}
|
|
4120
|
+
dropFile(pendingConnectFile(scope));
|
|
4121
|
+
}
|
|
4122
|
+
const lock = pendingConnectFile(scope) + ".lock";
|
|
4123
|
+
mkdirSync(connectDir(), { recursive: true });
|
|
4124
|
+
let fd = null;
|
|
4125
|
+
try {
|
|
4126
|
+
fd = openSync(lock, "wx", 0o600);
|
|
4127
|
+
}
|
|
4128
|
+
catch {
|
|
4129
|
+
try {
|
|
4130
|
+
if (Date.now() - statSync(lock).mtimeMs > 30_000) {
|
|
4131
|
+
rmSync(lock, { force: true });
|
|
4132
|
+
fd = openSync(lock, "wx", 0o600);
|
|
4133
|
+
}
|
|
4134
|
+
}
|
|
4135
|
+
catch {
|
|
4136
|
+
fd = null;
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
4139
|
+
if (fd !== null && (p = readPendingConnect(scope)) && new Date(p.expiresAt).getTime() > Date.now() + 5_000) {
|
|
4140
|
+
try {
|
|
4141
|
+
closeSync(fd);
|
|
4142
|
+
}
|
|
4143
|
+
catch { }
|
|
4144
|
+
dropFile(lock);
|
|
4145
|
+
return linkWords(p, false);
|
|
4146
|
+
}
|
|
4147
|
+
if (fd === null) {
|
|
4148
|
+
for (let i = 0; i < 20 && !(p = readPendingConnect(scope)); i++)
|
|
4149
|
+
await new Promise((res) => setTimeout(res, 250));
|
|
4150
|
+
if (p)
|
|
4151
|
+
return linkWords(p, false);
|
|
4152
|
+
return { ok: false, reason: "another Decidio check on this computer is connecting it right now - retry the same command in a few seconds.", user: "Decidio: connecting this computer" };
|
|
4153
|
+
}
|
|
4154
|
+
try {
|
|
4155
|
+
const started = await startConnect(scope, "claude-code", true);
|
|
4156
|
+
if (!started)
|
|
4157
|
+
return { ok: false, reason: `this computer isn't connected to Decidio, and a connection couldn't be started: ${connectError}`, user: "Decidio: this computer isn't connected" };
|
|
4158
|
+
return linkWords(started, true);
|
|
4159
|
+
}
|
|
4160
|
+
finally {
|
|
4161
|
+
try {
|
|
4162
|
+
closeSync(fd);
|
|
4163
|
+
}
|
|
4164
|
+
catch { }
|
|
4165
|
+
dropFile(lock);
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
function toldOnce(session, binding) {
|
|
4169
|
+
if (!session)
|
|
4170
|
+
return "hold";
|
|
4171
|
+
const dir = joinPath(connectDir(), "told");
|
|
4172
|
+
const f = joinPath(dir, createHash("sha256").update(`${API}\n${session}\n${binding}`).digest("hex").slice(0, 40));
|
|
4173
|
+
try {
|
|
4174
|
+
if (existsSync(f))
|
|
4175
|
+
return "passed";
|
|
4176
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
4177
|
+
const week = Date.now() - 7 * 86_400_000;
|
|
4178
|
+
for (const old of readdirSync(dir)) {
|
|
4179
|
+
try {
|
|
4180
|
+
if (statSync(joinPath(dir, old)).mtimeMs < week)
|
|
4181
|
+
rmSync(joinPath(dir, old), { force: true });
|
|
4182
|
+
}
|
|
4183
|
+
catch { }
|
|
4184
|
+
}
|
|
4185
|
+
writeFileSync(f, "", { mode: 0o600, flag: "wx" });
|
|
4186
|
+
return "noted";
|
|
4187
|
+
}
|
|
4188
|
+
catch {
|
|
4189
|
+
return "hold";
|
|
4190
|
+
}
|
|
4191
|
+
}
|
|
4192
|
+
async function guardPausedNow(cfg, sessionKey, category) {
|
|
4193
|
+
try {
|
|
4194
|
+
assertSafeTransport(cfg.apiUrl, true);
|
|
4195
|
+
const r = await fetch(cfg.apiUrl.replace(/\/$/, "") + "/agent/guard/check", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiToken}` },
|
|
4196
|
+
body: JSON.stringify({ session: sessionKey, category }), signal: AbortSignal.timeout(10_000) });
|
|
4197
|
+
const body = await r.json().catch(() => null);
|
|
4198
|
+
return r.ok && body?.paused === true ? "paused" : r.ok && body?.paused === false ? "on" : "unknown";
|
|
4199
|
+
}
|
|
4200
|
+
catch {
|
|
4201
|
+
return "unknown";
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
4204
|
+
function pauseNote(session) {
|
|
4205
|
+
return joinPath(connectDir(), "paused", createHash("sha256").update(`${API}\n${session}`).digest("hex").slice(0, 40));
|
|
4206
|
+
}
|
|
4207
|
+
function notePause(session, paused) {
|
|
4208
|
+
const f = pauseNote(session);
|
|
4209
|
+
try {
|
|
4210
|
+
if (!paused)
|
|
4211
|
+
return dropFile(f);
|
|
4212
|
+
const dir = dirname(f);
|
|
4213
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
4214
|
+
for (const old of readdirSync(dir)) {
|
|
4215
|
+
try {
|
|
4216
|
+
if (statSync(joinPath(dir, old)).mtimeMs < Date.now() - GUARD_PAUSE_HOURS * 3_600_000)
|
|
4217
|
+
rmSync(joinPath(dir, old), { force: true });
|
|
4218
|
+
}
|
|
4219
|
+
catch { }
|
|
4220
|
+
}
|
|
4221
|
+
writeFileSync(f, "", { mode: 0o600 });
|
|
4222
|
+
}
|
|
4223
|
+
catch { }
|
|
4224
|
+
}
|
|
4225
|
+
function dropFile(f) { try {
|
|
4226
|
+
rmSync(f, { force: true });
|
|
4227
|
+
}
|
|
4228
|
+
catch { } }
|
|
4229
|
+
const minutesLeft = (iso) => Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 60_000));
|
|
4230
|
+
function showConnectLink(p, opened, tty) {
|
|
4231
|
+
const title = `Connect ${connectHostLabel(p.host)} to Decidio`;
|
|
4232
|
+
log(`${brand()} ${bold(title)}`);
|
|
4233
|
+
log(` Open: ${link(p.link)}`);
|
|
4234
|
+
log(` Code: ${bold(p.code)} ${dim("(the page shows the same code - only allow it if they match)")}`);
|
|
4235
|
+
log(dim(` ${p.project} · ${p.machine} · the link works for ${minutesLeft(p.expiresAt) || 1} more minute${minutesLeft(p.expiresAt) === 1 ? "" : "s"}${opened ? " · opened in your browser" : ""}`));
|
|
4236
|
+
if (tty && pendingQr) {
|
|
4237
|
+
log("");
|
|
4238
|
+
for (const line of pendingQr.split("\n"))
|
|
4239
|
+
if (line.trim())
|
|
4240
|
+
log(` ${line}`);
|
|
4241
|
+
log(dim(" Scan with your phone's camera to allow it there."));
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
async function cmdConnect(opts) {
|
|
4245
|
+
const host = (opts.host ?? "claude-code");
|
|
4246
|
+
if (host !== "claude-code") {
|
|
4247
|
+
err(`✘ connect knows Claude Code today (--host claude-code); "${opts.host}" is not supported yet.`);
|
|
4248
|
+
return 2;
|
|
4249
|
+
}
|
|
4250
|
+
if (opts.ticket !== undefined && (opts.wait || opts.team || opts.force || opts.holdEveryone)) {
|
|
4251
|
+
err("✘ --ticket finishes a link the Decidio connector allowed and takes no other option.");
|
|
4252
|
+
return 2;
|
|
4253
|
+
}
|
|
4254
|
+
if (opts.holdEveryone && !opts.team && !inCloudSession()) {
|
|
4255
|
+
err("✘ --hold-everyone is for the shared file (--team, or a cloud session): it keeps refusing a computer that isn't connected.");
|
|
4256
|
+
return 2;
|
|
4257
|
+
}
|
|
4258
|
+
try {
|
|
4259
|
+
assertSafeTransport(API, true);
|
|
4260
|
+
}
|
|
4261
|
+
catch (e) {
|
|
4262
|
+
err(`✘ ${String(e?.message ?? e).replace(/^\[decidio\] /, "")}`);
|
|
4263
|
+
return 1;
|
|
4264
|
+
}
|
|
4265
|
+
const root = projectRoot();
|
|
4266
|
+
if (opts.ticket !== undefined)
|
|
4267
|
+
return connectWithTicket(root, opts.ticket);
|
|
4268
|
+
const tty = !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
4269
|
+
if (CREDS_FROM === "shell" || CREDS_FROM === ".env") {
|
|
4270
|
+
log(`• this project already has Decidio credentials ${CREDS_FROM === "shell" ? "from your shell" : `in ${ENV_PATH}`} for "${process.env.DECIDIO_AGENT_ID ?? defaultAgentId()}" - the hook uses those, so connect leaves them alone.`);
|
|
4271
|
+
log(dim(` To use a connection instead, remove DECIDIO_API_TOKEN / DECIDIO_AGENT_ID from ${CREDS_FROM === "shell" ? "your shell" : ENV_PATH} and run npx @decidio/sdk connect again.`));
|
|
4272
|
+
return finishConnect(root, null, opts, false);
|
|
4273
|
+
}
|
|
4274
|
+
const existing = activeConnection(root, API);
|
|
4275
|
+
if (existing && !opts.force && new Date(existing.expiresAt).getTime() > Date.now())
|
|
4276
|
+
return finishConnect(root, existing, opts, false);
|
|
4277
|
+
const scope = connectScope(root);
|
|
4278
|
+
if (opts.wait)
|
|
4279
|
+
return waitConnect(root, scope, opts, false);
|
|
4280
|
+
let p = readPendingConnect(scope);
|
|
4281
|
+
if (!p || p.host !== host || new Date(p.expiresAt).getTime() <= Date.now() + 15_000)
|
|
4282
|
+
p = await startConnect(scope, host);
|
|
4283
|
+
if (!p)
|
|
4284
|
+
return 1;
|
|
4285
|
+
const opened = !opts.noBrowser && localDesktop() && openInBrowser(p.link);
|
|
4286
|
+
showConnectLink(p, opened, tty);
|
|
4287
|
+
if (tty) {
|
|
4288
|
+
log("");
|
|
4289
|
+
return waitConnect(root, scope, opts, true);
|
|
4290
|
+
}
|
|
4291
|
+
log("");
|
|
4292
|
+
log(`Next: allow it at the link above, then run ${bold("npx @decidio/sdk connect --wait")} - it finishes by itself (nothing else to type).`);
|
|
4293
|
+
log(dim("Assistants: show the person the link and the code above, then run npx @decidio/sdk connect --wait."));
|
|
4294
|
+
return 0;
|
|
4295
|
+
}
|
|
4296
|
+
async function connectWithTicket(root, ticket) {
|
|
4297
|
+
if (!/^[A-Za-z0-9_-]{32,128}$/.test(ticket)) {
|
|
4298
|
+
err("✘ --ticket needs the ticket connect_device returned (letters, digits, - and _).");
|
|
4299
|
+
return 2;
|
|
4300
|
+
}
|
|
4301
|
+
const scope = connectScope(root);
|
|
4302
|
+
const p = readPendingConnect(scope);
|
|
4303
|
+
if (!p || new Date(p.expiresAt).getTime() <= Date.now()) {
|
|
4304
|
+
const c = activeConnection(root, API);
|
|
4305
|
+
if (c) {
|
|
4306
|
+
log(`✔ This computer is already connected to Decidio${printableName(c.workspaceName) ? ` - workspace ${printableName(c.workspaceName)}` : ""}. Retry the command.`);
|
|
4307
|
+
return 0;
|
|
4308
|
+
}
|
|
4309
|
+
err("✘ this computer has no link waiting to be finished - retry the command Decidio refused (it shows a fresh code), then call connect_device with that code.");
|
|
4310
|
+
return 1;
|
|
4311
|
+
}
|
|
4312
|
+
const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret, "x-decidio-connect-ticket": ticket }, body: "{}" }, "", true);
|
|
4313
|
+
if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
|
|
4314
|
+
const c = storeCollected(scope, p, r.body);
|
|
4315
|
+
if (typeof c === "string") {
|
|
4316
|
+
err(`✘ connected, but this computer's credential couldn't be written (${c}) - run npx @decidio/sdk doctor.`);
|
|
4317
|
+
return 1;
|
|
4318
|
+
}
|
|
4319
|
+
const ws = printableName(c.workspaceName), approver = printableName(c.approver);
|
|
4320
|
+
log(`✔ Connected this computer to Decidio${ws ? ` - workspace ${ws}` : ""}, through the Decidio connector.`);
|
|
4321
|
+
log(`✔ Retry the command Decidio refused: it now asks ${approver ?? "the approver"} before it runs.`);
|
|
4322
|
+
if (r.body.trust === "unconfirmed")
|
|
4323
|
+
log(dim(" Until the workspace owner confirms this computer is theirs (Decidio sent them a notification), every destructive command it asks about goes to a person."));
|
|
4324
|
+
else if (r.body.trust === "cloud")
|
|
4325
|
+
log(dim(" This cloud machine always asks a person for its destructive commands."));
|
|
4326
|
+
return 0;
|
|
4327
|
+
}
|
|
4328
|
+
if (r.status === 202) {
|
|
4329
|
+
err(`✘ this computer's link (code ${p.code}) hasn't been allowed yet - call connect_device with that code first, then run this again with the ticket it returns.`);
|
|
4330
|
+
return 3;
|
|
4331
|
+
}
|
|
4332
|
+
if (r.status === 409 && r.body?.status === "ticket_mismatch") {
|
|
4333
|
+
err(`✘ that ticket is for another link - call connect_device with this computer's code (${p.code}) and use the ticket it returns.`);
|
|
4334
|
+
return 3;
|
|
4335
|
+
}
|
|
4336
|
+
if (r.status === 403 && r.body?.status === "denied") {
|
|
4337
|
+
dropFile(pendingConnectFile(scope));
|
|
4338
|
+
err("✘ this computer's link was refused on the web (Not me). Nothing was saved.");
|
|
4339
|
+
return 3;
|
|
4340
|
+
}
|
|
4341
|
+
if (r.status === 410 && r.body?.status === "removed") {
|
|
4342
|
+
dropFile(pendingConnectFile(scope));
|
|
4343
|
+
err("✘ the workspace owner removed this computer before it finished connecting. Nothing was saved - ask them before trying again.");
|
|
4344
|
+
return 3;
|
|
4345
|
+
}
|
|
4346
|
+
if ((r.status === 404 || r.status === 410) && (r.body?.status === "expired" || r.body?.status === "collected")) {
|
|
4347
|
+
const won = r.body.status === "collected" ? activeConnection(root, API) : null;
|
|
4348
|
+
if (won) {
|
|
4349
|
+
log("✔ This computer is connected (another run finished it first). Retry the command.");
|
|
4350
|
+
return 0;
|
|
4351
|
+
}
|
|
4352
|
+
dropFile(pendingConnectFile(scope));
|
|
4353
|
+
err("✘ this computer's link expired before it was finished - retry the command Decidio refused for a fresh code, then call connect_device again.");
|
|
4354
|
+
return 3;
|
|
4355
|
+
}
|
|
4356
|
+
if (r.status === 0) {
|
|
4357
|
+
err(`✘ couldn't reach Decidio at ${API} - check the connection and run this again (the ticket stays good for ten minutes).`);
|
|
4358
|
+
return 1;
|
|
4359
|
+
}
|
|
4360
|
+
err(`✘ Decidio answered ${r.status}${r.body?.message ? `: ${String(r.body.message).slice(0, 200)}` : ""}`);
|
|
4361
|
+
return 1;
|
|
4362
|
+
}
|
|
4363
|
+
function storeCollected(scope, p, body) {
|
|
4364
|
+
const c = { v: 1, api: API, root: scope, project: p.project, host: p.host, machine: p.machine, cloud: p.cloud, agentId: String(body.agentId), did: p.did, key: p.key,
|
|
4365
|
+
token: body.token, jti: String(body.jti), expiresAt: String(body.expiresAt), workspaceId: String(body.workspaceId), workspaceName: body.workspace?.name ?? null,
|
|
4366
|
+
approver: body.approver?.name ?? null, approvedBy: body.approvedBy?.name ?? null, appUrl: typeof body.appUrl === "string" ? body.appUrl : null, connectedAt: new Date().toISOString(),
|
|
4367
|
+
guardPausable: body.guardPausable !== false, ...(body.answered === false ? { answered: false } : {}) };
|
|
4368
|
+
mkdirSync(connectDir(), { recursive: true });
|
|
4369
|
+
try {
|
|
4370
|
+
writeSecretFile(connectionFile(scope), JSON.stringify(c) + "\n");
|
|
4371
|
+
}
|
|
4372
|
+
catch (e) {
|
|
4373
|
+
return String(e?.message ?? e);
|
|
4374
|
+
}
|
|
4375
|
+
dropFile(pendingConnectFile(scope));
|
|
4376
|
+
return c;
|
|
4377
|
+
}
|
|
4378
|
+
async function waitConnect(root, scope, opts, interactive) {
|
|
4379
|
+
const p = readPendingConnect(scope);
|
|
4380
|
+
if (!p) {
|
|
4381
|
+
const c = activeConnection(root, API);
|
|
4382
|
+
if (c)
|
|
4383
|
+
return finishConnect(root, c, opts, false);
|
|
4384
|
+
err("✘ no connection is in progress for this project - start one: npx @decidio/sdk connect");
|
|
4385
|
+
return 1;
|
|
4386
|
+
}
|
|
4387
|
+
const deadline = interactive ? new Date(p.expiresAt).getTime() + 5_000 : Math.min(new Date(p.expiresAt).getTime() + 5_000, Date.now() + CONNECT_WAIT_S() * 1000);
|
|
4388
|
+
const spin = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4389
|
+
let i = 0, misses = 0;
|
|
4390
|
+
if (!interactive)
|
|
4391
|
+
err(`· waiting for the connection to be allowed at ${p.link} (code ${p.code}; up to ${Math.round((deadline - Date.now()) / 1000)} s)`);
|
|
4392
|
+
while (Date.now() < deadline) {
|
|
4393
|
+
const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret }, body: "{}" }, "", true);
|
|
4394
|
+
if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
|
|
4395
|
+
if (interactive && TTY_OUT)
|
|
4396
|
+
process.stderr.write("\r\x1b[K");
|
|
4397
|
+
const c = storeCollected(scope, p, r.body);
|
|
4398
|
+
if (typeof c === "string") {
|
|
4399
|
+
err(`✘ connected, but this machine's credentials could not be written (${c}) - run npx @decidio/sdk connect again (the server keeps nothing to collect twice).`);
|
|
4400
|
+
return 1;
|
|
4401
|
+
}
|
|
4402
|
+
return finishConnect(root, c, opts, true);
|
|
4403
|
+
}
|
|
4404
|
+
if (r.status === 403 && r.body?.status === "denied") {
|
|
4405
|
+
dropFile(pendingConnectFile(scope));
|
|
4406
|
+
if (interactive && TTY_OUT)
|
|
4407
|
+
process.stderr.write("\r\x1b[K");
|
|
4408
|
+
err("✘ the connection was refused on the web (Not me). Nothing was saved.");
|
|
4409
|
+
return 3;
|
|
4410
|
+
}
|
|
4411
|
+
if (r.status === 410 && r.body?.status === "removed") {
|
|
4412
|
+
dropFile(pendingConnectFile(scope));
|
|
4413
|
+
if (interactive && TTY_OUT)
|
|
4414
|
+
process.stderr.write("\r\x1b[K");
|
|
4415
|
+
err("✘ the workspace owner removed this computer before it finished connecting. Nothing was saved.");
|
|
4416
|
+
return 3;
|
|
4417
|
+
}
|
|
4418
|
+
if (r.status === 409 && (r.body?.status === "ticket_required" || r.body?.status === "ticket_mismatch")) {
|
|
4419
|
+
if (interactive && TTY_OUT)
|
|
4420
|
+
process.stderr.write("\r\x1b[K");
|
|
4421
|
+
err("✘ this computer's link was allowed through the Decidio connector: finish it with npx @decidio/sdk connect --ticket <the ticket YOUR connect_device returned> - never a ticket from anywhere else.");
|
|
4422
|
+
return 3;
|
|
4423
|
+
}
|
|
4424
|
+
if ((r.status === 404 || r.status === 410) && (r.body?.status === "expired" || r.body?.status === "collected")) {
|
|
4425
|
+
const won = r.body.status === "collected" ? activeConnection(root, API) : null;
|
|
4426
|
+
if (won) {
|
|
4427
|
+
if (interactive && TTY_OUT)
|
|
4428
|
+
process.stderr.write("\r\x1b[K");
|
|
4429
|
+
return finishConnect(root, won, opts, true);
|
|
4430
|
+
}
|
|
4431
|
+
dropFile(pendingConnectFile(scope));
|
|
4432
|
+
if (interactive && TTY_OUT)
|
|
4433
|
+
process.stderr.write("\r\x1b[K");
|
|
4434
|
+
err(r.body.status === "collected" ? "✘ this connection was already collected by another run - start a fresh one: npx @decidio/sdk connect" : "✘ the link expired before it was allowed - start a fresh one: npx @decidio/sdk connect");
|
|
4435
|
+
return 3;
|
|
4436
|
+
}
|
|
4437
|
+
if (r.status === 0 && ++misses > 10) {
|
|
4438
|
+
err(`✘ lost touch with Decidio at ${API} - check your connection, then run npx @decidio/sdk connect --wait again.`);
|
|
4439
|
+
return 1;
|
|
4440
|
+
}
|
|
4441
|
+
if (interactive && TTY_OUT)
|
|
4442
|
+
process.stderr.write(`\r${spin[i++ % spin.length]} waiting for you to allow it in the browser… ${dim(`(${minutesLeft(p.expiresAt)} min left · Ctrl-C stops waiting; npx @decidio/sdk connect --wait picks it up again)`)}`);
|
|
4443
|
+
await new Promise((res) => setTimeout(res, (r.status === 429 ? 2 : 1) * Math.max(1, p.interval) * 1000));
|
|
4444
|
+
}
|
|
4445
|
+
if (interactive && TTY_OUT)
|
|
4446
|
+
process.stderr.write("\r\x1b[K");
|
|
4447
|
+
log(`• still waiting for it to be allowed: ${link(p.link)} (code ${p.code}, ${minutesLeft(p.expiresAt)} min left). Run npx @decidio/sdk connect --wait again once it is.`);
|
|
4448
|
+
return 1;
|
|
4449
|
+
}
|
|
4450
|
+
async function finishConnect(root, c, opts, fresh) {
|
|
4451
|
+
if (c) {
|
|
4452
|
+
promoteConnection(c);
|
|
4453
|
+
CREDS_FROM = "connection";
|
|
4454
|
+
}
|
|
4455
|
+
const cloud = inCloudSession();
|
|
4456
|
+
const shared = opts.team || cloud;
|
|
4457
|
+
const everywhere = !shared && c?.root === COMPUTER;
|
|
4458
|
+
const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
|
|
4459
|
+
const plugin = !cloud && decidioPluginEnabled(root);
|
|
4460
|
+
if (!plugin) {
|
|
4461
|
+
const rc = await cmdHookInstall({ project: shared || everywhere, dir: everywhere ? home : root, quiet: true, portable: everywhere, ...(shared && !opts.holdEveryone ? { unconnected: "tell" } : {}) });
|
|
4462
|
+
if (rc !== 0)
|
|
4463
|
+
return rc;
|
|
4464
|
+
}
|
|
4465
|
+
const probe = await api("/agent/status/doctor-scope-probe", {}, process.env.DECIDIO_API_TOKEN, true);
|
|
4466
|
+
const verified = probe.status === 404;
|
|
4467
|
+
const settings = everywhere ? "~/.claude/settings.json" : lastHookInstall ? relative(root, lastHookInstall.settingsPath).split(pathSep).join("/") || lastHookInstall.settingsPath : ".claude/settings.json";
|
|
4468
|
+
const hostName = connectHostLabel(c?.host ?? "claude-code");
|
|
4469
|
+
const approverName = printableName(c?.approver);
|
|
4470
|
+
if (c) {
|
|
4471
|
+
const wsName = printableName(c.workspaceName), allowedBy = printableName(c.approvedBy);
|
|
4472
|
+
log(`✔ ${fresh ? "Connected to Decidio" : "Already connected to Decidio"}${wsName ? ` - workspace ${wsName}` : ""}${fresh && allowedBy ? `, allowed by ${allowedBy}` : ""}`);
|
|
4473
|
+
if (c.answered === false)
|
|
4474
|
+
log(`• One question is still open for this computer: "Should another person approve this?" - the workspace owner answers it once on the Decidio Agents page${c.appUrl ? ` (${c.appUrl.replace(/\/+$/, "")}/connect/agents#coding-assistants)` : ""}. Until then its owner can't approve its destructive commands - another approver can, or they wait.`);
|
|
4475
|
+
else
|
|
4476
|
+
log(`✔ Approvals go to ${approverName ?? "its approver"} - on the phone app, Slack, Teams or the web`);
|
|
4477
|
+
}
|
|
4478
|
+
if (!verified) {
|
|
4479
|
+
err(probe.status === 401 || probe.status === 403
|
|
4480
|
+
? `✘ Decidio refused this connection's token (${probe.status}) - it was revoked or has expired. Reconnect: npx @decidio/sdk connect --force`
|
|
4481
|
+
: `⚠ couldn't check the token just now (${probe.status || "no answer"}) - the hooks are on; a gated command will say if anything is wrong.`);
|
|
4482
|
+
if (probe.status === 401 || probe.status === 403)
|
|
4483
|
+
return 1;
|
|
4484
|
+
}
|
|
4485
|
+
const changed = !plugin && lastHookInstall && (lastHookInstall.pre !== "kept" || lastHookInstall.post !== "kept");
|
|
4486
|
+
log(plugin
|
|
4487
|
+
? `✔ ${hostName} asks before destructive commands in this project (the Decidio plugin's hooks)`
|
|
4488
|
+
: `✔ ${hostName} asks before destructive commands in ${everywhere ? "every project on this computer" : "this project"} (${settings})`);
|
|
4489
|
+
log(dim(` What waits for a person's approval: deleting files (rm -rf), force-pushing, dropping tables, terraform and kubectl changes, publishing a package, curl | sh - and switching Decidio's own check off. Everything else runs as before.`));
|
|
4490
|
+
if (changed)
|
|
4491
|
+
log(dim(` Claude Code picks the settings change up by itself - if a destructive command is not asked about, open /hooks once (or restart it).`));
|
|
4492
|
+
if (c)
|
|
4493
|
+
log(dim(` Credentials: ${connectionFile(c.root)} (outside the repository, owner-only) · ${c.root === COMPUTER ? "every project on this computer" : "this project"} · until ${c.expiresAt.slice(0, 10)}${cloud ? ` · this cloud machine only - a fresh one connects again (the Decidio connector does it with no tap)${opts.holdEveryone ? ", and is refused destructive commands until it does" : ""}` : ""}`));
|
|
4494
|
+
if (!plugin && !everywhere && (shared || process.platform === "win32") && !sameDir(realOrSame(process.cwd()), root))
|
|
4495
|
+
log(dim(` Claude Code reads ${settings} from the folder it starts in - start it at ${root}.`));
|
|
4496
|
+
if (cloud && !opts.team)
|
|
4497
|
+
log(`• A cloud session reads the repository's settings, not a computer's, so the hooks are in ${settings} - not committed. Ask the person before committing it: committed, every cloud session of this repository asks too, and so does every teammate's clone (a teammate connects with npx @decidio/sdk connect).`);
|
|
4498
|
+
else if (shared)
|
|
4499
|
+
log(`• Commit ${settings}: every clone then asks before destructive commands (a teammate connects with npx @decidio/sdk connect).`);
|
|
4500
|
+
if (shared && !plugin)
|
|
4501
|
+
log(dim(opts.holdEveryone
|
|
4502
|
+
? " A computer that isn't connected is refused every destructive command until it connects (--hold-everyone)."
|
|
4503
|
+
: " A computer that isn't connected is told once (with how to connect), then the same command runs without Decidio's approval. To keep refusing it until it connects: npx @decidio/sdk connect --team --hold-everyone"));
|
|
4504
|
+
if (fresh && c?.answered !== false)
|
|
4505
|
+
log(dim(` Try it: ask ${hostName} to delete a scratch folder with rm -rf - the approval reaches ${approverName ?? "the approver"} on their phone.`));
|
|
4506
|
+
return 0;
|
|
4507
|
+
}
|
|
4508
|
+
const DECIDIO_PLUGIN = "decidio-approvals";
|
|
4509
|
+
function decidioPluginEnabled(root) {
|
|
4510
|
+
const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
|
|
4511
|
+
for (const f of [joinPath(root, ".claude", "settings.local.json"), joinPath(root, ".claude", "settings.json"), joinPath(home, ".claude", "settings.json")]) {
|
|
4512
|
+
const ep = readJsonQuiet(f)?.enabledPlugins;
|
|
4513
|
+
if (!ep || typeof ep !== "object" || Array.isArray(ep))
|
|
4514
|
+
continue;
|
|
4515
|
+
const named = Object.entries(ep).find(([k]) => k.split("@")[0] === DECIDIO_PLUGIN);
|
|
4516
|
+
if (named)
|
|
4517
|
+
return named[1] === true;
|
|
4518
|
+
}
|
|
4519
|
+
return false;
|
|
4520
|
+
}
|
|
4521
|
+
async function cmdDisconnect() {
|
|
4522
|
+
const root = projectRoot();
|
|
4523
|
+
const scope = connectScope(root);
|
|
4524
|
+
const c = loadConnection(scope, API);
|
|
4525
|
+
dropFile(pendingConnectFile(scope));
|
|
4526
|
+
const where = scope === COMPUTER ? "this computer" : "this project on this machine";
|
|
4527
|
+
if (!c) {
|
|
4528
|
+
log(`• ${where} isn't connected - nothing to do.`);
|
|
4529
|
+
return 0;
|
|
4530
|
+
}
|
|
4531
|
+
try {
|
|
4532
|
+
writeSecretFile(connectionFile(scope), JSON.stringify({ ...c, token: "", jti: "", disconnected: true }) + "\n");
|
|
4533
|
+
}
|
|
4534
|
+
catch (e) {
|
|
4535
|
+
err(`✘ couldn't update ${connectionFile(scope)} (${e?.message ?? e}) - delete it by hand to disconnect.`);
|
|
4536
|
+
return 1;
|
|
4537
|
+
}
|
|
4538
|
+
log(`✔ disconnected ${where} - its token is deleted.`);
|
|
4539
|
+
log(` Destructive commands are now refused${scope === COMPUTER ? " in every project here" : ""} until you connect again (the hooks stay on: switching Decidio off needs a person's approval).`);
|
|
4540
|
+
log(dim(` "${c.agentId}" stays listed in Decidio until its token expires (${c.expiresAt.slice(0, 10)}); to end it now, revoke it on the Agents page${c.appUrl ? `: ${c.appUrl.replace(/\/+$/, "")}/connect/agents` : ""}.`));
|
|
4541
|
+
return 0;
|
|
4542
|
+
}
|
|
3749
4543
|
async function inviteApprover(email) {
|
|
3750
4544
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
3751
4545
|
err(`✘ --approver needs an email address (got "${email}")`);
|
|
@@ -4911,7 +5705,7 @@ async function cmdGithubInit(opts) {
|
|
|
4911
5705
|
log(` ${line}`);
|
|
4912
5706
|
}
|
|
4913
5707
|
const agentsRel = "AGENTS.md", agentsAbs = joinPath(root, agentsRel);
|
|
4914
|
-
const appUrl = (
|
|
5708
|
+
const appUrl = appUrlFor(apiUrl);
|
|
4915
5709
|
const section = agentsMdSection(appUrl);
|
|
4916
5710
|
if (!plainRepoPath(root, agentsRel))
|
|
4917
5711
|
err(`✘ ${agentsRel} is a symbolic link - nothing was written through it`);
|
|
@@ -5025,6 +5819,21 @@ APPROVALS AND RECEIPTS
|
|
|
5025
5819
|
(never the receipt's own issuer field; --allow-unpinned: structural check only)
|
|
5026
5820
|
|
|
5027
5821
|
CODING AGENTS (Claude Code)
|
|
5822
|
+
npx @decidio/sdk connect connect this computer's coding assistant once, for every project on it: prints a
|
|
5823
|
+
link and a short code (a QR and the browser too, on a real terminal); the workspace
|
|
5824
|
+
owner allows it on the web, then this computer gets an ask-only credential (kept
|
|
5825
|
+
outside every repository) and the hooks below switch on in ~/.claude/settings.json.
|
|
5826
|
+
npx @decidio/sdk connect --wait finish a connect started by an assistant: returns once it is allowed, within 90 s
|
|
5827
|
+
(exit 0 connected · 3 refused or expired · 1 still waiting, run it again)
|
|
5828
|
+
--team: the shared .claude/settings.json every clone reads (a cloud session reads
|
|
5829
|
+
the repository's files, not a computer's, so there it is always the shared one); a
|
|
5830
|
+
computer that isn't connected is told once, then let through - --hold-everyone keeps
|
|
5831
|
+
refusing it · --no-browser · --force: a fresh token for this computer
|
|
5832
|
+
npx @decidio/sdk connect --ticket <t>
|
|
5833
|
+
finish the link the Decidio connector allowed (its connect_device tool returns the
|
|
5834
|
+
ticket): writes this computer's credential, nothing else
|
|
5835
|
+
npx @decidio/sdk disconnect delete this computer's connection (destructive commands are then refused until
|
|
5836
|
+
you connect again; the hooks stay on)
|
|
5028
5837
|
npx @decidio/sdk hook install [--project] [--approver <email>] [--cli <path>]
|
|
5029
5838
|
a person approves Claude Code's destructive shell commands before they run
|
|
5030
5839
|
(rm -rf, git push --force, DROP TABLE, terraform apply, kubectl delete,
|
|
@@ -5045,6 +5854,11 @@ CODING AGENTS (Claude Code)
|
|
|
5045
5854
|
(90 s; at most 120 s) for the approver, then refused with its decision id; the SAME command
|
|
5046
5855
|
retried after approval runs once - its execution is recorded and that
|
|
5047
5856
|
approval is spent. No credentials, no gate, no answer = refused (fail closed).
|
|
5857
|
+
--unconnected=tell (written into a shared file by connect): a computer with no
|
|
5858
|
+
credential is refused once per session and command, then let through with a line.
|
|
5859
|
+
npx @decidio/sdk hook prompt the UserPromptSubmit hook: the person's own typed "decidio pause" / "decidio start"
|
|
5860
|
+
pauses or resumes the guard for that session (never on an enterprise plan; every
|
|
5861
|
+
pause and every command run under it is recorded); other prompts pass untouched.
|
|
5048
5862
|
|
|
5049
5863
|
CONTEXT PACKS (Claude Code, Claude.ai, ChatGPT)
|
|
5050
5864
|
npx @decidio/sdk context create <slug> --title "ACME application" --trigger "whenever we discuss the ACME application"
|
|
@@ -5129,7 +5943,8 @@ NOTES
|
|
|
5129
5943
|
Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
|
|
5130
5944
|
3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
|
|
5131
5945
|
receipt written — pass --auto-approve-demo for a run that completes unattended), or
|
|
5132
|
-
hook wait: the approver said no
|
|
5946
|
+
hook wait: the approver said no, or connect --wait: the connection was refused or expired (1 there: still
|
|
5947
|
+
waiting - run it again). hook pretool/posttool always exit 0 (their answer is the JSON).
|
|
5133
5948
|
Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
|
|
5134
5949
|
pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
|
|
5135
5950
|
Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
|
|
@@ -5271,13 +6086,15 @@ async function main(argv) {
|
|
|
5271
6086
|
logout: { flags: [] },
|
|
5272
6087
|
phone: { flags: [] },
|
|
5273
6088
|
init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
|
|
6089
|
+
connect: { flags: ["--wait", "--no-browser", "--team", "--force", "--hold-everyone"], valued: ["--host", "--ticket"] },
|
|
6090
|
+
disconnect: { flags: [] },
|
|
5274
6091
|
doctor: { flags: [] },
|
|
5275
6092
|
approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
|
|
5276
6093
|
receipt: { flags: ["--json"] },
|
|
5277
6094
|
verify: { flags: ["--allow-unpinned"], valued: ["--issuer"] },
|
|
5278
6095
|
tokens: { flags: ["--mine"] },
|
|
5279
6096
|
dev: { flags: [], valued: ["--target", "--port"] },
|
|
5280
|
-
hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli"] },
|
|
6097
|
+
hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli", "--unconnected"] },
|
|
5281
6098
|
github: { flags: ["--agents-md"], valued: ["--environment", "--justification", "--decision", "--workflow", "--api"] },
|
|
5282
6099
|
deploys: { flags: ["--watch", "--json"], valued: ["--environment", "--repo", "--limit"] },
|
|
5283
6100
|
controls: { flags: ["--json"] },
|
|
@@ -5320,7 +6137,7 @@ async function main(argv) {
|
|
|
5320
6137
|
return 2;
|
|
5321
6138
|
}
|
|
5322
6139
|
}
|
|
5323
|
-
const MAX_POS = { login: 1, logout: 0, phone: 0, quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0, hook: 2, github: 1, decisions: 1 };
|
|
6140
|
+
const MAX_POS = { login: 1, logout: 0, phone: 0, quickstart: 1, init: 1, connect: 0, disconnect: 0, doctor: 0, receipt: 1, tokens: 2, dev: 0, hook: 2, github: 1, decisions: 1 };
|
|
5324
6141
|
const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || (cmd === "hook" && positionals[0] === "justify") || cmd === "verify";
|
|
5325
6142
|
if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
|
|
5326
6143
|
err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
|
|
@@ -5478,11 +6295,14 @@ async function main(argv) {
|
|
|
5478
6295
|
}
|
|
5479
6296
|
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") });
|
|
5480
6297
|
}
|
|
6298
|
+
case "connect": return cmdConnect({ wait: argsIn.includes("--wait"), host: valuedOpt(argsIn, "--host"), noBrowser: argsIn.includes("--no-browser"), team: argsIn.includes("--team"), force: argsIn.includes("--force"),
|
|
6299
|
+
ticket: valuedOpt(argsIn, "--ticket"), holdEveryone: argsIn.includes("--hold-everyone") });
|
|
6300
|
+
case "disconnect": return cmdDisconnect();
|
|
5481
6301
|
case "hook": {
|
|
5482
|
-
const VALUED = ["--timeout", "--approver", "--cli"];
|
|
6302
|
+
const VALUED = ["--timeout", "--approver", "--cli", "--unconnected"];
|
|
5483
6303
|
const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
|
|
5484
6304
|
const sub = pos[0];
|
|
5485
|
-
const allowed = { pretool: [], posttool: [], wait: ["--timeout"], install: ["--project", "--approver", "--cli"], rules: [], justify: [] };
|
|
6305
|
+
const allowed = { pretool: ["--unconnected"], posttool: [], session: [], prompt: [], wait: ["--timeout"], install: ["--project", "--approver", "--cli"], rules: [], justify: [] };
|
|
5486
6306
|
const given = argsIn.filter((x) => x.startsWith("--")).map((x) => x.split("=")[0]);
|
|
5487
6307
|
if (sub && allowed[sub]) {
|
|
5488
6308
|
const stray = given.find((f) => !allowed[sub].includes(f));
|
|
@@ -5492,8 +6312,10 @@ async function main(argv) {
|
|
|
5492
6312
|
}
|
|
5493
6313
|
}
|
|
5494
6314
|
switch (sub) {
|
|
5495
|
-
case "pretool": return cmdHookPretool();
|
|
6315
|
+
case "pretool": return cmdHookPretool({ unconnected: valuedOpt(argsIn, "--unconnected") === "tell" ? "tell" : "hold" });
|
|
5496
6316
|
case "posttool": return cmdHookPosttool();
|
|
6317
|
+
case "session": return cmdHookSession();
|
|
6318
|
+
case "prompt": return cmdHookPrompt();
|
|
5497
6319
|
case "wait": {
|
|
5498
6320
|
const t = valuedOpt(argsIn, "--timeout") ?? "600";
|
|
5499
6321
|
if (!/^\d+$/.test(t) || Number(t) < 1 || Number(t) > 86_400) {
|
|
@@ -5506,7 +6328,7 @@ async function main(argv) {
|
|
|
5506
6328
|
case "rules": return cmdHookRules();
|
|
5507
6329
|
case "justify": return cmdHookJustify(pos[1], pos.slice(2).join(" "));
|
|
5508
6330
|
default:
|
|
5509
|
-
err(`✘ hook needs a subcommand: install | wait <decision-id> | justify <decision-id> "<what changed>" | rules (pretool and
|
|
6331
|
+
err(`✘ hook needs a subcommand: install | wait <decision-id> | justify <decision-id> "<what changed>" | rules (pretool, posttool and session are run by Claude Code).\n\n${USAGE}`);
|
|
5510
6332
|
return 2;
|
|
5511
6333
|
}
|
|
5512
6334
|
}
|