@a2f0/agent-tool 0.1.23 → 0.1.25

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
@@ -45,10 +45,10 @@ checkout and explicit policy file.
45
45
  ```sh
46
46
  agent-tool branch create chore/maintenance --base main --remote origin
47
47
  agent-tool repo context
48
- agent-tool run --label tests -- bun test
48
+ agent-tool bun test --log
49
49
  agent-tool review claude
50
50
  agent-tool branch push <reviewed-head> --remote origin
51
- agent-tool pr open 'chore: maintain repository' < /tmp/pr-body.txt
51
+ agent-tool pr open 'chore: maintain repository' --body-file /tmp/pr-body.txt
52
52
  agent-tool pr checks --wait --head <reviewed-head>
53
53
  agent-tool pr preflight <reviewed-head> <base-branch> <reviewed-base>
54
54
  agent-tool pr merge 'chore: maintain repository' <reviewed-head> <base-branch>
@@ -85,6 +85,8 @@ validation suite, and release procedures.
85
85
  - [Development](docs/development.md): setup, source layout, validation, and release
86
86
  procedures for this package.
87
87
  - [Images](docs/images.md): declarative brand sources and generated outputs.
88
+ - [Permissions](docs/permissions.md): which commands run without approval in
89
+ Claude Code and Codex, and the dependency commands that enforce it.
88
90
  - [Dependency maintenance](docs/dependency-upgrades.md): active compatibility
89
91
  constraints and the dependency-update workflow.
90
92
  - [Design](docs/design.md) and [extraction history](docs/extraction.md): component
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "packageManager": "bun@1.4.3",
5
5
  "type": "module",
6
6
  "description": "Portable cross-agent review, PR workflows, and shared skills",
@@ -17,11 +17,15 @@ a changed result needs another review. Respect existing push authorization.
17
17
  If the user only requested a draft description, prepare it without publishing.
18
18
 
19
19
  Write a title that satisfies the data-only `agent-tool.json` subject policy.
20
- Describe the concrete resulting behavior and relevant validation. Put multiline
21
- body text in a temporary file, then run:
20
+ Describe the concrete resulting behavior and relevant validation. When the
21
+ branch changes package manifests, run
22
+ `agent-tool dependencies diff --base <base-oid>` and name every `newPackages`
23
+ entry in the body with its manifest and version, so a reviewer sees new
24
+ third-party code however it was added. Put multiline body text in a temporary
25
+ file, then run:
22
26
 
23
27
  ```sh
24
- agent-tool pr open 'feat: describe the change' < /path/to/pr-body.txt
28
+ agent-tool pr open 'feat: describe the change' --body-file /path/to/pr-body.txt
25
29
  ```
26
30
 
27
31
  Omitting the title uses the latest commit subject. The tool refuses duplicate
@@ -52,7 +52,8 @@ do not reduce test scope, skip checks, silence warnings, or mask failures.
52
52
  reply with `pr threads reply <comment-id>` (body file or stdin), and use
53
53
  `pr threads resolve <thread-id>` only for fully addressed findings.
54
54
  Validate, commit, push, and independently review every feedback or CI repair.
55
- Wait with `agent-tool pr checks --wait --head <reviewed-head>`, then run
55
+ Wait with `agent-tool pr checks --wait --head <reviewed-head>`; when a job
56
+ fails, read its log with `agent-tool pr logs --head <reviewed-head>`. Then run
56
57
  `agent-tool pr preflight <reviewed-head> <base-branch> <reviewed-base>` to
57
58
  recheck CI, live base, PR identity, local state, and explicit version policy.
58
59
  A changed base requires integration, validation, and another review. When
@@ -218,7 +218,12 @@ Capture relevant baseline checks and warnings first, after screening their side
218
218
  effects. Update one compatibility group at a time. Read the upstream instructions
219
219
  for every breaking step; implement required source/config/schema migrations and
220
220
  removed API replacements. Use the manager that owns each lockfile, on the selected
221
- runtime, and inspect the resulting diff. Refresh transitive dependencies through
221
+ runtime, and inspect the resulting diff. For Bun lockfiles, change declared
222
+ versions with `agent-tool bun update <name[@version]>` and drop packages with
223
+ `agent-tool bun remove <name>`; both use the pinned Bun and refuse to add a
224
+ package. A new dependency goes through `agent-tool bun add <name@version>`, which
225
+ asks for approval; never introduce one by editing `package.json` and
226
+ reinstalling. Refresh transitive dependencies through
222
227
  their owning direct dependency; do not hand-edit integrity hashes or lock entries.
223
228
  Review intentional patches, overrides, and pins before removal; retain them until
224
229
  upstream fixes and regression checks demonstrate they are unnecessary.
@@ -0,0 +1,256 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { closeSync, existsSync, fstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { createLog } from "../commands/logFile";
5
+ import { DEFAULT_LIMITS, printLogTail } from "../commands/output";
6
+ import { assert, flag, option, output } from "../workflows/core";
7
+ import { bunExecute } from "./execute";
8
+ import { pinnedBun, type BunExecutable } from "./toolchain";
9
+ import { lockedWorkspaceVersions, lostWorkspaceReferences } from "../version/lockfileVersions";
10
+ import { packageDirectories, workspacePackage } from "./workspace";
11
+
12
+ const SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"] as const;
13
+ type Section = typeof SECTIONS[number];
14
+ /** Sections Bun installs. Peer ranges are a published compatibility contract, so `update` never rewrites them. */
15
+ const INSTALLED: Section[] = ["dependencies", "devDependencies", "optionalDependencies"];
16
+ const SECTION_FLAGS: Record<Section, string[]> = { dependencies: [], devDependencies: ["--dev"], optionalDependencies: ["--optional"], peerDependencies: ["--peer"] };
17
+ const NAME = /^(?:@[a-z0-9~][a-z0-9._~-]*\/)?[a-z0-9~][a-z0-9._~-]*$/i;
18
+ const EXACT = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
19
+ const USAGE = `Usage:
20
+ agent-tool bun install [--cwd <dir>]
21
+ agent-tool bun add <name@version>... [--dev|--optional|--peer] [--exact] [--cwd <dir>]
22
+ agent-tool bun update <name[@version]>... [--cwd <dir>]
23
+ agent-tool bun remove <name>... [--cwd <dir>]
24
+ agent-tool bun test [--cwd <dir>] [--log] [-- args...]
25
+ agent-tool bun x <executable> [--cwd <dir>] [--log] [-- args...]`;
26
+
27
+ type Manifest = Record<string, unknown> & Partial<Record<Section, Record<string, string>>>;
28
+ interface Requested { name: string; spec?: string }
29
+ export interface DependencyChange { name: string; section: Section; from: string | null; to: string | null }
30
+
31
+ /** Registry versions, ranges, and tags; never a workspace, alias, Git, URL, or path source. */
32
+ export function registrySpec(spec: string): boolean { return /^[\w^~<>=*|][\w^~<>=*|.+ -]*$/.test(spec) && spec.trim() === spec; }
33
+
34
+ export function parseRequested(value: string): Requested {
35
+ const at = value.indexOf("@", value.startsWith("@") ? 1 : 0);
36
+ const name = at === -1 ? value : value.slice(0, at);
37
+ const spec = at === -1 ? undefined : value.slice(at + 1);
38
+ assert(NAME.test(name), `Invalid package name '${name}'.`);
39
+ assert(spec === undefined || (spec.trim().length > 0 && !/[\r\n\t]/.test(spec) && !spec.startsWith("-")), `Invalid version for ${name}.`);
40
+ return { name, spec };
41
+ }
42
+
43
+ function readManifest(file: string): Manifest {
44
+ const value = JSON.parse(readFileSync(file, "utf8")) as unknown;
45
+ assert(value && typeof value === "object" && !Array.isArray(value), `${file} is not a JSON object.`);
46
+ for (const section of SECTIONS) {
47
+ const entries = (value as Manifest)[section];
48
+ assert(entries === undefined || (typeof entries === "object" && !Array.isArray(entries) && Object.values(entries).every(spec => typeof spec === "string")), `${file} has an invalid ${section} section.`);
49
+ }
50
+ return value as Manifest;
51
+ }
52
+
53
+ function declarations(manifest: Manifest, name: string): { section: Section; spec: string }[] {
54
+ return SECTIONS.flatMap(section => { const spec = manifest[section]?.[name]; return spec === undefined ? [] : [{ section, spec }]; });
55
+ }
56
+
57
+ /** Compare everything except dependency sections, which callers check entry by entry. */
58
+ function otherFields(manifest: Manifest): string {
59
+ const rest: Record<string, unknown> = { ...manifest };
60
+ for (const section of SECTIONS) delete rest[section];
61
+ return JSON.stringify(rest);
62
+ }
63
+
64
+ /**
65
+ * Check that Bun changed only what the action allows. `update` may change only the
66
+ * requested versions; `remove` may only delete requested names; `add` may only create
67
+ * requested names in one section. Any other manifest change fails and is restored.
68
+ */
69
+ export function manifestViolations(action: "add" | "update" | "remove", before: Manifest, after: Manifest, names: Set<string>, addSection?: Section): string[] {
70
+ const violations: string[] = [];
71
+ if (otherFields(before) !== otherFields(after)) violations.push("package.json fields outside dependency sections changed.");
72
+ if (action === "add") for (const name of names) if (!(name in (after[addSection!] ?? {}))) violations.push(`${name} was not added to ${addSection}.`);
73
+ for (const section of SECTIONS) {
74
+ const old = before[section] ?? {}, now = after[section] ?? {};
75
+ for (const name of new Set([...Object.keys(old), ...Object.keys(now)])) {
76
+ const requested = names.has(name);
77
+ if (action === "remove" && requested) { if (name in now) violations.push(`${name} is still declared in ${section}.`); continue; }
78
+ if (action === "add" && requested && section === addSection) { if (!(name in now)) violations.push(`${name} was not added to ${section}.`); continue; }
79
+ if (action === "update" && requested && section !== "peerDependencies" && name in old) {
80
+ if (!(name in now)) violations.push(`${name} was removed from ${section}.`);
81
+ else if (!registrySpec(now[name]!)) violations.push(`${name} in ${section} no longer uses a registry version (${now[name]}).`);
82
+ continue;
83
+ }
84
+ if (!(name in old)) violations.push(`${name} was unexpectedly added to ${section}.`);
85
+ else if (!(name in now)) violations.push(`${name} was unexpectedly removed from ${section}.`);
86
+ else if (old[name] !== now[name]) violations.push(`${name} in ${section} unexpectedly changed from ${old[name]} to ${now[name]}.`);
87
+ }
88
+ }
89
+ return violations;
90
+ }
91
+
92
+ function changes(before: Manifest, after: Manifest, names: Set<string>): DependencyChange[] {
93
+ return SECTIONS.flatMap(section => [...names].flatMap(name => {
94
+ const from = before[section]?.[name] ?? null, to = after[section]?.[name] ?? null;
95
+ return from === to ? [] : [{ name, section, from, to }];
96
+ }));
97
+ }
98
+
99
+ /** Saved bytes for each file, with a way to put them back and to list which ones changed. */
100
+ function snapshot(files: string[]) {
101
+ const saved = files.map(file => ({ file, contents: existsSync(file) ? readFileSync(file) : null }));
102
+ return {
103
+ /** Restore every file even if one fails, recreating directories a script removed. */
104
+ restore() {
105
+ const failures: string[] = [];
106
+ for (const { file, contents } of saved) {
107
+ try {
108
+ if (contents) { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, contents); }
109
+ else if (existsSync(file)) unlinkSync(file);
110
+ } catch (error) { failures.push(`${file}: ${(error as Error).message}`); }
111
+ }
112
+ if (failures.length) throw new Error(`Could not restore ${failures.join("; ")}`);
113
+ },
114
+ changed(): string[] {
115
+ return saved.filter(({ file, contents }) => {
116
+ const now = existsSync(file) ? readFileSync(file) : null;
117
+ return !(now === null ? contents === null : contents !== null && now.equals(contents));
118
+ }).map(({ file }) => file);
119
+ },
120
+ };
121
+ }
122
+
123
+ function runBun(bun: BunExecutable, directory: string, args: string[]) {
124
+ const { log, fd } = createLog("agent-tool-bun-");
125
+ const started = performance.now();
126
+ try {
127
+ const result = spawnSync(bun.path, args, { cwd: directory, stdio: ["ignore", fd, fd], env: process.env });
128
+ const exitCode = result.error ? 126 : result.status ?? 1;
129
+ return { command: ["bun", ...args], exitCode, signal: result.signal ?? null, durationMs: Math.round(performance.now() - started), outputBytes: fstatSync(fd).size, log };
130
+ } finally { closeSync(fd); }
131
+ }
132
+
133
+ function parseArgs(action: string, args: string[]) {
134
+ const sections = (["--dev", "--optional", "--peer"] as const).filter(name => flag(args, name));
135
+ const exact = flag(args, "--exact");
136
+ for (const value of args) assert(!value.startsWith("-"), `Unknown bun ${action} argument '${value}'.\n${USAGE}`);
137
+ if (action === "add") assert(sections.length <= 1, "Choose at most one of --dev, --optional, and --peer.");
138
+ else assert(!sections.length && !exact, `bun ${action} keeps each dependency's section and pin style; section and --exact flags apply to add.`);
139
+ const requested = args.map(parseRequested);
140
+ assert(action === "install" ? !requested.length : requested.length > 0, action === "install" ? "bun install takes no packages; add one with `agent-tool bun add`." : USAGE);
141
+ assert(new Set(requested.map(item => item.name)).size === requested.length, "Each package may be named once.");
142
+ const addSection: Section = sections[0] === "--dev" ? "devDependencies" : sections[0] === "--optional" ? "optionalDependencies" : sections[0] === "--peer" ? "peerDependencies" : "dependencies";
143
+ return { requested, exact, addSection };
144
+ }
145
+
146
+ /** Plan the Bun invocations for one action, refusing anything outside its permission class. */
147
+ function plan(action: "add" | "update" | "remove", manifest: Manifest, requested: Requested[], exact: boolean, addSection: Section, workspaceNames: Set<string>): string[][] {
148
+ if (action === "add") {
149
+ for (const { name, spec } of requested) {
150
+ assert(!declarations(manifest, name).length, `${name} is already declared; use \`agent-tool bun update ${name}\` to change its version.`);
151
+ assert(spec && (registrySpec(spec) || /^workspace:[^\s]*$/.test(spec)), `Name an explicit registry or workspace version to add ${name}, for example ${name}@1.2.3.`);
152
+ }
153
+ return [["add", ...SECTION_FLAGS[addSection], ...(exact ? ["--exact"] : []), ...requested.map(({ name, spec }) => `${name}@${spec}`)]];
154
+ }
155
+ if (action === "remove") {
156
+ for (const { name, spec } of requested) {
157
+ assert(spec === undefined, `Name only the package to remove: ${name}.`);
158
+ assert(declarations(manifest, name).length, `${name} is not declared in this package.json; nothing to remove.`);
159
+ }
160
+ return [["remove", ...requested.map(({ name }) => name)]];
161
+ }
162
+ // Group by section and pin style so each Bun call keeps both unchanged.
163
+ const groups = new Map<string, string[]>();
164
+ for (const { name, spec } of requested) {
165
+ const installed = declarations(manifest, name).filter(item => INSTALLED.includes(item.section));
166
+ assert(installed.length, `${name} is not an installed dependency here; adding a package needs \`agent-tool bun add\`.`);
167
+ // A version change can move a workspace reference to the registry package of the same name.
168
+ assert(!workspaceNames.has(name), `${name} is a workspace package; change references to it by hand.`);
169
+ assert(installed.length === 1, `${name} is declared in ${installed.map(item => item.section).join(" and ")}; update it manually.`);
170
+ const current = installed[0]!;
171
+ assert(registrySpec(current.spec), `${name} uses a non-registry source (${current.spec}); update it manually.`);
172
+ assert(spec === undefined || registrySpec(spec), `${name} can only be updated to a registry version or tag.`);
173
+ // An explicit version is written as given; moving to latest keeps an exact pin exact.
174
+ const flags = [...SECTION_FLAGS[current.section], ...(EXACT.test(spec ?? current.spec) ? ["--exact"] : [])];
175
+ const key = flags.join(" ");
176
+ groups.set(key, [...(groups.get(key) ?? []), `${name}@${spec ?? "latest"}`]);
177
+ }
178
+ return [...groups].map(([flags, packages]) => ["add", ...(flags ? flags.split(" ") : []), ...packages]);
179
+ }
180
+
181
+ export function bunCommand(args: string[]): number | Promise<number> {
182
+ const action = args.shift();
183
+ if (action === "test" || action === "x") return bunExecute(action, args);
184
+ assert(action === "install" || action === "add" || action === "update" || action === "remove", USAGE);
185
+ const target = path.resolve(option(args, "--cwd") ?? ".");
186
+ assert(existsSync(path.join(target, "package.json")), `No package.json in ${target}.`);
187
+ const { root, directory } = workspacePackage(target);
188
+ const file = path.join(directory, "package.json");
189
+ const { requested, exact, addSection } = parseArgs(action, args);
190
+ const bun = pinnedBun(directory, root);
191
+ const bunInfo = { path: bun.path, version: bun.version, source: bun.source, pinned: bun.pinned };
192
+ const before = readManifest(file);
193
+ const lockfiles = [path.join(root, "bun.lock"), path.join(root, "bun.lockb")];
194
+ // Workspaces share the root lockfile; Bun creating one beside any package means it was treated as standalone.
195
+ const nested = packageDirectories(root).filter(item => item !== root).flatMap(item => [path.join(item, "bun.lock"), path.join(item, "bun.lockb")]);
196
+ // Without a lockfile, Bun resolves whatever package.json names, including hand-added packages.
197
+ assert(lockfiles.some(item => existsSync(item)), "No bun.lock was found; a first install resolves new packages, so run it directly with approval.");
198
+ // Before a change, the lockfile must already cover every declaration, or Bun would also resolve a hand-added package.
199
+ const preflight = ["install", "--frozen-lockfile", "--lockfile-only"];
200
+ const workspaceNames = new Set(packageDirectories(root).map(item => (JSON.parse(readFileSync(path.join(item, "package.json"), "utf8")) as { name?: string }).name).filter((name): name is string => typeof name === "string"));
201
+ const calls = action === "install" ? [["install", "--frozen-lockfile"]] : [preflight, ...plan(action, before, requested, exact, addSection, workspaceNames)];
202
+ // The checks below read the text lockfile; a binary bun.lockb cannot be verified.
203
+ if (action !== "install") assert(existsSync(path.join(root, "bun.lock")), "Changing dependencies needs a text bun.lock; convert bun.lockb with approval first.");
204
+ if (action !== "install") {
205
+ // A frozen check can accept stale workspace versions; an unfrozen call would then fetch such a reference.
206
+ const lost = lostWorkspaceReferences(root, lockedWorkspaceVersions(root));
207
+ assert(!lost.length, `${lost.join(", ")} no longer match their workspaces, so Bun would fetch them from the registry; fix those references before changing dependencies.`);
208
+ }
209
+ // Lifecycle scripts can touch any workspace manifest; every one but the target must stay as it was.
210
+ const others = packageDirectories(root).map(item => path.join(item, "package.json")).filter(item => item !== file && existsSync(item));
211
+ const othersBefore = new Map(others.map(item => [item, JSON.stringify(readManifest(item))]));
212
+ const restored = [...new Set([file, ...others, ...lockfiles, ...nested])];
213
+ const saved = snapshot(restored);
214
+ const names = new Set(requested.map(item => item.name));
215
+ const runs = [];
216
+ let violations: string[] = [];
217
+ let after = before;
218
+ // Validate after every Bun call: a lifecycle script in one call must not feed the next.
219
+ const known = new Set([file, ...others]);
220
+ // A lifecycle script can create a workspace, or widen the globs to one, for a later call to resolve.
221
+ const discover = (): string[] => packageDirectories(root).map(item => path.join(item, "package.json")).filter(item => !known.has(item) && existsSync(item));
222
+ // Call after restoring the root manifest: under the original globs, every manifest that existed
223
+ // before is known, so anything else was created during the change. Widened globs never delete.
224
+ const removeCreated = () => { for (const item of discover()) unlinkSync(item); };
225
+ const validate = (call: string[]): string[] => {
226
+ const appeared = discover();
227
+ if (appeared.length) return appeared.map(item => `${path.relative(root, item)} appeared during the change.`);
228
+ if (call === preflight || action === "install") return saved.changed().map(item => `${path.relative(directory, item) || "package.json"} changed during a frozen install.`);
229
+ after = readManifest(file);
230
+ const found = manifestViolations(action, before, after, names, addSection);
231
+ for (const item of others) if (JSON.stringify(readManifest(item)) !== othersBefore.get(item)) found.push(`${path.relative(root, item)} changed outside the target package.`);
232
+ for (const item of saved.changed().filter(changed => nested.includes(changed))) found.push(`${path.relative(root, item)} changed beside a workspace package.`);
233
+ return found;
234
+ };
235
+ try {
236
+ for (const call of calls) {
237
+ const run = runBun(bun, directory, call);
238
+ runs.push(run);
239
+ if (run.exitCode !== 0) { violations = [call === preflight ? "package.json and the lockfile disagree; resolve that difference with approval before changing dependencies." : `bun ${call[0]} failed with exit ${run.exitCode}.`]; break; }
240
+ violations = validate(call);
241
+ if (violations.length) break;
242
+ }
243
+ } catch (error) {
244
+ saved.restore();
245
+ removeCreated();
246
+ throw error;
247
+ }
248
+ // Leave no partial dependency change behind; node_modules is rebuilt by the next frozen install.
249
+ if (violations.length) {
250
+ saved.restore();
251
+ removeCreated();
252
+ }
253
+ output({ ok: !violations.length, action, directory, bun: bunInfo, changes: violations.length ? [] : changes(before, after, names), ...(violations.length ? { violations, restored: restored.map(item => path.relative(directory, item) || "package.json") } : {}), runs });
254
+ if (runs.at(-1)?.exitCode) printLogTail(runs.at(-1)!.log, DEFAULT_LIMITS, 2);
255
+ return violations.length ? 1 : 0;
256
+ }
@@ -0,0 +1,80 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants as files, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { constants } from "node:os";
4
+ import path from "node:path";
5
+ import { runCommand } from "../commands/run";
6
+ import { assert, flag, option } from "../workflows/core";
7
+ import { CLASSES } from "../permissions/catalog";
8
+ import { checkPermissions } from "../permissions/install";
9
+ import { projectCommands } from "../permissions/project";
10
+ import { pinnedBun } from "./toolchain";
11
+ import { workspacePackage } from "./workspace";
12
+
13
+ const USAGE = `Usage:
14
+ agent-tool bun test [--cwd <dir>] [--log] [-- args...]
15
+ agent-tool bun x <executable> [--cwd <dir>] [--log] [-- args...]`;
16
+ const EXECUTABLE = /^[\w.+-][\w.+-]*$/;
17
+
18
+ /**
19
+ * An executable whose class asks relies on a generated ask rule. If the recorded rules predate
20
+ * its classification, an older allowance could run it without approval, so refuse until they
21
+ * are regenerated.
22
+ */
23
+ function assertCurrentRules(root: string, command: string): void {
24
+ if (!existsSync(path.join(root, ".agent-tool-permissions.json"))) return;
25
+ // Only a current allow class matches whatever rules exist; a removed or asking class relies on fresh ones.
26
+ const entry = projectCommands(root).find(item => item.command === command);
27
+ if (entry && CLASSES[entry.class].decision === "allow") return;
28
+ const recorded = JSON.parse(readFileSync(path.join(root, ".agent-tool-permissions.json"), "utf8")) as { claude?: unknown; codex?: unknown };
29
+ const targets = [...(recorded.claude ? ["claude"] : []), ...(recorded.codex ? ["codex"] : [])];
30
+ const stale = targets.flatMap(target => checkPermissions(root, target)).filter(item => item.status !== "unchanged");
31
+ assert(!stale.length, `Permission rules predate the current class of '${command}'; regenerate them with approval (agent-tool permissions install --apply) before running it.`);
32
+ }
33
+
34
+ /** The package directory's or root's `node_modules/.bin/<name>`: an executable regular file inside the checkout. */
35
+ function installedBinary(root: string, directory: string, name: string): string {
36
+ for (const base of [directory, root]) {
37
+ const link = path.join(base, "node_modules/.bin", name);
38
+ if (!existsSync(link)) continue;
39
+ const file = realpathSync(link), relative = path.relative(root, file);
40
+ assert(statSync(file).isFile() && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative), `${link} is not an installed binary inside this checkout.`);
41
+ try { accessSync(file, files.X_OK); } catch { throw new Error(`${link} is not executable.`); }
42
+ return link;
43
+ }
44
+ throw new Error(`${name} is not installed in this checkout's node_modules/.bin.`);
45
+ }
46
+
47
+ /**
48
+ * Run the test runner or an installed executable with the pinned Bun. The executable name comes
49
+ * first and options precede `--`, so a harness rule can classify the command by its prefix.
50
+ */
51
+ export function bunExecute(action: "test" | "x", args: string[]): number | Promise<number> {
52
+ const separator = args.indexOf("--");
53
+ const options = separator === -1 ? args : args.slice(0, separator);
54
+ const forwarded = separator === -1 ? [] : args.slice(separator + 1);
55
+ const name = action === "x" ? options.shift() : undefined;
56
+ assert(action === "test" || (name && !name.startsWith("-")), `Name the executable first.\n${USAGE}`);
57
+ const target = path.resolve(option(options, "--cwd") ?? ".");
58
+ const log = flag(options, "--log");
59
+ assert(!options.length, `Unexpected bun ${action} argument '${options[0]}'; pass the command's own arguments after --.\n${USAGE}`);
60
+ const { root, directory } = workspacePackage(target);
61
+ const bun = pinnedBun(directory, root);
62
+ let executable = bun.path, command: string[];
63
+ if (action === "x") {
64
+ assert(EXECUTABLE.test(name!), `Name an installed executable, not a package or version: '${name}'.`);
65
+ // Run this checkout's own installed binary directly, so neither PATH nor Bun's cache can stand in.
66
+ executable = installedBinary(root, directory, name!);
67
+ assertCurrentRules(root, `bun x ${name}`);
68
+ command = forwarded;
69
+ } else command = ["test", ...forwarded];
70
+ // Tests and executables that call bun themselves get the pinned version too.
71
+ const env = { ...process.env, PATH: [path.dirname(bun.path), process.env.PATH].filter(Boolean).join(path.delimiter) };
72
+ if (log) {
73
+ process.chdir(directory);
74
+ process.env.PATH = env.PATH;
75
+ return runCommand(["--label", ["bun", action, name].filter(Boolean).join(" "), "--", executable, ...command]);
76
+ }
77
+ const result = spawnSync(executable, command, { cwd: directory, env, stdio: "inherit" });
78
+ if (result.error) throw result.error;
79
+ return result.signal ? 128 + (constants.signals[result.signal] ?? 0) : result.status ?? 1;
80
+ }
@@ -0,0 +1,58 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { outsideExecutable } from "../review/executable";
6
+ import { assert } from "../workflows/core";
7
+
8
+ export interface BunExecutable { path: string; version: string; source: "mise" | "path"; pinned: string | null }
9
+
10
+ /** The nearest mise config's bun pin, read the way `toolchain check` reads `.mise.toml`. */
11
+ export function misePin(directory: string): { file: string; version: string } | null {
12
+ for (let current = path.resolve(directory); ; current = path.dirname(current)) {
13
+ for (const name of [".mise.toml", "mise.toml"]) {
14
+ const file = path.join(current, name);
15
+ if (!existsSync(file)) continue;
16
+ const tools = (Bun.TOML.parse(readFileSync(file, "utf8")) as { tools?: Record<string, unknown> }).tools ?? {};
17
+ const pin = tools.bun;
18
+ if (pin === undefined) continue;
19
+ assert(typeof pin === "string" && /^[0-9][0-9A-Za-z.+-]*$/.test(pin), `Unsupported bun pin in ${file}; use a version string.`);
20
+ return { file, version: pin };
21
+ }
22
+ if (current === path.dirname(current)) return null;
23
+ }
24
+ }
25
+
26
+ function versionOf(executable: string): string {
27
+ const result = spawnSync(executable, ["--version"], { encoding: "utf8", timeout: 10000 });
28
+ assert(result.status === 0, `Could not run ${executable} --version.`);
29
+ return result.stdout.trim();
30
+ }
31
+
32
+ function runnable(file: string): boolean {
33
+ try { accessSync(file, constants.X_OK); return true; } catch { return false; }
34
+ }
35
+
36
+ /**
37
+ * Resolve the Bun a checkout pins, without relying on the agent shell's PATH.
38
+ * Agent shells do not activate mise, so the pinned install is used directly when present.
39
+ */
40
+ export function pinnedBun(directory: string, root: string): BunExecutable {
41
+ const pin = misePin(directory)?.version ?? null;
42
+ const inside = (file: string) => { const relative = path.relative(realpathSync(root), realpathSync(file)); return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); };
43
+ if (pin) {
44
+ const data = process.env.MISE_DATA_DIR || path.join(process.env.XDG_DATA_HOME || path.join(homedir(), ".local/share"), "mise");
45
+ const candidate = path.join(data, "installs", "bun", pin, "bin", "bun");
46
+ if (runnable(candidate) && !inside(candidate)) {
47
+ const version = versionOf(candidate);
48
+ assert(version === pin || version.startsWith(`${pin}.`), `${candidate} reports Bun ${version}, not the pinned ${pin}; reinstall it with mise.`);
49
+ return { path: candidate, version, source: "mise", pinned: pin };
50
+ }
51
+ }
52
+ // A `bun` inside the checkout (node_modules/.bin is on PATH under `bun run`) is repository code; never run it.
53
+ const found = outsideExecutable("bun", process.env, root)?.candidate;
54
+ assert(found, "No Bun outside this checkout is on PATH.");
55
+ const version = versionOf(found);
56
+ assert(!pin || version === pin || version.startsWith(`${pin}.`), `Bun ${pin} from the mise config is not installed, and PATH has ${version}. Install the pinned Bun before changing dependencies.`);
57
+ return { path: found, version, source: "path", pinned: pin };
58
+ }
@@ -0,0 +1,40 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { assert } from "../workflows/core";
5
+
6
+ /** The checkout root, where the workspace manifest and its lockfile live. */
7
+ export function projectRoot(directory: string): string {
8
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: directory, encoding: "utf8" });
9
+ assert(result.status === 0, "Run Bun commands inside a Git checkout.");
10
+ return realpathSync(result.stdout.trim());
11
+ }
12
+
13
+ /** The root package and its declared Bun workspaces: the packages that share the root lockfile. */
14
+ export function packageDirectories(root: string): string[] {
15
+ const manifest = path.join(root, "package.json");
16
+ if (!existsSync(manifest)) return [];
17
+ const value = JSON.parse(readFileSync(manifest, "utf8")) as { workspaces?: unknown };
18
+ const patterns = Array.isArray(value.workspaces) ? value.workspaces : (value.workspaces as { packages?: unknown } | undefined)?.packages;
19
+ const list = (Array.isArray(patterns) ? patterns : []).filter((pattern): pattern is string => typeof pattern === "string");
20
+ // `!pattern` entries exclude directories that the other patterns match.
21
+ const excluded = list.filter(pattern => pattern.startsWith("!")).map(pattern => new Bun.Glob(pattern.slice(1).replace(/\/$/, "")));
22
+ const directories = new Set([root]);
23
+ for (const pattern of list.filter(pattern => !pattern.startsWith("!"))) {
24
+ for (const match of new Bun.Glob(`${pattern.replace(/\/$/, "")}/package.json`).scanSync({ cwd: root, onlyFiles: true })) {
25
+ const relative = path.dirname(match);
26
+ if (!match.split("/").includes("node_modules") && !excluded.some(glob => glob.match(relative))) directories.add(path.join(root, relative));
27
+ }
28
+ }
29
+ return [...directories];
30
+ }
31
+
32
+ /** Resolve a package directory that belongs to the checkout's workspace, refusing standalone packages. */
33
+ export function workspacePackage(directory: string): { root: string; directory: string } {
34
+ const resolved = realpathSync(directory);
35
+ const root = projectRoot(resolved);
36
+ // Harness rules come from the invoking checkout; --cwd may not reach another repository.
37
+ assert(root === projectRoot(process.cwd()), "--cwd must name a package in this checkout.");
38
+ assert(packageDirectories(root).some(item => realpathSync(item) === resolved), "Use the root package or a declared workspace; standalone packages keep their own lockfiles.");
39
+ return { root, directory: resolved };
40
+ }
package/src/cli.ts CHANGED
@@ -4,10 +4,13 @@ import path from "node:path";
4
4
  import { DEFAULT_CONFIG, loadConfig } from "./config";
5
5
  import { doctor } from "./doctor";
6
6
  import { checkTerraformPlan } from "./dependencies/terraformPlan";
7
+ import { dependencyDiff } from "./dependencies/diff";
7
8
  import { review } from "./harnesses";
8
9
  import { runAgentToolAction } from "./runAgentToolAction";
9
10
  import { BUNDLED_SKILLS } from "./skills/bundled";
10
11
  import { checkSkills, installSkills } from "./skills/install";
12
+ import { bunCommand } from "./bun/dependencies";
13
+ import { checkPermissions, installPermissions, showPermissions } from "./permissions/install";
11
14
  import { bumpVersions, checkVersions, planVersions } from "./version/bumpVersions";
12
15
  import { prepareVersions } from "./version/prepareVersions";
13
16
  import { resolveVersionConflicts } from "./version/resolveVersionConflicts";
@@ -45,17 +48,28 @@ Usage: agent-tool [--repo <directory>] [--config <file>] <command>
45
48
  release verify <package> <version> <merge-oid> --workflow name [--timeout seconds]
46
49
  logs <file> [--max-bytes N] [--tail-lines N] Bounded local log tail
47
50
  review <claude|codex|opencode> [effort] [--base <local-ref>]
48
- pr open [title] Body from stdin; branch already pushed
51
+ pr open [title] [--body-file file] Body from the file or stdin; branch already pushed
49
52
  pr merge <subject-or-empty> <head-oid> <base-branch>
50
53
  pr context | checks [--wait] [--head oid] [--timeout seconds]
54
+ pr logs [--head oid] [--max-bytes N] [--tail-lines N] Failed GitHub Actions job logs
51
55
  pr preflight <reviewed-head> <base-branch> <reviewed-base>
52
56
  pr threads list | reply <comment-id> [--body-file file] | resolve <thread-id>
53
57
  versions <plan|bump|check|prepare> <base-oid>
54
58
  versions resolve-conflicts
55
59
  dependencies check-terraform-plan <json-file> Read-only destructive-plan check
60
+ dependencies diff --base <ref> Declared dependencies added, removed, or changed since base
56
61
  skills list
57
62
  skills install [--harness <all|claude|codex|opencode>] [--apply]
58
63
  skills check [--harness <all|claude|codex|opencode>]
64
+ bun install [--cwd <dir>] Frozen-lockfile install with the pinned Bun
65
+ bun add <name@version>... [--dev|--optional|--peer] [--exact] [--cwd <dir>]
66
+ bun update <name[@version]>... [--cwd <dir>] Change versions of declared dependencies only
67
+ bun remove <name>... [--cwd <dir>] Remove declared dependencies only
68
+ bun test [--cwd <dir>] [--log] [-- args...]
69
+ bun x <executable> [--cwd <dir>] [--log] [-- args...] Installed executables only, never fetched
70
+ permissions show [--harness <all|claude|codex>]
71
+ permissions install [--harness <all|claude|codex>] [--apply]
72
+ permissions check [--harness <all|claude|codex>]
59
73
  images list [target...] JSON files each image target writes
60
74
  images build [target...] [--out <dir>] Render assets/brand/*.svg per agent-tool.json
61
75
  init Create agent-tool.json without overwriting
@@ -77,6 +91,8 @@ function gitRoot(): string {
77
91
 
78
92
  export function main(argv = process.argv.slice(2)): number | Promise<number> {
79
93
  const args = [...argv];
94
+ // File arguments are relative to where agent-tool was invoked, before --repo or the root change.
95
+ const invoked = process.cwd();
80
96
  while (args[0] === "--repo" || args[0] === "--config") {
81
97
  const flag = args.shift()!;
82
98
  const value = args.shift();
@@ -94,6 +110,10 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
94
110
  if (command === "ci") { usage(args.shift() === "gate", "Usage: ci gate <needs.json|-> [--policy policy.json]"); return gateCommand(args); }
95
111
  if (command === "release") return releaseCommand(args);
96
112
  if (command === "logs") return inspectLog(args);
113
+ if (command === "dependencies" && args[0] === "diff") {
114
+ usage(args.length === 3 && args[1] === "--base" && !!args[2] && !args[2].startsWith("-"), "Usage: agent-tool dependencies diff --base <ref>");
115
+ output(dependencyDiff(args[2]!)); return 0;
116
+ }
97
117
  if (command === "dependencies") {
98
118
  usage(args.length === 2 && args[0] === "check-terraform-plan", "Usage: agent-tool dependencies check-terraform-plan <json-file>");
99
119
  const source = readFileSync(args[1], "utf8");
@@ -147,6 +167,25 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
147
167
  }
148
168
  output({ applied: apply, changes: installSkills(process.cwd(), target, apply) }); return 0;
149
169
  }
170
+ if (command === "bun") return bunCommand(args);
171
+ if (command === "permissions") {
172
+ const action = args.shift();
173
+ usage(action === "show" || action === "install" || action === "check", "Usage: agent-tool permissions <show|install|check> [--harness <all|claude|codex>] [--apply]");
174
+ let target = "all", apply = false;
175
+ while (args.length) {
176
+ const flag = args.shift();
177
+ if (flag === "--apply") { usage(action === "install" && !apply, "--apply applies once, to permissions install."); apply = true; }
178
+ else if (flag === "--harness") { const value = args.shift(); usage(!!value, "--harness requires a name."); target = value!; }
179
+ else throw new Error(`Unknown permissions ${action} argument '${flag}'.`);
180
+ }
181
+ if (action === "show") { writeSync(1, showPermissions(process.cwd(), target)); return 0; }
182
+ if (action === "check") {
183
+ const changes = checkPermissions(process.cwd(), target);
184
+ const ok = changes.every(change => change.status === "unchanged");
185
+ output({ ok, changes }); return ok ? 0 : 1;
186
+ }
187
+ output({ applied: apply, changes: installPermissions(process.cwd(), target, apply) }); return 0;
188
+ }
150
189
  const root = gitRoot();
151
190
  process.chdir(root);
152
191
  loadConfig(root); // Validate policy before running a reviewer or mutating GitHub.
@@ -170,9 +209,15 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
170
209
  return review(root, name!, effort, base);
171
210
  }
172
211
  if (command === "pr") {
173
- if (["context", "checks", "preflight", "threads"].includes(args[0]!)) return prWorkflowCommand(args);
212
+ if (["context", "checks", "logs", "preflight", "threads"].includes(args[0]!)) return prWorkflowCommand(args);
174
213
  const action = args.shift();
175
- if (action === "open") { usage(args.length <= 1, "Usage: agent-tool pr open [title]"); return openPr(root, args[0]); }
214
+ if (action === "open") {
215
+ const index = args.indexOf("--body-file");
216
+ const given = index === -1 ? undefined : args.splice(index, 2)[1];
217
+ const bodyFile = given === undefined ? undefined : path.resolve(invoked, given);
218
+ usage((index === -1 || !!given) && args.length <= 1 && !args.includes("--body-file"), "Usage: agent-tool pr open [title] [--body-file <file>]");
219
+ return openPr(root, args[0], bodyFile);
220
+ }
176
221
  usage(action === "merge" && args.length === 3, "Usage: agent-tool pr merge <subject-or-empty> <reviewed-head-oid> <base-branch>");
177
222
  usage(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(args[1]!), "Merge requires a full reviewed HEAD OID.");
178
223
  return squashMerge(root, args[0], args[1], args[2]);