@decidio/sdk 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
3
- import { randomBytes } from "node:crypto";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
3
+ import { randomBytes, createHash } from "node:crypto";
4
4
  import { createServer } from "node:http";
5
+ import { execFileSync } from "node:child_process";
5
6
  import { createInterface } from "node:readline";
6
7
  import { createRequire } from "node:module";
7
8
  import { generateKeypair } from "./signing.js";
8
- import { withApproval } from "./index.js";
9
- import { verifyReceipt, verifyChain } from "@decidio/verify";
9
+ import { withApproval, DecidioRateLimitError } from "./index.js";
10
+ import { verifyReceipt } from "@decidio/verify";
10
11
  const VERSION = (() => {
11
12
  try {
12
13
  return createRequire(import.meta.url)("../package.json").version;
@@ -27,32 +28,37 @@ function readEnvFile() {
27
28
  }
28
29
  for (const [k, v] of Object.entries(readEnvFile())) {
29
30
  if (k === "DECIDIO_SESSION_TOKEN") {
30
- console.log("⚠ 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.");
31
+ 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.");
31
32
  continue;
32
33
  }
33
34
  if (process.env[k] === undefined)
34
35
  process.env[k] = v;
35
36
  }
36
- const IS_QUICKSTART = process.argv[2] === "quickstart";
37
- const API = (process.env.DECIDIO_API_URL ?? (IS_QUICKSTART ? "https://decidio-api.onrender.com" : "http://localhost:4000")).replace(/\/$/, "");
37
+ const API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
38
38
  const TOKEN = () => process.env.DECIDIO_API_TOKEN;
39
39
  const ENV_PATH = ".env";
40
40
  const log = (...a) => console.log(...a);
41
+ const err = (...a) => console.error(...a);
42
+ let hostAnnounced = false;
41
43
  async function api(path, init = {}, bearer) {
44
+ if (!hostAnnounced) {
45
+ hostAnnounced = true;
46
+ err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
47
+ }
42
48
  const auth = bearer ?? TOKEN();
43
49
  let res;
44
50
  try {
45
51
  res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
46
52
  }
47
53
  catch (e) {
48
- log(`✘ could not reach Decidio at ${API} (${e?.cause?.code ?? e?.message ?? "network error"}).`);
54
+ err(`✘ could not reach Decidio at ${API} (${e?.cause?.code ?? e?.message ?? "network error"}).`);
49
55
  if (API.includes("localhost")) {
50
- log(` You're pointed at localhost — for the hosted sandbox run:`);
51
- log(` export DECIDIO_API_URL=https://decidio-api.onrender.com`);
52
- log(` (or put that line in .env), then re-run this command.`);
56
+ err(` You're pointed at localhost — for the hosted sandbox run:`);
57
+ err(` export DECIDIO_API_URL=https://decidio-api.onrender.com`);
58
+ err(` (or put that line in .env), then re-run this command.`);
53
59
  }
54
60
  else {
55
- log(` Check your network, then re-run. If the URL is wrong, set DECIDIO_API_URL and retry.`);
61
+ err(` Check your network, then re-run. If the URL is wrong, set DECIDIO_API_URL and retry.`);
56
62
  }
57
63
  process.exit(1);
58
64
  }
@@ -66,14 +72,29 @@ async function api(path, init = {}, bearer) {
66
72
  }
67
73
  return { status: res.status, body };
68
74
  }
75
+ function writeSecretFile(path, body) {
76
+ writeFileSync(path, body, { mode: 0o600 });
77
+ try {
78
+ chmodSync(path, 0o600);
79
+ }
80
+ catch { }
81
+ if (process.platform === "win32") {
82
+ try {
83
+ execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${process.env.USERNAME ?? process.env.USER ?? ""}:F`], { stdio: "ignore" });
84
+ }
85
+ catch {
86
+ console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
87
+ }
88
+ }
89
+ }
69
90
  function upsertEnv(updates) {
70
91
  const cur = readEnvFile();
71
92
  const merged = { ...cur, ...updates };
72
93
  const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
73
- writeFileSync(ENV_PATH, body);
94
+ writeSecretFile(ENV_PATH, body);
74
95
  }
75
96
  function ensureGitignore() {
76
- const entries = [".env", ".env.quickstart", ".decidio-pending/"];
97
+ const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
77
98
  const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
78
99
  const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
79
100
  if (!missing.length)
@@ -95,15 +116,15 @@ function ask(question, mute = false) {
95
116
  });
96
117
  }
97
118
  async function acquireSession(purpose) {
98
- if (process.env.DECIDIO_SESSION_TOKEN)
99
- return process.env.DECIDIO_SESSION_TOKEN;
100
119
  if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
101
- log(`✘ refusing to send sign-in credentials over plaintext http to ${API}.`);
120
+ err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
102
121
  log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
103
122
  return null;
104
123
  }
124
+ if (process.env.DECIDIO_SESSION_TOKEN)
125
+ return process.env.DECIDIO_SESSION_TOKEN;
105
126
  if (!process.stdin.isTTY) {
106
- log(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
127
+ err(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
107
128
  log(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
108
129
  log(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
109
130
  log(` 2. POST its ?login= value — the response's "token" is your session token:`);
@@ -138,10 +159,20 @@ async function acquireSession(purpose) {
138
159
  log(` Sign-in links are single-use and expire in 15 minutes — request a fresh one and re-run.`);
139
160
  return null;
140
161
  }
141
- async function cmdInit(agentId) {
162
+ async function cmdInit(agentId, opts = {}) {
142
163
  log(`Registering agent "${agentId}" with Decidio at ${API}…`);
143
- const existingKey = readEnvFile().DECIDIO_AGENT_KEY;
144
- const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || readEnvFile().DECIDIO_SOURCE_SYSTEM;
164
+ const existingEnv = readEnvFile();
165
+ const existingKey = existingEnv.DECIDIO_AGENT_KEY;
166
+ const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || existingEnv.DECIDIO_SOURCE_SYSTEM;
167
+ if (existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId && !opts.force) {
168
+ err(`✘ this directory's .env already holds the key for a DIFFERENT agent ("${existingEnv.DECIDIO_AGENT_ID}").`);
169
+ log(` Registering "${agentId}" here would overwrite that key — and a private key that is`);
170
+ log(` overwritten is gone for good (it never left this machine). Nothing was written.`);
171
+ log(` Pick one:`);
172
+ log(` 1. New directory (cleanest): mkdir ${agentId} && cd ${agentId} && npx @decidio/sdk init ${agentId}`);
173
+ log(` 2. Replace the old agent here, backing its .env up first: npx @decidio/sdk init ${agentId} --force`);
174
+ return 1;
175
+ }
145
176
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
146
177
  const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
147
178
  let session = null;
@@ -162,7 +193,7 @@ async function cmdInit(agentId) {
162
193
  }
163
194
  else {
164
195
  const suggestion = `${agentId}-${randomBytes(2).toString("hex")}`;
165
- log(`✘ "${agentId}" is already registered in this workspace, and its private key is not on this machine`);
196
+ err(`✘ "${agentId}" is already registered in this workspace, and its private key is not on this machine`);
166
197
  log(` (keys never leave the machine that generated them — likely a previous run, or another directory).`);
167
198
  log(` Nothing was written. Two ways forward:`);
168
199
  log(` 1. Fresh agent (most common): npx @decidio/sdk init ${suggestion}`);
@@ -176,21 +207,23 @@ async function cmdInit(agentId) {
176
207
  log(`✔ registered (did ${did}) — key generated locally; only the public half went to Decidio`);
177
208
  }
178
209
  else {
179
- log(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
210
+ err(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
180
211
  log(` Fix the cause above and re-run — init is safe to repeat.`);
181
212
  return 1;
182
213
  }
183
- let agentToken, workspaceId;
214
+ let agentToken, workspaceId, tokenJti;
184
215
  const mintBearer = session ?? TOKEN();
185
216
  if (mintBearer) {
186
- const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli" }) }, mintBearer);
217
+ const expiresInDays = opts.expiresDays ?? 90;
218
+ const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli", expiresInDays }) }, mintBearer);
187
219
  if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
188
220
  agentToken = mint.body.token;
189
221
  workspaceId = mint.body.workspaceId;
190
- log(`✔ minted the agent's API token — floor-limited: it can request approvals and nothing else${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""}`);
222
+ tokenJti = mint.body.jti;
223
+ log(`✔ minted the agent's API token — floor-limited: it can request authorization, poll status and report confirmation; it cannot administer the workspace or approve anything${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ` (${expiresInDays} days)`}`);
191
224
  }
192
225
  else if (session) {
193
- log(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
226
+ err(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
194
227
  log(` Without it the SDK has no runtime credential. Fix the cause above and re-run init — it's safe to repeat.`);
195
228
  return 1;
196
229
  }
@@ -210,11 +243,18 @@ async function cmdInit(agentId) {
210
243
  envOut.DECIDIO_API_TOKEN = agentToken;
211
244
  if (workspaceId)
212
245
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
246
+ if (tokenJti)
247
+ envOut.DECIDIO_TOKEN_JTI = tokenJti;
248
+ ensureGitignore();
249
+ if (opts.force && existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId) {
250
+ const backup = `.env.${existingEnv.DECIDIO_AGENT_ID.replace(/[^\w.-]/g, "_")}.bak`;
251
+ writeSecretFile(backup, readFileSync(ENV_PATH, "utf8"));
252
+ log(`✔ backed the previous agent's .env up to ${backup} (its key would otherwise be unrecoverable)`);
253
+ }
213
254
  upsertEnv(envOut);
214
255
  for (const [k, v] of Object.entries(envOut))
215
256
  process.env[k] = v;
216
- log(`✔ wrote ${ENV_PATH} — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
217
- ensureGitignore();
257
+ 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)`);
218
258
  if (privateKey)
219
259
  log(` (the agent's private signing key is in there — for production, move it to your secret manager)`);
220
260
  log("");
@@ -247,10 +287,11 @@ async function cmdDoctor() {
247
287
  const env = readEnvFile();
248
288
  let ok = true;
249
289
  const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
250
- line(!!(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL), `API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (unset — defaulting to localhost; the hosted sandbox is https://decidio-api.onrender.com)"}`);
290
+ log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
251
291
  line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset — run: npx @decidio/sdk init <agent-name>)"}`);
252
292
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
253
- line(!!secret, secret ? "webhook secret: set — operator-only transport (inactive against the hosted sandbox; the poll worker guard.worker() is the self-serve resume path)" : "webhook secret: MISSING — the resume webhook (operator deployments) would fail closed; init generates one");
293
+ log(secret ? "✔ webhook secret: set — operator-only transport (inactive against the hosted sandbox; the poll worker guard.worker() is the self-serve resume path)"
294
+ : "• webhook secret: not set — only needed for the operator resume-webhook transport; the poll worker guard.worker() is the self-serve resume path (init generates one)");
254
295
  const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
255
296
  if (signing)
256
297
  log("✔ signing key: set — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (deny-by-default).");
@@ -258,6 +299,24 @@ async function cmdDoctor() {
258
299
  log("⚠ signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
259
300
  const h = await api("/api/health", {}, "");
260
301
  line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
302
+ const tok = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
303
+ if (tok) {
304
+ try {
305
+ const payload = JSON.parse(Buffer.from(tok.split(".")[1] ?? "", "base64url").toString("utf8"));
306
+ if (typeof payload?.exp === "number") {
307
+ const msLeft = payload.exp * 1000 - Date.now();
308
+ const days = Math.floor(msLeft / 86_400_000);
309
+ const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${days} day(s)`;
310
+ if (msLeft < 0)
311
+ line(false, `agent token: EXPIRED ${-days} day(s) ago — re-run \`npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}\` to mint a fresh one.`);
312
+ else if (days <= 14)
313
+ log(`⚠ agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
314
+ else
315
+ log(`✔ agent token: valid for another ${days} days`);
316
+ }
317
+ }
318
+ catch { }
319
+ }
261
320
  const rec = await api("/api/records");
262
321
  if (rec.status >= 200 && rec.status < 300)
263
322
  log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
@@ -275,10 +334,21 @@ async function cmdDoctor() {
275
334
  log(ok ? "\ndoctor: all green." : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
276
335
  return ok ? 0 : 1;
277
336
  }
278
- async function cmdApprovals(sub, id, reason) {
279
- const jsonMode = sub === "--json";
280
- if (jsonMode)
337
+ async function cmdApprovals(sub, id, reason, flags = {}) {
338
+ const jsonMode = !!flags.json;
339
+ const mineOnly = !!flags.mine;
340
+ if (sub?.startsWith("--"))
281
341
  sub = undefined;
342
+ if ((sub === "approve" || sub === "reject") && !id) {
343
+ err(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
344
+ return 2;
345
+ }
346
+ if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
347
+ err(`✘ --mine needs an agent identity, and there is no DECIDIO_AGENT_ID in this directory's .env or your environment.`);
348
+ err(` Run this from your agent's directory (where init wrote .env), or drop --mine for the workspace-wide list.`);
349
+ return 2;
350
+ }
351
+ const decisionArg = id ?? "";
282
352
  let bearer;
283
353
  const probe = await api("/api/decisions/cards");
284
354
  if (probe.status === 401 || probe.status === 403) {
@@ -288,11 +358,7 @@ async function cmdApprovals(sub, id, reason) {
288
358
  bearer = session;
289
359
  }
290
360
  if (sub === "approve" || sub === "reject") {
291
- if (!id) {
292
- log(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
293
- return 2;
294
- }
295
- const r = await api(`/api/decisions/${encodeURIComponent(id)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
361
+ const r = await api(`/api/decisions/${encodeURIComponent(decisionArg)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
296
362
  if (r.status >= 200 && r.status < 300) {
297
363
  if (r.body?.alreadyResolved) {
298
364
  log(`• ${id} was already resolved — nothing changed.`);
@@ -300,7 +366,7 @@ async function cmdApprovals(sub, id, reason) {
300
366
  log(` See its sealed record: npx @decidio/sdk receipt ${id}`);
301
367
  return 1;
302
368
  }
303
- log(`✔ ${sub}d ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
369
+ log(`✔ ${sub === "approve" ? "approved" : "rejected"} ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
304
370
  if (sub === "approve")
305
371
  log(` The agent's function runs next — a blocking call resumes now; a durable one when its worker or resume route picks this up.`);
306
372
  else
@@ -309,33 +375,38 @@ async function cmdApprovals(sub, id, reason) {
309
375
  return 0;
310
376
  }
311
377
  if (r.status === 404) {
312
- log(`✘ no decision "${id}" in this workspace — check the id (npx @decidio/sdk approvals lists pending ones).`);
378
+ err(`✘ no decision "${id}" in this workspace — check the id (npx @decidio/sdk approvals lists pending ones).`);
313
379
  return 1;
314
380
  }
315
- log(`✘ ${sub} failed (${r.status}): ${JSON.stringify(r.body)}`);
381
+ err(`✘ ${sub} failed (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
316
382
  return 1;
317
383
  }
318
384
  const r = bearer ? await api("/api/decisions/cards", {}, bearer) : probe;
319
385
  if (!(r.status >= 200 && r.status < 300)) {
320
- log(`✘ could not list approvals (${r.status}): ${JSON.stringify(r.body)}`);
386
+ err(`✘ could not list approvals (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
321
387
  return 1;
322
388
  }
323
389
  const cards = r.body?.cards ?? [];
324
390
  const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
391
+ const myAgent = process.env.DECIDIO_AGENT_ID;
392
+ const isMine = (c) => !!myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
393
+ const shown = mineOnly ? pending.filter(isMine) : pending;
325
394
  if (jsonMode) {
326
- log(JSON.stringify(pending.map((c) => ({ decisionId: c.id, action: c.agentRequest?.action ?? null, amount: c.agentRequest?.amount ?? null, title: c.title ?? null })), null, 2));
395
+ log(JSON.stringify(shown.map((c) => ({ decisionId: c.id, action: c.agentRequest?.action ?? null, amount: c.agentRequest?.amount ?? null, title: c.title ?? null })), null, 2));
327
396
  return 0;
328
397
  }
329
398
  if (!pending.length) {
330
399
  log("No pending agent approvals. Trigger one from your protected function, then re-run this.");
331
400
  return 0;
332
401
  }
333
- const myAgent = process.env.DECIDIO_AGENT_ID;
334
- log(`Pending agent approvals (${pending.length})${myAgent ? ` — workspace-wide; "←" marks this directory's agent (${myAgent})` : ""}:`);
335
- for (const c of pending) {
402
+ if (mineOnly && !shown.length) {
403
+ log(`No pending approvals for "${myAgent}" (${pending.length} pending elsewhere in this workspace — drop --mine to see them).`);
404
+ return 0;
405
+ }
406
+ log(`Pending agent approvals (${shown.length}${mineOnly ? ` for "${myAgent}"` : ""})${!mineOnly && myAgent ? ` — workspace-wide; "←" marks this directory's agent (${myAgent}). Use --mine to list only those.` : ""}:`);
407
+ for (const c of shown) {
336
408
  const ar = c.agentRequest ?? {};
337
- const mine = myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
338
- log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}${mine ? " ←" : ""}`);
409
+ log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}${!mineOnly && isMine(c) ? " ←" : ""}`);
339
410
  }
340
411
  log(`\nApprove: npx @decidio/sdk approvals approve <id> [reason]`);
341
412
  log(`Reject: npx @decidio/sdk approvals reject <id> [reason]`);
@@ -343,9 +414,9 @@ async function cmdApprovals(sub, id, reason) {
343
414
  }
344
415
  async function cmdReceipt(id, jsonMode = false) {
345
416
  if (!id) {
346
- log("usage: npx @decidio/sdk receipt <decisionId>");
347
- log(" Downloads the decision's sealed Authority Receipt (a W3C Verifiable Credential) to a .json file.");
348
- log(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
417
+ err("usage: npx @decidio/sdk receipt <decisionId>");
418
+ err(" Downloads the decision's sealed Authority Receipt (a W3C Verifiable Credential) to a .json file.");
419
+ err(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
349
420
  return 2;
350
421
  }
351
422
  let bearer;
@@ -358,39 +429,47 @@ async function cmdReceipt(id, jsonMode = false) {
358
429
  r = await api(`/api/records/${encodeURIComponent(id)}/verify`, {}, bearer);
359
430
  }
360
431
  if (r.status === 404) {
361
- log(`✘ no sealed record for "${id}" in this workspace.`);
362
- log(` Receipts exist once a decision is SEALED (approved or rejected). Still pending? npx @decidio/sdk approvals`);
432
+ err(`✘ no sealed record for "${id}" in this workspace.`);
433
+ err(` Receipts exist once a decision is SEALED (approved or rejected). Still pending? npx @decidio/sdk approvals`);
363
434
  return 1;
364
435
  }
365
436
  if (!(r.status >= 200 && r.status < 300) || !r.body?.credential) {
366
- log(`✘ could not fetch the receipt (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
437
+ err(`✘ could not fetch the receipt (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
367
438
  return 1;
368
439
  }
369
440
  const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
370
441
  const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
371
442
  if (existsSync(name)) {
372
443
  if (readFileSync(name, "utf8") !== nextBytes) {
373
- log(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
444
+ err(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
374
445
  return 1;
375
446
  }
376
- log(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
447
+ (jsonMode ? err : log)(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
377
448
  }
378
449
  else {
379
450
  writeFileSync(name, nextBytes);
380
- log(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
451
+ (jsonMode ? err : log)(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
381
452
  }
382
453
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
383
454
  const conf = detail.body?.record?.confirmation;
455
+ const issuer = r.body?.issuer;
384
456
  if (jsonMode) {
385
- log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed }, null, 2));
457
+ log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null }, null, 2));
386
458
  return 0;
387
459
  }
388
460
  if (conf?.status)
389
461
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
390
462
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
391
463
  log(` npx @decidio/sdk verify ${name}`);
392
- log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
393
- log(` npx @decidio/sdk verify --issuer <did:key:...> ${name}`);
464
+ if (issuer) {
465
+ log(` Your workspace's issuer DID (from the authenticated response — record it once, then pin it forever):`);
466
+ log(` ${issuer}`);
467
+ log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
468
+ }
469
+ else {
470
+ log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
471
+ log(` npx @decidio/sdk verify --issuer <did:key:...> ${name}`);
472
+ }
394
473
  return 0;
395
474
  }
396
475
  function parseReceiptStrict(text, file) {
@@ -440,22 +519,82 @@ function parseReceiptStrict(text, file) {
440
519
  }
441
520
  return value;
442
521
  }
522
+ function chainOf(vcs, expectedIssuer) {
523
+ if (!vcs.length)
524
+ return { valid: false, length: 0, reason: "no receipts supplied" };
525
+ const issuers = [...new Set(vcs.map((v) => String(v?.issuer ?? "")))];
526
+ if (issuers.length > 1)
527
+ return { valid: false, length: 0, reason: `these receipts come from ${issuers.length} DIFFERENT issuers — a chain must be issued end to end by one issuer` };
528
+ let prev = null;
529
+ let anchor = "segment";
530
+ for (let i = 0; i < vcs.length; i++) {
531
+ const r = verifyReceiptDoor(vcs[i], expectedIssuer);
532
+ if (!r.valid)
533
+ return { valid: false, length: i, reason: `receipt ${i} (${r.recordId ?? "?"}) invalid: ${r.reason}` };
534
+ const cs = vcs[i]?.credentialSubject ?? {};
535
+ if (i === 0)
536
+ anchor = (cs.prevHash ?? null) === null ? "genesis" : "segment";
537
+ else if ((cs.prevHash ?? null) !== prev)
538
+ return { valid: false, length: i, reason: `receipt ${i} does not follow receipt ${i - 1} (prevHash mismatch)` };
539
+ prev = cs.contentHash;
540
+ }
541
+ return { valid: true, length: vcs.length, anchor };
542
+ }
543
+ function verifyReceiptDoor(vc, expectedIssuer) {
544
+ const res = verifyReceipt(vc, expectedIssuer);
545
+ if (!res.valid && res.reason === "issuer DID is not the expected issuer") {
546
+ const cs = vc?.credentialSubject ?? {};
547
+ const derive = (canonical, prevHash) => createHash("sha256").update((prevHash ?? "") + canonical).digest("hex");
548
+ const bound = typeof cs.recordCanonical === "string" && typeof cs.contentHash === "string"
549
+ && derive(cs.recordCanonical, typeof cs.prevHash === "string" ? cs.prevHash : null) === cs.contentHash;
550
+ return { ...res, contentBound: bound, recordId: typeof cs.recordId === "string" ? cs.recordId : res.recordId, reason: "issuer DID is not the expected issuer (the receipt itself may be internally consistent — it was just not issued by who you pinned)" };
551
+ }
552
+ return res;
553
+ }
443
554
  function cmdVerify(argsIn) {
444
- const issIdx = argsIn.indexOf("--issuer");
445
- if (issIdx >= 0 && (argsIn[issIdx + 1] === undefined || argsIn[issIdx + 1].startsWith("--"))) {
446
- log("✘ --issuer needs a did:key value.");
447
- return 2;
555
+ let expectedIssuer;
556
+ const files = [];
557
+ for (let i = 0; i < argsIn.length; i++) {
558
+ const a = argsIn[i];
559
+ if (a === "--issuer") {
560
+ const v = argsIn[i + 1];
561
+ if (v === undefined || v.startsWith("-")) {
562
+ err("✘ --issuer needs a did:key value.");
563
+ return 2;
564
+ }
565
+ expectedIssuer = v;
566
+ i++;
567
+ }
568
+ else if (a.startsWith("--issuer=")) {
569
+ const v = a.slice("--issuer=".length);
570
+ if (!v) {
571
+ err("✘ --issuer needs a did:key value.");
572
+ return 2;
573
+ }
574
+ expectedIssuer = v;
575
+ }
576
+ else if (!a.startsWith("-")) {
577
+ files.push(a);
578
+ }
448
579
  }
449
- const expectedIssuer = issIdx >= 0 ? argsIn[issIdx + 1] : undefined;
450
- const files = argsIn.filter((a, i) => !a.startsWith("--") && !(issIdx >= 0 && i === issIdx + 1));
451
580
  if (!files.length) {
452
- log("usage: npx @decidio/sdk verify [--issuer <did:key>] <receipt.json> [more.json ...]");
453
- log(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
581
+ err("usage: npx @decidio/sdk verify [--issuer <did:key>] <receipt.json> [more.json ...]");
582
+ err(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
454
583
  return 2;
455
584
  }
585
+ const texts = [];
586
+ for (const f of files) {
587
+ try {
588
+ texts.push(readFileSync(f, "utf8"));
589
+ }
590
+ catch (e) {
591
+ err(`✘ cannot read ${f}: ${e?.code === "ENOENT" ? "no such file" : e?.message ?? e} (nothing was verified)`);
592
+ return 1;
593
+ }
594
+ }
456
595
  let vcs;
457
596
  try {
458
- vcs = files.map((f) => parseReceiptStrict(readFileSync(f, "utf8"), f));
597
+ vcs = files.map((f, i) => parseReceiptStrict(texts[i], f));
459
598
  }
460
599
  catch (e) {
461
600
  log(`✘ INVALID ${String(e?.message ?? e)}`);
@@ -463,24 +602,28 @@ function cmdVerify(argsIn) {
463
602
  }
464
603
  let allOk = true;
465
604
  vcs.forEach((vc, i) => {
466
- const res = verifyReceipt(vc, expectedIssuer);
605
+ const res = verifyReceiptDoor(vc, expectedIssuer);
467
606
  allOk = allOk && res.valid;
468
607
  log(`${res.valid ? "✔ VALID " : "✘ INVALID"} ${files[i]}`);
469
608
  log(` record: ${res.recordId ?? "?"} issuer: ${res.issuer}${expectedIssuer ? "" : " (unpinned — pass --issuer to bind to your workspace's DID)"}`);
470
609
  log(` content-bound: ${res.contentBound}${res.reason ? ` reason: ${res.reason}` : ""}`);
471
610
  });
472
611
  if (vcs.length > 1) {
473
- const c = verifyChain(vcs, expectedIssuer);
612
+ const c = chainOf(vcs, expectedIssuer);
474
613
  allOk = allOk && c.valid;
475
- log(`\nchain: ${c.valid ? "✔ intact" : "✘ broken"} (${c.length}/${vcs.length})${c.reason ? ` — ${c.reason}` : ""}`);
614
+ log(`\nchain: ${c.valid ? "✔ intact" : "✘ broken"} (${c.length}/${vcs.length})${c.valid ? (c.anchor === "segment" ? " — segment: ordering proven from the FIRST file forward; history before it is not covered by these receipts" : " — from genesis") : ""}${c.reason ? ` — ${c.reason}` : ""}`);
476
615
  }
477
616
  return allOk ? 0 : 1;
478
617
  }
479
- async function cmdQuickstart() {
618
+ async function cmdQuickstart(opts = {}) {
480
619
  const t0 = Date.now();
481
620
  log("Decidio quickstart — gate an action, route it to a human, execute on approval, hold the proof.");
482
621
  log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
483
622
  log("");
623
+ log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
624
+ log(" write .env (or .env.quickstart) + .gitignore entries · create one synthetic decision");
625
+ log(" and its sealed record (records are permanent by design). Cleanup offer at the end.");
626
+ log("");
484
627
  const session = await acquireSession("The quickstart");
485
628
  if (!session)
486
629
  return 1;
@@ -489,30 +632,40 @@ async function cmdQuickstart() {
489
632
  const { did, privateKeyBase64 } = generateKeypair();
490
633
  const reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
491
634
  if (!(reg.status >= 200 && reg.status < 300)) {
492
- log(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
635
+ err(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
493
636
  return 1;
494
637
  }
495
- const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart" }) }, session);
638
+ const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
496
639
  if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
497
- log(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
640
+ err(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
498
641
  return 1;
499
642
  }
500
643
  const envOut = {
501
644
  DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: reg.body?.did ?? did,
502
645
  DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
503
- DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "", DECIDIO_WEBHOOK_SECRET: readEnvFile().DECIDIO_WEBHOOK_SECRET || randomBytes(32).toString("hex"),
646
+ DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "",
647
+ ...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
504
648
  };
505
649
  let envFile = ENV_PATH;
506
650
  if (readEnvFile().DECIDIO_AGENT_KEY) {
507
651
  envFile = ".env.quickstart";
508
- writeFileSync(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
652
+ ensureGitignore();
653
+ if (existsSync(envFile)) {
654
+ const prev = readFileSync(envFile, "utf8");
655
+ const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
656
+ if (prevId && prevId !== agentId) {
657
+ writeSecretFile(".env.quickstart.bak", prev);
658
+ log(` ✔ previous tour agent (${prevId}) backed up to .env.quickstart.bak — its key would otherwise be unrecoverable`);
659
+ }
660
+ }
661
+ writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
509
662
  log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
510
663
  }
511
664
  else {
665
+ ensureGitignore();
512
666
  upsertEnv(envOut);
513
667
  }
514
- ensureGitignore();
515
- log(` ✔ key generated locally (public half registered) · floor-limited token minted · ${envFile} written`);
668
+ log(` ✔ key generated locally (public half registered) · 7-day floor-limited token minted${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""} · ${envFile} written`);
516
669
  log("");
517
670
  log(`② Gating a demo action — the tour runs this (blocking mode: watch it live):`);
518
671
  log("");
@@ -529,26 +682,50 @@ async function cmdQuickstart() {
529
682
  };
530
683
  const payInvoice = withApproval(async (inv) => ({ paid: true, id: inv.id }), (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }), cfg);
531
684
  const running = payInvoice({ id: "INV-2026-001", amount: 86_000 });
532
- running.catch(() => { });
685
+ let earlyError;
686
+ running.catch((e) => { earlyError = e; });
533
687
  const waitStart = Date.now();
534
- while (!decisionId && Date.now() - waitStart < 30_000)
688
+ while (!decisionId && !earlyError && Date.now() - waitStart < 30_000)
535
689
  await new Promise((r) => setTimeout(r, 250));
690
+ if (!decisionId && earlyError instanceof DecidioRateLimitError) {
691
+ err(`✘ this workspace's governed-action allowance is used up${earlyError.limit != null ? ` (${earlyError.limit} actions/hour on lab workspaces)` : ""}.`);
692
+ err(` Nothing was routed or executed — the cap is the sandbox protecting itself, not a fault${earlyError.resetAt ? `. It resets ${earlyError.resetAt}` : ""}.`);
693
+ err(` Re-run the tour after the reset (or talk to us about a production workspace).`);
694
+ return 1;
695
+ }
696
+ if (!decisionId && earlyError) {
697
+ err(`✘ the gate refused before routing: ${earlyError?.message ?? earlyError}`);
698
+ return 1;
699
+ }
536
700
  if (!decisionId) {
537
- log("✘ the gate did not route within 30s — check connectivity (npx @decidio/sdk doctor) and re-run.");
701
+ err("✘ the gate did not route within 30s — check connectivity (npx @decidio/sdk doctor) and re-run.");
538
702
  return 1;
539
703
  }
540
704
  log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
541
705
  log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
542
706
  log("");
543
- if (process.stdin.isTTY) {
707
+ const interactive = process.stdin.isTTY;
708
+ if (interactive) {
544
709
  await ask(`③ YOU are the approver. Press Enter to approve it here (or approve it in your Decidio queue first, then Enter): `);
545
710
  }
711
+ else if (opts.autoApproveDemo) {
712
+ log("③ --auto-approve-demo: approving with your session credential. This is AUTOMATED authority");
713
+ log(" (a flag you passed, not a human decision in the moment) — fine for CI, not the real story.");
714
+ }
546
715
  else {
547
- log("③ Headless terminal — auto-approving with your session so the tour can continue.");
716
+ log("③ This terminal can't put a human in the loop, and an approval IS a human act — the tour");
717
+ log(" stops here rather than approve its own request. The decision is routed and waiting:");
718
+ log("");
719
+ log(` npx @decidio/sdk approvals approve ${decisionId} (or approve it in your Decidio app)`);
720
+ log("");
721
+ log(" Then re-run the quickstart interactively to watch the whole loop — or, for CI, re-run");
722
+ log(" with --auto-approve-demo to let the tour approve itself under that explicit label.");
723
+ log(` Tidy up this tour's agent + token any time: npx @decidio/sdk quickstart cleanup`);
724
+ process.exit(0);
548
725
  }
549
- const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: "quickstart" }) }, session);
726
+ const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
550
727
  if (!(ap.status >= 200 && ap.status < 300)) {
551
- log(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
728
+ err(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
552
729
  return 1;
553
730
  }
554
731
  log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
@@ -557,7 +734,7 @@ async function cmdQuickstart() {
557
734
  result = await running;
558
735
  }
559
736
  catch (e) {
560
- log(`✘ the gated call did not complete: ${e?.message ?? e}`);
737
+ err(`✘ the gated call did not complete: ${e?.message ?? e}`);
561
738
  return 1;
562
739
  }
563
740
  log(` ✔ your function executed AFTER approval: ${JSON.stringify(result)}`);
@@ -565,13 +742,16 @@ async function cmdQuickstart() {
565
742
  log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
566
743
  const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
567
744
  if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
568
- log(`✘ receipt fetch failed (${rec.status})`);
745
+ err(`✘ receipt fetch failed (${rec.status})`);
569
746
  return 1;
570
747
  }
571
748
  const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
572
749
  writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
573
- const v = verifyReceipt(rec.body.credential, undefined);
574
- log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline; issuer UNPINNED — bind it with: npx @decidio/sdk verify --issuer <your workspace DID>)`);
750
+ const issuer = rec.body?.issuer;
751
+ const v = verifyReceipt(rec.body.credential, issuer);
752
+ log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : "; issuer unpinned"})`);
753
+ if (issuer)
754
+ log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
575
755
  const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
576
756
  const tier = detail.body?.record?.confirmation?.status;
577
757
  if (tier)
@@ -586,8 +766,129 @@ async function cmdQuickstart() {
586
766
  log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
587
767
  log("");
588
768
  log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
769
+ let doCleanup = false;
770
+ if (interactive) {
771
+ const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
772
+ doCleanup = answer.trim().toLowerCase() === "c";
773
+ if (!doCleanup)
774
+ log(` Kept. Tidy up any time: npx @decidio/sdk quickstart cleanup`);
775
+ }
776
+ else if (opts.autoApproveDemo && !opts.keepAgent) {
777
+ doCleanup = true;
778
+ log(`\nAutomated run: cleaning the tour up (pass --keep-agent to keep the agent + token).`);
779
+ }
780
+ if (doCleanup)
781
+ await quickstartCleanup(session, envFile);
589
782
  return v.valid ? 0 : 1;
590
783
  }
784
+ async function quickstartCleanup(session, envFile) {
785
+ const env = (() => {
786
+ const out = {};
787
+ if (existsSync(envFile))
788
+ for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
789
+ const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
790
+ if (m)
791
+ out[m[1]] = m[2];
792
+ }
793
+ return out;
794
+ })();
795
+ if (env.DECIDIO_TOKEN_JTI) {
796
+ const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(env.DECIDIO_TOKEN_JTI)}`, { method: "DELETE" }, session);
797
+ if (rev.status >= 200 && rev.status < 300)
798
+ log(` ✔ token revoked (jti ${env.DECIDIO_TOKEN_JTI})`);
799
+ else
800
+ log(` ⚠ token revoke returned ${rev.status} — it still expires on its own within 7 days. List tokens: npx @decidio/sdk tokens list`);
801
+ }
802
+ else {
803
+ log(` ⚠ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — revoke by hand: npx @decidio/sdk tokens list, then tokens revoke <jti>. It expires on its own within 7 days.`);
804
+ }
805
+ if (existsSync(envFile)) {
806
+ const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI"]);
807
+ const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
808
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=/);
809
+ return m ? !TOUR_KEYS.has(m[1]) : line.trim() !== "";
810
+ });
811
+ if (kept.length === 0) {
812
+ rmSync(envFile);
813
+ log(` ✔ ${envFile} deleted (the agent's local key is gone — the registered agent is now inert)`);
814
+ }
815
+ else {
816
+ writeSecretFile(envFile, kept.join("\n") + "\n");
817
+ log(` ✔ tour credentials removed from ${envFile} — its ${kept.length} other line${kept.length === 1 ? "" : "s"} untouched (the agent's local key is gone — the registered agent is now inert)`);
818
+ }
819
+ }
820
+ const tourAgent = env.DECIDIO_AGENT_ID;
821
+ if (tourAgent) {
822
+ const cards = await api("/api/decisions/cards", {}, session);
823
+ const stranded = (cards.body?.cards ?? []).filter((c) => c.isAgentRequest && c.status !== "sealed" && typeof c.title === "string" && c.title.startsWith(`${tourAgent}:`));
824
+ for (const c of stranded) {
825
+ log(` ⚠ this tour's decision ${c.id} is still PENDING in your workspace queue — clear it with:`);
826
+ log(` npx @decidio/sdk approvals reject ${c.id} "quickstart cleanup"`);
827
+ }
828
+ }
829
+ log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
830
+ log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
831
+ return 0;
832
+ }
833
+ async function cmdTokens(sub, jti) {
834
+ if (sub !== "list" && sub !== "revoke") {
835
+ err("usage: npx @decidio/sdk tokens list");
836
+ err(" npx @decidio/sdk tokens revoke <jti>");
837
+ err(" Agent tokens are shown by jti (their revocation id) — the token VALUE is never shown again after minting.");
838
+ return 2;
839
+ }
840
+ if (sub === "revoke" && !jti) {
841
+ err("usage: npx @decidio/sdk tokens revoke <jti> (find the jti with: npx @decidio/sdk tokens list)");
842
+ return 2;
843
+ }
844
+ let bearer;
845
+ let r = await api("/api/workspace/agent-token");
846
+ if (r.status === 401 || r.status === 403) {
847
+ const session = await acquireSession("Managing tokens");
848
+ if (!session)
849
+ return 1;
850
+ bearer = session;
851
+ if (sub === "list")
852
+ r = await api("/api/workspace/agent-token", {}, bearer);
853
+ }
854
+ if (sub === "revoke") {
855
+ const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
856
+ if (rev.status >= 200 && rev.status < 300) {
857
+ if (rev.body?.changed === false) {
858
+ log(`• ${jti} was already revoked — nothing changed.`);
859
+ log(` Revocation is permanent; there is no re-revoke. See it: npx @decidio/sdk tokens list`);
860
+ return 1;
861
+ }
862
+ log(`✔ revoked ${jti} — the token is dead now (its agent stays registered; mint a new one with init).`);
863
+ return 0;
864
+ }
865
+ if (rev.status === 404) {
866
+ err(`✘ no token with jti "${jti}" — list them: npx @decidio/sdk tokens list`);
867
+ return 1;
868
+ }
869
+ err(`✘ revoke failed (${rev.status}): ${JSON.stringify(rev.body).slice(0, 300)}`);
870
+ return 1;
871
+ }
872
+ if (!(r.status >= 200 && r.status < 300)) {
873
+ err(`✘ could not list tokens (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
874
+ return 1;
875
+ }
876
+ const tokens = r.body?.tokens ?? r.body ?? [];
877
+ if (!Array.isArray(tokens) || !tokens.length) {
878
+ log("No agent tokens in this workspace.");
879
+ return 0;
880
+ }
881
+ const status = (t) => t.revokedAt ? `REVOKED ${String(t.revokedAt).slice(0, 10)}`
882
+ : (t.expiresAt && Date.parse(t.expiresAt) < Date.now()) ? "expired" : "active";
883
+ log(`Agent tokens (${tokens.length}):`);
884
+ for (const t of tokens)
885
+ log(` ${t.jti} ${status(t)} agent=${t.agentId ?? "?"} label=${t.label ?? ""} expires=${t.expiresAt ?? "?"}`);
886
+ const al = r.body?.allowance;
887
+ if (al?.limit != null)
888
+ log(`\nGoverned-action allowance: ${al.remaining}/${al.limit} remaining this hour${al.resetAt ? ` (resets ${al.resetAt})` : ""}.`);
889
+ log(`\nRevoke one: npx @decidio/sdk tokens revoke <jti>`);
890
+ return 0;
891
+ }
591
892
  function cmdDev(target, port) {
592
893
  log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
593
894
  createServer((req, res) => {
@@ -624,10 +925,20 @@ const USAGE = `decidio — the Decidio SDK command line
624
925
  in, or writes files unless you ask it to.)
625
926
 
626
927
  npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
928
+ (interactive: YOUR Enter is the approval. Headless it stops
929
+ at the routed decision; --auto-approve-demo for CI, labeled
930
+ automated; ends with a keep-or-cleanup choice)
931
+ npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials
932
+ npx @decidio/sdk tokens list agent tokens with status (active/expired/REVOKED) by jti —
933
+ token values are never re-shown
934
+ npx @decidio/sdk tokens revoke <jti> kill a token now instead of waiting out its lifetime
627
935
  npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
628
- (--unique appends a random suffix — collision-proof samples)
936
+ --unique append a random suffix (collision-proof samples)
937
+ --force replace a different agent already in this .env
938
+ (its .env is backed up first)
939
+ --expires-days N agent-token lifetime (default 90)
629
940
  npx @decidio/sdk doctor check config, connectivity, and what your token can do
630
- npx @decidio/sdk approvals [--json] list pending agent approvals
941
+ npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
631
942
  npx @decidio/sdk approvals approve <id> [reason]
632
943
  npx @decidio/sdk approvals reject <id> [reason]
633
944
  npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
@@ -635,12 +946,15 @@ const USAGE = `decidio — the Decidio SDK command line
635
946
  npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
636
947
  npx @decidio/sdk --version print the CLI version
637
948
 
949
+ Every command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
950
+ DECIDIO_API_URL says otherwise; the target host is announced on stderr each run.
638
951
  Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error.
952
+ Errors and usage go to stderr; stdout carries results only (safe to pipe).
639
953
  Docs: the README that shipped with this package (quickstart, durable resume, adapters).`;
640
954
  async function main(argv) {
641
955
  const [cmd, a, b, ...rest] = argv.slice(2);
642
956
  const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
643
- if (cmd === "--version" || cmd === "-v" || cmd === "version") {
957
+ if (cmd === "--version" || cmd === "-v" || cmd === "-V" || cmd === "version") {
644
958
  log(VERSION);
645
959
  return 0;
646
960
  }
@@ -652,34 +966,122 @@ async function main(argv) {
652
966
  log(USAGE);
653
967
  return 0;
654
968
  }
969
+ const argsIn = argv.slice(3);
970
+ const KNOWN = {
971
+ quickstart: { flags: ["--auto-approve-demo", "--keep-agent"] },
972
+ init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
973
+ doctor: { flags: [] },
974
+ approvals: { flags: ["--json", "--mine"] },
975
+ receipt: { flags: ["--json"] },
976
+ verify: { flags: [], valued: ["--issuer"] },
977
+ tokens: { flags: [] },
978
+ dev: { flags: [], valued: ["--target", "--port"] },
979
+ };
980
+ const spec = KNOWN[cmd];
981
+ if (spec) {
982
+ const valued = spec.valued ?? [];
983
+ const positionals = [];
984
+ for (let i = 0; i < argsIn.length; i++) {
985
+ const a = argsIn[i];
986
+ if (!a.startsWith("--")) {
987
+ positionals.push(a);
988
+ continue;
989
+ }
990
+ if (valued.includes(a)) {
991
+ i++;
992
+ continue;
993
+ }
994
+ if (valued.some((v) => a.startsWith(v + "=")))
995
+ continue;
996
+ if (!spec.flags.includes(a)) {
997
+ err(`✘ unknown option "${a}" for \`${cmd}\` — refusing to guess (a flag you meant to matter must never be silently ignored).\n\n${USAGE}`);
998
+ return 2;
999
+ }
1000
+ }
1001
+ const MAX_POS = { quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
1002
+ const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || cmd === "verify";
1003
+ if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
1004
+ err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
1005
+ return 2;
1006
+ }
1007
+ if (!exemptTail && MAX_POS[cmd] !== undefined && positionals.length > MAX_POS[cmd]) {
1008
+ err(`✘ unexpected argument "${positionals[MAX_POS[cmd]]}" for \`${cmd}\` — refusing to guess (an argument you meant to matter must never be silently ignored).\n\n${USAGE}`);
1009
+ return 2;
1010
+ }
1011
+ if (cmd === "quickstart" && positionals.length === 1 && positionals[0] !== "cleanup") {
1012
+ err(`✘ unknown quickstart subcommand "${positionals[0]}" — the only one is: npx @decidio/sdk quickstart cleanup`);
1013
+ return 2;
1014
+ }
1015
+ if (cmd === "tokens" && positionals[0] === "list" && positionals.length > 1) {
1016
+ err(`✘ unexpected argument "${positionals[1]}" for \`tokens list\` — it takes none (an argument you meant to matter must never be silently ignored).`);
1017
+ return 2;
1018
+ }
1019
+ }
655
1020
  switch (cmd) {
656
- case "quickstart": return cmdQuickstart();
1021
+ case "quickstart": {
1022
+ if (argsIn.includes("cleanup")) {
1023
+ const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
1024
+ : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH : undefined;
1025
+ if (!envFile) {
1026
+ log("Nothing to clean up here: no .env.quickstart, and .env doesn't belong to a quickstart agent.");
1027
+ return 0;
1028
+ }
1029
+ const session = await acquireSession("Cleaning the quickstart up");
1030
+ if (!session)
1031
+ return 1;
1032
+ return quickstartCleanup(session, envFile);
1033
+ }
1034
+ return cmdQuickstart({ autoApproveDemo: argsIn.includes("--auto-approve-demo"), keepAgent: argsIn.includes("--keep-agent") });
1035
+ }
657
1036
  case "init": {
658
- const argsIn = argv.slice(3);
659
1037
  const unique = argsIn.includes("--unique");
660
- const nameArg = argsIn.find((x) => !x.startsWith("-"));
1038
+ const expIdx = argsIn.indexOf("--expires-days");
1039
+ const expEq = argsIn.find((x) => x.startsWith("--expires-days="));
1040
+ const expRaw = expIdx >= 0 ? argsIn[expIdx + 1] : expEq ? expEq.slice("--expires-days=".length) : undefined;
1041
+ if ((expIdx >= 0 || expEq) && (!expRaw || !/^\d+$/.test(expRaw) || Number(expRaw) < 1)) {
1042
+ err("✘ --expires-days needs a positive whole number of days (e.g. --expires-days 30).");
1043
+ return 2;
1044
+ }
1045
+ const nameArg = argsIn.find((x, i) => !x.startsWith("-") && !(expIdx >= 0 && i === expIdx + 1));
661
1046
  let name = nameArg ?? process.env.DECIDIO_AGENT_ID ?? "my-agent";
662
1047
  if (unique)
663
1048
  name = `${name}-${randomBytes(2).toString("hex")}`;
664
- return cmdInit(name);
1049
+ return cmdInit(name, { force: argsIn.includes("--force"), expiresDays: expRaw ? Number(expRaw) : undefined });
665
1050
  }
666
1051
  case "doctor": return cmdDoctor();
667
- case "approvals": return cmdApprovals(a, b, rest.join(" ") || undefined);
668
- case "receipt": {
669
- const argsIn = argv.slice(3);
670
- return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
1052
+ case "approvals": {
1053
+ const positional = argsIn.filter((x) => !x.startsWith("-"));
1054
+ return cmdApprovals(positional[0], positional[1], positional.slice(2).join(" ") || undefined, { json: argsIn.includes("--json"), mine: argsIn.includes("--mine") });
1055
+ }
1056
+ case "receipt": return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
1057
+ case "verify": return cmdVerify(argsIn);
1058
+ case "tokens": {
1059
+ const positional = argsIn.filter((x) => !x.startsWith("-"));
1060
+ return cmdTokens(positional[0], positional[1]);
671
1061
  }
672
- case "verify": return cmdVerify(argv.slice(3));
673
1062
  case "dev": {
674
- const args = argv.slice(3);
675
- const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
1063
+ const flag = (name) => {
1064
+ const i = argsIn.indexOf(name);
1065
+ if (i >= 0)
1066
+ return argsIn[i + 1];
1067
+ const eq = argsIn.find((x) => x.startsWith(name + "="));
1068
+ return eq ? eq.slice(name.length + 1) : undefined;
1069
+ };
676
1070
  const target = flag("--target") || process.env.DECIDIO_AGENT_RESUME_URL || "http://localhost:4100/decidio/resume";
677
- const port = Number(flag("--port") || process.env.DECIDIO_DEV_PORT || 4099);
678
- return cmdDev(target, port);
1071
+ const portRaw = flag("--port") || process.env.DECIDIO_DEV_PORT || "4099";
1072
+ if (!/^\d+$/.test(portRaw) || Number(portRaw) < 1 || Number(portRaw) > 65535) {
1073
+ err(`✘ --port needs a number between 1 and 65535 (got "${portRaw}").`);
1074
+ return 2;
1075
+ }
1076
+ if (!/^https?:\/\//.test(target)) {
1077
+ err(`✘ --target needs an http(s) URL to forward the webhook to (got "${target}").`);
1078
+ return 2;
1079
+ }
1080
+ return cmdDev(target, Number(portRaw));
679
1081
  }
680
1082
  default:
681
- log(`✘ unknown command "${cmd}"\n\n${USAGE}`);
682
- return 1;
1083
+ err(`✘ unknown command "${cmd}"\n\n${USAGE}`);
1084
+ return 2;
683
1085
  }
684
1086
  }
685
1087
  main(process.argv).then((code) => { if (code !== 0)