@decidio/sdk 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,15 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
+ import { homedir } from "node:os";
5
+ import { join as joinPath } from "node:path";
4
6
  import { createServer } from "node:http";
5
- import { execFileSync } from "node:child_process";
7
+ import { execFileSync, spawnSync } from "node:child_process";
6
8
  import { createInterface } from "node:readline";
7
9
  import { pathToFileURL as toFileUrl, fileURLToPath as fromFileUrl } from "node:url";
8
10
  import { realpathSync as realPath } from "node:fs";
9
11
  import { createRequire } from "node:module";
10
- import { generateKeypair, assertSafeTransport } from "./signing.js";
12
+ import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
11
13
  import { hardenFile } from "./resume.js";
12
- import { withApproval, DecidioRateLimitError, DecidioRejectedError, didFromPrivateKey } from "./index.js";
14
+ import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, didFromPrivateKey } from "./index.js";
13
15
  import { verifyReceipt, runVerify } from "@decidio/verify";
14
16
  const VERSION = (() => {
15
17
  try {
@@ -41,9 +43,10 @@ function readEnvFile() {
41
43
  return out;
42
44
  }
43
45
  const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
46
+ const helpFlagAnywhere = (argv) => { const dd = argv.indexOf("--"); return (dd >= 0 ? argv.slice(0, dd) : argv).some((s) => s === "--help" || s === "-h"); };
44
47
  const ARGV_TAIL = process.argv.slice(2);
45
48
  const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", undefined].includes(ARGV_TAIL[0])
46
- || wantsHelp(ARGV_TAIL[0]) || wantsHelp(ARGV_TAIL[1]);
49
+ || wantsHelp(ARGV_TAIL[0]) || wantsHelp(ARGV_TAIL[1]) || helpFlagAnywhere(ARGV_TAIL);
47
50
  if (!OFFLINE_CMD) {
48
51
  readEnvFile();
49
52
  if (envUnreadable) {
@@ -52,6 +55,7 @@ if (!OFFLINE_CMD) {
52
55
  process.exit(1);
53
56
  }
54
57
  }
58
+ const SHELL_ENV = { ...process.env };
55
59
  if (!OFFLINE_CMD)
56
60
  for (const [k, v] of Object.entries(readEnvFile())) {
57
61
  if (k === "DECIDIO_SESSION_TOKEN") {
@@ -67,7 +71,7 @@ const ENV_PATH = ".env";
67
71
  const log = (...a) => console.log(...a);
68
72
  const err = (...a) => console.error(...a);
69
73
  let hostAnnounced = false;
70
- async function api(path, init = {}, bearer) {
74
+ async function api(path, init = {}, bearer, soft = false) {
71
75
  if (!hostAnnounced) {
72
76
  hostAnnounced = true;
73
77
  err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
@@ -92,6 +96,8 @@ async function api(path, init = {}, bearer) {
92
96
  res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
93
97
  }
94
98
  catch (e) {
99
+ if (soft)
100
+ return { status: 0, body: null };
95
101
  err(`✘ could not reach Decidio at ${API} (${e?.cause?.code ?? e?.message ?? "network error"}).`);
96
102
  if (API.includes("localhost")) {
97
103
  err(` You're pointed at localhost — for the hosted sandbox run:`);
@@ -268,11 +274,12 @@ function credentialJtisOnDisk() {
268
274
  return out;
269
275
  }
270
276
  function journalOwed() {
271
- return journalOutstanding().filter((j) => j.label === "quickstart" || !j.persisted);
277
+ const held = credentialJtisOnDisk();
278
+ return journalOutstanding().filter((j) => j.label === "quickstart" || (!j.persisted && !held.has(j.jti)));
272
279
  }
273
280
  function journalUnheld() {
274
281
  const held = credentialJtisOnDisk();
275
- return journalOutstanding().filter((j) => j.label !== "quickstart" && j.persisted && !held.has(j.jti));
282
+ return journalOutstanding().filter((j) => j.label !== "quickstart" && !held.has(j.jti) && j.persisted);
276
283
  }
277
284
  const REFUSE_MINT_CORRUPT_JOURNAL = (e) => `✘ the token recovery journal cannot be trusted (${e.message}) - it may hold the id of a token that is still live. Refusing to mint on top of uncertain history. Inspect the file, revoke anything it names (npx @decidio/sdk tokens list --mine), and repair or move it aside; every byte is left as found. Nothing was minted.`;
278
285
  function journalProbe() {
@@ -291,10 +298,18 @@ function journalProbe() {
291
298
  }
292
299
  }
293
300
  function journalMinted(jti, agentId, label) { journalAppend({ event: "minted", jti, agentId, label, apiUrl: API }); }
294
- function journalRetire(jti, how) { try {
295
- journalAppend({ event: "retired", jti, how });
301
+ function journalRetire(jti, how) {
302
+ try {
303
+ journalAppend({ event: "retired", jti, how });
304
+ return true;
305
+ }
306
+ catch (e) {
307
+ err(how === "persisted"
308
+ ? ` ⚠ could not note in ${JOURNAL_PATH} that token ${jti} was written to its credential file (${e?.message ?? e}). The credential is safe in the file; doctor will list the token as live and held.`
309
+ : ` ⚠ could not note in ${JOURNAL_PATH} that token ${jti} was revoked (${e?.message ?? e}). It IS revoked; a later cleanup will find the server already agrees and retire the row.`);
310
+ return false;
311
+ }
296
312
  }
297
- catch { } }
298
313
  function journalOutstanding() {
299
314
  const minted = new Map();
300
315
  for (const e of journalRows()) {
@@ -370,6 +385,65 @@ function ensureGitignore() {
370
385
  appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
371
386
  log(`✔ added ${missing.join(" + ")} to .gitignore (new secret files here will not be committed)`);
372
387
  }
388
+ function recordKindOf(credential) {
389
+ try {
390
+ const rc = credential?.credentialSubject?.recordCanonical;
391
+ const rec = typeof rc === "string" ? JSON.parse(rc) : rc;
392
+ return typeof rec?.recordKind === "string" ? rec.recordKind : undefined;
393
+ }
394
+ catch {
395
+ return undefined;
396
+ }
397
+ }
398
+ function sessionReminder() {
399
+ log(` ⚠ agent code launched from THIS terminal inherits your sign-in — run logout first (it also ends on close or cleanup); if you`);
400
+ log(` exported DECIDIO_SESSION_TOKEN, unset it ${dim("(bash/zsh: unset DECIDIO_SESSION_TOKEN · PowerShell: Remove-Item Env:DECIDIO_SESSION_TOKEN)")}`);
401
+ }
402
+ const TTY_OUT = !!process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb";
403
+ const paint = (code, s) => (TTY_OUT ? `\x1b[${code}m${s}\x1b[0m` : s);
404
+ const bold = (s) => paint("1", s);
405
+ const dim = (s) => paint("2", s);
406
+ const link = (url) => (TTY_OUT ? `\x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\` : url);
407
+ const FRAME_WIDTH = 84;
408
+ const visibleLength = (s) => s.replace(/\x1b\[[0-9;]*m|\x1b\]8;;[^\x1b]*\x1b\\/g, "").length;
409
+ function frameWidth(rows) {
410
+ const cols = (process.stdout.columns ?? 0) > 20 ? process.stdout.columns : 120;
411
+ const widest = rows.reduce((m, r) => Math.max(m, visibleLength(r)), 0);
412
+ return Math.max(FRAME_WIDTH, Math.min(widest + 4, cols - 1));
413
+ }
414
+ function block(n, title, rows, tone) {
415
+ const tint = tone === "choice" ? (process.env.TERM?.includes("256") || process.env.COLORTERM || process.env.WT_SESSION ? "38;5;208" : "33") : tone === "good" ? "32" : tone === "bad" ? "31" : null;
416
+ const f = (s) => (tint ? paint(tint, s) : s);
417
+ const width = frameWidth(rows);
418
+ const inner = width - 4;
419
+ const head = `┌ ${n} ${title} `;
420
+ log(f(head + "─".repeat(Math.max(1, width - visibleLength(head) - 1)) + "┐"));
421
+ for (const row of rows) {
422
+ const pieces = [];
423
+ if (visibleLength(row) <= inner)
424
+ pieces.push(row);
425
+ else {
426
+ let cur = "";
427
+ for (const w of row.split(" ")) {
428
+ if (cur && visibleLength(cur) + 1 + visibleLength(w) > inner) {
429
+ pieces.push(cur);
430
+ cur = " ".repeat(Math.min(13, Math.max(0, inner - 24))) + w;
431
+ }
432
+ else
433
+ cur = cur ? `${cur} ${w}` : w;
434
+ }
435
+ if (cur)
436
+ pieces.push(cur);
437
+ }
438
+ for (const piece of pieces)
439
+ log(`${f("│")} ${piece}${TTY_OUT ? "\x1b[0m" : ""}${" ".repeat(Math.max(0, inner - visibleLength(piece)))} ${f("│")}`);
440
+ }
441
+ log(f("└" + "─".repeat(width - 2) + "┘"));
442
+ }
443
+ const APP_URL = API.replace("decidio-api.onrender.com", "app.decidioai.com");
444
+ let stdinReadAbandoned = false;
445
+ let EXPLAIN = false;
446
+ let currentRl = null;
373
447
  const APPROVE_WORDS = new Set(["y", "yes", "approve", "approved"]);
374
448
  const REJECT_WORDS = new Set(["n", "no", "reject", "rejected", "deny", "denied"]);
375
449
  export function approvalAnswer(answer) {
@@ -385,64 +459,173 @@ function ask(question, mute = false) {
385
459
  return Promise.resolve("");
386
460
  return new Promise((resolve) => {
387
461
  const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
462
+ currentRl = rl;
388
463
  if (mute) {
389
464
  const anyRl = rl;
390
465
  const write = anyRl._writeToOutput?.bind(rl);
391
466
  anyRl._writeToOutput = (s) => { if (s.includes(question))
392
467
  write?.(s); };
393
468
  }
394
- rl.once("close", () => resolve(""));
469
+ rl.once("close", () => { if (currentRl === rl)
470
+ currentRl = null; resolve(""); });
395
471
  rl.question(question, (answer) => { resolve(answer.trim()); rl.close(); if (mute)
396
472
  process.stderr.write("\n"); });
397
473
  });
398
474
  }
399
- async function acquireSession(purpose) {
475
+ function terminalId() {
476
+ for (const k of ["WT_SESSION", "VSCODE_IPC_HOOK_CLI", "TERM_SESSION_ID", "ITERM_SESSION_ID", "TMUX_PANE", "KONSOLE_DBUS_SESSION", "GNOME_TERMINAL_SCREEN", "WINDOWID"]) {
477
+ const v = process.env[k];
478
+ if (v)
479
+ return `${k}=${v}`;
480
+ }
481
+ return "";
482
+ }
483
+ function sessionDir() {
484
+ const base = process.platform === "win32" ? (process.env.LOCALAPPDATA ?? joinPath(homedir(), "AppData", "Local")) : (process.env.XDG_CONFIG_HOME ?? joinPath(homedir(), ".config"));
485
+ return joinPath(base, "decidio");
486
+ }
487
+ const sessionFile = () => joinPath(sessionDir(), "session.json");
488
+ function jwtExpiry(token) {
400
489
  try {
401
- assertSafeTransport(API, true);
490
+ const p = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
491
+ return typeof p?.exp === "number" ? new Date(p.exp * 1000) : null;
402
492
  }
403
493
  catch {
404
- err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
405
- err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
406
494
  return null;
407
495
  }
408
- if (process.env.DECIDIO_SESSION_TOKEN)
409
- return process.env.DECIDIO_SESSION_TOKEN;
410
- if (!process.stdin.isTTY) {
411
- err(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
412
- err(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
413
- err(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
414
- err(` 2. POST its ?login= value — the response's "token" is your session token:`);
415
- err(` curl -X POST ${API}/demo/login-consume -H "content-type: application/json" -d '{"token":"<login value>"}'`);
496
+ }
497
+ function saveSession(token, email) {
498
+ mkdirSync(sessionDir(), { recursive: true });
499
+ const s = { v: 1, api: API, token, email, expiresAt: (jwtExpiry(token) ?? new Date(Date.now() + 30 * 24 * 3600 * 1000)).toISOString(), terminal: terminalId(), createdAt: new Date().toISOString() };
500
+ writeSecretFile(sessionFile(), JSON.stringify(s) + "\n");
501
+ return s;
502
+ }
503
+ function loadSession() {
504
+ const f = sessionFile();
505
+ if (!existsSync(f))
416
506
  return null;
507
+ let s;
508
+ try {
509
+ s = JSON.parse(readFileSync(f, "utf8"));
417
510
  }
418
- log(`${purpose} needs your Decidio workspace sign-in (${API}).`);
419
- log(` The session stays in memory for this command only — it is never written to disk.`);
420
- log(` Press Enter at the password prompt to use an emailed sign-in link instead.`);
421
- const email = await ask(" email: ");
422
- if (email) {
423
- const password = await ask(" password (Enter to skip): ", true);
424
- if (password) {
425
- const r = await api("/demo/login", { method: "POST", body: JSON.stringify({ email, password }) }, "");
426
- if (r.status >= 200 && r.status < 300 && r.body?.token)
427
- return r.body.token;
428
- log(` ✘ password sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. No problem — the link path works for every account:`);
429
- }
430
- }
431
- log(` Request a sign-in link from the app's sign-in screen, then paste the LINK (or just its token) here.`);
432
- const pasted = await ask(" link or token: ");
511
+ catch (e) {
512
+ err(`⚠ the stored sign-in (${f}) could not be read (${e?.message ?? e}) - removing it; sign in again with: npx @decidio/sdk login`);
513
+ endSession();
514
+ return null;
515
+ }
516
+ if (!s || s.v !== 1 || typeof s.token !== "string" || s.api !== API)
517
+ return null;
518
+ if (new Date(String(s.expiresAt)).getTime() <= Date.now()) {
519
+ endSession();
520
+ return null;
521
+ }
522
+ const here = terminalId();
523
+ if (s.terminal && s.terminal !== here)
524
+ return null;
525
+ return s;
526
+ }
527
+ function endSession() {
528
+ const f = sessionFile();
529
+ if (!existsSync(f))
530
+ return false;
531
+ try {
532
+ rmSync(f);
533
+ return true;
534
+ }
535
+ catch (e) {
536
+ err(`⚠ could not remove the stored sign-in (${f}: ${e?.message ?? e}) - remove it by hand.`);
537
+ return false;
538
+ }
539
+ }
540
+ async function loginFlow(purpose, emailArg) {
541
+ log(purpose === "Signing in" ? `Sign in once — 30 days, or until this terminal closes, logout, or cleanup.` : `${purpose} needs your Decidio workspace sign-in — once: 30 days, or until this terminal closes, logout, or cleanup.`);
542
+ const email = emailArg ?? await ask(" email: ");
543
+ if (!email) {
544
+ log(" ✘ no email given - re-run when you have it.");
545
+ return null;
546
+ }
547
+ const password = await ask(" password (Enter = emailed sign-in link): ", true);
548
+ if (password) {
549
+ const r = await api("/demo/login", { method: "POST", body: JSON.stringify({ email, password }) }, "");
550
+ if (r.status >= 200 && r.status < 300 && r.body?.token) {
551
+ const s = saveSession(String(r.body.token), email);
552
+ log(` ✔ signed in as ${email} until ${s.expiresAt.slice(0, 10)}${s.terminal ? "" : dim(" (no terminal id here: the sign-in ends on logout or cleanup, not on close)")}`);
553
+ return s.token;
554
+ }
555
+ log(` ✘ password sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. No problem — the emailed link works for every account:`);
556
+ }
557
+ const req = await api("/demo/login-link", { method: "POST", body: JSON.stringify({ email }) }, "");
558
+ if (!(req.status >= 200 && req.status < 300)) {
559
+ log(` ✘ could not request a sign-in email (${req.status})${req.body?.error ? ` — ${req.body.error}` : ""}. Check the address and re-run.`);
560
+ return null;
561
+ }
562
+ log(` ✔ sign-in email requested for ${email} — check your inbox and spam; paste the link (or its login= code; single-use, 15 minutes)`);
563
+ log(dim(` invite-only lab: a link arrives only for an invited address — not invited yet? request access: decidioai.com/#early-access`));
564
+ const pasted = await ask(" link or code: ");
433
565
  if (!pasted) {
434
- log(" ✘ nothing pasted — re-run when you have the link.");
566
+ log(" ✘ nothing pasted — re-run when you have the email.");
435
567
  return null;
436
568
  }
437
569
  const m = pasted.match(/[?&]login=([^&\s]+)/);
438
570
  const linkToken = m ? decodeURIComponent(m[1]) : pasted;
439
571
  const r = await api("/demo/login-consume", { method: "POST", body: JSON.stringify({ token: linkToken }) }, "");
440
- if (r.status >= 200 && r.status < 300 && r.body?.token)
441
- return r.body.token;
572
+ if (r.status >= 200 && r.status < 300 && r.body?.token) {
573
+ const s = saveSession(String(r.body.token), email);
574
+ log(` ✔ signed in as ${email} until ${s.expiresAt.slice(0, 10)}${s.terminal ? "" : dim(" (no terminal id here: the sign-in ends on logout or cleanup, not on close)")}`);
575
+ return s.token;
576
+ }
442
577
  log(` ✘ that link didn't work (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}.`);
443
578
  log(` Sign-in links are single-use and expire in 15 minutes — request a fresh one and re-run.`);
444
579
  return null;
445
580
  }
581
+ async function acquireSession(purpose) {
582
+ try {
583
+ assertSafeTransport(API, true);
584
+ }
585
+ catch {
586
+ err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
587
+ err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
588
+ return null;
589
+ }
590
+ if (process.env.DECIDIO_SESSION_TOKEN)
591
+ return process.env.DECIDIO_SESSION_TOKEN;
592
+ const stored = loadSession();
593
+ if (stored)
594
+ return stored.token;
595
+ if (!process.stdin.isTTY) {
596
+ err(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
597
+ err(` Fix: run \`npx @decidio/sdk login\` in an interactive terminal first (the sign-in is stored for this terminal),`);
598
+ err(` or set DECIDIO_SESSION_TOKEN. To mint one by hand: request a sign-in link, then`);
599
+ err(` curl -X POST ${API}/demo/login-consume -H "content-type: application/json" -d '{"token":"<login value>"}' # the response's "token"`);
600
+ return null;
601
+ }
602
+ return loginFlow(purpose);
603
+ }
604
+ async function cmdLogin(emailArg) {
605
+ try {
606
+ assertSafeTransport(API, true);
607
+ }
608
+ catch {
609
+ err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
610
+ return 1;
611
+ }
612
+ const stored = loadSession();
613
+ if (stored) {
614
+ log(`• already signed in${stored.email ? ` as ${stored.email}` : ""} on this terminal until ${stored.expiresAt.slice(0, 10)} - run \`npx @decidio/sdk logout\` first to switch accounts.`);
615
+ return 0;
616
+ }
617
+ const token = await loginFlow("Signing in", emailArg);
618
+ return token ? 0 : 1;
619
+ }
620
+ function cmdLogout() {
621
+ if (endSession())
622
+ log("✔ signed out — the stored sign-in is removed.");
623
+ else
624
+ log("• no stored sign-in on this machine for this Decidio.");
625
+ if (process.env.DECIDIO_SESSION_TOKEN)
626
+ log(" • DECIDIO_SESSION_TOKEN is still exported in this shell — unset it too (bash: unset DECIDIO_SESSION_TOKEN · PowerShell: Remove-Item Env:DECIDIO_SESSION_TOKEN).");
627
+ return 0;
628
+ }
446
629
  async function cmdInit(agentId, opts = {}) {
447
630
  if (!assertNotTracked(ENV_PATH))
448
631
  return 1;
@@ -794,6 +977,21 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
794
977
  log(`Reject: npx @decidio/sdk approvals reject <id> [reason]`);
795
978
  return 0;
796
979
  }
980
+ export function receiptFileName(id) {
981
+ return `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, (c) => "~" + c.codePointAt(0).toString(16).padStart(2, "0"))}.json`;
982
+ }
983
+ function writeReceiptFile(name, bytes, say = log) {
984
+ if (existsSync(name)) {
985
+ if (readFileSync(name, "utf8") !== bytes) {
986
+ err(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
987
+ return "conflict";
988
+ }
989
+ say(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
990
+ return "identical";
991
+ }
992
+ writeFileSync(name, bytes);
993
+ return "written";
994
+ }
797
995
  async function cmdReceipt(id, jsonMode = false) {
798
996
  if (!id) {
799
997
  err("usage: npx @decidio/sdk receipt <decisionId>");
@@ -819,19 +1017,13 @@ async function cmdReceipt(id, jsonMode = false) {
819
1017
  err(`✘ could not fetch the receipt (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
820
1018
  return 1;
821
1019
  }
822
- const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
1020
+ const name = receiptFileName(id);
823
1021
  const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
824
- if (existsSync(name)) {
825
- if (readFileSync(name, "utf8") !== nextBytes) {
826
- err(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
827
- return 1;
828
- }
829
- (jsonMode ? err : log)(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
830
- }
831
- else {
832
- writeFileSync(name, nextBytes);
1022
+ const wrote = writeReceiptFile(name, nextBytes, jsonMode ? err : log);
1023
+ if (wrote === "conflict")
1024
+ return 1;
1025
+ if (wrote === "written")
833
1026
  (jsonMode ? err : log)(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
834
- }
835
1027
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
836
1028
  const conf = detail.body?.record?.confirmation;
837
1029
  const issuer = r.body?.issuer;
@@ -923,17 +1115,35 @@ function cmdVerify(argsIn) {
923
1115
  log(l);
924
1116
  return ok ? 0 : 1;
925
1117
  }
1118
+ function valuedOpt(args, flag) {
1119
+ const i = args.indexOf(flag);
1120
+ if (i >= 0)
1121
+ return args[i + 1];
1122
+ const eq = args.find((x) => x.startsWith(flag + "="));
1123
+ return eq ? eq.slice(flag.length + 1) : undefined;
1124
+ }
926
1125
  async function cmdQuickstart(opts = {}) {
927
1126
  const t0 = Date.now();
928
- log("Decidio quickstart — gate an action, route it to a human, execute on approval, hold the proof.");
929
- log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
930
- log("");
931
- log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
932
- log(" write .env (or .env.quickstart), .decidio-token-journal (revocation ids, no secrets) and .gitignore");
933
- log(" entries (incl. .decidio-pending/, the durable store your real agent will use) · save the receipt");
934
- log(" as decidio-receipt-<id>.json here (shareable: action, amount, policy, the human's reason - no secrets; it stays)");
935
- log(" · create one synthetic decision and its sealed record (records are permanent by design) · wait up to");
936
- log(" 5 minutes for your y (approve) or n (reject) at the human step. Cleanup offer at the end.");
1127
+ EXPLAIN = !!opts.explain;
1128
+ if (opts.phone !== undefined && opts.phone !== "app" && opts.phone !== "web") {
1129
+ err(`✘ --phone takes app (default: the QR opens the installed app when there is one) or web (the QR opens the phone web rendering, always).`);
1130
+ return 2;
1131
+ }
1132
+ log(bold("Decidio quickstart") + " — gate an action, route it to a human, execute on approval, hold the proof.");
1133
+ log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : dim(" (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)")}`);
1134
+ hostAnnounced = true;
1135
+ if (EXPLAIN) {
1136
+ log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
1137
+ log(" write .env (or .env.quickstart), .decidio-token-journal (revocation ids, no secrets) and .gitignore");
1138
+ log(" entries (incl. .decidio-pending/, the durable store your real agent will use) · save the receipt");
1139
+ log(" as decidio-receipt-<id>.json here (shareable: action, amount, policy, the human's reason - no secrets; it stays)");
1140
+ log(" · create one synthetic decision and its sealed record (records are permanent by design) · wait up to");
1141
+ log(" 5 minutes for your y (approve) or n (reject) at the human step. Cleanup offer at the end.");
1142
+ }
1143
+ else {
1144
+ log(dim(" Creates here: a synthetic agent + 7-day token (.env), .decidio-token-journal (revocation ids, no secrets), .gitignore entries incl."));
1145
+ log(dim(" .decidio-pending/ (the durable store), the receipt as decidio-receipt-<id>.json, one sealed record (permanent). --explain for the long form."));
1146
+ }
937
1147
  log("");
938
1148
  const session = await acquireSession("The quickstart");
939
1149
  if (!session)
@@ -943,7 +1153,8 @@ async function cmdQuickstart(opts = {}) {
943
1153
  return 1;
944
1154
  ensureGitignore();
945
1155
  let agentId = `quickstart-${randomBytes(5).toString("hex")}`;
946
- log(`① Registering agent "${agentId}"…`);
1156
+ if (EXPLAIN)
1157
+ log(` Registering agent "${agentId}"…`);
947
1158
  const { did, privateKeyBase64 } = generateKeypair();
948
1159
  let reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
949
1160
  for (let attempt = 0; reg.status === 409 && attempt < 3; attempt++) {
@@ -1039,7 +1250,7 @@ async function cmdQuickstart(opts = {}) {
1039
1250
  err(`✘ could not write ${envFile} (${e?.message ?? e}). The tour token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
1040
1251
  return 1;
1041
1252
  }
1042
- log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
1253
+ log(` ✔ .env already holds your agent — this tour uses ${envFile}`);
1043
1254
  }
1044
1255
  else {
1045
1256
  try {
@@ -1061,14 +1272,20 @@ async function cmdQuickstart(opts = {}) {
1061
1272
  await quickstartCleanup(session, envFile).catch(() => { });
1062
1273
  return code;
1063
1274
  };
1064
- log(` ✔ key generated locally (public half registered) · 7-day floor-limited token minted${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""} · ${envFile} written`);
1065
- log("");
1066
- log(`② Gating a demo action — the tour runs this (blocking mode: watch it live):`);
1067
- log("");
1068
- log(` const payInvoice = guard.protect(payInvoiceRaw,`);
1069
- log(` (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }),`);
1070
- log(` { mode: "blocking" }); // your real agent typically omits this — durable + a worker`);
1071
- log("");
1275
+ block("①", "Register a synthetic agent", [
1276
+ `${bold("agent")} ${agentId}`,
1277
+ `${bold("key")} generated locally · only the public half was registered`,
1278
+ `${bold("token")} 7-day, floor-limited${mint.body.expiresAt ? ` · expires ${String(mint.body.expiresAt).slice(0, 10)}` : ""} · written to ${envFile}`,
1279
+ ]);
1280
+ if (EXPLAIN) {
1281
+ log(` The tour runs exactly this, the BLOCKING primitive (your real agent uses guard.protect instead:`);
1282
+ log(` durable by default, resumed by a worker — the README shows both):`);
1283
+ log("");
1284
+ log(` const payInvoice = withApproval(payInvoiceRaw,`);
1285
+ log(` (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }),`);
1286
+ log(` { ...config, pollTimeoutMs: 300_000 }); // waits up to 5 minutes for the human`);
1287
+ log("");
1288
+ }
1072
1289
  let decisionId = "";
1073
1290
  const cfg = {
1074
1291
  apiUrl: API, agentId, apiToken: mint.body.token, agentKey: privateKeyBase64,
@@ -1097,30 +1314,82 @@ async function cmdQuickstart(opts = {}) {
1097
1314
  err("✘ the gate did not route within 30s — check connectivity (npx @decidio/sdk doctor) and re-run.");
1098
1315
  return failTour(1);
1099
1316
  }
1100
- log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
1101
- log(` so the policy ROUTES it to a person (routing-by-default). That is distinct from a BLOCK, which no human can`);
1102
- log(` override: a block is a named rule, or the universal backstop when no active policy governs agents at all.`);
1103
- log(` Nothing executes until someone decides.`);
1104
- log("");
1317
+ block("②", "Gate a demo action", [
1318
+ `${bold("call")} withApproval(payInvoiceRaw, (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }), { pollTimeoutMs: 300_000 }) ${dim("// blocking here; your agent's guard.protect is durable")}`,
1319
+ `${bold("routed")} $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — nothing executes until someone decides ${dim("(routed, not a BLOCK: a human can still say yes; a block no one can override)")}`,
1320
+ ]);
1321
+ if (EXPLAIN) {
1322
+ log(` This agent has no auto-approve rule, so the policy ROUTES it to a person (routing-by-default). That is`);
1323
+ log(` distinct from a BLOCK, which no human can override: a block is a named rule, or the universal backstop`);
1324
+ log(` when no active policy governs agents at all.`);
1325
+ log("");
1326
+ }
1105
1327
  const interactive = process.stdin.isTTY;
1106
1328
  let verdict = "approve";
1329
+ let decidedOutside = null;
1330
+ let denialVerified = false;
1107
1331
  if (interactive) {
1332
+ const handoff = await api("/demo/session-link", { method: "POST", body: JSON.stringify({ decisionId, ...(opts.phone === "web" ? { phone: "web" } : {}) }) }, session, true);
1333
+ const doorLink = handoff.status >= 200 && handoff.status < 300 && typeof handoff.body?.link === "string" ? String(handoff.body.link) : null;
1334
+ const qr = doorLink && typeof handoff.body?.qr === "string" ? String(handoff.body.qr) : null;
1335
+ const plainLink = `${APP_URL}/queue?focus=${encodeURIComponent(decisionId)}`;
1336
+ const rows = [
1337
+ `${bold("here")} Type y to approve or n to reject, then Enter ${dim("(waits up to 5 minutes)")}`,
1338
+ `${bold("web app")} ${link(doorLink ?? plainLink)} ${dim(doorLink ? "(one-time sign-in · 5 minutes)" : "(sign in there if it asks)")}`,
1339
+ ];
1340
+ if (qr) {
1341
+ rows.push(`${bold("your phone")} scan — the same one-time sign-in${opts.phone === "web" ? ", in the phone browser (--phone web)" : ": the Decidio app if it is installed, the browser if not"}`);
1342
+ for (const l of qr.split("\n"))
1343
+ if (l.trim().length)
1344
+ rows.push(" " + l);
1345
+ }
1346
+ else if (doorLink)
1347
+ rows.push(`${bold("your phone")} ${dim("the link above works on a phone too (no QR came back this time)")}`);
1348
+ rows.push(dim("whichever answers first wins — the terminal follows the web app or your phone."));
1349
+ block("③", "Your call", rows, "choice");
1350
+ let settledState = null;
1351
+ const settledP = running.then(() => { settledState = "approved"; }, (e) => { settledState = e instanceof DecidioRejectedError ? "rejected" : "other"; });
1108
1352
  verdict = "again";
1109
- for (let attempt = 0; attempt < 3 && verdict === "again"; attempt++) {
1110
- const answer = await ask(attempt === 0
1111
- ? `③ YOU are the approver. Type y to approve or n to reject, then Enter (or decide it in your Decidio queue first, then answer here the same way): `
1112
- : ` Please answer y (approve) or n (reject): `);
1113
- verdict = approvalAnswer(answer);
1114
- if (process.stdin.readableEnded || process.stdin.destroyed) {
1115
- verdict = "again";
1116
- break;
1353
+ const promptP = (async () => {
1354
+ for (let attempt = 0; attempt < 3 && verdict === "again" && !settledState; attempt++) {
1355
+ const answer = await ask(attempt === 0 ? ` › ` : ` Please answer y (approve) or n (reject): `);
1356
+ if (settledState)
1357
+ break;
1358
+ verdict = approvalAnswer(answer);
1359
+ if (process.stdin.readableEnded || process.stdin.destroyed) {
1360
+ verdict = "again";
1361
+ break;
1362
+ }
1117
1363
  }
1118
- }
1119
- if (verdict === "again") {
1364
+ })();
1365
+ await Promise.race([promptP, settledP]);
1366
+ if (settledState) {
1367
+ currentRl?.close();
1368
+ stdinReadAbandoned = true;
1369
+ await promptP;
1370
+ log("");
1371
+ }
1372
+ if (settledState === "approved" || settledState === "rejected") {
1373
+ decidedOutside = settledState;
1374
+ verdict = settledState === "approved" ? "approve" : "reject";
1375
+ }
1376
+ else if (settledState === "other")
1377
+ verdict = "again";
1378
+ if (verdict === "again" && !(earlyError instanceof DecidioTimeoutError)) {
1120
1379
  log(`③ No decision was made (no y or n answer), so the tour stops here rather than guess.`);
1121
1380
  log(` The request is still ROUTED and waiting in your queue - approve or reject it there, or re-run the tour.`);
1122
1381
  log(` NOTE: this tour's credentials are still live in ${envFile} (a 7-day token + the agent's key). Once you`);
1123
1382
  log(` have decided - or if you abandon the tour - remove them: npx @decidio/sdk quickstart cleanup`);
1383
+ log("");
1384
+ sessionReminder();
1385
+ process.exit(3);
1386
+ }
1387
+ if (earlyError instanceof DecidioTimeoutError) {
1388
+ log(`③ the tour's blocking call gave up after 5 minutes while waiting for your answer, so your answer is NOT posted:`);
1389
+ log(` the request is still ROUTED in your queue - decide it there, or re-run the tour and answer within 5 minutes.`);
1390
+ log(` NOTE: this tour's credentials are still live in ${envFile} - remove them: npx @decidio/sdk quickstart cleanup`);
1391
+ log("");
1392
+ sessionReminder();
1124
1393
  process.exit(3);
1125
1394
  }
1126
1395
  }
@@ -1147,17 +1416,22 @@ async function cmdQuickstart(opts = {}) {
1147
1416
  log(` NOTE: this tour's credentials are still live in ${envFile} (a 7-day token + the agent's`);
1148
1417
  log(` key), because the decision above is still waiting for you. Once you have approved it —`);
1149
1418
  log(` or if you abandon the tour — remove them: npx @decidio/sdk quickstart cleanup`);
1419
+ log("");
1420
+ sessionReminder();
1150
1421
  process.exit(3);
1151
1422
  }
1152
1423
  const rejected = verdict === "reject";
1153
1424
  if (rejected) {
1154
- const rj = await api(`/api/decisions/${encodeURIComponent(decisionId)}/reject`, { method: "POST", body: JSON.stringify({ reason: "quickstart: rejected by the approver" }) }, session);
1425
+ const rj = decidedOutside ? { status: 200, body: { alreadyResolved: true, sealed: true } } : await api(`/api/decisions/${encodeURIComponent(decisionId)}/reject`, { method: "POST", body: JSON.stringify({ reason: "quickstart: rejected by the approver" }) }, session);
1155
1426
  if (!(rj.status >= 200 && rj.status < 300)) {
1156
1427
  err(`✘ reject failed (${rj.status}): ${JSON.stringify(rj.body)}`);
1157
1428
  return failTour(1);
1158
1429
  }
1430
+ if (decidedOutside)
1431
+ log(` ✔ rejected in the web app (or on your phone) — this terminal saw it and followed`);
1159
1432
  const decidedInApp = rj.body?.alreadyResolved === true;
1160
- log(decidedInApp ? ` • this decision was already decided from your app — the tour follows that decision` : ` ✔ rejected`);
1433
+ if (!decidedOutside)
1434
+ log(decidedInApp ? ` • this decision was already decided from your app — the tour follows that decision` : ` ✔ rejected`);
1161
1435
  let leaked;
1162
1436
  let refused;
1163
1437
  try {
@@ -1175,8 +1449,35 @@ async function cmdQuickstart(opts = {}) {
1175
1449
  log(` ✔ your app APPROVED it first, so that decision stood and your function ran: ${JSON.stringify(leaked)}. Re-run the tour to watch a rejection.`);
1176
1450
  }
1177
1451
  else if (refused instanceof DecidioRejectedError) {
1178
- log(` ✔ your function did NOT run — the wrapper refused: ${refused.message}`);
1179
- log(` That is the product: a human said no, and nothing happened.${rj.body?.sealed === true ? " The denial is sealed on the record, as an approval would be." : ""}`);
1452
+ log(` ✔ your function did NOT run — the wrapper refused: ${refused.message}${EXPLAIN ? "" : dim(" (a human said no, and nothing happened)")}`);
1453
+ if (EXPLAIN)
1454
+ log(` That is the product: a human said no, and nothing happened.`);
1455
+ if (EXPLAIN)
1456
+ log(" Downloading the sealed record of the DENIAL and verifying it OFFLINE…");
1457
+ const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
1458
+ if (rec.status >= 200 && rec.status < 300 && rec.body?.credential) {
1459
+ const fname = receiptFileName(decisionId);
1460
+ const wrote = writeReceiptFile(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
1461
+ if (wrote === "conflict")
1462
+ return failTour(1);
1463
+ const issuer = rec.body?.issuer;
1464
+ const v = verifyReceipt(rec.body.credential, issuer);
1465
+ const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", PINNED to your workspace issuer" : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1466
+ if (!v.valid || !issuer) {
1467
+ log(` ${kindLine}`);
1468
+ err(` ✘ the denial's receipt did not verify PINNED (${!issuer ? "no issuer DID from the server" : v.reason ?? "no reason given"}) — the tour did not complete.`);
1469
+ return failTour(1);
1470
+ }
1471
+ block("④", "The record", [
1472
+ `✔ VALID — ${fname} ${dim("(signature + content binding, offline, PINNED to your workspace issuer)")}`,
1473
+ `${bold("record")} record kind: ${recordKindOf(rec.body.credential) ?? "?"} ${dim("— the denial is sealed too")}`,
1474
+ `${bold("issuer")} ${issuer} ${dim("· your trust anchor: verify --issuer <did> <receipt.json>")}`,
1475
+ ], "bad");
1476
+ denialVerified = true;
1477
+ }
1478
+ else {
1479
+ log(` • no sealed record was issued for this rejection (${rec.status}) — the denial is on the decision in your queue.`);
1480
+ }
1180
1481
  }
1181
1482
  else {
1182
1483
  err(`✘ the gated call ended with an error rather than a clean refusal: ${refused?.message ?? refused} — nothing ran, but the tour did not complete.`);
@@ -1184,21 +1485,29 @@ async function cmdQuickstart(opts = {}) {
1184
1485
  }
1185
1486
  log("");
1186
1487
  const secsR = Math.round((Date.now() - t0) / 1000);
1187
- log(ranOnAppApproval
1188
- ? `Done in ${secsR}s: gated → routed to a human → decided in your app (approved) → your function ran once.`
1189
- : `Done in ${secsR}s: gated → routed to a human → REJECTED → your function never ran.`);
1190
- if (!ranOnAppApproval)
1488
+ if (ranOnAppApproval)
1489
+ log(`Done in ${secsR}s: gated → routed to a human → decided in your app (approved) → your function ran once.`);
1490
+ else {
1491
+ block("✔", `Done in ${secsR}s`, [
1492
+ `${bold("gated")} payInvoice $86,000 — ROUTED to a human as ${decisionId}`,
1493
+ `${bold("decided")} REJECTED ${decidedOutside ? "in the web app (or on your phone)" : "by you, here"}`,
1494
+ `${bold("executed")} nothing — your function never ran`,
1495
+ `${bold("record")} ${denialVerified ? "the DENIAL is sealed too — VALID, verified offline (④ above)" : "none was issued for this rejection (④ above)"}`,
1496
+ dim("gated → routed to a human → REJECTED → your function never ran"),
1497
+ ], "bad");
1191
1498
  log(`Re-run the tour and answer y to watch the approval half, with its offline-verifiable receipt.`);
1499
+ }
1192
1500
  log("");
1193
- log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
1501
+ sessionReminder();
1502
+ log(`${bold("Where next")} your queue ${link(APP_URL)} · README "Production mode: durable async resume" ${dim("· built by OmniTwin — decidioai.com/#early-access")}`);
1194
1503
  }
1195
1504
  else {
1196
- 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);
1505
+ const ap = decidedOutside ? { status: 200, body: { alreadyResolved: true } } : await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
1197
1506
  if (!(ap.status >= 200 && ap.status < 300)) {
1198
1507
  err(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
1199
1508
  return failTour(1);
1200
1509
  }
1201
- log(` ✔ approved${ap.body?.alreadyResolved ? " (already decided from your app — the tour follows that decision)" : ""}`);
1510
+ log(decidedOutside ? ` ✔ approved in the web app (or on your phone) — this terminal saw it and followed` : ` ✔ approved${ap.body?.alreadyResolved ? " (already decided from your app — the tour follows that decision)" : ""}`);
1202
1511
  let done;
1203
1512
  try {
1204
1513
  done = await running;
@@ -1211,21 +1520,24 @@ async function cmdQuickstart(opts = {}) {
1211
1520
  err(`✘ the gated call did not complete: ${e?.message ?? e}`);
1212
1521
  return failTour(1);
1213
1522
  }
1214
- log(` ✔ your function executed AFTER approval: ${JSON.stringify(done.value)}`);
1215
- if (done.confirmation?.evidenceTier)
1216
- log(` …and the call itself told you what was recorded: confirmation.evidenceTier = ${done.confirmation.evidenceTier}`);
1217
- log("");
1218
- log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
1523
+ log(` ✔ your function executed AFTER approval: ${JSON.stringify(done.value)}${done.confirmation?.evidenceTier ? ` · confirmation.evidenceTier = ${done.confirmation.evidenceTier}` : ""}`);
1524
+ if (EXPLAIN && done.confirmation?.evidenceTier)
1525
+ log(` (the call itself told you what was recorded — the wrapper's signed report)`);
1526
+ if (EXPLAIN)
1527
+ log(" Downloading the sealed Authority Receipt and verifying it OFFLINE…");
1219
1528
  const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
1220
1529
  if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
1221
1530
  err(`✘ receipt fetch failed (${rec.status})`);
1222
1531
  return failTour(1);
1223
1532
  }
1224
- const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
1225
- writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
1533
+ const fname = receiptFileName(decisionId);
1534
+ if (writeReceiptFile(fname, JSON.stringify(rec.body.credential, null, 2) + "\n") === "conflict")
1535
+ return failTour(1);
1226
1536
  const issuer = rec.body?.issuer;
1227
1537
  const v = verifyReceipt(rec.body.credential, issuer);
1228
- log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : ""})`);
1538
+ const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", PINNED to your workspace issuer" : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1539
+ if (!v.valid || !issuer)
1540
+ log(` ${kindLine}`);
1229
1541
  if (!v.valid) {
1230
1542
  err(` ✘ the receipt did not verify (${v.reason ?? "no reason given"}) — the tour did not complete.`);
1231
1543
  return failTour(1);
@@ -1238,29 +1550,32 @@ async function cmdQuickstart(opts = {}) {
1238
1550
  err(` ✘ the tour does not count an UNPINNED check as success.`);
1239
1551
  return failTour(1);
1240
1552
  }
1241
- if (issuer)
1242
- log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
1243
1553
  const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
1244
1554
  const tier = detail.body?.record?.confirmation?.status;
1245
- if (tier)
1246
- log(` ✔ execution evidence on the record: ${tier} — the wrapper's signed report. (The receipt seals the DECISION; execution evidence accrues on the record — see the README.)`);
1247
- log("");
1555
+ block("④", "The receipt", [
1556
+ `✔ VALID — ${fname} ${dim("(signature + content binding, offline, PINNED to your workspace issuer)")}`,
1557
+ `${bold("record")} record kind: ${recordKindOf(rec.body.credential) ?? "?"}${tier ? ` · evidence: ${tier}` : ""} ${dim("— the receipt seals the DECISION; execution evidence lives on the record")}`,
1558
+ `${bold("issuer")} ${issuer} ${dim("· your trust anchor: verify --issuer <did> <receipt.json>")}`,
1559
+ ], "good");
1248
1560
  const secs = Math.round((Date.now() - t0) / 1000);
1249
- log(`Done in ${secs}s: gated → routed to a human → approved → your function executed once → an authority-decision receipt you can verify offline.`);
1561
+ block("✔", `Done in ${secs}s`, [
1562
+ `${bold("gated")} payInvoice $86,000 — ROUTED to a human as ${decisionId}`,
1563
+ `${bold("decided")} ${decidedOutside ? "approved in the web app (or on your phone)" : opts.autoApproveDemo ? "approved by --auto-approve-demo (automated, and labeled so on the record)" : "approved by you, here"}`,
1564
+ `${bold("executed")} your function ran once, AFTER approval`,
1565
+ `${bold("receipt")} VALID — verified offline, pinned to your workspace issuer (④ above)`,
1566
+ dim("gated → routed to a human → approved → executed once → a receipt you can verify offline"),
1567
+ ], "good");
1250
1568
  log("");
1251
- log("Take it to your real agent — the .env here already works:");
1569
+ log(bold("Take it to your real agent — the .env here already works:"));
1252
1570
  log(` import { guard } from "@decidio/sdk";`);
1253
- log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
1254
- log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
1255
- log("");
1256
- log(`Before launching agent code from THIS shell, unset DECIDIO_SESSION_TOKEN if you exported it: child processes`);
1257
- log(`inherit exported variables, and that credential is your workspace login, not the agent's.`);
1571
+ log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));${dim(" // durable by default")}`);
1258
1572
  log("");
1259
- log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
1260
- log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
1261
- log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
1262
- log(`already works here, so skip that flow's init step.`);
1263
- log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
1573
+ if (EXPLAIN) {
1574
+ log(` The README's seven-step flow is this same blocking flow in your own code; if you keep this agent, its .env`);
1575
+ log(` already works here, so skip that flow's init step. Production is DURABLE mode (default) + one worker.`);
1576
+ }
1577
+ sessionReminder();
1578
+ log(`${bold("Where next")} your queue ${link(APP_URL)} · README "Production mode: durable async resume" ${dim("· built by OmniTwin — decidioai.com/#early-access")}`);
1264
1579
  }
1265
1580
  let doCleanup = false;
1266
1581
  if (interactive) {
@@ -1437,6 +1752,10 @@ async function quickstartCleanup(session, envFile) {
1437
1752
  log(` pending and you do not want the history, remove it:`);
1438
1753
  log(` ${process.platform === "win32" ? "Remove-Item -Recurse .decidio-pending" : "rm -r .decidio-pending"}`);
1439
1754
  }
1755
+ if (endSession())
1756
+ log(` ✔ signed out — the stored sign-in is ended (npx @decidio/sdk login to sign in again).`);
1757
+ else if (process.env.DECIDIO_SESSION_TOKEN)
1758
+ log(` • DECIDIO_SESSION_TOKEN is still exported in this shell — unset it when you are done.`);
1440
1759
  return 0;
1441
1760
  }
1442
1761
  async function cmdTokens(sub, jti, mineOnly = false) {
@@ -1536,11 +1855,14 @@ function cmdDev(target, port) {
1536
1855
  res.end(JSON.stringify({ error: e?.message }));
1537
1856
  }
1538
1857
  });
1539
- }).listen(port);
1858
+ }).listen(port, "127.0.0.1");
1540
1859
  return 0;
1541
1860
  }
1542
1861
  const USAGE = `decidio — the Decidio SDK command line
1543
1862
 
1863
+ The wrapper IS the SDK: guard.protect(fn, describe) ships in this package (import { guard } from "@decidio/sdk").
1864
+ This command line is the tour, the sign-in, and the token admin around it.
1865
+
1544
1866
  New here? See the whole thing work in about two minutes:
1545
1867
 
1546
1868
  npx @decidio/sdk quickstart
@@ -1550,23 +1872,36 @@ const USAGE = `decidio — the Decidio SDK command line
1550
1872
  hosted record — the receipt seals who authorized what, not what ran afterward). Guided and
1551
1873
  explicit — this CLI never acts, signs you in, or writes files unless you ask it to.
1552
1874
 
1875
+ THE TOUR
1553
1876
  npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
1554
- (interactive: YOUR Enter is the approval. Headless it stops
1877
+ (interactive: y approves, n rejects - Enter is not an answer. Headless it stops
1555
1878
  at the routed decision; --auto-approve-demo for CI, labeled
1556
- automated; ends with a keep-or-cleanup choice)
1879
+ automated; ends with a keep-or-cleanup choice; --explain prints
1880
+ the long narration of each step; --phone web makes the QR open
1881
+ the phone web rendering even when the Decidio app is installed)
1557
1882
  npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials
1883
+ (and end the stored sign-in)
1558
1884
  npx @decidio/sdk quickstart --auto-approve-demo headless tour; cleans up after itself
1559
1885
  (add --keep-agent to KEEP the tour agent + token)
1560
- npx @decidio/sdk tokens list [--mine] agent tokens with status (active/expired/REVOKED) by
1561
- jti — token values are never re-shown; the list is
1562
- workspace-wide, --mine narrows to this directory's agent
1563
- npx @decidio/sdk tokens revoke <jti> kill a token now instead of waiting out its lifetime
1886
+
1887
+ SIGN IN (once)
1888
+ npx @decidio/sdk login [email] sign in ONCE: the CLI emails you a link, you paste it; the sign-in
1889
+ lasts 30 days, or until this terminal closes, logout, or cleanup
1890
+ npx @decidio/sdk logout end the stored sign-in
1891
+
1892
+ YOUR AGENT AND ITS TOKENS
1564
1893
  npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
1565
1894
  --unique append a random suffix (collision-proof samples)
1566
1895
  --force replace a different agent already in this .env
1567
1896
  (its .env is backed up first)
1568
1897
  --expires-days N agent-token lifetime (default 90)
1569
1898
  npx @decidio/sdk doctor check config, connectivity, and what your token can do
1899
+ npx @decidio/sdk tokens list [--mine] agent tokens with status (active/expired/REVOKED) by
1900
+ jti — token values are never re-shown; the list is
1901
+ workspace-wide, --mine narrows to this directory's agent
1902
+ npx @decidio/sdk tokens revoke <jti> kill a token now instead of waiting out its lifetime
1903
+
1904
+ APPROVALS AND RECEIPTS
1570
1905
  npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
1571
1906
  npx @decidio/sdk approvals approve <id> [reason]
1572
1907
  npx @decidio/sdk approvals reject <id> [reason]
@@ -1576,21 +1911,110 @@ const USAGE = `decidio — the Decidio SDK command line
1576
1911
  If it starts with a dash, put it after -- or use --reason:
1577
1912
  ... approve <id> -- "-50% cut, per the board"
1578
1913
  ... approve <id> --reason "-50% cut, per the board"
1579
- npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
1914
+ npx @decidio/sdk receipt <id> [--json] download a sealed decision's Authority Receipt (.json)
1580
1915
  npx @decidio/sdk verify [--issuer <did>] <receipt.json> [more...] verify receipts OFFLINE
1916
+
1917
+ DEVELOPMENT
1581
1918
  npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
1582
1919
  npx @decidio/sdk --version print the CLI version
1583
1920
 
1584
- Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
1585
- DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
1586
- fully offline — it touches no network and reads no .env.
1587
- Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
1588
- 3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
1589
- receipt written — pass --auto-approve-demo for a run that completes unattended).
1590
- Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
1591
- pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
1592
- Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
1593
- (quickstart, durable resume, workers, adapters).`;
1921
+ NOTES
1922
+ Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
1923
+ DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
1924
+ fully offline — it touches no network and reads no .env.
1925
+ Update check: network commands ask the public npm registry once a day whether a newer version exists. If so,
1926
+ an interactive run asks "Upgrade now? [y/n]" first (y upgrades and re-runs your command; n exits); a headless
1927
+ run prints the notice and continues (DECIDIO_REQUIRE_LATEST=1 makes it exit). DECIDIO_NO_UPDATE_CHECK=1 skips
1928
+ the check. Nothing is sent to Decidio; verify never checks.
1929
+ Help: --help (or -h) after any command prints this page - one page for every command.
1930
+ Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
1931
+ 3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
1932
+ receipt written — pass --auto-approve-demo for a run that completes unattended).
1933
+ Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
1934
+ pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
1935
+ Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
1936
+ (quickstart, durable resume, workers, adapters).`;
1937
+ const GATED_CMDS = new Set(["quickstart", "init", "login", "doctor", "approvals", "receipt", "tokens"]);
1938
+ function semverNewer(a, b) {
1939
+ const pa = a.split(".").map((x) => parseInt(x, 10)), pb = b.split(".").map((x) => parseInt(x, 10));
1940
+ if (pa.length < 3 || pb.length < 3 || pa.some((x) => Number.isNaN(x)) || pb.some((x) => Number.isNaN(x)))
1941
+ return false;
1942
+ for (let i = 0; i < 3; i++)
1943
+ if (pa[i] !== pb[i])
1944
+ return pa[i] > pb[i];
1945
+ return false;
1946
+ }
1947
+ async function latestPublished() {
1948
+ const cache = joinPath(sessionDir(), "update-check.json");
1949
+ try {
1950
+ const c = JSON.parse(readFileSync(cache, "utf8"));
1951
+ if (c && typeof c.latest === "string" && Date.now() - Number(c.checkedAt) < 24 * 3600 * 1000)
1952
+ return c.latest;
1953
+ }
1954
+ catch { }
1955
+ const reg = (SHELL_ENV.DECIDIO_UPDATE_REGISTRY ?? "https://registry.npmjs.org").replace(/\/+$/, "");
1956
+ try {
1957
+ const ctl = new AbortController();
1958
+ const t = setTimeout(() => ctl.abort(), 2000);
1959
+ const r = await fetch(`${reg}/@decidio%2fsdk/latest`, { signal: ctl.signal });
1960
+ clearTimeout(t);
1961
+ if (!r.ok)
1962
+ return null;
1963
+ const body = await r.json();
1964
+ const latest = typeof body?.version === "string" && /^\d+\.\d+\.\d+$/.test(body.version) ? body.version : null;
1965
+ if (latest) {
1966
+ try {
1967
+ mkdirSync(sessionDir(), { recursive: true });
1968
+ writeFileSync(cache, JSON.stringify({ checkedAt: Date.now(), latest }));
1969
+ }
1970
+ catch { }
1971
+ }
1972
+ return latest;
1973
+ }
1974
+ catch {
1975
+ return null;
1976
+ }
1977
+ }
1978
+ async function updateGate(cmd, argv) {
1979
+ if (!cmd || !GATED_CMDS.has(cmd) || SHELL_ENV.DECIDIO_NO_UPDATE_CHECK || VERSION === "unknown" || VERSION.includes("-"))
1980
+ return null;
1981
+ const latest = await latestPublished();
1982
+ if (!latest || !semverNewer(latest, VERSION))
1983
+ return null;
1984
+ const underNpx = /[\\/]_npx[\\/]/.test(process.argv[1] ?? "");
1985
+ const upgradeCmd = underNpx ? `npx @decidio/sdk@latest ${argv.slice(2).join(" ")}` : "npm i -g @decidio/sdk@latest";
1986
+ const notice = `${latest} is available (you have ${VERSION}).`;
1987
+ if (!(process.stdin.isTTY && process.stdout.isTTY)) {
1988
+ err(`⚠ ${notice} Upgrade: ${upgradeCmd}`);
1989
+ if (SHELL_ENV.DECIDIO_REQUIRE_LATEST) {
1990
+ err(`✘ DECIDIO_REQUIRE_LATEST is set — not running a stale version.`);
1991
+ return 1;
1992
+ }
1993
+ return null;
1994
+ }
1995
+ const a = (await ask(`${notice} Upgrade now? [y/n]: `)).toLowerCase();
1996
+ if (a !== "y" && a !== "yes") {
1997
+ err(`✘ not upgraded — run this when ready: ${upgradeCmd}`);
1998
+ return 1;
1999
+ }
2000
+ if (underNpx) {
2001
+ err(` Under npx there is nothing installed to upgrade — re-run as: ${upgradeCmd}`);
2002
+ return 1;
2003
+ }
2004
+ if (SHELL_ENV.DECIDIO_UPDATE_DRY_RUN) {
2005
+ log(` would run: ${upgradeCmd}`);
2006
+ return null;
2007
+ }
2008
+ log(` upgrading: ${upgradeCmd}`);
2009
+ const up = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["i", "-g", "@decidio/sdk@latest"], { stdio: "inherit", shell: process.platform === "win32", env: SHELL_ENV });
2010
+ if (up.status !== 0) {
2011
+ err(`✘ the upgrade did not complete (exit ${up.status ?? "?"}) — run it by hand: ${upgradeCmd}`);
2012
+ return 1;
2013
+ }
2014
+ log(` ✔ upgraded — re-running: decidio ${argv.slice(2).join(" ")}`);
2015
+ const rerun = spawnSync(process.execPath, [process.argv[1], ...argv.slice(2)], { stdio: "inherit", env: SHELL_ENV });
2016
+ return rerun.status ?? 1;
2017
+ }
1594
2018
  async function main(argv) {
1595
2019
  const [cmd, a, b, ...rest] = argv.slice(2);
1596
2020
  if (cmd === "--version" || cmd === "-v" || cmd === "-V" || cmd === "version") {
@@ -1605,9 +2029,15 @@ async function main(argv) {
1605
2029
  log(USAGE);
1606
2030
  return 0;
1607
2031
  }
2032
+ if (helpFlagAnywhere(argv.slice(2))) {
2033
+ log(USAGE);
2034
+ return 0;
2035
+ }
1608
2036
  const argsIn = argv.slice(3);
1609
2037
  const KNOWN = {
1610
- quickstart: { flags: ["--auto-approve-demo", "--keep-agent"] },
2038
+ quickstart: { flags: ["--explain", "--auto-approve-demo", "--keep-agent"], valued: ["--phone"] },
2039
+ login: { flags: [] },
2040
+ logout: { flags: [] },
1611
2041
  init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
1612
2042
  doctor: { flags: [] },
1613
2043
  approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
@@ -1648,7 +2078,7 @@ async function main(argv) {
1648
2078
  return 2;
1649
2079
  }
1650
2080
  }
1651
- const MAX_POS = { quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
2081
+ const MAX_POS = { login: 1, logout: 0, quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
1652
2082
  const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || cmd === "verify";
1653
2083
  if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
1654
2084
  err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
@@ -1667,7 +2097,12 @@ async function main(argv) {
1667
2097
  return 2;
1668
2098
  }
1669
2099
  }
2100
+ const gated = await updateGate(cmd, argv);
2101
+ if (gated !== null)
2102
+ return gated;
1670
2103
  switch (cmd) {
2104
+ case "login": return cmdLogin(argsIn.find((x) => !x.startsWith("-")));
2105
+ case "logout": return cmdLogout();
1671
2106
  case "quickstart": {
1672
2107
  if (argsIn.includes("cleanup")) {
1673
2108
  const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
@@ -1679,7 +2114,14 @@ async function main(argv) {
1679
2114
  return ".env.quickstart";
1680
2115
  } })();
1681
2116
  if (!envFile) {
1682
- log("Nothing to clean up here: no .env.quickstart, .env doesn't belong to a quickstart agent, and the token recovery journal owes nothing.");
2117
+ let held = [];
2118
+ try {
2119
+ held = journalOutstanding();
2120
+ }
2121
+ catch { }
2122
+ log(held.length
2123
+ ? `Nothing to clean up here: no tour credentials. The token recovery journal names ${held.length} live token(s) held by a credential file here (${held.map((j) => j.jti).join(", ")}) — not tour tokens, left alone; doctor lists them.`
2124
+ : "Nothing to clean up here: no .env.quickstart, .env doesn't belong to a quickstart agent, and the token recovery journal has no live token to act on.");
1683
2125
  return 0;
1684
2126
  }
1685
2127
  const session = await acquireSession("Cleaning the quickstart up");
@@ -1687,7 +2129,7 @@ async function main(argv) {
1687
2129
  return 1;
1688
2130
  return quickstartCleanup(session, envFile);
1689
2131
  }
1690
- return cmdQuickstart({ autoApproveDemo: argsIn.includes("--auto-approve-demo"), keepAgent: argsIn.includes("--keep-agent") });
2132
+ return cmdQuickstart({ autoApproveDemo: argsIn.includes("--auto-approve-demo"), keepAgent: argsIn.includes("--keep-agent"), explain: argsIn.includes("--explain"), phone: valuedOpt(argsIn, "--phone") });
1691
2133
  }
1692
2134
  case "init": {
1693
2135
  const unique = argsIn.includes("--unique");
@@ -1758,7 +2200,7 @@ async function main(argv) {
1758
2200
  return 2;
1759
2201
  }
1760
2202
  if (!/^https?:\/\//.test(target)) {
1761
- err(`✘ --target needs an http(s) URL to forward the webhook to (got "${target}").`);
2203
+ err(`✘ --target needs an http(s) URL to forward the webhook to (got "${redactUrl(target)}").`);
1762
2204
  return 2;
1763
2205
  }
1764
2206
  return cmdDev(target, Number(portRaw));
@@ -1785,5 +2227,12 @@ const invokedAsProgram = (() => {
1785
2227
  }
1786
2228
  })();
1787
2229
  if (invokedAsProgram)
1788
- main(process.argv).then((code) => { if (code !== 0)
1789
- process.exitCode = code; }).catch((e) => { console.error(`✘ unexpected error: ${e?.message ?? e}`); process.exit(1); });
2230
+ main(process.argv).then(async (code) => {
2231
+ if (code !== 0)
2232
+ process.exitCode = code;
2233
+ if (stdinReadAbandoned) {
2234
+ await new Promise((r) => process.stdout.write("", () => r()));
2235
+ await new Promise((r) => process.stderr.write("", () => r()));
2236
+ process.exit(code);
2237
+ }
2238
+ }).catch((e) => { console.error(`✘ unexpected error: ${e?.message ?? e}`); process.exit(1); });