diff --git a/index.ts b/index.ts index 4d962d1..27a681f 100644 --- a/index.ts +++ b/index.ts @@ -1,995 +1,274 @@ #!/usr/bin/env bun -import * as readline from "node:readline"; -import { loadConfig, saveConfig } from "./src/config"; -import { - isGitRepo, - getRepoRoot, - getStagedFiles, - getUnstagedFiles, - getStagedDiff, - getRecentCommits, - stageFiles, - commit, -} from "./src/git"; -import { selectFiles } from "./src/selector"; +// gai — AI-powered git commit and PR helper +// v0.2.0 + +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 { BACK, selectOne } from "./src/menu"; -import type { PromptBack } from "./src/menu"; -import { collectProjectContext } from "./src/context"; -import { buildPrompt, SYSTEM_PROMPT } from "./src/prompt"; -import { generateCommitMessage } from "./src/ai"; -import { copyToClipboard } from "./src/clipboard"; -import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "./src/terminal"; -import type { Config } from "./src/types"; -import { - getDefaultBranch, - getBranchName, - getBranchPushStatus, - pushCurrentBranch, - getBranchCommits, - getBranchDiff, - detectPlatform, - getRemoteHostname, - createPR, -} from "./src/pr"; -import type { Platform } from "./src/pr"; -import { PR_SYSTEM_PROMPT, buildPRPrompt } from "./src/prompt"; -import { generatePRMessage } from "./src/ai"; -const args = process.argv.slice(2); +// ── Interactive Menu (default, no subcommand) ───────────────────────── -function showHelp() { - console.log(` -${BOLD}gai${RESET} — AI-powered git commit message generator - -${BOLD}Usage:${RESET} - gai Open interactive menu - gai commit Generate commit message for staged/changed files - gai commit --auto Auto-stage all changed files - gai commit -d Generate message without committing - gai pr Create a PR with AI-generated title and body - gai pr --draft Create a draft PR - gai config Configure API settings - gai --help Show this help message - gai --version Show version - -${BOLD}Configuration:${RESET} - Set via ${CYAN}gai config${RESET} or environment variables: - GAI_API_KEY OpenAI-compatible API key - GAI_API_BASE API base URL (default: https://api.deepseek.com/v1) - GAI_MODEL Model name (default: deepseek-chat) - GAI_MAX_TOKENS Max tokens (default: 500) - GAI_TEMPERATURE Temperature (default: 0.7) -`); -} - -function ask(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -type ConfigKey = keyof Config; - -interface ConfigField { - key: ConfigKey; - label: string; - format: (config: Config) => string; - initialEditValue: (config: Config) => string; - parse: (value: string) => { value: Config[ConfigKey] } | { error: string }; -} - -const CONFIG_FIELDS: ConfigField[] = [ - { - key: "apiKey", - label: "API Key", - format: (config) => - config.apiKey ? `****${config.apiKey.slice(-4)}` : `${YELLOW}(not set)${RESET}`, - initialEditValue: () => "", - parse: (value) => ({ value }), - }, - { - key: "apiBase", - label: "API Base", - format: (config) => config.apiBase, - initialEditValue: (config) => config.apiBase, - parse: (value) => ({ value }), - }, - { - key: "model", - label: "Model", - format: (config) => config.model, - initialEditValue: (config) => config.model, - parse: (value) => ({ value }), - }, - { - key: "maxTokens", - label: "Max Tokens", - format: (config) => String(config.maxTokens), - initialEditValue: (config) => String(config.maxTokens), - parse: (value) => { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed <= 0) { - return { error: "Max Tokens must be a positive integer." }; - } - return { value: parsed }; - }, - }, - { - key: "temperature", - label: "Temperature", - format: (config) => String(config.temperature), - initialEditValue: (config) => String(config.temperature), - parse: (value) => { - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return { error: "Temperature must be a finite number." }; - } - return { value: parsed }; - }, - }, -]; - -function visibleLength(value: string) { - return value.replace(/\x1b\[[0-9;]*m/g, "").length; -} - -function clearLine() { - process.stdout.write("\r\x1b[2K"); -} - -function moveUp(lines: number) { - if (lines > 0) process.stdout.write(`\x1b[${lines}A`); -} - -function renderConfigPage( - config: Config, - cursor: number, - previousLines: number, - status: string | null, - editState: { buffer: string; cursor: number } | null, -) { - if (previousLines > 0) { - for (let i = 0; i < previousLines; i++) { - clearLine(); - process.stdout.write("\n"); - } - moveUp(previousLines); - } - - const labelWidth = Math.max(...CONFIG_FIELDS.map((field) => field.label.length)) + 2; - const lines = [ - "", - ` ${BOLD}Configuration${RESET}`, - editState - ? ` ${DIM}editing · enter save · esc cancel · ctrl+c cancel${RESET}` - : ` ${DIM}↑/↓ navigate · space edit · ←/backspace back · ctrl+c cancel${RESET}`, - "", - ]; - - let activeValueOffset = 0; - for (let i = 0; i < CONFIG_FIELDS.length; i++) { - const field = CONFIG_FIELDS[i]!; - const active = i === cursor; - const pointer = active ? `${CYAN}❯${RESET}` : " "; - const marker = active ? `${GREEN}●${RESET}` : `${DIM}○${RESET}`; - const label = active ? `${BOLD}${field.label}${RESET}` : field.label; - const padding = " ".repeat(Math.max(1, labelWidth - visibleLength(field.label))); - const value = active && editState ? editState.buffer : field.format(config); - if (active && editState) { - activeValueOffset = visibleLength(` ${pointer} ${marker} ${label}${padding}`); - } - lines.push(` ${pointer} ${marker} ${label}${padding}${value}`); - } - - if (status) { - lines.push("", ` ${status}`); - } - - for (const line of lines) { - process.stdout.write(`${line}\n`); - } - if (editState) { - moveUp(lines.length - (4 + cursor)); - const column = activeValueOffset + editState.cursor; - process.stdout.write(`\r${column > 0 ? `\x1b[${column}C` : ""}`); - } else { - moveUp(lines.length); - } - return lines.length; -} - -async function handleConfig(): Promise<"done" | "back"> { - if (process.stdin.isTTY !== true) { - console.error(` ${RED}Error: Configuration requires a TTY.${RESET}`); - process.exit(1); - } - - let config = await loadConfig(); - let cursor = 0; - let renderedLines = 0; - let escapeBuf = ""; - let status: string | null = null; - let editState: { buffer: string; cursor: number } | null = null; - let renderedCursorRow = 0; - const wasRaw = process.stdin.isRaw; - - if (wasRaw !== true) process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdout.write("\x1b[?25l"); - - const render = () => { - moveUp(renderedCursorRow); - renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState); - renderedCursorRow = editState ? 4 + cursor : 0; - process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l"); - }; - - render(); - - return new Promise((resolve) => { - const finish = (value: "done" | "back") => { - process.stdin.setRawMode(wasRaw === true); - process.stdin.pause(); - process.stdin.removeListener("data", onData); - moveUp(renderedCursorRow); - for (let i = 0; i < renderedLines; i++) { - clearLine(); - process.stdout.write("\n"); - } - moveUp(renderedLines); - process.stdout.write("\x1b[?25h"); - resolve(value); - }; - - const saveEdit = async () => { - if (!editState) return; - const field = CONFIG_FIELDS[cursor]!; - const value = editState.buffer.trim(); - editState = null; - - if (value === "") { - status = `${DIM}No changes.${RESET}`; - } else { - const parsed = field.parse(value); - if ("error" in parsed) { - status = `${RED}${parsed.error}${RESET}`; - } else { - await saveConfig({ [field.key]: parsed.value } as Partial); - config = await loadConfig(); - status = `${GREEN}${field.label} saved.${RESET}`; - } - } - - render(); - }; - - const onData = (data: Buffer) => { - const key = data.toString(); - const UP = "\x1b[A"; - const DOWN = "\x1b[B"; - const LEFT = "\x1b[D"; - const ALT_UP = "\x1bOA"; - const ALT_DOWN = "\x1bOB"; - const ALT_LEFT = "\x1bOD"; - const SPACE = " "; - const ENTER = "\r"; - const ESC = "\x1b"; - const RIGHT = "\x1b[C"; - const ALT_RIGHT = "\x1bOC"; - const CTRL_C = "\x03"; - const BACKSPACE = "\x7f"; - - if (editState) { - if (key === CTRL_C || key === ESC) { - editState = null; - status = `${DIM}No changes.${RESET}`; - render(); - return; - } - if (key === ENTER) { - void saveEdit(); - return; - } - if (key === "\x01") { - editState.cursor = 0; - render(); - return; - } - if (key === "\x05") { - editState.cursor = editState.buffer.length; - render(); - return; - } - if (key === "\x0b") { - editState.buffer = editState.buffer.slice(0, editState.cursor); - render(); - return; - } - if (key === "\x15") { - editState.buffer = editState.buffer.slice(editState.cursor); - editState.cursor = 0; - render(); - return; - } - if (key === BACKSPACE) { - if (editState.cursor > 0) { - editState.buffer = - editState.buffer.slice(0, editState.cursor - 1) + - editState.buffer.slice(editState.cursor); - editState.cursor--; - render(); - } - return; - } - if (key === LEFT || key === ALT_LEFT) { - if (editState.cursor > 0) editState.cursor--; - render(); - return; - } - if (key === RIGHT || key === ALT_RIGHT) { - if (editState.cursor < editState.buffer.length) editState.cursor++; - render(); - return; - } - if (key.startsWith("\x1b[")) { - if (key === "\x1b[H" || key === "\x1b[1~") { - editState.cursor = 0; - } else if (key === "\x1b[F" || key === "\x1b[4~") { - editState.cursor = editState.buffer.length; - } else if (key === "\x1b[3~" && editState.cursor < editState.buffer.length) { - editState.buffer = - editState.buffer.slice(0, editState.cursor) + - editState.buffer.slice(editState.cursor + 1); - } - render(); - return; - } - if (key >= " " && key !== "\x7f") { - editState.buffer = - editState.buffer.slice(0, editState.cursor) + - key + - editState.buffer.slice(editState.cursor); - editState.cursor += key.length; - render(); - } - return; - } - - const action = (() => { - if (key === UP || key === ALT_UP) return "up"; - if (key === DOWN || key === ALT_DOWN) return "down"; - if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return "back"; - if (key === SPACE) return "edit"; - if (key === CTRL_C) return "cancel"; - if (key === "\x1b" || key.startsWith("\x1b[")) { - escapeBuf = key; - return null; - } - if (escapeBuf) { - const next = escapeBuf + key; - escapeBuf = /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next; - if (next === UP || next === ALT_UP) return "up"; - if (next === DOWN || next === ALT_DOWN) return "down"; - if (next === LEFT || next === ALT_LEFT) return "back"; - } - return null; - })(); - - if (action === "cancel") return finish("done"); - if (action === "back") return finish("back"); - if (action === "up" && cursor > 0) { - cursor--; - status = null; - render(); - } else if (action === "down" && cursor < CONFIG_FIELDS.length - 1) { - cursor++; - status = null; - render(); - } else if (action === "edit") { - const value = CONFIG_FIELDS[cursor]!.initialEditValue(config); - editState = { buffer: value, cursor: value.length }; - status = null; - render(); - } - }; - - process.stdin.on("data", onData); - }); -} - -async function confirmCommit(message: string): Promise<"y" | "n" | "e"> { - console.log(`\n ${BOLD}Generated commit message:${RESET}`); - console.log(` ${GREEN}${message}${RESET}\n`); - const answer = await ask(` Use this message? [${GREEN}Y${RESET}/n/e] `); - const lower = answer.toLowerCase(); - if (lower === "n") return "n"; - if (lower === "e") return "e"; - return "y"; -} - -async function editMessage(current: string): Promise { - if (!process.stdin.isTTY) return null; - - process.stdout.write(` ${DIM}Edit message (Enter to confirm, Esc to abort):${RESET}\n`); - - const savedRaw = process.stdin.isRaw; - process.stdin.setRawMode(true); - process.stdin.resume(); - - let buffer = current; - let cursor = current.length; - const ESC = "\x1b"; - const ENTER = "\r"; - const CTRL_C = "\x03"; - const BACKSPACE = "\x7f"; - - function render() { - process.stdout.write("\x1b[2K\r > " + buffer); - if (cursor < buffer.length) { - process.stdout.write(`\x1b[${buffer.length - cursor}D`); - } - } - - process.stdout.write(" > "); - process.stdout.write(buffer); - - return new Promise((resolve) => { - let escapeBuf = ""; - - function handleSeq(seq: string) { - if (seq === "\x1b[D" || seq === "\x1bOD") { - if (cursor > 0) { - cursor--; - process.stdout.write("\x1b[D"); - } - } else if (seq === "\x1b[C" || seq === "\x1bOC") { - if (cursor < buffer.length) { - cursor++; - process.stdout.write("\x1b[C"); - } - } else if (seq === "\x1b[H" || seq === "\x1b[1~" || seq === "\x1bOH") { - if (cursor > 0) { - process.stdout.write(`\x1b[${cursor}D`); - cursor = 0; - } - } else if (seq === "\x1b[F" || seq === "\x1b[4~" || seq === "\x1bOF") { - if (cursor < buffer.length) { - process.stdout.write(`\x1b[${buffer.length - cursor}C`); - cursor = buffer.length; - } - } else if (seq === "\x1b[3~") { - if (cursor < buffer.length) { - buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1); - render(); - } - } - } - - process.stdin.on("data", (data: Buffer) => { - const key = data.toString(); - - if (key === CTRL_C) { - process.stdin.setRawMode(savedRaw === true); - process.stdin.pause(); - process.stdin.removeAllListeners("data"); - process.stdout.write("\n"); - resolve(null); - return; - } - - if (key === ESC || key.startsWith("\x1b[")) { - escapeBuf = key; - if (key.length >= 3) { - handleSeq(key); - escapeBuf = ""; - } - return; - } - - if (escapeBuf) { - escapeBuf += key; - if (/^[A-Za-z~]$/.test(key)) { - handleSeq(escapeBuf); - escapeBuf = ""; - } else if (escapeBuf.length > 8) { - escapeBuf = ""; - } - return; - } - - if (key === ENTER) { - process.stdin.setRawMode(savedRaw === true); - process.stdin.pause(); - process.stdin.removeAllListeners("data"); - process.stdout.write("\n"); - const result = buffer.trim(); - resolve(result || null); - return; - } - - if (key === BACKSPACE) { - if (cursor > 0) { - buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor); - cursor--; - render(); - } - return; - } - - if (key === "\x01") { - if (cursor > 0) { - process.stdout.write(`\x1b[${cursor}D`); - cursor = 0; - } - return; - } - if (key === "\x05") { - if (cursor < buffer.length) { - process.stdout.write(`\x1b[${buffer.length - cursor}C`); - cursor = buffer.length; - } - return; - } - if (key === "\x0b") { - buffer = buffer.slice(0, cursor); - render(); - return; - } - if (key === "\x15") { - buffer = buffer.slice(cursor); - cursor = 0; - render(); - return; - } - - if (key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) { - buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor); - cursor++; - render(); - } - }); - }); -} - -function printCommitResult( - result: { branch: string; hash: string; files: number; insertions: number; deletions: number }, - msg: string, -) { - console.log(`\n ${GREEN}${BOLD}✔ Committed successfully!${RESET}`); - - const id = result.branch && result.hash - ? `${YELLOW}[${result.branch} ${result.hash}]${RESET}` - : result.hash - ? `${YELLOW}${result.hash}${RESET}` - : ""; - console.log(` ${id} ${msg}`); - - const parts: string[] = []; - if (result.files > 0) parts.push(`${YELLOW}${result.files} file${result.files > 1 ? "s" : ""} changed${RESET}`); - if (result.insertions > 0) parts.push(`${GREEN}${result.insertions} insertion${result.insertions > 1 ? "s" : ""}(+)${RESET}`); - if (result.deletions > 0) parts.push(`${RED}${result.deletions} deletion${result.deletions > 1 ? "s" : ""}(-)${RESET}`); - if (parts.length > 0) console.log(` ${parts.join(", ")}`); -} - -interface MenuAction { - key: "commit" | "pr" | "config"; - label: string; - description: string; -} - -const MENU_ACTIONS: MenuAction[] = [ - { key: "commit", label: "commit", description: "Generate AI commit message" }, - { key: "pr", label: "pr", description: "Create a PR with AI-generated title" }, - { key: "config", label: "config", description: "Configure API settings" }, -]; - -async function showMenu(): Promise { - if (!process.stdin.isTTY) { - console.error(` ${RED}Error: Interactive menu requires a TTY.${RESET}`); - process.exit(1); +async function showMenu(): Promise { + if (!isStdinTTY()) { + console.error("Error: Interactive menu requires a TTY. Use --help for usage."); + return 1; } while (true) { const selected = await selectOne({ title: "gai", - subtitle: "Choose a workflow", + subtitle: "AI-powered git helper — choose a workflow", allowBack: false, - items: MENU_ACTIONS.map((action) => ({ - label: action.label, - value: action.key, - description: action.description, - })), + items: [ + { label: "commit", value: "commit", description: "Generate AI commit message" }, + { label: "pr", value: "pr", description: "Create a PR with AI-generated title" }, + { label: "explain", value: "explain", description: "Explain staged changes in plain language" }, + { label: "review", value: "review", description: "AI code review of staged changes" }, + { label: "changelog", value: "changelog", description: "Generate changelog from commits" }, + { label: "suggest", value: "suggest", description: "Suggest branch name or commit type" }, + { label: "amend", value: "amend", description: "Amend last commit with AI message" }, + { label: "config", value: "config", description: "Configure API settings" }, + ], }); - if (selected === null || selected === BACK) return; + if (selected === null || selected === BACK) return 0; - const result = - selected === "commit" - ? await handleCommit(false, false) - : selected === "pr" - ? await handlePR(false) - : await handleConfig(); + // Build synthetic args for subcommand handlers + const fakeArgs: ParsedArgs = { + command: selected, + flags: {}, + positional: [], + raw: [], + subcommand: { + name: selected, + description: "", + handler: async () => 0, + }, + }; - if (result !== "back") return; + let result: number; + switch (selected) { + case "commit": + result = await handleCommit(fakeArgs); + break; + case "pr": + result = await handlePR(fakeArgs); + break; + case "config": + result = await handleConfig(fakeArgs); + break; + case "explain": + result = await handleExplain(fakeArgs); + break; + case "review": + result = await handleReview(fakeArgs); + break; + case "changelog": + result = await handleChangelog(fakeArgs); + break; + case "suggest": + result = await handleSuggest(fakeArgs); + break; + case "amend": + fakeArgs.flags["amend"] = true; + result = await handleCommit(fakeArgs); + break; + default: + result = 0; + } + + // Return to menu unless they explicitly chose "back" + if (result !== 0) return result; + // Loop back to menu for another action } } -async function selectPlatform( - hostname: string, -): Promise { - if (!process.stdin.isTTY) { - console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`); - process.exit(1); - } +// ── Command Definitions ──────────────────────────────────────────────── - return selectOne({ - title: "Select remote platform", - subtitle: `Remote ${hostname} could not be auto-detected`, - items: [ - { label: "GitHub", value: "github" as Platform, description: "Use gh CLI" }, - { label: "Gitea", value: "gitea" as Platform, description: "Use tea CLI" }, - { label: "GitLab", value: "gitlab" as Platform, description: "Use glab CLI" }, +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, + }, -async function handleCommit( - autoMode: boolean, - dryRun: boolean, -): Promise<"done" | "back"> { - if (!(await isGitRepo())) { - console.error(` ${RED}Error: Not a git repository.${RESET}`); - process.exit(1); - } + { + 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, + }, - const stagedFiles = await getStagedFiles(); - const unstagedFiles = await getUnstagedFiles(); + { + 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, + }, - if (stagedFiles.length === 0 && unstagedFiles.length === 0) { - console.log(` ${DIM}Nothing to commit. No staged or unstaged changes.${RESET}`); - return "done"; - } + { + 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, + }, - if (unstagedFiles.length > 0) { - if (autoMode) { - await stageFiles(unstagedFiles.map((f) => f.path)); - console.log( - ` ${GREEN}Auto-staged ${unstagedFiles.length} file(s).${RESET}`, - ); - } else { - const selected = await selectFiles(stagedFiles, unstagedFiles); - if (selected === BACK) return "back"; - if (selected.length > 0) { - await stageFiles(selected); - console.log( - ` ${GREEN}Staged ${selected.length} file(s).${RESET}`, - ); - } - } - } + { + 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, + }, - const diff = await getStagedDiff(); - if (!diff) { - console.log(` ${DIM}Nothing to commit. No staged changes to commit.${RESET}`); - return "done"; - } + { + 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, + }, - const config = await loadConfig(); + { + 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, + }, +); - if (!config.apiKey) { - console.error( - ` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`, - ); - process.exit(1); - } +// Keep the defs accessible for help command +const allCommandDefs = [...commands.values()].filter( + (c, i, arr) => arr.findIndex((x) => x.name === c.name) === i, +); - const MAX_DIFF_SIZE = 15000; - const truncatedDiff = - diff.length > MAX_DIFF_SIZE - ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" - : diff; - - const repoRoot = await getRepoRoot(); - const projectCtx = await collectProjectContext(repoRoot); - const recentCommits = await getRecentCommits(10); - - const userPrompt = buildPrompt({ - readme: projectCtx.readme, - packageDescription: projectCtx.packageDescription, - structure: projectCtx.structure, - recentCommits, - diff: truncatedDiff, - }); - - console.log("\n Generating commit message..."); - - let message: string; - try { - message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt); - } catch (err) { - console.error( - ` ${RED}AI request failed: ${err instanceof Error ? err.message : err}${RESET}`, - ); - process.exit(1); - } - - if (dryRun) { - console.log(`\n ${BOLD}Generated commit message:${RESET}`); - console.log(` ${GREEN}${message}${RESET}`); - await copyToClipboard(message); - console.log(`\n ${DIM}(dry-run, message copied to clipboard)${RESET}`); - return "done"; - } - - const action = await confirmCommit(message); - - if (action === "y") { - try { - const result = await commit(message); - printCommitResult(result, message); - } catch (err) { - console.error( - ` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`, - ); - process.exit(1); - } - } else if (action === "e") { - const edited = await editMessage(message); - if (edited) { - try { - const result = await commit(edited); - printCommitResult(result, edited); - } catch (err) { - console.error( - ` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`, - ); - process.exit(1); - } - } else { - console.log(" Aborted."); - } - } else { - const copied = await copyToClipboard(message); - console.log( - copied - ? ` Aborted. Message copied to clipboard.` - : ` Aborted. Message: ${message}`, - ); - } - - return "done"; -} - -async function handlePR(draft: boolean): Promise<"done" | "back"> { - const config = await loadConfig(); - - if (!config.apiKey) { - console.error( - ` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`, - ); - process.exit(1); - } - - if (!(await isGitRepo())) { - console.error(` ${RED}Error: Not a git repository.${RESET}`); - process.exit(1); - } - - let platform = await detectPlatform(); - if (!platform) { - const hostname = (await getRemoteHostname()) || "unknown"; - const chosen = await selectPlatform(hostname); - if (chosen === BACK) return "back"; - if (!chosen) { - console.log(" Aborted."); - process.exit(0); - } - platform = chosen; - } - - const platformLabel = - platform === "github" ? "GitHub" : platform === "gitlab" ? "GitLab" : "Gitea"; - console.log(` Using: ${CYAN}${platformLabel}${RESET}`); - - const baseBranch = await getDefaultBranch(); - const branchName = await getBranchName(); - - if (branchName === baseBranch) { - console.error( - ` ${RED}Error: You are on the default branch (${baseBranch}). Switch to a feature branch first.${RESET}`, - ); - process.exit(1); - } - - console.log( - ` Branch: ${CYAN}${branchName}${RESET} → base: ${CYAN}${baseBranch}${RESET}`, - ); - - const commits = await getBranchCommits(baseBranch); - - if (commits.length === 0) { - const choice = await selectOne({ - title: "No commits to compare", - subtitle: `No commits on ${branchName} compared to ${baseBranch}. Commit something first.`, - items: [{ label: "Back", value: "back" as const }], - }); - if (choice === null) process.exit(0); - return "done"; - } - - console.log( - ` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`, - ); - - const pushStatus = await getBranchPushStatus(); - if (!pushStatus.pushed) { - const target = pushStatus.upstream ?? `origin/${branchName}`; - const answer = await ask( - ` Branch is not pushed to ${CYAN}${target}${RESET}. Push now? [${GREEN}Y${RESET}/n] `, - ); - - if (answer.toLowerCase() === "n") { - console.log(" Aborted."); - return "done"; - } - - console.log(` Pushing ${CYAN}${branchName}${RESET}...`); - try { - await pushCurrentBranch(branchName, pushStatus.upstream); - console.log(` ${GREEN}Pushed ${branchName}.${RESET}`); - } catch (err) { - console.error( - ` ${RED}Push failed: ${err instanceof Error ? err.message : err}${RESET}`, - ); - process.exit(1); - } - } - - const diff = await getBranchDiff(baseBranch); - if (!diff) { - console.error(` ${RED}Error: No diff from base branch.${RESET}`); - process.exit(1); - } - - const MAX_DIFF_SIZE = 15000; - const truncatedDiff = - diff.length > MAX_DIFF_SIZE - ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" - : diff; - - const repoRoot = await getRepoRoot(); - const projectCtx = await collectProjectContext(repoRoot); - - const userPrompt = buildPRPrompt({ - readme: projectCtx.readme, - packageDescription: projectCtx.packageDescription, - structure: projectCtx.structure, - branchName, - baseBranch, - branchCommits: commits, - diff: truncatedDiff, - }); - - console.log("\n Generating PR title..."); - - let title: string; - let body: string; - try { - const result = await generatePRMessage(config, PR_SYSTEM_PROMPT, userPrompt); - title = result.title; - body = result.body; - } catch (err) { - console.error( - ` ${RED}AI request failed: ${err instanceof Error ? err.message : err}${RESET}`, - ); - process.exit(1); - } - - console.log(`\n ${BOLD}Generated PR:${RESET}`); - console.log(` Title: ${GREEN}${title}${RESET}`); - if (body) { - console.log( - ` Body: ${DIM}${body.replace(/\n/g, "\n ")}${RESET}`, - ); - } - console.log(""); - - const answer = await ask(` Create this PR? [${GREEN}Y${RESET}/n/e] `); - const lower = answer.toLowerCase(); - - if (lower === "n") { - console.log(" Aborted."); - return "done"; - } - - if (lower === "e") { - const newTitle = await ask(" Title: "); - const newBody = await ask(" Body (optional): "); - if (!newTitle.trim()) { - console.log(" Aborted."); - return "done"; - } - title = newTitle; - body = newBody; - } - - console.log(`\n Creating PR...`); - - try { - const url = await createPR(platform, title, body, baseBranch, draft); - console.log(` ${GREEN}${BOLD}✔ PR created!${RESET}`); - console.log(` ${CYAN}${url}${RESET}`); - } catch (err) { - console.error( - ` ${RED}PR creation failed: ${err instanceof Error ? err.message : err}${RESET}`, - ); - process.exit(1); - } - - return "done"; -} - -async function main() { - if (args.includes("--help") || args.includes("-h")) { - showHelp(); - return; - } - - if (args.includes("--version") || args.includes("-v")) { - console.log("gai v0.1.0"); - return; - } - - const subcommand = args[0]; - - if (subcommand === "config") { - await handleConfig(); - return; - } - - if (subcommand === "help") { - showHelp(); - return; - } - - if (subcommand === "commit") { - const autoMode = args.includes("--auto") || args.includes("-a"); - const dryRun = args.includes("--dry-run") || args.includes("-d"); - await handleCommit(autoMode, dryRun); - return; - } - - if (subcommand === "pr") { - const draft = args.includes("--draft"); - await handlePR(draft); - return; - } - - if (!subcommand) { - await showMenu(); - return; - } - - console.error(` ${RED}Unknown command: ${subcommand}${RESET}`); - showHelp(); - process.exit(1); -} +// ── Main ─────────────────────────────────────────────────────────────── process.on("SIGINT", () => { - process.stdout.write("\x1b[?25h"); + process.stdout.write("\x1b[?25h"); // ensure cursor is shown process.stdout.write("\n"); process.exit(130); }); -main().catch((err) => { - console.error(` ${RED}Unexpected error: ${err}${RESET}`); - process.exit(1); -}); +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); + }); diff --git a/src/clipboard.ts b/src/clipboard.ts index b42c988..7611d41 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -1,26 +1,26 @@ export async function copyToClipboard(text: string): Promise { - const commands: string[][] = []; + const commands: string[][] = []; - if (process.platform === "darwin") { - commands.push(["pbcopy"]); - } else if (process.platform === "linux") { - commands.push(["xclip", "-selection", "clipboard"]); - commands.push(["xsel", "--clipboard", "--input"]); - } + if (process.platform === "darwin") { + commands.push(["pbcopy"]); + } else if (process.platform === "linux") { + commands.push(["xclip", "-selection", "clipboard"]); + commands.push(["xsel", "--clipboard", "--input"]); + } - for (const cmd of commands) { - try { - const proc = Bun.spawn(cmd, { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", - }); - proc.stdin.write(text); - proc.stdin.end(); - const exitCode = await proc.exited; - if (exitCode === 0) return true; - } catch {} - } + for (const cmd of commands) { + try { + const proc = Bun.spawn(cmd, { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }); + proc.stdin.write(text); + proc.stdin.end(); + const exitCode = await proc.exited; + if (exitCode === 0) return true; + } catch {} + } - return false; + return false; } diff --git a/src/menu.ts b/src/menu.ts index 4d59295..63e1d79 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -1,4 +1,5 @@ import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal"; +import { isStdinTTY } from "./tty"; const UP = "\x1b[A"; const DOWN = "\x1b[B"; @@ -38,13 +39,8 @@ interface MultiPromptOptions extends BasePromptOptions { doneLabel?: string; } -function hideCursor() { - process.stdout.write("\x1b[?25l"); -} - -function showCursor() { - process.stdout.write("\x1b[?25h"); -} +function hideCursor() { process.stdout.write("\x1b[?25l"); } +function showCursor() { process.stdout.write("\x1b[?25h"); } function moveUp(lines: number) { if (lines > 0) process.stdout.write(`\x1b[${lines}A`); @@ -65,10 +61,10 @@ function padLabel(label: string, width: number) { function controls(mode: "single" | "multi", showBackHint = true) { if (mode === "single") { const backHint = showBackHint ? " · ←/backspace back" : ""; - return `${DIM}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET}`; + return `${DIM()}↑/↓ navigate · enter/space select${backHint} · ctrl+c cancel${RESET()}`; } const backHint = showBackHint ? " · ←/backspace back" : ""; - return `${DIM}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET}`; + return `${DIM()}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET()}`; } function renderPrompt(lines: string[], previousLines: number) { @@ -79,10 +75,7 @@ function renderPrompt(lines: string[], previousLines: number) { } moveUp(previousLines); } - - for (const line of lines) { - process.stdout.write(`${line}\n`); - } + for (const line of lines) process.stdout.write(`${line}\n`); moveUp(lines.length); return lines.length; } @@ -98,28 +91,19 @@ function clearPrompt(lines: number) { function normalizeKey(key: string, escapeBuf: string) { if (key === UP || key === ALT_UP) return { action: "up", escapeBuf: "" }; if (key === DOWN || key === ALT_DOWN) return { action: "down", escapeBuf: "" }; - if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) { - return { action: "back", escapeBuf: "" }; - } + if (key === LEFT || key === ALT_LEFT || key === BACKSPACE) return { action: "back", escapeBuf: "" }; if (key === SPACE) return { action: "space", escapeBuf: "" }; if (key === ENTER) return { action: "enter", escapeBuf: "" }; if (key === CTRL_C) return { action: "cancel", escapeBuf: "" }; - if (key === "\x1b" || key.startsWith("\x1b[")) { - return { action: null, escapeBuf: key }; - } + if (key === "\x1b" || key.startsWith("\x1b[")) return { action: null, escapeBuf: key }; if (escapeBuf) { const next = escapeBuf + key; if (next === UP || next === ALT_UP) return { action: "up", escapeBuf: "" }; if (next === DOWN || next === ALT_DOWN) return { action: "down", escapeBuf: "" }; - if (next === LEFT || next === ALT_LEFT) { - return { action: "back", escapeBuf: "" }; - } - return { - action: null, - escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next, - }; + if (next === LEFT || next === ALT_LEFT) return { action: "back", escapeBuf: "" }; + return { action: null, escapeBuf: /^[A-Za-z~]$/.test(key) || next.length > 8 ? "" : next }; } return { action: null, escapeBuf: "" }; @@ -130,41 +114,35 @@ function createLines( mode: "single" | "multi", cursor: number, ) { - const labelWidth = Math.max( - ...options.items.map((item) => visibleLength(item.label)), - 0, - ) + 2; - + const labelWidth = Math.max(...options.items.map((item) => visibleLength(item.label)), 0) + 2; const lines = [ "", - ` ${BOLD}${options.title}${RESET}`, + ` ${BOLD()}${options.title}${RESET()}`, ]; - if (options.subtitle) lines.push(` ${DIM}${options.subtitle}${RESET}`); + if (options.subtitle) lines.push(` ${DIM()}${options.subtitle}${RESET()}`); lines.push(` ${controls(mode, options.allowBack !== false)}`, ""); for (let i = 0; i < options.items.length; i++) { const item = options.items[i]!; const active = i === cursor; - const pointer = active ? `${CYAN}❯${RESET}` : " "; + const pointer = active ? `${CYAN()}❯${RESET()}` : " "; const marker = mode === "single" - ? active ? `${GREEN}●${RESET}` : `${DIM}○${RESET}` - : item.selected ? `${GREEN}◼${RESET}` : `${DIM}□${RESET}`; - const label = active ? `${BOLD}${item.label}${RESET}` : item.label; + ? active ? `${GREEN()}●${RESET()}` : `${DIM()}○${RESET()}` + : item.selected ? `${GREEN()}◼${RESET()}` : `${DIM()}□${RESET()}`; + const label = active ? `${BOLD()}${item.label}${RESET()}` : item.label; const description = item.description - ? active ? item.description : `${DIM}${item.description}${RESET}` + ? active ? item.description : `${DIM()}${item.description}${RESET()}` : ""; - lines.push( - ` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`, - ); + lines.push(` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`); } return lines; } function ensureTTY(title: string) { - if (process.stdin.isTTY !== true) { + if (!isStdinTTY()) { throw new Error(`${title} requires a TTY.`); } } @@ -184,12 +162,8 @@ export async function selectOne( hideCursor(); const render = () => { - renderedLines = renderPrompt( - createLines(options, "single", cursor), - renderedLines, - ); + renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines); }; - render(); return new Promise((resolve) => { @@ -210,16 +184,10 @@ export async function selectOne( escapeBuf = result.escapeBuf; if (result.action === "cancel") return finish(null); - if (result.action === "back" && options.allowBack !== false) { - return finish(BACK); - } - if (result.action === "up" && cursor > 0) { - cursor--; - render(); - } else if (result.action === "down" && cursor < options.items.length - 1) { - cursor++; - render(); - } else if (result.action === "space" || result.action === "enter") { + if (result.action === "back" && options.allowBack !== false) return finish(BACK); + if (result.action === "up" && cursor > 0) { cursor--; render(); } + else if (result.action === "down" && cursor < options.items.length - 1) { cursor++; render(); } + else if (result.action === "space" || result.action === "enter") { finish(options.items[cursor]!.value); } }; @@ -257,11 +225,8 @@ export async function selectMany( const toggle = (index: number) => { const item = items[index]!; item.selected = !item.selected; - if (options.selectAllLabel && index === 0) { - for (let i = 1; i < items.length; i++) { - items[i]!.selected = item.selected; - } + for (let i = 1; i < items.length; i++) items[i]!.selected = item.selected; } else { syncSelectAll(); } @@ -269,19 +234,10 @@ export async function selectMany( const render = () => { renderedLines = renderPrompt( - createLines( - { - title: options.title, - subtitle: options.subtitle, - items, - }, - "multi", - cursor, - ), + createLines({ title: options.title, subtitle: options.subtitle, items }, "multi", cursor), renderedLines, ); }; - render(); return new Promise((resolve) => { @@ -302,24 +258,12 @@ export async function selectMany( escapeBuf = result.escapeBuf; if (result.action === "cancel") return finish(null); - if (result.action === "back" && options.allowBack !== false) { - return finish(BACK); - } - if (result.action === "up" && cursor > 0) { - cursor--; - render(); - } else if (result.action === "down" && cursor < items.length - 1) { - cursor++; - render(); - } else if (result.action === "space") { - toggle(cursor); - render(); - } else if (result.action === "enter") { - finish( - items - .filter((item) => item.selected && item.value !== null) - .map((item) => item.value as T), - ); + if (result.action === "back" && options.allowBack !== false) return finish(BACK); + if (result.action === "up" && cursor > 0) { cursor--; render(); } + else if (result.action === "down" && cursor < items.length - 1) { cursor++; render(); } + else if (result.action === "space") { toggle(cursor); render(); } + else if (result.action === "enter") { + finish(items.filter((item) => item.selected && item.value !== null).map((item) => item.value as T)); } }; diff --git a/src/selector.ts b/src/selector.ts index 96019b1..2672130 100644 --- a/src/selector.ts +++ b/src/selector.ts @@ -1,5 +1,6 @@ import type { FileEntry } from "./types"; import { BOLD, GREEN, YELLOW, RESET } from "./terminal"; +import { isStdinTTY } from "./tty"; import { BACK, selectMany } from "./menu"; import type { PromptBack } from "./menu"; @@ -10,13 +11,13 @@ export async function selectFiles( if (unstagedFiles.length === 0) return []; if (stagedFiles.length > 0) { - process.stdout.write(`\n ${BOLD}Staged files (will be included):${RESET}\n`); + process.stdout.write(`\n ${BOLD()}Staged files (will be included):${RESET()}\n`); for (const f of stagedFiles) { - process.stdout.write(` ${GREEN}✓${RESET} ${f.path} (${YELLOW}${f.label}${RESET})\n`); + process.stdout.write(` ${GREEN()}✓${RESET()} ${f.path} (${YELLOW()}${f.label}${RESET()})\n`); } } - if (process.stdin.isTTY !== true) return []; + if (!isStdinTTY()) return []; const selected = await selectMany({ title: "Select files to stage", @@ -35,7 +36,7 @@ export async function selectFiles( if (selected.length > 0) { process.stdout.write( - ` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`, + ` ${GREEN()}Staged ${selected.length} file(s):${RESET()} ${selected.join(", ")}\n`, ); }