@a2f0/agent-tool 0.1.1
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 +288 -0
- package/package.json +36 -0
- package/skills/cross-agent-review/SKILL.md +46 -0
- package/skills/open-pr/SKILL.md +31 -0
- package/skills/reset/SKILL.md +25 -0
- package/skills/ship-pr/SKILL.md +48 -0
- package/skills/squash-merge/SKILL.md +39 -0
- package/src/cli.ts +121 -0
- package/src/config.ts +101 -0
- package/src/doctor.ts +23 -0
- package/src/git/currentBranchPr.ts +114 -0
- package/src/git/environment.ts +10 -0
- package/src/git/prContext.ts +497 -0
- package/src/harnesses.ts +53 -0
- package/src/index.ts +9 -0
- package/src/pr/assertBranchPushed.ts +21 -0
- package/src/pr/assertNoClaudeBranding.ts +52 -0
- package/src/pr/baseFreshness.ts +67 -0
- package/src/pr/mergeChecks.ts +90 -0
- package/src/pr/openPr.ts +80 -0
- package/src/pr/prNumberSuffix.ts +43 -0
- package/src/pr/squashMerge.ts +229 -0
- package/src/pr/subjectLine.ts +21 -0
- package/src/pr/validateCommitSubject.ts +12 -0
- package/src/review/destinationPaths.ts +154 -0
- package/src/review/executable.ts +34 -0
- package/src/review/pinnedReviewInput.ts +91 -0
- package/src/review/rawGitTree.ts +301 -0
- package/src/review/reviewEffort.ts +54 -0
- package/src/review/reviewOutput.ts +62 -0
- package/src/review/reviewPrompt.ts +104 -0
- package/src/review/reviewSnapshot.ts +67 -0
- package/src/review/runReview.ts +62 -0
- package/src/review/solicitClaudeCodeReview.ts +140 -0
- package/src/review/solicitCodexReview.ts +205 -0
- package/src/review/solicitOpencodeReview.ts +233 -0
- package/src/runAgentToolAction.ts +100 -0
- package/src/skills/bundled.ts +8 -0
- package/src/skills/install.ts +92 -0
- package/src/version/bumpVersions.ts +196 -0
- package/src/version/lockfileVersions.ts +43 -0
- package/src/version/packageVersion.ts +112 -0
- package/src/version/prepareVersions.ts +135 -0
- package/src/version/resolveVersionConflicts.ts +126 -0
- package/src/version/versionPreparationGit.ts +127 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface RequiredCheck {
|
|
5
|
+
name: string;
|
|
6
|
+
workflow?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AgentToolConfig {
|
|
10
|
+
schemaVersion: 1;
|
|
11
|
+
subject: { conventional: boolean; maxLength: number; types: string[] };
|
|
12
|
+
merge: { requireChecks: boolean; requiredChecks: RequiredCheck[]; requireStrictBaseFreshness: boolean };
|
|
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 };
|
|
15
|
+
pr: { rejectClaudeBranding: boolean };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_CONFIG: AgentToolConfig = {
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
subject: {
|
|
21
|
+
conventional: true,
|
|
22
|
+
maxLength: 72,
|
|
23
|
+
types: ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"],
|
|
24
|
+
},
|
|
25
|
+
merge: { requireChecks: true, requiredChecks: [], requireStrictBaseFreshness: false },
|
|
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" },
|
|
28
|
+
pr: { rejectClaudeBranding: false },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function object(value: unknown, name: string): Record<string, unknown> {
|
|
32
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
33
|
+
throw new Error(`${name} must be an object.`);
|
|
34
|
+
}
|
|
35
|
+
return value as Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function keys(value: Record<string, unknown>, allowed: string[], name: string): void {
|
|
39
|
+
for (const key of Object.keys(value)) {
|
|
40
|
+
if (!allowed.includes(key)) throw new Error(`Unknown ${name} setting '${key}'.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function strings(value: unknown): value is string[] {
|
|
45
|
+
return Array.isArray(value) && value.every(item => typeof item === "string" && item.length > 0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Data-only policy: never import repository JavaScript or commitlint code. */
|
|
49
|
+
export function parseConfig(source: string): AgentToolConfig {
|
|
50
|
+
const input = object(JSON.parse(source), "config");
|
|
51
|
+
keys(input, Object.keys(DEFAULT_CONFIG), "config");
|
|
52
|
+
if (input.schemaVersion !== 1) throw new Error("Unsupported config schemaVersion; expected 1.");
|
|
53
|
+
const result = structuredClone(DEFAULT_CONFIG);
|
|
54
|
+
for (const section of ["subject", "merge", "review", "versions", "pr"] as const) {
|
|
55
|
+
if (input[section] === undefined) continue;
|
|
56
|
+
const settings = object(input[section], section);
|
|
57
|
+
keys(settings, Object.keys(result[section]), section);
|
|
58
|
+
Object.assign(result[section], settings);
|
|
59
|
+
}
|
|
60
|
+
const invalid = (setting: string): never => { throw new Error(`Invalid config setting '${setting}'.`); };
|
|
61
|
+
if (typeof result.subject.conventional !== "boolean") invalid("subject.conventional");
|
|
62
|
+
if (!Number.isSafeInteger(result.subject.maxLength) || result.subject.maxLength < 1) invalid("subject.maxLength");
|
|
63
|
+
if (!strings(result.subject.types) || result.subject.types.length === 0 || result.subject.types.some(type => !/^[a-z]+$/.test(type))) invalid("subject.types");
|
|
64
|
+
if (typeof result.merge.requireChecks !== "boolean") invalid("merge.requireChecks");
|
|
65
|
+
if (!Array.isArray(result.merge.requiredChecks)) invalid("merge.requiredChecks");
|
|
66
|
+
if (typeof result.merge.requireStrictBaseFreshness !== "boolean") invalid("merge.requireStrictBaseFreshness");
|
|
67
|
+
for (const check of result.merge.requiredChecks) {
|
|
68
|
+
const entry = object(check, "merge.requiredChecks entry");
|
|
69
|
+
keys(entry, ["name", "workflow"], "merge.requiredChecks entry");
|
|
70
|
+
if (typeof check.name !== "string" || !check.name.trim() || (check.workflow !== undefined && (typeof check.workflow !== "string" || !check.workflow.trim()))) invalid("merge.requiredChecks");
|
|
71
|
+
}
|
|
72
|
+
for (const key of ["claudeModel", "codexModel"] as const) {
|
|
73
|
+
if (result.review[key] !== null && (typeof result.review[key] !== "string" || !/^[^\s]+$/.test(result.review[key]!))) invalid(`review.${key}`);
|
|
74
|
+
}
|
|
75
|
+
const variants = object(result.review.opencodeVariants, "review.opencodeVariants");
|
|
76
|
+
keys(variants, ["low", "medium", "high", "xhigh", "max"], "review.opencodeVariants");
|
|
77
|
+
for (const value of Object.values(variants)) if (typeof value !== "string" || !/^[^\s]+$/.test(value)) invalid("review.opencodeVariants");
|
|
78
|
+
if (typeof result.review.opencodeModel !== "string" || !/^[^\s/]+\/[^\s]+$/.test(result.review.opencodeModel)) invalid("review.opencodeModel");
|
|
79
|
+
if (!Number.isSafeInteger(result.review.timeoutMs) || result.review.timeoutMs < 1000 || result.review.timeoutMs > 3_600_000) invalid("review.timeoutMs");
|
|
80
|
+
if (result.versions.packages !== null) {
|
|
81
|
+
if (!strings(result.versions.packages) || result.versions.packages.some(dir => dir !== "." && (path.isAbsolute(dir) || dir.includes("\\") || dir.split("/").some(part => !part || part === "." || part === "..")))) invalid("versions.packages");
|
|
82
|
+
}
|
|
83
|
+
if (result.versions.lockfile !== null && result.versions.lockfile !== "bun.lock") invalid("versions.lockfile");
|
|
84
|
+
// Commands are argv arrays resolved on PATH outside the repository; no shell.
|
|
85
|
+
if (!Array.isArray(result.versions.validate) || result.versions.validate.some(command => !strings(command) || command.length === 0 || !/^[\w.+-]+$/.test(command[0]!))) invalid("versions.validate");
|
|
86
|
+
if (typeof result.versions.commitMessage !== "string" || !result.versions.commitMessage.trim() || /[\r\n]/.test(result.versions.commitMessage)) invalid("versions.commitMessage");
|
|
87
|
+
if (typeof result.pr.rejectClaudeBranding !== "boolean") invalid("pr.rejectClaudeBranding");
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Explicit file wins; project policy is opt-in through agent-tool.json. */
|
|
92
|
+
export function loadConfig(rootDir: string = process.cwd()): AgentToolConfig {
|
|
93
|
+
const explicit = process.env.AGENT_TOOL_CONFIG;
|
|
94
|
+
const file = explicit ? path.resolve(explicit) : path.join(rootDir, "agent-tool.json");
|
|
95
|
+
if (!existsSync(file)) {
|
|
96
|
+
if (explicit) throw new Error(`Config file does not exist: ${file}`);
|
|
97
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
98
|
+
}
|
|
99
|
+
if (!lstatSync(file).isFile()) throw new Error(`Config must be a regular file: ${file}`);
|
|
100
|
+
return parseConfig(readFileSync(file, "utf8"));
|
|
101
|
+
}
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { loadConfig } from "./config";
|
|
3
|
+
|
|
4
|
+
const specifications = [
|
|
5
|
+
{ name: "git", help: [] as string[], flags: [] as string[] },
|
|
6
|
+
{ name: "gh", help: [] as string[], flags: [] as string[] },
|
|
7
|
+
{ name: "claude", help: ["--help"], flags: ["--safe-mode", "--permission-mode", "--effort"] },
|
|
8
|
+
{ name: "codex", help: ["exec", "--help"], flags: ["--ignore-user-config", "--ignore-rules", "--strict-config", "--ephemeral", "--output-last-message"] },
|
|
9
|
+
{ name: "opencode", help: ["run", "--help"], flags: ["--pure", "--variant", "--agent"] },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/** Probe only local versions and flags; never launch a model or print credentials. */
|
|
13
|
+
export function doctor(rootDir: string): { schemaVersion: 1; config: ReturnType<typeof loadConfig>; tools: { name: string; version: string; compatible: boolean; missingFlags: string[] }[] } {
|
|
14
|
+
const config = loadConfig(rootDir);
|
|
15
|
+
const tools = specifications.map(spec => {
|
|
16
|
+
const version = spawnSync(spec.name, ["--version"], { encoding: "utf8", timeout: 3000, maxBuffer: 256 * 1024 });
|
|
17
|
+
const help = spec.flags.length ? spawnSync(spec.name, spec.help, { encoding: "utf8", timeout: 3000, maxBuffer: 256 * 1024 }) : undefined;
|
|
18
|
+
const helpText = `${help?.stdout ?? ""}\n${help?.stderr ?? ""}`;
|
|
19
|
+
const missingFlags = spec.flags.filter(flag => !helpText.includes(flag));
|
|
20
|
+
return { name: spec.name, version: version.stdout?.trim() || "unavailable", compatible: version.status === 0 && (!help || help.status === 0) && missingFlags.length === 0, missingFlags };
|
|
21
|
+
});
|
|
22
|
+
return { schemaVersion: 1, config, tools };
|
|
23
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
interface CurrentBranchPr {
|
|
4
|
+
readonly baseRefName: string;
|
|
5
|
+
readonly branch: string;
|
|
6
|
+
readonly prNumber: string;
|
|
7
|
+
readonly repo: string;
|
|
8
|
+
readonly title: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface CommandResult {
|
|
12
|
+
readonly error?: Error;
|
|
13
|
+
readonly signal: string | null;
|
|
14
|
+
readonly status: number | null;
|
|
15
|
+
readonly stderr: string;
|
|
16
|
+
readonly stdout: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type CommandRunner = (command: string, args: string[]) => CommandResult;
|
|
20
|
+
|
|
21
|
+
function runWithResult(command: string, args: string[]): CommandResult {
|
|
22
|
+
const result = spawnSync(command, args, {
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
+
});
|
|
26
|
+
const output = {
|
|
27
|
+
status: result.status,
|
|
28
|
+
signal: result.signal,
|
|
29
|
+
stderr: result.stderr,
|
|
30
|
+
stdout: result.stdout,
|
|
31
|
+
};
|
|
32
|
+
return result.error ? { ...output, error: result.error } : output;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function field(source: string, key: string): unknown {
|
|
36
|
+
try {
|
|
37
|
+
const parsed: unknown = JSON.parse(source);
|
|
38
|
+
return typeof parsed === "object" && parsed !== null
|
|
39
|
+
? Reflect.get(parsed, key)
|
|
40
|
+
: undefined;
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stringField(source: string, key: string): string {
|
|
47
|
+
const value = field(source, key);
|
|
48
|
+
return typeof value === "string" ? value : "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function numberField(source: string, key: string): string {
|
|
52
|
+
const value = field(source, key);
|
|
53
|
+
return typeof value === "number" ? String(value) : "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function repoFromPrUrl(prUrl: string): string {
|
|
57
|
+
try {
|
|
58
|
+
const segments = new URL(prUrl).pathname.split("/").filter(Boolean);
|
|
59
|
+
if (segments.length >= 4 && segments[2] === "pull") {
|
|
60
|
+
return `${segments[0]}/${segments[1]}`;
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Fall through to the actionable error below.
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Could not resolve the base repository from '${prUrl}'.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolve the current branch's open PR, including one from an upstream fork. */
|
|
69
|
+
export function viewCurrentBranchPr(
|
|
70
|
+
branch: string,
|
|
71
|
+
execute: CommandRunner = runWithResult,
|
|
72
|
+
): CurrentBranchPr | undefined {
|
|
73
|
+
const result = execute("gh", [
|
|
74
|
+
"pr",
|
|
75
|
+
"view",
|
|
76
|
+
"--json",
|
|
77
|
+
"number,state,title,url,baseRefName",
|
|
78
|
+
]);
|
|
79
|
+
if (result.error) {
|
|
80
|
+
throw new Error(`Could not run gh pr view: ${result.error.message}`);
|
|
81
|
+
}
|
|
82
|
+
if (result.signal !== null) {
|
|
83
|
+
throw new Error(`gh pr view terminated by signal ${result.signal}.`);
|
|
84
|
+
}
|
|
85
|
+
if (result.status !== 0) {
|
|
86
|
+
const failure = result.stderr.trim();
|
|
87
|
+
if (failure === `no pull requests found for branch "${branch}"`) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Could not resolve the current branch's PR: ${failure || "gh pr view failed"}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const state = stringField(result.stdout, "state");
|
|
95
|
+
if (state === "CLOSED" || state === "MERGED") {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
if (state !== "OPEN") {
|
|
99
|
+
throw new Error(`Current branch PR has invalid state '${state}'.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const prNumber = numberField(result.stdout, "number");
|
|
103
|
+
const prUrl = stringField(result.stdout, "url");
|
|
104
|
+
if (prNumber.length === 0 || prUrl.length === 0) {
|
|
105
|
+
throw new Error("Could not determine the current branch's PR identity.");
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
branch,
|
|
109
|
+
repo: repoFromPrUrl(prUrl),
|
|
110
|
+
prNumber,
|
|
111
|
+
title: stringField(result.stdout, "title"),
|
|
112
|
+
baseRefName: stringField(result.stdout, "baseRefName"),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Disable object substitution and hooks, retaining existing Git config overrides. */
|
|
2
|
+
export function gitEnvironment(): NodeJS.ProcessEnv {
|
|
3
|
+
const env: NodeJS.ProcessEnv = { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
|
|
4
|
+
const count = Number(env.GIT_CONFIG_COUNT ?? "0");
|
|
5
|
+
if (!Number.isSafeInteger(count) || count < 0 || count > 1000) throw new Error("Invalid GIT_CONFIG_COUNT.");
|
|
6
|
+
env.GIT_CONFIG_COUNT = String(count + 1);
|
|
7
|
+
env[`GIT_CONFIG_KEY_${count}`] = "core.hooksPath";
|
|
8
|
+
env[`GIT_CONFIG_VALUE_${count}`] = "/dev/null";
|
|
9
|
+
return env;
|
|
10
|
+
}
|