@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/dist/profiles.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { isUuid } from "./contracts.js";
4
5
  import { isRecord } from "./json.js";
5
- const POINTER_FILE = ".gatebolt";
6
+ export const REPO_LINK_FILE = ".gatebolt";
6
7
  const CONFIG_FILE_MODE = 0o600;
7
8
  function configPath() {
8
9
  return (nonEmpty(process.env.GATEBOLT_CONFIG) ??
@@ -28,14 +29,15 @@ export function parseConfig(raw) {
28
29
  function parseProfile(value) {
29
30
  if (!isRecord(value))
30
31
  return null;
31
- const { url, repo, key } = value;
32
- if (typeof repo !== "string" || typeof key !== "string") {
32
+ const { url, key } = value;
33
+ if (typeof key !== "string")
33
34
  return null;
34
- }
35
- if (url !== undefined && typeof url !== "string") {
35
+ if (url !== undefined && typeof url !== "string")
36
36
  return null;
37
- }
38
- return url === undefined ? { repo, key } : { url, repo, key };
37
+ const profile = { key };
38
+ if (url !== undefined)
39
+ profile.url = url;
40
+ return profile;
39
41
  }
40
42
  export function selectProfileName(config, inputs) {
41
43
  const explicit = nonEmpty(inputs.name) ?? nonEmpty(inputs.env) ?? nonEmpty(inputs.pointer);
@@ -51,21 +53,25 @@ export async function resolveProfile(opts) {
51
53
  if (!config)
52
54
  return null;
53
55
  const explicit = nonEmpty(opts.name) ?? nonEmpty(process.env.GATEBOLT_PROFILE);
54
- const pointer = await readPointer(opts.root);
56
+ const pointer = await readLinkedProfile(opts.root);
57
+ const preference = configured(config, nonEmpty(opts.preferred)) ?? pointer;
55
58
  const name = selectProfileName(config, {
56
59
  name: opts.name,
57
60
  env: process.env.GATEBOLT_PROFILE,
58
- pointer,
61
+ pointer: preference,
59
62
  });
60
63
  if (!name)
61
64
  return null;
62
65
  const profile = config.profiles[name];
63
66
  if (profile)
64
- return profile;
65
- if (!explicit && name === pointer)
67
+ return { name, ...profile };
68
+ if (!explicit && name === preference)
66
69
  return null;
67
70
  throw new Error(`Profile "${name}" is not in ${configPath()}. Run \`gatebolt configure --profile ${name}\`.`);
68
71
  }
72
+ function configured(config, name) {
73
+ return name && config.profiles[name] ? name : undefined;
74
+ }
69
75
  export function profileLoader(opts) {
70
76
  let cached;
71
77
  return () => (cached ??= resolveProfile(opts));
@@ -102,14 +108,43 @@ async function loadConfig() {
102
108
  }
103
109
  return config;
104
110
  }
105
- async function readPointer(root) {
111
+ async function readLinkedProfile(root) {
106
112
  try {
107
- return nonEmpty((await readFile(join(root, POINTER_FILE), "utf8")).trim());
113
+ const link = await readRepoLink(root);
114
+ return link?.profile;
108
115
  }
109
116
  catch {
110
117
  return undefined;
111
118
  }
112
119
  }
120
+ export async function readRepoLink(root) {
121
+ let raw;
122
+ try {
123
+ raw = await readFile(join(root, REPO_LINK_FILE), "utf8");
124
+ }
125
+ catch (error) {
126
+ if (isNotFound(error))
127
+ return null;
128
+ throw error;
129
+ }
130
+ const data = safeJsonParse(raw);
131
+ if (!isRecord(data) ||
132
+ typeof data.repositoryId !== "string" ||
133
+ !isUuid(data.repositoryId)) {
134
+ throw new Error(`${join(root, REPO_LINK_FILE)} is malformed — it should hold {"repositoryId": "<uuid>"}. ` +
135
+ "Run `gatebolt link` from the repo root to rewrite it, then commit it.");
136
+ }
137
+ const link = { repositoryId: data.repositoryId };
138
+ if (typeof data.profile === "string") {
139
+ link.profile = data.profile;
140
+ }
141
+ return link;
142
+ }
143
+ export async function writeRepoLink(root, link) {
144
+ const path = join(root, REPO_LINK_FILE);
145
+ await writeFile(path, JSON.stringify(link, null, 2) + "\n", "utf8");
146
+ return path;
147
+ }
113
148
  function safeJsonParse(raw) {
114
149
  try {
115
150
  return JSON.parse(raw);
package/dist/rest.js ADDED
@@ -0,0 +1,49 @@
1
+ const AUTH_BASE_PATH = "/api/auth";
2
+ const REQUEST_TIMEOUT_MS = 30_000;
3
+ export async function authFetch(baseUrl, path, request) {
4
+ let response;
5
+ try {
6
+ response = await fetch(buildUrl(baseUrl, path, request.query), {
7
+ method: request.method,
8
+ headers: buildHeaders(request),
9
+ body: request.body === undefined ? undefined : JSON.stringify(request.body),
10
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
11
+ });
12
+ }
13
+ catch (error) {
14
+ throw connectionError(baseUrl, error);
15
+ }
16
+ return { status: response.status, json: await readJson(response) };
17
+ }
18
+ function buildUrl(baseUrl, path, query) {
19
+ const url = new URL(`${baseUrl}${AUTH_BASE_PATH}${path}`);
20
+ for (const [name, value] of Object.entries(query ?? {})) {
21
+ url.searchParams.set(name, value);
22
+ }
23
+ return url.toString();
24
+ }
25
+ function buildHeaders(request) {
26
+ const headers = {};
27
+ if (request.body !== undefined)
28
+ headers["content-type"] = "application/json";
29
+ if (request.token)
30
+ headers.authorization = `Bearer ${request.token}`;
31
+ return headers;
32
+ }
33
+ async function readJson(response) {
34
+ const text = await response.text();
35
+ if (!text)
36
+ return null;
37
+ try {
38
+ return JSON.parse(text);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ function connectionError(baseUrl, error) {
45
+ if (error instanceof Error && error.name === "TimeoutError") {
46
+ return new Error(`Timed out waiting for ${baseUrl}.`);
47
+ }
48
+ return new Error(`Could not reach ${baseUrl}.`);
49
+ }
package/dist/session.js CHANGED
@@ -26,11 +26,12 @@ 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 }) => typeof changeId === "string" && typeof baseSha === "string"
29
+ return readJson(gitDir, SESSION_FILE, ({ changeId, baseSha, branch, profile }) => typeof changeId === "string" && typeof baseSha === "string"
30
30
  ? {
31
31
  changeId,
32
32
  baseSha,
33
33
  branch: typeof branch === "string" ? branch : undefined,
34
+ profile: typeof profile === "string" ? profile : undefined,
34
35
  }
35
36
  : null);
36
37
  }
package/dist/transport.js CHANGED
@@ -3,11 +3,15 @@ const REQUEST_TIMEOUT_MS = 30_000;
3
3
  export class RequestError extends Error {
4
4
  status;
5
5
  code;
6
- constructor(message, status, code) {
6
+ procedure;
7
+ fieldErrors;
8
+ constructor(message, status, code, details) {
7
9
  super(message);
8
10
  this.status = status;
9
11
  this.code = code;
10
12
  this.name = "RequestError";
13
+ this.procedure = details.procedure;
14
+ this.fieldErrors = details.fieldErrors ?? [];
11
15
  }
12
16
  }
13
17
  export async function postChange(connection, procedure, input) {
@@ -24,7 +28,7 @@ export async function postChange(connection, procedure, input) {
24
28
  });
25
29
  }
26
30
  catch (error) {
27
- throw connectionError(connection.baseUrl, error);
31
+ throw connectionError(connection.baseUrl, error, procedure);
28
32
  }
29
33
  let body;
30
34
  try {
@@ -32,32 +36,38 @@ export async function postChange(connection, procedure, input) {
32
36
  }
33
37
  catch (error) {
34
38
  if (!(error instanceof SyntaxError)) {
35
- throw connectionError(connection.baseUrl, error);
39
+ throw connectionError(connection.baseUrl, error, procedure);
36
40
  }
37
41
  body = null;
38
42
  }
39
43
  if (!response.ok) {
40
- throw toRequestError(response.status, body);
44
+ throw toRequestError(response.status, body, procedure);
41
45
  }
42
- return extractResult(body);
46
+ return extractResult(body, procedure);
43
47
  }
44
- function connectionError(baseUrl, error) {
48
+ function connectionError(baseUrl, error, procedure) {
45
49
  if (error instanceof Error && error.name === "TimeoutError") {
46
- return new RequestError(`Timed out waiting for ${baseUrl}.`, 0, "TIMEOUT");
50
+ return new RequestError(`Timed out waiting for ${baseUrl}.`, 0, "TIMEOUT", {
51
+ procedure,
52
+ });
47
53
  }
48
- return new RequestError(`Could not reach ${baseUrl}.`, 0, "NETWORK");
54
+ return new RequestError(`Could not reach ${baseUrl}.`, 0, "NETWORK", {
55
+ procedure,
56
+ });
49
57
  }
50
- function toRequestError(status, body) {
58
+ function toRequestError(status, body, procedure) {
51
59
  const parsed = parseTrpcError(body);
52
- return new RequestError(parsed?.message ?? `Request failed with status ${status}.`, status, parsed?.code ?? "UNKNOWN");
60
+ return new RequestError(parsed?.message ?? `Request failed with status ${status}.`, status, parsed?.code ?? "UNKNOWN", { procedure, fieldErrors: parsed?.fieldErrors ?? [] });
53
61
  }
54
- export function extractResult(body) {
62
+ export function extractResult(body, procedure) {
55
63
  const result = isRecord(body) ? body.result : undefined;
56
64
  const data = isRecord(result) ? result.data : undefined;
57
65
  if (isRecord(data) && data.json !== undefined) {
58
66
  return data.json;
59
67
  }
60
- throw new RequestError("Unexpected response from server.", 0, "MALFORMED");
68
+ throw new RequestError("Unexpected response from server.", 0, "MALFORMED", {
69
+ procedure,
70
+ });
61
71
  }
62
72
  function parseTrpcError(body) {
63
73
  const error = isRecord(body) ? body.error : undefined;
@@ -68,5 +78,15 @@ function parseTrpcError(body) {
68
78
  return {
69
79
  message: typeof json.message === "string" ? json.message : undefined,
70
80
  code: isRecord(data) && typeof data.code === "string" ? data.code : undefined,
81
+ fieldErrors: parseFieldErrors(data),
71
82
  };
72
83
  }
84
+ function parseFieldErrors(data) {
85
+ const zodError = isRecord(data) ? data.zodError : undefined;
86
+ const fieldErrors = isRecord(zodError) ? zodError.fieldErrors : undefined;
87
+ if (!isRecord(fieldErrors))
88
+ return [];
89
+ return Object.values(fieldErrors)
90
+ .flat()
91
+ .filter((message) => typeof message === "string");
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gatebolt/cli",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Declare-then-observe agent client for GateBolt — records the files an AI agent intends to touch, then reconciles the actual git diff.",
5
5
  "keywords": [
6
6
  "gatebolt",
@@ -22,8 +22,7 @@
22
22
  "gatebolt": "./dist/index.js"
23
23
  },
24
24
  "files": [
25
- "dist",
26
- "recipes"
25
+ "dist"
27
26
  ],
28
27
  "engines": {
29
28
  "node": ">=20"
@@ -40,6 +39,6 @@
40
39
  "build": "tsc -p tsconfig.build.json",
41
40
  "typecheck": "tsc --noEmit --emitDeclarationOnly false && tsc -p tsconfig.test.json",
42
41
  "test": "vitest run",
43
- "test:it": "vitest run --config vitest.it.config.ts"
42
+ "test:it": "pnpm build && vitest run --config vitest.it.config.ts"
44
43
  }
45
44
  }
@@ -1,64 +0,0 @@
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.
@@ -1,48 +0,0 @@
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
@@ -1,24 +0,0 @@
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
@@ -1,23 +0,0 @@
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
@@ -1,25 +0,0 @@
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
- }