mirror of
https://github.com/Xevion/dotfiles.git
synced 2026-01-31 14:24:09 -06:00
feat: add TUI commit tool with Claude and OpenCode backends
This commit is contained in:
@@ -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: <subject line>
|
||||
[optional body paragraph]
|
||||
|
||||
2: <subject line>
|
||||
[optional body paragraph]
|
||||
|
||||
3: <subject line>
|
||||
[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<ModelAlias, string> = {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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);
|
||||
Reference in New Issue
Block a user