@gatebolt/cli 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.
@@ -11,18 +11,39 @@ const GUARD_SUB = "guard";
11
11
  const POST_TURN_SUB = "post-turn";
12
12
  const ENFORCE_STRICT = "strict";
13
13
  const ENFORCE_LENIENT = "lenient";
14
+ const DENY_DECISION = "deny";
15
+ export const MISSING_CLI_MESSAGE = "GateBolt: CLI not found on PATH - install it: npm i -g @gatebolt/cli";
16
+ function missingCliDenyPayload() {
17
+ return JSON.stringify({
18
+ hookSpecificOutput: {
19
+ hookEventName: PRE_TOOL_USE,
20
+ permissionDecision: DENY_DECISION,
21
+ permissionDecisionReason: MISSING_CLI_MESSAGE,
22
+ },
23
+ });
24
+ }
25
+ function guardCommand(cli) {
26
+ return (`command -v ${cli} >/dev/null 2>&1 && exec ${cli} hook ${GUARD_SUB} || ` +
27
+ `{ [ "$GATEBOLT_ENFORCE" = ${ENFORCE_STRICT} ] && ` +
28
+ `{ echo "${MISSING_CLI_MESSAGE}" >&2; ` +
29
+ `printf '%s\\n' '${missingCliDenyPayload()}'; }; exit 0; }`);
30
+ }
31
+ function postTurnCommand(cli) {
32
+ return (`command -v ${cli} >/dev/null 2>&1 && exec ${cli} hook ${POST_TURN_SUB} || ` +
33
+ `echo "${MISSING_CLI_MESSAGE}" >&2`);
34
+ }
14
35
  export function mergeSettings(existing, opts) {
15
36
  const settings = structuredClone(existing);
16
37
  const hooks = plainObject(settings.hooks, "hooks");
17
38
  settings.hooks = hooks;
18
39
  let changed = false;
19
40
  const preToolUse = arrayField(hooks[PRE_TOOL_USE], `hooks.${PRE_TOOL_USE}`);
20
- if (ensureHookCommand(preToolUse, `hook ${GUARD_SUB}`, opts.command, EDIT_TOOLS_MATCHER)) {
41
+ if (ensureHookCommand(preToolUse, `hook ${GUARD_SUB}`, guardCommand(opts.command), EDIT_TOOLS_MATCHER)) {
21
42
  changed = true;
22
43
  }
23
44
  hooks[PRE_TOOL_USE] = preToolUse;
24
45
  const stop = arrayField(hooks[STOP], `hooks.${STOP}`);
25
- if (ensureHookCommand(stop, `hook ${POST_TURN_SUB}`, opts.command)) {
46
+ if (ensureHookCommand(stop, `hook ${POST_TURN_SUB}`, postTurnCommand(opts.command))) {
26
47
  changed = true;
27
48
  }
28
49
  hooks[STOP] = stop;
@@ -71,11 +92,8 @@ function arrayField(value, label) {
71
92
  return value;
72
93
  throw new Error(`${SETTINGS_FILE}: "${label}" must be a JSON array.`);
73
94
  }
74
- // Update our command in place when it drifts, not just add-when-absent: a
75
- // re-run of `init` that left a stale command would silently disable the guard.
76
- function ensureHookCommand(entries, suffix, command, matcher) {
77
- const desired = `${command} ${suffix}`;
78
- const existing = findGateboltHook(entries, suffix);
95
+ function ensureHookCommand(entries, marker, desired, matcher) {
96
+ const existing = findGateboltHook(entries, marker);
79
97
  if (existing) {
80
98
  if (existing.command === desired)
81
99
  return false;
@@ -86,14 +104,14 @@ function ensureHookCommand(entries, suffix, command, matcher) {
86
104
  entries.push(matcher === undefined ? { hooks: [hook] } : { matcher, hooks: [hook] });
87
105
  return true;
88
106
  }
89
- function findGateboltHook(entries, suffix) {
107
+ function findGateboltHook(entries, marker) {
90
108
  for (const entry of entries) {
91
109
  if (!isRecord(entry) || !Array.isArray(entry.hooks))
92
110
  continue;
93
111
  for (const hook of entry.hooks) {
94
112
  if (isRecord(hook) &&
95
113
  typeof hook.command === "string" &&
96
- hook.command.trim().endsWith(suffix)) {
114
+ hook.command.includes(marker)) {
97
115
  return hook;
98
116
  }
99
117
  }
@@ -1,6 +1,8 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { dirname } from "node:path";
2
3
  import { formatError } from "../errors.js";
3
4
  import { hasCommits, repoContext } from "../git.js";
5
+ import { isRecord } from "../json.js";
4
6
  import { readSession } from "../session.js";
5
7
  import { RequestError } from "../transport.js";
6
8
  import { runObserve } from "./observe.js";
@@ -21,33 +23,68 @@ task — specific repo-relative paths, not broad globs like src/** :
21
23
  The GateBolt CLI is already configured for this repository. Once it
22
24
  prints "Declared change …", retry the edit.`;
23
25
  }
24
- export async function runHook(argv) {
25
- anchorToProjectDir();
26
- drainStdin();
26
+ export async function runHook(argv, input = {}) {
27
27
  const sub = argv[0] ?? "";
28
28
  switch (sub) {
29
29
  case "guard":
30
- return runGuard();
30
+ return runGuard(input);
31
31
  case "post-turn":
32
- return runPostTurn();
32
+ return runPostTurn(input);
33
33
  case "pre-push":
34
- return runPrePush();
34
+ return runPrePush(input);
35
35
  default:
36
36
  throw new Error(`Unknown hook '${sub}'. Expected one of: guard, post-turn, pre-push.`);
37
37
  }
38
38
  }
39
- let stdinDrained = false;
40
- function drainStdin() {
41
- if (stdinDrained || process.stdin.isTTY)
42
- return;
43
- stdinDrained = true;
44
- process.stdin.on("error", () => undefined);
45
- process.stdin.resume();
46
- }
47
- function anchorToProjectDir() {
48
- const dir = process.env.CLAUDE_PROJECT_DIR;
49
- if (dir && existsSync(dir))
39
+ async function resolveRepo(input) {
40
+ const candidates = [
41
+ input.filePath ? dirname(input.filePath) : undefined,
42
+ input.cwd,
43
+ process.env.CLAUDE_PROJECT_DIR,
44
+ process.cwd(),
45
+ ];
46
+ for (const dir of candidates) {
47
+ if (!dir || !existsSync(dir))
48
+ continue;
50
49
  process.chdir(dir);
50
+ const repo = await hookRepoContext();
51
+ if (repo)
52
+ return repo;
53
+ }
54
+ return null;
55
+ }
56
+ export async function readHookPayload() {
57
+ const data = safeJsonParse(await readStdin());
58
+ if (!isRecord(data))
59
+ return {};
60
+ const toolInput = isRecord(data.tool_input) ? data.tool_input : undefined;
61
+ return {
62
+ cwd: typeof data.cwd === "string" ? data.cwd : undefined,
63
+ filePath: toolInput && typeof toolInput.file_path === "string"
64
+ ? toolInput.file_path
65
+ : undefined,
66
+ };
67
+ }
68
+ function safeJsonParse(raw) {
69
+ try {
70
+ return JSON.parse(raw);
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ function readStdin() {
77
+ if (process.stdin.isTTY)
78
+ return Promise.resolve("");
79
+ return new Promise((resolve) => {
80
+ const chunks = [];
81
+ const done = () => resolve(Buffer.concat(chunks).toString("utf8"));
82
+ process.stdin.on("data", (chunk) => {
83
+ chunks.push(chunk);
84
+ });
85
+ process.stdin.on("end", done);
86
+ process.stdin.on("error", done);
87
+ });
51
88
  }
52
89
  async function hookRepoContext() {
53
90
  try {
@@ -57,10 +94,10 @@ async function hookRepoContext() {
57
94
  return null;
58
95
  }
59
96
  }
60
- async function runGuard() {
97
+ async function runGuard(input) {
61
98
  if (process.env.GATEBOLT_ENFORCE !== ENFORCE_STRICT)
62
99
  return;
63
- const repo = await hookRepoContext();
100
+ const repo = await resolveRepo(input);
64
101
  if (!repo)
65
102
  return;
66
103
  if (await readSession(repo.gitDir))
@@ -71,8 +108,8 @@ async function runGuard() {
71
108
  const cli = process.env.GATEBOLT_CLI ?? DEFAULT_CLI;
72
109
  process.stdout.write(denyPayload(declareInstructions(cli)));
73
110
  }
74
- async function runPostTurn() {
75
- const repo = await hookRepoContext();
111
+ async function runPostTurn(input) {
112
+ const repo = await resolveRepo(input);
76
113
  if (!repo)
77
114
  return;
78
115
  if (!(await readSession(repo.gitDir)))
@@ -86,8 +123,8 @@ async function runPostTurn() {
86
123
  }
87
124
  // Backstop for the interrupt case the Stop hook misses: an open, un-observed
88
125
  // declaration.
89
- async function runPrePush() {
90
- const repo = await hookRepoContext();
126
+ async function runPrePush(input) {
127
+ const repo = await resolveRepo(input);
91
128
  if (!repo)
92
129
  return;
93
130
  if (!(await readSession(repo.gitDir)))
@@ -3,7 +3,7 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { delimiter, dirname, isAbsolute, join, relative } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { parseArgs } from "node:util";
6
- import { claudeCodeAdapter } from "../agents/claude-code.js";
6
+ import { claudeCodeAdapter, MISSING_CLI_MESSAGE, } from "../agents/claude-code.js";
7
7
  import { repoIdentityFromName, resolveConnection } from "../config.js";
8
8
  import { hooksPath, isPathIgnored, isUncommitted, repoContext, } from "../git.js";
9
9
  import { isRecord } from "../json.js";
@@ -163,6 +163,10 @@ export function prePushScript(command) {
163
163
  return [
164
164
  "#!/usr/bin/env sh",
165
165
  `# ${PRE_PUSH_MARKER} — reconciles an open declaration before a push.`,
166
+ `if ! command -v ${command} >/dev/null 2>&1; then`,
167
+ ` echo "${MISSING_CLI_MESSAGE}" >&2`,
168
+ " exit 0",
169
+ "fi",
166
170
  `exec ${command} hook ${PRE_PUSH_FILE}`,
167
171
  "",
168
172
  ].join("\n");
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { runConfigure } from "./commands/configure.js";
3
3
  import { runDeclare } from "./commands/declare.js";
4
- import { runHook } from "./commands/hook.js";
4
+ import { readHookPayload, runHook } from "./commands/hook.js";
5
5
  import { runInit } from "./commands/init.js";
6
6
  import { runLink } from "./commands/link.js";
7
7
  import { runLogin } from "./commands/login.js";
@@ -43,7 +43,7 @@ async function dispatch(command, argv) {
43
43
  case "init":
44
44
  return runInit(argv);
45
45
  case "hook":
46
- return runHook(argv);
46
+ return runHook(argv, await readHookPayload());
47
47
  default:
48
48
  process.stderr.write(`${USAGE}\nRun \`gatebolt --help\` for details.\n`);
49
49
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gatebolt/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Declare-then-observe agent client for GateBolt — records the files an AI agent intends to touch, then reconciles the actual git diff.",
5
5
  "keywords": [
6
6
  "gatebolt",