diff --git a/home/dot_local/bin/executable_commit b/home/dot_local/bin/executable_commit new file mode 100644 index 0000000..850d492 --- /dev/null +++ b/home/dot_local/bin/executable_commit @@ -0,0 +1,547 @@ +#!/usr/bin/env bun + +import { defineCommand, runMain } from "citty"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { execSync, spawnSync, spawn as cpSpawn } from "child_process"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type Backend = "claude" | "opencode"; +type ModelAlias = "haiku" | "sonnet" | "opus"; + +interface CommitOption { + subject: string; + body?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isCancel(value: unknown): value is symbol { + return p.isCancel(value); +} + +function bail(msg?: string): never { + p.cancel(msg ?? "Cancelled."); + process.exit(1); +} + +/** Run a shell command and return trimmed stdout, or null on failure. */ +function shell(cmd: string): string | null { + try { + return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); + } catch { + return null; + } +} + +/** Check whether there are staged changes. */ +function hasStagedChanges(): boolean { + const result = spawnSync("git", ["diff", "--cached", "--quiet"], { stdio: "pipe" }); + // exit code 1 = there ARE changes, 0 = no changes + return result.status === 1; +} + +/** Gather context via commit-helper. */ +function gatherDiffContext(): string { + const out = shell("commit-helper --staged"); + if (!out) { + p.log.error("Failed to gather staged changes via commit-helper."); + bail(); + } + return out; +} + +// --------------------------------------------------------------------------- +// System prompt (embedded commit-style rules) +// --------------------------------------------------------------------------- + +const SYSTEM_PROMPT = `You are a commit message generator. You will be given a diff of staged changes and optional context from the developer. + +Your task: produce exactly 3 commit message options. + +## CRITICAL: Match the existing commit style EXACTLY + +The input includes a "Recent Commit Style" section showing the repository's actual recent commits. +This section is your PRIMARY source of truth. You MUST replicate the exact formatting conventions you see there. + +Before writing ANY option, analyze the recent commits and answer these questions internally: +1. Do they use a prefix like "type: description"? If yes, EVERY option you generate MUST use a prefix. +2. What prefix types appear? (e.g. feat, fix, config, refactor, docs) Use only prefixes that appear in the history or are semantically appropriate for the change. +3. What comes after the prefix — lowercase or capitalized? Match it. +4. How long are the messages? Match that level of detail. + +If the recent commits use conventional commit style (e.g. "feat:", "fix:", "config:"), then ALL 3 of your options MUST use a prefix. Never mix prefixed and unprefixed styles. + +## Formatting rules + +- Subject line under 72 characters +- Imperative mood ("add", "fix", "refactor", not "added", "fixes") +- No trailing periods on subject lines +- Single-line subject for most commits +- Add a body (separated by blank line) ONLY for genuinely complex changes +- Focus on WHAT changed and WHY, not implementation details +- NEVER mention: test results, lockfile changes, file counts, build status + +## Output format + +Respond with EXACTLY this format and nothing else — no commentary, no markdown fences, no preamble: + +1: +[optional body paragraph] + +2: +[optional body paragraph] + +3: +[optional body paragraph] + +Each option starts with its number prefix on a new line. The body, if present, is separated from the subject by one blank line and should be wrapped at 72 characters.`; + +// --------------------------------------------------------------------------- +// AI invocation +// --------------------------------------------------------------------------- + +/** Map model alias to the string each backend expects. */ +function modelFlag(backend: Backend, alias: ModelAlias): string { + if (backend === "claude") { + return alias; // claude CLI accepts "haiku", "sonnet", "opus" directly + } + // opencode wants "provider/model-version" format + const map: Record = { + haiku: "anthropic/claude-haiku-4-5", + sonnet: "anthropic/claude-sonnet-4-5", + opus: "anthropic/claude-opus-4-5", + }; + return map[alias]; +} + +/** Build the user prompt from diff context + optional developer context. */ +function buildUserPrompt(diffContext: string, userContext: string): string { + let prompt = diffContext; + if (userContext.trim()) { + prompt += `\n\n## Developer Context\n\n${userContext.trim()}`; + } + prompt += "\n\nGenerate exactly 3 commit message options in the specified format."; + return prompt; +} + +/** Write content to a temp file and return the path. */ +function writeTempFile(prefix: string, content: string): string { + const path = `/tmp/${prefix}-${Date.now()}.txt`; + require("fs").writeFileSync(path, content, "utf-8"); + return path; +} + +/** Clean up temp files, ignoring errors. */ +function removeTempFile(path: string): void { + try { require("fs").unlinkSync(path); } catch { /* ignore */ } +} + +/** Invoke an AI backend asynchronously, returning stdout. */ +function invokeAI( + backend: Backend, + model: ModelAlias, + diffContext: string, + userContext: string, + variant?: string, +): Promise { + const userPrompt = buildUserPrompt(diffContext, userContext); + const m = modelFlag(backend, model); + + if (backend === "claude") { + // Use JSON output to get clean text without thinking preamble + return spawnAsync( + "claude", + ["--print", "--model", m, "--output-format", "json", "--system-prompt", SYSTEM_PROMPT, "--tools", ""], + userPrompt, + ).then((raw) => { + try { + const parsed = JSON.parse(raw); + return parsed.result ?? raw; + } catch { + return raw; + } + }); + } + + // opencode has no --system-prompt flag — combine system+user into a temp file + // and pass the instruction as a short positional message + const fullPrompt = `${SYSTEM_PROMPT}\n\n---\n\n${userPrompt}`; + const promptFile = writeTempFile("commit-prompt", fullPrompt); + + const opencodeArgs = ["run", "--model", m, "--format", "json", "-f", promptFile]; + if (variant) opencodeArgs.push("--variant", variant); + + return spawnAsync( + "opencode", + opencodeArgs, + "Follow the attached file exactly. Generate 3 commit message options.", + ).then((raw) => { + // opencode JSON is NDJSON — extract text from "type":"text" events + try { + const textParts: string[] = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + const event = JSON.parse(line); + if (event.type === "text" && event.part?.text) { + textParts.push(event.part.text); + } + } + return textParts.join("") || raw; + } catch { + return raw; + } + }).finally(() => removeTempFile(promptFile)); +} + +/** Spawn a child process and collect stdout/stderr. Rejects on non-zero exit. */ +function spawnAsync(cmd: string, args: string[], stdin?: string): Promise { + return new Promise((resolve, reject) => { + const child = cpSpawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); }); + child.stderr.on("data", (d: Buffer) => { stderr += d.toString(); }); + + child.on("error", (err) => reject(err)); + child.on("close", (code) => { + clearTimeout(timeout); + if (code !== 0) { + reject(new Error(`${cmd} exited ${code}: ${stderr.trim() || "unknown error"}`)); + } else { + resolve(stdout.trim()); + } + }); + + if (stdin) { + child.stdin.write(stdin); + child.stdin.end(); + } else { + child.stdin.end(); + } + + const timeout = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error(`${cmd} timed out after 120s`)); + }, 120_000); + }); +} + +// --------------------------------------------------------------------------- +// Parsing AI output +// --------------------------------------------------------------------------- + +/** + * Parse the AI's numbered response into CommitOption[]. + * Expected format: + * 1: subject line + * optional body + * + * 2: subject line + * ... + */ +function parseOptions(raw: string): CommitOption[] { + const options: CommitOption[] = []; + + // Strip any preamble before the first numbered option + const firstOption = raw.search(/^[123]:\s/m); + if (firstOption === -1) return []; + const cleaned = raw.slice(firstOption); + + // Split on lines starting with "N:" to get each option block + const parts = cleaned.split(/(?:^|\n)(?=[123]:\s)/); + + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) continue; + + // Remove the leading "N: " prefix + const withoutPrefix = trimmed.replace(/^[123]:\s*/, ""); + const lines = withoutPrefix.split("\n"); + const subject = lines[0].trim(); + if (!subject) continue; + + // Everything after a blank line separator is the body + const blankIdx = lines.findIndex((l, i) => i > 0 && l.trim() === ""); + let body: string | undefined; + if (blankIdx > 0 && blankIdx < lines.length - 1) { + body = lines + .slice(blankIdx + 1) + .join("\n") + .trim(); + } + + options.push({ subject, body }); + } + + return options; +} + +/** Format a CommitOption for display in the selector (label only — subject line). */ +function formatOptionLabel(opt: CommitOption): string { + return opt.subject; +} + +/** Format a CommitOption body as a hint string for clack select. */ +function formatOptionHint(opt: CommitOption): string | undefined { + if (!opt.body) return undefined; + // Show first line of body, truncated to fit terminal reasonably + const firstLine = opt.body.split("\n")[0].trim(); + if (firstLine.length > 72) return firstLine.slice(0, 69) + "..."; + return firstLine; +} + +/** Convert a CommitOption to the full commit message string. */ +function toCommitMessage(opt: CommitOption): string { + if (opt.body) { + return `${opt.subject}\n\n${opt.body}`; + } + return opt.subject; +} + +// --------------------------------------------------------------------------- +// Git commit +// --------------------------------------------------------------------------- + +function runCommit(message: string, dryRun: boolean): void { + if (dryRun) { + p.log.info(pc.yellow("Dry run — would commit with:")); + console.log(); + console.log(pc.bold(message)); + console.log(); + return; + } + + // Write message to a temp file to avoid shell escaping issues + const tmpFile = `/tmp/commit-msg-${Date.now()}.txt`; + Bun.write(tmpFile, message); + + try { + const result = spawnSync("git", ["commit", "-F", tmpFile], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + + if (result.status !== 0) { + const stderr = result.stderr?.trim() || ""; + const stdout = result.stdout?.trim() || ""; + p.log.error("git commit failed:"); + if (stderr) console.error(stderr); + if (stdout) console.log(stdout); + process.exit(result.status ?? 1); + } + + const stdout = result.stdout?.trim() || ""; + if (stdout) p.log.success(stdout); + } finally { + try { + require("fs").unlinkSync(tmpFile); + } catch { /* ignore cleanup errors */ } + } +} + +// --------------------------------------------------------------------------- +// Subcommands +// --------------------------------------------------------------------------- + +const promptCmd = defineCommand({ + meta: { + name: "prompt", + description: "Print the full prompt that would be sent to the AI", + }, + args: { + context: { + type: "string", + alias: "c", + description: "Optional developer context to include", + }, + system: { + type: "boolean", + alias: "s", + description: "Include the system prompt (off by default)", + }, + }, + run({ args }) { + if (!hasStagedChanges()) { + console.error("No staged changes. Stage files with `git add` first."); + process.exit(1); + } + + const diffContext = gatherDiffContext(); + const userPrompt = buildUserPrompt(diffContext, args.context ?? ""); + + if (args.system) { + console.log("=== SYSTEM PROMPT ===\n"); + console.log(SYSTEM_PROMPT); + console.log("\n=== USER PROMPT ===\n"); + } + console.log(userPrompt); + }, +}); + +// --------------------------------------------------------------------------- +// Main TUI flow +// --------------------------------------------------------------------------- + +const main = defineCommand({ + meta: { + name: "commit", + description: "Interactive AI-powered commit message generator", + }, + subCommands: { + prompt: promptCmd, + }, + args: { + dryRun: { + type: "boolean", + alias: "n", + description: "Preview the commit message without actually committing", + }, + }, + async run({ args }) { + const dryRun = args.dryRun; + + p.intro(pc.bgCyan(pc.black(" commit "))); + + // 1. Check staged changes + if (!hasStagedChanges()) { + p.log.error("No staged changes. Stage files with " + pc.cyan("git add") + " first."); + bail(); + } + + // 2. Backend selection + const backend = await p.select({ + message: "Backend", + options: [ + { value: "opencode" as Backend, label: "OpenCode", hint: "opencode CLI" }, + { value: "claude" as Backend, label: "Claude Code", hint: "claude CLI" }, + ], + }); + if (isCancel(backend)) bail(); + + // 3. Model selection + const model = await p.select({ + message: "Model", + options: [ + { value: "sonnet" as ModelAlias, label: "Sonnet", hint: "balanced" }, + { value: "haiku" as ModelAlias, label: "Haiku", hint: "fast & cheap" }, + { value: "opus" as ModelAlias, label: "Opus", hint: "strongest" }, + ], + }); + if (isCancel(model)) bail(); + + // 3b. Thinking variant (opencode only) + let variant: string | undefined; + if (backend === "opencode") { + const v = await p.select({ + message: "Thinking level", + options: [ + { value: "minimal", label: "Minimal" }, + { value: "high", label: "High" }, + { value: "max", label: "Max" }, + ], + }); + if (isCancel(v)) bail(); + variant = v as string; + } + + // 4. Optional context + const userContext = await p.text({ + message: "Context for the AI " + pc.dim("(optional, Enter to skip)"), + placeholder: "e.g. refactored auth flow, fixed edge case in parser...", + defaultValue: "", + }); + if (isCancel(userContext)) bail(); + + // 5. Gather diff context + const s = p.spinner(); + s.start("Gathering staged changes..."); + const diffContext = gatherDiffContext(); + s.stop("Context gathered."); + + // 6. Generate + select loop + let chosen: CommitOption | null = null; + + while (!chosen) { + const genSpinner = p.spinner(); + const backendLabel = `${pc.cyan(backend)}/${pc.yellow(model)}`; + const t0 = Date.now(); + genSpinner.start(`Generating via ${backendLabel}...`); + + // Update spinner with elapsed time every second + const timer = setInterval(() => { + const elapsed = ((Date.now() - t0) / 1000).toFixed(0); + genSpinner.message(`Generating via ${backendLabel}... ${pc.dim(`${elapsed}s`)}`); + }, 1000); + + let raw: string; + try { + raw = await invokeAI(backend, model, diffContext, userContext, variant); + } catch (err) { + clearInterval(timer); + genSpinner.stop("Generation failed."); + p.log.error( + err instanceof Error ? err.message : "Unknown error from AI backend.", + ); + + const retry = await p.confirm({ message: "Retry?" }); + if (isCancel(retry) || !retry) bail(); + continue; + } + + clearInterval(timer); + const elapsed = ((Date.now() - t0) / 1000).toFixed(1); + genSpinner.stop(`Options generated. ${pc.dim(`(${elapsed}s)`)}`); + + const options = parseOptions(raw); + + if (options.length === 0) { + p.log.warn("Could not parse AI response. Raw output:"); + console.log(pc.dim(raw)); + const retry = await p.confirm({ message: "Retry?" }); + if (isCancel(retry) || !retry) bail(); + continue; + } + + // Build select options: each commit option + Regenerate + const selectOptions: Array<{ value: number; label: string; hint?: string }> = + options.map((opt, i) => ({ + value: i, + label: formatOptionLabel(opt), + hint: formatOptionHint(opt), + })); + selectOptions.push({ + value: -1, + label: pc.yellow("Regenerate"), + }); + + const pick = await p.select({ + message: "Pick a commit message", + options: selectOptions, + }); + if (isCancel(pick)) bail(); + + if (pick === -1) { + // Regenerate — loop continues + continue; + } + + chosen = options[pick as number]; + } + + // 7. Commit + const message = toCommitMessage(chosen); + runCommit(message, dryRun); + + p.outro(dryRun ? pc.yellow("Dry run complete.") : pc.green("Committed!")); + }, +}); + +runMain(main);