285 lines
8.4 KiB
TypeScript
285 lines
8.4 KiB
TypeScript
import { parseArgs } from "node:util";
|
|
|
|
const excludedPrefixes = [
|
|
".agents/",
|
|
".claude/skills/",
|
|
"node_modules/",
|
|
"repos/",
|
|
"dist/",
|
|
"build/",
|
|
"apps/web/build/",
|
|
"apps/web/.react-router/",
|
|
"apps/native/.expo/",
|
|
"apps/native/dist/",
|
|
"apps/native/web-build/",
|
|
"packages/backend/convex/_generated/",
|
|
];
|
|
const maxPromptCharacters = 900_000;
|
|
|
|
const diffExclusions = excludedPrefixes.map(
|
|
(prefix) => `:(exclude,glob)${prefix}**`
|
|
);
|
|
|
|
const documentationNames = new Set([
|
|
"AGENTS.md",
|
|
"README.md",
|
|
"README.mdx",
|
|
"LLMS.md",
|
|
]);
|
|
|
|
const supportedAgents = ["codex", "opencode", "claude"] as const;
|
|
type AgentName = (typeof supportedAgents)[number];
|
|
|
|
interface CommandResult {
|
|
readonly exitCode: number;
|
|
readonly stdout: string;
|
|
readonly stderr: string;
|
|
}
|
|
|
|
const run = async (
|
|
command: readonly string[],
|
|
options: { readonly stdin?: string; readonly inherit?: boolean } = {}
|
|
): Promise<CommandResult> => {
|
|
const process = Bun.spawn([...command], {
|
|
cwd: processCwd,
|
|
stdin: options.stdin === undefined ? "ignore" : new Blob([options.stdin]),
|
|
stdout: options.inherit ? "inherit" : "pipe",
|
|
stderr: options.inherit ? "inherit" : "pipe",
|
|
});
|
|
const [exitCode, stdout, stderr] = await Promise.all([
|
|
process.exited,
|
|
options.inherit ? Promise.resolve("") : new Response(process.stdout).text(),
|
|
options.inherit ? Promise.resolve("") : new Response(process.stderr).text(),
|
|
]);
|
|
return { exitCode, stdout, stderr };
|
|
};
|
|
|
|
const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
|
|
const formatCommand = (command: readonly string[]) =>
|
|
command.map(shellQuote).join(" ");
|
|
|
|
const isExcluded = (path: string) =>
|
|
excludedPrefixes.some((prefix) => {
|
|
const directory = prefix.slice(0, -1);
|
|
return (
|
|
path === directory ||
|
|
path.startsWith(prefix) ||
|
|
path.includes(`/${directory}/`) ||
|
|
path.endsWith(`/${directory}`)
|
|
);
|
|
});
|
|
|
|
const isDocumentation = (path: string) => {
|
|
const name = path.split("/").at(-1) ?? path;
|
|
return (
|
|
documentationNames.has(name) ||
|
|
name.endsWith(".md") ||
|
|
name.endsWith(".mdx")
|
|
);
|
|
};
|
|
|
|
const resolveRange = async (revision: string | undefined) => {
|
|
const requested = revision ?? "HEAD";
|
|
if (requested.includes("..")) {
|
|
const result = await run(["git", "rev-list", "--max-count=1", requested]);
|
|
if (result.exitCode !== 0 || result.stdout.trim() === "") {
|
|
throw new Error(
|
|
`Invalid revision range ${shellQuote(requested)}: ${result.stderr.trim()}`
|
|
);
|
|
}
|
|
return requested;
|
|
}
|
|
const verify = await run([
|
|
"git",
|
|
"rev-parse",
|
|
"--verify",
|
|
`${requested}^{commit}`,
|
|
]);
|
|
if (verify.exitCode !== 0) {
|
|
throw new Error(
|
|
`Invalid commit ${shellQuote(requested)}: ${verify.stderr.trim()}`
|
|
);
|
|
}
|
|
return `${requested}^!`;
|
|
};
|
|
|
|
const listChangedFiles = async (range: string) => {
|
|
const result = await run([
|
|
"git",
|
|
"diff",
|
|
"--name-only",
|
|
"--diff-filter=ACDMRTUXB",
|
|
range,
|
|
"--",
|
|
]);
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(
|
|
`Unable to inspect ${shellQuote(range)}: ${result.stderr.trim()}`
|
|
);
|
|
}
|
|
return result.stdout
|
|
.split("\n")
|
|
.map((path) => path.trim())
|
|
.filter((path) => path !== "" && !isExcluded(path));
|
|
};
|
|
|
|
const listDocumentationFiles = async () => {
|
|
const result = await run(["git", "ls-files", "*.md", "*.mdx"]);
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(
|
|
`Unable to list documentation files: ${result.stderr.trim()}`
|
|
);
|
|
}
|
|
return result.stdout
|
|
.split("\n")
|
|
.map((path) => path.trim())
|
|
.filter(
|
|
(path) => path !== "" && !isExcluded(path) && isDocumentation(path)
|
|
);
|
|
};
|
|
|
|
const resolveAgent = (requested: string | undefined): AgentName => {
|
|
const configured = requested ?? process.env.DOCS_AGENT;
|
|
if (configured !== undefined) {
|
|
if (!supportedAgents.includes(configured as AgentName)) {
|
|
throw new Error(
|
|
`Unsupported agent ${shellQuote(configured)}. Use codex, opencode, or claude.`
|
|
);
|
|
}
|
|
if (Bun.which(configured) === null) {
|
|
throw new Error(
|
|
`Agent executable ${shellQuote(configured)} was not found on PATH.`
|
|
);
|
|
}
|
|
return configured as AgentName;
|
|
}
|
|
const detected = supportedAgents.find((agent) => Bun.which(agent) !== null);
|
|
if (detected === undefined) {
|
|
throw new Error(
|
|
"No supported coding agent found. Install codex, opencode, or claude, or set DOCS_AGENT."
|
|
);
|
|
}
|
|
return detected;
|
|
};
|
|
|
|
const agentCommand = (agent: AgentName) => {
|
|
switch (agent) {
|
|
case "codex":
|
|
return [
|
|
"codex",
|
|
"exec",
|
|
"-",
|
|
"--sandbox",
|
|
"workspace-write",
|
|
"--cd",
|
|
processCwd,
|
|
"--ephemeral",
|
|
];
|
|
case "opencode":
|
|
return ["opencode", "run", "--auto"];
|
|
case "claude":
|
|
return ["claude", "--print", "--permission-mode", "acceptEdits"];
|
|
}
|
|
};
|
|
|
|
const buildPrompt = (input: {
|
|
readonly range: string;
|
|
readonly changedFiles: readonly string[];
|
|
readonly documentationFiles: readonly string[];
|
|
readonly diff: string;
|
|
}) => `Update repository documentation for revision ${input.range} in the current checkout.
|
|
|
|
Requirements:
|
|
- Read and follow the root AGENTS.md and any more specific AGENTS.md governing files you edit.
|
|
- Inspect the supplied diff and the current code before editing; do not document behavior that is not implemented.
|
|
- Update affected README.md, AGENTS.md, LLMS.md, or other first-party Markdown/MDX files when the revision made them stale.
|
|
- Keep documentation concise and practical. Preserve generated markers and package-specific instructions.
|
|
- Never edit repos/**, .agents/**, .claude/skills/**, generated files, or dependency/build output.
|
|
- Do not modify source code, run broad formatting, or commit changes.
|
|
- If documentation is already accurate, make no edits and say so.
|
|
|
|
Changed first-party files:
|
|
${input.changedFiles.map((path) => `- ${path}`).join("\n") || "- None"}
|
|
|
|
First-party documentation candidates:
|
|
${input.documentationFiles.map((path) => `- ${path}`).join("\n") || "- None"}
|
|
|
|
Revision diff:
|
|
\`\`\`diff
|
|
${input.diff}
|
|
\`\`\`
|
|
`;
|
|
|
|
const processCwd = process.cwd();
|
|
const { values, positionals } = parseArgs({
|
|
args: process.argv.slice(2),
|
|
options: {
|
|
agent: { type: "string" },
|
|
"dry-run": { type: "boolean", default: false },
|
|
help: { type: "boolean", short: "h", default: false },
|
|
},
|
|
allowPositionals: true,
|
|
strict: true,
|
|
});
|
|
|
|
if (values.help || positionals.length > 1) {
|
|
console.log(`Usage: bun run docs:update [<commit-or-range>] [--agent codex|opencode|claude] [--dry-run]
|
|
|
|
A single commit defaults to HEAD and is converted to <commit>^!. Revision ranges such as main..HEAD and main...HEAD are accepted directly. Set DOCS_AGENT to choose the default coding-agent CLI.`);
|
|
process.exit(positionals.length > 1 ? 1 : 0);
|
|
}
|
|
|
|
try {
|
|
const range = await resolveRange(positionals[0]);
|
|
const [changedFiles, documentationFiles, diffResult] = await Promise.all([
|
|
listChangedFiles(range),
|
|
listDocumentationFiles(),
|
|
run(["git", "diff", "--find-renames", range, "--", ".", ...diffExclusions]),
|
|
]);
|
|
if (diffResult.exitCode !== 0) {
|
|
throw new Error(
|
|
`Unable to read revision diff: ${diffResult.stderr.trim()}`
|
|
);
|
|
}
|
|
const agent = resolveAgent(values.agent);
|
|
const command = agentCommand(agent);
|
|
const prompt = buildPrompt({
|
|
range,
|
|
changedFiles,
|
|
documentationFiles,
|
|
diff: diffResult.stdout,
|
|
});
|
|
if (prompt.length > maxPromptCharacters) {
|
|
throw new Error(
|
|
`Generated documentation prompt is ${prompt.length} characters, exceeding the ${maxPromptCharacters} character safety limit. Use a smaller revision range.`
|
|
);
|
|
}
|
|
|
|
console.log(`Revision: ${range}`);
|
|
console.log(`Agent: ${agent}`);
|
|
console.log(`Changed first-party files: ${changedFiles.length}`);
|
|
console.log(`Prompt characters: ${prompt.length}`);
|
|
console.log(`Command: ${formatCommand(command)} < generated-prompt`);
|
|
|
|
if (values["dry-run"]) {
|
|
console.log("\n--- Generated prompt ---\n");
|
|
console.log(prompt);
|
|
process.exit(0);
|
|
}
|
|
|
|
const result = await run(command, { stdin: prompt, inherit: true });
|
|
if (result.exitCode !== 0) {
|
|
process.exit(result.exitCode);
|
|
}
|
|
const status = await run(["git", "status", "--short", "--", "*.md", "*.mdx"]);
|
|
if (status.stdout.trim() !== "") {
|
|
console.log("\nDocumentation changes:");
|
|
console.log(status.stdout.trimEnd());
|
|
} else {
|
|
console.log("\nNo documentation files changed.");
|
|
}
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
}
|