@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 +4 -2
- package/package.json +1 -1
- package/skills/open-pr/SKILL.md +7 -3
- package/skills/ship-pr/SKILL.md +2 -1
- package/skills/update-dependencies/SKILL.md +6 -1
- package/src/bun/dependencies.ts +256 -0
- package/src/bun/execute.ts +80 -0
- package/src/bun/toolchain.ts +58 -0
- package/src/bun/workspace.ts +40 -0
- package/src/cli.ts +48 -3
- package/src/config.ts +7 -1
- package/src/dependencies/diff.ts +71 -0
- package/src/permissions/catalog.ts +102 -0
- package/src/permissions/install.ts +130 -0
- package/src/permissions/project.ts +10 -0
- package/src/pr/openPr.ts +6 -5
- package/src/skills/bundled.ts +3 -3
- package/src/version/lockfileVersions.ts +125 -1
- package/src/version/prepareVersions.ts +15 -2
- package/src/workflows/pr.ts +35 -2
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
|
|
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'
|
|
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
package/skills/open-pr/SKILL.md
CHANGED
|
@@ -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.
|
|
21
|
-
|
|
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'
|
|
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
|
package/skills/ship-pr/SKILL.md
CHANGED
|
@@ -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
|
|
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.
|
|
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]
|
|
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") {
|
|
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]);
|