@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.
- package/dist/agents/claude-code.js +34 -9
- package/dist/commands/declare.js +1 -0
- package/dist/commands/hook.js +126 -27
- package/dist/commands/init.js +5 -1
- package/dist/git.js +7 -0
- package/dist/index.js +2 -2
- package/dist/session.js +2 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
75
|
-
|
|
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,
|
|
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.
|
|
121
|
+
hook.command.includes(marker)) {
|
|
97
122
|
return hook;
|
|
98
123
|
}
|
|
99
124
|
}
|
package/dist/commands/declare.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
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)))
|
package/dist/commands/init.js
CHANGED
|
@@ -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