@gatebolt/cli 0.7.0 → 0.9.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.
@@ -5,27 +5,55 @@ const CLAUDE_DIR = ".claude";
5
5
  const SETTINGS_FILE = "settings.json";
6
6
  const PRE_TOOL_USE = "PreToolUse";
7
7
  const STOP = "Stop";
8
+ const USER_PROMPT_SUBMIT = "UserPromptSubmit";
8
9
  const EDIT_TOOLS_MATCHER = "Edit|Write|NotebookEdit";
9
10
  const COMMAND_TYPE = "command";
10
11
  const GUARD_SUB = "guard";
11
12
  const POST_TURN_SUB = "post-turn";
13
+ const PROMPT_SUB = "prompt";
12
14
  const ENFORCE_STRICT = "strict";
13
15
  const ENFORCE_LENIENT = "lenient";
16
+ const DENY_DECISION = "deny";
17
+ export const MISSING_CLI_MESSAGE = "GateBolt: CLI not found on PATH - install it: npm i -g @gatebolt/cli";
18
+ function missingCliDenyPayload() {
19
+ return JSON.stringify({
20
+ hookSpecificOutput: {
21
+ hookEventName: PRE_TOOL_USE,
22
+ permissionDecision: DENY_DECISION,
23
+ permissionDecisionReason: MISSING_CLI_MESSAGE,
24
+ },
25
+ });
26
+ }
27
+ function guardCommand(cli) {
28
+ return (`command -v ${cli} >/dev/null 2>&1 && exec ${cli} hook ${GUARD_SUB} || ` +
29
+ `{ [ "$GATEBOLT_ENFORCE" = ${ENFORCE_STRICT} ] && ` +
30
+ `{ echo "${MISSING_CLI_MESSAGE}" >&2; ` +
31
+ `printf '%s\\n' '${missingCliDenyPayload()}'; }; exit 0; }`);
32
+ }
33
+ function advisoryCommand(cli, sub) {
34
+ return (`command -v ${cli} >/dev/null 2>&1 && exec ${cli} hook ${sub} || ` +
35
+ `echo "${MISSING_CLI_MESSAGE}" >&2`);
36
+ }
14
37
  export function mergeSettings(existing, opts) {
15
38
  const settings = structuredClone(existing);
16
39
  const hooks = plainObject(settings.hooks, "hooks");
17
40
  settings.hooks = hooks;
18
41
  let changed = false;
19
42
  const preToolUse = arrayField(hooks[PRE_TOOL_USE], `hooks.${PRE_TOOL_USE}`);
20
- if (ensureHookCommand(preToolUse, `hook ${GUARD_SUB}`, opts.command, EDIT_TOOLS_MATCHER)) {
43
+ if (ensureHookCommand(preToolUse, `hook ${GUARD_SUB}`, guardCommand(opts.command), EDIT_TOOLS_MATCHER)) {
21
44
  changed = true;
22
45
  }
23
46
  hooks[PRE_TOOL_USE] = preToolUse;
24
47
  const stop = arrayField(hooks[STOP], `hooks.${STOP}`);
25
- if (ensureHookCommand(stop, `hook ${POST_TURN_SUB}`, opts.command)) {
48
+ if (ensureHookCommand(stop, `hook ${POST_TURN_SUB}`, advisoryCommand(opts.command, POST_TURN_SUB))) {
26
49
  changed = true;
27
50
  }
28
51
  hooks[STOP] = stop;
52
+ const userPrompt = arrayField(hooks[USER_PROMPT_SUBMIT], `hooks.${USER_PROMPT_SUBMIT}`);
53
+ if (ensureHookCommand(userPrompt, `hook ${PROMPT_SUB}`, advisoryCommand(opts.command, PROMPT_SUB))) {
54
+ changed = true;
55
+ }
56
+ hooks[USER_PROMPT_SUBMIT] = userPrompt;
29
57
  if (opts.enforce === ENFORCE_STRICT) {
30
58
  const env = plainObject(settings.env, "env");
31
59
  settings.env = env;
@@ -71,11 +99,8 @@ function arrayField(value, label) {
71
99
  return value;
72
100
  throw new Error(`${SETTINGS_FILE}: "${label}" must be a JSON array.`);
73
101
  }
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);
102
+ function ensureHookCommand(entries, marker, desired, matcher) {
103
+ const existing = findGateboltHook(entries, marker);
79
104
  if (existing) {
80
105
  if (existing.command === desired)
81
106
  return false;
@@ -86,14 +111,14 @@ function ensureHookCommand(entries, suffix, command, matcher) {
86
111
  entries.push(matcher === undefined ? { hooks: [hook] } : { matcher, hooks: [hook] });
87
112
  return true;
88
113
  }
89
- function findGateboltHook(entries, suffix) {
114
+ function findGateboltHook(entries, marker) {
90
115
  for (const entry of entries) {
91
116
  if (!isRecord(entry) || !Array.isArray(entry.hooks))
92
117
  continue;
93
118
  for (const hook of entry.hooks) {
94
119
  if (isRecord(hook) &&
95
120
  typeof hook.command === "string" &&
96
- hook.command.trim().endsWith(suffix)) {
121
+ hook.command.includes(marker)) {
97
122
  return hook;
98
123
  }
99
124
  }
@@ -109,6 +109,7 @@ export async function runDeclare(argv) {
109
109
  baseSha,
110
110
  branch,
111
111
  profile: connection.profileName,
112
+ sessionId: process.env.CLAUDE_CODE_SESSION_ID,
112
113
  });
113
114
  process.stdout.write(`Declared change ${result.changeId} (${declaredFiles.length} file(s)).\n`);
114
115
  }
@@ -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
- import { hasCommits, repoContext } from "../git.js";
4
+ import { hasCommits, repoContext, worktreePaths } 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,83 @@ 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
+ function promptReminder(cli) {
27
+ return `This repository uses GateBolt strict mode. Before editing any file, declare
28
+ the files you plan to touch — run this in Bash first, listing EVERY file you will
29
+ create or modify for this task:
30
+
31
+ ${cli} declare \\
32
+ --task "<one line describing the task>" \\
33
+ --vendor claude_code --name "Claude Code" --model <your-model-id> \\
34
+ --file <path> [--file <path> ...]
35
+
36
+ Then make your edits. Editing before declaring will be blocked.`;
37
+ }
38
+ export async function runHook(argv, input = {}) {
27
39
  const sub = argv[0] ?? "";
28
40
  switch (sub) {
29
41
  case "guard":
30
- return runGuard();
42
+ return runGuard(input);
43
+ case "prompt":
44
+ return runPrompt(input);
31
45
  case "post-turn":
32
- return runPostTurn();
46
+ return runPostTurn(input);
33
47
  case "pre-push":
34
- return runPrePush();
48
+ return runPrePush(input);
35
49
  default:
36
- throw new Error(`Unknown hook '${sub}'. Expected one of: guard, post-turn, pre-push.`);
50
+ throw new Error(`Unknown hook '${sub}'. Expected one of: guard, prompt, post-turn, pre-push.`);
37
51
  }
38
52
  }
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))
53
+ async function resolveRepo(input) {
54
+ const candidates = [
55
+ input.filePath ? dirname(input.filePath) : undefined,
56
+ input.cwd,
57
+ process.env.CLAUDE_PROJECT_DIR,
58
+ process.cwd(),
59
+ ];
60
+ for (const dir of candidates) {
61
+ if (!dir || !existsSync(dir))
62
+ continue;
50
63
  process.chdir(dir);
64
+ const repo = await hookRepoContext();
65
+ if (repo)
66
+ return repo;
67
+ }
68
+ return null;
69
+ }
70
+ export async function readHookPayload() {
71
+ const data = safeJsonParse(await readStdin());
72
+ if (!isRecord(data))
73
+ return {};
74
+ const toolInput = isRecord(data.tool_input) ? data.tool_input : undefined;
75
+ return {
76
+ cwd: typeof data.cwd === "string" ? data.cwd : undefined,
77
+ filePath: toolInput && typeof toolInput.file_path === "string"
78
+ ? toolInput.file_path
79
+ : undefined,
80
+ sessionId: typeof data.session_id === "string" ? data.session_id : undefined,
81
+ };
82
+ }
83
+ function safeJsonParse(raw) {
84
+ try {
85
+ return JSON.parse(raw);
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ function readStdin() {
92
+ if (process.stdin.isTTY)
93
+ return Promise.resolve("");
94
+ return new Promise((resolve) => {
95
+ const chunks = [];
96
+ const done = () => resolve(Buffer.concat(chunks).toString("utf8"));
97
+ process.stdin.on("data", (chunk) => {
98
+ chunks.push(chunk);
99
+ });
100
+ process.stdin.on("end", done);
101
+ process.stdin.on("error", done);
102
+ });
51
103
  }
52
104
  async function hookRepoContext() {
53
105
  try {
@@ -57,10 +109,10 @@ async function hookRepoContext() {
57
109
  return null;
58
110
  }
59
111
  }
60
- async function runGuard() {
112
+ async function runGuard(input) {
61
113
  if (process.env.GATEBOLT_ENFORCE !== ENFORCE_STRICT)
62
114
  return;
63
- const repo = await hookRepoContext();
115
+ const repo = await resolveRepo(input);
64
116
  if (!repo)
65
117
  return;
66
118
  if (await readSession(repo.gitDir))
@@ -71,12 +123,36 @@ async function runGuard() {
71
123
  const cli = process.env.GATEBOLT_CLI ?? DEFAULT_CLI;
72
124
  process.stdout.write(denyPayload(declareInstructions(cli)));
73
125
  }
74
- async function runPostTurn() {
75
- const repo = await hookRepoContext();
76
- if (!repo)
126
+ // Proactively remind the agent to declare before its first edit, so it doesn't
127
+ // discover strict mode by tripping the guard. Silent once a declaration is open.
128
+ async function runPrompt(input) {
129
+ if (process.env.GATEBOLT_ENFORCE !== ENFORCE_STRICT)
77
130
  return;
78
- if (!(await readSession(repo.gitDir)))
131
+ const repo = await resolveRepo(input);
132
+ if (!repo || !(await hasCommits()))
79
133
  return;
134
+ if (await readSession(repo.gitDir))
135
+ return;
136
+ process.stdout.write(promptReminder(process.env.GATEBOLT_CLI ?? DEFAULT_CLI));
137
+ }
138
+ async function runPostTurn(input) {
139
+ const repo = await resolveRepo(input);
140
+ if (!repo)
141
+ return;
142
+ if (await readSession(repo.gitDir))
143
+ return observeHere();
144
+ for (const { path, session } of await worktreeSessions()) {
145
+ if (session.sessionId === undefined) {
146
+ process.stderr.write(`GateBolt: ${path} has an open declaration from outside this session; leaving it open.\n`);
147
+ continue;
148
+ }
149
+ if (session.sessionId !== input.sessionId)
150
+ continue;
151
+ if (chdirTo(path))
152
+ await observeHere();
153
+ }
154
+ }
155
+ async function observeHere() {
80
156
  try {
81
157
  await runObserve([]);
82
158
  }
@@ -84,10 +160,33 @@ async function runPostTurn() {
84
160
  process.stderr.write(`${formatError(error)}\n`);
85
161
  }
86
162
  }
163
+ async function worktreeSessions() {
164
+ const found = [];
165
+ for (const path of await worktreePaths().catch(() => [])) {
166
+ if (!chdirTo(path))
167
+ continue;
168
+ const repo = await hookRepoContext();
169
+ const session = repo ? await readSession(repo.gitDir) : null;
170
+ if (session)
171
+ found.push({ path, session });
172
+ }
173
+ return found;
174
+ }
175
+ // `git worktree list` keeps reporting a worktree whose directory was deleted
176
+ // without a `git worktree prune`.
177
+ function chdirTo(path) {
178
+ try {
179
+ process.chdir(path);
180
+ return true;
181
+ }
182
+ catch {
183
+ return false;
184
+ }
185
+ }
87
186
  // Backstop for the interrupt case the Stop hook misses: an open, un-observed
88
187
  // declaration.
89
- async function runPrePush() {
90
- const repo = await hookRepoContext();
188
+ async function runPrePush(input) {
189
+ const repo = await resolveRepo(input);
91
190
  if (!repo)
92
191
  return;
93
192
  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/git.js CHANGED
@@ -34,6 +34,13 @@ export async function hooksPath() {
34
34
  const output = await git(["rev-parse", "--git-path", "hooks"]);
35
35
  return resolve(output.trim());
36
36
  }
37
+ export async function worktreePaths() {
38
+ const output = await git(["worktree", "list", "--porcelain"]);
39
+ return output
40
+ .split("\n")
41
+ .filter((line) => line.startsWith("worktree "))
42
+ .map((line) => line.replace(/^worktree /, "").trim());
43
+ }
37
44
  export async function isPathIgnored(path) {
38
45
  try {
39
46
  await git(["check-ignore", "--quiet", path]);
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/dist/session.js CHANGED
@@ -26,12 +26,13 @@ export function writeSession(gitDir, session) {
26
26
  return writeJson(gitDir, SESSION_FILE, session);
27
27
  }
28
28
  export function readSession(gitDir) {
29
- return readJson(gitDir, SESSION_FILE, ({ changeId, baseSha, branch, profile }) => typeof changeId === "string" && typeof baseSha === "string"
29
+ return readJson(gitDir, SESSION_FILE, ({ changeId, baseSha, branch, profile, sessionId }) => typeof changeId === "string" && typeof baseSha === "string"
30
30
  ? {
31
31
  changeId,
32
32
  baseSha,
33
33
  branch: typeof branch === "string" ? branch : undefined,
34
34
  profile: typeof profile === "string" ? profile : undefined,
35
+ sessionId: typeof sessionId === "string" ? sessionId : undefined,
35
36
  }
36
37
  : null);
37
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gatebolt/cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",