@decidio/sdk 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ the gate, signing, receipts and the verifier - is conformance-locked between the
5
5
  first-run surfaces (the fork, `--demo`, the code sign-in) are TypeScript-first and named as such in the parity
6
6
  allowlist. Each entry names what changed, what a reader must do on upgrade, and what proved it.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.10.0 — connect a coding assistant in one tap
11
+
12
+ - **`npx @decidio/sdk connect`** (cycle connect). A coding assistant sets Decidio up for you: it runs `connect`, which prints a
13
+ link and a short code (on a real terminal also a QR, and it opens the browser); the workspace owner opens the link signed in,
14
+ checks the code, picks who approves and taps Allow; `connect --wait` then returns by itself (exit 0 connected, 1 still
15
+ waiting - run it again, 3 refused or expired; within 90 s, under an assistant's 2-minute command limit). The machine gets an
16
+ ask-only credential - never a session - kept OUTSIDE the repository (the config dir, owner-only, keyed by the repository root
17
+ and the Decidio), and the hooks switch on in `.claude/settings.local.json` (`--team`: the shared `.claude/settings.json`).
18
+ No `.env`, nothing to paste, the word "agent" nowhere in the setup.
19
+ - **In a cloud session the hooks go in `.claude/settings.json`.** A cloud session reads only the repository's own settings file,
20
+ never `.claude/settings.local.json` (Claude Code's docs), so that is where they work. connect leaves it uncommitted and says to ask
21
+ the person before committing it (committed, every cloud session of the repository and every teammate's clone ask too). The
22
+ credential is that cloud machine's: a fresh one refuses destructive commands until it connects again.
23
+ - **The machine proves its key.** The start is signed with the machine's own key (the request-bound proof every signed
24
+ request carries, over the assistant, project, machine and cloud-or-local), so nobody can file a connect in another
25
+ machine's name; the server refuses a start without it. The connect checks the wire first (https, or a local address),
26
+ since the poll's answer carries the credential.
27
+ - **Credentials, in order:** the shell, then the folder's `.env`, then this repository's connection - all or nothing, so a
28
+ project with an `init` `.env` keeps exactly that identity. `disconnect` deletes this machine's token (gated commands are then
29
+ refused until you connect again; the machine's key stays, so reconnecting reuses its agent).
30
+ - **The hook's words** for an unconnected project now name `connect` and `connect --wait` (the plugin's skill says the same);
31
+ `hook install` with no credentials points at `connect` first.
32
+ - **The links the command line prints follow the API's own web app** (launch plan L1c). Staging's API maps to
33
+ `staging.decidioai.com`, production's (`mcp.decidioai.com`) to `app.decidioai.com`, and any other origin to itself.
34
+ `DECIDIO_APP_URL` wins only as a plain `http(s)` address, and the host line names it.
35
+ - **Release condition:** publish only after `staging.decidioai.com` serves staging's web app (docs/Deployment_Runbook.md
36
+ A1). Tell testers to run `github init` again to refresh their AGENTS.md section.
37
+
38
+ On upgrade: nothing to change - `init` projects keep their `.env`. The plugin pins 0.10.0. `connect` needs a Decidio that
39
+ serves `/connect/*` (the lab, from cycle connect's deploy). Proved by: cli-smoke.ts (356 → 378 checks: connect, its signed
40
+ start, wait, reconnect, disconnect, refused, expired, a cloud session, --team, beside the plugin, .env precedence, the hook
41
+ through a connection).
42
+
8
43
  ## 0.9.0 — the coding-agent hook catches what agents type, protects itself, and tells the approver what was asked
9
44
 
10
45
  - **Wider matching, from real agent runs.** `sql-destructive` counts a statement sent through an interpreter or runner
package/README.md CHANGED
@@ -75,8 +75,8 @@ npx @decidio/sdk init --unique my-agent # --unique appends a random suffix: co
75
75
  Every network command talks to the hosted sandbox by default (`verify` is offline) and announces its target host on
76
76
  stderr each run — set `DECIDIO_API_URL` (env or `.env`) to point at another Decidio instead.
77
77
 
78
- `init` signs you into your workspace (email + password, or the 8-character code the CLI emails you —
79
- type it where you asked), generates the agent's Ed25519 keypair **locally** (only the public `did:key`
78
+ `init` signs you into your workspace (the 8-character code the CLI emails you — type it where you asked —
79
+ or, for an account made before sign-up became passwordless, its email + password), generates the agent's Ed25519 keypair **locally** (only the public `did:key`
80
80
  is sent), registers the agent, mints its **floor-limited API token** (limited to the agent runtime — request authorization, poll status, report signed confirmation; it cannot administer the workspace or approve anything — and your workspace session never touches the agent's disk), and writes `.env`.
81
81
  Every later command that talks to Decidio reads `.env` from this directory automatically (`verify` is
82
82
  offline and never does). Re-running init for an
package/dist/cli.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ export declare function appUrlFor(api: string, override?: string | undefined): string;
2
3
  export type ApprovalAnswer = "approve" | "reject" | "again";
3
4
  export declare function approvalAnswer(answer: unknown): ApprovalAnswer;
4
5
  /** The issuer store's key for a Decidio address: its origin (scheme + lowercased host + non-default port), computed by a
package/dist/cli.js CHANGED
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, rmdirSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync, statSync, lstatSync, fstatSync, readSync } from "node:fs";
3
3
  import { randomBytes, createHash } from "node:crypto";
4
- import { homedir, tmpdir } from "node:os";
4
+ import { homedir, tmpdir, hostname } from "node:os";
5
5
  import { join as joinPath, resolve as resolvePath, sep as pathSep, basename, dirname, relative, isAbsolute } from "node:path";
6
6
  import { createServer } from "node:http";
7
7
  import { isIP } from "node:net";
8
- import { execFileSync, spawnSync } from "node:child_process";
8
+ import { execFileSync, spawnSync, spawn } from "node:child_process";
9
9
  import { createInterface } from "node:readline";
10
10
  import { pathToFileURL as toFileUrl, fileURLToPath as fromFileUrl } from "node:url";
11
11
  import { realpathSync as realPath } from "node:fs";
12
12
  import { createRequire } from "node:module";
13
- import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
13
+ import { generateKeypair, assertSafeTransport, redactUrl, signBoundProof } from "./signing.js";
14
14
  import { hardenFile } from "./resume.js";
15
15
  import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, DecidioHttpError, didFromPrivateKey, checkApproval, signConfirm, buildConfirmBody, readConfirmResponse, bindingMaterial, boundScope, idempotencyKey, decisionUrl, decisionIdProblem } from "./index.js";
16
16
  import { gateAuthorizeTwinAware, adapterRequest } from "./adapters/_gate.js";
@@ -76,6 +76,14 @@ if (!OFFLINE_CMD)
76
76
  }
77
77
  const DEFAULT_API = "https://decidio-api.onrender.com";
78
78
  let API = (process.env.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
79
+ let CREDS_FROM = SHELL_ENV.DECIDIO_API_TOKEN ? "shell" : process.env.DECIDIO_API_TOKEN ? ".env" : null;
80
+ if (!OFFLINE_CMD && !["init", "quickstart", "login", "logout", "phone"].includes(ARGV_TAIL[0] ?? "") && !process.env.DECIDIO_API_TOKEN && !process.env.DECIDIO_AGENT_ID) {
81
+ const c = loadConnection(projectRoot(), API);
82
+ if (c) {
83
+ promoteConnection(c);
84
+ CREDS_FROM = "connection";
85
+ }
86
+ }
79
87
  let DEMO = false;
80
88
  function enterDemo(url) { API = url; process.env.DECIDIO_API_URL = url; DEMO = true; }
81
89
  const pinWords = () => (DEMO ? "PINNED to this demo's own key" : "PINNED to your workspace issuer");
@@ -90,7 +98,7 @@ let hostAnnounced = false;
90
98
  async function api(path, init = {}, bearer, soft = false) {
91
99
  if (!hostAnnounced) {
92
100
  hostAnnounced = true;
93
- err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
101
+ err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}${APP_URL_OVERRIDDEN ? ` · links → ${APP_URL} (DECIDIO_APP_URL)` : ""}`);
94
102
  }
95
103
  const auth = bearer ?? TOKEN();
96
104
  if (auth && envUnreadable && !process.env.DECIDIO_API_URL) {
@@ -462,7 +470,24 @@ function block(n, title, rows, tone) {
462
470
  }
463
471
  log(f("└" + "─".repeat(width - 2) + "┘"));
464
472
  }
465
- const APP_URL = API.replace("decidio-api.onrender.com", "app.decidioai.com");
473
+ const APP_URL_SHAPE = /^https?:\/\/[\x21-\x7e]+$/;
474
+ const asciiTrim = (v) => v.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, "");
475
+ export function appUrlFor(api, override = process.env.DECIDIO_APP_URL) {
476
+ const o = asciiTrim(override ?? "");
477
+ if (APP_URL_SHAPE.test(o))
478
+ return o.replace(/\/+$/, "");
479
+ const base = api.replace(/\/+$/, "");
480
+ const known = {
481
+ "https://decidio-api.onrender.com": "https://staging.decidioai.com",
482
+ "https://mcp-staging.decidioai.com": "https://staging.decidioai.com",
483
+ "https://mcp.decidioai.com": "https://app.decidioai.com",
484
+ };
485
+ return known[base] ?? base;
486
+ }
487
+ const APP_URL = appUrlFor(API);
488
+ const APP_URL_OVERRIDDEN = APP_URL !== appUrlFor(API, "");
489
+ if (asciiTrim(process.env.DECIDIO_APP_URL ?? "") && !APP_URL_SHAPE.test(asciiTrim(process.env.DECIDIO_APP_URL ?? "")))
490
+ console.error("⚠ ignoring DECIDIO_APP_URL: it must be a plain http(s) address (printable characters only)");
466
491
  let stdinReadAbandoned = false;
467
492
  let EXPLAIN = false;
468
493
  let currentRl = null;
@@ -3263,11 +3288,11 @@ async function readHookStdin() {
3263
3288
  function hookConfig() {
3264
3289
  const token = process.env.DECIDIO_API_TOKEN;
3265
3290
  if (!token)
3266
- return { missing: "DECIDIO_API_TOKEN is not set - no agent token in this folder's .env or the environment" };
3291
+ return { missing: "this project is not connected to Decidio (no connection, no agent token in this folder's .env or the environment)" };
3267
3292
  return {
3268
3293
  apiUrl: API, agentId: process.env.DECIDIO_AGENT_ID || defaultAgentId(), apiToken: token, sourceSystem: "claude-code",
3269
3294
  agentKey: process.env.DECIDIO_AGENT_KEY || undefined, agentDid: process.env.DECIDIO_AGENT_DID || undefined, workspaceId: process.env.DECIDIO_WORKSPACE_ID || undefined,
3270
- appUrl: process.env.DECIDIO_APP_URL || APP_URL,
3295
+ appUrl: APP_URL,
3271
3296
  };
3272
3297
  }
3273
3298
  const hookStateDir = () => (process.env.CLAUDE_PLUGIN_DATA ? joinPath(process.env.CLAUDE_PLUGIN_DATA, "hooks") : joinPath(sessionDir(), "hooks"));
@@ -3428,7 +3453,7 @@ async function cmdHookPretool() {
3428
3453
  const short = hookShort(hookRedact(g.text.replace(/\s+/g, " ").trim()));
3429
3454
  const cfg = hookConfig();
3430
3455
  if ("missing" in cfg)
3431
- return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval, but this project has no Decidio agent credentials (${cfg.missing}). ${w.ran} Ask the user to run \`npx @decidio/sdk init\` in ${projectDir}, then retry the same ${w.noun}.`, `Decidio: no agent credentials in ${projectDir} - run npx @decidio/sdk init`);
3456
+ return deny(`Decidio: "${short}" is a ${g.category} ${w.noun} that needs a person's approval, but this project is not connected to Decidio yet. ${w.ran} Run \`npx @decidio/sdk connect\` in ${projectDir} and show the user the link it prints (they allow it on the web), then run \`npx @decidio/sdk connect --wait\`; once it says connected, retry the same ${w.noun}.`, `Decidio: this project is not connected - run npx @decidio/sdk connect`);
3432
3457
  const rel = relative(projectDir, cwdAbs);
3433
3458
  const cwdRel = !rel ? "." : rel.startsWith("..") || isAbsolute(rel) ? cwdAbs : rel;
3434
3459
  const session = typeof input.session_id === "string" ? input.session_id : "";
@@ -3569,7 +3594,7 @@ async function cmdHookWait(id, timeoutSec) {
3569
3594
  }
3570
3595
  const cfg = hookConfig();
3571
3596
  if ("missing" in cfg) {
3572
- err(`✘ ${cfg.missing} - run \`npx @decidio/sdk init\` in this project first.`);
3597
+ err(`✘ ${cfg.missing} - connect this project first: npx @decidio/sdk connect`);
3573
3598
  return 1;
3574
3599
  }
3575
3600
  const where = decisionUrl(cfg.appUrl, id);
@@ -3671,7 +3696,7 @@ function hookRunner() {
3671
3696
  const HOOK_FILE_TOOLS = ["Write", "Edit", "MultiEdit"];
3672
3697
  const HOOK_GUARD_PATHS = ["**/.claude/settings*.json", "~/.claude/settings*.json", "~/.claude/plugins/**"];
3673
3698
  async function cmdHookInstall(opts) {
3674
- const settingsPath = joinPath(".claude", opts.project ? "settings.json" : "settings.local.json");
3699
+ const settingsPath = joinPath(opts.dir ?? ".", ".claude", opts.project ? "settings.json" : "settings.local.json");
3675
3700
  let settings = {};
3676
3701
  if (existsSync(settingsPath)) {
3677
3702
  try {
@@ -3725,27 +3750,380 @@ async function cmdHookInstall(opts) {
3725
3750
  guard[place("PreToolUse", t, `${t}(${p})`, preLine, { timeout: 150, onFailure: "block" })]++;
3726
3751
  guard[place("PostToolUse", t, `${t}(${p})`, postLine, { timeout: 30 })]++;
3727
3752
  }
3728
- mkdirSync(".claude", { recursive: true });
3753
+ mkdirSync(dirname(settingsPath), { recursive: true });
3729
3754
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
3755
+ lastHookInstall = { settingsPath, pre, post };
3756
+ if (opts.quiet)
3757
+ return 0;
3730
3758
  log(`✔ ${settingsPath.split(pathSep).join("/")}: PreToolUse hook ${pre}, PostToolUse hook ${post} (matcher Bash|PowerShell${opts.project ? "; this file is shared with everyone who clones the project" : "; this file is yours alone - Claude Code keeps it out of git"}).`);
3731
3759
  log(`✔ the guard on Claude Code's own settings - ${HOOK_FILE_TOOLS.join(", ")} on ${HOOK_GUARD_PATHS.join(", ")}, PreToolUse and PostToolUse: ${Object.entries(guard).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(", ")}.`);
3732
3760
  log(` Runs: ${runner} hook pretool|posttool - the pretool hook times out at 150 s and BLOCKS the call if it fails (fail closed).`);
3733
3761
  const env = readEnvFile();
3734
3762
  const token = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
3735
- if (token)
3763
+ if (token && CREDS_FROM === "connection")
3764
+ log(`✔ credentials: this project's Decidio connection (${process.env.DECIDIO_AGENT_ID}) - kept outside the repository, owner-only.`);
3765
+ else if (token)
3736
3766
  log(`✔ agent credentials: ${ENV_PATH} holds DECIDIO_API_TOKEN${(process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) ? " and the agent's signing key" : ""} for "${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || defaultAgentId()}".`);
3737
3767
  else
3738
- log(`⚠ no agent credentials here (no DECIDIO_API_TOKEN in ${ENV_PATH}) - until there are, EVERY gated command is refused. Run: npx @decidio/sdk init ${defaultAgentId()}`);
3768
+ log(`⚠ not connected yet - until this project is, EVERY gated command is refused. Run: npx @decidio/sdk connect (or, for your own agent: npx @decidio/sdk init ${defaultAgentId()})`);
3739
3769
  log(` Gated: ${HOOK_RULES.map((r) => r.category).join(", ")} (npx @decidio/sdk hook rules lists the patterns; DECIDIO_HOOK_EXTRA_RULES adds yours).`);
3740
3770
  log(` Approver: anyone in your workspace with a role other than the agent's owner (the person who ran init) - on the phone app, the web app, Slack or Teams.`);
3741
3771
  log(` Nobody yet? Invite one: npx @decidio/sdk hook install --approver <email> (single-use link; share by WhatsApp, SMS or email)`);
3742
3772
  log(` Flow: a gated command is held up to ${Math.min(Number(process.env.DECIDIO_HOOK_HOLD_MS ?? 90_000) || 90_000, HOOK_HOLD_CAP_MS) / 1000} s for the approver, then refused with its decision id; \`npx @decidio/sdk hook wait <id>\` returns when they decide,`);
3743
3773
  log(` and the SAME command retried then runs - once. A lab workspace allows 30 governed actions an hour; a gated command spends two (ask, retry).`);
3744
- log(` Reload: restart Claude Code, or run /hooks in it - hooks are read when a session starts.`);
3774
+ log(` Reload: none needed - Claude Code picks up the settings file by itself. If a gated command is not asked about, open /hooks once (or restart Claude Code).`);
3745
3775
  if (opts.approver !== undefined)
3746
3776
  return inviteApprover(opts.approver);
3747
3777
  return 0;
3748
3778
  }
3779
+ function connectHostLabel(h) { return h === "claude-code" ? "Claude Code" : h; }
3780
+ function connectDir() { return joinPath(sessionDir(), "connections"); }
3781
+ function realOrSame(p) { try {
3782
+ return realPath(p);
3783
+ }
3784
+ catch {
3785
+ return resolvePath(p);
3786
+ } }
3787
+ const sameDir = (a, b) => (process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b);
3788
+ function projectRoot() {
3789
+ try {
3790
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
3791
+ const top = String(r.stdout ?? "").trim();
3792
+ if (r.status === 0 && top)
3793
+ return realOrSame(top);
3794
+ }
3795
+ catch { }
3796
+ return realOrSame(SHELL_ENV.CLAUDE_PROJECT_DIR || process.cwd());
3797
+ }
3798
+ function connectionKey(root) { return createHash("sha256").update(`${API}\n${process.platform === "win32" ? root.toLowerCase() : root}`, "utf8").digest("hex").slice(0, 20); }
3799
+ function connectionFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.json`); }
3800
+ function pendingConnectFile(root) { return joinPath(connectDir(), `${connectionKey(root)}.pending.json`); }
3801
+ function readJsonQuiet(f) {
3802
+ try {
3803
+ return existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null;
3804
+ }
3805
+ catch {
3806
+ return null;
3807
+ }
3808
+ }
3809
+ function loadConnection(root, api) {
3810
+ const c = connectionRecord(root, api);
3811
+ return c && !c.disconnected && c.token ? c : null;
3812
+ }
3813
+ function connectionRecord(root, api) {
3814
+ const c = readJsonQuiet(connectionFile(root));
3815
+ return c && c.v === 1 && c.api === api && c.root === root && typeof c.token === "string" && typeof c.agentId === "string" && typeof c.key === "string" && typeof c.did === "string" ? c : null;
3816
+ }
3817
+ function promoteConnection(c) {
3818
+ process.env.DECIDIO_API_TOKEN = c.token;
3819
+ process.env.DECIDIO_AGENT_ID = c.agentId;
3820
+ process.env.DECIDIO_AGENT_DID = c.did;
3821
+ process.env.DECIDIO_AGENT_KEY = c.key;
3822
+ process.env.DECIDIO_WORKSPACE_ID = c.workspaceId;
3823
+ process.env.DECIDIO_TOKEN_JTI = c.jti;
3824
+ if (c.appUrl && process.env.DECIDIO_APP_URL === undefined)
3825
+ process.env.DECIDIO_APP_URL = c.appUrl;
3826
+ }
3827
+ function inCloudSession() { return SHELL_ENV.CLAUDE_CODE_REMOTE === "true"; }
3828
+ function machineId() {
3829
+ const f = joinPath(sessionDir(), "machine-id");
3830
+ let cur = "";
3831
+ try {
3832
+ cur = existsSync(f) ? readFileSync(f, "utf8").trim() : "";
3833
+ }
3834
+ catch { }
3835
+ if (/^[0-9a-f]{8,32}$/.test(cur))
3836
+ return cur;
3837
+ const id = randomBytes(8).toString("hex");
3838
+ mkdirSync(sessionDir(), { recursive: true });
3839
+ writeFileSync(f, id + "\n");
3840
+ return id;
3841
+ }
3842
+ function machineLabel(cloud) {
3843
+ if (cloud)
3844
+ return `cloud-${machineId().slice(0, 4)}`;
3845
+ const h = (() => { try {
3846
+ return hostname();
3847
+ }
3848
+ catch {
3849
+ return "";
3850
+ } })().split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
3851
+ return (h || "machine").slice(0, 40);
3852
+ }
3853
+ function projectLabel(root) {
3854
+ try {
3855
+ const r = spawnSync("git", ["-C", root, "config", "--get", "remote.origin.url"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
3856
+ const m = String(r.stdout ?? "").trim().match(/([^/:\\]+?)(?:\.git)?\/?$/);
3857
+ if (r.status === 0 && m?.[1])
3858
+ return m[1].slice(0, 80);
3859
+ }
3860
+ catch { }
3861
+ try {
3862
+ const pj = JSON.parse(readFileSync(joinPath(root, "package.json"), "utf8"));
3863
+ if (typeof pj?.name === "string" && pj.name)
3864
+ return pj.name.replace(/^@[^/]+\//, "").slice(0, 80);
3865
+ }
3866
+ catch { }
3867
+ return (basename(root) || "project").slice(0, 80);
3868
+ }
3869
+ function localDesktop() {
3870
+ if (inCloudSession() || SHELL_ENV.CI || SHELL_ENV.SSH_CONNECTION || SHELL_ENV.SSH_TTY || SHELL_ENV.CODESPACES || SHELL_ENV.GITPOD_WORKSPACE_ID || SHELL_ENV.DECIDIO_NO_BROWSER)
3871
+ return false;
3872
+ if (process.platform === "darwin" || process.platform === "win32")
3873
+ return true;
3874
+ return !!(SHELL_ENV.DISPLAY || SHELL_ENV.WAYLAND_DISPLAY);
3875
+ }
3876
+ function openInBrowser(url) {
3877
+ if (!/^https?:\/\/[^\s"'<>`]+$/.test(url))
3878
+ return false;
3879
+ try {
3880
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
3881
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
3882
+ child.on("error", () => { });
3883
+ child.unref();
3884
+ return true;
3885
+ }
3886
+ catch {
3887
+ return false;
3888
+ }
3889
+ }
3890
+ const CONNECT_WAIT_S = () => { const n = Number(process.env.DECIDIO_CONNECT_WAIT_S ?? 90); return Number.isFinite(n) && n >= 1 && n <= 110 ? n : 90; };
3891
+ let lastHookInstall = null;
3892
+ async function startConnect(root, host) {
3893
+ const prior = connectionRecord(root, API);
3894
+ const kp = prior && !inCloudSession() ? { did: prior.did, privateKeyBase64: prior.key } : generateKeypair();
3895
+ const cloud = inCloudSession();
3896
+ const project = projectLabel(root).trim() || "project", machine = machineLabel(cloud).trim() || "machine";
3897
+ const proof = signBoundProof(kp.privateKeyBase64, { agentId: "connect", did: kp.did, action: "connect.start", amount: 0, scope: [host, project, machine, cloud ? "cloud" : "local"].join("\n") });
3898
+ const r = await api("/connect/start", { method: "POST", body: JSON.stringify({ host, project, machine, did: kp.did, cloud, proof }) }, "", true);
3899
+ if (r.status === 0) {
3900
+ err(`✘ couldn't reach Decidio at ${API} - check your connection and run npx @decidio/sdk connect again.`);
3901
+ return null;
3902
+ }
3903
+ if (r.status === 404 || r.status === 401) {
3904
+ err(`✘ ${r.body?.message ?? "Connecting a coding assistant is available in the Decidio lab only."} (${API})`);
3905
+ return null;
3906
+ }
3907
+ if (r.status === 429) {
3908
+ err(`✘ ${r.body?.message ?? "Too many tries - wait a minute and try again."}`);
3909
+ return null;
3910
+ }
3911
+ if (r.status !== 201 || typeof r.body?.pollSecret !== "string" || typeof r.body?.link !== "string") {
3912
+ err(`✘ couldn't start a connection (${r.status}${r.body?.message ? `: ${r.body.message}` : ""}). Nothing was saved.`);
3913
+ return null;
3914
+ }
3915
+ const p = { v: 1, api: API, root, project, host, machine, cloud, did: kp.did, key: kp.privateKeyBase64, code: String(r.body.code), link: String(r.body.link),
3916
+ pollSecret: r.body.pollSecret, expiresAt: String(r.body.expiresAt), interval: Number(r.body.interval) || 2, startedAt: new Date().toISOString() };
3917
+ mkdirSync(connectDir(), { recursive: true });
3918
+ writeSecretFile(pendingConnectFile(root), JSON.stringify(p) + "\n");
3919
+ if (typeof r.body.qr === "string")
3920
+ pendingQr = r.body.qr;
3921
+ return p;
3922
+ }
3923
+ let pendingQr = null;
3924
+ function readPendingConnect(root) {
3925
+ const p = readJsonQuiet(pendingConnectFile(root));
3926
+ return p && p.v === 1 && p.api === API && p.root === root && typeof p.pollSecret === "string" ? p : null;
3927
+ }
3928
+ function dropFile(f) { try {
3929
+ rmSync(f, { force: true });
3930
+ }
3931
+ catch { } }
3932
+ const minutesLeft = (iso) => Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 60_000));
3933
+ function showConnectLink(p, opened, tty) {
3934
+ const title = `Connect ${connectHostLabel(p.host)} to Decidio`;
3935
+ log(`${brand()} ${bold(title)}`);
3936
+ log(` Open: ${link(p.link)}`);
3937
+ log(` Code: ${bold(p.code)} ${dim("(the page shows the same code - only allow it if they match)")}`);
3938
+ log(dim(` ${p.project} · ${p.machine} · the link works for ${minutesLeft(p.expiresAt) || 1} more minute${minutesLeft(p.expiresAt) === 1 ? "" : "s"}${opened ? " · opened in your browser" : ""}`));
3939
+ if (tty && pendingQr) {
3940
+ log("");
3941
+ for (const line of pendingQr.split("\n"))
3942
+ if (line.trim())
3943
+ log(` ${line}`);
3944
+ log(dim(" Scan with your phone's camera to allow it there."));
3945
+ }
3946
+ }
3947
+ async function cmdConnect(opts) {
3948
+ const host = (opts.host ?? "claude-code");
3949
+ if (host !== "claude-code") {
3950
+ err(`✘ connect knows Claude Code today (--host claude-code); "${opts.host}" is not supported yet.`);
3951
+ return 2;
3952
+ }
3953
+ try {
3954
+ assertSafeTransport(API, true);
3955
+ }
3956
+ catch (e) {
3957
+ err(`✘ ${String(e?.message ?? e).replace(/^\[decidio\] /, "")}`);
3958
+ return 1;
3959
+ }
3960
+ const root = projectRoot();
3961
+ const tty = !!(process.stdin.isTTY && process.stdout.isTTY);
3962
+ if (CREDS_FROM === "shell" || CREDS_FROM === ".env") {
3963
+ log(`• this project already has Decidio credentials ${CREDS_FROM === "shell" ? "from your shell" : `in ${ENV_PATH}`} for "${process.env.DECIDIO_AGENT_ID ?? defaultAgentId()}" - the hook uses those, so connect leaves them alone.`);
3964
+ log(dim(` To use a connection instead, remove DECIDIO_API_TOKEN / DECIDIO_AGENT_ID from ${CREDS_FROM === "shell" ? "your shell" : ENV_PATH} and run npx @decidio/sdk connect again.`));
3965
+ return finishConnect(root, null, opts, false);
3966
+ }
3967
+ const existing = loadConnection(root, API);
3968
+ if (existing && !opts.force && new Date(existing.expiresAt).getTime() > Date.now())
3969
+ return finishConnect(root, existing, opts, false);
3970
+ if (opts.wait)
3971
+ return waitConnect(root, opts, false);
3972
+ let p = readPendingConnect(root);
3973
+ if (!p || p.host !== host || new Date(p.expiresAt).getTime() <= Date.now() + 15_000)
3974
+ p = await startConnect(root, host);
3975
+ if (!p)
3976
+ return 1;
3977
+ const opened = !opts.noBrowser && localDesktop() && openInBrowser(p.link);
3978
+ showConnectLink(p, opened, tty);
3979
+ if (tty) {
3980
+ log("");
3981
+ return waitConnect(root, opts, true);
3982
+ }
3983
+ log("");
3984
+ log(`Next: allow it at the link above, then run ${bold("npx @decidio/sdk connect --wait")} - it finishes by itself (nothing else to type).`);
3985
+ log(dim("Assistants: show the person the link and the code above, then run npx @decidio/sdk connect --wait."));
3986
+ return 0;
3987
+ }
3988
+ async function waitConnect(root, opts, interactive) {
3989
+ const p = readPendingConnect(root);
3990
+ if (!p) {
3991
+ const c = loadConnection(root, API);
3992
+ if (c)
3993
+ return finishConnect(root, c, opts, false);
3994
+ err("✘ no connection is in progress for this project - start one: npx @decidio/sdk connect");
3995
+ return 1;
3996
+ }
3997
+ const deadline = interactive ? new Date(p.expiresAt).getTime() + 5_000 : Math.min(new Date(p.expiresAt).getTime() + 5_000, Date.now() + CONNECT_WAIT_S() * 1000);
3998
+ const spin = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3999
+ let i = 0, misses = 0;
4000
+ if (!interactive)
4001
+ err(`· waiting for the connection to be allowed at ${p.link} (code ${p.code}; up to ${Math.round((deadline - Date.now()) / 1000)} s)`);
4002
+ while (Date.now() < deadline) {
4003
+ const r = await api("/connect/poll", { method: "POST", headers: { "x-decidio-connect-secret": p.pollSecret }, body: "{}" }, "", true);
4004
+ if (r.status === 200 && r.body?.status === "connected" && typeof r.body.token === "string") {
4005
+ if (interactive && TTY_OUT)
4006
+ process.stderr.write("\r\x1b[K");
4007
+ const c = { v: 1, api: API, root, project: p.project, host: p.host, machine: p.machine, cloud: p.cloud, agentId: String(r.body.agentId), did: p.did, key: p.key,
4008
+ token: r.body.token, jti: String(r.body.jti), expiresAt: String(r.body.expiresAt), workspaceId: String(r.body.workspaceId), workspaceName: r.body.workspace?.name ?? null,
4009
+ approver: r.body.approver?.name ?? null, approvedBy: r.body.approvedBy?.name ?? null, appUrl: typeof r.body.appUrl === "string" ? r.body.appUrl : null, connectedAt: new Date().toISOString() };
4010
+ mkdirSync(connectDir(), { recursive: true });
4011
+ try {
4012
+ writeSecretFile(connectionFile(root), JSON.stringify(c) + "\n");
4013
+ }
4014
+ catch (e) {
4015
+ err(`✘ connected, but this machine's credentials could not be written (${e?.message ?? e}) - run npx @decidio/sdk connect again (the server keeps nothing to collect twice).`);
4016
+ return 1;
4017
+ }
4018
+ dropFile(pendingConnectFile(root));
4019
+ return finishConnect(root, c, opts, true);
4020
+ }
4021
+ if (r.status === 403 && r.body?.status === "denied") {
4022
+ dropFile(pendingConnectFile(root));
4023
+ if (interactive && TTY_OUT)
4024
+ process.stderr.write("\r\x1b[K");
4025
+ err("✘ the connection was refused on the web (Not me). Nothing was saved.");
4026
+ return 3;
4027
+ }
4028
+ if ((r.status === 404 || r.status === 410) && (r.body?.status === "expired" || r.body?.status === "collected")) {
4029
+ dropFile(pendingConnectFile(root));
4030
+ if (interactive && TTY_OUT)
4031
+ process.stderr.write("\r\x1b[K");
4032
+ err(r.body.status === "collected" ? "✘ this connection was already collected by another run - start a fresh one: npx @decidio/sdk connect" : "✘ the link expired before it was allowed - start a fresh one: npx @decidio/sdk connect");
4033
+ return 3;
4034
+ }
4035
+ if (r.status === 0 && ++misses > 10) {
4036
+ err(`✘ lost touch with Decidio at ${API} - check your connection, then run npx @decidio/sdk connect --wait again.`);
4037
+ return 1;
4038
+ }
4039
+ if (interactive && TTY_OUT)
4040
+ process.stderr.write(`\r${spin[i++ % spin.length]} waiting for you to allow it in the browser… ${dim(`(${minutesLeft(p.expiresAt)} min left · Ctrl-C stops waiting; npx @decidio/sdk connect --wait picks it up again)`)}`);
4041
+ await new Promise((res) => setTimeout(res, (r.status === 429 ? 2 : 1) * Math.max(1, p.interval) * 1000));
4042
+ }
4043
+ if (interactive && TTY_OUT)
4044
+ process.stderr.write("\r\x1b[K");
4045
+ log(`• still waiting for it to be allowed: ${link(p.link)} (code ${p.code}, ${minutesLeft(p.expiresAt)} min left). Run npx @decidio/sdk connect --wait again once it is.`);
4046
+ return 1;
4047
+ }
4048
+ async function finishConnect(root, c, opts, fresh) {
4049
+ if (c) {
4050
+ promoteConnection(c);
4051
+ CREDS_FROM = "connection";
4052
+ }
4053
+ const cloud = inCloudSession();
4054
+ const shared = opts.team || cloud;
4055
+ const plugin = !cloud && decidioPluginEnabled(root);
4056
+ if (!plugin) {
4057
+ const rc = await cmdHookInstall({ project: shared, dir: root, quiet: true });
4058
+ if (rc !== 0)
4059
+ return rc;
4060
+ }
4061
+ const probe = await api("/agent/status/doctor-scope-probe", {}, process.env.DECIDIO_API_TOKEN, true);
4062
+ const verified = probe.status === 404;
4063
+ const settings = lastHookInstall ? relative(root, lastHookInstall.settingsPath).split(pathSep).join("/") || lastHookInstall.settingsPath : ".claude/settings.json";
4064
+ const hostName = connectHostLabel(c?.host ?? "claude-code");
4065
+ if (c) {
4066
+ log(`✔ ${fresh ? "Connected to Decidio" : "Already connected to Decidio"}${c.workspaceName ? ` - workspace ${c.workspaceName}` : ""}${fresh && c.approvedBy ? `, allowed by ${c.approvedBy}` : ""}`);
4067
+ log(`✔ Approvals go to ${c.approver ?? "the approver you picked"} - on the phone app, Slack, Teams or the web`);
4068
+ }
4069
+ if (!verified) {
4070
+ err(probe.status === 401 || probe.status === 403
4071
+ ? `✘ Decidio refused this connection's token (${probe.status}) - it was revoked or has expired. Reconnect: npx @decidio/sdk connect --force`
4072
+ : `⚠ couldn't check the token just now (${probe.status || "no answer"}) - the hooks are on; a gated command will say if anything is wrong.`);
4073
+ if (probe.status === 401 || probe.status === 403)
4074
+ return 1;
4075
+ }
4076
+ const changed = !plugin && lastHookInstall && (lastHookInstall.pre !== "kept" || lastHookInstall.post !== "kept");
4077
+ log(plugin
4078
+ ? `✔ ${hostName} asks before destructive commands in this project (the Decidio plugin's hooks)`
4079
+ : `✔ ${hostName} asks before destructive commands in this project (${settings})`);
4080
+ if (changed)
4081
+ log(dim(` Claude Code picks the settings change up by itself - if a destructive command is not asked about, open /hooks once (or restart it).`));
4082
+ if (c)
4083
+ log(dim(` Credentials: ${connectionFile(root)} (outside the repository, owner-only) · until ${c.expiresAt.slice(0, 10)}${cloud ? " · this cloud machine only - a fresh one refuses destructive commands until it connects again" : ""}`));
4084
+ if (!plugin && (shared || process.platform === "win32") && !sameDir(realOrSame(process.cwd()), root))
4085
+ log(dim(` Claude Code reads ${settings} from the folder it starts in - start it at ${root}.`));
4086
+ if (cloud && !opts.team)
4087
+ log(`• A cloud session reads only the repository's ${settings}, so the hooks are there - not committed. Ask the person before committing it: committed, every cloud session of this repository asks too, and so does every teammate's clone (a teammate connects with npx @decidio/sdk connect).`);
4088
+ else if (shared)
4089
+ log(`• Commit ${settings}: every clone then asks before destructive commands (a teammate connects with npx @decidio/sdk connect).`);
4090
+ if (fresh)
4091
+ log(dim(` Try it: ask ${hostName} to delete a scratch folder with rm -rf - the approval reaches ${c?.approver ?? "the approver"} on their phone.`));
4092
+ return 0;
4093
+ }
4094
+ const DECIDIO_PLUGIN = "decidio-approvals";
4095
+ function decidioPluginEnabled(root) {
4096
+ const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
4097
+ for (const f of [joinPath(root, ".claude", "settings.local.json"), joinPath(root, ".claude", "settings.json"), joinPath(home, ".claude", "settings.json")]) {
4098
+ const ep = readJsonQuiet(f)?.enabledPlugins;
4099
+ if (!ep || typeof ep !== "object" || Array.isArray(ep))
4100
+ continue;
4101
+ const named = Object.entries(ep).find(([k]) => k.split("@")[0] === DECIDIO_PLUGIN);
4102
+ if (named)
4103
+ return named[1] === true;
4104
+ }
4105
+ return false;
4106
+ }
4107
+ async function cmdDisconnect() {
4108
+ const root = projectRoot();
4109
+ const c = loadConnection(root, API);
4110
+ dropFile(pendingConnectFile(root));
4111
+ if (!c) {
4112
+ log("• this project isn't connected on this machine - nothing to do.");
4113
+ return 0;
4114
+ }
4115
+ try {
4116
+ writeSecretFile(connectionFile(root), JSON.stringify({ ...c, token: "", jti: "", disconnected: true }) + "\n");
4117
+ }
4118
+ catch (e) {
4119
+ err(`✘ couldn't update ${connectionFile(root)} (${e?.message ?? e}) - delete it by hand to disconnect.`);
4120
+ return 1;
4121
+ }
4122
+ log(`✔ disconnected this project on this machine - its token is deleted.`);
4123
+ log(` Destructive commands are now refused until you connect again (the hooks stay on: switching Decidio off needs a person's approval).`);
4124
+ log(dim(` "${c.agentId}" stays listed in Decidio until its token expires (${c.expiresAt.slice(0, 10)}); to end it now, revoke it on the Agents page${c.appUrl ? `: ${c.appUrl.replace(/\/+$/, "")}/connect/agents` : ""}.`));
4125
+ return 0;
4126
+ }
3749
4127
  async function inviteApprover(email) {
3750
4128
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
3751
4129
  err(`✘ --approver needs an email address (got "${email}")`);
@@ -4911,7 +5289,7 @@ async function cmdGithubInit(opts) {
4911
5289
  log(` ${line}`);
4912
5290
  }
4913
5291
  const agentsRel = "AGENTS.md", agentsAbs = joinPath(root, agentsRel);
4914
- const appUrl = (process.env.DECIDIO_APP_URL || apiUrl.replace(/^https:\/\/(decidio-api\.onrender\.com|mcp\.decidioai\.com)$/, "https://app.decidioai.com")).replace(/\/$/, "");
5292
+ const appUrl = appUrlFor(apiUrl);
4915
5293
  const section = agentsMdSection(appUrl);
4916
5294
  if (!plainRepoPath(root, agentsRel))
4917
5295
  err(`✘ ${agentsRel} is a symbolic link - nothing was written through it`);
@@ -5025,6 +5403,17 @@ APPROVALS AND RECEIPTS
5025
5403
  (never the receipt's own issuer field; --allow-unpinned: structural check only)
5026
5404
 
5027
5405
  CODING AGENTS (Claude Code)
5406
+ npx @decidio/sdk connect connect this project's coding assistant in one tap: prints a link and a short
5407
+ code (a QR and the browser too, on a real terminal); the workspace owner allows
5408
+ it on the web, then this machine gets an ask-only credential (kept outside the
5409
+ repository) and the hooks below switch on. Your assistant can run it for you.
5410
+ npx @decidio/sdk connect --wait finish a connect started by an assistant: returns once it is allowed, within 90 s
5411
+ (exit 0 connected · 3 refused or expired · 1 still waiting, run it again)
5412
+ --team: the shared .claude/settings.json every clone reads (default: yours
5413
+ alone; a cloud session reads only the shared file, so there it is always that
5414
+ one) · --no-browser · --force: a fresh token for this machine
5415
+ npx @decidio/sdk disconnect delete this machine's connection for this project (destructive commands are then
5416
+ refused until you connect again; the hooks stay on)
5028
5417
  npx @decidio/sdk hook install [--project] [--approver <email>] [--cli <path>]
5029
5418
  a person approves Claude Code's destructive shell commands before they run
5030
5419
  (rm -rf, git push --force, DROP TABLE, terraform apply, kubectl delete,
@@ -5129,7 +5518,8 @@ NOTES
5129
5518
  Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
5130
5519
  3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
5131
5520
  receipt written — pass --auto-approve-demo for a run that completes unattended), or
5132
- hook wait: the approver said no. hook pretool/posttool always exit 0 (their answer is the JSON).
5521
+ hook wait: the approver said no, or connect --wait: the connection was refused or expired (1 there: still
5522
+ waiting - run it again). hook pretool/posttool always exit 0 (their answer is the JSON).
5133
5523
  Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
5134
5524
  pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
5135
5525
  Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
@@ -5271,6 +5661,8 @@ async function main(argv) {
5271
5661
  logout: { flags: [] },
5272
5662
  phone: { flags: [] },
5273
5663
  init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
5664
+ connect: { flags: ["--wait", "--no-browser", "--team", "--force"], valued: ["--host"] },
5665
+ disconnect: { flags: [] },
5274
5666
  doctor: { flags: [] },
5275
5667
  approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
5276
5668
  receipt: { flags: ["--json"] },
@@ -5320,7 +5712,7 @@ async function main(argv) {
5320
5712
  return 2;
5321
5713
  }
5322
5714
  }
5323
- const MAX_POS = { login: 1, logout: 0, phone: 0, quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0, hook: 2, github: 1, decisions: 1 };
5715
+ const MAX_POS = { login: 1, logout: 0, phone: 0, quickstart: 1, init: 1, connect: 0, disconnect: 0, doctor: 0, receipt: 1, tokens: 2, dev: 0, hook: 2, github: 1, decisions: 1 };
5324
5716
  const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || (cmd === "hook" && positionals[0] === "justify") || cmd === "verify";
5325
5717
  if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
5326
5718
  err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
@@ -5478,6 +5870,8 @@ async function main(argv) {
5478
5870
  }
5479
5871
  return cmdContext(sub, pos.slice(1), { title: valuedOpt(argsIn, "--title"), trigger: valuedOpt(argsIn, "--trigger"), team: valuedOpt(argsIn, "--team"), unit: valuedOpt(argsIn, "--unit"), org: argsIn.includes("--org"), here: argsIn.includes("--here"), pack: valuedOpt(argsIn, "--pack"), chatgpt: argsIn.includes("--chatgpt"), json: argsIn.includes("--json") });
5480
5872
  }
5873
+ case "connect": return cmdConnect({ wait: argsIn.includes("--wait"), host: valuedOpt(argsIn, "--host"), noBrowser: argsIn.includes("--no-browser"), team: argsIn.includes("--team"), force: argsIn.includes("--force") });
5874
+ case "disconnect": return cmdDisconnect();
5481
5875
  case "hook": {
5482
5876
  const VALUED = ["--timeout", "--approver", "--cli"];
5483
5877
  const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
package/openapi.yaml CHANGED
@@ -5,7 +5,7 @@
5
5
  openapi: 3.1.0
6
6
  info:
7
7
  title: Decidio Agent Gate
8
- version: 0.9.0 # tracks the SDK release - checked by packages/conformance/manifests.mjs
8
+ version: 0.10.0 # tracks the SDK release - checked by packages/conformance/manifests.mjs
9
9
  description: >
10
10
  Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
11
11
  receive the signed resume webhook when a routed action is decided. Every outcome seals a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
85
85
  },
86
86
  "dependencies": {
87
- "@decidio/verify": "0.9.0"
87
+ "@decidio/verify": "0.10.0"
88
88
  },
89
89
  "author": "OmniTwin Technologies Inc.",
90
90
  "bugs": {