Files
gai/index.ts
T
Mplan ab9a41ab83
Build / bun-build (push) Successful in 16m54s
feat(brand): redesign logo with bold box-drawing ASCII art; bump v0.1.3
Replace the simple ASCII logo with a bold 'GAI' box-drawing font
that renders cleanly in modern terminals. Fix version to v0.1.3
across all files (brand, cli, index, package.json).
2026-06-16 02:10:09 +08:00

439 lines
13 KiB
TypeScript

#!/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<string> {
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<number> {
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<void> {
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<void>((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<number> {
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<number> {
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 <msg>] [--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 <key>|set <key> <value>|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 <ref>] [--to <ref>] [--count <n>]",
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);
});