@a2f0/agent-tool 0.1.4 → 0.1.6
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 +11 -2
- package/package.json +1 -1
- package/src/config.ts +5 -5
- package/src/doctor.ts +1 -1
- package/src/version/bumpVersions.ts +64 -12
- package/src/version/bundledPackages.ts +83 -0
- package/src/version/packageVersion.ts +6 -2
package/README.md
CHANGED
|
@@ -71,8 +71,8 @@ platform archives, a Homebrew formula, and Debian packages.
|
|
|
71
71
|
- Bun 1.4 or newer on `PATH` for `versions prepare`, with either install,
|
|
72
72
|
because it refreshes workspace versions in `bun.lock`.
|
|
73
73
|
|
|
74
|
-
Run `agent-tool doctor` to report installed versions and
|
|
75
|
-
without calling a model.
|
|
74
|
+
Run `agent-tool doctor` to report installed versions and the flags the tool needs
|
|
75
|
+
from each CLI, such as `gh pr checks --json` for merging, without calling a model.
|
|
76
76
|
|
|
77
77
|
## Quick start
|
|
78
78
|
|
|
@@ -143,6 +143,7 @@ feature checkout with policy from a trusted source.
|
|
|
143
143
|
},
|
|
144
144
|
"versions": {
|
|
145
145
|
"packages": ["packages/api", "packages/client"],
|
|
146
|
+
"bundles": ["packages/client"],
|
|
146
147
|
"lockfile": "bun.lock",
|
|
147
148
|
"validate": [["bun", "run", "lint", "--", "--staged"]],
|
|
148
149
|
"commitMessage": "chore: bump package versions"
|
|
@@ -172,6 +173,14 @@ including files in other configured packages. A changed package moves one patch
|
|
|
172
173
|
past the base; a deliberate major or minor bump is kept. `plan`, `bump`, and
|
|
173
174
|
`check` neither regenerate lockfiles nor commit.
|
|
174
175
|
|
|
176
|
+
`versions.bundles` names versioned packages whose published artifact includes
|
|
177
|
+
the workspace packages they depend on. Such a package also counts as changed
|
|
178
|
+
when any workspace package it reaches through `workspace:` ranges in
|
|
179
|
+
`dependencies` changes, directly or transitively; an alias range such as
|
|
180
|
+
`workspace:crypto@*` names its target. `devDependencies` and
|
|
181
|
+
`peerDependencies` do not count. A bundled package whose only change is its
|
|
182
|
+
version does not move the bundle.
|
|
183
|
+
|
|
175
184
|
`versions prepare` runs the whole sequence before a review snapshot:
|
|
176
185
|
|
|
177
186
|
1. It requires a clean worktree on an attached branch with the exact base
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface AgentToolConfig {
|
|
|
11
11
|
subject: { conventional: boolean; maxLength: number; types: string[] };
|
|
12
12
|
merge: { requireChecks: boolean; requiredChecks: RequiredCheck[]; requireStrictBaseFreshness: boolean };
|
|
13
13
|
review: { claudeModel: string | null; codexModel: string | null; opencodeModel: string; opencodeVariants: Record<string, string>; timeoutMs: number };
|
|
14
|
-
versions: { packages: string[] | null; lockfile: "bun.lock" | null; validate: string[][]; commitMessage: string };
|
|
14
|
+
versions: { packages: string[] | null; bundles: string[]; lockfile: "bun.lock" | null; validate: string[][]; commitMessage: string };
|
|
15
15
|
pr: { rejectClaudeBranding: boolean };
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -24,7 +24,7 @@ export const DEFAULT_CONFIG: AgentToolConfig = {
|
|
|
24
24
|
},
|
|
25
25
|
merge: { requireChecks: true, requiredChecks: [], requireStrictBaseFreshness: false },
|
|
26
26
|
review: { claudeModel: null, codexModel: null, opencodeModel: "deepseek/deepseek-v4-pro", opencodeVariants: { low: "low", medium: "medium", high: "high", xhigh: "max", max: "max" }, timeoutMs: 600_000 },
|
|
27
|
-
versions: { packages: null, lockfile: "bun.lock", validate: [], commitMessage: "chore: bump package versions" },
|
|
27
|
+
versions: { packages: null, bundles: [], lockfile: "bun.lock", validate: [], commitMessage: "chore: bump package versions" },
|
|
28
28
|
pr: { rejectClaudeBranding: false },
|
|
29
29
|
};
|
|
30
30
|
|
|
@@ -77,9 +77,9 @@ export function parseConfig(source: string): AgentToolConfig {
|
|
|
77
77
|
for (const value of Object.values(variants)) if (typeof value !== "string" || !/^[^\s]+$/.test(value)) invalid("review.opencodeVariants");
|
|
78
78
|
if (typeof result.review.opencodeModel !== "string" || !/^[^\s/]+\/[^\s]+$/.test(result.review.opencodeModel)) invalid("review.opencodeModel");
|
|
79
79
|
if (!Number.isSafeInteger(result.review.timeoutMs) || result.review.timeoutMs < 1000 || result.review.timeoutMs > 3_600_000) invalid("review.timeoutMs");
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
const packageDirs = (dirs: unknown): boolean => strings(dirs) && !dirs.some(dir => dir !== "." && (path.isAbsolute(dir) || dir.includes("\\") || dir.split("/").some(part => !part || part === "." || part === "..")));
|
|
81
|
+
if (result.versions.packages !== null && !packageDirs(result.versions.packages)) invalid("versions.packages");
|
|
82
|
+
if (!packageDirs(result.versions.bundles)) invalid("versions.bundles");
|
|
83
83
|
if (result.versions.lockfile !== null && result.versions.lockfile !== "bun.lock") invalid("versions.lockfile");
|
|
84
84
|
// Commands are argv arrays resolved on PATH outside the repository; no shell.
|
|
85
85
|
if (!Array.isArray(result.versions.validate) || result.versions.validate.some(command => !strings(command) || command.length === 0 || !/^[\w.+-]+$/.test(command[0]!))) invalid("versions.validate");
|
package/src/doctor.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { loadConfig } from "./config";
|
|
|
3
3
|
|
|
4
4
|
const specifications = [
|
|
5
5
|
{ name: "git", help: [] as string[], flags: [] as string[] },
|
|
6
|
-
{ name: "gh", help: [
|
|
6
|
+
{ name: "gh", help: ["pr", "checks", "--help"], flags: ["--json"] },
|
|
7
7
|
{ name: "claude", help: ["--help"], flags: ["--safe-mode", "--permission-mode", "--effort"] },
|
|
8
8
|
{ name: "codex", help: ["exec", "--help"], flags: ["--ignore-user-config", "--ignore-rules", "--strict-config", "--ephemeral", "--output-last-message"] },
|
|
9
9
|
{ name: "opencode", help: ["run", "--help"], flags: ["--pure", "--variant", "--agent"] },
|
|
@@ -3,6 +3,7 @@ import { execFileSync, spawnSync } from "node:child_process";
|
|
|
3
3
|
import { lstatSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
|
+
import { bundledPackages } from "./bundledPackages";
|
|
6
7
|
import {
|
|
7
8
|
bumpPatch,
|
|
8
9
|
isReleaseBump,
|
|
@@ -58,18 +59,12 @@ function resolveBaseCommit(
|
|
|
58
59
|
return git(rootDir, ["rev-parse", "--verify", `${oid}^{commit}`]).trim();
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
* Whether the branch changes the package, counting its manifest only for edits
|
|
63
|
-
* beyond the version field.
|
|
64
|
-
*/
|
|
65
|
-
function packageChanged(
|
|
62
|
+
function changedFiles(
|
|
66
63
|
rootDir: string,
|
|
67
64
|
mergeBase: string,
|
|
68
65
|
packageDir: string,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const manifest = manifestPath(packageDir);
|
|
72
|
-
const changed = git(rootDir, [
|
|
66
|
+
): string[] {
|
|
67
|
+
return git(rootDir, [
|
|
73
68
|
"diff",
|
|
74
69
|
"--name-only",
|
|
75
70
|
"--no-renames",
|
|
@@ -81,6 +76,20 @@ function packageChanged(
|
|
|
81
76
|
])
|
|
82
77
|
.split("\0")
|
|
83
78
|
.filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether the branch changes the package, counting its manifest only for edits
|
|
83
|
+
* beyond the version field.
|
|
84
|
+
*/
|
|
85
|
+
function packageChanged(
|
|
86
|
+
rootDir: string,
|
|
87
|
+
mergeBase: string,
|
|
88
|
+
packageDir: string,
|
|
89
|
+
headManifest: string,
|
|
90
|
+
): boolean {
|
|
91
|
+
const manifest = manifestPath(packageDir);
|
|
92
|
+
const changed = changedFiles(rootDir, mergeBase, packageDir);
|
|
84
93
|
if (changed.some((file) => file !== manifest)) {
|
|
85
94
|
return true;
|
|
86
95
|
}
|
|
@@ -98,10 +107,40 @@ function packageChanged(
|
|
|
98
107
|
return withVersion(mergeBaseManifest, version) !== headManifest;
|
|
99
108
|
}
|
|
100
109
|
|
|
110
|
+
/** A manifest's fields other than `version`, in a comparable form. */
|
|
111
|
+
function withoutVersion(manifest: string | null): string | null {
|
|
112
|
+
if (manifest === null) return null;
|
|
113
|
+
const parsed: unknown = JSON.parse(manifest);
|
|
114
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
115
|
+
Reflect.deleteProperty(parsed, "version");
|
|
116
|
+
}
|
|
117
|
+
return JSON.stringify(parsed);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether the branch changes a package a bundle ships, beyond its version.
|
|
122
|
+
* The package need not be versioned, so its version is never parsed.
|
|
123
|
+
*/
|
|
124
|
+
function bundledPackageChanged(
|
|
125
|
+
rootDir: string,
|
|
126
|
+
mergeBase: string,
|
|
127
|
+
packageDir: string,
|
|
128
|
+
): boolean {
|
|
129
|
+
const manifest = manifestPath(packageDir);
|
|
130
|
+
const changed = changedFiles(rootDir, mergeBase, packageDir);
|
|
131
|
+
if (changed.some((file) => file !== manifest)) return true;
|
|
132
|
+
if (!changed.includes(manifest)) return false;
|
|
133
|
+
return (
|
|
134
|
+
withoutVersion(showFile(rootDir, mergeBase, manifest)) !==
|
|
135
|
+
withoutVersion(showFile(rootDir, "HEAD", manifest))
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
101
139
|
/**
|
|
102
140
|
* The version each package should carry at HEAD to merge onto `baseCommit`:
|
|
103
|
-
* one patch past the base when the branch changes the package
|
|
104
|
-
* version when it does not, and a deliberate major or
|
|
141
|
+
* one patch past the base when the branch changes the package or a package it
|
|
142
|
+
* bundles, the base's own version when it does not, and a deliberate major or
|
|
143
|
+
* minor bump left alone.
|
|
105
144
|
*/
|
|
106
145
|
export function planVersions(
|
|
107
146
|
rootDir: string,
|
|
@@ -109,6 +148,16 @@ export function planVersions(
|
|
|
109
148
|
): VersionPlan[] {
|
|
110
149
|
const baseCommit = resolveBaseCommit(rootDir, baseOid);
|
|
111
150
|
const mergeBase = git(rootDir, ["merge-base", baseCommit, "HEAD"]).trim();
|
|
151
|
+
const bundles = bundledPackages(rootDir);
|
|
152
|
+
const bundledChanges = new Map<string, boolean>();
|
|
153
|
+
const bundledChanged = (packageDir: string): boolean => {
|
|
154
|
+
let result = bundledChanges.get(packageDir);
|
|
155
|
+
if (result === undefined) {
|
|
156
|
+
result = bundledPackageChanged(rootDir, mergeBase, packageDir);
|
|
157
|
+
bundledChanges.set(packageDir, result);
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
};
|
|
112
161
|
const plans: VersionPlan[] = [];
|
|
113
162
|
for (const packageDir of workspacePackages(rootDir)) {
|
|
114
163
|
const manifest = manifestPath(packageDir);
|
|
@@ -127,7 +176,10 @@ export function planVersions(
|
|
|
127
176
|
let targetVersion = baseVersion;
|
|
128
177
|
if (isReleaseBump(headVersion, baseVersion)) {
|
|
129
178
|
targetVersion = headVersion;
|
|
130
|
-
} else if (
|
|
179
|
+
} else if (
|
|
180
|
+
packageChanged(rootDir, mergeBase, packageDir, headManifest) ||
|
|
181
|
+
(bundles.get(packageDir) ?? []).some(bundledChanged)
|
|
182
|
+
) {
|
|
131
183
|
targetVersion = bumpPatch(baseVersion);
|
|
132
184
|
}
|
|
133
185
|
plans.push({ manifest, baseVersion, headVersion, targetVersion });
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { gitEnvironment } from "../git/environment";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { loadConfig } from "../config";
|
|
4
|
+
import {
|
|
5
|
+
discoveredWorkspaces,
|
|
6
|
+
manifestPath,
|
|
7
|
+
workspacePackages,
|
|
8
|
+
} from "./packageVersion";
|
|
9
|
+
|
|
10
|
+
interface WorkspaceManifest {
|
|
11
|
+
name?: unknown;
|
|
12
|
+
dependencies?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function headManifest(rootDir: string, packageDir: string): WorkspaceManifest {
|
|
16
|
+
const file = manifestPath(packageDir);
|
|
17
|
+
const result = spawnSync("git", ["show", `HEAD:${file}`], {
|
|
18
|
+
cwd: rootDir,
|
|
19
|
+
env: gitEnvironment(),
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
22
|
+
});
|
|
23
|
+
if (result.error) throw result.error;
|
|
24
|
+
if (result.status !== 0) throw new Error(`${file} is not committed at HEAD.`);
|
|
25
|
+
return JSON.parse(result.stdout);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The workspace packages a manifest depends on at runtime through `workspace:`
|
|
30
|
+
* ranges. An alias range such as `workspace:crypto@*` names its target.
|
|
31
|
+
*/
|
|
32
|
+
function workspaceDependencies(manifest: WorkspaceManifest): string[] {
|
|
33
|
+
const { dependencies } = manifest;
|
|
34
|
+
if (typeof dependencies !== "object" || dependencies === null) return [];
|
|
35
|
+
return Object.entries(dependencies).flatMap(([name, range]) => {
|
|
36
|
+
if (typeof range !== "string" || !range.startsWith("workspace:")) return [];
|
|
37
|
+
const alias = /^((?:@[^/@]+\/)?[^/@]+)@/.exec(range.slice("workspace:".length));
|
|
38
|
+
return [alias?.[1] ?? name];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The workspace packages each configured bundle ships inside its own artifact:
|
|
44
|
+
* every package it reaches through `workspace:` ranges in `dependencies`, read
|
|
45
|
+
* from HEAD. A change to any of them is a change to the bundle.
|
|
46
|
+
*/
|
|
47
|
+
export function bundledPackages(rootDir: string): Map<string, string[]> {
|
|
48
|
+
const bundles = loadConfig(rootDir).versions.bundles;
|
|
49
|
+
const result = new Map<string, string[]>();
|
|
50
|
+
if (bundles.length === 0) return result;
|
|
51
|
+
const versioned = workspacePackages(rootDir);
|
|
52
|
+
// Discovered only when a bundle has a workspace dependency, so a bundle
|
|
53
|
+
// without any needs no workspace declaration.
|
|
54
|
+
let directories: Map<string, string> | undefined;
|
|
55
|
+
const directoryOf = (name: string): string | undefined => {
|
|
56
|
+
if (directories === undefined) {
|
|
57
|
+
directories = new Map();
|
|
58
|
+
for (const packageDir of discoveredWorkspaces(rootDir)) {
|
|
59
|
+
const manifest = headManifest(rootDir, packageDir);
|
|
60
|
+
if (typeof manifest.name === "string") directories.set(manifest.name, packageDir);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return directories.get(name);
|
|
64
|
+
};
|
|
65
|
+
for (const bundle of bundles) {
|
|
66
|
+
if (!versioned.includes(bundle)) {
|
|
67
|
+
throw new Error(`versions.bundles names ${bundle}, which is not a versioned package.`);
|
|
68
|
+
}
|
|
69
|
+
const reached = new Set<string>();
|
|
70
|
+
const pending = workspaceDependencies(headManifest(rootDir, bundle));
|
|
71
|
+
for (let name = pending.pop(); name !== undefined; name = pending.pop()) {
|
|
72
|
+
const packageDir = directoryOf(name);
|
|
73
|
+
if (packageDir === undefined) {
|
|
74
|
+
throw new Error(`${bundle} depends on ${name}, which is not a workspace package.`);
|
|
75
|
+
}
|
|
76
|
+
if (packageDir === bundle || reached.has(packageDir)) continue;
|
|
77
|
+
reached.add(packageDir);
|
|
78
|
+
pending.push(...workspaceDependencies(headManifest(rootDir, packageDir)));
|
|
79
|
+
}
|
|
80
|
+
result.set(bundle, [...reached].sort());
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
@@ -6,10 +6,14 @@ import {
|
|
|
6
6
|
import { posix } from "node:path";
|
|
7
7
|
import { loadConfig } from "../config";
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/** The versioned packages: the configured directories, or every workspace. */
|
|
10
10
|
export function workspacePackages(rootDir: string): string[] {
|
|
11
11
|
const configured = loadConfig(rootDir).versions.packages;
|
|
12
|
-
|
|
12
|
+
return configured === null ? discoveredWorkspaces(rootDir) : [...configured];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Discover every committed workspace, including globbed and newly added ones. */
|
|
16
|
+
export function discoveredWorkspaces(rootDir: string): string[] {
|
|
13
17
|
const options: ExecFileSyncOptionsWithStringEncoding = {
|
|
14
18
|
cwd: rootDir,
|
|
15
19
|
env: gitEnvironment(),
|