@gatebolt/cli 0.3.1 → 0.4.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 CHANGED
@@ -16,11 +16,14 @@ 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
24
  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`):
25
+ slug** to record the work against. Store them once as a named **profile** in
26
+ `~/.gatebolt/config.json` (written owner-only, `0600`):
24
27
 
25
28
  ```
26
29
  gatebolt configure --profile payments-api \
@@ -60,7 +63,7 @@ to `<git-dir>/gatebolt/session.json`. The repository must have at least one comm
60
63
  | `--name <name>` | yes | Agent name |
61
64
  | `--model <model>` | yes | Model id |
62
65
  | `--file <path>` | yes\* | Intended file (repeatable, one per path) |
63
- | `--version <v>` | no | Agent/tool version |
66
+ | `--agent-version <v>` | no | Agent/tool version (`--version` prints the CLI's own version) |
64
67
  | `--launched-by <email>` | no | Who launched the agent |
65
68
  | `--profile <name>` | no | Profile to resolve `key`/`url`/`repo` from |
66
69
 
@@ -101,7 +104,10 @@ gatebolt observe
101
104
  untracked files, so uncommitted edits and newly created files are both captured. It accepts a
102
105
  base that is an ancestor of HEAD or one HEAD amended/squashed in place on the same branch, but
103
106
  **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
107
+ would fold in unrelated work, so re-run `declare`. It never blocks on ambiguity, though: git
108
+ records nothing that separates the agent's commits from a colleague's, so when commits have
109
+ landed since `declare` — or a merge is in progress — `observe` **says so in a `Note` and
110
+ reconciles anyway**, leaving you to judge whose work the diff contains. It is **advisory** — it exits `0` regardless of the
105
111
  drift level (errors exit non-zero) — and on success it clears the session. A diff touching more
106
112
  than 2000 files is rejected; narrow the change.
107
113
 
@@ -1,5 +1,6 @@
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
5
  import { slugifyRepo } from "../config.js";
5
6
  import { readConfigForWrite, writeConfig } from "../profiles.js";
@@ -33,11 +34,30 @@ function requireFlag(value, flag) {
33
34
  return value;
34
35
  }
35
36
  async function promptForKey() {
36
- const rl = createInterface({ input: stdin, output: stdout });
37
+ let muted = false;
38
+ const output = new Writable({
39
+ write(chunk, encoding, done) {
40
+ if (!muted)
41
+ stdout.write(chunk, encoding);
42
+ done();
43
+ },
44
+ });
45
+ // `terminal` moves echoing from the tty to readline, which is what makes it
46
+ // suppressible; muting the stream alone leaves the tty echoing in canonical
47
+ // mode. Off a tty nothing echoes anyway, and leaving it off keeps readline's
48
+ // cursor escapes out of piped output.
49
+ const rl = createInterface({
50
+ input: stdin,
51
+ output,
52
+ terminal: stdin.isTTY === true,
53
+ });
37
54
  try {
38
- return await rl.question("API key: ");
55
+ const answer = rl.question("API key: ");
56
+ muted = true;
57
+ return await answer;
39
58
  }
40
59
  finally {
41
60
  rl.close();
61
+ stdout.write("\n");
42
62
  }
43
63
  }
@@ -20,7 +20,7 @@ export async function runDeclare(argv) {
20
20
  vendor: { type: "string" },
21
21
  name: { type: "string" },
22
22
  model: { type: "string" },
23
- version: { type: "string" },
23
+ "agent-version": { type: "string" },
24
24
  "launched-by": { type: "string" },
25
25
  file: { type: "string", multiple: true },
26
26
  },
@@ -38,8 +38,9 @@ export async function runDeclare(argv) {
38
38
  name: requireFlag(values.name, "--name"),
39
39
  model: requireFlag(values.model, "--model"),
40
40
  };
41
- if (values.version)
42
- agent.version = values.version;
41
+ const agentVersion = values["agent-version"];
42
+ if (agentVersion)
43
+ agent.version = agentVersion;
43
44
  const launchedBy = values["launched-by"];
44
45
  if (launchedBy && !isValidEmail(launchedBy)) {
45
46
  throw new Error(`Invalid --launched-by '${launchedBy}'. Expected an email address.`);
@@ -2,7 +2,7 @@ import { parseArgs } from "node:util";
2
2
  import { resolveConnection } from "../config.js";
3
3
  import { OBSERVED_FILES_MAX } from "../contracts.js";
4
4
  import { observedFilesFromGit } from "../diff.js";
5
- import { commitExists, diffNameStatus, diffNumstat, isReconcilableBase, listUntracked, repoContext, } from "../git.js";
5
+ import { commitExists, commitsSinceBase, diffNameStatus, diffNumstat, isAncestor, isReconcilableBase, listUntracked, mergeInProgress, repoContext, } from "../git.js";
6
6
  import { isRecord } from "../json.js";
7
7
  import { profileLoader } from "../profiles.js";
8
8
  import { clearPendingDeclare, clearSession, readSession } from "../session.js";
@@ -50,7 +50,8 @@ export async function runObserve(argv) {
50
50
  if (!(await isReconcilableBase(base, declaredBranch))) {
51
51
  throw new Error("The working tree has moved off the declared base (branch switch or divergent rebase?). Re-run `gatebolt declare`.");
52
52
  }
53
- const [nameStatus, numstat, untracked] = await Promise.all([
53
+ const [notes, nameStatus, numstat, untracked] = await Promise.all([
54
+ attributionNotes(base),
54
55
  diffNameStatus(base),
55
56
  diffNumstat(base),
56
57
  listUntracked(),
@@ -64,7 +65,7 @@ export async function runObserve(argv) {
64
65
  if (!isReconciliation(result)) {
65
66
  throw new Error("Unexpected response from server.");
66
67
  }
67
- printVerdict(result);
68
+ printVerdict(result, notes);
68
69
  if (observingSessionChange) {
69
70
  await clearSession(dir);
70
71
  await clearPendingDeclare(dir);
@@ -87,7 +88,26 @@ export function isReconciliation(value) {
87
88
  Array.isArray(value.missing) &&
88
89
  Array.isArray(value.reasons));
89
90
  }
90
- function printVerdict(result) {
91
+ // Never blocked on: observe is advisory, and only the person at the keyboard
92
+ // knows whose commits these are.
93
+ export async function attributionNotes(base) {
94
+ const notes = [];
95
+ if (await mergeInProgress()) {
96
+ notes.push("A merge is in progress; the diff includes the merged branch.");
97
+ }
98
+ // isReconcilableBase also accepts an in-place amend/squash, where base shares
99
+ // HEAD's parent but isn't its ancestor — and there base..HEAD would count the
100
+ // agent's own rewritten commit (the very work being reconciled) as foreign.
101
+ if (await isAncestor(base)) {
102
+ const commits = await commitsSinceBase(base);
103
+ if (commits > 0) {
104
+ const plural = commits === 1 ? "commit" : "commits";
105
+ notes.push(`${commits} ${plural} landed since declare; GateBolt cannot tell whose.`);
106
+ }
107
+ }
108
+ return notes;
109
+ }
110
+ function printVerdict(result, notes) {
91
111
  const lines = [
92
112
  `Change ${result.changeId}`,
93
113
  `Drift ${result.driftLevel} (${result.driftScore})`,
@@ -96,5 +116,11 @@ function printVerdict(result) {
96
116
  for (const reason of result.reasons) {
97
117
  lines.push(` ${reason}`);
98
118
  }
119
+ for (const note of notes) {
120
+ lines.push(`Note ${note}`);
121
+ }
122
+ if (notes.length > 0) {
123
+ lines.push(" Files above may include work from elsewhere.");
124
+ }
99
125
  process.stdout.write(`${lines.join("\n")}\n`);
100
126
  }
package/dist/diff.js CHANGED
@@ -1,3 +1,4 @@
1
+ const PARSER_BUG_HINT = "This is likely a GateBolt bug — please report it.";
1
2
  export function observedFilesFromGit(nameStatusOutput, numstatOutput, untrackedOutput) {
2
3
  const counts = parseNumstat(numstatOutput);
3
4
  const observed = parseNameStatus(nameStatusOutput).map((entry) => {
@@ -44,7 +45,7 @@ function parseNumstat(output) {
44
45
  const firstTab = head.indexOf("\t");
45
46
  const secondTab = head.indexOf("\t", firstTab + 1);
46
47
  if (firstTab < 0 || secondTab < 0) {
47
- throw new Error(`malformed numstat record: ${head}`);
48
+ throw new Error(`Could not read the line counts in this repository's diff — git returned an unexpected record: "${head}". ${PARSER_BUG_HINT}`);
48
49
  }
49
50
  const additions = head.slice(0, firstTab);
50
51
  const deletions = head.slice(firstTab + 1, secondTab);
@@ -72,7 +73,7 @@ function statusToAction(status) {
72
73
  case "D":
73
74
  return "deleted";
74
75
  default:
75
- throw new Error(`unsupported git status: ${status}`);
76
+ throw new Error(`Could not read this repository's diff — git reported the file status "${status}", which GateBolt does not recognise. ${PARSER_BUG_HINT}`);
76
77
  }
77
78
  }
78
79
  function splitFields(output) {
@@ -89,7 +90,7 @@ function createReader(fields) {
89
90
  take: () => {
90
91
  const value = fields[index];
91
92
  if (value === undefined) {
92
- throw new Error("unexpected end of git output");
93
+ throw new Error(`Could not read this repository's diff — git's output ended sooner than expected. ${PARSER_BUG_HINT}`);
93
94
  }
94
95
  index += 1;
95
96
  return value;
package/dist/errors.js ADDED
@@ -0,0 +1,33 @@
1
+ import { RequestError } from "./transport.js";
2
+ const STATUS_BAD_REQUEST = 400;
3
+ const STATUS_UNAUTHORIZED = 401;
4
+ const STATUS_NOT_FOUND = 404;
5
+ const STATUS_CONFLICT = 409;
6
+ const STATUS_RATE_LIMITED = 429;
7
+ export function formatError(error) {
8
+ if (error instanceof RequestError) {
9
+ return messageForStatus(error);
10
+ }
11
+ if (error instanceof Error) {
12
+ return error.message;
13
+ }
14
+ return "Unexpected error.";
15
+ }
16
+ export function messageForStatus(error) {
17
+ switch (error.status) {
18
+ case STATUS_BAD_REQUEST:
19
+ return "Invalid request — check the declared values and try again.";
20
+ // The server collapses a missing/invalid key and a spent rate limit into one
21
+ // 401, so this cannot name which without guessing.
22
+ case STATUS_UNAUTHORIZED:
23
+ return "Unauthorized — the API key is invalid or has hit its rate limit.";
24
+ case STATUS_NOT_FOUND:
25
+ return "Not found — no change matches this API key. `observe` must use the same key that ran `declare`; if the key was rotated in between, re-run `gatebolt declare`.";
26
+ case STATUS_CONFLICT:
27
+ return "Conflict — this change was already observed with different files.";
28
+ case STATUS_RATE_LIMITED:
29
+ return "Rate limited — try again later.";
30
+ default:
31
+ return error.message;
32
+ }
33
+ }
package/dist/git.js CHANGED
@@ -85,7 +85,8 @@ export async function isReconcilableBase(base, declaredBranch) {
85
85
  return true;
86
86
  // An in-place amend/squash shares HEAD's parent — but so does a sibling branch
87
87
  // cut from that parent, and only the amend is safe to diff. Require the branch
88
- // to distinguish them.
88
+ // to distinguish them. A same-branch reset onto a sibling still passes: it is
89
+ // shaped exactly like an amend, and git records nothing that separates them.
89
90
  if (!declaredBranch || (await currentBranch()) !== declaredBranch) {
90
91
  return false;
91
92
  }
@@ -95,6 +96,13 @@ export async function isReconcilableBase(base, declaredBranch) {
95
96
  ]);
96
97
  return baseParent !== null && baseParent === headParent;
97
98
  }
99
+ export async function commitsSinceBase(base) {
100
+ const output = await git(["rev-list", "--count", `${base}..HEAD`]);
101
+ return Number(output.trim());
102
+ }
103
+ export function mergeInProgress() {
104
+ return commitExists("MERGE_HEAD");
105
+ }
98
106
  export function diffNameStatus(base) {
99
107
  return git(["diff", "--name-status", "-M", "-z", base]);
100
108
  }
package/dist/help.js ADDED
@@ -0,0 +1,94 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { DEFAULT_BASE_URL } from "./config.js";
3
+ import { AGENT_VENDORS } from "./contracts.js";
4
+ import { isRecord } from "./json.js";
5
+ export const USAGE = "Usage: gatebolt <declare|observe|configure> [options]";
6
+ const ROOT_HELP = `gatebolt — the GateBolt agent client. An agent declares the files it intends to
7
+ touch before it edits, then observes the actual diff when the work lands;
8
+ GateBolt reconciles the two and scores the drift.
9
+
10
+ ${USAGE}
11
+
12
+ Commands:
13
+ declare Record the files the agent intends to touch, before it edits.
14
+ observe Derive the diff from git and reconcile it against the declaration.
15
+ configure Save a per-repo profile (url, repo, key) to ~/.gatebolt/config.json.
16
+
17
+ Options:
18
+ -h, --help Show this help, or the help of a command.
19
+ --version Print the version of this CLI.
20
+
21
+ Run \`gatebolt <command> --help\` for the options of a command.
22
+ `;
23
+ const DECLARE_HELP = `Usage: gatebolt declare --task <text> --vendor <vendor> --name <name>
24
+ --model <id> --file <path> [--file <path> ...] [options]
25
+
26
+ Records the files the agent intends to touch, before it edits. Snapshots the
27
+ current commit as the base and writes the change id under <git-dir>/gatebolt/
28
+ for \`gatebolt observe\` to pick up. The repository must have at least one commit.
29
+
30
+ Required:
31
+ --task <text> What the agent is doing.
32
+ --vendor <vendor> One of: ${AGENT_VENDORS.join(", ")}.
33
+ --name <name> Agent name, e.g. "Claude Code".
34
+ --model <id> Agent model id.
35
+ --file <path> A file the agent plans to touch. Repeat for each file.
36
+
37
+ Options:
38
+ --agent-version <ver> Version of the agent — not of this CLI.
39
+ --launched-by <email> Email of the person who launched the agent.
40
+ --profile <name> Profile to read url, repo and key from.
41
+ --key <key> API key. Overrides the profile.
42
+ --url <url> GateBolt URL. Overrides the profile.
43
+ --repo <slug> Repo slug. Overrides the profile.
44
+ -h, --help Show this help.
45
+ `;
46
+ const OBSERVE_HELP = `Usage: gatebolt observe [options]
47
+
48
+ Derives the diff from git since the declared base commit and posts it to
49
+ GateBolt, which reconciles it against the declaration and scores the drift.
50
+ Observes the change recorded by \`gatebolt declare\` unless --change-id is given.
51
+
52
+ Options:
53
+ --change-id <uuid> Change to observe. Defaults to the declared change.
54
+ --base <commit> Commit to diff against. Defaults to the declared base.
55
+ --profile <name> Profile to read url and key from.
56
+ --key <key> API key. Overrides the profile.
57
+ --url <url> GateBolt URL. Overrides the profile.
58
+ -h, --help Show this help.
59
+ `;
60
+ const CONFIGURE_HELP = `Usage: gatebolt configure --repo <slug> [options]
61
+
62
+ Saves a profile to ~/.gatebolt/config.json, owner-readable only. Prompts for the
63
+ API key unless --key is given; what you type is not echoed.
64
+
65
+ Required:
66
+ --repo <slug> Slug of the repo this agent works in, created on the first
67
+ declare. Normalized to lowercase-hyphens.
68
+
69
+ Options:
70
+ --profile <name> Profile name. Defaults to the repo slug.
71
+ --url <url> GateBolt URL. Defaults to ${DEFAULT_BASE_URL}.
72
+ --key <key> API key. Skips the prompt, for scripting.
73
+ -h, --help Show this help.
74
+
75
+ Set GATEBOLT_CONFIG to keep the config somewhere other than the default path.
76
+ `;
77
+ const COMMAND_HELP = new Map([
78
+ ["declare", DECLARE_HELP],
79
+ ["observe", OBSERVE_HELP],
80
+ ["configure", CONFIGURE_HELP],
81
+ ]);
82
+ export function isCommand(command) {
83
+ return COMMAND_HELP.has(command);
84
+ }
85
+ export function helpFor(command) {
86
+ return COMMAND_HELP.get(command) ?? ROOT_HELP;
87
+ }
88
+ export async function cliVersion() {
89
+ const raw = await readFile(new URL("../package.json", import.meta.url), "utf8");
90
+ const pkg = JSON.parse(raw);
91
+ return isRecord(pkg) && typeof pkg.version === "string"
92
+ ? pkg.version
93
+ : "unknown";
94
+ }
package/dist/index.js CHANGED
@@ -2,61 +2,48 @@
2
2
  import { runConfigure } from "./commands/configure.js";
3
3
  import { runDeclare } from "./commands/declare.js";
4
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;
5
+ import { formatError } from "./errors.js";
6
+ import { cliVersion, helpFor, isCommand, USAGE } from "./help.js";
12
7
  async function main() {
13
8
  const [, , command = "", ...argv] = process.argv;
14
9
  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
- }
10
+ await dispatch(command, argv);
29
11
  }
30
12
  catch (error) {
31
13
  process.stderr.write(`${formatError(error)}\n`);
32
14
  process.exitCode = 1;
33
15
  }
34
16
  }
35
- function formatError(error) {
36
- if (error instanceof RequestError) {
37
- return messageForStatus(error);
17
+ async function dispatch(command, argv) {
18
+ // The commands' parseArgs is strict, so it would reject these as unknown
19
+ // options: they have to be answered before argv reaches one.
20
+ if (wantsHelp(command, argv)) {
21
+ process.stdout.write(helpFor(command));
22
+ return;
38
23
  }
39
- if (error instanceof Error) {
40
- return error.message;
24
+ if (command === "--version") {
25
+ process.stdout.write(`${await cliVersion()}\n`);
26
+ return;
41
27
  }
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.";
28
+ switch (command) {
29
+ case "declare":
30
+ return runDeclare(argv);
31
+ case "observe":
32
+ return runObserve(argv);
33
+ case "configure":
34
+ return runConfigure(argv);
58
35
  default:
59
- return error.message;
36
+ process.stderr.write(`${USAGE}\nRun \`gatebolt --help\` for details.\n`);
37
+ process.exitCode = 1;
38
+ return;
60
39
  }
61
40
  }
41
+ function wantsHelp(command, argv) {
42
+ if (isHelpFlag(command))
43
+ return true;
44
+ return isCommand(command) && argv.some(isHelpFlag);
45
+ }
46
+ function isHelpFlag(arg) {
47
+ return arg === "--help" || arg === "-h";
48
+ }
62
49
  void main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gatebolt/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.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",
@@ -40,6 +40,6 @@
40
40
  "build": "tsc -p tsconfig.build.json",
41
41
  "typecheck": "tsc --noEmit --emitDeclarationOnly false && tsc -p tsconfig.test.json",
42
42
  "test": "vitest run",
43
- "test:it": "vitest run --config vitest.it.config.ts"
43
+ "test:it": "pnpm build && vitest run --config vitest.it.config.ts"
44
44
  }
45
45
  }