@gatebolt/cli 0.3.1 → 0.5.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 +68 -19
- package/dist/agents/claude-code.js +183 -0
- package/dist/commands/configure.js +30 -13
- package/dist/commands/declare.js +54 -12
- package/dist/commands/hook.js +128 -0
- package/dist/commands/init.js +221 -0
- package/dist/commands/link.js +61 -0
- package/dist/commands/login.js +317 -0
- package/dist/commands/observe.js +46 -14
- package/dist/config.js +48 -12
- package/dist/contracts.js +6 -0
- package/dist/diff.js +6 -4
- package/dist/errors.js +35 -0
- package/dist/git.js +26 -1
- package/dist/help.js +161 -0
- package/dist/index.js +42 -43
- package/dist/profiles.js +48 -13
- package/dist/rest.js +49 -0
- package/dist/session.js +2 -1
- package/dist/transport.js +32 -12
- package/package.json +3 -4
- package/recipes/claude-code/README.md +0 -64
- package/recipes/claude-code/hooks/gatebolt-guard.sh +0 -48
- package/recipes/claude-code/hooks/gatebolt-observe.sh +0 -24
- package/recipes/claude-code/hooks/pre-push +0 -23
- package/recipes/claude-code/settings.json +0 -25
package/README.md
CHANGED
|
@@ -16,36 +16,82 @@ npx @gatebolt/cli declare …
|
|
|
16
16
|
|
|
17
17
|
Requires Node ≥ 20 and `git` on your `PATH`.
|
|
18
18
|
|
|
19
|
+
`gatebolt --help` lists the commands and `gatebolt <command> --help` details one;
|
|
20
|
+
`gatebolt --version` prints the version of the CLI itself.
|
|
21
|
+
|
|
19
22
|
## Configure
|
|
20
23
|
|
|
21
|
-
Every command needs
|
|
22
|
-
|
|
23
|
-
(written owner-only, `0600`):
|
|
24
|
+
Every command needs two connection settings — an API **key** and the GateBolt **URL**. Store
|
|
25
|
+
them once as a **profile** in `~/.gatebolt/config.json` (written owner-only, `0600`):
|
|
24
26
|
|
|
25
27
|
```
|
|
26
|
-
gatebolt configure --
|
|
27
|
-
--url https://your-gatebolt.app --repo payments-api
|
|
28
|
+
gatebolt configure --url https://your-gatebolt.app
|
|
28
29
|
# paste the API key when prompted (or pass --key for scripting)
|
|
29
30
|
```
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
`
|
|
32
|
+
One profile per GateBolt workspace. `--profile <name>` saves another alongside it; without the
|
|
33
|
+
flag it saves `default`, and a second `configure` refuses rather than replace the first
|
|
34
|
+
workspace's key. (`gatebolt login` writes a profile for you.)
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
Which repository each change belongs to is recorded separately, by `gatebolt link` (below).
|
|
37
|
+
Inside a repo, the CLI picks the active profile in this order:
|
|
35
38
|
|
|
36
39
|
1. `--profile <name>` flag
|
|
37
40
|
2. `GATEBOLT_PROFILE` env var
|
|
38
|
-
3.
|
|
41
|
+
3. the `profile` field of a committable `.gatebolt` file (see `gatebolt link`)
|
|
39
42
|
4. the sole profile, when there is only one
|
|
40
43
|
5. the `default` profile
|
|
41
44
|
|
|
42
|
-
An individual setting can still be overridden ad-hoc: a flag (`--key` / `--url`
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
An individual setting can still be overridden ad-hoc: a flag (`--key` / `--url`) or the
|
|
46
|
+
`GATEBOLT_KEY` / `GATEBOLT_URL` env var wins over the profile, so a fully-env setup needs no profile
|
|
47
|
+
file at all. Point the config elsewhere (CI, tests) with `GATEBOLT_CONFIG`.
|
|
48
|
+
|
|
49
|
+
## `gatebolt init`
|
|
50
|
+
|
|
51
|
+
Sets up GateBolt in a repository in one command. Run it once from the repo root, after saving a
|
|
52
|
+
key (see [Configure](#configure)):
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
gatebolt init
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
It **links** the repository (writing a committable `.gatebolt`, exactly like `gatebolt link`),
|
|
59
|
+
**wires** your agent's declare → observe hooks — for Claude Code, appended to
|
|
60
|
+
`.claude/settings.local.json` — and installs a **pre-push backstop** at `.git/hooks/pre-push`
|
|
61
|
+
that reconciles an open declaration before a push. It reuses a saved profile (it never signs you
|
|
62
|
+
in), and is safe to re-run — it never duplicates hooks.
|
|
63
|
+
|
|
64
|
+
| Flag | Description |
|
|
65
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------- |
|
|
66
|
+
| `--enforce <mode>` | `strict` blocks edits until you declare; `lenient` keeps it advisory; omit to leave a repo's current mode unchanged |
|
|
67
|
+
| `--no-hooks` | Only link the repository; don't wire the agent hooks |
|
|
68
|
+
| `--no-pre-push` | Don't install the pre-push backstop |
|
|
69
|
+
| `--force` | Replace an existing, non-GateBolt pre-push hook |
|
|
70
|
+
| `--name <name>` | Name the repository (when there is no `origin` remote to derive from) |
|
|
71
|
+
|
|
72
|
+
`--profile` selects a saved profile. Unlike the other commands, `init` takes no `--key` / `--url`:
|
|
73
|
+
the hooks it wires authenticate later, so they need a durable saved profile, not one-off flags
|
|
74
|
+
(`GATEBOLT_KEY` / `GATEBOLT_URL` env still apply).
|
|
75
|
+
|
|
76
|
+
The hooks call `gatebolt hook <guard|post-turn|pre-push>` — internal subcommands you don't run by
|
|
77
|
+
hand: `guard` blocks an edit until a declaration exists (strict mode), `post-turn` reconciles the
|
|
78
|
+
diff when a turn ends, and `pre-push` reconciles before a push.
|
|
79
|
+
|
|
80
|
+
## `gatebolt link`
|
|
81
|
+
|
|
82
|
+
Records **which repository** this checkout is, so every clone, worktree and CI job records
|
|
83
|
+
against the same one. Run it once per repo, from the repo root, and commit the file it writes:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
gatebolt link
|
|
87
|
+
git add .gatebolt && git commit -m "record the GateBolt repository"
|
|
88
|
+
```
|
|
46
89
|
|
|
47
|
-
|
|
48
|
-
|
|
90
|
+
It derives the repository's name from the `origin` remote (or pass `--name` when there is no
|
|
91
|
+
remote), asks the server to find-or-create it, and writes the repository id to a committable
|
|
92
|
+
`.gatebolt` file. `declare` and `observe` read that id; when `.gatebolt` is absent, `declare`
|
|
93
|
+
links automatically from the remote. The file is authoritative — a malformed one is a hard
|
|
94
|
+
error telling you to re-run `gatebolt link`, never a silent guess.
|
|
49
95
|
|
|
50
96
|
## `gatebolt declare`
|
|
51
97
|
|
|
@@ -60,7 +106,7 @@ to `<git-dir>/gatebolt/session.json`. The repository must have at least one comm
|
|
|
60
106
|
| `--name <name>` | yes | Agent name |
|
|
61
107
|
| `--model <model>` | yes | Model id |
|
|
62
108
|
| `--file <path>` | yes\* | Intended file (repeatable, one per path) |
|
|
63
|
-
| `--version <v>`
|
|
109
|
+
| `--agent-version <v>` | no | Agent/tool version (`--version` prints the CLI's own version) |
|
|
64
110
|
| `--launched-by <email>` | no | Who launched the agent |
|
|
65
111
|
| `--profile <name>` | no | Profile to resolve `key`/`url`/`repo` from |
|
|
66
112
|
|
|
@@ -101,7 +147,10 @@ gatebolt observe
|
|
|
101
147
|
untracked files, so uncommitted edits and newly created files are both captured. It accepts a
|
|
102
148
|
base that is an ancestor of HEAD or one HEAD amended/squashed in place on the same branch, but
|
|
103
149
|
**refuses if the working tree has diverged from the recorded base** — a branch switch or reset
|
|
104
|
-
would fold in unrelated work, so re-run `declare`. It
|
|
150
|
+
would fold in unrelated work, so re-run `declare`. It never blocks on ambiguity, though: git
|
|
151
|
+
records nothing that separates the agent's commits from a colleague's, so when commits have
|
|
152
|
+
landed since `declare` — or a merge is in progress — `observe` **says so in a `Note` and
|
|
153
|
+
reconciles anyway**, leaving you to judge whose work the diff contains. It is **advisory** — it exits `0` regardless of the
|
|
105
154
|
drift level (errors exit non-zero) — and on success it clears the session. A diff touching more
|
|
106
155
|
than 2000 files is rejected; narrow the change.
|
|
107
156
|
|
|
@@ -110,5 +159,5 @@ than 2000 files is rejected; narrow the change.
|
|
|
110
159
|
- **One session per worktree.** Session state lives in `<git-dir>/gatebolt/`, which is
|
|
111
160
|
per-worktree. Run one agent session per working tree; for parallel agents, use separate git
|
|
112
161
|
worktrees (a shared tree can't attribute one diff to two sessions).
|
|
113
|
-
- **Claude Code.**
|
|
114
|
-
|
|
162
|
+
- **Claude Code.** `gatebolt init` wires the declare → observe loop to run automatically inside a
|
|
163
|
+
Claude Code session — see [`gatebolt init`](#gatebolt-init).
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isRecord } from "../json.js";
|
|
4
|
+
const CLAUDE_DIR = ".claude";
|
|
5
|
+
const SETTINGS_FILE = "settings.local.json";
|
|
6
|
+
const GITIGNORE_FILE = ".gitignore";
|
|
7
|
+
const GITIGNORE_ENTRY = `${CLAUDE_DIR}/${SETTINGS_FILE}`;
|
|
8
|
+
const PRE_TOOL_USE = "PreToolUse";
|
|
9
|
+
const STOP = "Stop";
|
|
10
|
+
const EDIT_TOOLS_MATCHER = "Edit|Write|NotebookEdit";
|
|
11
|
+
const COMMAND_TYPE = "command";
|
|
12
|
+
const GUARD_SUB = "guard";
|
|
13
|
+
const POST_TURN_SUB = "post-turn";
|
|
14
|
+
const ENFORCE_STRICT = "strict";
|
|
15
|
+
const ENFORCE_LENIENT = "lenient";
|
|
16
|
+
export function mergeSettings(existing, opts) {
|
|
17
|
+
const settings = structuredClone(existing);
|
|
18
|
+
const hooks = plainObject(settings.hooks, "hooks");
|
|
19
|
+
settings.hooks = hooks;
|
|
20
|
+
let changed = false;
|
|
21
|
+
const preToolUse = arrayField(hooks[PRE_TOOL_USE], `hooks.${PRE_TOOL_USE}`);
|
|
22
|
+
if (ensureHookCommand(preToolUse, `hook ${GUARD_SUB}`, opts.command, EDIT_TOOLS_MATCHER)) {
|
|
23
|
+
changed = true;
|
|
24
|
+
}
|
|
25
|
+
hooks[PRE_TOOL_USE] = preToolUse;
|
|
26
|
+
const stop = arrayField(hooks[STOP], `hooks.${STOP}`);
|
|
27
|
+
if (ensureHookCommand(stop, `hook ${POST_TURN_SUB}`, opts.command)) {
|
|
28
|
+
changed = true;
|
|
29
|
+
}
|
|
30
|
+
hooks[STOP] = stop;
|
|
31
|
+
if (opts.enforce === ENFORCE_STRICT) {
|
|
32
|
+
const env = plainObject(settings.env, "env");
|
|
33
|
+
settings.env = env;
|
|
34
|
+
if (env.GATEBOLT_ENFORCE !== ENFORCE_STRICT) {
|
|
35
|
+
env.GATEBOLT_ENFORCE = ENFORCE_STRICT;
|
|
36
|
+
changed = true;
|
|
37
|
+
}
|
|
38
|
+
if (env.GATEBOLT_CLI !== opts.command) {
|
|
39
|
+
env.GATEBOLT_CLI = opts.command;
|
|
40
|
+
changed = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (opts.enforce === ENFORCE_LENIENT && settings.env !== undefined) {
|
|
44
|
+
const env = plainObject(settings.env, "env");
|
|
45
|
+
if ("GATEBOLT_ENFORCE" in env) {
|
|
46
|
+
delete env.GATEBOLT_ENFORCE;
|
|
47
|
+
changed = true;
|
|
48
|
+
}
|
|
49
|
+
if ("GATEBOLT_CLI" in env) {
|
|
50
|
+
delete env.GATEBOLT_CLI;
|
|
51
|
+
changed = true;
|
|
52
|
+
}
|
|
53
|
+
if (Object.keys(env).length === 0) {
|
|
54
|
+
delete settings.env;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
settings.env = env;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { settings, changed };
|
|
61
|
+
}
|
|
62
|
+
export function withGitignoreEntry(existing, entry) {
|
|
63
|
+
const present = existing.split("\n").some((line) => line.trim() === entry);
|
|
64
|
+
if (present)
|
|
65
|
+
return null;
|
|
66
|
+
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
67
|
+
return `${existing}${prefix}${entry}\n`;
|
|
68
|
+
}
|
|
69
|
+
function plainObject(value, label) {
|
|
70
|
+
if (value === undefined)
|
|
71
|
+
return {};
|
|
72
|
+
if (isRecord(value) && !Array.isArray(value))
|
|
73
|
+
return value;
|
|
74
|
+
throw new Error(`${SETTINGS_FILE}: "${label}" must be a JSON object.`);
|
|
75
|
+
}
|
|
76
|
+
function arrayField(value, label) {
|
|
77
|
+
if (value === undefined)
|
|
78
|
+
return [];
|
|
79
|
+
if (Array.isArray(value))
|
|
80
|
+
return value;
|
|
81
|
+
throw new Error(`${SETTINGS_FILE}: "${label}" must be a JSON array.`);
|
|
82
|
+
}
|
|
83
|
+
// Update our command in place when it drifts, not just add-when-absent: a
|
|
84
|
+
// re-run of `init` that left a stale command would silently disable the guard.
|
|
85
|
+
function ensureHookCommand(entries, suffix, command, matcher) {
|
|
86
|
+
const desired = `${command} ${suffix}`;
|
|
87
|
+
const existing = findGateboltHook(entries, suffix);
|
|
88
|
+
if (existing) {
|
|
89
|
+
if (existing.command === desired)
|
|
90
|
+
return false;
|
|
91
|
+
existing.command = desired;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
const hook = { type: COMMAND_TYPE, command: desired };
|
|
95
|
+
entries.push(matcher === undefined ? { hooks: [hook] } : { matcher, hooks: [hook] });
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
function findGateboltHook(entries, suffix) {
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (!isRecord(entry) || !Array.isArray(entry.hooks))
|
|
101
|
+
continue;
|
|
102
|
+
for (const hook of entry.hooks) {
|
|
103
|
+
if (isRecord(hook) &&
|
|
104
|
+
typeof hook.command === "string" &&
|
|
105
|
+
hook.command.trim().endsWith(suffix)) {
|
|
106
|
+
return hook;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
async function wireClaudeCode(root, opts) {
|
|
113
|
+
const settingsPath = join(root, CLAUDE_DIR, SETTINGS_FILE);
|
|
114
|
+
const { settings, changed } = mergeSettings(await readSettings(settingsPath), opts);
|
|
115
|
+
if (changed) {
|
|
116
|
+
await mkdir(join(root, CLAUDE_DIR), { recursive: true });
|
|
117
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
118
|
+
}
|
|
119
|
+
const gitignoreChanged = await ensureGitignore(root);
|
|
120
|
+
return {
|
|
121
|
+
settingsPath,
|
|
122
|
+
settingsChanged: changed,
|
|
123
|
+
gitignoreChanged,
|
|
124
|
+
enforce: effectiveEnforce(settings),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function effectiveEnforce(settings) {
|
|
128
|
+
const env = settings.env;
|
|
129
|
+
if (isRecord(env) && env.GATEBOLT_ENFORCE === ENFORCE_STRICT) {
|
|
130
|
+
return ENFORCE_STRICT;
|
|
131
|
+
}
|
|
132
|
+
return ENFORCE_LENIENT;
|
|
133
|
+
}
|
|
134
|
+
async function readSettings(path) {
|
|
135
|
+
let raw;
|
|
136
|
+
try {
|
|
137
|
+
raw = await readFile(path, "utf8");
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (isNotFound(error))
|
|
141
|
+
return {};
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
const data = safeParse(raw);
|
|
145
|
+
if (!isRecord(data) || Array.isArray(data)) {
|
|
146
|
+
throw new Error(`${path} is not a JSON object. Fix or remove it, then re-run \`gatebolt init\`.`);
|
|
147
|
+
}
|
|
148
|
+
return data;
|
|
149
|
+
}
|
|
150
|
+
async function ensureGitignore(root) {
|
|
151
|
+
const path = join(root, GITIGNORE_FILE);
|
|
152
|
+
const updated = withGitignoreEntry(await readTextOrEmpty(path), GITIGNORE_ENTRY);
|
|
153
|
+
if (updated === null)
|
|
154
|
+
return false;
|
|
155
|
+
await writeFile(path, updated, "utf8");
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
async function readTextOrEmpty(path) {
|
|
159
|
+
try {
|
|
160
|
+
return await readFile(path, "utf8");
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (isNotFound(error))
|
|
164
|
+
return "";
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function safeParse(raw) {
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(raw);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function isNotFound(error) {
|
|
177
|
+
return isRecord(error) && error.code === "ENOENT";
|
|
178
|
+
}
|
|
179
|
+
export const claudeCodeAdapter = {
|
|
180
|
+
id: "claude-code",
|
|
181
|
+
displayName: "Claude Code",
|
|
182
|
+
wire: wireClaudeCode,
|
|
183
|
+
};
|
|
@@ -1,43 +1,60 @@
|
|
|
1
1
|
import { stdin, stdout } from "node:process";
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { Writable } from "node:stream";
|
|
3
4
|
import { parseArgs } from "node:util";
|
|
4
|
-
import { slugifyRepo } from "../config.js";
|
|
5
5
|
import { readConfigForWrite, writeConfig } from "../profiles.js";
|
|
6
|
+
const DEFAULT_PROFILE = "default";
|
|
6
7
|
export async function runConfigure(argv) {
|
|
7
8
|
const { values } = parseArgs({
|
|
8
9
|
args: argv,
|
|
9
10
|
options: {
|
|
10
11
|
profile: { type: "string" },
|
|
11
12
|
url: { type: "string" },
|
|
12
|
-
repo: { type: "string" },
|
|
13
13
|
key: { type: "string" },
|
|
14
14
|
},
|
|
15
15
|
});
|
|
16
|
-
const repo = slugifyRepo(requireFlag(values.repo, "--repo"));
|
|
17
16
|
const url = values.url?.replace(/\/+$/, "");
|
|
18
|
-
const name = values.profile ??
|
|
17
|
+
const name = values.profile ?? DEFAULT_PROFILE;
|
|
19
18
|
const key = (values.key ?? (await promptForKey())).trim();
|
|
20
19
|
if (!key) {
|
|
21
20
|
throw new Error("No API key provided.");
|
|
22
21
|
}
|
|
23
22
|
const config = await readConfigForWrite();
|
|
24
|
-
config.profiles[name]
|
|
23
|
+
const existing = config.profiles[name];
|
|
24
|
+
if (!values.profile && existing && existing.key !== key) {
|
|
25
|
+
throw new Error(`Profile "${name}" already holds a different key. Pass --profile <name> to save ` +
|
|
26
|
+
`this one alongside it, or --profile ${name} to replace it.`);
|
|
27
|
+
}
|
|
28
|
+
config.profiles[name] = url ? { url, key } : { key };
|
|
25
29
|
config.default ??= name;
|
|
26
30
|
const path = await writeConfig(config);
|
|
27
31
|
process.stdout.write(`Saved profile "${name}" to ${path}.\n`);
|
|
28
32
|
}
|
|
29
|
-
function requireFlag(value, flag) {
|
|
30
|
-
if (!value) {
|
|
31
|
-
throw new Error(`Missing required option ${flag}.`);
|
|
32
|
-
}
|
|
33
|
-
return value;
|
|
34
|
-
}
|
|
35
33
|
async function promptForKey() {
|
|
36
|
-
|
|
34
|
+
let muted = false;
|
|
35
|
+
const output = new Writable({
|
|
36
|
+
write(chunk, encoding, done) {
|
|
37
|
+
if (!muted)
|
|
38
|
+
stdout.write(chunk, encoding);
|
|
39
|
+
done();
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
// `terminal` moves echoing from the tty to readline, which is what makes it
|
|
43
|
+
// suppressible; muting the stream alone leaves the tty echoing in canonical
|
|
44
|
+
// mode. Off a tty nothing echoes anyway, and leaving it off keeps readline's
|
|
45
|
+
// cursor escapes out of piped output.
|
|
46
|
+
const rl = createInterface({
|
|
47
|
+
input: stdin,
|
|
48
|
+
output,
|
|
49
|
+
terminal: stdin.isTTY === true,
|
|
50
|
+
});
|
|
37
51
|
try {
|
|
38
|
-
|
|
52
|
+
const answer = rl.question("API key: ");
|
|
53
|
+
muted = true;
|
|
54
|
+
return await answer;
|
|
39
55
|
}
|
|
40
56
|
finally {
|
|
41
57
|
rl.close();
|
|
58
|
+
stdout.write("\n");
|
|
42
59
|
}
|
|
43
60
|
}
|
package/dist/commands/declare.js
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { relative, resolve, sep } from "node:path";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
-
import { resolveConnection
|
|
4
|
+
import { resolveConnection } from "../config.js";
|
|
5
5
|
import { AGENT_VENDORS, DECLARED_FILES_MAX, isAgentVendor, TASK_MAX_LENGTH, } from "../contracts.js";
|
|
6
6
|
import { currentBranch, currentHead, hasCommits, repoContext } from "../git.js";
|
|
7
7
|
import { isRecord } from "../json.js";
|
|
8
|
-
import { profileLoader } from "../profiles.js";
|
|
9
|
-
import { readPendingDeclare, writePendingDeclare, writeSession, } from "../session.js";
|
|
8
|
+
import { profileLoader, readRepoLink } from "../profiles.js";
|
|
9
|
+
import { clearPendingDeclare, clearSession, readPendingDeclare, readSession, writePendingDeclare, writeSession, } from "../session.js";
|
|
10
10
|
import { postChange } from "../transport.js";
|
|
11
|
+
import { deriveIdentity, linkRepository } from "./link.js";
|
|
12
|
+
const NO_REMOTE = `GateBolt cannot derive this repository's identity from its \`origin\` remote.
|
|
13
|
+
|
|
14
|
+
Ask a human to run this once, from the repo root:
|
|
15
|
+
|
|
16
|
+
gatebolt link --name <a-name-for-this-repo>
|
|
17
|
+
|
|
18
|
+
It writes a .gatebolt file holding the repository id. Commit that file and every
|
|
19
|
+
clone, worktree and CI job will resolve the same repository.
|
|
20
|
+
|
|
21
|
+
Do not guess a name. A wrong name creates a second repository and splits this
|
|
22
|
+
repo's audit trail in two.`;
|
|
23
|
+
const ID_PREFIX_LEN = 8;
|
|
11
24
|
export async function runDeclare(argv) {
|
|
12
25
|
const { values } = parseArgs({
|
|
13
26
|
args: argv,
|
|
@@ -15,16 +28,19 @@ export async function runDeclare(argv) {
|
|
|
15
28
|
key: { type: "string" },
|
|
16
29
|
url: { type: "string" },
|
|
17
30
|
profile: { type: "string" },
|
|
18
|
-
repo: { type: "string" },
|
|
19
31
|
task: { type: "string" },
|
|
20
32
|
vendor: { type: "string" },
|
|
21
33
|
name: { type: "string" },
|
|
22
34
|
model: { type: "string" },
|
|
23
|
-
version: { type: "string" },
|
|
35
|
+
"agent-version": { type: "string" },
|
|
24
36
|
"launched-by": { type: "string" },
|
|
25
37
|
file: { type: "string", multiple: true },
|
|
38
|
+
abort: { type: "boolean" },
|
|
26
39
|
},
|
|
27
40
|
});
|
|
41
|
+
if (values.abort) {
|
|
42
|
+
return abortDeclaration();
|
|
43
|
+
}
|
|
28
44
|
const rawFiles = collectFiles(values.file);
|
|
29
45
|
if (rawFiles.length === 0) {
|
|
30
46
|
throw new Error("Declare at least one file with --file.");
|
|
@@ -38,8 +54,9 @@ export async function runDeclare(argv) {
|
|
|
38
54
|
name: requireFlag(values.name, "--name"),
|
|
39
55
|
model: requireFlag(values.model, "--model"),
|
|
40
56
|
};
|
|
41
|
-
|
|
42
|
-
|
|
57
|
+
const agentVersion = values["agent-version"];
|
|
58
|
+
if (agentVersion)
|
|
59
|
+
agent.version = agentVersion;
|
|
43
60
|
const launchedBy = values["launched-by"];
|
|
44
61
|
if (launchedBy && !isValidEmail(launchedBy)) {
|
|
45
62
|
throw new Error(`Invalid --launched-by '${launchedBy}'. Expected an email address.`);
|
|
@@ -55,10 +72,10 @@ export async function runDeclare(argv) {
|
|
|
55
72
|
const { gitDir: dir, root } = repo;
|
|
56
73
|
const loadProfile = profileLoader({ name: values.profile, root });
|
|
57
74
|
const connection = await resolveConnection({ key: values.key, url: values.url }, loadProfile);
|
|
58
|
-
const repoSlug = await resolveRepoSlug({ repo: values.repo }, loadProfile);
|
|
59
75
|
if (!(await hasCommits())) {
|
|
60
76
|
throw new Error("This repository has no commits yet — make an initial commit before declaring.");
|
|
61
77
|
}
|
|
78
|
+
const repositoryId = await resolveRepositoryId(root, connection);
|
|
62
79
|
const baseSha = await currentHead();
|
|
63
80
|
const branch = await currentBranch();
|
|
64
81
|
const declaredFiles = dedupeByPath(rawFiles.map((raw) => ({ path: normalizeRepoPath(root, raw) })));
|
|
@@ -66,7 +83,7 @@ export async function runDeclare(argv) {
|
|
|
66
83
|
throw new Error(`Declaring ${declaredFiles.length} files, over the ${DECLARED_FILES_MAX} limit. Narrow the change.`);
|
|
67
84
|
}
|
|
68
85
|
const intentKey = buildIntentKey({
|
|
69
|
-
|
|
86
|
+
repositoryId,
|
|
70
87
|
task,
|
|
71
88
|
agent,
|
|
72
89
|
baseSha,
|
|
@@ -74,7 +91,7 @@ export async function runDeclare(argv) {
|
|
|
74
91
|
});
|
|
75
92
|
const correlationId = correlationIdFor(await readPendingDeclare(dir), intentKey);
|
|
76
93
|
const input = buildDeclareInput({
|
|
77
|
-
|
|
94
|
+
repositoryId,
|
|
78
95
|
task,
|
|
79
96
|
agent,
|
|
80
97
|
declaredFiles,
|
|
@@ -91,15 +108,40 @@ export async function runDeclare(argv) {
|
|
|
91
108
|
changeId: result.changeId,
|
|
92
109
|
baseSha,
|
|
93
110
|
branch,
|
|
111
|
+
profile: connection.profileName,
|
|
94
112
|
});
|
|
95
113
|
process.stdout.write(`Declared change ${result.changeId} (${declaredFiles.length} file(s)).\n`);
|
|
96
114
|
}
|
|
115
|
+
async function abortDeclaration() {
|
|
116
|
+
const repo = await repoContext();
|
|
117
|
+
if (!repo) {
|
|
118
|
+
throw new Error("Not inside a git repository.");
|
|
119
|
+
}
|
|
120
|
+
const session = await readSession(repo.gitDir);
|
|
121
|
+
if (!session) {
|
|
122
|
+
process.stdout.write("No open declaration to clear.\n");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await clearSession(repo.gitDir);
|
|
126
|
+
await clearPendingDeclare(repo.gitDir);
|
|
127
|
+
process.stdout.write(`Cleared the open declaration (change ${session.changeId.slice(0, ID_PREFIX_LEN)}…). Declare again to track new work.\n`);
|
|
128
|
+
}
|
|
129
|
+
async function resolveRepositoryId(root, connection) {
|
|
130
|
+
const link = await readRepoLink(root);
|
|
131
|
+
if (link)
|
|
132
|
+
return link.repositoryId;
|
|
133
|
+
const identity = await deriveIdentity();
|
|
134
|
+
if (!identity) {
|
|
135
|
+
throw new Error(NO_REMOTE);
|
|
136
|
+
}
|
|
137
|
+
return (await linkRepository(connection, root, identity)).repositoryId;
|
|
138
|
+
}
|
|
97
139
|
export function isDeclareResult(value) {
|
|
98
140
|
return isRecord(value) && typeof value.changeId === "string";
|
|
99
141
|
}
|
|
100
142
|
export function buildIntentKey(parts) {
|
|
101
143
|
return JSON.stringify({
|
|
102
|
-
|
|
144
|
+
repositoryId: parts.repositoryId,
|
|
103
145
|
task: parts.task,
|
|
104
146
|
agent: parts.agent,
|
|
105
147
|
baseSha: parts.baseSha,
|
|
@@ -108,7 +150,7 @@ export function buildIntentKey(parts) {
|
|
|
108
150
|
}
|
|
109
151
|
function buildDeclareInput(parts) {
|
|
110
152
|
const input = {
|
|
111
|
-
|
|
153
|
+
repositoryId: parts.repositoryId,
|
|
112
154
|
task: parts.task,
|
|
113
155
|
agent: parts.agent,
|
|
114
156
|
declaredFiles: parts.declaredFiles,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { formatError } from "../errors.js";
|
|
3
|
+
import { hasCommits, repoContext } from "../git.js";
|
|
4
|
+
import { readSession } from "../session.js";
|
|
5
|
+
import { RequestError } from "../transport.js";
|
|
6
|
+
import { runObserve } from "./observe.js";
|
|
7
|
+
const ENFORCE_STRICT = "strict";
|
|
8
|
+
const PRE_TOOL_USE = "PreToolUse";
|
|
9
|
+
const DENY_DECISION = "deny";
|
|
10
|
+
const DEFAULT_CLI = "gatebolt";
|
|
11
|
+
function declareInstructions(cli) {
|
|
12
|
+
return `GateBolt strict mode: declare the files you plan to touch before editing.
|
|
13
|
+
Run this in Bash first, listing EVERY file you plan to create or modify for this
|
|
14
|
+
task — specific repo-relative paths, not broad globs like src/** :
|
|
15
|
+
|
|
16
|
+
${cli} declare \\
|
|
17
|
+
--task "<one line describing the task>" \\
|
|
18
|
+
--vendor claude_code --name "Claude Code" --model <your-model-id> \\
|
|
19
|
+
--file <path> [--file <path> ...]
|
|
20
|
+
|
|
21
|
+
The GateBolt CLI is already configured for this repository. Once it
|
|
22
|
+
prints "Declared change …", retry the edit.`;
|
|
23
|
+
}
|
|
24
|
+
export async function runHook(argv) {
|
|
25
|
+
anchorToProjectDir();
|
|
26
|
+
drainStdin();
|
|
27
|
+
const sub = argv[0] ?? "";
|
|
28
|
+
switch (sub) {
|
|
29
|
+
case "guard":
|
|
30
|
+
return runGuard();
|
|
31
|
+
case "post-turn":
|
|
32
|
+
return runPostTurn();
|
|
33
|
+
case "pre-push":
|
|
34
|
+
return runPrePush();
|
|
35
|
+
default:
|
|
36
|
+
throw new Error(`Unknown hook '${sub}'. Expected one of: guard, post-turn, pre-push.`);
|
|
37
|
+
}
|
|
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))
|
|
50
|
+
process.chdir(dir);
|
|
51
|
+
}
|
|
52
|
+
async function hookRepoContext() {
|
|
53
|
+
try {
|
|
54
|
+
return await repoContext();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function runGuard() {
|
|
61
|
+
if (process.env.GATEBOLT_ENFORCE !== ENFORCE_STRICT)
|
|
62
|
+
return;
|
|
63
|
+
const repo = await hookRepoContext();
|
|
64
|
+
if (!repo)
|
|
65
|
+
return;
|
|
66
|
+
if (await readSession(repo.gitDir))
|
|
67
|
+
return;
|
|
68
|
+
const hasBaseCommit = await hasCommits();
|
|
69
|
+
if (!hasBaseCommit)
|
|
70
|
+
return;
|
|
71
|
+
const cli = process.env.GATEBOLT_CLI ?? DEFAULT_CLI;
|
|
72
|
+
process.stdout.write(denyPayload(declareInstructions(cli)));
|
|
73
|
+
}
|
|
74
|
+
async function runPostTurn() {
|
|
75
|
+
const repo = await hookRepoContext();
|
|
76
|
+
if (!repo)
|
|
77
|
+
return;
|
|
78
|
+
if (!(await readSession(repo.gitDir)))
|
|
79
|
+
return;
|
|
80
|
+
try {
|
|
81
|
+
await runObserve([]);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
process.stderr.write(`${formatError(error)}\n`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Backstop for the interrupt case the Stop hook misses: an open, un-observed
|
|
88
|
+
// declaration.
|
|
89
|
+
async function runPrePush() {
|
|
90
|
+
const repo = await hookRepoContext();
|
|
91
|
+
if (!repo)
|
|
92
|
+
return;
|
|
93
|
+
if (!(await readSession(repo.gitDir)))
|
|
94
|
+
return;
|
|
95
|
+
process.stderr.write("GateBolt: an un-observed declaration is still open; reconciling before push…\n");
|
|
96
|
+
try {
|
|
97
|
+
await runObserve([]);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (isUnreachable(error)) {
|
|
101
|
+
process.stderr.write("GateBolt: couldn't reach the server to reconcile; allowing the push.\n" +
|
|
102
|
+
`${formatError(error)}\n` +
|
|
103
|
+
"The declaration stays open and will reconcile on your next `gatebolt observe`.\n");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
process.stderr.write("GateBolt: observe failed — refusing to push unreconciled work.\n" +
|
|
107
|
+
`${formatError(error)}\n` +
|
|
108
|
+
"Resolve the error above (or run `gatebolt observe` yourself), then push again.\n" +
|
|
109
|
+
"If this declaration can no longer be reconciled, run `gatebolt declare --abort` to clear it.\n");
|
|
110
|
+
process.exitCode = 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function isUnreachable(error) {
|
|
114
|
+
return (error instanceof RequestError &&
|
|
115
|
+
(error.code === "NETWORK" || error.code === "TIMEOUT"));
|
|
116
|
+
}
|
|
117
|
+
// Claude Code's PreToolUse deny contract. Emitting this JSON on stdout with a
|
|
118
|
+
// zero exit holds the block even under --dangerously-skip-permissions, where an
|
|
119
|
+
// exit-2 fallback would not.
|
|
120
|
+
export function denyPayload(reason) {
|
|
121
|
+
return (JSON.stringify({
|
|
122
|
+
hookSpecificOutput: {
|
|
123
|
+
hookEventName: PRE_TOOL_USE,
|
|
124
|
+
permissionDecision: DENY_DECISION,
|
|
125
|
+
permissionDecisionReason: reason,
|
|
126
|
+
},
|
|
127
|
+
}) + "\n");
|
|
128
|
+
}
|