From 0d9c31ae3bb48632b69dc63ac24c533ba1c1f4bd Mon Sep 17 00:00:00 2001 From: Mplan Date: Wed, 17 Jun 2026 00:14:35 +0800 Subject: [PATCH] Revert "feat(ui): redesign menu and add explain, review, changelog, suggest commands" This reverts commit 1e370be8afa87380e594b8ac41dee9e8a066f254. --- README.md | 263 ++------ index.ts | 1334 ++++++++++++++++++++++++++----------- package.json | 4 +- src/ai.ts | 118 ++-- src/brand.ts | 22 - src/cli.ts | 295 -------- src/clipboard.ts | 42 +- src/commands/changelog.ts | 81 --- src/commands/commit.ts | 433 ------------ src/commands/config.ts | 333 --------- src/commands/explain.ts | 129 ---- src/commands/pr.ts | 209 ------ src/commands/review.ts | 130 ---- src/commands/suggest.ts | 146 ---- src/git.ts | 27 - src/menu.ts | 127 ++-- src/prompt.ts | 186 +----- src/selector.ts | 49 +- src/terminal.ts | 42 +- src/tty.ts | 36 - src/types.ts | 14 - test/config.test.ts | 52 +- 22 files changed, 1245 insertions(+), 2827 deletions(-) delete mode 100644 src/brand.ts delete mode 100644 src/cli.ts delete mode 100644 src/commands/changelog.ts delete mode 100644 src/commands/commit.ts delete mode 100644 src/commands/config.ts delete mode 100644 src/commands/explain.ts delete mode 100644 src/commands/pr.ts delete mode 100644 src/commands/review.ts delete mode 100644 src/commands/suggest.ts delete mode 100644 src/tty.ts diff --git a/README.md b/README.md index 61b1205..f82e76c 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,14 @@ # gai -**AI-powered Git helper โ€” commit messages, PRs, code review, changelogs, and more** +**AI-powered Git commit and pull request helper** +[![Release](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgit.catpl.top%2Fapi%2Fv1%2Frepos%2FMplan%2Fgai%2Freleases%2Flatest&query=%24.tag_name&label=release&color=blue)](https://git.catpl.top/Mplan/gai/releases) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) [![Bun](https://img.shields.io/badge/runtime-Bun-f9d71c.svg)](https://bun.sh) [![TypeScript](https://img.shields.io/badge/lang-TypeScript-3178c6.svg)](https://www.typescriptlang.org/) -Generate **Conventional Commits** messages, pull request descriptions, code reviews, changelogs, and more โ€” powered by AI with full project context. +Generate **Conventional Commits** messages and pull request descriptions using AI, based on your project context, code diff, branch changes, and commit history. @@ -16,18 +17,15 @@ Generate **Conventional Commits** messages, pull request descriptions, code revi ## Features -- **๐Ÿค– AI Commit Messages** โ€” generate Conventional Commits from staged diffs with project context -- **๐Ÿ”€ AI Pull Requests** โ€” create GitHub, Gitea, or GitLab PRs with AI-generated title and body -- **๐Ÿ“– Explain Changes** โ€” `gai explain` explains diffs in plain language -- **๐Ÿ” Code Review** โ€” `gai review` provides thorough, constructive code review -- **๐Ÿ“ Changelog Generation** โ€” `gai changelog` generates user-facing changelogs from commits -- **๐Ÿ’ก Smart Suggestions** โ€” `gai suggest` recommends branch names or commit types -- **โœ๏ธ Amend Commits** โ€” `gai commit --amend` amends with AI-generated message -- **โšก Streaming Output** โ€” AI responses stream token-by-token for instant feedback -- **๐Ÿ“‚ Interactive File Selection** โ€” โ†‘/โ†“ navigate, space toggle, "Select all" support -- **๐Ÿ“‹ Pipe Support** โ€” pipe diffs directly: `git diff | gai explain` -- **๐ŸŽจ Mainstream CLI UX** โ€” proper flags, aliases, `--help` per command, `--no-color` -- **๐Ÿ”ง OpenAI-compatible API** โ€” works with DeepSeek, OpenAI, Ollama, OpenRouter, and more +- **Interactive menu** โ€” `gai` opens a menu, select actions with โ†‘/โ†“ + space/enter +- **Context-aware commits** โ€” reads project overview, staged diff, and recent commit history +- **Conventional Commits** โ€” `feat(scope): description` format by default +- **Interactive file selection** โ€” โ†‘/โ†“ to navigate, space to select, top-level "Select all" +- **Inline editing** โ€” edit AI-generated messages right in the terminal with cursor movement +- **AI-generated PRs** โ€” create GitHub, Gitea, or GitLab pull requests with generated title and body +- **OpenAI-compatible API** โ€” works with DeepSeek, OpenAI, Ollama, OpenRouter, and more +- **Review before commit** โ€” confirm, edit, or abort the generated message +- **Bun-native runtime** โ€” built on Bun APIs with no runtime npm dependencies ## Quick Start @@ -41,196 +39,85 @@ gai config # Open interactive menu gai -# Generate a commit message +# Or directly generate a commit message gai commit -# Explain your changes -gai explain +# Or create an AI-generated pull request +gai pr ``` ## Usage ``` -gai Open interactive menu -gai commit Generate AI commit message (interactive file selection) -gai commit -a Auto-stage all changed files -gai commit -d Generate message without committing -gai commit -m "msg" Use custom message (skip AI) -gai commit --amend Amend last commit with AI-generated message -gai pr Create a PR with AI-generated title and body -gai pr --draft Create a draft PR -gai explain Explain staged changes in plain language -gai explain --unstaged Explain unstaged changes -gai review AI code review of staged changes -gai review --strict Thorough review, flag minor issues -gai review --lenient Focus only on major issues -gai changelog Generate changelog from recent commits -gai changelog --from v1.0 --to v1.1 Range-based changelog -gai suggest branch Suggest branch names for current changes -gai suggest type Suggest Conventional Commit type -gai config Configure API settings (interactive) -gai config list List all settings -gai config get Get a specific setting -gai config set Set a setting -gai help Show help -gai help Show command-specific help -gai --version Show version -``` - -### Global Flags - -| Flag | Description | -|---|---| -| `-h, --help` | Show help | -| `-V, --version` | Show version | -| `-v, --verbose` | Verbose output | -| `--no-color` | Disable colored output | - -Also respects the `NO_COLOR` and `FORCE_COLOR` environment variables. - -### Subcommand Aliases - -| Command | Aliases | -|---|---| -| `commit` | `c`, `ci` | -| `pr` | `p` | -| `config` | `cfg` | -| `explain` | `x` | -| `review` | `r`, `rv` | -| `changelog` | `cl`, `log` | -| `suggest` | `sg` | -| `help` | `h` | - -### Pipe Support - -All AI commands accept piped input โ€” no git repository required: - -```bash -# Explain any diff -git diff main..feature | gai explain - -# Review changes from a PR -curl https://patch-diff.githubusercontent.com/... | gai review - -# Suggest branch name for a diff -git diff | gai suggest branch +gai Open interactive menu +gai commit Generate commit message (interactive file selection) +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 help +gai --version Show version ``` ### Interactive Menu -Run `gai` without arguments to open the mole-style interactive menu: - ``` - gai v0.1.3 - AI-powered git helper for commits, PRs, reviews, and changelogs - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +$ gai - CREATE - โ”‚ โ€บ 1 Commit Generate AI commit message - 2 PR Create a PR with AI-generated title - 3 Amend Amend last commit with AI message + gai + Choose a workflow + โ†‘/โ†“ navigate ยท enter/space select ยท ctrl+c cancel - INSPECT - 4 Explain Explain staged changes in plain language - 5 Review AI code review of staged changes - 6 Changelog Generate changelog from commits - 7 Suggest Suggest branch name or commit type - - PROJECT - 8 Config Configure API settings - - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - โ†‘/โ†“ navigate enter run 1-8 jump h help v version q quit + โฏ โ— commit Generate AI commit message + โ—‹ pr Create a PR with AI-generated title + โ—‹ config Configure API settings ``` -Number keys `1`โ€“`8` jump directly to the corresponding action. After a command finishes, press Enter to return to the menu. +### Commit Flow -### Command Examples +``` +$ gai commit -#### Commit + Staged files (will be included): + โœ“ src/git.ts (modified) + + Select files to stage: + 2 unstaged files available + โ†‘/โ†“ navigate ยท space toggle ยท enter confirm ยท โ†/backspace back ยท ctrl+c cancel + + โฏ โ–ก Select all + โ–ก src/ai.ts modified + โ–  src/newfile.ts new + + Generating commit message... + + Generated commit message: + feat(git): add interactive file staging and commit wrapper + + Use this message? [Y/n/e] Y + + โœ” Committed successfully! + [main a3f7c2b] feat(git): add interactive file staging and commit wrapper + 1 file changed, 45 insertions(+), 12 deletions(-) +``` + +### Pull Request Flow + +`gai pr` detects the remote platform from `origin`: + +- GitHub remotes use the `gh` CLI +- Gitea remotes use the `tea` CLI +- GitLab remotes use the `glab` CLI +- Unknown remotes prompt you to choose a platform + +The command compares your current branch against the default branch, pushes the branch if needed, generates a PR title/body from the branch commits and diff, then asks for confirmation before creating the PR. ```bash -# Interactive file selection -gai commit - -# Auto-stage everything -gai commit -a - -# Dry-run (no commit) -gai commit -d - -# Custom message (skip AI) -gai commit -m "fix: correct typo in README" - -# Amend last commit with AI message -gai commit --amend - -# Pipe diff for commit message -git diff --staged | gai commit -``` - -#### PR - -```bash -# Create PR gai pr - -# Draft PR gai pr --draft ``` -#### Explain - -```bash -# Explain staged changes -gai explain - -# Explain unstaged changes -gai explain --unstaged - -# Pipe any diff -git diff HEAD~3 | gai explain -``` - -#### Review - -```bash -# Review staged changes (normal strictness) -gai review - -# Thorough review -gai review --strict - -# Lenient review -gai review --lenient - -# Review unstaged changes -gai review --unstaged -``` - -#### Changelog - -```bash -# From last 20 commits -gai changelog - -# Custom count -gai changelog -n 50 - -# Between tags -gai changelog --from v1.0.0 --to v1.1.0 -``` - -#### Suggest - -```bash -# Suggest branch name -gai suggest branch - -# Suggest commit type -gai suggest type -``` - ## Configuration ### Via `gai config` (interactive) @@ -239,15 +126,6 @@ gai suggest type gai config ``` -### CLI-based config - -```bash -gai config list -gai config get model -gai config set model gpt-4o -gai config set temperature 0.3 -``` - ### Via environment variables | Variable | Default | Description | @@ -315,14 +193,13 @@ GAI_MODEL=anthropic/claude-sonnet-4 โ”‚ โ”‚ โ”‚ 2. Collect code changes โ”‚ โ”‚ โ”œโ”€ git diff --staged for commits โ”‚ -โ”‚ โ”œโ”€ Branch diff for pull requests โ”‚ -โ”‚ โ””โ”€ Pipe support for external diffs โ”‚ +โ”‚ โ””โ”€ branch diff for pull requests โ”‚ โ”‚ โ”‚ โ”‚ 3. Collect commit history โ”‚ โ”‚ โ”œโ”€ git log --oneline -10 for commits โ”‚ -โ”‚ โ””โ”€ Branch commits for pull requests โ”‚ +โ”‚ โ””โ”€ branch commits for pull requests โ”‚ โ”‚ โ”‚ -โ”‚ 4. Build prompt โ†’ Call AI API (streaming) โ”‚ +โ”‚ 4. Build prompt โ†’ Call AI API โ”‚ โ”‚ โ”‚ โ”‚ 5. Review โ†’ Confirm โ†’ Commit or Create PR โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ diff --git a/index.ts b/index.ts index b8169f0..4d962d1 100644 --- a/index.ts +++ b/index.ts @@ -1,461 +1,995 @@ #!/usr/bin/env bun -// gai โ€” AI-powered git commit and PR helper -// v0.1.3 +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"; +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"; -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 { VERSION } from "./src/brand"; -import { SKIP_WAIT } from "./src/menu"; +const args = process.argv.slice(2); -// โ”€โ”€ Interactive Menu (mole-style) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function showHelp() { + console.log(` +${BOLD}gai${RESET} โ€” AI-powered git commit message generator -interface MenuItem { - key: string; - label: string; - description: string; - group: "Create" | "Inspect" | "Project"; +${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) +`); } -const MENU_ITEMS: MenuItem[] = [ - { key: "commit", label: "Commit", description: "Generate AI commit message", group: "Create" }, - { key: "pr", label: "PR", description: "Create a PR with AI-generated title", group: "Create" }, - { key: "amend", label: "Amend", description: "Amend last commit with AI message", group: "Create" }, - { key: "explain", label: "Explain", description: "Explain staged changes in plain language", group: "Inspect" }, - { key: "review", label: "Review", description: "AI code review of staged changes", group: "Inspect" }, - { key: "changelog", label: "Changelog", description: "Generate changelog from commits", group: "Inspect" }, - { key: "suggest", label: "Suggest", description: "Suggest branch name or commit type", group: "Inspect" }, - { key: "config", label: "Config", description: "Configure API settings", group: "Project" }, +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 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 visibleLength(value: string) { + return value.replace(/\x1b\[[0-9;]*m/g, "").length; } -function visibleLen(s: string): number { - return s.replace(/\x1b\[[0-9;]*m/g, "").length; +function clearLine() { + process.stdout.write("\r\x1b[2K"); } -function padRight(value: string, width: number): string { - return value + " ".repeat(Math.max(0, width - visibleLen(value))); +function moveUp(lines: number) { + if (lines > 0) process.stdout.write(`\x1b[${lines}A`); } -function renderMenu(cursor: number): number { - process.stdout.write("\x1b[H"); // cursor home +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); + } - let lineCount = 0; - const C = CYAN(); - const D = DIM(); - const G = GREEN(); - const R = RESET(); - const width = 72; - const separator = `${D}${"โ”€".repeat(width)}${R}`; + 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}`, + "", + ]; - const write = (line = "") => { - clearLine(); - process.stdout.write(`${line}\n`); - lineCount++; - }; - - write(""); - write(` ${BOLD()}gai${R} ${D}v${VERSION}${R}`); - write(` ${D}AI-powered git helper for commits, PRs, reviews, and changelogs${R}`); - write(` ${separator}`); - write(""); - - const keyWidth = 3; - const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2; - let currentGroup: MenuItem["group"] | null = null; - - for (let i = 0; i < MENU_ITEMS.length; i++) { - const item = MENU_ITEMS[i]!; - const num = String(i + 1); + let activeValueOffset = 0; + for (let i = 0; i < CONFIG_FIELDS.length; i++) { + const field = CONFIG_FIELDS[i]!; const active = i === cursor; - - if (item.group !== currentGroup) { - if (currentGroup !== null) write(""); - write(` ${D}${item.group.toUpperCase()}${R}`); - currentGroup = item.group; - } - - const pointer = active ? `${C}โ€บ${R}` : " "; - const key = active ? `${G}${num}${R}` : `${D}${num}${R}`; - const label = active ? `${BOLD()}${item.label}${R}` : item.label; - const description = active ? item.description : `${D}${item.description}${R}`; - const row = [ - pointer, - padRight(key, keyWidth), - padRight(label, labelWidth), - description, - ].join(" "); - - if (active) { - write(` ${C}โ”‚${R} ${row}`); - } else { - write(` ${row}`); + 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}`); } - write(""); - write(` ${separator}`); - write(` ${D}โ†‘/โ†“ navigate enter run ${G}1-8${D} jump ${G}h${D} help ${G}v${D} version ${G}q${D} quit${R}`); - write(""); - - // 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); - if (result === (SKIP_WAIT as unknown as number)) { - return 0; // user explicitly backed out โ€” skip "Press Enter" and return directly - } - await waitForEnter(); - return result; -} - -async function showMenu(): Promise { - if (!isStdinTTY()) { - console.error("Error: Interactive menu requires a TTY. Use --help for usage."); - return 1; + 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(); - hideCursor(); + process.stdout.write("\x1b[?25l"); - // Initial render - renderMenu(cursor); + const render = () => { + moveUp(renderedCursorRow); + renderedLines = renderConfigPage(config, cursor, renderedLines, status, editState); + renderedCursorRow = editState ? 4 + cursor : 0; + process.stdout.write(editState ? "\x1b[?25h" : "\x1b[?25l"); + }; - try { - while (true) { - const raw = await readKey(); + render(); - // Escape sequences (arrows) - if (raw === "\x1b[A" || raw === "\x1bOA") { - if (cursor > 0) { cursor--; renderMenu(cursor); } - continue; - } - if (raw === "\x1b[B" || raw === "\x1bOB") { - if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(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(cursor); - continue; - } - - // Ctrl+C - if (raw === "\x03") { - showCursor(); - process.stdin.setRawMode(wasRaw === true); - process.stdin.pause(); + 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"); - return 0; } + moveUp(renderedLines); + process.stdout.write("\x1b[?25h"); + resolve(value); + }; - // Number hotkeys (1-8) - if (raw >= "1" && raw <= "8") { - const idx = parseInt(raw) - 1; - if (idx < MENU_ITEMS.length) { - cursor = idx; - renderMenu(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(cursor); - continue; + 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}`; } } - // 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; + 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; } - if (lower === "v") { - showCursor(); - process.stdin.setRawMode(wasRaw === true); - process.stdin.pause(); - process.stdout.write("\x1b[2J\x1b[H"); - console.log(`gai v${VERSION}`); - return 0; + + 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(); } - if (lower === "q") { - showCursor(); - process.stdin.setRawMode(wasRaw === true); - process.stdin.pause(); - process.stdout.write("\n"); - return 0; + }; + + 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(); + } } } - } finally { - showCursor(); - process.stdin.setRawMode(wasRaw === true); - process.stdin.pause(); + + 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); + } + + while (true) { + const selected = await selectOne({ + title: "gai", + subtitle: "Choose a workflow", + allowBack: false, + items: MENU_ACTIONS.map((action) => ({ + label: action.label, + value: action.key, + description: action.description, + })), + }); + + if (selected === null || selected === BACK) return; + + const result = + selected === "commit" + ? await handleCommit(false, false) + : selected === "pr" + ? await handlePR(false) + : await handleConfig(); + + if (result !== "back") return; } } -// โ”€โ”€ Command Definitions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function selectPlatform( + hostname: string, +): Promise { + if (!process.stdin.isTTY) { + console.error(` ${RED}Error: Platform selection requires a TTY.${RESET}`); + process.exit(1); + } -const commands = registerCommands( - { - name: "", - description: "Open interactive menu", - usage: "gai", - handler: async () => showMenu(), - } as CommandDef, + 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" }, + ], + }); +} - { - 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, +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: "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, - }, + const stagedFiles = await getStagedFiles(); + const unstagedFiles = await getUnstagedFiles(); - { - 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, - }, + if (stagedFiles.length === 0 && unstagedFiles.length === 0) { + console.log(` ${DIM}Nothing to commit. No staged or unstaged changes.${RESET}`); + return "done"; + } - { - 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 (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: "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, - }, + const diff = await getStagedDiff(); + if (!diff) { + console.log(` ${DIM}Nothing to commit. No staged changes to commit.${RESET}`); + return "done"; + } - { - 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 config = await loadConfig(); - { - 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, - }, + if (!config.apiKey) { + console.error( + ` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`, + ); + process.exit(1); + } - { - 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, - }, -); + const MAX_DIFF_SIZE = 15000; + const truncatedDiff = + diff.length > MAX_DIFF_SIZE + ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" + : diff; -// Keep the defs accessible for help command -const allCommandDefs = [...commands.values()].filter( - (c, i, arr) => arr.findIndex((x) => x.name === c.name) === i, -); + const repoRoot = await getRepoRoot(); + const projectCtx = await collectProjectContext(repoRoot); + const recentCommits = await getRecentCommits(10); -// โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + 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); +} process.on("SIGINT", () => { - process.stdout.write("\x1b[?25h"); // ensure cursor is shown + process.stdout.write("\x1b[?25h"); 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); - }); +main().catch((err) => { + console.error(` ${RED}Unexpected error: ${err}${RESET}`); + process.exit(1); +}); diff --git a/package.json b/package.json index 849414d..ad09ed6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gai", - "version": "0.1.3", - "description": "AI-powered git helper โ€” commit messages, PRs, code review, changelogs, and more", + "version": "0.1.2", + "description": "AI-powered git commit message generator", "module": "index.ts", "type": "module", "bin": { diff --git a/src/ai.ts b/src/ai.ts index 744da83..4c96662 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -1,20 +1,29 @@ -import type { Config, StreamCallbacks } from "./types"; +import type { Config } from "./types"; interface ChatMessage { role: "system" | "user" | "assistant"; content: string; } -const MAX_RETRIES = 3; -const RETRY_DELAY_MS = 1000; - -async function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); +interface ChatCompletionResponse { + choices?: Array<{ + message?: { + content?: string | null; + }; + finish_reason?: string; + }>; + error?: { + message?: string; + type?: string; + code?: string; + }; } +const MAX_RETRIES = 3; +const RETRY_DELAY = 1000; + function cleanMessage(raw: string): string { let msg = raw.trim(); - // Strip code fences if the whole response is wrapped if (msg.startsWith("```") && msg.endsWith("```")) { const lines = msg.split("\n"); if (lines.length > 2) { @@ -26,14 +35,16 @@ function cleanMessage(raw: string): string { return msg; } +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export async function callAI( config: Config, systemPrompt: string, userPrompt: string, - callbacks?: StreamCallbacks, ): Promise { const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`; - const stream = callbacks != null; const messages: ChatMessage[] = [ { role: "system", content: systemPrompt }, @@ -53,45 +64,45 @@ export async function callAI( max_tokens: config.maxTokens, temperature: config.temperature, messages, - stream, }), }); if (!response.ok) { const text = await response.text(); if (response.status === 429 && attempt < MAX_RETRIES) { - await sleep(RETRY_DELAY_MS * attempt); + await sleep(RETRY_DELAY * attempt); continue; } throw new Error(`API request failed (${response.status}): ${text}`); } - if (stream && response.body) { - return await readStream(response.body, callbacks!); - } - - const data = (await response.json()) as { - choices?: Array<{ message?: { content?: string | null }; finish_reason?: string }>; - error?: { message?: string; type?: string; code?: string }; - }; + const data = (await response.json()) as ChatCompletionResponse; if (data.error) { - throw new Error(`API error: ${data.error.message ?? JSON.stringify(data.error)}`); + throw new Error( + `API error: ${data.error.message ?? JSON.stringify(data.error)}`, + ); } const raw = data.choices?.[0]?.message?.content; const finishReason = data.choices?.[0]?.finish_reason; - if (raw && raw.trim()) return raw; + if (raw && raw.trim()) { + return raw; + } if (finishReason === "length") { - throw new Error("Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS."); + throw new Error( + "Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS.", + ); } + if (finishReason === "content_filter") { throw new Error("Response blocked by content filter."); } + if (attempt < MAX_RETRIES) { - await sleep(RETRY_DELAY_MS * attempt); + await sleep(RETRY_DELAY * attempt); continue; } @@ -102,70 +113,21 @@ export async function callAI( if (attempt >= MAX_RETRIES) throw err; if (err instanceof Error && err.message.startsWith("API error")) throw err; if (err instanceof Error && err.message.includes("max_tokens")) throw err; - if (err instanceof Error && err.message.includes("content filter")) throw err; - await sleep(RETRY_DELAY_MS * attempt); + if (err instanceof Error && err.message.includes("content filter")) + throw err; + await sleep(RETRY_DELAY * attempt); } } throw new Error("Failed to generate response"); } -async function readStream(body: ReadableStream, callbacks: StreamCallbacks): Promise { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let fullText = ""; - let buffer = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - // Keep the last potentially incomplete line - buffer = lines.pop() ?? ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith("data:")) continue; - - const data = trimmed.slice(5).trim(); - if (data === "[DONE]") continue; - - try { - const parsed = JSON.parse(data) as { - choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>; - }; - const token = parsed.choices?.[0]?.delta?.content; - if (token) { - fullText += token; - callbacks.onToken?.(token); - } - const finishReason = parsed.choices?.[0]?.finish_reason; - if (finishReason === "length") { - callbacks.onError?.(new Error("Response truncated (max_tokens too low).")); - } - } catch { - // Skip unparseable SSE lines - } - } - } - } finally { - reader.releaseLock(); - } - - callbacks.onDone?.(fullText); - return fullText; -} - export async function generateCommitMessage( config: Config, systemPrompt: string, userPrompt: string, - callbacks?: StreamCallbacks, ): Promise { - const raw = await callAI(config, systemPrompt, userPrompt, callbacks); + const raw = await callAI(config, systemPrompt, userPrompt); return cleanMessage(raw); } @@ -173,9 +135,8 @@ export async function generatePRMessage( config: Config, systemPrompt: string, userPrompt: string, - callbacks?: StreamCallbacks, ): Promise<{ title: string; body: string }> { - const raw = await callAI(config, systemPrompt, userPrompt, callbacks); + const raw = await callAI(config, systemPrompt, userPrompt); const cleaned = cleanMessage(raw); const lines = cleaned.split("\n"); @@ -187,5 +148,6 @@ export async function generatePRMessage( } const body = lines.slice(bodyStart).join("\n").trim(); + return { title, body }; } diff --git a/src/brand.ts b/src/brand.ts deleted file mode 100644 index 1aa4454..0000000 --- a/src/brand.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Brand banner and ASCII art logo for gai - -import { GREEN, CYAN, RESET } from "./terminal"; - -export const VERSION = "0.1.3"; - -export function showBanner(): string { - const G = GREEN(); - const C = CYAN(); - const R = RESET(); - - return [ - "", - `${G} โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—${R}`, - `${G} โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ• โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘${R}`, - `${G} โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘${R}`, - `${G} โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘${R}`, - `${G} โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ• โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘${R} ${C}AI-powered git helper${R}`, - `${G} โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ•โ•šโ•โ•${R} ${C}v${VERSION}${R}`, - "", - ].join("\n"); -} diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100644 index 1c10a3a..0000000 --- a/src/cli.ts +++ /dev/null @@ -1,295 +0,0 @@ -// Lightweight CLI argument parser aligned with mainstream CLI conventions. -// Supports: subcommands, short/long flags, flag values, positional args, --help, --version. - -export interface FlagDef { - long: string; // e.g. "dry-run" - short?: string; // e.g. "d" - type: "boolean" | "string"; - description: string; - default?: unknown; // boolean flags default to false -} - -export interface CommandDef { - name: string; - aliases?: string[]; - description: string; - usage?: string; // one-line usage, e.g. "gai commit [-a|--all] [-d|--dry-run]" - flags?: FlagDef[]; - examples?: string[]; - handler: (args: ParsedArgs) => Promise; // returns exit code -} - -export interface ParsedArgs { - command: string; // matched command name - flags: Record; // resolved flags by long name - positional: string[]; // remaining positional args - raw: string[]; // original argv - subcommand: CommandDef; -} - -// Global flags available to all commands -const GLOBAL_FLAGS: FlagDef[] = [ - { long: "help", short: "h", type: "boolean", description: "Show help for this command" }, - { long: "version", short: "V", type: "boolean", description: "Show version" }, - { long: "verbose", short: "v", type: "boolean", description: "Enable verbose output" }, - { long: "no-color", type: "boolean", description: "Disable colored output" }, -]; - -function buildFlagIndex(flags: FlagDef[]): Map { - const index = new Map(); - for (const f of flags) { - index.set("--" + f.long, f); - if (f.short) index.set("-" + f.short, f); - } - return index; -} - -function resolveFlagName(raw: string): { flag: FlagDef; value?: string } | null { - // "--key=value" - const eqIndex = raw.indexOf("="); - if (eqIndex !== -1) { - const name = raw.slice(0, eqIndex); - const value = raw.slice(eqIndex + 1); - const allFlags = buildFlagIndex([...GLOBAL_FLAGS]); // we'll rebuild in context - // We'll handle = syntax in the main parse loop with proper index - return null; // handled inline - } - return null; // handled inline -} - -function parseArgs( - rawArgs: string[], - commands: Map, -): ParsedArgs | { error: string } { - const args = [...rawArgs]; - let cmdName = ""; - - // Find subcommand - for (let i = 0; i < args.length; i++) { - const arg = args[i]!; - if (arg.startsWith("-")) continue; // skip flags before subcommand - cmdName = arg; - args.splice(i, 1); - break; - } - - // Resolve subcommand (including aliases) - let subcommand: CommandDef | undefined; - if (cmdName) { - subcommand = commands.get(cmdName); - if (!subcommand) { - // Try aliases - for (const [, cmd] of commands) { - if (cmd.aliases?.includes(cmdName)) { - subcommand = cmd; - cmdName = cmd.name; - break; - } - } - } - if (!subcommand) { - return { error: `Unknown command: ${cmdName}\nRun 'gai --help' for usage.` }; - } - } else { - // Default: interactive menu - subcommand = commands.get("")!; - } - - const flags = subcommand.flags ?? []; - const allFlags = [...GLOBAL_FLAGS, ...flags]; - const flagIndex = buildFlagIndex(allFlags); - - const resolved: Record = {}; - // Set defaults - for (const f of allFlags) { - if (f.type === "boolean") { - resolved[f.long] = f.default ?? false; - } else if (f.default !== undefined) { - resolved[f.long] = f.default; - } - } - - const positional: string[] = []; - - // Parse remaining args - for (let i = 0; i < args.length; i++) { - const arg = args[i]!; - - // Handle --flag=value - if (arg.startsWith("--") && arg.includes("=")) { - const eqIdx = arg.indexOf("="); - const name = arg.slice(0, eqIdx); - const value = arg.slice(eqIdx + 1); - const flag = flagIndex.get(name); - if (!flag) return { error: `Unknown flag: ${name}` }; - if (flag.type === "boolean") { - resolved[flag.long] = value === "true" || value === "1" || value === ""; - } else { - resolved[flag.long] = value; - } - continue; - } - - // Handle --flag or -f - if (arg.startsWith("-")) { - const flag = flagIndex.get(arg); - if (!flag) return { error: `Unknown flag: ${arg}` }; - if (flag.type === "boolean") { - resolved[flag.long] = true; - } else { - // Consume next arg as value - i++; - if (i >= args.length || args[i]!.startsWith("-")) { - return { error: `Flag ${arg} requires a value.` }; - } - resolved[flag.long] = args[i]!; - } - continue; - } - - // Positional - positional.push(arg); - } - - return { - command: cmdName || "", - flags: resolved, - positional, - raw: rawArgs, - subcommand, - }; -} - -export function registerCommands(...cmds: CommandDef[]): Map { - const map = new Map(); - for (const cmd of cmds) { - map.set(cmd.name, cmd); - if (cmd.aliases) { - for (const alias of cmd.aliases) { - if (!map.has(alias)) { - map.set(alias, cmd); - } - } - } - } - return map; -} - -export function formatHelp(commands: Map, cmdName?: string): string { - if (cmdName) { - const cmd = commands.get(cmdName); - if (!cmd) return `Unknown command: ${cmdName}`; - - const lines: string[] = []; - lines.push(""); - lines.push(` gai ${cmdName} โ€” ${cmd.description}`); - lines.push(""); - - if (cmd.usage) { - lines.push(` Usage: ${cmd.usage}`); - lines.push(""); - } - - const flags = cmd.flags ?? []; - if (flags.length > 0) { - lines.push(" Flags:"); - for (const f of flags) { - const shorts = f.short ? `-${f.short}, ` : " "; - const typeHint = f.type === "string" ? " " : ""; - lines.push(` ${shorts}--${f.long}${typeHint}`); - lines.push(` ${f.description}`); - } - lines.push(""); - } - - lines.push(" Global flags:"); - for (const f of GLOBAL_FLAGS) { - const shorts = f.short ? `-${f.short}, ` : " "; - const typeHint = f.type === "string" ? " " : ""; - lines.push(` ${shorts}--${f.long}${typeHint}`); - lines.push(` ${f.description}`); - } - lines.push(""); - - if (cmd.examples && cmd.examples.length > 0) { - lines.push(" Examples:"); - for (const ex of cmd.examples) { - lines.push(` $ ${ex}`); - } - lines.push(""); - } - - return lines.join("\n"); - } - - // General help โ€” deduplicate: show only canonical command names - const lines: string[] = []; - lines.push(""); - lines.push(" gai โ€” AI-powered git commit and PR helper"); - lines.push(""); - lines.push(" Usage: gai [flags]"); - lines.push(""); - lines.push(" Commands:"); - - // Deduplicate: only show canonical names (not aliases) - const seen = new Set(); - let maxLen = 0; - const entries: { name: string; desc: string }[] = []; - for (const [name, cmd] of commands) { - if (!name) continue; // skip default - // Skip if this name is an alias of another command (i.e., not the canonical name) - if (cmd.name !== name) continue; - if (seen.has(name)) continue; - seen.add(name); - const aliases = cmd.aliases && cmd.aliases.length > 0 ? ` (${cmd.aliases.join(", ")})` : ""; - const label = ` ${name}${aliases}`; - entries.push({ name: label, desc: cmd.description }); - if (label.length > maxLen) maxLen = label.length; - } - maxLen += 4; - - for (const entry of entries) { - const padding = " ".repeat(Math.max(2, maxLen - entry.name.length)); - lines.push(`${entry.name}${padding}${entry.desc}`); - } - - lines.push(""); - lines.push(" Global flags:"); - for (const f of GLOBAL_FLAGS) { - const shorts = f.short ? `-${f.short}, ` : " "; - lines.push(` ${shorts}--${f.long} ${f.description}`); - } - lines.push(""); - lines.push(` Run 'gai help ' for command-specific help.`); - lines.push(""); - - return lines.join("\n"); -} - -export async function runCLI(rawArgs: string[], commands: Map): Promise { - const result = parseArgs(rawArgs, commands); - - if ("error" in result) { - console.error(`\n Error: ${result.error}\n`); - return 1; - } - - // Handle --help globally - if (result.flags["help"]) { - console.log(formatHelp(commands, result.command || undefined)); - return 0; - } - - // Handle --version globally - if (result.flags["version"]) { - console.log("gai v0.1.3"); - return 0; - } - - try { - return await result.subcommand.handler(result); - } catch (err) { - console.error(`\n Error: ${err instanceof Error ? err.message : err}\n`); - return 1; - } -} diff --git a/src/clipboard.ts b/src/clipboard.ts index 7611d41..b42c988 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/commands/changelog.ts b/src/commands/changelog.ts deleted file mode 100644 index 1ec76ca..0000000 --- a/src/commands/changelog.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { loadConfig } from "../config"; -import { isGitRepo, getRecentCommits } from "../git"; -import { CHANGELOG_SYSTEM_PROMPT, buildChangelogPrompt } from "../prompt"; -import { callAI } from "../ai"; -import { BOLD, RED, DIM, RESET, CYAN } from "../terminal"; -import { isStdinTTY } from "../tty"; -import type { StreamCallbacks } from "../types"; -import type { ParsedArgs } from "../cli"; - -export async function handleChangelog(args: ParsedArgs): Promise { - const config = await loadConfig(); - - if (!config.apiKey) { - console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); - return 1; - } - - if (!(await isGitRepo())) { - console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); - return 1; - } - - const from = args.flags["from"] as string | undefined; - const to = args.flags["to"] as string | undefined; - const countFlag = args.flags["count"] as number | undefined; - const verbose = args.flags["verbose"] as boolean; - - // Collect commits - let commits: string[]; - - if (from) { - // Range-based: get commits between from..to - const range = to ? `${from}..${to}` : `${from}..HEAD`; - try { - const result = await Bun.$`git log --oneline ${range}`.quiet().text(); - commits = result.trim().split("\n").filter(Boolean); - } catch { - console.error(`\n ${RED()}Error: Invalid range: ${range}${RESET()}\n`); - return 1; - } - } else { - // Count-based - const count = typeof countFlag === "number" ? countFlag : 20; - commits = await getRecentCommits(count); - } - - if (commits.length === 0) { - console.log(` ${DIM()}No commits found for the specified range.${RESET()}`); - return 0; - } - - if (verbose) { - console.log(` ${DIM()}Processing ${commits.length} commits${RESET()}`); - console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); - } - - const userPrompt = buildChangelogPrompt(commits, from, to); - - const tty = isStdinTTY(); - if (tty) { - console.log(`\n ${BOLD()}${CYAN()}Generating changelog from ${commits.length} commits...${RESET()}\n`); - } - - try { - const callbacks: StreamCallbacks | undefined = tty ? { - onToken: (token) => process.stdout.write(token), - } : undefined; - - const result = await callAI(config, CHANGELOG_SYSTEM_PROMPT, userPrompt, callbacks); - if (callbacks) { - process.stdout.write("\n"); - } else { - process.stdout.write(result + "\n"); - } - } catch (err) { - console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 1; - } - - return 0; -} diff --git a/src/commands/commit.ts b/src/commands/commit.ts deleted file mode 100644 index d8ff3e6..0000000 --- a/src/commands/commit.ts +++ /dev/null @@ -1,433 +0,0 @@ -import * as readline from "node:readline"; -import { - isGitRepo, - getRepoRoot, - getStagedFiles, - getUnstagedFiles, - getStagedDiff, - getRecentCommits, - stageFiles, - applyFileSelection, - commit, -} from "../git"; -import { selectFiles } from "../selector"; -import { BACK, SKIP_WAIT } from "../menu"; -import { collectProjectContext } from "../context"; -import { buildPrompt, SYSTEM_PROMPT } from "../prompt"; -import { generateCommitMessage } from "../ai"; -import { copyToClipboard } from "../clipboard"; -import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal"; -import { isStdinTTY } from "../tty"; -import type { Config, CommitResult, StreamCallbacks } from "../types"; -import { loadConfig } from "../config"; -import type { ParsedArgs } from "../cli"; - -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()); - }); - }); -} - -function printCommitResult(result: CommitResult, 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(", ")}`); -} - -function printSelectionResult(result: { staged: string[]; unstaged: string[] }) { - const parts: string[] = []; - if (result.staged.length > 0) { - parts.push(`${GREEN()}staged ${result.staged.length}${RESET()}`); - } - if (result.unstaged.length > 0) { - parts.push(`${YELLOW()}unstaged ${result.unstaged.length}${RESET()}`); - } - if (parts.length > 0) { - console.log(` Updated staging area: ${parts.join(", ")}`); - } -} - -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 (!isStdinTTY()) 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; - - 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 = ""; - - process.stdin.on("data", (data: Buffer) => { - const key = data.toString(); - - if (key === "\x03") { - process.stdin.setRawMode(savedRaw === true); - process.stdin.pause(); - process.stdin.removeAllListeners("data"); - process.stdout.write("\n"); - resolve(null); - return; - } - - if (key === "\x1b" || 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 === "\r") { - 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 === "\x7f") { - 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 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(); } - } - } - }); -} - -export async function handleCommit(args: ParsedArgs): Promise { - const autoMode = args.flags["all"] as boolean || args.flags["auto"] as boolean; - const dryRun = args.flags["dry-run"] as boolean; - const amend = args.flags["amend"] as boolean; - const customMessage = args.flags["message"] as string | undefined; - const verbose = args.flags["verbose"] as boolean; - - if (!(await isGitRepo())) { - console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); - return 1; - } - - // If a custom message is provided, skip AI and commit directly - if (customMessage) { - const stagedFiles = await getStagedFiles(); - const unstagedFiles = await getUnstagedFiles(); - - if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) { - console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`); - return 1; - } - - if (!amend && autoMode && unstagedFiles.length > 0) { - await stageFiles(unstagedFiles.map((f) => f.path)); - console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); - } else if (!amend) { - if (isStdinTTY()) { - const selected = await selectFiles(stagedFiles, unstagedFiles); - if (selected === BACK) return SKIP_WAIT as unknown as number; - printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected)); - } else if (stagedFiles.length === 0 && unstagedFiles.length > 0) { - console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`); - return 1; - } - } - - const diff = await getStagedDiff(); - if (!diff && !amend) { - console.log(` ${DIM()}Nothing to commit.${RESET()}`); - return 0; - } - - try { - const result = await commit(customMessage); - printCommitResult(result, customMessage); - return 0; - } catch (err) { - console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 1; - } - } - - // Handle amend - if (amend) { - return handleAmendCommit(args); - } - - const stagedFiles = await getStagedFiles(); - const unstagedFiles = await getUnstagedFiles(); - - if (stagedFiles.length === 0 && unstagedFiles.length === 0) { - console.log(` ${DIM()}Nothing to commit. No staged or unstaged changes.${RESET()}`); - return 0; - } - - if (autoMode && unstagedFiles.length > 0) { - 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 SKIP_WAIT as unknown as number; - printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected)); - } - - const diff = await getStagedDiff(); - if (!diff) { - console.log(` ${DIM()}Nothing to commit. No staged changes to commit.${RESET()}`); - return 0; - } - - const config = await loadConfig(); - - if (!config.apiKey) { - console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); - return 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 recentCommits = await getRecentCommits(10); - - const userPrompt = buildPrompt({ - readme: projectCtx.readme, - packageDescription: projectCtx.packageDescription, - structure: projectCtx.structure, - recentCommits, - diff: truncatedDiff, - }); - - if (verbose) { - console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); - } - - const tty = isStdinTTY(); - if (tty) { - console.log("\n Generating commit message..."); - } - - let message: string; - try { - const callbacks: StreamCallbacks | undefined = tty ? { - onToken: (token) => process.stdout.write(token), - } : undefined; - - if (callbacks) process.stdout.write(" "); - - message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt, callbacks); - - if (callbacks) process.stdout.write("\n"); - } catch (err) { - console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 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 0; - } - - const action = await confirmCommit(message); - - if (action === "y") { - try { - const result = await commit(message); - printCommitResult(result, message); - } catch (err) { - console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 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(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 1; - } - } else { - console.log(" Aborted."); - } - } else { - const copied = await copyToClipboard(message); - console.log(copied ? ` Aborted. Message copied to clipboard.` : ` Aborted. Message: ${message}`); - } - - return 0; -} - -async function handleAmendCommit(args: ParsedArgs): Promise { - const config = await loadConfig(); - if (!config.apiKey) { - console.error(`\n ${RED()}Error: API key not set.${RESET()}\n`); - return 1; - } - - // Get the diff of the last commit (what would be amended) - let diff: string; - try { - diff = await Bun.$`git diff HEAD~1..HEAD`.quiet().text(); - diff = diff.trim(); - } catch { - // If there's no previous commit (first commit), get staged diff - diff = await getStagedDiff(); - } - - if (!diff) { - // Try getting the commit message from HEAD - try { - const lastMsg = await Bun.$`git log -1 --format=%B`.quiet().text(); - if (lastMsg.trim()) { - console.log(` Last commit message: ${DIM()}${lastMsg.trim()}${RESET()}`); - const newMsg = await editMessage(lastMsg.trim()); - if (newMsg) { - await Bun.spawn(["git", "commit", "--amend", "-m", newMsg], { stdio: ["inherit", "inherit", "inherit"] }); - } - return 0; - } - } catch {} - console.log(` ${DIM()}No changes to amend.${RESET()}`); - return 0; - } - - const repoRoot = await getRepoRoot(); - const projectCtx = await collectProjectContext(repoRoot); - const recentCommits = await getRecentCommits(10); - - const MAX_DIFF_SIZE = 15000; - const truncatedDiff = diff.length > MAX_DIFF_SIZE - ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" - : diff; - - const userPrompt = buildPrompt({ - readme: projectCtx.readme, - packageDescription: projectCtx.packageDescription, - structure: projectCtx.structure, - recentCommits, - diff: truncatedDiff, - }); - - console.log("\n Generating amended commit message..."); - - let message: string; - try { - message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt); - } catch (err) { - console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 1; - } - - console.log(`\n ${BOLD()}Generated amended message:${RESET()}`); - console.log(` ${GREEN()}${message}${RESET()}\n`); - - const answer = await ask(` Amend commit with this message? [${GREEN()}Y${RESET()}/n/e] `); - const lower = answer.toLowerCase(); - - if (lower === "n") { - console.log(" Aborted."); - return 0; - } - - if (lower === "e") { - const edited = await editMessage(message); - if (!edited) { console.log(" Aborted."); return 0; } - message = edited; - } - - try { - const proc = Bun.spawn(["git", "commit", "--amend", "-m", message], { - stdio: ["inherit", "inherit", "inherit"], - }); - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`git commit --amend failed (exit code ${exitCode})`); - } - } catch (err) { - console.error(`\n ${RED()}Amend failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 1; - } - - return 0; -} diff --git a/src/commands/config.ts b/src/commands/config.ts deleted file mode 100644 index b692fab..0000000 --- a/src/commands/config.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { loadConfig, saveConfig } from "../config"; -import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal"; -import { isStdinTTY } from "../tty"; -import { SKIP_WAIT } from "../menu"; -import type { Config } from "../types"; -import type { ParsedArgs } from "../cli"; - -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 interactiveConfig(): Promise<"done" | "back"> { - if (!isStdinTTY()) { - console.error(`\n ${RED()}Error: Interactive config requires a TTY.${RESET()}\n`); - process.exit(1); - } - - let config = await loadConfig(); - let cursor = 0; - let renderedLines = 0; - 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(); - - if (editState) { - if (key === "\x03" || key === "\x1b") { - editState = null; - status = `${DIM()}No changes.${RESET()}`; - render(); - return; - } - if (key === "\r") { 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 === "\x7f") { - if (editState.cursor > 0) { - editState.buffer = editState.buffer.slice(0, editState.cursor - 1) + editState.buffer.slice(editState.cursor); - editState.cursor--; - render(); - } - return; - } - if (key === "\x1b[D" || key === "\x1bOD") { if (editState.cursor > 0) editState.cursor--; render(); return; } - if (key === "\x1b[C" || key === "\x1bOC") { if (editState.cursor < editState.buffer.length) editState.cursor++; render(); return; } - if (key === "\x1b[H" || key === "\x1b[1~") { editState.cursor = 0; render(); return; } - if (key === "\x1b[F" || key === "\x1b[4~") { editState.cursor = editState.buffer.length; render(); return; } - 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; - } - - // Not editing - if (key === "\x03") return finish("done"); - if (key === "\x1b[A" || key === "\x1bOA") { - if (cursor > 0) { cursor--; status = null; render(); } - } else if (key === "\x1b[B" || key === "\x1bOB") { - if (cursor < CONFIG_FIELDS.length - 1) { cursor++; status = null; render(); } - } else if (key === "\x1b[D" || key === "\x1bOD" || key === "\x7f") { - return finish("back"); - } else if (key === " ") { - const value = CONFIG_FIELDS[cursor]!.initialEditValue(config); - editState = { buffer: value, cursor: value.length }; - status = null; - render(); - } - }; - - process.stdin.on("data", onData); - }); -} - -export async function handleConfig(args: ParsedArgs): Promise { - const positional = args.positional; - - // gai config get - if (positional[0] === "get") { - const key = positional[1]; - if (!key) { - console.error(`\n ${RED()}Error: Usage: gai config get ${RESET()}\n`); - return 1; - } - const config = await loadConfig(); - const field = CONFIG_FIELDS.find((f) => f.key === key); - if (!field) { - console.error(`\n ${RED()}Error: Unknown config key: ${key}${RESET()}`); - console.error(` Valid keys: ${CONFIG_FIELDS.map((f) => f.key).join(", ")}\n`); - return 1; - } - // For apiKey, show masked value - if (key === "apiKey") { - console.log(config.apiKey || ""); - } else { - console.log(String(config[key as keyof Config])); - } - return 0; - } - - // gai config set - if (positional[0] === "set") { - const key = positional[1]; - const value = positional.slice(2).join(" "); - if (!key || value === undefined) { - console.error(`\n ${RED()}Error: Usage: gai config set ${RESET()}\n`); - return 1; - } - const field = CONFIG_FIELDS.find((f) => f.key === key); - if (!field) { - console.error(`\n ${RED()}Error: Unknown config key: ${key}${RESET()}`); - console.error(` Valid keys: ${CONFIG_FIELDS.map((f) => f.key).join(", ")}\n`); - return 1; - } - const parsed = field.parse(value); - if ("error" in parsed) { - console.error(`\n ${RED()}Error: ${parsed.error}${RESET()}\n`); - return 1; - } - await saveConfig({ [field.key]: parsed.value } as Partial); - console.log(` ${GREEN()}${field.label} set.${RESET()}`); - return 0; - } - - // gai config list - if (positional[0] === "list" || positional[0] === "ls") { - const config = await loadConfig(); - const labelWidth = Math.max(...CONFIG_FIELDS.map((f) => f.label.length)) + 2; - console.log(""); - for (const field of CONFIG_FIELDS) { - const padding = " ".repeat(Math.max(1, labelWidth - field.label.length)); - console.log(` ${BOLD()}${field.label}${RESET()}${padding}${field.format(config)}`); - } - console.log(""); - return 0; - } - - // gai config (no args) โ†’ interactive - if (positional.length === 0) { - const result = await interactiveConfig(); - return result === "back" ? (SKIP_WAIT as unknown as number) : 0; - } - - console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`); - console.error(` Try: gai config [get|set|list]\n`); - return 1; -} diff --git a/src/commands/explain.ts b/src/commands/explain.ts deleted file mode 100644 index 15eb9ac..0000000 --- a/src/commands/explain.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { loadConfig } from "../config"; -import { - isGitRepo, - getStagedFiles, - getStagedDiff, - getUnstagedFiles, - getRepoRoot, - applyFileSelection, -} from "../git"; -import { selectFiles } from "../selector"; -import { BACK, SKIP_WAIT } from "../menu"; -import { collectProjectContext } from "../context"; -import { EXPLAIN_SYSTEM_PROMPT, buildExplainPrompt } from "../prompt"; -import { callAI } from "../ai"; -import { BOLD, GREEN, RED, DIM, RESET, CYAN } from "../terminal"; -import { isStdinTTY } from "../tty"; -import type { StreamCallbacks } from "../types"; -import type { ParsedArgs } from "../cli"; - -export async function handleExplain(args: ParsedArgs): Promise { - const config = await loadConfig(); - - if (!config.apiKey) { - console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); - return 1; - } - - const unstaged = args.flags["unstaged"] as boolean; - const verbose = args.flags["verbose"] as boolean; - - // Determine which diff to explain - let diff: string; - let sourceLabel: string; - - if (unstaged) { - if (!(await isGitRepo())) { - console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); - return 1; - } - try { - diff = (await Bun.$`git diff`.quiet().text()).trim(); - } catch { - diff = ""; - } - sourceLabel = "unstaged changes"; - } else { - // Default: staged changes (or piped) - if (isStdinTTY()) { - if (!(await isGitRepo())) { - console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); - return 1; - } - const stagedFiles = await getStagedFiles(); - const unstagedFiles = await getUnstagedFiles(); - sourceLabel = "selected changes"; - - if (stagedFiles.length > 0 || unstagedFiles.length > 0) { - const selected = await selectFiles(stagedFiles, unstagedFiles); - if (selected === BACK) return SKIP_WAIT as unknown as number; - await applyFileSelection(stagedFiles, unstagedFiles, selected); - } - diff = await getStagedDiff(); - } else { - // Read from pipe - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); - } - diff = Buffer.concat(chunks).toString("utf-8").trim(); - sourceLabel = "piped input"; - } - } - - if (!diff) { - console.log(` ${DIM()}No ${sourceLabel} to explain.${RESET()}`); - return 0; - } - - if (args.flags["verbose"]) { - console.log(` ${DIM()}Explaining ${sourceLabel} (${diff.length} bytes)${RESET()}`); - } - - const MAX_DIFF_SIZE = 15000; - const truncatedDiff = diff.length > MAX_DIFF_SIZE - ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" - : diff; - - // Collect project context for better explanations - let contextPrefix = ""; - try { - if (await isGitRepo()) { - const repoRoot = await getRepoRoot(); - const ctx = await collectProjectContext(repoRoot); - if (ctx.packageDescription) { - contextPrefix = `Project: ${ctx.packageDescription}\n\n`; - } - } - } catch {} - - const userPrompt = contextPrefix + buildExplainPrompt(truncatedDiff); - - if (verbose) { - console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); - } - - const tty = isStdinTTY(); - if (tty) { - console.log(`\n ${BOLD()}${CYAN()}Analyzing ${sourceLabel}...${RESET()}\n`); - } - - try { - const callbacks: StreamCallbacks | undefined = tty ? { - onToken: (token) => process.stdout.write(token), - } : undefined; - - const explanation = await callAI(config, EXPLAIN_SYSTEM_PROMPT, userPrompt, callbacks); - if (callbacks) { - process.stdout.write("\n"); - } else { - // Non-TTY: print the result directly - process.stdout.write(explanation + "\n"); - } - } catch (err) { - console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 1; - } - - return 0; -} diff --git a/src/commands/pr.ts b/src/commands/pr.ts deleted file mode 100644 index 304ac83..0000000 --- a/src/commands/pr.ts +++ /dev/null @@ -1,209 +0,0 @@ -import * as readline from "node:readline"; -import { loadConfig } from "../config"; -import { isGitRepo, getRepoRoot } from "../git"; -import { collectProjectContext } from "../context"; -import { PR_SYSTEM_PROMPT, buildPRPrompt } from "../prompt"; -import { generatePRMessage } from "../ai"; -import { BACK, SKIP_WAIT, selectOne } from "../menu"; -import { - getDefaultBranch, - getBranchName, - getBranchPushStatus, - pushCurrentBranch, - getBranchCommits, - getBranchDiff, - detectPlatform, - getRemoteHostname, - createPR, -} from "../pr"; -import type { Platform } from "../pr"; -import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal"; -import { isStdinTTY } from "../tty"; -import { copyToClipboard } from "../clipboard"; -import type { ParsedArgs } from "../cli"; - -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()); - }); - }); -} - -async function selectPlatform(hostname: string): Promise { - if (!isStdinTTY()) { - console.error(`\n ${RED()}Error: Platform selection requires a TTY.${RESET()}\n`); - process.exit(1); - } - 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" }, - ], - }); -} - -export async function handlePR(args: ParsedArgs): Promise { - const config = await loadConfig(); - - if (!config.apiKey) { - console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); - return 1; - } - - if (!(await isGitRepo())) { - console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); - return 1; - } - - const draft = args.flags["draft"] as boolean; - const verbose = args.flags["verbose"] as boolean; - - let platform = await detectPlatform(); - if (!platform) { - const hostname = (await getRemoteHostname()) || "unknown"; - const chosen = await selectPlatform(hostname); - if (chosen === BACK) return SKIP_WAIT as unknown as number; - if (!chosen) { - console.log(" Aborted."); - return 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(`\n ${RED()}Error: You are on the default branch (${baseBranch}). Switch to a feature branch first.${RESET()}\n`); - return 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 SKIP_WAIT as unknown as number; - } - - console.log(` ${commits.length} commit${commits.length > 1 ? "s" : ""} on this branch`); - - if (verbose) { - console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); - } - - 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 0; - } - - console.log(` Pushing ${CYAN()}${branchName}${RESET()}...`); - try { - await pushCurrentBranch(branchName, pushStatus.upstream); - console.log(` ${GREEN()}Pushed ${branchName}.${RESET()}`); - } catch (err) { - console.error(`\n ${RED()}Push failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 1; - } - } - - const diff = await getBranchDiff(baseBranch); - if (!diff) { - console.error(`\n ${RED()}Error: No diff from base branch.${RESET()}\n`); - return 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 and description..."); - - 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(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 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."); - await copyToClipboard(`${title}\n\n${body}`); - console.log(` ${DIM()}PR title copied to clipboard.${RESET()}`); - return 0; - } - - if (lower === "e") { - const newTitle = await ask(" Title: "); - const newBody = await ask(" Body (optional): "); - if (!newTitle.trim()) { - console.log(" Aborted."); - return 0; - } - title = newTitle; - body = newBody; - } - - console.log(`\n Creating PR...`); - - try { - const url = await createPR(platform, title, body, baseBranch, draft); - console.log(`\n ${GREEN()}${BOLD()}โœ” PR created!${RESET()}`); - console.log(` ${CYAN()}${url}${RESET()}`); - } catch (err) { - console.error(`\n ${RED()}PR creation failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 1; - } - - return 0; -} diff --git a/src/commands/review.ts b/src/commands/review.ts deleted file mode 100644 index 754459e..0000000 --- a/src/commands/review.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { loadConfig } from "../config"; -import { - isGitRepo, - getStagedFiles, - getStagedDiff, - getUnstagedFiles, - getRepoRoot, - applyFileSelection, -} from "../git"; -import { selectFiles } from "../selector"; -import { BACK, SKIP_WAIT } from "../menu"; -import { collectProjectContext } from "../context"; -import { REVIEW_SYSTEM_PROMPT, buildReviewPrompt } from "../prompt"; -import { callAI } from "../ai"; -import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal"; -import { isStdinTTY } from "../tty"; -import type { StreamCallbacks } from "../types"; -import type { ParsedArgs } from "../cli"; - -export async function handleReview(args: ParsedArgs): Promise { - const config = await loadConfig(); - - if (!config.apiKey) { - console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); - return 1; - } - - const strictnessFlag = args.flags["strict"] as boolean - ? "strict" - : args.flags["lenient"] as boolean - ? "lenient" - : "normal"; - - const unstaged = args.flags["unstaged"] as boolean; - const verbose = args.flags["verbose"] as boolean; - - let diff: string; - let sourceLabel: string; - - if (unstaged) { - if (!(await isGitRepo())) { - console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); - return 1; - } - try { - diff = (await Bun.$`git diff`.quiet().text()).trim(); - } catch { - diff = ""; - } - sourceLabel = "unstaged changes"; - } else if (!isStdinTTY()) { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); - } - diff = Buffer.concat(chunks).toString("utf-8").trim(); - sourceLabel = "piped input"; - } else { - if (!(await isGitRepo())) { - console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); - return 1; - } - const stagedFiles = await getStagedFiles(); - const unstagedFiles = await getUnstagedFiles(); - sourceLabel = "selected changes"; - - if (stagedFiles.length > 0 || unstagedFiles.length > 0) { - const selected = await selectFiles(stagedFiles, unstagedFiles); - if (selected === BACK) return SKIP_WAIT as unknown as number; - await applyFileSelection(stagedFiles, unstagedFiles, selected); - } - diff = await getStagedDiff(); - } - - if (!diff) { - console.log(` ${DIM()}No ${sourceLabel} to review.${RESET()}`); - return 0; - } - - const MAX_DIFF_SIZE = 15000; - const truncatedDiff = diff.length > MAX_DIFF_SIZE - ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" - : diff; - - let contextPrefix = ""; - try { - if (await isGitRepo()) { - const repoRoot = await getRepoRoot(); - const ctx = await collectProjectContext(repoRoot); - if (ctx.packageDescription) { - contextPrefix = `Project: ${ctx.packageDescription}\n\n`; - } - } - } catch {} - - const userPrompt = contextPrefix + buildReviewPrompt(truncatedDiff, strictnessFlag); - - const strictnessLabel = strictnessFlag === "strict" - ? `${RED()}strict${RESET()}` - : strictnessFlag === "lenient" - ? `${GREEN()}lenient${RESET()}` - : `${YELLOW()}normal${RESET()}`; - - if (verbose) { - console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase} | Strictness: ${strictnessFlag}${RESET()}`); - } - - const tty = isStdinTTY(); - if (tty) { - console.log(`\n ${BOLD()}${CYAN()}Reviewing ${sourceLabel} (${strictnessLabel})...${RESET()}\n`); - } - - try { - const callbacks: StreamCallbacks | undefined = tty ? { - onToken: (token) => process.stdout.write(token), - } : undefined; - - const result = await callAI(config, REVIEW_SYSTEM_PROMPT, userPrompt, callbacks); - if (callbacks) { - process.stdout.write("\n"); - } else { - process.stdout.write(result + "\n"); - } - } catch (err) { - console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 1; - } - - return 0; -} diff --git a/src/commands/suggest.ts b/src/commands/suggest.ts deleted file mode 100644 index 676051c..0000000 --- a/src/commands/suggest.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { loadConfig } from "../config"; -import { - isGitRepo, - getStagedFiles, - getStagedDiff, - getUnstagedFiles, - applyFileSelection, -} from "../git"; -import { selectFiles } from "../selector"; -import { BACK, SKIP_WAIT } from "../menu"; -import { - SUGGEST_SYSTEM_PROMPT, - buildSuggestBranchPrompt, - buildSuggestTypePrompt, -} from "../prompt"; -import { callAI } from "../ai"; -import { BOLD, GREEN, YELLOW, RED, DIM, RESET, CYAN } from "../terminal"; -import { isStdinTTY } from "../tty"; -import type { Config } from "../types"; -import type { ParsedArgs } from "../cli"; - -export async function handleSuggest(args: ParsedArgs): Promise { - const config = await loadConfig(); - - if (!config.apiKey) { - console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); - return 1; - } - - const mode = args.positional[0] || "branch"; - const verbose = args.flags["verbose"] as boolean; - - if (mode !== "branch" && mode !== "type") { - console.error(`\n ${RED()}Error: Unknown suggest mode: ${mode}${RESET()}`); - console.error(` Try: gai suggest branch | gai suggest type\n`); - return 1; - } - - // Get diff (staged, or unstaged if --unstaged, or piped) - let diff: string; - if (!isStdinTTY()) { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); - } - diff = Buffer.concat(chunks).toString("utf-8").trim(); - } else { - if (!(await isGitRepo())) { - console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); - return 1; - } - - if (args.flags["unstaged"] as boolean) { - try { - diff = (await Bun.$`git diff`.quiet().text()).trim(); - } catch { - diff = ""; - } - } else { - const stagedFiles = await getStagedFiles(); - const unstagedFiles = await getUnstagedFiles(); - - if (stagedFiles.length > 0 || unstagedFiles.length > 0) { - const selected = await selectFiles(stagedFiles, unstagedFiles); - if (selected === BACK) return SKIP_WAIT as unknown as number; - await applyFileSelection(stagedFiles, unstagedFiles, selected); - } - diff = await getStagedDiff(); - } - } - - if (!diff) { - console.log(` ${DIM()}No changes to suggest from.${RESET()}`); - return 0; - } - - const MAX_DIFF_SIZE = 15000; - const truncatedDiff = diff.length > MAX_DIFF_SIZE - ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" - : diff; - - if (verbose) { - console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); - } - - if (mode === "branch") { - return handleSuggestBranch(config, truncatedDiff); - } else { - return handleSuggestType(config, truncatedDiff); - } -} - -async function handleSuggestBranch(config: Config, diff: string): Promise { - const tty = isStdinTTY(); - if (tty) { - console.log(`\n ${BOLD()}${CYAN()}Suggesting branch names...${RESET()}\n`); - } - - try { - const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestBranchPrompt(diff)); - const suggestions = raw - .split("\n") - .map((line) => line.replace(/^[\d.\s\-*]+/, "").trim()) - .filter(Boolean); - - if (suggestions.length === 0) { - console.log(` ${DIM()}No suggestions generated.${RESET()}`); - return 0; - } - - for (const s of suggestions) { - console.log(` ${GREEN()}${s}${RESET()}`); - } - console.log(""); - } catch (err) { - console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 1; - } - - return 0; -} - -async function handleSuggestType(config: Config, diff: string): Promise { - const tty = isStdinTTY(); - if (tty) { - console.log(`\n ${BOLD()}${CYAN()}Suggesting commit type...${RESET()}\n`); - } - - try { - const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestTypePrompt(diff)); - const type = raw.trim().toLowerCase(); - const validTypes = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"]; - - if (validTypes.includes(type)) { - console.log(` Suggested type: ${GREEN()}${BOLD()}${type}${RESET()}\n`); - } else { - console.log(` Suggested type: ${YELLOW()}${raw.trim()}${RESET()}`); - console.log(` ${DIM()}(Not a standard Conventional Commit type)${RESET()}\n`); - } - } catch (err) { - console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); - return 1; - } - - return 0; -} diff --git a/src/git.ts b/src/git.ts index 58cf11f..6e09840 100644 --- a/src/git.ts +++ b/src/git.ts @@ -98,33 +98,6 @@ export async function stageFiles(paths: string[]): Promise { await Bun.$`git add -- ${paths}`; } -export async function unstageFiles(paths: string[]): Promise { - if (paths.length === 0) return; - try { - await Bun.$`git restore --staged -- ${paths}`.quiet(); - } catch { - await Bun.$`git rm --cached -r -- ${paths}`.quiet(); - } -} - -export async function applyFileSelection( - stagedFiles: FileEntry[], - unstagedFiles: FileEntry[], - selectedPaths: string[], -): Promise<{ staged: string[]; unstaged: string[] }> { - const selected = new Set(selectedPaths); - const stagedPaths = new Set(stagedFiles.map((file) => file.path)); - const unstagedPaths = new Set(unstagedFiles.map((file) => file.path)); - - const toUnstage = [...stagedPaths].filter((path) => !selected.has(path)); - const toStage = [...selected].filter((path) => unstagedPaths.has(path)); - - await unstageFiles(toUnstage); - await stageFiles(toStage); - - return { staged: toStage, unstaged: toUnstage }; -} - export async function commit( message: string, ): Promise<{ branch: string; hash: string; files: number; insertions: number; deletions: number }> { diff --git a/src/menu.ts b/src/menu.ts index 92158fe..4d59295 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -1,5 +1,4 @@ import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal"; -import { isStdinTTY } from "./tty"; const UP = "\x1b[A"; const DOWN = "\x1b[B"; @@ -15,10 +14,6 @@ const BACKSPACE = "\x7f"; export const BACK = Symbol("prompt-back"); export type PromptBack = typeof BACK; -// Sent by command handlers to skip the "Press Enter to return" wait in the -// interactive menu when the user explicitly backed out of a sub-menu. -export const SKIP_WAIT = Symbol("skip-wait"); - export interface Choice { label: string; value: T; @@ -43,8 +38,13 @@ 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 +65,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,7 +79,10 @@ 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; } @@ -95,19 +98,28 @@ 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: "" }; @@ -118,35 +130,41 @@ 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 (!isStdinTTY()) { + if (process.stdin.isTTY !== true) { throw new Error(`${title} requires a TTY.`); } } @@ -166,8 +184,12 @@ 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) => { @@ -188,10 +210,16 @@ 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); } }; @@ -225,13 +253,15 @@ export async function selectMany( if (!options.selectAllLabel) return; items[0]!.selected = items.slice(1).every((item) => item.selected); }; - syncSelectAll(); 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(); } @@ -239,10 +269,19 @@ 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) => { @@ -263,12 +302,24 @@ 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/prompt.ts b/src/prompt.ts index 16327e3..4090f1a 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -1,7 +1,5 @@ import type { PRContext, ProjectContext } from "./types"; -// โ”€โ”€ Commit System Prompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - export const SYSTEM_PROMPT = `You are an expert at writing concise, meaningful git commit messages following the Conventional Commits specification. Format: (): @@ -21,17 +19,29 @@ Rules: export function buildPrompt(context: ProjectContext): string { const parts: string[] = []; - if (context.packageDescription || context.readme || context.structure) { + if ( + context.packageDescription || + context.readme || + context.structure + ) { parts.push("## Project Context"); - if (context.packageDescription) parts.push(`Description: ${context.packageDescription}`); - if (context.structure) parts.push(`Structure: ${context.structure}`); - if (context.readme) parts.push(`README:\n${context.readme}`); + if (context.packageDescription) { + parts.push(`Description: ${context.packageDescription}`); + } + if (context.structure) { + parts.push(`Structure: ${context.structure}`); + } + if (context.readme) { + parts.push(`README:\n${context.readme}`); + } parts.push(""); } if (context.recentCommits.length > 0) { parts.push("## Recent Commits (for style reference)"); - for (const c of context.recentCommits) parts.push(c); + for (const c of context.recentCommits) { + parts.push(c); + } parts.push(""); } @@ -45,8 +55,6 @@ export function buildPrompt(context: ProjectContext): string { return parts.join("\n"); } -// โ”€โ”€ PR System Prompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - export const PR_SYSTEM_PROMPT = `You are an expert at writing clear, concise pull request titles and descriptions. Format: @@ -66,11 +74,21 @@ Rules: export function buildPRPrompt(context: PRContext): string { const parts: string[] = []; - if (context.packageDescription || context.readme || context.structure) { + if ( + context.packageDescription || + context.readme || + context.structure + ) { parts.push("## Project Context"); - if (context.packageDescription) parts.push(`Description: ${context.packageDescription}`); - if (context.structure) parts.push(`Structure: ${context.structure}`); - if (context.readme) parts.push(`README:\n${context.readme}`); + if (context.packageDescription) { + parts.push(`Description: ${context.packageDescription}`); + } + if (context.structure) { + parts.push(`Structure: ${context.structure}`); + } + if (context.readme) { + parts.push(`README:\n${context.readme}`); + } parts.push(""); } @@ -81,7 +99,9 @@ export function buildPRPrompt(context: PRContext): string { if (context.branchCommits.length > 0) { parts.push("## Commits on This Branch"); - for (const c of context.branchCommits) parts.push(c); + for (const c of context.branchCommits) { + parts.push(c); + } parts.push(""); } @@ -90,141 +110,9 @@ export function buildPRPrompt(context: PRContext): string { parts.push(context.diff); parts.push("```"); parts.push(""); - parts.push("Generate a pull request title and brief body for the above changes."); + parts.push( + "Generate a pull request title and brief body for the above changes.", + ); return parts.join("\n"); } - -// โ”€โ”€ Explain Prompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -export const EXPLAIN_SYSTEM_PROMPT = `You are an expert software engineer explaining code changes in plain, accessible language. - -Given a git diff, explain: -1. WHAT changed at a high level (1 sentence summary) -2. WHY these changes matter (what problem they solve or what they enable) -3. A brief breakdown of the key changes (bullet points, one per file/module) - -Rules: -- Be concise but thorough -- Use plain language suitable for both junior and senior engineers -- Focus on the intent and impact, not just restating the diff -- Do NOT use markdown headings (no ##, ###). Use bold text markers like **Section:** instead. -- Keep each bullet point to 1-2 lines -- If the diff is trivial, say so and keep the explanation short`; - -export function buildExplainPrompt(diff: string): string { - const parts: string[] = []; - parts.push("## Changes to Explain"); - parts.push("```diff"); - parts.push(diff); - parts.push("```"); - parts.push(""); - parts.push("Explain these changes in plain language as described."); - return parts.join("\n"); -} - -// โ”€โ”€ Review Prompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -export const REVIEW_SYSTEM_PROMPT = `You are a senior software engineer performing a thorough but friendly code review. - -Review the following code changes and provide feedback in these categories: -1. **Bugs & Logic Errors** โ€” actual bugs, off-by-one errors, null safety, edge cases -2. **Code Quality** โ€” readability, naming, duplication, complexity -3. **Performance** โ€” inefficient patterns, unnecessary allocations, N+1 queries -4. **Security** โ€” injection risks, exposed secrets, unsafe operations -5. **Suggestions** โ€” concrete improvements with code snippets where helpful - -Rules: -- Be constructive, not harsh. Use "consider" and "suggest" instead of "you should". -- Prioritize by severity. Mention critical issues first. -- If the code looks great, say so! Don't fabricate issues. -- Keep feedback actionable โ€” every issue should have a clear suggestion. -- Use **bold** for section headers and \`code\` for code references. -- Do NOT output a concluding summary paragraph. End with the last suggestion.`; - -export function buildReviewPrompt(diff: string, strictness: "lenient" | "normal" | "strict"): string { - const strictnessHints: Record = { - lenient: "Focus only on major issues. Skip minor style nits.", - normal: "Provide balanced feedback covering all categories.", - strict: "Be thorough. Flag even minor issues and style inconsistencies.", - }; - - const parts: string[] = []; - parts.push(`Review strictness: ${strictnessHints[strictness]}`); - parts.push(""); - parts.push("## Code Changes to Review"); - parts.push("```diff"); - parts.push(diff); - parts.push("```"); - parts.push(""); - parts.push("Please review the above changes."); - return parts.join("\n"); -} - -// โ”€โ”€ Changelog Prompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -export const CHANGELOG_SYSTEM_PROMPT = `You are an expert at writing clear, user-facing changelogs from git commit history. - -Given a list of commits, generate a changelog organized by type: -- **Features** (feat commits) -- **Bug Fixes** (fix commits) -- **Improvements** (refactor, perf, style commits) -- **Documentation** (docs commits) -- **Chores & Maintenance** (chore, build, ci, test commits) - -Rules: -- Group by type, with the heading in **bold** -- Each entry should be a single line describing the change in user-friendly language -- Translate technical commit messages into language a user would understand -- Skip merge commits and trivial chore commits if they don't add value -- If a type has no entries, omit that section -- Output ONLY the changelog text, no preamble or markdown code blocks`; - -export function buildChangelogPrompt(commits: string[], from?: string, to?: string): string { - const parts: string[] = []; - const range = from ? `from ${from}${to ? ` to ${to}` : " to HEAD"}` : ""; - parts.push(range ? `Generate a changelog for commits ${range}.` : "Generate a changelog from the following commits."); - parts.push(""); - parts.push("## Commits"); - for (const c of commits) parts.push(c); - parts.push(""); - parts.push("Generate a changelog from these commits."); - return parts.join("\n"); -} - -// โ”€โ”€ Suggest Prompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -export const SUGGEST_SYSTEM_PROMPT = `You are an expert at suggesting git branch names and commit types based on code changes. - -For branch name suggestions: -- Use format: / -- Types: feat, fix, refactor, docs, chore, perf, test -- Description should be 2-4 hyphenated words -- Provide exactly 3 suggestions, one per line - -For commit type suggestions: -- Return exactly one Conventional Commit type that best matches the changes -- Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert -- Output ONLY the type name`; - -export function buildSuggestBranchPrompt(diff: string): string { - const parts: string[] = []; - parts.push("## Changes"); - parts.push("```diff"); - parts.push(diff); - parts.push("```"); - parts.push(""); - parts.push("Suggest 3 branch names for these changes. Output one per line, no numbering."); - return parts.join("\n"); -} - -export function buildSuggestTypePrompt(diff: string): string { - const parts: string[] = []; - parts.push("## Changes"); - parts.push("```diff"); - parts.push(diff); - parts.push("```"); - parts.push(""); - parts.push("What Conventional Commit type best describes these changes? Output ONLY the type name."); - return parts.join("\n"); -} diff --git a/src/selector.ts b/src/selector.ts index c4e428b..96019b1 100644 --- a/src/selector.ts +++ b/src/selector.ts @@ -1,54 +1,43 @@ import type { FileEntry } from "./types"; -import { isStdinTTY } from "./tty"; +import { BOLD, GREEN, YELLOW, RESET } from "./terminal"; import { BACK, selectMany } from "./menu"; import type { PromptBack } from "./menu"; -function mergeFiles(stagedFiles: FileEntry[], unstagedFiles: FileEntry[]) { - const files = new Map(); - - for (const file of stagedFiles) { - files.set(file.path, { ...file, staged: true, unstaged: false }); - } - - for (const file of unstagedFiles) { - const existing = files.get(file.path); - if (existing) { - existing.unstaged = true; - existing.label = existing.label === file.label - ? existing.label - : `${existing.label}, ${file.label}`; - } else { - files.set(file.path, { ...file, staged: false, unstaged: true }); - } - } - - return [...files.values()]; -} - export async function selectFiles( stagedFiles: FileEntry[], unstagedFiles: FileEntry[], ): Promise { - const files = mergeFiles(stagedFiles, unstagedFiles); - if (files.length === 0) return []; + if (unstagedFiles.length === 0) return []; - if (!isStdinTTY()) return stagedFiles.map((file) => file.path); + if (stagedFiles.length > 0) { + 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`); + } + } + + if (process.stdin.isTTY !== true) return []; const selected = await selectMany({ - title: "Select files for this action", - subtitle: `${stagedFiles.length} staged, ${unstagedFiles.length} unstaged`, + title: "Select files to stage", + subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`, selectAllLabel: "Select all", cancelMessage: "Aborted.", - items: files.map((f) => ({ + items: unstagedFiles.map((f) => ({ label: f.path, value: f.path, description: f.label, - selected: f.staged, })), }); if (selected === null) process.exit(1); if (selected === BACK) return BACK; + if (selected.length > 0) { + process.stdout.write( + ` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`, + ); + } + return selected; } diff --git a/src/terminal.ts b/src/terminal.ts index df203b3..769649c 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -1,35 +1,7 @@ -// Terminal styling utilities. -// Respects NO_COLOR convention, --no-color flag, and TTY detection. - -import { isStdoutTTY } from "./tty"; - -let _enabled: boolean | null = null; - -export function setColorEnabled(enabled: boolean): void { - _enabled = enabled; -} - -export function isColorEnabled(): boolean { - if (_enabled !== null) return _enabled; - - // Respect NO_COLOR: https://no-color.org/ - if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") { - return false; - } - if (!isStdoutTTY()) return false; - if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true; - - return true; -} - -function s(code: string): string { - return isColorEnabled() ? code : ""; -} - -export const BOLD = () => s("\x1b[1m"); -export const DIM = () => s("\x1b[2m"); -export const GREEN = () => s("\x1b[32m"); -export const YELLOW = () => s("\x1b[33m"); -export const CYAN = () => s("\x1b[36m"); -export const RED = () => s("\x1b[31m"); -export const RESET = () => s("\x1b[0m"); +export const BOLD = "\x1b[1m"; +export const GREEN = "\x1b[32m"; +export const YELLOW = "\x1b[33m"; +export const CYAN = "\x1b[36m"; +export const RED = "\x1b[31m"; +export const DIM = "\x1b[2m"; +export const RESET = "\x1b[0m"; diff --git a/src/tty.ts b/src/tty.ts deleted file mode 100644 index 704c2a0..0000000 --- a/src/tty.ts +++ /dev/null @@ -1,36 +0,0 @@ -// TTY detection for Bun compatibility. -// Bun does not set process.stdin.isTTY, so we use fs.fstatSync. - -import { fstatSync } from "node:fs"; - -let _stdinTTY: boolean | null = null; - -export function initTTY(): void { - if (_stdinTTY !== null) return; - - try { - // fd 0 = stdin. On Unix, a TTY is a character device. - const stat = fstatSync(0); - _stdinTTY = stat.isCharacterDevice(); - } catch { - _stdinTTY = false; - } -} - -export function isStdinTTY(): boolean { - if (_stdinTTY === null) initTTY(); - return _stdinTTY!; -} - -export function isStdoutTTY(): boolean { - // Use a heuristic for stdout โ€” check if we're in a terminal - if (process.env.TERM || process.env.TERM_PROGRAM) return true; - if (process.env.NO_COLOR) return false; - // Try fstat on fd 1 (stdout) - try { - const stat = fstatSync(1); - return stat.isCharacterDevice(); - } catch { - return false; - } -} diff --git a/src/types.ts b/src/types.ts index 5a5c41f..b81f755 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,17 +29,3 @@ export interface PRContext { branchCommits: string[]; diff: string; } - -export interface CommitResult { - branch: string; - hash: string; - files: number; - insertions: number; - deletions: number; -} - -export interface StreamCallbacks { - onToken?: (token: string) => void; - onDone?: (fullText: string) => void; - onError?: (err: Error) => void; -} diff --git a/test/config.test.ts b/test/config.test.ts index f5aec44..0664ec8 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -2,39 +2,39 @@ import { test, expect, describe } from "bun:test"; import { loadConfig } from "../src/config"; describe("config", () => { - test("loadConfig env variables override config file and defaults", async () => { - const origBase = process.env.GAI_API_BASE; - const origModel = process.env.GAI_MODEL; + test("loadConfig env variables override config file and defaults", async () => { + const origBase = process.env.GAI_API_BASE; + const origModel = process.env.GAI_MODEL; - process.env.GAI_API_BASE = "https://custom.api.com/v1"; - process.env.GAI_MODEL = "custom-model"; + process.env.GAI_API_BASE = "https://custom.api.com/v1"; + process.env.GAI_MODEL = "custom-model"; - const config = await loadConfig(); + const config = await loadConfig(); - expect(config.apiBase).toBe("https://custom.api.com/v1"); - expect(config.model).toBe("custom-model"); + expect(config.apiBase).toBe("https://custom.api.com/v1"); + expect(config.model).toBe("custom-model"); - if (origBase) process.env.GAI_API_BASE = origBase; - else delete process.env.GAI_API_BASE; - if (origModel) process.env.GAI_MODEL = origModel; - else delete process.env.GAI_MODEL; - }); + if (origBase) process.env.GAI_API_BASE = origBase; + else delete process.env.GAI_API_BASE; + if (origModel) process.env.GAI_MODEL = origModel; + else delete process.env.GAI_MODEL; + }); - test("loadConfig reads from environment variables", async () => { - const origBase = process.env.GAI_API_BASE; - const origModel = process.env.GAI_MODEL; + test("loadConfig reads from environment variables", async () => { + const origBase = process.env.GAI_API_BASE; + const origModel = process.env.GAI_MODEL; - process.env.GAI_API_BASE = "https://api.deepseek.com/v1"; - process.env.GAI_MODEL = "deepseek-v4-flash"; + process.env.GAI_API_BASE = "https://api.deepseek.com/v1"; + process.env.GAI_MODEL = "deepseek-v4-flash"; - const config = await loadConfig(); + const config = await loadConfig(); - expect(config.apiBase).toBe("https://api.deepseek.com/v1"); - expect(config.model).toBe("deepseek-v4-flash"); + expect(config.apiBase).toBe("https://api.deepseek.com/v1"); + expect(config.model).toBe("deepseek-v4-flash"); - if (origBase) process.env.GAI_API_BASE = origBase; - else delete process.env.GAI_API_BASE; - if (origModel) process.env.GAI_MODEL = origModel; - else delete process.env.GAI_MODEL; - }); + if (origBase) process.env.GAI_API_BASE = origBase; + else delete process.env.GAI_API_BASE; + if (origModel) process.env.GAI_MODEL = origModel; + else delete process.env.GAI_MODEL; + }); });