@gatebolt/cli 0.2.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 +114 -0
- package/dist/commands/configure.js +43 -0
- package/dist/commands/declare.js +165 -0
- package/dist/commands/observe.js +100 -0
- package/dist/config.js +32 -0
- package/dist/contracts.js +14 -0
- package/dist/diff.js +98 -0
- package/dist/git.js +120 -0
- package/dist/index.js +62 -0
- package/dist/json.js +3 -0
- package/dist/profiles.js +128 -0
- package/dist/session.js +50 -0
- package/dist/transport.js +72 -0
- package/package.json +45 -0
- package/recipes/claude-code/README.md +64 -0
- package/recipes/claude-code/hooks/gatebolt-guard.sh +48 -0
- package/recipes/claude-code/hooks/gatebolt-observe.sh +24 -0
- package/recipes/claude-code/hooks/pre-push +23 -0
- package/recipes/claude-code/settings.json +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# @gatebolt/cli
|
|
2
|
+
|
|
3
|
+
The GateBolt agent client. An AI agent **declares** the files it intends to touch before it
|
|
4
|
+
edits, then **observes** the actual diff when work lands; GateBolt reconciles the two and
|
|
5
|
+
scores the drift. This CLI is a thin, dependency-light wrapper (Node `fetch` + `git` via
|
|
6
|
+
`child_process`, zero runtime dependencies) over the `change.declare` / `change.observe`
|
|
7
|
+
ingestion API.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npm i -g @gatebolt/cli # global `gatebolt` binary
|
|
13
|
+
# …or run on demand:
|
|
14
|
+
npx @gatebolt/cli declare …
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Node ≥ 20 and `git` on your `PATH`.
|
|
18
|
+
|
|
19
|
+
## Configure
|
|
20
|
+
|
|
21
|
+
Every command needs three settings — an API **key**, the GateBolt **URL**, and the **repo
|
|
22
|
+
slug** bound to that key. Store them once as a named **profile** in `~/.gatebolt/config.json`
|
|
23
|
+
(written owner-only, `0600`):
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
gatebolt configure --profile payments-api \
|
|
27
|
+
--url https://your-gatebolt.app --repo payments-api
|
|
28
|
+
# paste the API key when prompted (or pass --key for scripting)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The `--repo` value is normalized to a slug (lowercase, hyphen-separated), so
|
|
32
|
+
`Payments_API` is stored and reported as `payments-api`.
|
|
33
|
+
|
|
34
|
+
Add a profile per repo. Inside a repo, the CLI picks the active profile in this order:
|
|
35
|
+
|
|
36
|
+
1. `--profile <name>` flag
|
|
37
|
+
2. `GATEBOLT_PROFILE` env var
|
|
38
|
+
3. a `.gatebolt` file at the repo root holding a profile name — safe to commit (no secret)
|
|
39
|
+
4. the sole profile, when there is only one
|
|
40
|
+
5. the `default` profile
|
|
41
|
+
|
|
42
|
+
An individual setting can still be overridden ad-hoc: a flag (`--key` / `--url` / `--repo`)
|
|
43
|
+
or the `GBOLT_KEY` / `GBOLT_URL` / `GBOLT_REPO` env var wins over the profile, so a
|
|
44
|
+
fully-env setup needs no profile file at all. Point the config elsewhere (CI, tests) with
|
|
45
|
+
`GATEBOLT_CONFIG`.
|
|
46
|
+
|
|
47
|
+
> **Rate limit.** A new key allows 10 requests/day (~5 changes) today — grab a fresh key
|
|
48
|
+
> while iterating, and a `401` means you hit the daily limit.
|
|
49
|
+
|
|
50
|
+
## `gatebolt declare`
|
|
51
|
+
|
|
52
|
+
Records the agent's intended files **before** editing. Snapshots the current commit
|
|
53
|
+
(`git rev-parse HEAD`) as the base plus the current branch, and writes `{ changeId, baseSha }`
|
|
54
|
+
to `<git-dir>/gatebolt/session.json`. The repository must have at least one commit.
|
|
55
|
+
|
|
56
|
+
| Flag | Required | Description |
|
|
57
|
+
| ----------------------- | -------- | ----------------------------------------------------------------------- |
|
|
58
|
+
| `--task <text>` | yes | What the agent is doing |
|
|
59
|
+
| `--vendor <vendor>` | yes | `claude_code` \| `cursor` \| `copilot` \| `codex` \| `devin` \| `other` |
|
|
60
|
+
| `--name <name>` | yes | Agent name |
|
|
61
|
+
| `--model <model>` | yes | Model id |
|
|
62
|
+
| `--file <path>` | yes\* | Intended file (repeatable, one per path) |
|
|
63
|
+
| `--version <v>` | no | Agent/tool version |
|
|
64
|
+
| `--launched-by <email>` | no | Who launched the agent |
|
|
65
|
+
| `--profile <name>` | no | Profile to resolve `key`/`url`/`repo` from |
|
|
66
|
+
|
|
67
|
+
\* At least one `--file` is required. Repeat it per path (`--file a --file b`)
|
|
68
|
+
rather than comma-joining, so paths that contain commas stay intact.
|
|
69
|
+
|
|
70
|
+
Declared paths must be **repo-root-relative** and match git's output exactly, or be a
|
|
71
|
+
directory glob like `src/auth/**` — a bare `src/auth` will **not** match the files inside it.
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
gatebolt declare \
|
|
75
|
+
--task "Refresh session on token expiry" \
|
|
76
|
+
--vendor claude_code --name "Claude Code" --model opus-4 \
|
|
77
|
+
--file src/auth/session.ts
|
|
78
|
+
# → Declared change 1df4210c-… (1 file(s)).
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## `gatebolt observe`
|
|
82
|
+
|
|
83
|
+
Derives the actual diff from git when work lands, posts it, and prints the drift verdict. It
|
|
84
|
+
takes no file arguments — it reads the session written by `declare`.
|
|
85
|
+
|
|
86
|
+
| Flag | Required | Description |
|
|
87
|
+
| -------------------- | -------- | -------------------------------------------------- |
|
|
88
|
+
| `--change-id <uuid>` | no | Change to observe (defaults to the active session) |
|
|
89
|
+
| `--base <sha>` | no | Base commit to diff against (defaults to session) |
|
|
90
|
+
| `--profile <name>` | no | Profile to resolve `key`/`url` from |
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
gatebolt observe
|
|
94
|
+
# Change 1df4210c-…
|
|
95
|
+
# Drift critical (100)
|
|
96
|
+
# Files 1 matched, 1 unexpected, 0 missing
|
|
97
|
+
# Touched 1 undeclared secrets file: .env
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`observe` diffs the working tree against the recorded base (`git diff <base>`) and sweeps in
|
|
101
|
+
untracked files, so uncommitted edits and newly created files are both captured. It accepts a
|
|
102
|
+
base that is an ancestor of HEAD or one HEAD amended/squashed in place on the same branch, but
|
|
103
|
+
**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 is **advisory** — it exits `0` regardless of the
|
|
105
|
+
drift level (errors exit non-zero) — and on success it clears the session. A diff touching more
|
|
106
|
+
than 2000 files is rejected; narrow the change.
|
|
107
|
+
|
|
108
|
+
## Notes
|
|
109
|
+
|
|
110
|
+
- **One session per worktree.** Session state lives in `<git-dir>/gatebolt/`, which is
|
|
111
|
+
per-worktree. Run one agent session per working tree; for parallel agents, use separate git
|
|
112
|
+
worktrees (a shared tree can't attribute one diff to two sessions).
|
|
113
|
+
- **Claude Code.** To run the declare → observe loop automatically inside a Claude Code
|
|
114
|
+
session, use the hook recipe in [`recipes/claude-code/`](./recipes/claude-code/).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { stdin, stdout } from "node:process";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { slugifyRepo } from "../config.js";
|
|
5
|
+
import { readConfigForWrite, writeConfig } from "../profiles.js";
|
|
6
|
+
export async function runConfigure(argv) {
|
|
7
|
+
const { values } = parseArgs({
|
|
8
|
+
args: argv,
|
|
9
|
+
options: {
|
|
10
|
+
profile: { type: "string" },
|
|
11
|
+
url: { type: "string" },
|
|
12
|
+
repo: { type: "string" },
|
|
13
|
+
key: { type: "string" },
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
const repo = slugifyRepo(requireFlag(values.repo, "--repo"));
|
|
17
|
+
const url = values.url?.replace(/\/+$/, "");
|
|
18
|
+
const name = values.profile ?? repo;
|
|
19
|
+
const key = (values.key ?? (await promptForKey())).trim();
|
|
20
|
+
if (!key) {
|
|
21
|
+
throw new Error("No API key provided.");
|
|
22
|
+
}
|
|
23
|
+
const config = await readConfigForWrite();
|
|
24
|
+
config.profiles[name] = url ? { url, repo, key } : { repo, key };
|
|
25
|
+
config.default ??= name;
|
|
26
|
+
const path = await writeConfig(config);
|
|
27
|
+
process.stdout.write(`Saved profile "${name}" to ${path}.\n`);
|
|
28
|
+
}
|
|
29
|
+
function requireFlag(value, flag) {
|
|
30
|
+
if (!value) {
|
|
31
|
+
throw new Error(`Missing required option ${flag}.`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
async function promptForKey() {
|
|
36
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
37
|
+
try {
|
|
38
|
+
return await rl.question("API key: ");
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
rl.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { relative, resolve, sep } from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { resolveConnection, resolveRepoSlug } from "../config.js";
|
|
5
|
+
import { AGENT_VENDORS, DECLARED_FILES_MAX, isAgentVendor, TASK_MAX_LENGTH, } from "../contracts.js";
|
|
6
|
+
import { currentBranch, currentHead, hasCommits, repoContext } from "../git.js";
|
|
7
|
+
import { isRecord } from "../json.js";
|
|
8
|
+
import { profileLoader } from "../profiles.js";
|
|
9
|
+
import { readPendingDeclare, writePendingDeclare, writeSession, } from "../session.js";
|
|
10
|
+
import { postChange } from "../transport.js";
|
|
11
|
+
export async function runDeclare(argv) {
|
|
12
|
+
const { values } = parseArgs({
|
|
13
|
+
args: argv,
|
|
14
|
+
options: {
|
|
15
|
+
key: { type: "string" },
|
|
16
|
+
url: { type: "string" },
|
|
17
|
+
profile: { type: "string" },
|
|
18
|
+
repo: { type: "string" },
|
|
19
|
+
task: { type: "string" },
|
|
20
|
+
vendor: { type: "string" },
|
|
21
|
+
name: { type: "string" },
|
|
22
|
+
model: { type: "string" },
|
|
23
|
+
version: { type: "string" },
|
|
24
|
+
"launched-by": { type: "string" },
|
|
25
|
+
file: { type: "string", multiple: true },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const rawFiles = collectFiles(values.file);
|
|
29
|
+
if (rawFiles.length === 0) {
|
|
30
|
+
throw new Error("Declare at least one file with --file.");
|
|
31
|
+
}
|
|
32
|
+
const vendor = requireFlag(values.vendor, "--vendor");
|
|
33
|
+
if (!isAgentVendor(vendor)) {
|
|
34
|
+
throw new Error(`Invalid --vendor '${vendor}'. Expected one of: ${AGENT_VENDORS.join(", ")}.`);
|
|
35
|
+
}
|
|
36
|
+
const agent = {
|
|
37
|
+
vendor,
|
|
38
|
+
name: requireFlag(values.name, "--name"),
|
|
39
|
+
model: requireFlag(values.model, "--model"),
|
|
40
|
+
};
|
|
41
|
+
if (values.version)
|
|
42
|
+
agent.version = values.version;
|
|
43
|
+
const launchedBy = values["launched-by"];
|
|
44
|
+
if (launchedBy && !isValidEmail(launchedBy)) {
|
|
45
|
+
throw new Error(`Invalid --launched-by '${launchedBy}'. Expected an email address.`);
|
|
46
|
+
}
|
|
47
|
+
const task = requireFlag(values.task, "--task");
|
|
48
|
+
if (task.length > TASK_MAX_LENGTH) {
|
|
49
|
+
throw new Error(`--task is ${task.length} characters, over the ${TASK_MAX_LENGTH} limit.`);
|
|
50
|
+
}
|
|
51
|
+
const repo = await repoContext();
|
|
52
|
+
if (!repo) {
|
|
53
|
+
throw new Error("Not inside a git repository.");
|
|
54
|
+
}
|
|
55
|
+
const { gitDir: dir, root } = repo;
|
|
56
|
+
const loadProfile = profileLoader({ name: values.profile, root });
|
|
57
|
+
const connection = await resolveConnection({ key: values.key, url: values.url }, loadProfile);
|
|
58
|
+
const repoSlug = await resolveRepoSlug({ repo: values.repo }, loadProfile);
|
|
59
|
+
if (!(await hasCommits())) {
|
|
60
|
+
throw new Error("This repository has no commits yet — make an initial commit before declaring.");
|
|
61
|
+
}
|
|
62
|
+
const baseSha = await currentHead();
|
|
63
|
+
const branch = await currentBranch();
|
|
64
|
+
const declaredFiles = dedupeByPath(rawFiles.map((raw) => ({ path: normalizeRepoPath(root, raw) })));
|
|
65
|
+
if (declaredFiles.length > DECLARED_FILES_MAX) {
|
|
66
|
+
throw new Error(`Declaring ${declaredFiles.length} files, over the ${DECLARED_FILES_MAX} limit. Narrow the change.`);
|
|
67
|
+
}
|
|
68
|
+
const intentKey = buildIntentKey({
|
|
69
|
+
repoSlug,
|
|
70
|
+
task,
|
|
71
|
+
agent,
|
|
72
|
+
baseSha,
|
|
73
|
+
declaredFiles,
|
|
74
|
+
});
|
|
75
|
+
const correlationId = correlationIdFor(await readPendingDeclare(dir), intentKey);
|
|
76
|
+
const input = buildDeclareInput({
|
|
77
|
+
repoSlug,
|
|
78
|
+
task,
|
|
79
|
+
agent,
|
|
80
|
+
declaredFiles,
|
|
81
|
+
correlationId,
|
|
82
|
+
launchedBy,
|
|
83
|
+
branch,
|
|
84
|
+
});
|
|
85
|
+
await writePendingDeclare(dir, { correlationId, intentKey });
|
|
86
|
+
const result = await postChange(connection, "change.declare", input);
|
|
87
|
+
if (!isDeclareResult(result)) {
|
|
88
|
+
throw new Error("Unexpected response from server.");
|
|
89
|
+
}
|
|
90
|
+
await writeSession(dir, {
|
|
91
|
+
changeId: result.changeId,
|
|
92
|
+
baseSha,
|
|
93
|
+
branch,
|
|
94
|
+
});
|
|
95
|
+
process.stdout.write(`Declared change ${result.changeId} (${declaredFiles.length} file(s)).\n`);
|
|
96
|
+
}
|
|
97
|
+
export function isDeclareResult(value) {
|
|
98
|
+
return isRecord(value) && typeof value.changeId === "string";
|
|
99
|
+
}
|
|
100
|
+
export function buildIntentKey(parts) {
|
|
101
|
+
return JSON.stringify({
|
|
102
|
+
repoSlug: parts.repoSlug,
|
|
103
|
+
task: parts.task,
|
|
104
|
+
agent: parts.agent,
|
|
105
|
+
baseSha: parts.baseSha,
|
|
106
|
+
files: parts.declaredFiles.map((file) => file.path).sort(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function buildDeclareInput(parts) {
|
|
110
|
+
const input = {
|
|
111
|
+
repoSlug: parts.repoSlug,
|
|
112
|
+
task: parts.task,
|
|
113
|
+
agent: parts.agent,
|
|
114
|
+
declaredFiles: parts.declaredFiles,
|
|
115
|
+
correlationId: parts.correlationId,
|
|
116
|
+
};
|
|
117
|
+
if (parts.launchedBy)
|
|
118
|
+
input.launchedBy = parts.launchedBy;
|
|
119
|
+
if (parts.branch)
|
|
120
|
+
input.branch = parts.branch;
|
|
121
|
+
return input;
|
|
122
|
+
}
|
|
123
|
+
export function correlationIdFor(pending, intentKey) {
|
|
124
|
+
if (pending?.intentKey === intentKey) {
|
|
125
|
+
return pending.correlationId;
|
|
126
|
+
}
|
|
127
|
+
return randomUUID();
|
|
128
|
+
}
|
|
129
|
+
function collectFiles(file) {
|
|
130
|
+
return (file ?? [])
|
|
131
|
+
.map((value) => value.trim())
|
|
132
|
+
.filter((value) => value.length > 0);
|
|
133
|
+
}
|
|
134
|
+
export function dedupeByPath(files) {
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
return files.filter((file) => {
|
|
137
|
+
if (seen.has(file.path))
|
|
138
|
+
return false;
|
|
139
|
+
seen.add(file.path);
|
|
140
|
+
return true;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
export function normalizeRepoPath(root, rawPath) {
|
|
144
|
+
const repoRelative = relative(root, resolve(process.cwd(), rawPath))
|
|
145
|
+
.split(sep)
|
|
146
|
+
.join("/");
|
|
147
|
+
if (repoRelative === "" ||
|
|
148
|
+
repoRelative === ".." ||
|
|
149
|
+
repoRelative.startsWith("../")) {
|
|
150
|
+
throw new Error(`--file '${rawPath}' is outside the repository.`);
|
|
151
|
+
}
|
|
152
|
+
return repoRelative;
|
|
153
|
+
}
|
|
154
|
+
function requireFlag(value, flag) {
|
|
155
|
+
if (!value) {
|
|
156
|
+
throw new Error(`Missing required option ${flag}.`);
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
// Mirrors z.email() in @gatebolt/validators; keep in sync by hand (the CLI
|
|
161
|
+
// takes no dependency on the validators package).
|
|
162
|
+
const SERVER_EMAIL = /^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+.-]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9-]*\.)+[A-Za-z]{2,}$/;
|
|
163
|
+
export function isValidEmail(value) {
|
|
164
|
+
return SERVER_EMAIL.test(value);
|
|
165
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { resolveConnection } from "../config.js";
|
|
3
|
+
import { OBSERVED_FILES_MAX } from "../contracts.js";
|
|
4
|
+
import { observedFilesFromGit } from "../diff.js";
|
|
5
|
+
import { commitExists, diffNameStatus, diffNumstat, isReconcilableBase, listUntracked, repoContext, } from "../git.js";
|
|
6
|
+
import { isRecord } from "../json.js";
|
|
7
|
+
import { profileLoader } from "../profiles.js";
|
|
8
|
+
import { clearPendingDeclare, clearSession, readSession } from "../session.js";
|
|
9
|
+
import { postChange } from "../transport.js";
|
|
10
|
+
function resolveTarget(values, session) {
|
|
11
|
+
const provided = values["change-id"];
|
|
12
|
+
if (provided !== undefined && !isUuid(provided)) {
|
|
13
|
+
throw new Error(`Invalid --change-id '${provided}'. Expected a UUID.`);
|
|
14
|
+
}
|
|
15
|
+
const changeId = provided ?? session?.changeId;
|
|
16
|
+
const observingSessionChange = !values["change-id"] || values["change-id"] === session?.changeId;
|
|
17
|
+
const base = values.base ?? (observingSessionChange ? session?.baseSha : undefined);
|
|
18
|
+
if (!changeId) {
|
|
19
|
+
throw new Error("No active declaration. Run `gatebolt declare` first, or pass --change-id.");
|
|
20
|
+
}
|
|
21
|
+
if (!base) {
|
|
22
|
+
throw new Error("No base commit for this change. Pass --base (the commit it was declared against).");
|
|
23
|
+
}
|
|
24
|
+
return { changeId, base, observingSessionChange };
|
|
25
|
+
}
|
|
26
|
+
export async function runObserve(argv) {
|
|
27
|
+
const { values } = parseArgs({
|
|
28
|
+
args: argv,
|
|
29
|
+
options: {
|
|
30
|
+
key: { type: "string" },
|
|
31
|
+
url: { type: "string" },
|
|
32
|
+
profile: { type: "string" },
|
|
33
|
+
"change-id": { type: "string" },
|
|
34
|
+
base: { type: "string" },
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const repo = await repoContext();
|
|
38
|
+
if (!repo) {
|
|
39
|
+
throw new Error("Not inside a git repository.");
|
|
40
|
+
}
|
|
41
|
+
const dir = repo.gitDir;
|
|
42
|
+
const loadProfile = profileLoader({ name: values.profile, root: repo.root });
|
|
43
|
+
const connection = await resolveConnection({ key: values.key, url: values.url }, loadProfile);
|
|
44
|
+
const session = await readSession(dir);
|
|
45
|
+
const { changeId, base, observingSessionChange } = resolveTarget(values, session);
|
|
46
|
+
if (!(await commitExists(base))) {
|
|
47
|
+
throw new Error(`Base commit ${base} no longer exists. Re-run \`gatebolt declare\`.`);
|
|
48
|
+
}
|
|
49
|
+
const declaredBranch = observingSessionChange ? session?.branch : undefined;
|
|
50
|
+
if (!(await isReconcilableBase(base, declaredBranch))) {
|
|
51
|
+
throw new Error("The working tree has moved off the declared base (branch switch or divergent rebase?). Re-run `gatebolt declare`.");
|
|
52
|
+
}
|
|
53
|
+
const [nameStatus, numstat, untracked] = await Promise.all([
|
|
54
|
+
diffNameStatus(base),
|
|
55
|
+
diffNumstat(base),
|
|
56
|
+
listUntracked(),
|
|
57
|
+
]);
|
|
58
|
+
const observedFiles = observedFilesFromGit(nameStatus, numstat, untracked);
|
|
59
|
+
if (observedFiles.length > OBSERVED_FILES_MAX) {
|
|
60
|
+
throw new Error(`Diff touches ${observedFiles.length} files, over the ${OBSERVED_FILES_MAX} limit. Narrow the change.`);
|
|
61
|
+
}
|
|
62
|
+
const input = { changeId, observedFiles };
|
|
63
|
+
const result = await postChange(connection, "change.observe", input);
|
|
64
|
+
if (!isReconciliation(result)) {
|
|
65
|
+
throw new Error("Unexpected response from server.");
|
|
66
|
+
}
|
|
67
|
+
printVerdict(result);
|
|
68
|
+
if (observingSessionChange) {
|
|
69
|
+
await clearSession(dir);
|
|
70
|
+
await clearPendingDeclare(dir);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Mirrors z.uuid() in @gatebolt/validators; keep in sync by hand (the CLI
|
|
74
|
+
// takes no dependency on the validators package).
|
|
75
|
+
const SERVER_UUID = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/;
|
|
76
|
+
export function isUuid(value) {
|
|
77
|
+
return SERVER_UUID.test(value);
|
|
78
|
+
}
|
|
79
|
+
export function isReconciliation(value) {
|
|
80
|
+
if (!isRecord(value))
|
|
81
|
+
return false;
|
|
82
|
+
return (typeof value.changeId === "string" &&
|
|
83
|
+
typeof value.driftScore === "number" &&
|
|
84
|
+
typeof value.driftLevel === "string" &&
|
|
85
|
+
Array.isArray(value.matched) &&
|
|
86
|
+
Array.isArray(value.unexpected) &&
|
|
87
|
+
Array.isArray(value.missing) &&
|
|
88
|
+
Array.isArray(value.reasons));
|
|
89
|
+
}
|
|
90
|
+
function printVerdict(result) {
|
|
91
|
+
const lines = [
|
|
92
|
+
`Change ${result.changeId}`,
|
|
93
|
+
`Drift ${result.driftLevel} (${result.driftScore})`,
|
|
94
|
+
`Files ${result.matched.length} matched, ${result.unexpected.length} unexpected, ${result.missing.length} missing`,
|
|
95
|
+
];
|
|
96
|
+
for (const reason of result.reasons) {
|
|
97
|
+
lines.push(` ${reason}`);
|
|
98
|
+
}
|
|
99
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
100
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const DEFAULT_BASE_URL = "https://gatebolt.com";
|
|
2
|
+
export async function resolveConnection(flags, loadProfile) {
|
|
3
|
+
const key = flags.key ?? process.env.GBOLT_KEY;
|
|
4
|
+
const url = flags.url ?? process.env.GBOLT_URL;
|
|
5
|
+
const profile = key && url ? null : await loadProfile();
|
|
6
|
+
const resolvedKey = key ?? profile?.key;
|
|
7
|
+
const baseUrl = (url ?? profile?.url ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
8
|
+
if (!resolvedKey) {
|
|
9
|
+
throw new Error("Missing API key. Set GBOLT_KEY, pass --key, or run `gatebolt configure`.");
|
|
10
|
+
}
|
|
11
|
+
return { key: resolvedKey, baseUrl };
|
|
12
|
+
}
|
|
13
|
+
export async function resolveRepoSlug(flags, loadProfile) {
|
|
14
|
+
const raw = flags.repo ?? process.env.GBOLT_REPO ?? (await loadProfile())?.repo;
|
|
15
|
+
if (!raw) {
|
|
16
|
+
throw new Error("Missing repository slug. Set GBOLT_REPO, pass --repo, or run `gatebolt configure`.");
|
|
17
|
+
}
|
|
18
|
+
return slugifyRepo(raw);
|
|
19
|
+
}
|
|
20
|
+
const SLUG_MAX_LENGTH = 48;
|
|
21
|
+
export function slugifyRepo(value) {
|
|
22
|
+
const slug = value
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
26
|
+
.slice(0, SLUG_MAX_LENGTH)
|
|
27
|
+
.replace(/^-+|-+$/g, "");
|
|
28
|
+
if (!slug) {
|
|
29
|
+
throw new Error(`Could not derive a repository slug from "${value}".`);
|
|
30
|
+
}
|
|
31
|
+
return slug;
|
|
32
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const OBSERVED_FILES_MAX = 2000;
|
|
2
|
+
export const DECLARED_FILES_MAX = 500;
|
|
3
|
+
export const TASK_MAX_LENGTH = 2000;
|
|
4
|
+
export const AGENT_VENDORS = [
|
|
5
|
+
"claude_code",
|
|
6
|
+
"cursor",
|
|
7
|
+
"copilot",
|
|
8
|
+
"codex",
|
|
9
|
+
"devin",
|
|
10
|
+
"other",
|
|
11
|
+
];
|
|
12
|
+
export function isAgentVendor(value) {
|
|
13
|
+
return AGENT_VENDORS.includes(value);
|
|
14
|
+
}
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export function observedFilesFromGit(nameStatusOutput, numstatOutput, untrackedOutput) {
|
|
2
|
+
const counts = parseNumstat(numstatOutput);
|
|
3
|
+
const observed = parseNameStatus(nameStatusOutput).map((entry) => {
|
|
4
|
+
const file = { path: entry.path, action: entry.action };
|
|
5
|
+
const line = counts.get(entry.path);
|
|
6
|
+
if (line) {
|
|
7
|
+
file.additions = line.additions;
|
|
8
|
+
file.deletions = line.deletions;
|
|
9
|
+
}
|
|
10
|
+
return file;
|
|
11
|
+
});
|
|
12
|
+
// A path can appear in both lists — `git rm --cached` leaves a staged deletion
|
|
13
|
+
// in the diff while the on-disk file shows as untracked; prefer the diff entry.
|
|
14
|
+
const seen = new Set(observed.map((file) => file.path));
|
|
15
|
+
for (const path of splitFields(untrackedOutput)) {
|
|
16
|
+
if (seen.has(path))
|
|
17
|
+
continue;
|
|
18
|
+
observed.push({ path, action: "added" });
|
|
19
|
+
}
|
|
20
|
+
return observed;
|
|
21
|
+
}
|
|
22
|
+
function parseNameStatus(output) {
|
|
23
|
+
const reader = createReader(splitFields(output));
|
|
24
|
+
const entries = [];
|
|
25
|
+
while (!reader.done()) {
|
|
26
|
+
const status = reader.take();
|
|
27
|
+
const code = status.charAt(0);
|
|
28
|
+
if (code === "R") {
|
|
29
|
+
const from = reader.take();
|
|
30
|
+
const to = reader.take();
|
|
31
|
+
entries.push({ action: "deleted", path: from });
|
|
32
|
+
entries.push({ action: "renamed", path: to });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
entries.push({ action: statusToAction(status), path: reader.take() });
|
|
36
|
+
}
|
|
37
|
+
return entries;
|
|
38
|
+
}
|
|
39
|
+
function parseNumstat(output) {
|
|
40
|
+
const reader = createReader(splitFields(output));
|
|
41
|
+
const counts = new Map();
|
|
42
|
+
while (!reader.done()) {
|
|
43
|
+
const head = reader.take();
|
|
44
|
+
const firstTab = head.indexOf("\t");
|
|
45
|
+
const secondTab = head.indexOf("\t", firstTab + 1);
|
|
46
|
+
if (firstTab < 0 || secondTab < 0) {
|
|
47
|
+
throw new Error(`malformed numstat record: ${head}`);
|
|
48
|
+
}
|
|
49
|
+
const additions = head.slice(0, firstTab);
|
|
50
|
+
const deletions = head.slice(firstTab + 1, secondTab);
|
|
51
|
+
let path = head.slice(secondTab + 1);
|
|
52
|
+
if (path === "") {
|
|
53
|
+
reader.take();
|
|
54
|
+
path = reader.take();
|
|
55
|
+
}
|
|
56
|
+
if (additions === "-")
|
|
57
|
+
continue;
|
|
58
|
+
counts.set(path, {
|
|
59
|
+
additions: Number(additions),
|
|
60
|
+
deletions: Number(deletions),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return counts;
|
|
64
|
+
}
|
|
65
|
+
function statusToAction(status) {
|
|
66
|
+
switch (status.charAt(0)) {
|
|
67
|
+
case "A":
|
|
68
|
+
return "added";
|
|
69
|
+
case "M":
|
|
70
|
+
case "T":
|
|
71
|
+
return "modified";
|
|
72
|
+
case "D":
|
|
73
|
+
return "deleted";
|
|
74
|
+
default:
|
|
75
|
+
throw new Error(`unsupported git status: ${status}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function splitFields(output) {
|
|
79
|
+
const fields = output.split("\0");
|
|
80
|
+
while (fields.length > 0 && fields[fields.length - 1] === "") {
|
|
81
|
+
fields.pop();
|
|
82
|
+
}
|
|
83
|
+
return fields;
|
|
84
|
+
}
|
|
85
|
+
function createReader(fields) {
|
|
86
|
+
let index = 0;
|
|
87
|
+
return {
|
|
88
|
+
done: () => index >= fields.length,
|
|
89
|
+
take: () => {
|
|
90
|
+
const value = fields[index];
|
|
91
|
+
if (value === undefined) {
|
|
92
|
+
throw new Error("unexpected end of git output");
|
|
93
|
+
}
|
|
94
|
+
index += 1;
|
|
95
|
+
return value;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const run = promisify(execFile);
|
|
4
|
+
const MAX_GIT_OUTPUT_BYTES = 67_108_864;
|
|
5
|
+
async function git(args) {
|
|
6
|
+
const { stdout } = await run("git", args, {
|
|
7
|
+
maxBuffer: MAX_GIT_OUTPUT_BYTES,
|
|
8
|
+
});
|
|
9
|
+
return stdout;
|
|
10
|
+
}
|
|
11
|
+
export async function repoContext() {
|
|
12
|
+
let output;
|
|
13
|
+
try {
|
|
14
|
+
output = await git([
|
|
15
|
+
"rev-parse",
|
|
16
|
+
"--is-inside-work-tree",
|
|
17
|
+
"--show-toplevel",
|
|
18
|
+
"--git-dir",
|
|
19
|
+
]);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (gitExitedNonZero(error))
|
|
23
|
+
return null;
|
|
24
|
+
throw gitFailure(error);
|
|
25
|
+
}
|
|
26
|
+
const [inside, root, dir] = output.split("\n").map((line) => line.trim());
|
|
27
|
+
if (inside !== "true" || !root || !dir)
|
|
28
|
+
return null;
|
|
29
|
+
return { gitDir: dir, root };
|
|
30
|
+
}
|
|
31
|
+
export async function currentHead() {
|
|
32
|
+
const output = await git(["rev-parse", "HEAD"]);
|
|
33
|
+
return output.trim();
|
|
34
|
+
}
|
|
35
|
+
export async function commitExists(rev) {
|
|
36
|
+
try {
|
|
37
|
+
await git(["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (gitExitedNonZero(error))
|
|
42
|
+
return false;
|
|
43
|
+
throw gitFailure(error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function hasCommits() {
|
|
47
|
+
return commitExists("HEAD");
|
|
48
|
+
}
|
|
49
|
+
export async function currentBranch() {
|
|
50
|
+
// `git branch --show-current` needs Git 2.22+; symbolic-ref is portable.
|
|
51
|
+
try {
|
|
52
|
+
const output = await git(["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
53
|
+
return output.trim();
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (gitExitedNonZero(error))
|
|
57
|
+
return "";
|
|
58
|
+
throw gitFailure(error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export async function isAncestor(rev) {
|
|
62
|
+
try {
|
|
63
|
+
await git(["merge-base", "--is-ancestor", rev, "HEAD"]);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (gitExitedNonZero(error))
|
|
68
|
+
return false;
|
|
69
|
+
throw gitFailure(error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function parentOf(rev) {
|
|
73
|
+
try {
|
|
74
|
+
const output = await git(["rev-parse", "--verify", "--quiet", `${rev}^`]);
|
|
75
|
+
return output.trim() || null;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (gitExitedNonZero(error))
|
|
79
|
+
return null;
|
|
80
|
+
throw gitFailure(error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function isReconcilableBase(base, declaredBranch) {
|
|
84
|
+
if (await isAncestor(base))
|
|
85
|
+
return true;
|
|
86
|
+
// An in-place amend/squash shares HEAD's parent — but so does a sibling branch
|
|
87
|
+
// cut from that parent, and only the amend is safe to diff. Require the branch
|
|
88
|
+
// to distinguish them.
|
|
89
|
+
if (!declaredBranch || (await currentBranch()) !== declaredBranch) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const [baseParent, headParent] = await Promise.all([
|
|
93
|
+
parentOf(base),
|
|
94
|
+
parentOf("HEAD"),
|
|
95
|
+
]);
|
|
96
|
+
return baseParent !== null && baseParent === headParent;
|
|
97
|
+
}
|
|
98
|
+
export function diffNameStatus(base) {
|
|
99
|
+
return git(["diff", "--name-status", "-M", "-z", base]);
|
|
100
|
+
}
|
|
101
|
+
export function diffNumstat(base) {
|
|
102
|
+
return git(["diff", "--numstat", "-M", "-z", base]);
|
|
103
|
+
}
|
|
104
|
+
export function listUntracked() {
|
|
105
|
+
return git(["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
106
|
+
}
|
|
107
|
+
function errorCode(error) {
|
|
108
|
+
return error instanceof Error
|
|
109
|
+
? error.code
|
|
110
|
+
: undefined;
|
|
111
|
+
}
|
|
112
|
+
function gitExitedNonZero(error) {
|
|
113
|
+
return typeof errorCode(error) === "number";
|
|
114
|
+
}
|
|
115
|
+
function gitFailure(error) {
|
|
116
|
+
if (errorCode(error) === "ENOENT") {
|
|
117
|
+
return new Error("Could not run git — is it installed and on your PATH?");
|
|
118
|
+
}
|
|
119
|
+
return error instanceof Error ? error : new Error("Could not run git.");
|
|
120
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runConfigure } from "./commands/configure.js";
|
|
3
|
+
import { runDeclare } from "./commands/declare.js";
|
|
4
|
+
import { runObserve } from "./commands/observe.js";
|
|
5
|
+
import { RequestError } from "./transport.js";
|
|
6
|
+
const STATUS_BAD_REQUEST = 400;
|
|
7
|
+
const STATUS_UNAUTHORIZED = 401;
|
|
8
|
+
const STATUS_FORBIDDEN = 403;
|
|
9
|
+
const STATUS_NOT_FOUND = 404;
|
|
10
|
+
const STATUS_CONFLICT = 409;
|
|
11
|
+
const STATUS_RATE_LIMITED = 429;
|
|
12
|
+
async function main() {
|
|
13
|
+
const [, , command = "", ...argv] = process.argv;
|
|
14
|
+
try {
|
|
15
|
+
switch (command) {
|
|
16
|
+
case "declare":
|
|
17
|
+
await runDeclare(argv);
|
|
18
|
+
break;
|
|
19
|
+
case "observe":
|
|
20
|
+
await runObserve(argv);
|
|
21
|
+
break;
|
|
22
|
+
case "configure":
|
|
23
|
+
await runConfigure(argv);
|
|
24
|
+
break;
|
|
25
|
+
default:
|
|
26
|
+
process.stderr.write("Usage: gatebolt <declare|observe|configure> [options]\n");
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
process.stderr.write(`${formatError(error)}\n`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function formatError(error) {
|
|
36
|
+
if (error instanceof RequestError) {
|
|
37
|
+
return messageForStatus(error);
|
|
38
|
+
}
|
|
39
|
+
if (error instanceof Error) {
|
|
40
|
+
return error.message;
|
|
41
|
+
}
|
|
42
|
+
return "Unexpected error.";
|
|
43
|
+
}
|
|
44
|
+
function messageForStatus(error) {
|
|
45
|
+
switch (error.status) {
|
|
46
|
+
case STATUS_BAD_REQUEST:
|
|
47
|
+
return "Invalid request — check the declared values and try again.";
|
|
48
|
+
case STATUS_UNAUTHORIZED:
|
|
49
|
+
return "Unauthorized — the API key is invalid or has hit its rate limit.";
|
|
50
|
+
case STATUS_FORBIDDEN:
|
|
51
|
+
return "Forbidden — the repository slug does not match this API key.";
|
|
52
|
+
case STATUS_NOT_FOUND:
|
|
53
|
+
return "Not found — no matching change for this key and repository.";
|
|
54
|
+
case STATUS_CONFLICT:
|
|
55
|
+
return "Conflict — this change was already observed with different files.";
|
|
56
|
+
case STATUS_RATE_LIMITED:
|
|
57
|
+
return "Rate limited — try again later.";
|
|
58
|
+
default:
|
|
59
|
+
return error.message;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
void main();
|
package/dist/json.js
ADDED
package/dist/profiles.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { isRecord } from "./json.js";
|
|
5
|
+
const POINTER_FILE = ".gatebolt";
|
|
6
|
+
const CONFIG_FILE_MODE = 0o600;
|
|
7
|
+
function configPath() {
|
|
8
|
+
return (nonEmpty(process.env.GATEBOLT_CONFIG) ??
|
|
9
|
+
join(homedir(), ".gatebolt", "config.json"));
|
|
10
|
+
}
|
|
11
|
+
export function parseConfig(raw) {
|
|
12
|
+
if (!isRecord(raw) || !isRecord(raw.profiles)) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const profiles = {};
|
|
16
|
+
for (const [name, value] of Object.entries(raw.profiles)) {
|
|
17
|
+
const profile = parseProfile(value);
|
|
18
|
+
if (!profile)
|
|
19
|
+
return null;
|
|
20
|
+
profiles[name] = profile;
|
|
21
|
+
}
|
|
22
|
+
const config = { profiles };
|
|
23
|
+
if (typeof raw.default === "string") {
|
|
24
|
+
config.default = raw.default;
|
|
25
|
+
}
|
|
26
|
+
return config;
|
|
27
|
+
}
|
|
28
|
+
function parseProfile(value) {
|
|
29
|
+
if (!isRecord(value))
|
|
30
|
+
return null;
|
|
31
|
+
const { url, repo, key } = value;
|
|
32
|
+
if (typeof repo !== "string" || typeof key !== "string") {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (url !== undefined && typeof url !== "string") {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return url === undefined ? { repo, key } : { url, repo, key };
|
|
39
|
+
}
|
|
40
|
+
export function selectProfileName(config, inputs) {
|
|
41
|
+
const explicit = nonEmpty(inputs.name) ?? nonEmpty(inputs.env) ?? nonEmpty(inputs.pointer);
|
|
42
|
+
if (explicit)
|
|
43
|
+
return explicit;
|
|
44
|
+
const names = Object.keys(config.profiles);
|
|
45
|
+
if (names.length === 1)
|
|
46
|
+
return names[0] ?? null;
|
|
47
|
+
return config.default ?? null;
|
|
48
|
+
}
|
|
49
|
+
export async function resolveProfile(opts) {
|
|
50
|
+
const config = await loadConfig();
|
|
51
|
+
if (!config)
|
|
52
|
+
return null;
|
|
53
|
+
const explicit = nonEmpty(opts.name) ?? nonEmpty(process.env.GATEBOLT_PROFILE);
|
|
54
|
+
const pointer = await readPointer(opts.root);
|
|
55
|
+
const name = selectProfileName(config, {
|
|
56
|
+
name: opts.name,
|
|
57
|
+
env: process.env.GATEBOLT_PROFILE,
|
|
58
|
+
pointer,
|
|
59
|
+
});
|
|
60
|
+
if (!name)
|
|
61
|
+
return null;
|
|
62
|
+
const profile = config.profiles[name];
|
|
63
|
+
if (profile)
|
|
64
|
+
return profile;
|
|
65
|
+
if (!explicit && name === pointer)
|
|
66
|
+
return null;
|
|
67
|
+
throw new Error(`Profile "${name}" is not in ${configPath()}. Run \`gatebolt configure --profile ${name}\`.`);
|
|
68
|
+
}
|
|
69
|
+
export function profileLoader(opts) {
|
|
70
|
+
let cached;
|
|
71
|
+
return () => (cached ??= resolveProfile(opts));
|
|
72
|
+
}
|
|
73
|
+
export async function readConfigForWrite() {
|
|
74
|
+
const config = await loadConfig();
|
|
75
|
+
if (config)
|
|
76
|
+
return config;
|
|
77
|
+
return { profiles: {} };
|
|
78
|
+
}
|
|
79
|
+
export async function writeConfig(config) {
|
|
80
|
+
const path = configPath();
|
|
81
|
+
await mkdir(dirname(path), { recursive: true });
|
|
82
|
+
await writeFile(path, JSON.stringify(config, null, 2) + "\n", {
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
mode: CONFIG_FILE_MODE,
|
|
85
|
+
});
|
|
86
|
+
await chmod(path, CONFIG_FILE_MODE);
|
|
87
|
+
return path;
|
|
88
|
+
}
|
|
89
|
+
async function loadConfig() {
|
|
90
|
+
let raw;
|
|
91
|
+
try {
|
|
92
|
+
raw = await readFile(configPath(), "utf8");
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (isNotFound(error))
|
|
96
|
+
return null;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
const config = parseConfig(safeJsonParse(raw));
|
|
100
|
+
if (!config) {
|
|
101
|
+
throw new Error(`${configPath()} is malformed. Fix or remove it, then retry.`);
|
|
102
|
+
}
|
|
103
|
+
return config;
|
|
104
|
+
}
|
|
105
|
+
async function readPointer(root) {
|
|
106
|
+
try {
|
|
107
|
+
return nonEmpty((await readFile(join(root, POINTER_FILE), "utf8")).trim());
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function safeJsonParse(raw) {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(raw);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function nonEmpty(value) {
|
|
122
|
+
if (!value)
|
|
123
|
+
return undefined;
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
function isNotFound(error) {
|
|
127
|
+
return isRecord(error) && error.code === "ENOENT";
|
|
128
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isRecord } from "./json.js";
|
|
4
|
+
const SESSION_FILE = "session.json";
|
|
5
|
+
const PENDING_FILE = "pending-declare.json";
|
|
6
|
+
function sessionDir(gitDir) {
|
|
7
|
+
return join(gitDir, "gatebolt");
|
|
8
|
+
}
|
|
9
|
+
async function writeJson(gitDir, file, data) {
|
|
10
|
+
await mkdir(sessionDir(gitDir), { recursive: true });
|
|
11
|
+
await writeFile(join(sessionDir(gitDir), file), JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
12
|
+
}
|
|
13
|
+
async function readJson(gitDir, file, parse) {
|
|
14
|
+
try {
|
|
15
|
+
const data = JSON.parse(await readFile(join(sessionDir(gitDir), file), "utf8"));
|
|
16
|
+
return isRecord(data) ? parse(data) : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function clearJson(gitDir, file) {
|
|
23
|
+
return rm(join(sessionDir(gitDir), file), { force: true });
|
|
24
|
+
}
|
|
25
|
+
export function writeSession(gitDir, session) {
|
|
26
|
+
return writeJson(gitDir, SESSION_FILE, session);
|
|
27
|
+
}
|
|
28
|
+
export function readSession(gitDir) {
|
|
29
|
+
return readJson(gitDir, SESSION_FILE, ({ changeId, baseSha, branch }) => typeof changeId === "string" && typeof baseSha === "string"
|
|
30
|
+
? {
|
|
31
|
+
changeId,
|
|
32
|
+
baseSha,
|
|
33
|
+
branch: typeof branch === "string" ? branch : undefined,
|
|
34
|
+
}
|
|
35
|
+
: null);
|
|
36
|
+
}
|
|
37
|
+
export function clearSession(gitDir) {
|
|
38
|
+
return clearJson(gitDir, SESSION_FILE);
|
|
39
|
+
}
|
|
40
|
+
export function writePendingDeclare(gitDir, pending) {
|
|
41
|
+
return writeJson(gitDir, PENDING_FILE, pending);
|
|
42
|
+
}
|
|
43
|
+
export function readPendingDeclare(gitDir) {
|
|
44
|
+
return readJson(gitDir, PENDING_FILE, ({ correlationId, intentKey }) => typeof correlationId === "string" && typeof intentKey === "string"
|
|
45
|
+
? { correlationId, intentKey }
|
|
46
|
+
: null);
|
|
47
|
+
}
|
|
48
|
+
export function clearPendingDeclare(gitDir) {
|
|
49
|
+
return clearJson(gitDir, PENDING_FILE);
|
|
50
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { isRecord } from "./json.js";
|
|
2
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
3
|
+
export class RequestError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
code;
|
|
6
|
+
constructor(message, status, code) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = "RequestError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function postChange(connection, procedure, input) {
|
|
14
|
+
let response;
|
|
15
|
+
try {
|
|
16
|
+
response = await fetch(`${connection.baseUrl}/api/trpc/${procedure}`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: {
|
|
19
|
+
authorization: `Bearer ${connection.key}`,
|
|
20
|
+
"content-type": "application/json",
|
|
21
|
+
},
|
|
22
|
+
body: JSON.stringify({ json: input }),
|
|
23
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw connectionError(connection.baseUrl, error);
|
|
28
|
+
}
|
|
29
|
+
let body;
|
|
30
|
+
try {
|
|
31
|
+
body = await response.json();
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (!(error instanceof SyntaxError)) {
|
|
35
|
+
throw connectionError(connection.baseUrl, error);
|
|
36
|
+
}
|
|
37
|
+
body = null;
|
|
38
|
+
}
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw toRequestError(response.status, body);
|
|
41
|
+
}
|
|
42
|
+
return extractResult(body);
|
|
43
|
+
}
|
|
44
|
+
function connectionError(baseUrl, error) {
|
|
45
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
46
|
+
return new RequestError(`Timed out waiting for ${baseUrl}.`, 0, "TIMEOUT");
|
|
47
|
+
}
|
|
48
|
+
return new RequestError(`Could not reach ${baseUrl}.`, 0, "NETWORK");
|
|
49
|
+
}
|
|
50
|
+
function toRequestError(status, body) {
|
|
51
|
+
const parsed = parseTrpcError(body);
|
|
52
|
+
return new RequestError(parsed?.message ?? `Request failed with status ${status}.`, status, parsed?.code ?? "UNKNOWN");
|
|
53
|
+
}
|
|
54
|
+
export function extractResult(body) {
|
|
55
|
+
const result = isRecord(body) ? body.result : undefined;
|
|
56
|
+
const data = isRecord(result) ? result.data : undefined;
|
|
57
|
+
if (isRecord(data) && data.json !== undefined) {
|
|
58
|
+
return data.json;
|
|
59
|
+
}
|
|
60
|
+
throw new RequestError("Unexpected response from server.", 0, "MALFORMED");
|
|
61
|
+
}
|
|
62
|
+
function parseTrpcError(body) {
|
|
63
|
+
const error = isRecord(body) ? body.error : undefined;
|
|
64
|
+
const json = isRecord(error) ? error.json : undefined;
|
|
65
|
+
if (!isRecord(json))
|
|
66
|
+
return null;
|
|
67
|
+
const data = json.data;
|
|
68
|
+
return {
|
|
69
|
+
message: typeof json.message === "string" ? json.message : undefined,
|
|
70
|
+
code: isRecord(data) && typeof data.code === "string" ? data.code : undefined,
|
|
71
|
+
};
|
|
72
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gatebolt/cli",
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"gatebolt",
|
|
7
|
+
"ai-agents",
|
|
8
|
+
"provenance",
|
|
9
|
+
"audit",
|
|
10
|
+
"reconciliation",
|
|
11
|
+
"claude-code",
|
|
12
|
+
"git",
|
|
13
|
+
"cli"
|
|
14
|
+
],
|
|
15
|
+
"license": "UNLICENSED",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/gatebolt/gatebolt-cli.git"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"bin": {
|
|
22
|
+
"gatebolt": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"recipes"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^22.18.12",
|
|
36
|
+
"typescript": "^5.9.3",
|
|
37
|
+
"vitest": "^4.0.16"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.build.json",
|
|
41
|
+
"typecheck": "tsc --noEmit --emitDeclarationOnly false && tsc -p tsconfig.test.json",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:it": "vitest run --config vitest.it.config.ts"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# GateBolt × Claude Code — declare → observe recipe
|
|
2
|
+
|
|
3
|
+
Files you copy into the repo your agent works in so a Claude Code session runs
|
|
4
|
+
the GateBolt loop on its own: the agent **declares** the files it plans to touch
|
|
5
|
+
before editing, and each turn's diff is **observed** and reconciled when the turn
|
|
6
|
+
ends. Drives the [`gatebolt` CLI](../../README.md) — read that for what `declare`
|
|
7
|
+
and `observe` do.
|
|
8
|
+
|
|
9
|
+
## What's in here
|
|
10
|
+
|
|
11
|
+
| File | Copies to | Role |
|
|
12
|
+
| --------------------------- | ------------------------------ | --------------------------------------------------- |
|
|
13
|
+
| `settings.json` | `<repo>/.claude/settings.json` | Wires the `PreToolUse` guard + `Stop` observe hook |
|
|
14
|
+
| `hooks/gatebolt-guard.sh` | `<repo>/.claude/hooks/` | Strict mode: deny `Edit`/`Write` until declared |
|
|
15
|
+
| `hooks/gatebolt-observe.sh` | `<repo>/.claude/hooks/` | `Stop`: run `gatebolt observe` when a turn ends |
|
|
16
|
+
| `hooks/pre-push` | `<repo>/.git/hooks/pre-push` | Backstop: refuse to push an un-observed declaration |
|
|
17
|
+
|
|
18
|
+
Merge the `hooks` block if the repo already has a `.claude/settings.json`.
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
# 1. Copy the recipe in.
|
|
24
|
+
cp hooks/gatebolt-*.sh <repo>/.claude/hooks/
|
|
25
|
+
cp settings.json <repo>/.claude/settings.json
|
|
26
|
+
cp hooks/pre-push <repo>/.git/hooks/pre-push
|
|
27
|
+
chmod +x <repo>/.claude/hooks/gatebolt-*.sh <repo>/.git/hooks/pre-push
|
|
28
|
+
|
|
29
|
+
# 2. Save a GateBolt profile for this repo once — paste the key when prompted.
|
|
30
|
+
# The hooks call `gatebolt` with no flags; the profile is resolved automatically.
|
|
31
|
+
npx @gatebolt/cli configure --profile <repo-slug> \
|
|
32
|
+
--url https://your-gatebolt.app --repo <repo-slug>
|
|
33
|
+
|
|
34
|
+
# 3. Launch Claude Code.
|
|
35
|
+
export GATEBOLT_ENFORCE="strict" # or omit for lenient
|
|
36
|
+
export GATEBOLT_CLI="npx @gatebolt/cli" # optional — omit if `gatebolt` is on PATH
|
|
37
|
+
cd <repo> && claude
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Prereqs:** `git`, `jq`, and the [`@gatebolt/cli`](../../README.md) client
|
|
41
|
+
(`npm i -g @gatebolt/cli`). `GATEBOLT_CLI` is a command _prefix_ the hooks append
|
|
42
|
+
to, defaulting to `gatebolt`; set it to `npx @gatebolt/cli` if you didn't install
|
|
43
|
+
globally.
|
|
44
|
+
|
|
45
|
+
## The two modes (`GATEBOLT_ENFORCE`)
|
|
46
|
+
|
|
47
|
+
- **`strict`** — the guard **denies** every `Edit`/`Write` until a declaration
|
|
48
|
+
exists (holds even under `--dangerously-skip-permissions`) and tells the agent
|
|
49
|
+
to run `gatebolt declare` first. That `Bash` call isn't blocked, so the agent
|
|
50
|
+
declares, then edits go through.
|
|
51
|
+
- **`lenient`** (default) — no block; declaring is an instructed step (e.g. a
|
|
52
|
+
line in the repo's `CLAUDE.md`). The pre-push backstop guarantees nothing lands
|
|
53
|
+
undeclared.
|
|
54
|
+
|
|
55
|
+
## Notes and limits
|
|
56
|
+
|
|
57
|
+
- **One turn ≈ one change.** `Stop` fires every turn, so `observe` reconciles and
|
|
58
|
+
clears the session each turn; strict mode re-declares next turn.
|
|
59
|
+
- **Rate limit.** Two API calls per change, and the default key limit is low
|
|
60
|
+
(10/day today, surfacing as `401` when hit). Use a fresh key when iterating.
|
|
61
|
+
- **Backstop is v1.** Catches only an open, un-observed declaration (the
|
|
62
|
+
Esc-interrupt case), not work that was never declared — a follow-up.
|
|
63
|
+
- **Watch over-broad declarations.** Declaring `src/**` to dodge the block kills
|
|
64
|
+
the drift signal, like an empty declaration. The deny asks for specific paths.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# GateBolt strict-mode PreToolUse guard — blocks Edit/Write until a declaration
|
|
3
|
+
# exists for this change. Opt-in via GATEBOLT_ENFORCE=strict.
|
|
4
|
+
set -u
|
|
5
|
+
|
|
6
|
+
# Drain stdin so the hook's pipe doesn't block; we don't need the payload.
|
|
7
|
+
cat >/dev/null
|
|
8
|
+
|
|
9
|
+
[ "${GATEBOLT_ENFORCE:-lenient}" = "strict" ] || exit 0
|
|
10
|
+
|
|
11
|
+
project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
12
|
+
git_dir=$(git -C "$project_dir" rev-parse --git-dir 2>/dev/null) || exit 0
|
|
13
|
+
case "$git_dir" in
|
|
14
|
+
/*) ;;
|
|
15
|
+
*) git_dir="$project_dir/$git_dir" ;;
|
|
16
|
+
esac
|
|
17
|
+
|
|
18
|
+
# session.json exists = already declared → allow the edit.
|
|
19
|
+
[ -f "$git_dir/gatebolt/session.json" ] && exit 0
|
|
20
|
+
|
|
21
|
+
cli="${GATEBOLT_CLI:-gatebolt}"
|
|
22
|
+
reason="GateBolt strict mode: declare the files you plan to touch before editing.
|
|
23
|
+
Run this in Bash first, listing EVERY file you plan to create or modify for this
|
|
24
|
+
task — specific repo-relative paths, not broad globs like src/** :
|
|
25
|
+
|
|
26
|
+
$cli declare \\
|
|
27
|
+
--task \"<one line describing the task>\" \\
|
|
28
|
+
--vendor claude_code --name \"Claude Code\" --model <your-model-id> \\
|
|
29
|
+
--file <path> [--file <path> ...]
|
|
30
|
+
|
|
31
|
+
GBOLT_KEY, GBOLT_URL and GBOLT_REPO are already set in your environment. Once it
|
|
32
|
+
prints \"Declared change …\", retry the edit."
|
|
33
|
+
|
|
34
|
+
# JSON deny holds even under --dangerously-skip-permissions; without jq we fall
|
|
35
|
+
# back to exit-2, which re-prompts but isn't guaranteed under bypass.
|
|
36
|
+
if command -v jq >/dev/null 2>&1; then
|
|
37
|
+
jq -n --arg r "$reason" '{
|
|
38
|
+
hookSpecificOutput: {
|
|
39
|
+
hookEventName: "PreToolUse",
|
|
40
|
+
permissionDecision: "deny",
|
|
41
|
+
permissionDecisionReason: $r
|
|
42
|
+
}
|
|
43
|
+
}'
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
printf '%s\n' "$reason" >&2
|
|
48
|
+
exit 2
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# GateBolt Stop hook — runs `gatebolt observe` to reconcile the diff when a turn
|
|
3
|
+
# ends. Advisory; never blocks the turn.
|
|
4
|
+
set -u
|
|
5
|
+
|
|
6
|
+
# Drain stdin; observe reads its target from the session file.
|
|
7
|
+
cat >/dev/null
|
|
8
|
+
|
|
9
|
+
cd "${CLAUDE_PROJECT_DIR:-$PWD}" || exit 0
|
|
10
|
+
|
|
11
|
+
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
|
|
12
|
+
case "$git_dir" in
|
|
13
|
+
/*) ;;
|
|
14
|
+
*) git_dir="$PWD/$git_dir" ;;
|
|
15
|
+
esac
|
|
16
|
+
|
|
17
|
+
# Stop fires every turn; only reconcile when a declaration is actually open.
|
|
18
|
+
[ -f "$git_dir/gatebolt/session.json" ] || exit 0
|
|
19
|
+
|
|
20
|
+
cli="${GATEBOLT_CLI:-gatebolt}"
|
|
21
|
+
# GATEBOLT_CLI is a command prefix; word-splitting is intentional.
|
|
22
|
+
# shellcheck disable=SC2086
|
|
23
|
+
$cli observe 2>&1 || true
|
|
24
|
+
exit 0
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# GateBolt pre-push backstop — refuses to push while a declaration is still open
|
|
3
|
+
# but un-observed (the Esc-interrupt case Stop misses). Needs gatebolt's env
|
|
4
|
+
# (GBOLT_KEY / GBOLT_URL / GATEBOLT_CLI) exported in the shell you push from.
|
|
5
|
+
set -u
|
|
6
|
+
|
|
7
|
+
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
|
|
8
|
+
case "$git_dir" in
|
|
9
|
+
/*) ;;
|
|
10
|
+
*) git_dir="$PWD/$git_dir" ;;
|
|
11
|
+
esac
|
|
12
|
+
|
|
13
|
+
[ -f "$git_dir/gatebolt/session.json" ] || exit 0
|
|
14
|
+
|
|
15
|
+
cli="${GATEBOLT_CLI:-gatebolt}"
|
|
16
|
+
echo "GateBolt: an un-observed declaration is still open; reconciling before push…" >&2
|
|
17
|
+
# shellcheck disable=SC2086
|
|
18
|
+
if ! $cli observe >&2; then
|
|
19
|
+
echo "GateBolt: observe failed — refusing to push unreconciled work." >&2
|
|
20
|
+
echo "Resolve the error above (or run '$cli observe' yourself), then push again." >&2
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
exit 0
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Edit|Write",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gatebolt-guard.sh"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"Stop": [
|
|
15
|
+
{
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gatebolt-observe.sh"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
}
|