#!/usr/bin/env bun // gai — AI-powered git commit and PR helper // v0.1.3 import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli"; import { handleCommit } from "./src/commands/commit"; import { handlePR } from "./src/commands/pr"; import { handleConfig } from "./src/commands/config"; import { handleExplain } from "./src/commands/explain"; import { handleReview } from "./src/commands/review"; import { handleChangelog } from "./src/commands/changelog"; import { handleSuggest } from "./src/commands/suggest"; import { setColorEnabled } from "./src/terminal"; import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal"; import { isStdinTTY, initTTY } from "./src/tty"; import { showBanner } from "./src/brand"; // ── Interactive Menu (mole-style) ───────────────────────────────────── interface MenuItem { key: string; label: string; description: string; } const MENU_ITEMS: MenuItem[] = [ { key: "commit", label: "Commit", description: "Generate AI commit message" }, { key: "pr", label: "PR", description: "Create a PR with AI-generated title" }, { key: "explain", label: "Explain", description: "Explain staged changes in plain language" }, { key: "review", label: "Review", description: "AI code review of staged changes" }, { key: "changelog", label: "Changelog", description: "Generate changelog from commits" }, { key: "suggest", label: "Suggest", description: "Suggest branch name or commit type" }, { key: "amend", label: "Amend", description: "Amend last commit with AI message" }, { key: "config", label: "Config", description: "Configure API settings" }, ]; function hideCursor() { process.stdout.write("\x1b[?25l"); } function showCursor() { process.stdout.write("\x1b[?25h"); } function clearLine() { process.stdout.write("\r\x1b[2K"); } async function readKey(): Promise { return new Promise((resolve) => { const onData = (data: Buffer) => { process.stdin.removeListener("data", onData); resolve(data.toString()); }; process.stdin.once("data", onData); }); } function visibleLen(s: string): number { return s.replace(/\x1b\[[0-9;]*m/g, "").length; } function renderMenu(banner: string, cursor: number): number { process.stdout.write("\x1b[H"); // cursor home let lineCount = 0; for (const line of banner.split("\n")) { clearLine(); process.stdout.write(line + "\n"); lineCount++; } const G = GREEN(); const C = CYAN(); const D = DIM(); const R = RESET(); const ARROW = "➤"; // Calculate padding const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2; for (let i = 0; i < MENU_ITEMS.length; i++) { const item = MENU_ITEMS[i]!; const num = String(i + 1); const active = i === cursor; clearLine(); if (active) { const padding = " ".repeat(Math.max(1, labelWidth - visibleLen(item.label))); process.stdout.write(` ${C}${ARROW} ${num}. ${item.label}${R}${padding}${D}${item.description}${R}\n`); } else { const padding = " ".repeat(labelWidth - visibleLen(item.label) + 3); process.stdout.write(` ${num}. ${item.label}${padding}${D}${item.description}${R}\n`); } lineCount++; } // Footer clearLine(); process.stdout.write("\n"); lineCount++; clearLine(); process.stdout.write(` ${D}↑↓ | Enter | ${G}H${D} Help | ${G}V${D} Version | ${G}Q${D} Quit${R}\n`); lineCount++; clearLine(); process.stdout.write("\n"); lineCount++; // Clear rest of screen process.stdout.write("\x1b[J"); return lineCount; } async function dispatchMenuAction(key: string): Promise { const fakeArgs: ParsedArgs = { command: key, flags: {}, positional: [], raw: [], subcommand: { name: key, description: "", handler: async () => 0 }, }; if (key === "amend") fakeArgs.flags["amend"] = true; switch (key) { case "commit": return handleCommit(fakeArgs); case "pr": return handlePR(fakeArgs); case "config": return handleConfig(fakeArgs); case "explain": return handleExplain(fakeArgs); case "review": return handleReview(fakeArgs); case "changelog": return handleChangelog(fakeArgs); case "suggest": return handleSuggest(fakeArgs); case "amend": return handleCommit(fakeArgs); default: return 0; } } async function waitForEnter(): Promise { const D = DIM(); const R = RESET(); process.stdout.write(`\n ${D}Press Enter to return to menu...${R}`); // Read a line from stdin (works in cooked mode — blocks until Enter) await new Promise((resolve) => { const onData = (data: Buffer) => { process.stdin.removeListener("data", onData); resolve(); }; process.stdin.on("data", onData); // Resume stdin in case it was paused process.stdin.resume(); }); process.stdout.write("\x1b[2J\x1b[H"); // clear screen } async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise { showCursor(); process.stdin.setRawMode(wasRaw === true); process.stdin.pause(); process.stdout.write("\x1b[2J\x1b[H"); // clear screen const result = await dispatchMenuAction(item.key); await waitForEnter(); return result; } async function showMenu(): Promise { if (!isStdinTTY()) { console.error("Error: Interactive menu requires a TTY. Use --help for usage."); return 1; } const banner = showBanner(); let cursor = 0; const wasRaw = process.stdin.isRaw; if (wasRaw !== true) process.stdin.setRawMode(true); process.stdin.resume(); hideCursor(); // Initial render renderMenu(banner, cursor); try { while (true) { const raw = await readKey(); // Escape sequences (arrows) if (raw === "\x1b[A" || raw === "\x1bOA") { if (cursor > 0) { cursor--; renderMenu(banner, cursor); } continue; } if (raw === "\x1b[B" || raw === "\x1bOB") { if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(banner, cursor); } continue; } // Enter if (raw === "\r" || raw === "\n") { const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw); if (result !== 0) return result; hideCursor(); if (wasRaw !== true) process.stdin.setRawMode(true); process.stdin.resume(); renderMenu(banner, cursor); continue; } // Ctrl+C if (raw === "\x03") { showCursor(); process.stdin.setRawMode(wasRaw === true); process.stdin.pause(); process.stdout.write("\n"); return 0; } // Number hotkeys (1-8) if (raw >= "1" && raw <= "8") { const idx = parseInt(raw) - 1; if (idx < MENU_ITEMS.length) { cursor = idx; renderMenu(banner, cursor); const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw); if (result !== 0) return result; hideCursor(); if (wasRaw !== true) process.stdin.setRawMode(true); process.stdin.resume(); renderMenu(banner, cursor); continue; } } // Letter hotkeys const lower = raw.toLowerCase(); if (lower === "h") { showCursor(); process.stdin.setRawMode(wasRaw === true); process.stdin.pause(); process.stdout.write("\x1b[2J\x1b[H"); console.log(formatHelp(commands)); return 0; } if (lower === "v") { showCursor(); process.stdin.setRawMode(wasRaw === true); process.stdin.pause(); process.stdout.write("\x1b[2J\x1b[H"); console.log("gai v0.1.3"); return 0; } if (lower === "q") { showCursor(); process.stdin.setRawMode(wasRaw === true); process.stdin.pause(); process.stdout.write("\n"); return 0; } } } finally { showCursor(); process.stdin.setRawMode(wasRaw === true); process.stdin.pause(); } } // ── Command Definitions ──────────────────────────────────────────────── const commands = registerCommands( { name: "", description: "Open interactive menu", usage: "gai", handler: async () => showMenu(), } as CommandDef, { name: "help", aliases: ["h"], description: "Show help for gai or a specific command", usage: "gai help [command]", handler: async (args: ParsedArgs) => { const commandsMap = registerCommands(...allCommandDefs); const cmdName = args.positional[0] || undefined; console.log(formatHelp(commandsMap, cmdName)); return 0; }, } as CommandDef, { name: "commit", aliases: ["c", "ci"], description: "Generate AI commit message for staged changes", usage: "gai commit [-a|--all] [-d|--dry-run] [-m|--message ] [--amend]", flags: [ { long: "all", short: "a", type: "boolean", description: "Auto-stage all changed files" }, { long: "auto", type: "boolean", description: "Alias for --all" }, { long: "dry-run", short: "d", type: "boolean", description: "Generate message without committing" }, { long: "message", short: "m", type: "string", description: "Use provided message (skip AI)" }, { long: "amend", type: "boolean", description: "Amend the last commit with AI-generated message" }, ], examples: [ "gai commit", "gai commit -a", "gai commit -d", "gai commit -m 'fix: typo in README'", "gai commit --amend", "git diff --staged | gai commit", ], handler: handleCommit, }, { name: "pr", aliases: ["p"], description: "Create a PR with AI-generated title and body", usage: "gai pr [--draft]", flags: [ { long: "draft", type: "boolean", description: "Create as draft PR" }, ], examples: [ "gai pr", "gai pr --draft", ], handler: handlePR, }, { name: "config", aliases: ["cfg"], description: "Configure API settings", usage: "gai config [get |set |list]", flags: [], examples: [ "gai config", "gai config list", "gai config get model", "gai config set model gpt-4o", ], handler: handleConfig, }, { name: "explain", aliases: ["x"], description: "Explain staged changes in plain language", usage: "gai explain [--unstaged]", flags: [ { long: "unstaged", short: "u", type: "boolean", description: "Explain unstaged changes instead of staged" }, { long: "staged", short: "s", type: "boolean", description: "Explain staged changes (default)" }, ], examples: [ "gai explain", "gai explain --unstaged", "git diff main..feature | gai explain", ], handler: handleExplain, }, { name: "review", aliases: ["r", "rv"], description: "AI code review of staged changes", usage: "gai review [--strict|--lenient] [--unstaged]", flags: [ { long: "strict", type: "boolean", description: "Thorough review, flag minor issues" }, { long: "lenient", type: "boolean", description: "Focus only on major issues" }, { long: "unstaged", short: "u", type: "boolean", description: "Review unstaged changes instead of staged" }, ], examples: [ "gai review", "gai review --strict", "gai review --unstaged", "git diff main..feature | gai review", ], handler: handleReview, }, { name: "changelog", aliases: ["cl", "log"], description: "Generate changelog from recent commits", usage: "gai changelog [--from ] [--to ] [--count ]", flags: [ { long: "from", short: "f", type: "string", description: "Starting ref (tag or commit)" }, { long: "to", short: "t", type: "string", description: "Ending ref (default: HEAD)" }, { long: "count", short: "n", type: "string", description: "Number of recent commits (default: 20)" }, ], examples: [ "gai changelog", "gai changelog --from v1.0.0", "gai changelog --from v1.0.0 --to v1.1.0", "gai changelog -n 50", ], handler: handleChangelog, }, { name: "suggest", aliases: ["sg"], description: "Suggest branch name or commit type based on changes", usage: "gai suggest [branch|type]", flags: [ { long: "unstaged", short: "u", type: "boolean", description: "Use unstaged changes" }, ], examples: [ "gai suggest branch", "gai suggest type", "git diff | gai suggest branch", ], handler: handleSuggest, }, ); // Keep the defs accessible for help command const allCommandDefs = [...commands.values()].filter( (c, i, arr) => arr.findIndex((x) => x.name === c.name) === i, ); // ── Main ─────────────────────────────────────────────────────────────── process.on("SIGINT", () => { process.stdout.write("\x1b[?25h"); // ensure cursor is shown process.stdout.write("\n"); process.exit(130); }); const args = process.argv.slice(2); // Initialize TTY detection early (before any command handlers run) initTTY(); // Apply --no-color early if (args.includes("--no-color")) { setColorEnabled(false); } runCLI(args, commands) .then((exitCode) => { process.exit(exitCode); }) .catch((err) => { console.error(`\n Unexpected error: ${err.message ?? err}\n`); process.exit(1); });