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 => { 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 [] [--agent codex|opencode|claude] [--dry-run] A single commit defaults to HEAD and is converted to ^!. 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); }