@scopebond/hook 0.7.0 → 0.8.1
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 +36 -7
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +260 -49
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +35 -4
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +74 -6
- package/dist/init.js.map +1 -1
- package/dist/install.d.ts +29 -4
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +109 -6
- package/dist/install.js.map +1 -1
- package/dist/minimize.d.ts +11 -3
- package/dist/minimize.d.ts.map +1 -1
- package/dist/minimize.js +35 -6
- package/dist/minimize.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -55,10 +55,27 @@ npx @scopebond/hook init --codex # Codex, then approve it once with /hooks
|
|
|
55
55
|
|
|
56
56
|
`init` scaffolds `.scopebond/` in the current project (a machine signing key, a
|
|
57
57
|
countersigning key, a starter policy — "protect main and production paths" — and a
|
|
58
|
-
`.gitignore` so none of it is committed) and
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
`.gitignore` so none of it is committed) and wires your agent to the hook. Then run one
|
|
59
|
+
safe command in the agent and see the receipt in `.scopebond/receipts.db`. `init`, `trust`
|
|
60
|
+
and `uninstall` are meant for a person at a terminal: in a script or CI, pass `--yes`.
|
|
61
|
+
`npx @scopebond/hook init --dry-run` shows what it would write, and changes nothing.
|
|
62
|
+
|
|
63
|
+
**Where the hook goes, and why.** `init` pins a copy of the hook on this machine so it
|
|
64
|
+
starts fast on every tool call. That command names paths that exist only here, so it
|
|
65
|
+
never goes into a file your team shares: for Claude Code it goes into
|
|
66
|
+
`.claude/settings.local.json`, which `init` keeps out of git for this clone; for Cursor
|
|
67
|
+
and Codex it goes into `.cursor/hooks.json` or `.codex/hooks.json` only while git does not
|
|
68
|
+
already track that file, and a tracked file gets the portable command instead. A hook
|
|
69
|
+
command that cannot start is treated by the agent as a non-blocking error — the agent
|
|
70
|
+
carries on with no check — so a machine-specific path in a committed file would leave
|
|
71
|
+
every teammate unprotected while the file says otherwise. `doctor` reports that case, and
|
|
72
|
+
running `init` again moves an entry an older version wrote into `.claude/settings.json`.
|
|
73
|
+
|
|
74
|
+
To give everyone who clones the project the hook, use `init --shared`: it writes the
|
|
75
|
+
portable command (`npx -y @scopebond/hook@<version> claude`) to `.claude/settings.json`,
|
|
76
|
+
`.cursor/hooks.json` or `.codex/hooks.json`. It starts on any machine, more slowly, and
|
|
77
|
+
until a teammate runs `init` themselves it blocks their agent's actions with a message
|
|
78
|
+
saying how to set it up.
|
|
62
79
|
|
|
63
80
|
Check what it did with `npx @scopebond/hook status` (which agents are configured, in
|
|
64
81
|
which scope) and `npx @scopebond/hook doctor` (whether each configured command can
|
|
@@ -116,9 +133,21 @@ before they land, make `@scopebond/github-action` a required check on pull reque
|
|
|
116
133
|
|
|
117
134
|
## Connect it to your workspace (optional)
|
|
118
135
|
|
|
119
|
-
To see the receipts in your hosted Scopebond workspace,
|
|
120
|
-
|
|
121
|
-
|
|
136
|
+
To see the receipts in your hosted Scopebond workspace, sign this computer in:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
npx @scopebond/hook login https://<your-workspace>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
It prints a short code and a link. Someone who manages the workspace opens the link,
|
|
143
|
+
checks that the code matches, and approves it for an environment and agent. The
|
|
144
|
+
command then finishes connecting on its own: nothing is copied or pasted, and the
|
|
145
|
+
code expires after 10 minutes if nobody approves it. Add `--cursor` or `--codex`
|
|
146
|
+
for those agents, or `--no-install` to leave the agent's settings alone.
|
|
147
|
+
|
|
148
|
+
If your workspace does not offer sign-in codes, create a connection from the
|
|
149
|
+
portal's **Connect** step (it gives you a one-use enrollment bundle), save it as
|
|
150
|
+
`scopebond-enrollment.json`, then:
|
|
122
151
|
|
|
123
152
|
```
|
|
124
153
|
npx @scopebond/hook connect https://<your-workspace> scopebond-enrollment.json
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAojBA,6EAA6E;AAC7E,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,GAAE,MAAmB,GAAG,MAAM,GAAG,IAAI,CAS7F"}
|
package/dist/cli.js
CHANGED
|
@@ -8,22 +8,38 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Config dir: $SCOPEBOND_HOOK_DIR, else ./.scopebond
|
|
10
10
|
// Fail-closed: any error denies the action with a repair message.
|
|
11
|
+
// `node:sqlite` (the receipt store) is still flagged experimental on Node 22, and Node
|
|
12
|
+
// prints a warning on stderr the first time it loads — on every `verify`, and into the
|
|
13
|
+
// agent's transcript on every hook call. It is a notice about Node, not about the user's
|
|
14
|
+
// setup, so it is dropped; every other warning still prints through Node's own handler.
|
|
15
|
+
// (The store is required lazily, after this module has run, so the filter is in place.)
|
|
16
|
+
{
|
|
17
|
+
const nodeWarningHandlers = process.listeners("warning");
|
|
18
|
+
process.removeAllListeners("warning");
|
|
19
|
+
process.on("warning", (warning) => {
|
|
20
|
+
if (warning.name === "ExperimentalWarning" && /sqlite/i.test(warning.message))
|
|
21
|
+
return;
|
|
22
|
+
for (const handler of nodeWarningHandlers)
|
|
23
|
+
handler.call(process, warning);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
11
26
|
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
27
|
+
import { join, resolve } from "node:path";
|
|
28
|
+
import { hostname, tmpdir } from "node:os";
|
|
14
29
|
import { execFileSync } from "node:child_process";
|
|
15
30
|
import { verifyReceipt } from "@scopebond/gateway";
|
|
16
31
|
import { openReceiptStore, loadOrCreateAttester } from "@scopebond/gateway/node";
|
|
17
32
|
import { mapClaudeToolUse, mapCodexToolUse, mapCursorEvent, fillPushBranch } from "./map.js";
|
|
18
33
|
import { createHookRuntime } from "./runtime.js";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
34
|
+
import { useDigestKey, loadOrCreateDigestKey } from "./minimize.js";
|
|
35
|
+
import { scaffold, harnessSnippet, placeHook } from "./init.js";
|
|
36
|
+
import { userHome, userHarnessFile, resolveConfigDir, writeHarnessConfig, removeHarnessConfig, cursorDetected, codexDetected, absoluteHookCommand, isHarnessConfigured, purgeHome, harnessScopes, harnessScopeLabel, configuredHookCommands, hookCommandResolves, projectHarnessFile, localHarnessFile, gitShareState, isMachineSpecificCommand, trustProjectPolicy, untrustedProjectPolicy, } from "./install.js";
|
|
21
37
|
import { connectCloud, loadConnection } from "./cloud.js";
|
|
22
38
|
import { compile, defaultRules, describeRules, loadRules, saveRules, rulesPath, pathRuleFor } from "./rules.js";
|
|
23
39
|
import { createSigner } from "@scopebond/sdk";
|
|
24
40
|
import { describeAction } from "./explain.js";
|
|
25
|
-
import { ensureDurableRuntime } from "./runtime-install.js";
|
|
26
|
-
import { cliCommand, hookVersion } from "./version.js";
|
|
41
|
+
import { ensureDurableRuntime, pinnedCliPath, isEphemeralPath } from "./runtime-install.js";
|
|
42
|
+
import { cliCommand, hookCommand, hookVersion } from "./version.js";
|
|
27
43
|
import { fileURLToPath } from "node:url";
|
|
28
44
|
/** The current git branch in `cwd` (best-effort). A bare `git push` pushes it, so
|
|
29
45
|
* the runtime fills it in before evaluating; on failure the ref stays absent and
|
|
@@ -129,7 +145,9 @@ async function runPreToolUse(mapper, deny = denyClaude) {
|
|
|
129
145
|
let runtime;
|
|
130
146
|
try {
|
|
131
147
|
const cwd = input?.cwd ? String(input.cwd) : process.cwd();
|
|
132
|
-
|
|
148
|
+
const dir = resolveConfigDir(cwd);
|
|
149
|
+
runtime = createHookRuntime(runtimePaths(dir));
|
|
150
|
+
useDigestKey(loadOrCreateDigestKey(dir));
|
|
133
151
|
const decision = await runtime.evaluate(fillPushBranch(mapper(input), currentBranch(cwd)));
|
|
134
152
|
await runtime.flush();
|
|
135
153
|
// Close before deciding: the receipt is already committed, and leaving the handle
|
|
@@ -183,7 +201,9 @@ async function runCursor() {
|
|
|
183
201
|
let runtime;
|
|
184
202
|
try {
|
|
185
203
|
const cwd = input?.cwd ? String(input.cwd) : process.cwd();
|
|
186
|
-
|
|
204
|
+
const dir = resolveConfigDir(cwd);
|
|
205
|
+
runtime = createHookRuntime(runtimePaths(dir));
|
|
206
|
+
useDigestKey(loadOrCreateDigestKey(dir));
|
|
187
207
|
const mapped = fillPushBranch(mapCursorEvent(event, input), currentBranch(cwd));
|
|
188
208
|
const decision = await runtime.evaluate(mapped);
|
|
189
209
|
await runtime.flush();
|
|
@@ -238,9 +258,34 @@ function requireInteractive(command, args) {
|
|
|
238
258
|
process.exit(1);
|
|
239
259
|
}
|
|
240
260
|
function runInit(args) {
|
|
241
|
-
requireInteractive("init", args);
|
|
242
261
|
const harness = selectedHarness(args);
|
|
243
262
|
const dir = configDir();
|
|
263
|
+
// A dry run changes nothing, so it needs no terminal and no --yes.
|
|
264
|
+
if (args.includes("--dry-run")) {
|
|
265
|
+
const shared = projectHarnessFile(harness, process.cwd());
|
|
266
|
+
const local = localHarnessFile(harness, process.cwd());
|
|
267
|
+
const personal = args.includes("--shared") || args.includes("--npx") ? null
|
|
268
|
+
: local ?? (gitShareState(shared) === "tracked" ? null : shared);
|
|
269
|
+
console.log(`Dry run — nothing is written.\n`);
|
|
270
|
+
console.log(`Would scaffold ${dir} (machine key, countersigning key, starter policy, .gitignore)`);
|
|
271
|
+
if (args.includes("--no-install")) {
|
|
272
|
+
console.log(`Would print the ${harnessFileName(harness)} snippet instead of writing it`);
|
|
273
|
+
}
|
|
274
|
+
else if (personal) {
|
|
275
|
+
console.log(`Would configure ${personal} (this machine only; kept out of git)`);
|
|
276
|
+
console.log(` adding hook "<node>" "${pinnedCliPath(hookVersion())}" ${harness}`);
|
|
277
|
+
console.log(` (or the path this copy runs from, when it is already installed durably)`);
|
|
278
|
+
if (local)
|
|
279
|
+
console.log(` and remove any machine-specific Scopebond entry from ${shared}`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
console.log(`Would configure ${shared} (shared — safe to commit)`);
|
|
283
|
+
console.log(` adding hook ${hookCommand(harness)}`);
|
|
284
|
+
}
|
|
285
|
+
console.log(`\nNothing else in those files is changed. Run without --dry-run to apply.`);
|
|
286
|
+
process.exit(0);
|
|
287
|
+
}
|
|
288
|
+
requireInteractive("init", args);
|
|
244
289
|
const { agentKid, policyPath, rulesPath: rulesFile } = scaffold(dir, { force: args.includes("--force") });
|
|
245
290
|
console.log(`Scopebond hook enrolled in ${dir}`);
|
|
246
291
|
console.log(` machine key ${agentKid}`);
|
|
@@ -257,34 +302,49 @@ function runInit(args) {
|
|
|
257
302
|
// directly costs ~110 ms. Pin a durable copy and use that, and fall back to `npx`
|
|
258
303
|
// (slow, but it always starts) when no durable copy can be made. `--npx` forces the
|
|
259
304
|
// portable form for anyone who wants it.
|
|
260
|
-
const
|
|
305
|
+
const shared = args.includes("--shared");
|
|
306
|
+
const pin = args.includes("--npx") || shared ? { cli: null, how: "unavailable" } : ensureDurableRuntime(cliPath(), hookVersion());
|
|
261
307
|
const command = pin.cli ? absoluteHookCommand(pin.cli, harness) : undefined;
|
|
262
|
-
// No per-action millisecond claim here: it varies by machine, and this project only
|
|
263
|
-
// states numbers it has measured. The measured comparison lives in the changelog.
|
|
264
|
-
console.log(` hook runtime ${pin.cli
|
|
265
|
-
? `${pin.cli}\n pinned — no npx resolution per action`
|
|
266
|
-
: `npx @scopebond/hook@${hookVersion()} — portable, but re-resolves on every action`}`);
|
|
267
|
-
console.log("");
|
|
268
308
|
// Configure the agent automatically by default (idempotent), so there is no
|
|
269
309
|
// hand-editing step; --no-install prints the snippet instead.
|
|
270
310
|
if (!args.includes("--no-install")) {
|
|
271
|
-
let
|
|
311
|
+
let placed;
|
|
272
312
|
try {
|
|
273
|
-
|
|
313
|
+
placed = placeHook(harness, process.cwd(), command, { shared });
|
|
274
314
|
}
|
|
275
315
|
catch (error) {
|
|
276
316
|
console.error(error.message);
|
|
277
317
|
process.exit(1);
|
|
278
318
|
}
|
|
279
|
-
|
|
319
|
+
// No per-action millisecond claim here: it varies by machine, and this project only
|
|
320
|
+
// states numbers it has measured. The measured comparison lives in the changelog.
|
|
321
|
+
console.log(` hook runtime ${placed.scope === "personal"
|
|
322
|
+
? `${pin.cli}\n pinned — no npx resolution per action`
|
|
323
|
+
: `npx @scopebond/hook@${hookVersion()} — portable, but re-resolves on every action`}`);
|
|
324
|
+
console.log("");
|
|
325
|
+
console.log(`✓ ${harnessName(harness)} configured in ${placed.file}`);
|
|
326
|
+
console.log(placed.scope === "personal"
|
|
327
|
+
? ` this machine only — kept out of git, so no teammate inherits a path that does not exist for them`
|
|
328
|
+
: ` shared — the portable command starts on any machine that clones this project`);
|
|
329
|
+
if (placed.repaired > 0)
|
|
330
|
+
console.log(` moved a machine-specific hook out of ${projectHarnessFile(harness, process.cwd())}; commit that change`);
|
|
331
|
+
if (placed.note)
|
|
332
|
+
console.log(` note: ${placed.note}`);
|
|
280
333
|
if (harness === "codex")
|
|
281
334
|
console.log(`\nOne last step: ${codexTrustStep}`);
|
|
282
335
|
if (harness === "cursor")
|
|
283
336
|
console.log(`\n${cursorCoverageNote}`);
|
|
284
337
|
}
|
|
285
338
|
else {
|
|
339
|
+
// The snippet is for a file the user will likely commit, so it carries the portable
|
|
340
|
+
// command; the pinned one is offered separately, for a file only this machine uses.
|
|
286
341
|
console.log(`Add this to your ${harnessFileName(harness)}:`);
|
|
287
|
-
console.log(harnessSnippet(harness
|
|
342
|
+
console.log(harnessSnippet(harness));
|
|
343
|
+
const local = localHarnessFile(harness, process.cwd());
|
|
344
|
+
if (command && local) {
|
|
345
|
+
console.log(`\nFaster, for this machine only — use this command in ${local} instead (keep that file out of git):`);
|
|
346
|
+
console.log(` ${command}`);
|
|
347
|
+
}
|
|
288
348
|
if (harness === "cursor")
|
|
289
349
|
console.log(`\n${cursorCoverageNote}`);
|
|
290
350
|
}
|
|
@@ -626,7 +686,7 @@ async function runTest(args) {
|
|
|
626
686
|
}
|
|
627
687
|
const dir = resolveConfigDir(process.cwd());
|
|
628
688
|
if (!existsSync(join(dir, "policy.json"))) {
|
|
629
|
-
console.error(
|
|
689
|
+
console.error(`no policy yet — run \`${cliCommand("init")}\` first.`);
|
|
630
690
|
process.exit(1);
|
|
631
691
|
}
|
|
632
692
|
// Evaluate against the real policy and keys, but a throwaway store, so `test`
|
|
@@ -665,7 +725,8 @@ async function runConnect(args) {
|
|
|
665
725
|
const bundleArg = positional[1];
|
|
666
726
|
const harness = selectedHarness(args);
|
|
667
727
|
if (!url) {
|
|
668
|
-
console.error(
|
|
728
|
+
console.error(`usage: ${cliCommand("connect <workspace-url> <enrollment> [--claude|--cursor|--codex] [--no-install]")}`);
|
|
729
|
+
console.error(enrollmentHelp);
|
|
669
730
|
process.exit(1);
|
|
670
731
|
}
|
|
671
732
|
const dir = configDir();
|
|
@@ -679,16 +740,36 @@ async function runConnect(args) {
|
|
|
679
740
|
bundle = readBundleArg(bundleArg, readStdin);
|
|
680
741
|
}
|
|
681
742
|
catch {
|
|
682
|
-
console.error(
|
|
743
|
+
console.error(`could not read the enrollment (expected a file, inline blob, or JSON on stdin)\n${enrollmentHelp}`);
|
|
683
744
|
process.exit(1);
|
|
684
745
|
}
|
|
746
|
+
await finishConnect(dir, url, bundle, harness, args);
|
|
747
|
+
}
|
|
748
|
+
/** Enroll with a bundle and wire the agent: shared by `connect` (a pasted enrollment)
|
|
749
|
+
* and `login` (one received through device-code approval). */
|
|
750
|
+
async function finishConnect(dir, url, bundle, harness, args) {
|
|
685
751
|
try {
|
|
686
752
|
const c = await connectCloud(dir, url, bundle);
|
|
687
753
|
console.log(`✓ Connected to ${c.url}`);
|
|
754
|
+
// With a user-level install present, the hook ignores a project policy until it is
|
|
755
|
+
// trusted, and would fall back to the user home, which holds no cloud.json: the agent
|
|
756
|
+
// stays governed, but nothing reaches the workspace. Connecting this project is the
|
|
757
|
+
// user's decision to use it, exactly as running `init` here is, so pin it the same way.
|
|
758
|
+
if (!process.env.SCOPEBOND_HOOK_DIR && resolve(dir) !== resolve(userHome()) && existsSync(join(userHome(), "policy.json"))) {
|
|
759
|
+
trustProjectPolicy(dir);
|
|
760
|
+
console.log(`✓ This project's rules are trusted (they override ${userHome()} here)`);
|
|
761
|
+
}
|
|
688
762
|
// Configure the agent automatically (merges into the existing config), unless the
|
|
689
|
-
// caller opts out. This removes the "paste this snippet" step.
|
|
690
|
-
|
|
691
|
-
|
|
763
|
+
// caller opts out. This removes the "paste this snippet" step. A hook that is already
|
|
764
|
+
// configured — pinned by `init`, or user-level by `install` — is left as it is:
|
|
765
|
+
// connecting changes where receipts go, not how the hook starts.
|
|
766
|
+
const scopes = harnessScopes(harness, process.cwd());
|
|
767
|
+
const existing = scopes.local ?? scopes.project ?? scopes.user;
|
|
768
|
+
if (existing && !args.includes("--no-install")) {
|
|
769
|
+
console.log(`✓ ${harnessName(harness)} already configured in ${existing}`);
|
|
770
|
+
}
|
|
771
|
+
else if (!args.includes("--no-install")) {
|
|
772
|
+
const { file } = placeHook(harness, process.cwd(), undefined);
|
|
692
773
|
console.log(`✓ ${harnessName(harness)} configured in ${file}`);
|
|
693
774
|
if (harness === "codex")
|
|
694
775
|
console.log(`\nOne last step: ${codexTrustStep}`);
|
|
@@ -701,14 +782,26 @@ async function runConnect(args) {
|
|
|
701
782
|
console.log("Run your agent — the first action appears in your workspace within seconds.");
|
|
702
783
|
}
|
|
703
784
|
catch (error) {
|
|
704
|
-
|
|
785
|
+
const message = error.message;
|
|
786
|
+
console.error(`connect failed: ${message}`);
|
|
787
|
+
if (/enrollment|expired|401|403/i.test(message))
|
|
788
|
+
console.error(enrollmentHelp);
|
|
705
789
|
process.exit(1);
|
|
706
790
|
}
|
|
707
791
|
}
|
|
792
|
+
/** Where an enrollment comes from, for every connect error that means "this one will
|
|
793
|
+
* not work": the bare "invalid enrollment token" told the reader nothing about what to
|
|
794
|
+
* do next. */
|
|
795
|
+
const enrollmentHelp = [
|
|
796
|
+
"An enrollment comes from your Scopebond workspace: open it, choose to connect an agent,",
|
|
797
|
+
"and copy the command it shows — it includes the workspace URL and a fresh enrollment.",
|
|
798
|
+
"Each enrollment is single-use and expires soon after it is created; if this one was",
|
|
799
|
+
"used or has expired, create a new one there.",
|
|
800
|
+
].join("\n");
|
|
708
801
|
async function runFlush() {
|
|
709
802
|
const dir = resolveConfigDir(process.cwd());
|
|
710
803
|
if (!loadConnection(dir)) {
|
|
711
|
-
console.error(
|
|
804
|
+
console.error(`not connected to a workspace; run \`${cliCommand("connect <workspace-url> <enrollment>")}\` first`);
|
|
712
805
|
process.exit(1);
|
|
713
806
|
}
|
|
714
807
|
const runtime = createHookRuntime(runtimePaths(dir));
|
|
@@ -728,6 +821,14 @@ function cliPath() {
|
|
|
728
821
|
* so every project a developer opens is governed without a per-repo `init`. */
|
|
729
822
|
function runInstall(args) {
|
|
730
823
|
const dir = userHome();
|
|
824
|
+
// Run through `npx`, this CLI lives in npm's throwaway cache; registering that path
|
|
825
|
+
// would leave a hook that stops starting whenever npm clears it — and a hook that
|
|
826
|
+
// cannot start lets every action through. Pin the durable copy, as `init` does, and
|
|
827
|
+
// fall back to the portable `npx` command when none can be made.
|
|
828
|
+
const commandFor = (h) => {
|
|
829
|
+
const pin = ensureDurableRuntime(cliPath(), hookVersion());
|
|
830
|
+
return pin.cli ? absoluteHookCommand(pin.cli, h) : hookCommand(h);
|
|
831
|
+
};
|
|
731
832
|
const harnessesFor = () => args.includes("--codex") ? ["codex"]
|
|
732
833
|
: args.includes("--cursor") ? ["cursor"]
|
|
733
834
|
: args.includes("--claude") ? ["claude"]
|
|
@@ -744,7 +845,8 @@ function runInstall(args) {
|
|
|
744
845
|
console.log(`Would ${exists ? "modify" : "create"} ${file}`);
|
|
745
846
|
if (exists)
|
|
746
847
|
console.log(` backing it up to ${file}.scopebond-backup`);
|
|
747
|
-
|
|
848
|
+
// Previewed without copying anything: the path the real run would pin.
|
|
849
|
+
console.log(` adding hook ${absoluteHookCommand(isEphemeralPath(cliPath()) ? pinnedCliPath(hookVersion()) : cliPath(), h)}`);
|
|
748
850
|
if (exists && isHarnessConfigured(file))
|
|
749
851
|
console.log(` (a Scopebond hook is already there; it would be replaced, not duplicated)`);
|
|
750
852
|
}
|
|
@@ -762,7 +864,7 @@ function runInstall(args) {
|
|
|
762
864
|
try {
|
|
763
865
|
const target = userHarnessFile(h);
|
|
764
866
|
const backup = existsSync(target) ? `${target}.scopebond-backup` : null;
|
|
765
|
-
const file = writeHarnessConfig(target, h,
|
|
867
|
+
const file = writeHarnessConfig(target, h, commandFor(h));
|
|
766
868
|
console.log(`✓ ${harnessName(h)} configured in ${file}`);
|
|
767
869
|
if (backup && existsSync(backup))
|
|
768
870
|
console.log(` original kept at ${backup}`);
|
|
@@ -822,7 +924,7 @@ function runStatus() {
|
|
|
822
924
|
const connected = !!loadConnection(resolveConfigDir(process.cwd()));
|
|
823
925
|
const dbPath = join(resolveConfigDir(process.cwd()), "receipts.db");
|
|
824
926
|
console.log(`Scopebond hook ${hookVersion()}`);
|
|
825
|
-
console.log(` user home ${home} ${installed ? "(installed)" :
|
|
927
|
+
console.log(` user home ${home} ${installed ? "(installed)" : `(not installed — run \`${cliCommand("install")}\`)`}`);
|
|
826
928
|
console.log(` active config ${resolveConfigDir(process.cwd())}`);
|
|
827
929
|
const ignored = untrustedProjectPolicy(process.cwd());
|
|
828
930
|
if (ignored)
|
|
@@ -833,15 +935,14 @@ function runStatus() {
|
|
|
833
935
|
console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
|
|
834
936
|
console.log(` local receipts ${existsSync(dbPath) ? `${dbPath} (${describeStore(dbPath)})` : "none yet"}`);
|
|
835
937
|
for (const [name, scopes] of [["Claude Code", claude], ["Cursor", cursor], ["Codex", codex]]) {
|
|
836
|
-
for (const file of [scopes.project, scopes.user])
|
|
938
|
+
for (const file of [scopes.project, scopes.local, scopes.user])
|
|
837
939
|
if (file)
|
|
838
940
|
console.log(` ${name}: ${file}`);
|
|
839
941
|
}
|
|
840
942
|
}
|
|
841
943
|
async function runDoctor() {
|
|
842
944
|
const problems = [];
|
|
843
|
-
const
|
|
844
|
-
const nodeOk = major > 22 || (major === 22 && minor >= 13);
|
|
945
|
+
const nodeOk = nodeSupported();
|
|
845
946
|
console.log(`Scopebond doctor`);
|
|
846
947
|
console.log(` node ${process.versions.node} ${nodeOk ? "ok" : "TOO OLD (need >=22.13)"}`);
|
|
847
948
|
if (!nodeOk)
|
|
@@ -850,7 +951,7 @@ async function runDoctor() {
|
|
|
850
951
|
console.log(` cli ${cli} ${existsSync(cli) ? "ok" : "MISSING"}`);
|
|
851
952
|
const active = resolveConfigDir(process.cwd());
|
|
852
953
|
const hasPolicy = existsSync(join(active, "policy.json"));
|
|
853
|
-
console.log(` active config ${active} ${hasPolicy ? "ok" :
|
|
954
|
+
console.log(` active config ${active} ${hasPolicy ? "ok" : `no policy (run \`${cliCommand("init")}\` here, or \`${cliCommand("install")}\` once for your user)`}`);
|
|
854
955
|
if (!hasPolicy)
|
|
855
956
|
problems.push("no policy found in the active config dir");
|
|
856
957
|
const ignored = untrustedProjectPolicy(process.cwd());
|
|
@@ -871,16 +972,27 @@ async function runDoctor() {
|
|
|
871
972
|
}
|
|
872
973
|
anyHarness = true;
|
|
873
974
|
console.log(` ${name.padEnd(15)} ${label}`);
|
|
874
|
-
for (const file of [scopes.project, scopes.user]) {
|
|
975
|
+
for (const file of [scopes.project, scopes.local, scopes.user]) {
|
|
875
976
|
if (!file)
|
|
876
977
|
continue;
|
|
978
|
+
// A project file git shares must not name a path on this machine: it starts here,
|
|
979
|
+
// so the resolve check below passes, but on every teammate's machine it cannot
|
|
980
|
+
// start — and a hook that cannot start is a non-blocking error, so their agent runs
|
|
981
|
+
// unchecked. Only the doctor on the machine that wrote it can see this coming.
|
|
982
|
+
const share = file === scopes.project ? gitShareState(file) : "none";
|
|
983
|
+
const shared = share === "tracked" || share === "untracked";
|
|
877
984
|
for (const command of configuredHookCommands(file)) {
|
|
878
985
|
const ok = hookCommandResolves(command);
|
|
879
|
-
|
|
986
|
+
const leaks = ok && shared && isMachineSpecificCommand(command);
|
|
987
|
+
console.log(` ${ok && !leaks ? "ok " : "BAD "} ${file}`);
|
|
880
988
|
if (!ok) {
|
|
881
989
|
console.log(` command cannot start: ${command}`);
|
|
882
990
|
problems.push(`${name} hook command no longer resolves in ${file} — run \`${cliCommand("init")}\` to repair it`);
|
|
883
991
|
}
|
|
992
|
+
else if (leaks) {
|
|
993
|
+
console.log(` machine-specific command in a file git shares: ${command}`);
|
|
994
|
+
problems.push(`${name} hook in ${file} names a path on this machine and git shares that file — anyone who clones it gets a hook that cannot start, and their agent runs unchecked. Run \`${cliCommand(`init${harness === "claude" ? "" : ` --${harness}`}`)}\` to move it, then commit the change`);
|
|
995
|
+
}
|
|
884
996
|
}
|
|
885
997
|
}
|
|
886
998
|
if (harness === "codex")
|
|
@@ -913,8 +1025,8 @@ function runUninstall(args) {
|
|
|
913
1025
|
// the install the site actually tells people to run — `uninstall` reported "no
|
|
914
1026
|
// user-level harness config found" and left the project hook in place.
|
|
915
1027
|
for (const h of ["claude", "cursor", "codex"]) {
|
|
916
|
-
for (const file of [projectHarnessFile(h, process.cwd()), userHarnessFile(h)]) {
|
|
917
|
-
if (removeHarnessConfig(file)) {
|
|
1028
|
+
for (const file of [projectHarnessFile(h, process.cwd()), localHarnessFile(h, process.cwd()), userHarnessFile(h)]) {
|
|
1029
|
+
if (file && removeHarnessConfig(file)) {
|
|
918
1030
|
console.log(`✓ removed the Scopebond hook from ${file}`);
|
|
919
1031
|
removed++;
|
|
920
1032
|
}
|
|
@@ -947,23 +1059,103 @@ function runTrust(args) {
|
|
|
947
1059
|
console.log(`✓ trusted ${join(dir, "policy.json")} (sha256 ${digest.slice(0, 12)}…)`);
|
|
948
1060
|
console.log("It governs agents in this project until it changes; after any edit, review it and run trust again.");
|
|
949
1061
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1062
|
+
/** `login <workspace-url>` — connect this computer without pasting anything. It asks
|
|
1063
|
+
* the workspace for a short code, shows it with the page to open, and waits while a
|
|
1064
|
+
* person who can manage the workspace approves it there for an environment and agent.
|
|
1065
|
+
* The approval hands back a single-use enrollment, which completes exactly as
|
|
1066
|
+
* `connect` does. Nothing secret is printed: the device code stays in memory. */
|
|
1067
|
+
async function runLogin(args) {
|
|
1068
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
1069
|
+
const harness = selectedHarness(args);
|
|
1070
|
+
let origin;
|
|
1071
|
+
try {
|
|
1072
|
+
const parsed = new URL(positional[0] ?? "");
|
|
1073
|
+
const local = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
|
|
1074
|
+
if (parsed.protocol !== "https:" && !(local && parsed.protocol === "http:"))
|
|
1075
|
+
throw new Error("https required");
|
|
1076
|
+
origin = parsed.origin;
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
console.error(`usage: ${cliCommand("login <workspace-url> [--claude|--cursor|--codex] [--no-install]")}`);
|
|
1080
|
+
console.error("The workspace URL is the address of your Scopebond workspace, for example https://cloud.scopebond.com.");
|
|
1081
|
+
process.exit(1);
|
|
1082
|
+
}
|
|
1083
|
+
const post = async (path, body) => {
|
|
1084
|
+
const response = await fetch(new URL(path, origin), {
|
|
1085
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
|
1086
|
+
redirect: "error", signal: AbortSignal.timeout(15_000),
|
|
1087
|
+
});
|
|
1088
|
+
return { status: response.status, json: await response.json().catch(() => ({})) };
|
|
1089
|
+
};
|
|
1090
|
+
let start;
|
|
1091
|
+
try {
|
|
1092
|
+
start = await post("/v1/device/code", { client_name: hostname(), harness });
|
|
1093
|
+
}
|
|
1094
|
+
catch (error) {
|
|
1095
|
+
console.error(`could not reach ${origin}: ${error.message}`);
|
|
1096
|
+
process.exit(1);
|
|
1097
|
+
}
|
|
1098
|
+
const deviceCode = typeof start.json.device_code === "string" ? start.json.device_code : "";
|
|
1099
|
+
if (start.status !== 200 || !deviceCode) {
|
|
1100
|
+
console.error(`${origin} did not start a login (HTTP ${start.status}). Check the workspace URL, or use ${cliCommand("connect <workspace-url> <enrollment>")}.`);
|
|
1101
|
+
process.exit(1);
|
|
1102
|
+
}
|
|
1103
|
+
const userCode = String(start.json.user_code ?? "");
|
|
1104
|
+
const verify = String(start.json.verification_uri_complete ?? start.json.verification_uri ?? origin);
|
|
1105
|
+
let intervalMs = Math.max(1, Number(start.json.interval ?? 5)) * 1000;
|
|
1106
|
+
const deadline = Date.now() + Math.max(60, Number(start.json.expires_in ?? 600)) * 1000;
|
|
1107
|
+
console.log(`To connect this computer, open:\n\n ${verify}\n\nand check that it shows the code ${userCode}\n`);
|
|
1108
|
+
console.log("Waiting for approval (the code expires in 10 minutes; Ctrl+C to stop)…");
|
|
1109
|
+
const dir = configDir();
|
|
1110
|
+
scaffold(dir, {});
|
|
1111
|
+
while (Date.now() < deadline) {
|
|
1112
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1113
|
+
let polled;
|
|
1114
|
+
try {
|
|
1115
|
+
polled = await post("/v1/device/token", { device_code: deviceCode });
|
|
1116
|
+
}
|
|
1117
|
+
catch {
|
|
1118
|
+
continue;
|
|
1119
|
+
} // a transient network error: keep waiting until the deadline
|
|
1120
|
+
if (polled.status === 200 && polled.json.enrollment && typeof polled.json.enrollment === "object") {
|
|
1121
|
+
console.log("✓ Approved");
|
|
1122
|
+
await finishConnect(dir, origin, polled.json.enrollment, harness, args);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const error = polled.json.error;
|
|
1126
|
+
if (error === "authorization_pending")
|
|
1127
|
+
continue;
|
|
1128
|
+
if (error === "slow_down") {
|
|
1129
|
+
intervalMs += 5_000;
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
if (error === "access_denied") {
|
|
1133
|
+
console.error("The request was denied in the workspace. Nothing was connected.");
|
|
1134
|
+
process.exit(1);
|
|
1135
|
+
}
|
|
1136
|
+
if (error === "expired_token")
|
|
1137
|
+
break;
|
|
1138
|
+
console.error(`login failed (${String(error ?? `HTTP ${polled.status}`)}). Run the command again for a new code.`);
|
|
1139
|
+
process.exit(1);
|
|
1140
|
+
}
|
|
1141
|
+
console.error("The code expired before it was approved. Run the command again for a new one.");
|
|
1142
|
+
process.exit(1);
|
|
955
1143
|
}
|
|
956
1144
|
/** What each command does, its arguments, and one example. The whole help used to be a
|
|
957
1145
|
* single usage line listing 15 command names, which told a reader nothing about what any
|
|
958
1146
|
* of them did or what arguments they take. */
|
|
959
1147
|
const COMMANDS = [
|
|
960
|
-
{ name: "init", args: "[--cursor|--codex] [--no-install] [--npx] [--force] [--yes]",
|
|
1148
|
+
{ name: "init", args: "[--cursor|--codex] [--shared] [--dry-run] [--no-install] [--npx] [--force] [--yes]",
|
|
961
1149
|
summary: "set this project up: keys, a starter policy, and your agent wired to the hook",
|
|
962
1150
|
detail: [
|
|
963
1151
|
"Writes .scopebond/ (machine key, countersigning key, starter policy, .gitignore) and",
|
|
964
|
-
"
|
|
965
|
-
"
|
|
966
|
-
"
|
|
1152
|
+
"wires your agent to the hook. It pins a durable copy of this package so the hook starts",
|
|
1153
|
+
"fast, and because that command names paths on this machine it goes where git will not",
|
|
1154
|
+
"share it: .claude/settings.local.json (kept out of git for this clone), or for Cursor",
|
|
1155
|
+
"and Codex their project file only while git does not track it. --shared writes the",
|
|
1156
|
+
"portable npx command to .claude/settings.json, .cursor/hooks.json or .codex/hooks.json",
|
|
1157
|
+
"instead, so everyone who clones the project gets the hook. --dry-run shows what it",
|
|
1158
|
+
"would write. --no-install prints the config snippet rather than writing it.",
|
|
967
1159
|
"Needs a terminal, or --yes in a script, because it changes what governs your agent.",
|
|
968
1160
|
] },
|
|
969
1161
|
{ name: "install", args: "[--claude] [--cursor] [--codex] [--dry-run] [--force] [--yes]",
|
|
@@ -1002,6 +1194,12 @@ const COMMANDS = [
|
|
|
1002
1194
|
"to a JSONL file beside the database, then removes them. Refuses once the log has been",
|
|
1003
1195
|
"anchored, because a receipt's position is its anchor leaf index.",
|
|
1004
1196
|
] },
|
|
1197
|
+
{ name: "login", args: "<workspace-url> [--claude|--cursor|--codex] [--no-install]",
|
|
1198
|
+
summary: "connect this computer to a Scopebond Cloud workspace by approving a short code there",
|
|
1199
|
+
detail: [
|
|
1200
|
+
"Prints a code and a link; someone who manages the workspace opens it, checks the code",
|
|
1201
|
+
"and approves it for an environment and agent. Nothing is copied or pasted.",
|
|
1202
|
+
] },
|
|
1005
1203
|
{ name: "connect", args: "<workspace-url> <enrollment> [--claude|--cursor|--codex]",
|
|
1006
1204
|
summary: "send receipts to a Scopebond Cloud workspace as well as keeping them locally" },
|
|
1007
1205
|
{ name: "flush", summary: "deliver any receipts still queued for the workspace now" },
|
|
@@ -1051,7 +1249,20 @@ function printHelp(topic, toStderr = false) {
|
|
|
1051
1249
|
out(`Receipts and keys stay in .scopebond/ in this project. Nothing leaves your machine`);
|
|
1052
1250
|
out(`unless you run \`connect\`. Docs: https://github.com/avouro-com/scopebond`);
|
|
1053
1251
|
}
|
|
1252
|
+
/** Whether this Node can run the receipt store (`node:sqlite`, 22.13+). */
|
|
1253
|
+
function nodeSupported(version = process.versions.node) {
|
|
1254
|
+
const [major, minor] = version.split(".").map(Number);
|
|
1255
|
+
return major > 22 || (major === 22 && minor >= 13);
|
|
1256
|
+
}
|
|
1054
1257
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
1258
|
+
// A setup command on an older Node would scaffold and wire the agent, then fail on the
|
|
1259
|
+
// first action with an error about a missing module. Stop before changing anything, and
|
|
1260
|
+
// say what to do. The hook subcommands are left alone: they already fail closed.
|
|
1261
|
+
if (["init", "install", "connect", "login"].includes(cmd ?? "") && !nodeSupported()) {
|
|
1262
|
+
console.error(`Scopebond needs Node.js 22.13 or later; this is Node ${process.versions.node}.`);
|
|
1263
|
+
console.error("Install the current Node.js LTS from https://nodejs.org, open a new terminal, and run the command again.");
|
|
1264
|
+
process.exit(1);
|
|
1265
|
+
}
|
|
1055
1266
|
if (cmd === "claude") {
|
|
1056
1267
|
await runClaude();
|
|
1057
1268
|
}
|
|
@@ -1092,7 +1303,7 @@ else if (cmd === "uninstall") {
|
|
|
1092
1303
|
runUninstall(rest);
|
|
1093
1304
|
}
|
|
1094
1305
|
else if (cmd === "login") {
|
|
1095
|
-
runLogin();
|
|
1306
|
+
await runLogin(rest);
|
|
1096
1307
|
}
|
|
1097
1308
|
else if (cmd === "trust") {
|
|
1098
1309
|
runTrust(rest);
|