@scopebond/hook 0.3.0 → 0.5.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/README.md CHANGED
@@ -1,26 +1,60 @@
1
1
  # @scopebond/hook
2
2
 
3
- The Scopebond connector for **Claude Code** and **Cursor**. It checks every tool
3
+ The Scopebond connector for **Claude Code**, **Cursor**, and **OpenAI Codex**. It checks every tool
4
4
  call a coding agent makes against your policy *before it runs*, blocks the ones
5
5
  outside policy, and records a signed receipt — locally, on the machine, and
6
6
  optionally mirrored to your Scopebond Cloud workspace for monitoring.
7
7
 
8
- - **Label:** in-path · prevents (a Claude Code `PreToolUse` deny via exit code 2
9
- blocks even in bypass-permissions mode).
10
- - **Ceiling:** it governs actions the harness routes through its tool system
8
+ - **What it does:** checks supported agent actions before they run and blocks an
9
+ action when it breaks your policy.
10
+ - **Where it works:** actions the coding agent routes through its tool system
11
11
  (shell, file, MCP, web). A process started outside the harness is not covered.
12
12
 
13
- ## Enroll
13
+ ## Install once for your machine
14
14
 
15
15
  ```
16
- npx @scopebond/hook init # Claude Code (or: init --cursor)
16
+ npm i -g @scopebond/hook
17
+ scopebond install # detects Cursor and Codex
18
+ scopebond install --codex # set up Codex only
17
19
  ```
18
20
 
19
- `init` scaffolds `.scopebond/` (a machine signing key, a countersigning key, a
20
- starter policy — "protect main and production paths" — and a `.gitignore` so none
21
- of it is committed) and prints the hook configuration to add to
22
- `.claude/settings.json` or `.cursor/hooks.json`. Then run one safe command in the
23
- agent and see the receipt in `.scopebond/receipts.db`.
21
+ `install` sets Scopebond up **once per developer machine**, not per repository: it
22
+ scaffolds a user-level home (`~/.scopebond`, override `SCOPEBOND_HOME`) with a
23
+ signing key, a countersigning key and a starter policy, and registers the hook by
24
+ absolute path in your user-level `~/.claude/settings.json`, `~/.cursor/hooks.json`,
25
+ or `~/.codex/hooks.json`. After Codex setup, open Codex, run `/hooks`, review
26
+ Scopebond, and choose **Trust** once. Every project you open is then governed, and a project-local
27
+ `.scopebond/` still takes precedence when you want a per-repo policy.
28
+
29
+ ```
30
+ scopebond status # home, which agents are configured, cloud, receipts
31
+ scopebond doctor # node version, config location, cloud reachability
32
+ scopebond log # recent decisions scopebond verify # offline
33
+ scopebond uninstall # remove the hook (add --purge to delete keys too)
34
+ ```
35
+
36
+ ### Install as a Claude Code plugin
37
+
38
+ Scopebond is also a Claude Code plugin (this repo is a plugin marketplace):
39
+
40
+ ```
41
+ /plugin marketplace add avouro-com/scopebond
42
+ /plugin install scopebond
43
+ ```
44
+
45
+ ### Per-repository enroll (alternative)
46
+
47
+ ```
48
+ npx @scopebond/hook init # Claude Code
49
+ npx @scopebond/hook init --cursor # Cursor
50
+ npx @scopebond/hook init --codex # Codex, then approve it once with /hooks
51
+ ```
52
+
53
+ `init` scaffolds `.scopebond/` in the current project (a machine signing key, a
54
+ countersigning key, a starter policy — "protect main and production paths" — and a
55
+ `.gitignore` so none of it is committed) and configures `.claude/settings.json`,
56
+ `.cursor/hooks.json`, or `.codex/hooks.json`. Then run one safe command in the agent and see the receipt in
57
+ `.scopebond/receipts.db`.
24
58
 
25
59
  ## Connect it to your workspace (optional)
26
60
 
@@ -32,11 +66,13 @@ as `scopebond-enrollment.json`, then:
32
66
  npx @scopebond/hook connect https://<your-workspace> scopebond-enrollment.json
33
67
  ```
34
68
 
69
+ In Windows PowerShell, use `npx.cmd` instead of `npx` if script execution policy blocks `npx.ps1`; no execution-policy change is needed. Enrollment registers both the gateway attester and the hook's separate agent signing key with proof of possession, so Cloud can verify authenticated receipts.
70
+
35
71
  `connect` does the whole setup in one command: it scaffolds `.scopebond/` if needed,
36
72
  enrolls this machine's countersigning key, stores a scoped machine credential in
37
- `.scopebond/cloud.json` (a secret — never commit it), **and configures Claude Code
38
- for you** (it merges the hook into `.claude/settings.json`, preserving anything
39
- already there — pass `--no-install` to skip, or `--cursor` for Cursor). The
73
+ `.scopebond/cloud.json` (a secret — never commit it), **and configures your coding
74
+ agent for you** (it merges the hook into the agent's settings, preserving anything
75
+ already there — pass `--no-install` to skip, `--cursor` for Cursor, or `--codex` for Codex). The
40
76
  enrollment argument can be a file, an inline blob, or JSON on stdin, so the portal
41
77
  can hand you a single copy-paste command with nothing to save.
42
78
 
@@ -51,8 +87,8 @@ anything still queued — run it on a session-end hook (and set
51
87
  Each tool call is mapped to a normalized [Action Taxonomy](https://github.com/avouro-com/scopebond)
52
88
  action (`shell.exec`, `git.push`, `file.write`, `mcp.tool.call`, …), signed by the
53
89
  machine key and decided against your policy by an in-process check-only gateway.
54
- An allowed action is a **cooperative allow** (recorded, never executed by the
55
- hook — the agent performs it); a denied action is blocked. Unknown tools are
90
+ An allowed action is recorded and left to the coding agent's normal permission
91
+ prompt; the hook never auto-approves it. A denied action is blocked. Unknown tools are
56
92
  recorded as *not evaluated* and grant nothing. Anything unexpected fails closed
57
93
  (deny) with a repair message.
58
94
 
@@ -68,10 +104,10 @@ unmapped tools too — fail-closed coverage for anything the taxonomy does not m
68
104
  The mapper and runtime are exported for testing and embedding:
69
105
 
70
106
  ```js
71
- import { mapClaudeToolUse, createHookRuntime } from "@scopebond/hook";
107
+ import { mapClaudeToolUse, mapCodexToolUse, createHookRuntime } from "@scopebond/hook";
72
108
  ```
73
109
 
74
- `mapClaudeToolUse` / `mapCursorEvent` are pure functions (native payload →
110
+ `mapClaudeToolUse`, `mapCursorEvent`, and `mapCodexToolUse` are pure functions (native payload →
75
111
  normalized action). `createHookRuntime` builds the check-only gateway from a
76
112
  policy, a machine key and a local receipt log; pass `cloud: { connection }` (from
77
113
  `connectCloud`) to mirror receipts to a workspace.
package/dist/cli.js CHANGED
@@ -4,8 +4,9 @@
4
4
  //
5
5
  // scopebond-hook claude evaluate a Claude Code PreToolUse call (stdin JSON)
6
6
  // scopebond-hook cursor evaluate a Cursor hook event (stdin JSON)
7
- // scopebond-hook init [--cursor] [--no-install] scaffold keys + starter policy and configure the agent
8
- // scopebond-hook connect <url> <bundle.json> [--cursor]
7
+ // scopebond-hook codex evaluate a Codex PreToolUse call (stdin JSON)
8
+ // scopebond-hook init [--cursor|--codex] [--no-install]
9
+ // scopebond-hook connect <url> <bundle.json> [--cursor|--codex]
9
10
  // enroll with a Cloud workspace and start exporting
10
11
  // scopebond-hook log [-n N] show the most recent local receipts
11
12
  // scopebond-hook verify verify every local receipt offline against the attester key
@@ -20,10 +21,13 @@ import { tmpdir } from "node:os";
20
21
  import { execFileSync } from "node:child_process";
21
22
  import { verifyReceipt } from "@scopebond/gateway";
22
23
  import { openReceiptStore, loadOrCreateAttester } from "@scopebond/gateway/node";
23
- import { mapClaudeToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
24
+ import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
24
25
  import { createHookRuntime } from "./runtime.js";
25
26
  import { scaffold, harnessSnippet, installHarness } from "./init.js";
27
+ import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, } from "./install.js";
26
28
  import { connectCloud, loadConnection } from "./cloud.js";
29
+ import { cliCommand, hookVersion } from "./version.js";
30
+ import { fileURLToPath } from "node:url";
27
31
  /** The current git branch in `cwd` (best-effort). A bare `git push` pushes it, so
28
32
  * the runtime fills it in before evaluating; on failure the ref stays absent and
29
33
  * the starter policy fails closed. */
@@ -90,30 +94,48 @@ function denyClaude(reason) {
90
94
  process.stderr.write(`Scopebond: ${reason}\n`);
91
95
  process.exit(2);
92
96
  }
93
- async function runClaude() {
97
+ /** Codex accepts the structured deny response on a successful hook exit. Keeping
98
+ * exit 0 lets its UI show a completed policy check instead of a failed hook while
99
+ * still preventing the tool call. */
100
+ function denyCodex(reason) {
101
+ process.stdout.write(JSON.stringify({
102
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
103
+ }) + "\n");
104
+ process.exit(0);
105
+ }
106
+ const harnessName = (harness) => harness === "claude" ? "Claude Code" : harness === "cursor" ? "Cursor" : "Codex";
107
+ const harnessFileName = (harness) => harness === "claude" ? ".claude/settings.json" : harness === "cursor" ? ".cursor/hooks.json" : ".codex/hooks.json";
108
+ const selectedHarness = (args) => args.includes("--codex") ? "codex" : args.includes("--cursor") ? "cursor" : "claude";
109
+ const codexTrustStep = "Open Codex, run `/hooks`, review Scopebond, and choose Trust. Then start a new task.";
110
+ async function runPreToolUse(mapper, deny = denyClaude) {
94
111
  let input;
95
112
  try {
96
113
  input = JSON.parse(readStdin());
97
114
  }
98
115
  catch {
99
- denyClaude("hook received invalid JSON on stdin");
116
+ deny("hook received invalid JSON on stdin");
100
117
  }
101
118
  try {
102
119
  const cwd = input?.cwd ? String(input.cwd) : process.cwd();
103
- const runtime = createHookRuntime(runtimePaths(configDir()));
104
- const decision = await runtime.evaluate(fillPushBranch(mapClaudeToolUse(input), currentBranch(cwd)));
120
+ const runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
121
+ const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
105
122
  await runtime.flush();
106
123
  if (decision.decision === "deny")
107
- denyClaude(decision.reason);
108
- // allow and not_evaluated both DEFER to Claude Code's own permission flow: the
109
- // hook records the receipt but never returns permissionDecision:"allow", which
110
- // would suppress the user's normal review. Scopebond blocks; it does not approve.
124
+ deny(decision.reason);
125
+ // Stay silent on allow/not_evaluated so the coding agent's normal permission
126
+ // flow remains in charge. Scopebond blocks; it never silently approves.
111
127
  process.exit(0);
112
128
  }
113
129
  catch (error) {
114
- denyClaude(`Scopebond hook failed closed: ${error.message}. Repair: run \`scopebond-hook init\`.`);
130
+ deny(`Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`);
115
131
  }
116
132
  }
133
+ async function runClaude() {
134
+ await runPreToolUse(mapClaudeToolUse);
135
+ }
136
+ async function runCodex() {
137
+ await runPreToolUse(mapCodexToolUse, denyCodex);
138
+ }
117
139
  async function runCursor() {
118
140
  let input = {};
119
141
  try {
@@ -125,7 +147,7 @@ async function runCursor() {
125
147
  let message = "Scopebond hook failed closed";
126
148
  try {
127
149
  const cwd = input?.cwd ? String(input.cwd) : process.cwd();
128
- const runtime = createHookRuntime(runtimePaths(configDir()));
150
+ const runtime = createHookRuntime(runtimePaths(resolveConfigDir(cwd)));
129
151
  const decision = await runtime.evaluate(fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd)));
130
152
  await runtime.flush();
131
153
  // Only an out-of-policy action is denied outright; an allowed or unevaluated
@@ -135,13 +157,13 @@ async function runCursor() {
135
157
  }
136
158
  catch (error) {
137
159
  permission = "deny";
138
- message = `Scopebond hook failed closed: ${error.message}. Repair: run \`scopebond-hook init\`.`;
160
+ message = `Scopebond hook failed closed: ${error.message}. Repair: run \`${cliCommand("init")}\`.`;
139
161
  }
140
162
  process.stdout.write(JSON.stringify({ permission, agentMessage: message }) + "\n");
141
163
  process.exit(0);
142
164
  }
143
165
  function runInit(args) {
144
- const harness = args.includes("--cursor") ? "cursor" : "claude";
166
+ const harness = selectedHarness(args);
145
167
  const dir = configDir();
146
168
  const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
147
169
  console.log(`Scopebond hook enrolled in ${dir}`);
@@ -152,16 +174,20 @@ function runInit(args) {
152
174
  // hand-editing step; --no-install prints the snippet instead.
153
175
  if (!args.includes("--no-install")) {
154
176
  const file = installHarness(harness);
155
- console.log(`✓ ${harness === "cursor" ? "Cursor" : "Claude Code"} configured in ${file}`);
177
+ console.log(`✓ ${harnessName(harness)} configured in ${file}`);
178
+ if (harness === "codex")
179
+ console.log(`\nOne last step: ${codexTrustStep}`);
156
180
  }
157
181
  else {
158
- console.log(`Add this to your ${harness === "cursor" ? ".cursor/hooks.json" : ".claude/settings.json"}:`);
182
+ console.log(`Add this to your ${harnessFileName(harness)}:`);
159
183
  console.log(harnessSnippet(harness));
160
184
  }
161
185
  console.log("");
162
- console.log("Next: run one command in the agent, then `scopebond-hook log` to see the receipt");
163
- console.log("and `scopebond-hook verify` to check it offline. Try `scopebond-hook test \"rm -rf /\"`.");
164
- console.log("To send receipts to a workspace: scopebond-hook connect <workspace-url> <enrollment>");
186
+ // Print the runnable `npx` form: after `npx @scopebond/hook init` there is no
187
+ // `scopebond-hook` binary on PATH, so a bare `scopebond-hook log` would fail.
188
+ console.log(`Next: run one command in the agent, then \`${cliCommand("log")}\` to see the receipt`);
189
+ console.log(`and \`${cliCommand("verify")}\` to check it offline. Try \`${cliCommand('test "rm -rf /"')}\`.`);
190
+ console.log(`To send receipts to a workspace: ${cliCommand("connect <workspace-url> <enrollment>")}`);
165
191
  }
166
192
  function decisionOf(payload) {
167
193
  const rr = String(payload.realtime_result ?? "");
@@ -183,7 +209,7 @@ function describeIntent(payload) {
183
209
  return `${String(intent.action_type ?? "?")}${bits ? ` ${bits}` : ""}`;
184
210
  }
185
211
  async function runLog(args) {
186
- const dir = configDir();
212
+ const dir = resolveConfigDir(process.cwd());
187
213
  const dbPath = join(dir, "receipts.db");
188
214
  if (!existsSync(dbPath)) {
189
215
  console.log("no receipts yet — run a command in the agent first.");
@@ -202,10 +228,10 @@ async function runLog(args) {
202
228
  const p = r.payload;
203
229
  console.log(`${String(p.timestamp ?? "")} ${decisionOf(p).padEnd(13)} ${describeIntent(p)}`);
204
230
  }
205
- console.log(`\n${recent.length} of ${all.length} receipt(s). Verify them: scopebond-hook verify`);
231
+ console.log(`\n${recent.length} of ${all.length} receipt(s). Verify them: ${cliCommand("verify")}`);
206
232
  }
207
233
  async function runVerify() {
208
- const dir = configDir();
234
+ const dir = resolveConfigDir(process.cwd());
209
235
  const dbPath = join(dir, "receipts.db");
210
236
  const attesterPath = join(dir, "attester.key");
211
237
  if (!existsSync(dbPath) || !existsSync(attesterPath)) {
@@ -240,7 +266,7 @@ async function runTest(args) {
240
266
  console.error('usage: scopebond-hook test "<shell command>"');
241
267
  process.exit(1);
242
268
  }
243
- const dir = configDir();
269
+ const dir = resolveConfigDir(process.cwd());
244
270
  if (!existsSync(join(dir, "policy.json"))) {
245
271
  console.error("no policy yet — run `scopebond-hook init` first.");
246
272
  process.exit(1);
@@ -272,9 +298,9 @@ async function runConnect(args) {
272
298
  const positional = args.filter((a) => !a.startsWith("--"));
273
299
  const url = positional[0];
274
300
  const bundleArg = positional[1];
275
- const harness = args.includes("--cursor") ? "cursor" : "claude";
301
+ const harness = selectedHarness(args);
276
302
  if (!url) {
277
- console.error("usage: scopebond-hook connect <workspace-url> <enrollment> [--cursor] [--no-install]");
303
+ console.error("usage: scopebond-hook connect <workspace-url> <enrollment> [--claude|--cursor|--codex] [--no-install]");
278
304
  process.exit(1);
279
305
  }
280
306
  const dir = configDir();
@@ -298,10 +324,12 @@ async function runConnect(args) {
298
324
  // caller opts out. This removes the "paste this snippet" step.
299
325
  if (!args.includes("--no-install")) {
300
326
  const file = installHarness(harness);
301
- console.log(`✓ ${harness === "cursor" ? "Cursor" : "Claude Code"} configured in ${file}`);
327
+ console.log(`✓ ${harnessName(harness)} configured in ${file}`);
328
+ if (harness === "codex")
329
+ console.log(`\nOne last step: ${codexTrustStep}`);
302
330
  }
303
331
  else {
304
- console.log(`Add this to your ${harness === "cursor" ? ".cursor/hooks.json" : ".claude/settings.json"}:`);
332
+ console.log(`Add this to your ${harnessFileName(harness)}:`);
305
333
  console.log(harnessSnippet(harness));
306
334
  }
307
335
  console.log("");
@@ -313,7 +341,7 @@ async function runConnect(args) {
313
341
  }
314
342
  }
315
343
  async function runFlush() {
316
- const dir = configDir();
344
+ const dir = resolveConfigDir(process.cwd());
317
345
  if (!loadConnection(dir)) {
318
346
  console.error("not connected to a workspace; run `scopebond-hook connect` first");
319
347
  process.exit(1);
@@ -321,8 +349,122 @@ async function runFlush() {
321
349
  const runtime = createHookRuntime(runtimePaths(dir));
322
350
  await runtime.exporter?.flush();
323
351
  const status = runtime.exporter?.status();
352
+ runtime.exporter?.stop();
324
353
  console.log(`flushed; ${status?.pending ?? 0} receipt(s) still pending${status?.lastError ? ` (last error: ${status.lastError})` : ""}`);
325
- process.exit(status && status.pending > 0 ? 1 : 0);
354
+ // Let pending HTTP handles close normally (forced exit can abort on Windows).
355
+ process.exitCode = status && status.pending > 0 ? 1 : 0;
356
+ }
357
+ /** The absolute path to this CLI file, for registering the hook by absolute path. */
358
+ function cliPath() {
359
+ return fileURLToPath(import.meta.url);
360
+ }
361
+ /** `install` — the once-per-machine, user-level install (SB112). Scaffolds the
362
+ * user home and registers the hook by absolute path in the user-level agent config,
363
+ * so every project a developer opens is governed without a per-repo `init`. */
364
+ function runInstall(args) {
365
+ const dir = userHome();
366
+ const { agentKid, policyPath } = scaffold(dir, { force: args.includes("--force") });
367
+ console.log(`Scopebond installed for this user in ${dir}`);
368
+ console.log(` machine key ${agentKid}`);
369
+ console.log(` policy ${policyPath} (starter — edit the limits)`);
370
+ console.log("");
371
+ const harnesses = args.includes("--codex") ? ["codex"]
372
+ : args.includes("--cursor") ? ["cursor"]
373
+ : args.includes("--claude") ? ["claude"]
374
+ : ["claude", ...(cursorDetected() ? ["cursor"] : []), ...(codexDetected() ? ["codex"] : [])];
375
+ if (!args.includes("--no-install")) {
376
+ for (const h of harnesses) {
377
+ const file = writeHarnessConfig(userHarnessFile(h), h, absoluteHookCommand(cliPath(), h));
378
+ console.log(`✓ ${harnessName(h)} configured in ${file}`);
379
+ }
380
+ }
381
+ else {
382
+ console.log("Add this to your user-level agent config:");
383
+ console.log(harnessSnippet(harnesses[0]));
384
+ }
385
+ if (harnesses.includes("codex"))
386
+ console.log(`\nOne last step for Codex: ${codexTrustStep}`);
387
+ console.log("");
388
+ console.log("A project-local .scopebond still takes precedence when present.");
389
+ console.log(`Check it: ${cliCommand("doctor")} · see decisions: ${cliCommand("log")}`);
390
+ console.log(`To send receipts to a workspace: ${cliCommand("connect <workspace-url> <enrollment>")}`);
391
+ }
392
+ function runStatus() {
393
+ const home = userHome();
394
+ const installed = existsSync(join(home, "policy.json"));
395
+ const claude = isHarnessConfigured(userHarnessFile("claude"));
396
+ const cursor = isHarnessConfigured(userHarnessFile("cursor"));
397
+ const codex = isHarnessConfigured(userHarnessFile("codex"));
398
+ const connected = !!loadConnection(resolveConfigDir(process.cwd()));
399
+ const dbPath = join(resolveConfigDir(process.cwd()), "receipts.db");
400
+ console.log(`Scopebond hook ${hookVersion()}`);
401
+ console.log(` user home ${home} ${installed ? "(installed)" : "(not installed — run `scopebond install`)"}`);
402
+ console.log(` active config ${resolveConfigDir(process.cwd())}`);
403
+ console.log(` Claude Code ${claude ? "configured" : "not configured"}`);
404
+ console.log(` Cursor ${cursor ? "configured" : cursorDetected() ? "detected, not configured" : "not detected"}`);
405
+ console.log(` Codex ${codex ? "configured (approve once with /hooks)" : codexDetected() ? "detected, not configured" : "not detected"}`);
406
+ console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
407
+ console.log(` local receipts ${existsSync(dbPath) ? dbPath : "none yet"}`);
408
+ }
409
+ async function runDoctor() {
410
+ const problems = [];
411
+ const [major, minor] = process.versions.node.split(".").map(Number);
412
+ const nodeOk = major > 22 || (major === 22 && minor >= 13);
413
+ console.log(`Scopebond doctor`);
414
+ console.log(` node ${process.versions.node} ${nodeOk ? "ok" : "TOO OLD (need >=22.13)"}`);
415
+ if (!nodeOk)
416
+ problems.push("node >=22.13 is required (the Cloud outbox uses node:sqlite)");
417
+ const cli = cliPath();
418
+ console.log(` cli ${cli} ${existsSync(cli) ? "ok" : "MISSING"}`);
419
+ const active = resolveConfigDir(process.cwd());
420
+ const hasPolicy = existsSync(join(active, "policy.json"));
421
+ console.log(` active config ${active} ${hasPolicy ? "ok" : "no policy (run `scopebond install` or `init`)"}`);
422
+ if (!hasPolicy)
423
+ problems.push("no policy found in the active config dir");
424
+ const codex = isHarnessConfigured(userHarnessFile("codex"));
425
+ console.log(` Codex hook ${codex ? "configured" : codexDetected() ? "not configured — run `scopebond install --codex`" : "not detected"}`);
426
+ if (codex)
427
+ console.log(` Codex approval run /hooks in Codex and approve Scopebond once`);
428
+ const connection = loadConnection(active);
429
+ if (!connection) {
430
+ console.log(` cloud not connected (local only) — receipts stay on this machine`);
431
+ }
432
+ else {
433
+ let reachable = "unknown";
434
+ try {
435
+ const res = await fetch(new URL("/healthz", connection.url).toString(), { method: "GET" });
436
+ reachable = res.ok ? "reachable" : `unhealthy (${res.status})`;
437
+ }
438
+ catch (error) {
439
+ reachable = `unreachable (${error.message})`;
440
+ }
441
+ console.log(` cloud ${connection.url} — ${reachable}`);
442
+ }
443
+ console.log(problems.length ? `\n${problems.length} problem(s): ${problems.join("; ")}` : `\nAll good.`);
444
+ process.exitCode = problems.length ? 1 : 0;
445
+ }
446
+ function runUninstall(args) {
447
+ let removed = 0;
448
+ for (const h of ["claude", "cursor", "codex"]) {
449
+ if (removeHarnessConfig(userHarnessFile(h))) {
450
+ console.log(`✓ removed the Scopebond hook from ${userHarnessFile(h)}`);
451
+ removed++;
452
+ }
453
+ }
454
+ if (removed === 0)
455
+ console.log("no user-level harness config found.");
456
+ if (args.includes("--purge")) {
457
+ purgeHome();
458
+ console.log(`✓ purged ${userHome()} (keys, policy, receipts)`);
459
+ }
460
+ else
461
+ console.log(`Kept ${userHome()} (keys, policy, receipts). Use --purge to remove it too.`);
462
+ }
463
+ function runLogin() {
464
+ console.log("Device-code login is not available yet.");
465
+ console.log(`For now, connect with a one-time enrollment from your workspace:`);
466
+ console.log(` ${cliCommand("connect <workspace-url> <enrollment>")}`);
467
+ process.exit(0);
326
468
  }
327
469
  const [cmd, ...rest] = process.argv.slice(2);
328
470
  if (cmd === "claude") {
@@ -331,9 +473,15 @@ if (cmd === "claude") {
331
473
  else if (cmd === "cursor") {
332
474
  await runCursor();
333
475
  }
476
+ else if (cmd === "codex") {
477
+ await runCodex();
478
+ }
334
479
  else if (cmd === "init") {
335
480
  runInit(rest);
336
481
  }
482
+ else if (cmd === "install") {
483
+ runInstall(rest);
484
+ }
337
485
  else if (cmd === "connect") {
338
486
  await runConnect(rest);
339
487
  }
@@ -349,8 +497,20 @@ else if (cmd === "test") {
349
497
  else if (cmd === "flush") {
350
498
  await runFlush();
351
499
  }
500
+ else if (cmd === "status") {
501
+ runStatus();
502
+ }
503
+ else if (cmd === "doctor") {
504
+ await runDoctor();
505
+ }
506
+ else if (cmd === "uninstall") {
507
+ runUninstall(rest);
508
+ }
509
+ else if (cmd === "login") {
510
+ runLogin();
511
+ }
352
512
  else {
353
- console.error("usage: scopebond-hook <claude|cursor|init|connect|log|verify|test|flush> [--cursor] [--force] [--strict] [--no-install]");
513
+ console.error("usage: scopebond <claude|cursor|codex|init|install|connect|log|verify|test|flush|status|doctor|uninstall|login> [--cursor] [--claude] [--codex] [--force] [--strict] [--no-install] [--purge]");
354
514
  process.exit(1);
355
515
  }
356
516
  //# sourceMappingURL=cli.js.map