@scopebond/hook 0.7.0 → 0.8.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 +36 -7
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +237 -46
- 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
27
|
import { join } from "node:path";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
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,28 @@ 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}`);
|
|
688
754
|
// Configure the agent automatically (merges into the existing config), unless the
|
|
689
|
-
// caller opts out. This removes the "paste this snippet" step.
|
|
690
|
-
|
|
691
|
-
|
|
755
|
+
// caller opts out. This removes the "paste this snippet" step. A hook that is already
|
|
756
|
+
// configured — pinned by `init`, or user-level by `install` — is left as it is:
|
|
757
|
+
// connecting changes where receipts go, not how the hook starts.
|
|
758
|
+
const scopes = harnessScopes(harness, process.cwd());
|
|
759
|
+
const existing = scopes.local ?? scopes.project ?? scopes.user;
|
|
760
|
+
if (existing && !args.includes("--no-install")) {
|
|
761
|
+
console.log(`✓ ${harnessName(harness)} already configured in ${existing}`);
|
|
762
|
+
}
|
|
763
|
+
else if (!args.includes("--no-install")) {
|
|
764
|
+
const { file } = placeHook(harness, process.cwd(), undefined);
|
|
692
765
|
console.log(`✓ ${harnessName(harness)} configured in ${file}`);
|
|
693
766
|
if (harness === "codex")
|
|
694
767
|
console.log(`\nOne last step: ${codexTrustStep}`);
|
|
@@ -701,14 +774,26 @@ async function runConnect(args) {
|
|
|
701
774
|
console.log("Run your agent — the first action appears in your workspace within seconds.");
|
|
702
775
|
}
|
|
703
776
|
catch (error) {
|
|
704
|
-
|
|
777
|
+
const message = error.message;
|
|
778
|
+
console.error(`connect failed: ${message}`);
|
|
779
|
+
if (/enrollment|expired|401|403/i.test(message))
|
|
780
|
+
console.error(enrollmentHelp);
|
|
705
781
|
process.exit(1);
|
|
706
782
|
}
|
|
707
783
|
}
|
|
784
|
+
/** Where an enrollment comes from, for every connect error that means "this one will
|
|
785
|
+
* not work": the bare "invalid enrollment token" told the reader nothing about what to
|
|
786
|
+
* do next. */
|
|
787
|
+
const enrollmentHelp = [
|
|
788
|
+
"An enrollment comes from your Scopebond workspace: open it, choose to connect an agent,",
|
|
789
|
+
"and copy the command it shows — it includes the workspace URL and a fresh enrollment.",
|
|
790
|
+
"Each enrollment is single-use and expires soon after it is created; if this one was",
|
|
791
|
+
"used or has expired, create a new one there.",
|
|
792
|
+
].join("\n");
|
|
708
793
|
async function runFlush() {
|
|
709
794
|
const dir = resolveConfigDir(process.cwd());
|
|
710
795
|
if (!loadConnection(dir)) {
|
|
711
|
-
console.error(
|
|
796
|
+
console.error(`not connected to a workspace; run \`${cliCommand("connect <workspace-url> <enrollment>")}\` first`);
|
|
712
797
|
process.exit(1);
|
|
713
798
|
}
|
|
714
799
|
const runtime = createHookRuntime(runtimePaths(dir));
|
|
@@ -728,6 +813,14 @@ function cliPath() {
|
|
|
728
813
|
* so every project a developer opens is governed without a per-repo `init`. */
|
|
729
814
|
function runInstall(args) {
|
|
730
815
|
const dir = userHome();
|
|
816
|
+
// Run through `npx`, this CLI lives in npm's throwaway cache; registering that path
|
|
817
|
+
// would leave a hook that stops starting whenever npm clears it — and a hook that
|
|
818
|
+
// cannot start lets every action through. Pin the durable copy, as `init` does, and
|
|
819
|
+
// fall back to the portable `npx` command when none can be made.
|
|
820
|
+
const commandFor = (h) => {
|
|
821
|
+
const pin = ensureDurableRuntime(cliPath(), hookVersion());
|
|
822
|
+
return pin.cli ? absoluteHookCommand(pin.cli, h) : hookCommand(h);
|
|
823
|
+
};
|
|
731
824
|
const harnessesFor = () => args.includes("--codex") ? ["codex"]
|
|
732
825
|
: args.includes("--cursor") ? ["cursor"]
|
|
733
826
|
: args.includes("--claude") ? ["claude"]
|
|
@@ -744,7 +837,8 @@ function runInstall(args) {
|
|
|
744
837
|
console.log(`Would ${exists ? "modify" : "create"} ${file}`);
|
|
745
838
|
if (exists)
|
|
746
839
|
console.log(` backing it up to ${file}.scopebond-backup`);
|
|
747
|
-
|
|
840
|
+
// Previewed without copying anything: the path the real run would pin.
|
|
841
|
+
console.log(` adding hook ${absoluteHookCommand(isEphemeralPath(cliPath()) ? pinnedCliPath(hookVersion()) : cliPath(), h)}`);
|
|
748
842
|
if (exists && isHarnessConfigured(file))
|
|
749
843
|
console.log(` (a Scopebond hook is already there; it would be replaced, not duplicated)`);
|
|
750
844
|
}
|
|
@@ -762,7 +856,7 @@ function runInstall(args) {
|
|
|
762
856
|
try {
|
|
763
857
|
const target = userHarnessFile(h);
|
|
764
858
|
const backup = existsSync(target) ? `${target}.scopebond-backup` : null;
|
|
765
|
-
const file = writeHarnessConfig(target, h,
|
|
859
|
+
const file = writeHarnessConfig(target, h, commandFor(h));
|
|
766
860
|
console.log(`✓ ${harnessName(h)} configured in ${file}`);
|
|
767
861
|
if (backup && existsSync(backup))
|
|
768
862
|
console.log(` original kept at ${backup}`);
|
|
@@ -822,7 +916,7 @@ function runStatus() {
|
|
|
822
916
|
const connected = !!loadConnection(resolveConfigDir(process.cwd()));
|
|
823
917
|
const dbPath = join(resolveConfigDir(process.cwd()), "receipts.db");
|
|
824
918
|
console.log(`Scopebond hook ${hookVersion()}`);
|
|
825
|
-
console.log(` user home ${home} ${installed ? "(installed)" :
|
|
919
|
+
console.log(` user home ${home} ${installed ? "(installed)" : `(not installed — run \`${cliCommand("install")}\`)`}`);
|
|
826
920
|
console.log(` active config ${resolveConfigDir(process.cwd())}`);
|
|
827
921
|
const ignored = untrustedProjectPolicy(process.cwd());
|
|
828
922
|
if (ignored)
|
|
@@ -833,7 +927,7 @@ function runStatus() {
|
|
|
833
927
|
console.log(` cloud workspace ${connected ? "connected" : "not connected (local only)"}`);
|
|
834
928
|
console.log(` local receipts ${existsSync(dbPath) ? `${dbPath} (${describeStore(dbPath)})` : "none yet"}`);
|
|
835
929
|
for (const [name, scopes] of [["Claude Code", claude], ["Cursor", cursor], ["Codex", codex]]) {
|
|
836
|
-
for (const file of [scopes.project, scopes.user])
|
|
930
|
+
for (const file of [scopes.project, scopes.local, scopes.user])
|
|
837
931
|
if (file)
|
|
838
932
|
console.log(` ${name}: ${file}`);
|
|
839
933
|
}
|
|
@@ -850,7 +944,7 @@ async function runDoctor() {
|
|
|
850
944
|
console.log(` cli ${cli} ${existsSync(cli) ? "ok" : "MISSING"}`);
|
|
851
945
|
const active = resolveConfigDir(process.cwd());
|
|
852
946
|
const hasPolicy = existsSync(join(active, "policy.json"));
|
|
853
|
-
console.log(` active config ${active} ${hasPolicy ? "ok" :
|
|
947
|
+
console.log(` active config ${active} ${hasPolicy ? "ok" : `no policy (run \`${cliCommand("init")}\` here, or \`${cliCommand("install")}\` once for your user)`}`);
|
|
854
948
|
if (!hasPolicy)
|
|
855
949
|
problems.push("no policy found in the active config dir");
|
|
856
950
|
const ignored = untrustedProjectPolicy(process.cwd());
|
|
@@ -871,16 +965,27 @@ async function runDoctor() {
|
|
|
871
965
|
}
|
|
872
966
|
anyHarness = true;
|
|
873
967
|
console.log(` ${name.padEnd(15)} ${label}`);
|
|
874
|
-
for (const file of [scopes.project, scopes.user]) {
|
|
968
|
+
for (const file of [scopes.project, scopes.local, scopes.user]) {
|
|
875
969
|
if (!file)
|
|
876
970
|
continue;
|
|
971
|
+
// A project file git shares must not name a path on this machine: it starts here,
|
|
972
|
+
// so the resolve check below passes, but on every teammate's machine it cannot
|
|
973
|
+
// start — and a hook that cannot start is a non-blocking error, so their agent runs
|
|
974
|
+
// unchecked. Only the doctor on the machine that wrote it can see this coming.
|
|
975
|
+
const share = file === scopes.project ? gitShareState(file) : "none";
|
|
976
|
+
const shared = share === "tracked" || share === "untracked";
|
|
877
977
|
for (const command of configuredHookCommands(file)) {
|
|
878
978
|
const ok = hookCommandResolves(command);
|
|
879
|
-
|
|
979
|
+
const leaks = ok && shared && isMachineSpecificCommand(command);
|
|
980
|
+
console.log(` ${ok && !leaks ? "ok " : "BAD "} ${file}`);
|
|
880
981
|
if (!ok) {
|
|
881
982
|
console.log(` command cannot start: ${command}`);
|
|
882
983
|
problems.push(`${name} hook command no longer resolves in ${file} — run \`${cliCommand("init")}\` to repair it`);
|
|
883
984
|
}
|
|
985
|
+
else if (leaks) {
|
|
986
|
+
console.log(` machine-specific command in a file git shares: ${command}`);
|
|
987
|
+
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`);
|
|
988
|
+
}
|
|
884
989
|
}
|
|
885
990
|
}
|
|
886
991
|
if (harness === "codex")
|
|
@@ -913,8 +1018,8 @@ function runUninstall(args) {
|
|
|
913
1018
|
// the install the site actually tells people to run — `uninstall` reported "no
|
|
914
1019
|
// user-level harness config found" and left the project hook in place.
|
|
915
1020
|
for (const h of ["claude", "cursor", "codex"]) {
|
|
916
|
-
for (const file of [projectHarnessFile(h, process.cwd()), userHarnessFile(h)]) {
|
|
917
|
-
if (removeHarnessConfig(file)) {
|
|
1021
|
+
for (const file of [projectHarnessFile(h, process.cwd()), localHarnessFile(h, process.cwd()), userHarnessFile(h)]) {
|
|
1022
|
+
if (file && removeHarnessConfig(file)) {
|
|
918
1023
|
console.log(`✓ removed the Scopebond hook from ${file}`);
|
|
919
1024
|
removed++;
|
|
920
1025
|
}
|
|
@@ -947,23 +1052,103 @@ function runTrust(args) {
|
|
|
947
1052
|
console.log(`✓ trusted ${join(dir, "policy.json")} (sha256 ${digest.slice(0, 12)}…)`);
|
|
948
1053
|
console.log("It governs agents in this project until it changes; after any edit, review it and run trust again.");
|
|
949
1054
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1055
|
+
/** `login <workspace-url>` — connect this computer without pasting anything. It asks
|
|
1056
|
+
* the workspace for a short code, shows it with the page to open, and waits while a
|
|
1057
|
+
* person who can manage the workspace approves it there for an environment and agent.
|
|
1058
|
+
* The approval hands back a single-use enrollment, which completes exactly as
|
|
1059
|
+
* `connect` does. Nothing secret is printed: the device code stays in memory. */
|
|
1060
|
+
async function runLogin(args) {
|
|
1061
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
1062
|
+
const harness = selectedHarness(args);
|
|
1063
|
+
let origin;
|
|
1064
|
+
try {
|
|
1065
|
+
const parsed = new URL(positional[0] ?? "");
|
|
1066
|
+
const local = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
|
|
1067
|
+
if (parsed.protocol !== "https:" && !(local && parsed.protocol === "http:"))
|
|
1068
|
+
throw new Error("https required");
|
|
1069
|
+
origin = parsed.origin;
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
console.error(`usage: ${cliCommand("login <workspace-url> [--claude|--cursor|--codex] [--no-install]")}`);
|
|
1073
|
+
console.error("The workspace URL is the address of your Scopebond workspace, for example https://cloud.scopebond.com.");
|
|
1074
|
+
process.exit(1);
|
|
1075
|
+
}
|
|
1076
|
+
const post = async (path, body) => {
|
|
1077
|
+
const response = await fetch(new URL(path, origin), {
|
|
1078
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
|
1079
|
+
redirect: "error", signal: AbortSignal.timeout(15_000),
|
|
1080
|
+
});
|
|
1081
|
+
return { status: response.status, json: await response.json().catch(() => ({})) };
|
|
1082
|
+
};
|
|
1083
|
+
let start;
|
|
1084
|
+
try {
|
|
1085
|
+
start = await post("/v1/device/code", { client_name: hostname(), harness });
|
|
1086
|
+
}
|
|
1087
|
+
catch (error) {
|
|
1088
|
+
console.error(`could not reach ${origin}: ${error.message}`);
|
|
1089
|
+
process.exit(1);
|
|
1090
|
+
}
|
|
1091
|
+
const deviceCode = typeof start.json.device_code === "string" ? start.json.device_code : "";
|
|
1092
|
+
if (start.status !== 200 || !deviceCode) {
|
|
1093
|
+
console.error(`${origin} did not start a login (HTTP ${start.status}). Check the workspace URL, or use ${cliCommand("connect <workspace-url> <enrollment>")}.`);
|
|
1094
|
+
process.exit(1);
|
|
1095
|
+
}
|
|
1096
|
+
const userCode = String(start.json.user_code ?? "");
|
|
1097
|
+
const verify = String(start.json.verification_uri_complete ?? start.json.verification_uri ?? origin);
|
|
1098
|
+
let intervalMs = Math.max(1, Number(start.json.interval ?? 5)) * 1000;
|
|
1099
|
+
const deadline = Date.now() + Math.max(60, Number(start.json.expires_in ?? 600)) * 1000;
|
|
1100
|
+
console.log(`To connect this computer, open:\n\n ${verify}\n\nand check that it shows the code ${userCode}\n`);
|
|
1101
|
+
console.log("Waiting for approval (the code expires in 10 minutes; Ctrl+C to stop)…");
|
|
1102
|
+
const dir = configDir();
|
|
1103
|
+
scaffold(dir, {});
|
|
1104
|
+
while (Date.now() < deadline) {
|
|
1105
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1106
|
+
let polled;
|
|
1107
|
+
try {
|
|
1108
|
+
polled = await post("/v1/device/token", { device_code: deviceCode });
|
|
1109
|
+
}
|
|
1110
|
+
catch {
|
|
1111
|
+
continue;
|
|
1112
|
+
} // a transient network error: keep waiting until the deadline
|
|
1113
|
+
if (polled.status === 200 && polled.json.enrollment && typeof polled.json.enrollment === "object") {
|
|
1114
|
+
console.log("✓ Approved");
|
|
1115
|
+
await finishConnect(dir, origin, polled.json.enrollment, harness, args);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const error = polled.json.error;
|
|
1119
|
+
if (error === "authorization_pending")
|
|
1120
|
+
continue;
|
|
1121
|
+
if (error === "slow_down") {
|
|
1122
|
+
intervalMs += 5_000;
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
if (error === "access_denied") {
|
|
1126
|
+
console.error("The request was denied in the workspace. Nothing was connected.");
|
|
1127
|
+
process.exit(1);
|
|
1128
|
+
}
|
|
1129
|
+
if (error === "expired_token")
|
|
1130
|
+
break;
|
|
1131
|
+
console.error(`login failed (${String(error ?? `HTTP ${polled.status}`)}). Run the command again for a new code.`);
|
|
1132
|
+
process.exit(1);
|
|
1133
|
+
}
|
|
1134
|
+
console.error("The code expired before it was approved. Run the command again for a new one.");
|
|
1135
|
+
process.exit(1);
|
|
955
1136
|
}
|
|
956
1137
|
/** What each command does, its arguments, and one example. The whole help used to be a
|
|
957
1138
|
* single usage line listing 15 command names, which told a reader nothing about what any
|
|
958
1139
|
* of them did or what arguments they take. */
|
|
959
1140
|
const COMMANDS = [
|
|
960
|
-
{ name: "init", args: "[--cursor|--codex] [--no-install] [--npx] [--force] [--yes]",
|
|
1141
|
+
{ name: "init", args: "[--cursor|--codex] [--shared] [--dry-run] [--no-install] [--npx] [--force] [--yes]",
|
|
961
1142
|
summary: "set this project up: keys, a starter policy, and your agent wired to the hook",
|
|
962
1143
|
detail: [
|
|
963
1144
|
"Writes .scopebond/ (machine key, countersigning key, starter policy, .gitignore) and",
|
|
964
|
-
"
|
|
965
|
-
"
|
|
966
|
-
"
|
|
1145
|
+
"wires your agent to the hook. It pins a durable copy of this package so the hook starts",
|
|
1146
|
+
"fast, and because that command names paths on this machine it goes where git will not",
|
|
1147
|
+
"share it: .claude/settings.local.json (kept out of git for this clone), or for Cursor",
|
|
1148
|
+
"and Codex their project file only while git does not track it. --shared writes the",
|
|
1149
|
+
"portable npx command to .claude/settings.json, .cursor/hooks.json or .codex/hooks.json",
|
|
1150
|
+
"instead, so everyone who clones the project gets the hook. --dry-run shows what it",
|
|
1151
|
+
"would write. --no-install prints the config snippet rather than writing it.",
|
|
967
1152
|
"Needs a terminal, or --yes in a script, because it changes what governs your agent.",
|
|
968
1153
|
] },
|
|
969
1154
|
{ name: "install", args: "[--claude] [--cursor] [--codex] [--dry-run] [--force] [--yes]",
|
|
@@ -1002,6 +1187,12 @@ const COMMANDS = [
|
|
|
1002
1187
|
"to a JSONL file beside the database, then removes them. Refuses once the log has been",
|
|
1003
1188
|
"anchored, because a receipt's position is its anchor leaf index.",
|
|
1004
1189
|
] },
|
|
1190
|
+
{ name: "login", args: "<workspace-url> [--claude|--cursor|--codex] [--no-install]",
|
|
1191
|
+
summary: "connect this computer to a Scopebond Cloud workspace by approving a short code there",
|
|
1192
|
+
detail: [
|
|
1193
|
+
"Prints a code and a link; someone who manages the workspace opens it, checks the code",
|
|
1194
|
+
"and approves it for an environment and agent. Nothing is copied or pasted.",
|
|
1195
|
+
] },
|
|
1005
1196
|
{ name: "connect", args: "<workspace-url> <enrollment> [--claude|--cursor|--codex]",
|
|
1006
1197
|
summary: "send receipts to a Scopebond Cloud workspace as well as keeping them locally" },
|
|
1007
1198
|
{ name: "flush", summary: "deliver any receipts still queued for the workspace now" },
|
|
@@ -1092,7 +1283,7 @@ else if (cmd === "uninstall") {
|
|
|
1092
1283
|
runUninstall(rest);
|
|
1093
1284
|
}
|
|
1094
1285
|
else if (cmd === "login") {
|
|
1095
|
-
runLogin();
|
|
1286
|
+
await runLogin(rest);
|
|
1096
1287
|
}
|
|
1097
1288
|
else if (cmd === "trust") {
|
|
1098
1289
|
runTrust(rest);
|