24 Commits

Author SHA1 Message Date
Mplan ab9a41ab83 feat(brand): redesign logo with bold box-drawing ASCII art; bump v0.1.3
Build / bun-build (push) Successful in 16m54s
Replace the simple ASCII logo with a bold 'GAI' box-drawing font
that renders cleanly in modern terminals. Fix version to v0.1.3
across all files (brand, cli, index, package.json).
2026-06-16 02:10:09 +08:00
Mplan 724d4d3b6b fix(ui): add 'Press Enter to return' pause after subcommand completes
After a subcommand returns (especially 'Nothing to commit' cases), the
menu no longer immediately redraws and hides the message. Instead, the
user sees the subcommand output plus a 'Press Enter to return to menu'
prompt, giving them time to read the result before returning.
2026-06-16 02:07:29 +08:00
Mplan 8ff481f630 feat(ui): adopt mole-style interactive menu with brand banner
- Add ASCII art brand banner (src/brand.ts)
- Redesign main menu to match mole's UI pattern:
  - Numbered items with ➤ arrow cursor (cyan for active, dim for inactive)
  - Label + description two-column layout
  - Number key hotkeys (1-8) for direct selection
  - Letter hotkeys: H for Help, V for Version, Q for Quit
  - Footer with controls hint: ↑↓ | Enter | H Help | V Version | Q Quit
  - Full-screen redraw using \033[H cursor home
  - Clear-screen transition when entering/exiting subcommands
- Keep selectOne/selectMany for other interactive dialogs (file selector,
  platform selector) unchanged
2026-06-16 02:04:34 +08:00
Mplan b9de1267e3 test: update config test for new settings format
Build / bun-build (push) Failing after 2m4s
2026-06-16 02:01:10 +08:00
Mplan 4572605f33 docs: update README and package.json for v0.2.0
- Full README rewrite with all new commands, pipe support, aliases,
  global flags, and configuration examples
- Bump version to 0.2.0, update description
2026-06-16 02:01:07 +08:00
Mplan 4b384a7581 refactor(ui): update menu, selector for TTY detection; thin dispatcher
- Update menu.ts and selector.ts to use isStdinTTY() and function-based
  terminal colors
- Refactor index.ts from 995-line monolith to ~270-line dispatcher that
  registers all commands via the CLI parser and delegates to modules
- Add initTTY() call at startup for correct pipe/TTY detection
- Interactive menu expanded to include new commands (explain, review,
  changelog, suggest, amend)
2026-06-16 02:01:05 +08:00
Mplan 8b2babfa5d feat: add explain, review, changelog, and suggest commands
- gai explain: plain-language diff explanation with pipe support
- gai review: AI code review with strict/normal/lenient modes
- gai changelog: generate user-facing changelog from commits,
  supports --from/--to ranges and -n count
- gai suggest: suggest branch names or commit type from diff
- All commands support pipe input and auto-stage on empty staging area
2026-06-16 02:01:02 +08:00
Mplan e69b08ac01 refactor: extract commit, pr, config into command modules
- commit: add --amend, -m/--message, streaming output, auto-stage flow
- pr: add clipboard copy on abort, TTY-safe platform selection
- config: add get/set/list subcommands for non-interactive use
- All modules use dynamic terminal colors and proper TTY detection
2026-06-16 02:00:59 +08:00
Mplan c0c3dfce7d feat(ai): add streaming SSE support and new prompt templates
- Stream AI responses token-by-token via SSE for instant feedback
- Add EXPLAIN, REVIEW, CHANGELOG, SUGGEST system prompts
- Add buildExplainPrompt, buildReviewPrompt, buildChangelogPrompt,
  buildSuggestBranchPrompt, buildSuggestTypePrompt functions
- Review prompt supports strict/lenient/normal modes
2026-06-16 02:00:56 +08:00
Mplan 8b21ab8d4a feat(terminal): add dynamic color with NO_COLOR/FORCE_COLOR support
- Convert static ANSI constants to functions for dynamic color control
- Respect NO_COLOR convention (https://no-color.org/)
- Support FORCE_COLOR for CI/CD environments
- setColorEnabled() API for --no-color flag integration
2026-06-16 02:00:53 +08:00
Mplan 42e0fafaab feat(cli): add argument parser and TTY detection
- New CLI argument parser supporting subcommands, short/long flags,
  flag values, positional args, aliases, and --help per command
- TTY detection via fstatSync (Bun compat: process.stdin.isTTY is
  undefined in Bun 1.3.x)
- Extended types: CommitResult, StreamCallbacks
2026-06-16 02:00:51 +08:00
Mplan d0506381f5 refactor(cli): remove interactive prompts for empty states and add clean repo test
Build / bun-build (push) Successful in 29s
Build / bun-build (pull_request) Successful in 37s
2026-06-12 00:42:49 +08:00
Mplan 14df49b110 refactor(cli): replace process.exit prompts with interactive selection for empty states 2026-06-12 00:36:01 +08:00
Mplan 962b76d20f refactor(cli): show empty state prompt in current page
Empty commit/PR states no longer auto-return to the menu. Instead, the
user is shown an in-page prompt with a Back option, mirroring the rest
of the CLI: pressing Enter on Back (or ←/backspace) closes the prompt
and returns control to the previous step.
2026-06-12 00:35:23 +08:00
Mplan 12e71a0af7 feat(config): add interactive editor with inline editing and navigation
Build / bun-build (push) Successful in 9m25s
2026-06-11 21:18:34 +08:00
Mplan e1354e8651 feat(menu): add back key navigation
Build / bun-build (push) Successful in 21s
2026-06-11 20:10:34 +08:00
Mplan 7e662b25cc refactor(pr): rely on selected CLI for PR creation 2026-06-11 19:43:19 +08:00
Mplan 5bb2dc8e8a chore(config): bump version to 0.1.2 2026-06-11 19:39:50 +08:00
Mplan 1dbfac7985 feat(pr): add GitLab support 2026-06-11 19:38:36 +08:00
Mplan c8626ff69a ci(gitea): add build workflow for Gitea CI
Build / bun-build (push) Successful in 1m2s
2026-06-11 18:56:08 +08:00
Mplan 7e9cab5805 docs(readme): add PR creation details and update badges 2026-06-11 18:51:44 +08:00
Mplan 9885096229 feat(cli): add branch push check before PR creation 2026-06-11 18:24:53 +08:00
Mplan 1ea180387c refactor(cli): extract interactive menu into reusable module (#3)
Extract duplicate menu rendering logic from `index.ts` into a new `src/menu.ts` module, providing generic `selectOne` and `selectMany` functions. This reduces code duplication, improves maintainability, and adds consistent UI controls display across the commit flow and platform selection.

Reviewed-on: #3
2026-06-11 18:12:12 +08:00
Mplan 6ff541284e feat(cli): add pull request creation with AI-generated messages (#2)
Add a new `gai pr` subcommand that generates pull request titles and descriptions using AI, then creates the PR via GitHub CLI (`gh`) or Gitea CLI (`tea`). This extends the existing commit-generation system by reusing retry logic and prompt infrastructure, and introduces a `callAI` function that returns raw output (instead of pre-cleaned messages) to support structured PR responses.

Reviewed-on: #2
2026-06-11 00:39:20 +08:00
25 changed files with 3395 additions and 832 deletions
+25
View File
@@ -0,0 +1,25 @@
name: Build
on:
push:
pull_request:
jobs:
bun-build:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Run tests
run: bun test
- name: Build binary
run: bun run build
+184 -46
View File
@@ -2,14 +2,13 @@
# gai # gai
**AI-powered git commit message generator** **AI-powered Git helper — commit messages, PRs, code review, changelogs, and more**
[![Version](https://img.shields.io/badge/version-0.1.0-blue.svg)](https://github.com/mplan/git-ai)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) [![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) [![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/) [![TypeScript](https://img.shields.io/badge/lang-TypeScript-3178c6.svg)](https://www.typescriptlang.org/)
Generate **Conventional Commits** messages using AI — based on your project context, code diff, and commit history. Generate **Conventional Commits** messages, pull request descriptions, code reviews, changelogs, and more — powered by AI with full project context.
</div> </div>
@@ -17,14 +16,18 @@ Generate **Conventional Commits** messages using AI — based on your project co
## Features ## Features
- **Interactive menu** — `gai` opens a menu, select actions with ↑/↓ + space/enter - **🤖 AI Commit Messages** — generate Conventional Commits from staged diffs with project context
- **3-layer context** — project overview, staged diff, and recent commit history - **🔀 AI Pull Requests** — create GitHub, Gitea, or GitLab PRs with AI-generated title and body
- **Conventional Commits** — `feat(scope): description` format by default - **📖 Explain Changes** — `gai explain` explains diffs in plain language
- **Interactive file selection** — ↑/↓ to navigate, space to select, top-level "Select all" - **🔍 Code Review** — `gai review` provides thorough, constructive code review
- **Inline editing** — edit AI-generated messages right in the terminal with cursor movement - **📝 Changelog Generation** — `gai changelog` generates user-facing changelogs from commits
- **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, and more - **💡 Smart Suggestions** — `gai suggest` recommends branch names or commit types
- **Review before commit** — confirm, edit, or abort the generated message - **✏️ Amend Commits** — `gai commit --amend` amends with AI-generated message
- **Zero dependencies** — built entirely on Bun native APIs - **⚡ 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
## Quick Start ## Quick Start
@@ -38,20 +41,79 @@ gai config
# Open interactive menu # Open interactive menu
gai gai
# Or directly generate a commit message # Generate a commit message
gai commit gai commit
# Explain your changes
gai explain
``` ```
## Usage ## Usage
``` ```
gai Open interactive menu gai Open interactive menu
gai commit Generate commit message (interactive file selection) gai commit Generate AI commit message (interactive file selection)
gai commit --auto Auto-stage all changed files gai commit -a Auto-stage all changed files
gai commit -d Generate message without committing gai commit -d Generate message without committing
gai config Configure API settings gai commit -m "msg" Use custom message (skip AI)
gai --help Show help gai commit --amend Amend last commit with AI-generated message
gai --version Show version 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 <key> Get a specific setting
gai config set <key> <v> Set a setting
gai help Show help
gai help <command> 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
``` ```
### Interactive Menu ### Interactive Menu
@@ -60,39 +122,103 @@ gai --version Show version
$ gai $ gai
gai gai
AI-powered git helper — choose a workflow
↑/↓ navigate · enter/space select · ctrl+c cancel
↑/↓ navigate, space/enter select ● commit Generate AI commit message
○ pr Create a PR with AI-generated title
◉ commit Generate AI commit message ○ explain Explain staged changes in plain language
○ review AI code review of staged changes
○ changelog Generate changelog from commits
○ suggest Suggest branch name or commit type
○ amend Amend last commit with AI message
○ config Configure API settings
``` ```
### Commit Flow ### Command Examples
#### Commit
```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
``` ```
$ gai commit
Staged files (will be included): #### PR
✓ src/git.ts (modified)
Unstaged files: ```bash
1. src/ai.ts (modified) # Create PR
2. src/newfile.ts (new) gai pr
Select files to stage: # Draft PR
◉ Select all gai pr --draft
○ src/ai.ts (modified) ```
◉ src/newfile.ts (new)
Generating commit message... #### Explain
Generated commit message: ```bash
feat(git): add interactive file staging and commit wrapper # Explain staged changes
gai explain
Use this message? [Y/n/e] Y # Explain unstaged changes
gai explain --unstaged
✔ Committed successfully! # Pipe any diff
[main a3f7c2b] feat(git): add interactive file staging and commit wrapper git diff HEAD~3 | gai explain
1 file changed, 45 insertions(+), 12 deletions(-) ```
#### 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 ## Configuration
@@ -103,13 +229,22 @@ $ gai commit
gai config 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 ### Via environment variables
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `GAI_API_KEY` | — | **Required.** Your API key | | `GAI_API_KEY` | — | **Required.** Your API key |
| `GAI_API_BASE` | `https://api.deepseek.com/v1` | API base URL | | `GAI_API_BASE` | `https://api.deepseek.com/v1` | API base URL |
| `GAI_MODEL` | `deepseek-chat` | Model name | | `GAI_MODEL` | `deepseek-v4-flash` | Model name |
| `GAI_MAX_TOKENS` | `500` | Max response tokens | | `GAI_MAX_TOKENS` | `500` | Max response tokens |
| `GAI_TEMPERATURE` | `0.7` | Sampling temperature | | `GAI_TEMPERATURE` | `0.7` | Sampling temperature |
@@ -120,7 +255,7 @@ Bun auto-loads `.env` — no dotenv needed:
```bash ```bash
GAI_API_KEY=sk-your-key GAI_API_KEY=sk-your-key
GAI_API_BASE=https://api.deepseek.com/v1 GAI_API_BASE=https://api.deepseek.com/v1
GAI_MODEL=deepseek-chat GAI_MODEL=deepseek-v4-flash
``` ```
### Using other providers ### Using other providers
@@ -169,14 +304,17 @@ GAI_MODEL=anthropic/claude-sonnet-4
│ └─ Directory structure │ │ └─ Directory structure │
│ │ │ │
│ 2. Collect code changes │ │ 2. Collect code changes │
─ git diff --staged ─ git diff --staged for commits
│ ├─ Branch diff for pull requests │
│ └─ Pipe support for external diffs │
│ │ │ │
│ 3. Collect commit history │ │ 3. Collect commit history │
─ git log --oneline -10 ─ git log --oneline -10 for commits
│ └─ Branch commits for pull requests │
│ │ │ │
│ 4. Build prompt → Call AI API │ 4. Build prompt → Call AI API (streaming)
│ │ │ │
│ 5. Review → Confirm → Commit │ 5. Review → Confirm → Commit or Create PR
└─────────────────────────────────────────────┘ └─────────────────────────────────────────────┘
``` ```
+385 -516
View File
@@ -1,569 +1,438 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import * as readline from "node:readline"; // gai — AI-powered git commit and PR helper
import { loadConfig, saveConfig } from "./src/config"; // v0.1.3
import {
isGitRepo,
getRepoRoot,
getStagedFiles,
getUnstagedFiles,
getStagedDiff,
getRecentCommits,
stageFiles,
commit,
} from "./src/git";
import { selectFiles } from "./src/selector";
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";
const args = process.argv.slice(2); import { runCLI, registerCommands, formatHelp, type CommandDef, type ParsedArgs } from "./src/cli";
import { handleCommit } from "./src/commands/commit";
import { handlePR } from "./src/commands/pr";
import { handleConfig } from "./src/commands/config";
import { handleExplain } from "./src/commands/explain";
import { handleReview } from "./src/commands/review";
import { handleChangelog } from "./src/commands/changelog";
import { handleSuggest } from "./src/commands/suggest";
import { setColorEnabled } from "./src/terminal";
import { BOLD, GREEN, CYAN, DIM, RESET } from "./src/terminal";
import { isStdinTTY, initTTY } from "./src/tty";
import { showBanner } from "./src/brand";
function showHelp() { // ── Interactive Menu (mole-style) ─────────────────────────────────────
console.log(`
${BOLD}gai${RESET} — AI-powered git commit message generator
${BOLD}Usage:${RESET} interface MenuItem {
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 config Configure API settings
gai --help Show this help message
gai --version Show version
${BOLD}Configuration:${RESET}
Set via ${CYAN}gai config${RESET} or environment variables:
GAI_API_KEY OpenAI-compatible API key
GAI_API_BASE API base URL (default: https://api.deepseek.com/v1)
GAI_MODEL Model name (default: deepseek-chat)
GAI_MAX_TOKENS Max tokens (default: 500)
GAI_TEMPERATURE Temperature (default: 0.7)
`);
}
function ask(question: string): Promise<string> {
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 handleConfig() {
const config = await loadConfig();
console.log(`\n ${BOLD}Current configuration:${RESET}`);
console.log(
` API Key: ${config.apiKey ? "****" + config.apiKey.slice(-4) : `${YELLOW}(not set)${RESET}`}`,
);
console.log(` API Base: ${config.apiBase}`);
console.log(` Model: ${config.model}`);
console.log(` Max Tokens: ${config.maxTokens}`);
console.log(` Temperature: ${config.temperature}`);
console.log(
`\n ${BOLD}Enter new values (leave empty to keep current):${RESET}`,
);
const apiKey = await ask(" API Key: ");
const apiBase = await ask(` API Base [${config.apiBase}]: `);
const model = await ask(` Model [${config.model}]: `);
const maxTokens = await ask(` Max Tokens [${config.maxTokens}]: `);
const temperature = await ask(` Temperature [${config.temperature}]: `);
const updates: Partial<Config> = {};
if (apiKey) updates.apiKey = apiKey;
if (apiBase) updates.apiBase = apiBase;
if (model) updates.model = model;
if (maxTokens) updates.maxTokens = parseInt(maxTokens);
if (temperature) updates.temperature = parseFloat(temperature);
if (Object.keys(updates).length > 0) {
await saveConfig(updates);
console.log(`\n ${GREEN}Configuration saved!${RESET}`);
} else {
console.log("\n No changes.");
}
}
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<string | null> {
if (!process.stdin.isTTY) return null;
process.stdout.write(` ${DIM}Edit message (Enter to confirm, Esc to abort):${RESET}\n`);
const savedRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
let buffer = current;
let cursor = current.length;
const ESC = "\x1b";
const ENTER = "\r";
const CTRL_C = "\x03";
const BACKSPACE = "\x7f";
function render() {
process.stdout.write("\x1b[2K\r > " + buffer);
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}D`);
}
}
process.stdout.write(" > ");
process.stdout.write(buffer);
return new Promise((resolve) => {
let escapeBuf = "";
function handleSeq(seq: string) {
if (seq === "\x1b[D" || seq === "\x1bOD") {
if (cursor > 0) {
cursor--;
process.stdout.write("\x1b[D");
}
} else if (seq === "\x1b[C" || seq === "\x1bOC") {
if (cursor < buffer.length) {
cursor++;
process.stdout.write("\x1b[C");
}
} else if (seq === "\x1b[H" || seq === "\x1b[1~" || seq === "\x1bOH") {
if (cursor > 0) {
process.stdout.write(`\x1b[${cursor}D`);
cursor = 0;
}
} else if (seq === "\x1b[F" || seq === "\x1b[4~" || seq === "\x1bOF") {
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}C`);
cursor = buffer.length;
}
} else if (seq === "\x1b[3~") {
if (cursor < buffer.length) {
buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1);
render();
}
}
}
process.stdin.on("data", (data: Buffer) => {
const key = data.toString();
if (key === CTRL_C) {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
process.stdout.write("\n");
resolve(null);
return;
}
if (key === ESC || key.startsWith("\x1b[")) {
escapeBuf = key;
if (key.length >= 3) {
handleSeq(key);
escapeBuf = "";
}
return;
}
if (escapeBuf) {
escapeBuf += key;
if (/^[A-Za-z~]$/.test(key)) {
handleSeq(escapeBuf);
escapeBuf = "";
} else if (escapeBuf.length > 8) {
escapeBuf = "";
}
return;
}
if (key === ENTER) {
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
process.stdout.write("\n");
const result = buffer.trim();
resolve(result || null);
return;
}
if (key === BACKSPACE) {
if (cursor > 0) {
buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor);
cursor--;
render();
}
return;
}
if (key === "\x01") {
if (cursor > 0) {
process.stdout.write(`\x1b[${cursor}D`);
cursor = 0;
}
return;
}
if (key === "\x05") {
if (cursor < buffer.length) {
process.stdout.write(`\x1b[${buffer.length - cursor}C`);
cursor = buffer.length;
}
return;
}
if (key === "\x0b") {
buffer = buffer.slice(0, cursor);
render();
return;
}
if (key === "\x15") {
buffer = buffer.slice(cursor);
cursor = 0;
render();
return;
}
if (key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor);
cursor++;
render();
}
});
});
}
function printCommitResult(
result: { branch: string; hash: string; files: number; insertions: number; deletions: number },
msg: string,
) {
console.log(`\n ${GREEN}${BOLD}✔ Committed successfully!${RESET}`);
const id = result.branch && result.hash
? `${YELLOW}[${result.branch} ${result.hash}]${RESET}`
: result.hash
? `${YELLOW}${result.hash}${RESET}`
: "";
console.log(` ${id} ${msg}`);
const parts: string[] = [];
if (result.files > 0) parts.push(`${YELLOW}${result.files} file${result.files > 1 ? "s" : ""} changed${RESET}`);
if (result.insertions > 0) parts.push(`${GREEN}${result.insertions} insertion${result.insertions > 1 ? "s" : ""}(+)${RESET}`);
if (result.deletions > 0) parts.push(`${RED}${result.deletions} deletion${result.deletions > 1 ? "s" : ""}(-)${RESET}`);
if (parts.length > 0) console.log(` ${parts.join(", ")}`);
}
interface MenuAction {
key: string; key: string;
label: string; label: string;
description: string; description: string;
} }
const MENU_ACTIONS: MenuAction[] = [ const MENU_ITEMS: MenuItem[] = [
{ key: "commit", label: "commit", description: "Generate AI commit message" }, { key: "commit", label: "Commit", description: "Generate AI commit message" },
{ key: "config", label: "config", description: "Configure API settings" }, { key: "pr", label: "PR", description: "Create a PR with AI-generated title" },
{ key: "explain", label: "Explain", description: "Explain staged changes in plain language" },
{ key: "review", label: "Review", description: "AI code review of staged changes" },
{ key: "changelog", label: "Changelog", description: "Generate changelog from commits" },
{ key: "suggest", label: "Suggest", description: "Suggest branch name or commit type" },
{ key: "amend", label: "Amend", description: "Amend last commit with AI message" },
{ key: "config", label: "Config", description: "Configure API settings" },
]; ];
async function showMenu(): Promise<void> { function hideCursor() { process.stdout.write("\x1b[?25l"); }
const actions = MENU_ACTIONS; function showCursor() { process.stdout.write("\x1b[?25h"); }
let cursor = 0;
const headerLines = 4; function clearLine() { process.stdout.write("\r\x1b[2K"); }
process.stdout.write(`\n ${BOLD}gai${RESET}\n`);
process.stdout.write(` ${DIM}↑/↓ navigate, space/enter select${RESET}\n\n`);
const totalLines = headerLines + actions.length;
function render() {
for (let i = 0; i < actions.length; i++) {
process.stdout.write("\x1b[2K\r");
const a = actions[i]!;
const pointer = i === cursor ? `${CYAN}${RESET} ` : " ";
const dot = i === cursor ? `${GREEN}${RESET}` : `${DIM}${RESET}`;
const name = i === cursor ? `${BOLD}${a.label}${RESET}` : a.label;
const desc = i === cursor ? a.description : `${DIM}${a.description}${RESET}`;
process.stdout.write(`${pointer} ${dot} ${name}${" ".repeat(Math.max(1, 14 - a.label.length))}${desc}\n`);
}
process.stdout.write(`\x1b[${actions.length}A`);
}
function clearMenu() {
process.stdout.write(`\x1b[${headerLines}A`);
for (let i = 0; i < totalLines; i++) {
process.stdout.write("\r\x1b[2K\n");
}
process.stdout.write(`\x1b[${totalLines}A`);
}
if (!process.stdin.isTTY) {
console.error(` ${RED}Error: Interactive menu requires a TTY.${RESET}`);
process.exit(1);
}
const savedRaw = process.stdin.isRaw;
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdout.write("\x1b[?25l");
render();
async function readKey(): Promise<string> {
return new Promise((resolve) => { return new Promise((resolve) => {
let escapeBuf = ""; const onData = (data: Buffer) => {
process.stdin.removeListener("data", onData);
function handleSeq(seq: string) { resolve(data.toString());
if (seq === "\x1b[A" || seq === "\x1bOA") { };
if (cursor > 0) { process.stdin.once("data", onData);
cursor--;
render();
}
} else if (seq === "\x1b[B" || seq === "\x1bOB") {
if (cursor < actions.length - 1) {
cursor++;
render();
}
}
}
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");
clearMenu();
process.stdout.write("\x1b[?25h");
resolve();
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 === " " || key === "\r") {
const selected = actions[cursor]!;
process.stdin.setRawMode(savedRaw === true);
process.stdin.pause();
process.stdin.removeAllListeners("data");
clearMenu();
process.stdout.write("\x1b[?25h");
if (selected.key === "commit") {
handleCommit(false, false).then(resolve);
} else if (selected.key === "config") {
handleConfig().then(resolve);
} else {
resolve();
}
return;
}
});
}); });
} }
async function handleCommit(autoMode: boolean, dryRun: boolean): Promise<void> { function visibleLen(s: string): number {
const config = await loadConfig(); return s.replace(/\x1b\[[0-9;]*m/g, "").length;
}
if (!config.apiKey) { function renderMenu(banner: string, cursor: number): number {
console.error( process.stdout.write("\x1b[H"); // cursor home
` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`,
); let lineCount = 0;
process.exit(1); for (const line of banner.split("\n")) {
clearLine();
process.stdout.write(line + "\n");
lineCount++;
} }
if (!(await isGitRepo())) { const G = GREEN();
console.error(` ${RED}Error: Not a git repository.${RESET}`); const C = CYAN();
process.exit(1); const D = DIM();
} const R = RESET();
const ARROW = "➤";
const stagedFiles = await getStagedFiles(); // Calculate padding
const unstagedFiles = await getUnstagedFiles(); const labelWidth = Math.max(...MENU_ITEMS.map((m) => visibleLen(m.label))) + 2;
if (stagedFiles.length === 0 && unstagedFiles.length === 0) { for (let i = 0; i < MENU_ITEMS.length; i++) {
console.log(" Nothing to commit."); const item = MENU_ITEMS[i]!;
return; const num = String(i + 1);
} const active = i === cursor;
if (unstagedFiles.length > 0) { clearLine();
if (autoMode) { if (active) {
await stageFiles(unstagedFiles.map((f) => f.path)); const padding = " ".repeat(Math.max(1, labelWidth - visibleLen(item.label)));
console.log( process.stdout.write(` ${C}${ARROW} ${num}. ${item.label}${R}${padding}${D}${item.description}${R}\n`);
` ${GREEN}Auto-staged ${unstagedFiles.length} file(s).${RESET}`,
);
} else { } else {
const selected = await selectFiles(stagedFiles, unstagedFiles); const padding = " ".repeat(labelWidth - visibleLen(item.label) + 3);
if (selected.length > 0) { process.stdout.write(` ${num}. ${item.label}${padding}${D}${item.description}${R}\n`);
await stageFiles(selected);
console.log(
` ${GREEN}Staged ${selected.length} file(s).${RESET}`,
);
}
} }
lineCount++;
} }
const diff = await getStagedDiff(); // Footer
if (!diff) { clearLine();
console.log(" No staged changes to commit."); process.stdout.write("\n");
return; lineCount++;
clearLine();
process.stdout.write(` ${D}↑↓ | Enter | ${G}H${D} Help | ${G}V${D} Version | ${G}Q${D} Quit${R}\n`);
lineCount++;
clearLine();
process.stdout.write("\n");
lineCount++;
// Clear rest of screen
process.stdout.write("\x1b[J");
return lineCount;
}
async function dispatchMenuAction(key: string): Promise<number> {
const fakeArgs: ParsedArgs = {
command: key,
flags: {},
positional: [],
raw: [],
subcommand: { name: key, description: "", handler: async () => 0 },
};
if (key === "amend") fakeArgs.flags["amend"] = true;
switch (key) {
case "commit": return handleCommit(fakeArgs);
case "pr": return handlePR(fakeArgs);
case "config": return handleConfig(fakeArgs);
case "explain": return handleExplain(fakeArgs);
case "review": return handleReview(fakeArgs);
case "changelog": return handleChangelog(fakeArgs);
case "suggest": return handleSuggest(fakeArgs);
case "amend": return handleCommit(fakeArgs);
default: return 0;
} }
}
const MAX_DIFF_SIZE = 15000; async function waitForEnter(): Promise<void> {
const truncatedDiff = const D = DIM();
diff.length > MAX_DIFF_SIZE const R = RESET();
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" process.stdout.write(`\n ${D}Press Enter to return to menu...${R}`);
: diff; // Read a line from stdin (works in cooked mode — blocks until Enter)
await new Promise<void>((resolve) => {
const repoRoot = await getRepoRoot(); const onData = (data: Buffer) => {
const projectCtx = await collectProjectContext(repoRoot); process.stdin.removeListener("data", onData);
const recentCommits = await getRecentCommits(10); resolve();
};
const userPrompt = buildPrompt({ process.stdin.on("data", onData);
readme: projectCtx.readme, // Resume stdin in case it was paused
packageDescription: projectCtx.packageDescription, process.stdin.resume();
structure: projectCtx.structure,
recentCommits,
diff: truncatedDiff,
}); });
process.stdout.write("\x1b[2J\x1b[H"); // clear screen
}
console.log("\n Generating commit message..."); async function dispatchAndWait(item: MenuItem, wasRaw: boolean): Promise<number> {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H"); // clear screen
const result = await dispatchMenuAction(item.key);
await waitForEnter();
return result;
}
async function showMenu(): Promise<number> {
if (!isStdinTTY()) {
console.error("Error: Interactive menu requires a TTY. Use --help for usage.");
return 1;
}
const banner = showBanner();
let cursor = 0;
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
hideCursor();
// Initial render
renderMenu(banner, cursor);
let message: string;
try { try {
message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt); while (true) {
} catch (err) { const raw = await readKey();
console.error(
` ${RED}AI request failed: ${err instanceof Error ? err.message : err}${RESET}`,
);
process.exit(1);
}
if (dryRun) { // Escape sequences (arrows)
console.log(`\n ${BOLD}Generated commit message:${RESET}`); if (raw === "\x1b[A" || raw === "\x1bOA") {
console.log(` ${GREEN}${message}${RESET}`); if (cursor > 0) { cursor--; renderMenu(banner, cursor); }
await copyToClipboard(message); continue;
console.log(`\n ${DIM}(dry-run, message copied to clipboard)${RESET}`); }
return; if (raw === "\x1b[B" || raw === "\x1bOB") {
} if (cursor < MENU_ITEMS.length - 1) { cursor++; renderMenu(banner, cursor); }
continue;
const action = await confirmCommit(message); }
if (action === "y") { // Enter
try { if (raw === "\r" || raw === "\n") {
const result = await commit(message); const result = await dispatchAndWait(MENU_ITEMS[cursor]!, wasRaw);
printCommitResult(result, message); if (result !== 0) return result;
} catch (err) { hideCursor();
console.error( if (wasRaw !== true) process.stdin.setRawMode(true);
` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`, process.stdin.resume();
); renderMenu(banner, cursor);
process.exit(1); continue;
} }
} else if (action === "e") {
const edited = await editMessage(message); // Ctrl+C
if (edited) { if (raw === "\x03") {
try { showCursor();
const result = await commit(edited); process.stdin.setRawMode(wasRaw === true);
printCommitResult(result, edited); process.stdin.pause();
} catch (err) { process.stdout.write("\n");
console.error( return 0;
` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`, }
);
process.exit(1); // Number hotkeys (1-8)
if (raw >= "1" && raw <= "8") {
const idx = parseInt(raw) - 1;
if (idx < MENU_ITEMS.length) {
cursor = idx;
renderMenu(banner, cursor);
const result = await dispatchAndWait(MENU_ITEMS[idx]!, wasRaw);
if (result !== 0) return result;
hideCursor();
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
renderMenu(banner, cursor);
continue;
}
}
// Letter hotkeys
const lower = raw.toLowerCase();
if (lower === "h") {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H");
console.log(formatHelp(commands));
return 0;
}
if (lower === "v") {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\x1b[2J\x1b[H");
console.log("gai v0.1.3");
return 0;
}
if (lower === "q") {
showCursor();
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdout.write("\n");
return 0;
} }
} else {
console.log(" Aborted.");
} }
} else { } finally {
const copied = await copyToClipboard(message); showCursor();
console.log( process.stdin.setRawMode(wasRaw === true);
copied process.stdin.pause();
? ` Aborted. Message copied to clipboard.`
: ` Aborted. Message: ${message}`,
);
} }
} }
async function main() { // ── Command Definitions ────────────────────────────────────────────────
if (args.includes("--help") || args.includes("-h")) {
showHelp();
return;
}
if (args.includes("--version") || args.includes("-v")) { const commands = registerCommands(
console.log("gai v0.1.0"); {
return; name: "",
} description: "Open interactive menu",
usage: "gai",
handler: async () => showMenu(),
} as CommandDef,
const subcommand = args[0]; {
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,
if (subcommand === "config") { {
await handleConfig(); name: "commit",
return; aliases: ["c", "ci"],
} description: "Generate AI commit message for staged changes",
usage: "gai commit [-a|--all] [-d|--dry-run] [-m|--message <msg>] [--amend]",
flags: [
{ long: "all", short: "a", type: "boolean", description: "Auto-stage all changed files" },
{ long: "auto", type: "boolean", description: "Alias for --all" },
{ long: "dry-run", short: "d", type: "boolean", description: "Generate message without committing" },
{ long: "message", short: "m", type: "string", description: "Use provided message (skip AI)" },
{ long: "amend", type: "boolean", description: "Amend the last commit with AI-generated message" },
],
examples: [
"gai commit",
"gai commit -a",
"gai commit -d",
"gai commit -m 'fix: typo in README'",
"gai commit --amend",
"git diff --staged | gai commit",
],
handler: handleCommit,
},
if (subcommand === "help") { {
showHelp(); name: "pr",
return; 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 (subcommand === "commit") { {
const autoMode = args.includes("--auto") || args.includes("-a"); name: "config",
const dryRun = args.includes("--dry-run") || args.includes("-d"); aliases: ["cfg"],
await handleCommit(autoMode, dryRun); description: "Configure API settings",
return; usage: "gai config [get <key>|set <key> <value>|list]",
} flags: [],
examples: [
"gai config",
"gai config list",
"gai config get model",
"gai config set model gpt-4o",
],
handler: handleConfig,
},
if (!subcommand) { {
await showMenu(); name: "explain",
return; 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,
},
console.error(` ${RED}Unknown command: ${subcommand}${RESET}`); {
showHelp(); name: "review",
process.exit(1); aliases: ["r", "rv"],
} description: "AI code review of staged changes",
usage: "gai review [--strict|--lenient] [--unstaged]",
flags: [
{ long: "strict", type: "boolean", description: "Thorough review, flag minor issues" },
{ long: "lenient", type: "boolean", description: "Focus only on major issues" },
{ long: "unstaged", short: "u", type: "boolean", description: "Review unstaged changes instead of staged" },
],
examples: [
"gai review",
"gai review --strict",
"gai review --unstaged",
"git diff main..feature | gai review",
],
handler: handleReview,
},
{
name: "changelog",
aliases: ["cl", "log"],
description: "Generate changelog from recent commits",
usage: "gai changelog [--from <ref>] [--to <ref>] [--count <n>]",
flags: [
{ long: "from", short: "f", type: "string", description: "Starting ref (tag or commit)" },
{ long: "to", short: "t", type: "string", description: "Ending ref (default: HEAD)" },
{ long: "count", short: "n", type: "string", description: "Number of recent commits (default: 20)" },
],
examples: [
"gai changelog",
"gai changelog --from v1.0.0",
"gai changelog --from v1.0.0 --to v1.1.0",
"gai changelog -n 50",
],
handler: handleChangelog,
},
{
name: "suggest",
aliases: ["sg"],
description: "Suggest branch name or commit type based on changes",
usage: "gai suggest [branch|type]",
flags: [
{ long: "unstaged", short: "u", type: "boolean", description: "Use unstaged changes" },
],
examples: [
"gai suggest branch",
"gai suggest type",
"git diff | gai suggest branch",
],
handler: handleSuggest,
},
);
// Keep the defs accessible for help command
const allCommandDefs = [...commands.values()].filter(
(c, i, arr) => arr.findIndex((x) => x.name === c.name) === i,
);
// ── Main ───────────────────────────────────────────────────────────────
process.on("SIGINT", () => { process.on("SIGINT", () => {
process.stdout.write("\x1b[?25h"); process.stdout.write("\x1b[?25h"); // ensure cursor is shown
process.stdout.write("\n"); process.stdout.write("\n");
process.exit(130); process.exit(130);
}); });
main().catch((err) => { const args = process.argv.slice(2);
console.error(` ${RED}Unexpected error: ${err}${RESET}`);
process.exit(1); // 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);
});
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "gai", "name": "gai",
"version": "0.1.0", "version": "0.1.3",
"description": "AI-powered git commit message generator", "description": "AI-powered git helper — commit messages, PRs, code review, changelogs, and more",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"bin": { "bin": {
+111 -43
View File
@@ -1,29 +1,20 @@
import type { Config } from "./types"; import type { Config, StreamCallbacks } from "./types";
interface ChatMessage { interface ChatMessage {
role: "system" | "user" | "assistant"; role: "system" | "user" | "assistant";
content: string; content: string;
} }
interface ChatCompletionResponse {
choices?: Array<{
message?: {
content?: string | null;
};
finish_reason?: string;
}>;
error?: {
message?: string;
type?: string;
code?: string;
};
}
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; const RETRY_DELAY_MS = 1000;
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function cleanMessage(raw: string): string { function cleanMessage(raw: string): string {
let msg = raw.trim(); let msg = raw.trim();
// Strip code fences if the whole response is wrapped
if (msg.startsWith("```") && msg.endsWith("```")) { if (msg.startsWith("```") && msg.endsWith("```")) {
const lines = msg.split("\n"); const lines = msg.split("\n");
if (lines.length > 2) { if (lines.length > 2) {
@@ -35,24 +26,21 @@ function cleanMessage(raw: string): string {
return msg; return msg;
} }
async function sleep(ms: number) { export async function callAI(
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function generateCommitMessage(
config: Config, config: Config,
systemPrompt: string, systemPrompt: string,
userPrompt: string, userPrompt: string,
retries = MAX_RETRIES, callbacks?: StreamCallbacks,
): Promise<string> { ): Promise<string> {
const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`; const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`;
const stream = callbacks != null;
const messages: ChatMessage[] = [ const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt }, { role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }, { role: "user", content: userPrompt },
]; ];
for (let attempt = 1; attempt <= retries; attempt++) { for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
@@ -65,59 +53,139 @@ export async function generateCommitMessage(
max_tokens: config.maxTokens, max_tokens: config.maxTokens,
temperature: config.temperature, temperature: config.temperature,
messages, messages,
stream,
}), }),
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
if (response.status === 429 && attempt < retries) { if (response.status === 429 && attempt < MAX_RETRIES) {
await sleep(RETRY_DELAY * attempt); await sleep(RETRY_DELAY_MS * attempt);
continue; continue;
} }
throw new Error(`API request failed (${response.status}): ${text}`); throw new Error(`API request failed (${response.status}): ${text}`);
} }
const data = (await response.json()) as ChatCompletionResponse; 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 };
};
if (data.error) { if (data.error) {
throw new Error( throw new Error(`API error: ${data.error.message ?? JSON.stringify(data.error)}`);
`API error: ${data.error.message ?? JSON.stringify(data.error)}`,
);
} }
const raw = data.choices?.[0]?.message?.content; const raw = data.choices?.[0]?.message?.content;
const finishReason = data.choices?.[0]?.finish_reason; const finishReason = data.choices?.[0]?.finish_reason;
if (raw && raw.trim()) { if (raw && raw.trim()) return raw;
return cleanMessage(raw);
}
if (finishReason === "length") { if (finishReason === "length") {
throw new Error( throw new Error("Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS.");
"Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS.",
);
} }
if (finishReason === "content_filter") { if (finishReason === "content_filter") {
throw new Error("Response blocked by content filter."); throw new Error("Response blocked by content filter.");
} }
if (attempt < MAX_RETRIES) {
if (attempt < retries) { await sleep(RETRY_DELAY_MS * attempt);
await sleep(RETRY_DELAY * attempt);
continue; continue;
} }
throw new Error( throw new Error(
`Empty response from AI after ${retries} attempts. finish_reason: ${finishReason ?? "unknown"}`, `Empty response from AI after ${MAX_RETRIES} attempts. finish_reason: ${finishReason ?? "unknown"}`,
); );
} catch (err) { } catch (err) {
if (attempt >= retries) throw err; if (attempt >= MAX_RETRIES) throw err;
if (err instanceof Error && err.message.startsWith("API error")) 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("max_tokens")) throw err;
if (err instanceof Error && err.message.includes("content filter")) throw err; if (err instanceof Error && err.message.includes("content filter")) throw err;
await sleep(RETRY_DELAY * attempt); await sleep(RETRY_DELAY_MS * attempt);
} }
} }
throw new Error("Failed to generate commit message"); throw new Error("Failed to generate response");
}
async function readStream(body: ReadableStream<Uint8Array>, callbacks: StreamCallbacks): Promise<string> {
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<string> {
const raw = await callAI(config, systemPrompt, userPrompt, callbacks);
return cleanMessage(raw);
}
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 cleaned = cleanMessage(raw);
const lines = cleaned.split("\n");
const title = lines[0]?.trim() || "Update";
let bodyStart = 1;
while (bodyStart < lines.length && lines[bodyStart]?.trim() === "") {
bodyStart++;
}
const body = lines.slice(bodyStart).join("\n").trim();
return { title, body };
} }
+22
View File
@@ -0,0 +1,22 @@
// Brand banner and ASCII art logo for gai
import { GREEN, CYAN, RESET } from "./terminal";
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");
}
+295
View File
@@ -0,0 +1,295 @@
// 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<number>; // returns exit code
}
export interface ParsedArgs {
command: string; // matched command name
flags: Record<string, unknown>; // 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<string, FlagDef> {
const index = new Map<string, FlagDef>();
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<string, CommandDef>,
): 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<string, unknown> = {};
// 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<string, CommandDef> {
const map = new Map<string, CommandDef>();
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<string, CommandDef>, 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" ? " <value>" : "";
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" ? " <value>" : "";
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 <command> [flags]");
lines.push("");
lines.push(" Commands:");
// Deduplicate: only show canonical names (not aliases)
const seen = new Set<string>();
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 <command>' for command-specific help.`);
lines.push("");
return lines.join("\n");
}
export async function runCLI(rawArgs: string[], commands: Map<string, CommandDef>): Promise<number> {
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;
}
}
+21 -21
View File
@@ -1,26 +1,26 @@
export async function copyToClipboard(text: string): Promise<boolean> { export async function copyToClipboard(text: string): Promise<boolean> {
const commands: string[][] = []; const commands: string[][] = [];
if (process.platform === "darwin") { if (process.platform === "darwin") {
commands.push(["pbcopy"]); commands.push(["pbcopy"]);
} else if (process.platform === "linux") { } else if (process.platform === "linux") {
commands.push(["xclip", "-selection", "clipboard"]); commands.push(["xclip", "-selection", "clipboard"]);
commands.push(["xsel", "--clipboard", "--input"]); commands.push(["xsel", "--clipboard", "--input"]);
} }
for (const cmd of commands) { for (const cmd of commands) {
try { try {
const proc = Bun.spawn(cmd, { const proc = Bun.spawn(cmd, {
stdin: "pipe", stdin: "pipe",
stdout: "ignore", stdout: "ignore",
stderr: "ignore", stderr: "ignore",
}); });
proc.stdin.write(text); proc.stdin.write(text);
proc.stdin.end(); proc.stdin.end();
const exitCode = await proc.exited; const exitCode = await proc.exited;
if (exitCode === 0) return true; if (exitCode === 0) return true;
} catch {} } catch {}
} }
return false; return false;
} }
+81
View File
@@ -0,0 +1,81 @@
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<number> {
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 = 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;
}
+418
View File
@@ -0,0 +1,418 @@
import * as readline from "node:readline";
import {
isGitRepo,
getRepoRoot,
getStagedFiles,
getUnstagedFiles,
getStagedDiff,
getRecentCommits,
stageFiles,
commit,
} from "../git";
import { selectFiles } from "../selector";
import { BACK } 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<string> {
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(", ")}`);
}
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<string | null> {
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<number> {
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 (stagedFiles.length === 0 && !amend) {
if (autoMode && unstagedFiles.length > 0) {
await stageFiles(unstagedFiles.map((f) => f.path));
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
} else if (unstagedFiles.length > 0) {
console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`);
return 1;
} else {
console.error(`\n ${RED()}Error: Nothing to commit.${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 (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 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
}
}
}
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<number> {
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;
}
+332
View File
@@ -0,0 +1,332 @@
import { loadConfig, saveConfig } from "../config";
import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal";
import { isStdinTTY } from "../tty";
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>);
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<number> {
const positional = args.positional;
// gai config get <key>
if (positional[0] === "get") {
const key = positional[1];
if (!key) {
console.error(`\n ${RED()}Error: Usage: gai config get <key>${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 <key> <value>
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 <key> <value>${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<Config>);
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" ? 0 : 0;
}
console.error(`\n ${RED()}Error: Unknown config subcommand: ${positional[0]}${RESET()}`);
console.error(` Try: gai config [get|set|list]\n`);
return 1;
}
+129
View File
@@ -0,0 +1,129 @@
import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
import { selectFiles } from "../selector";
import { BACK } 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<number> {
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 staged = args.flags["staged"] 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;
}
diff = await getStagedDiff();
sourceLabel = "staged changes";
// If no staged changes, offer to stage unstaged files
if (!diff) {
const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
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 = 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;
}
+209
View File
@@ -0,0 +1,209 @@
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, 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<string> {
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<Platform | null | typeof BACK> {
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<number> {
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 0;
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 0;
}
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;
}
+129
View File
@@ -0,0 +1,129 @@
import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, getRepoRoot, stageFiles } from "../git";
import { selectFiles } from "../selector";
import { BACK } 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<number> {
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;
}
diff = await getStagedDiff();
sourceLabel = "staged changes";
// If no staged changes, offer to stage unstaged files
if (!diff) {
const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
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 = 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;
}
+146
View File
@@ -0,0 +1,146 @@
import { loadConfig } from "../config";
import { isGitRepo, getStagedDiff, getUnstagedFiles, stageFiles } from "../git";
import { selectFiles } from "../selector";
import { BACK } 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<number> {
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 {
diff = await getStagedDiff();
// If no staged changes, offer to stage unstaged files
if (!diff) {
const unstagedFiles = await getUnstagedFiles();
if (unstagedFiles.length > 0) {
const selected = await selectFiles([], unstagedFiles);
if (selected === BACK) return 0;
if (selected.length > 0) {
await stageFiles(selected);
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
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<number> {
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<number> {
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;
}
+272
View File
@@ -0,0 +1,272 @@
import { BOLD, GREEN, CYAN, DIM, RESET } from "./terminal";
import { isStdinTTY } from "./tty";
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 CTRL_C = "\x03";
const BACKSPACE = "\x7f";
export const BACK = Symbol("prompt-back");
export type PromptBack = typeof BACK;
export interface Choice<T> {
label: string;
value: T;
description?: string;
selected?: boolean;
}
interface BasePromptOptions {
title: string;
subtitle?: string;
cancelMessage?: string;
allowBack?: boolean;
}
interface SinglePromptOptions<T> extends BasePromptOptions {
items: Choice<T>[];
}
interface MultiPromptOptions<T> extends BasePromptOptions {
items: Choice<T>[];
selectAllLabel?: string;
doneLabel?: string;
}
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`);
}
function clearLine() {
process.stdout.write("\r\x1b[2K");
}
function visibleLength(value: string) {
return value.replace(/\x1b\[[0-9;]*m/g, "").length;
}
function padLabel(label: string, width: number) {
return label + " ".repeat(Math.max(1, width - visibleLength(label)));
}
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()}`;
}
const backHint = showBackHint ? " · ←/backspace back" : "";
return `${DIM()}↑/↓ navigate · space toggle · enter confirm${backHint} · ctrl+c cancel${RESET()}`;
}
function renderPrompt(lines: string[], previousLines: number) {
if (previousLines > 0) {
for (let i = 0; i < previousLines; i++) {
clearLine();
process.stdout.write("\n");
}
moveUp(previousLines);
}
for (const line of lines) process.stdout.write(`${line}\n`);
moveUp(lines.length);
return lines.length;
}
function clearPrompt(lines: number) {
for (let i = 0; i < lines; i++) {
clearLine();
process.stdout.write("\n");
}
moveUp(lines);
}
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 === 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 (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 };
}
return { action: null, escapeBuf: "" };
}
function createLines<T>(
options: BasePromptOptions & { items: Choice<T>[] },
mode: "single" | "multi",
cursor: number,
) {
const labelWidth = Math.max(...options.items.map((item) => visibleLength(item.label)), 0) + 2;
const lines = [
"",
` ${BOLD()}${options.title}${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 marker = mode === "single"
? 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()}`
: "";
lines.push(` ${pointer} ${marker} ${padLabel(label, labelWidth)}${description}`);
}
return lines;
}
function ensureTTY(title: string) {
if (!isStdinTTY()) {
throw new Error(`${title} requires a TTY.`);
}
}
export async function selectOne<T>(
options: SinglePromptOptions<T>,
): Promise<T | null | PromptBack> {
ensureTTY(options.title);
let cursor = 0;
let renderedLines = 0;
let escapeBuf = "";
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
hideCursor();
const render = () => {
renderedLines = renderPrompt(createLines(options, "single", cursor), renderedLines);
};
render();
return new Promise((resolve) => {
const finish = (value: T | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
clearPrompt(renderedLines);
showCursor();
if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`);
}
resolve(value);
};
const onData = (data: Buffer) => {
const result = normalizeKey(data.toString(), escapeBuf);
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") {
finish(options.items[cursor]!.value);
}
};
process.stdin.on("data", onData);
});
}
export async function selectMany<T>(
options: MultiPromptOptions<T>,
): Promise<T[] | null | PromptBack> {
ensureTTY(options.title);
const items: Choice<T | null>[] = options.selectAllLabel
? [
{ label: options.selectAllLabel, value: null, selected: false },
...options.items.map((item) => ({ ...item })),
]
: options.items.map((item) => ({ ...item }));
let cursor = 0;
let renderedLines = 0;
let escapeBuf = "";
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) process.stdin.setRawMode(true);
process.stdin.resume();
hideCursor();
const syncSelectAll = () => {
if (!options.selectAllLabel) return;
items[0]!.selected = items.slice(1).every((item) => item.selected);
};
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;
} else {
syncSelectAll();
}
};
const render = () => {
renderedLines = renderPrompt(
createLines({ title: options.title, subtitle: options.subtitle, items }, "multi", cursor),
renderedLines,
);
};
render();
return new Promise((resolve) => {
const finish = (value: T[] | null | PromptBack) => {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
clearPrompt(renderedLines);
showCursor();
if (value === null && options.cancelMessage) {
process.stdout.write(` ${options.cancelMessage}\n`);
}
resolve(value);
};
const onData = (data: Buffer) => {
const result = normalizeKey(data.toString(), escapeBuf);
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));
}
};
process.stdin.on("data", onData);
});
}
+223
View File
@@ -0,0 +1,223 @@
export type Platform = "github" | "gitea" | "gitlab";
export async function getDefaultBranch(): Promise<string> {
try {
const result =
await Bun.$`git symbolic-ref refs/remotes/origin/HEAD`.quiet().text();
return result.trim().replace("refs/remotes/origin/", "");
} catch {
try {
const branches = await Bun.$`git branch -r`.quiet().text();
for (const line of branches.split("\n")) {
const trimmed = line.trim();
if (trimmed === "origin/main" || trimmed === "origin/master") {
return trimmed.replace("origin/", "");
}
}
} catch {}
return "main";
}
}
export async function getBranchName(): Promise<string> {
const result =
await Bun.$`git rev-parse --abbrev-ref HEAD`.quiet().text();
return result.trim();
}
export async function getBranchPushStatus(): Promise<{
pushed: boolean;
upstream: string | null;
}> {
try {
const upstream = (await Bun.$`git rev-parse --abbrev-ref --symbolic-full-name @{u}`.quiet().text()).trim();
const localHead = (await Bun.$`git rev-parse HEAD`.quiet().text()).trim();
const remoteHead = (await Bun.$`git rev-parse @{u}`.quiet().text()).trim();
return { pushed: localHead === remoteHead, upstream };
} catch {
return { pushed: false, upstream: null };
}
}
export async function pushCurrentBranch(branch: string, upstream: string | null): Promise<void> {
const args = upstream
? ["push"]
: ["push", "-u", "origin", branch];
const proc = Bun.spawn(["git", ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
throw new Error(stderr.trim() || stdout.trim() || `git push failed (exit code ${exitCode})`);
}
}
export async function getBranchCommits(base: string): Promise<string[]> {
try {
const result =
await Bun.$`git log --oneline origin/${base}..HEAD`.quiet().text();
return result.trim().split("\n").filter(Boolean);
} catch {
try {
const result =
await Bun.$`git log --oneline ${base}..HEAD`.quiet().text();
return result.trim().split("\n").filter(Boolean);
} catch {
return [];
}
}
}
export async function getBranchDiff(base: string): Promise<string> {
try {
const result =
await Bun.$`git diff ${base}...HEAD`.quiet().text();
return result.trim();
} catch {
return "";
}
}
function parseRemoteHostname(url: string): string | null {
const hostname = url
.trim()
.toLowerCase()
.replace(/^(https?:\/\/|ssh:\/\/|git:\/\/)/, "")
.replace(/^[^@]+@/, "")
.split(/[:/]/)[0];
return hostname || null;
}
export function detectPlatformFromHostname(hostname: string | null): Platform | null {
if (!hostname) return null;
if (hostname === "github.com") return "github";
if (hostname === "gitlab.com" || hostname.includes("gitlab")) {
return "gitlab";
}
if (hostname.includes("gitea")) return "gitea";
return null;
}
export async function detectPlatform(): Promise<Platform | null> {
try {
const url = await Bun.$`git remote get-url origin`.quiet().text();
const hostname = parseRemoteHostname(url);
return detectPlatformFromHostname(hostname);
} catch {
return null;
}
}
export async function getRemoteHostname(): Promise<string | null> {
try {
const url = await Bun.$`git remote get-url origin`.quiet().text();
return parseRemoteHostname(url);
} catch {
return null;
}
}
export async function createPR(
platform: Platform,
title: string,
body: string,
base: string,
draft: boolean,
): Promise<string> {
if (platform === "github") {
const args = [
"pr",
"create",
"--title",
title,
"--body",
body,
"--base",
base,
];
if (draft) args.push("--draft");
const proc = Bun.spawn(["gh", ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
throw new Error(
stderr.trim() || `gh pr create failed (exit code ${exitCode})`,
);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
return match?.[1] ?? stdout.trim();
}
if (platform === "gitlab") {
const args = [
"mr",
"create",
"--title",
title,
"--description",
body,
"--target-branch",
base,
];
if (draft) args.push("--draft");
const proc = Bun.spawn(["glab", ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
throw new Error(
stderr.trim() || `glab mr create failed (exit code ${exitCode})`,
);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
return match?.[1] ?? stdout.trim();
}
const args = [
"pulls",
"create",
"--title",
title,
"--description",
body,
"--base",
base,
];
const proc = Bun.spawn(["tea", ...args], {
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
if (exitCode !== 0) {
throw new Error(
stderr.trim() || `tea pulls create failed (exit code ${exitCode})`,
);
}
const match = stdout.match(/(https?:\/\/[^\s]+)/);
return match?.[1] ?? stdout.trim();
}
+192 -18
View File
@@ -1,4 +1,6 @@
import type { ProjectContext } from "./types"; 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. export const SYSTEM_PROMPT = `You are an expert at writing concise, meaningful git commit messages following the Conventional Commits specification.
@@ -19,29 +21,17 @@ Rules:
export function buildPrompt(context: ProjectContext): string { export function buildPrompt(context: ProjectContext): string {
const parts: string[] = []; const parts: string[] = [];
if ( if (context.packageDescription || context.readme || context.structure) {
context.packageDescription ||
context.readme ||
context.structure
) {
parts.push("## Project Context"); parts.push("## Project Context");
if (context.packageDescription) { if (context.packageDescription) parts.push(`Description: ${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.structure) {
parts.push(`Structure: ${context.structure}`);
}
if (context.readme) {
parts.push(`README:\n${context.readme}`);
}
parts.push(""); parts.push("");
} }
if (context.recentCommits.length > 0) { if (context.recentCommits.length > 0) {
parts.push("## Recent Commits (for style reference)"); parts.push("## Recent Commits (for style reference)");
for (const c of context.recentCommits) { for (const c of context.recentCommits) parts.push(c);
parts.push(c);
}
parts.push(""); parts.push("");
} }
@@ -54,3 +44,187 @@ export function buildPrompt(context: ProjectContext): string {
return parts.join("\n"); 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:
<pr title>
<blank line>
<pr body>
Rules:
1. Title must be under 72 characters, in imperative mood
2. Follow the Conventional Commits style for the title (e.g., "feat(api): add user authentication")
3. Body should be 2-3 sentences in plain text explaining WHAT was changed and WHY
4. Be specific — avoid vague messages
5. Match the language and style of recent commits if provided
6. If the branch name hints at the type (e.g., "feat/..." or "fix/..."), reflect that in the title
7. Output ONLY the PR text — no markdown, no code blocks, no prefixes`;
export function buildPRPrompt(context: PRContext): string {
const parts: string[] = [];
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}`);
parts.push("");
}
parts.push("## Branch Info");
parts.push(`Branch: ${context.branchName}`);
parts.push(`Target base: ${context.baseBranch}`);
parts.push("");
if (context.branchCommits.length > 0) {
parts.push("## Commits on This Branch");
for (const c of context.branchCommits) parts.push(c);
parts.push("");
}
parts.push("## Changes (diff from base)");
parts.push("```diff");
parts.push(context.diff);
parts.push("```");
parts.push("");
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<string, string> = {
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: <type>/<short-description>
- 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");
}
+29 -153
View File
@@ -1,168 +1,44 @@
import type { FileEntry } from "./types"; import type { FileEntry } from "./types";
import { BOLD, GREEN, YELLOW, CYAN, DIM, RESET } from "./terminal"; import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
import { isStdinTTY } from "./tty";
const UP = "\x1b[A"; import { BACK, selectMany } from "./menu";
const DOWN = "\x1b[B"; import type { PromptBack } from "./menu";
const SPACE = " ";
const ENTER = "\r";
const CTRL_C = "\x03";
function hideCursor() {
process.stdout.write("\x1b[?25l");
}
function showCursor() {
process.stdout.write("\x1b[?25h");
}
function moveUp(n: number) {
process.stdout.write(`\x1b[${n}A`);
}
function moveDown(n: number) {
process.stdout.write(`\x1b[${n}B`);
}
function clearLine() {
process.stdout.write("\x1b[2K\r");
}
interface SelectItem {
label: string;
path: string;
selected: boolean;
}
export async function selectFiles( export async function selectFiles(
stagedFiles: FileEntry[], stagedFiles: FileEntry[],
unstagedFiles: FileEntry[], unstagedFiles: FileEntry[],
): Promise<string[]> { ): Promise<string[] | PromptBack> {
if (unstagedFiles.length === 0) return []; if (unstagedFiles.length === 0) return [];
if (stagedFiles.length > 0) { if (stagedFiles.length > 0) {
process.stdout.write(`\n ${BOLD}Staged files (will be included):${RESET}\n`); process.stdout.write(`\n ${BOLD()}Staged files (will be included):${RESET()}\n`);
for (const f of stagedFiles) { for (const f of stagedFiles) {
process.stdout.write(` ${GREEN}${RESET} ${f.path} (${YELLOW}${f.label}${RESET})\n`); process.stdout.write(` ${GREEN()}${RESET()} ${f.path} (${YELLOW()}${f.label}${RESET()})\n`);
} }
} }
const items: SelectItem[] = [ if (!isStdinTTY()) return [];
{ label: "Select all", path: "__all__", selected: false },
...unstagedFiles.map((f) => ({ const selected = await selectMany({
label: `${f.path} (${f.label})`, title: "Select files to stage",
path: f.path, subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`,
selected: false, selectAllLabel: "Select all",
cancelMessage: "Aborted.",
items: unstagedFiles.map((f) => ({
label: f.path,
value: f.path,
description: f.label,
})), })),
];
let cursor = 0;
process.stdout.write(`\n ${BOLD}Select files to stage:${RESET}\n`);
process.stdout.write(` ${DIM}↑/↓ navigate, space select, enter confirm${RESET}\n\n`);
const itemStartRow = 4 + (stagedFiles.length > 0 ? stagedFiles.length + 2 : 0);
function render() {
for (let i = 0; i < items.length; i++) {
process.stdout.write("\x1b[2K\r");
const item = items[i]!;
const isAll = i === 0;
const cursor_ = i === cursor ? `${CYAN}${RESET} ` : " ";
const checkbox = item.selected ? `${GREEN}${RESET}` : `${DIM}${RESET}`;
if (isAll) {
process.stdout.write(`${cursor_} ${checkbox} ${BOLD}${item.label}${RESET}\n`);
} else {
process.stdout.write(`${cursor_} ${checkbox} ${item.path.includes("(") ? item.label : `${item.label}`}\n`);
}
}
moveUp(items.length);
}
function toggleItem(index: number) {
const item = items[index]!;
item.selected = !item.selected;
if (index === 0) {
for (let i = 1; i < items.length; i++) {
items[i]!.selected = item.selected;
}
} else {
const allSelected = items.slice(1).every((it) => it.selected);
items[0]!.selected = allSelected;
}
}
return new Promise((resolve) => {
if (process.stdin.isTTY !== true) {
resolve([]);
return;
}
const wasRaw = process.stdin.isRaw;
if (wasRaw !== true) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
hideCursor();
render();
const onData = (data: Buffer) => {
const key = data.toString();
if (key === CTRL_C) {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
showCursor();
for (let i = 0; i < items.length; i++) {
process.stdout.write("\x1b[2K\n");
}
moveUp(items.length);
process.stdout.write(`\n Aborted.\n`);
process.exit(1);
}
if (key === UP) {
if (cursor > 0) {
cursor--;
render();
}
} else if (key === DOWN) {
if (cursor < items.length - 1) {
cursor++;
render();
}
} else if (key === SPACE) {
toggleItem(cursor);
render();
} else if (key === ENTER) {
process.stdin.setRawMode(wasRaw === true);
process.stdin.pause();
process.stdin.removeListener("data", onData);
for (let i = 0; i < items.length; i++) {
process.stdout.write("\x1b[2K\n");
}
moveUp(items.length);
const selected = items
.slice(1)
.filter((it) => it.selected)
.map((it) => it.path);
if (selected.length > 0) {
process.stdout.write(
` ${GREEN}Staged ${selected.length} file(s):${RESET} ${selected.join(", ")}\n`,
);
}
showCursor();
resolve(selected);
}
};
process.stdin.on("data", onData);
}); });
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;
} }
+35 -7
View File
@@ -1,7 +1,35 @@
export const BOLD = "\x1b[1m"; // Terminal styling utilities.
export const GREEN = "\x1b[32m"; // Respects NO_COLOR convention, --no-color flag, and TTY detection.
export const YELLOW = "\x1b[33m";
export const CYAN = "\x1b[36m"; import { isStdoutTTY } from "./tty";
export const RED = "\x1b[31m";
export const DIM = "\x1b[2m"; let _enabled: boolean | null = null;
export const RESET = "\x1b[0m";
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");
+36
View File
@@ -0,0 +1,36 @@
// 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;
}
}
+24
View File
@@ -19,3 +19,27 @@ export interface ProjectContext {
recentCommits: string[]; recentCommits: string[];
diff: string; diff: string;
} }
export interface PRContext {
readme: string | null;
packageDescription: string | null;
structure: string | null;
branchName: string;
baseBranch: string;
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;
}
+50
View File
@@ -0,0 +1,50 @@
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test, expect, describe } from "bun:test";
async function run(command: string[], cwd: string, env: Record<string, string> = {}) {
const proc = Bun.spawn(command, {
cwd,
stdout: "pipe",
stderr: "pipe",
env: {
PATH: process.env.PATH ?? "",
HOME: env.HOME ?? process.env.HOME ?? "",
...env,
},
});
const [exitCode, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
return { exitCode, stdout, stderr };
}
describe("commit command", () => {
test("clean repository exits without requiring API key", async () => {
const repo = mkdtempSync(join(tmpdir(), "gai-clean-repo-"));
const home = mkdtempSync(join(tmpdir(), "gai-empty-home-"));
const init = await run(["git", "init"], repo, { HOME: home });
expect(init.exitCode).toBe(0);
const result = await run(
["bun", "run", join(import.meta.dir, "..", "index.ts"), "commit"],
repo,
{
HOME: home,
GAI_API_KEY: "",
},
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Nothing to commit");
expect(result.stdout).toContain("No staged or unstaged changes");
expect(result.stderr).not.toContain("API key not set");
expect(result.stderr).not.toContain("requires a TTY");
});
});
+26 -26
View File
@@ -2,39 +2,39 @@ import { test, expect, describe } from "bun:test";
import { loadConfig } from "../src/config"; import { loadConfig } from "../src/config";
describe("config", () => { describe("config", () => {
test("loadConfig env variables override config file and defaults", async () => { test("loadConfig env variables override config file and defaults", async () => {
const origBase = process.env.GAI_API_BASE; const origBase = process.env.GAI_API_BASE;
const origModel = process.env.GAI_MODEL; const origModel = process.env.GAI_MODEL;
process.env.GAI_API_BASE = "https://custom.api.com/v1"; process.env.GAI_API_BASE = "https://custom.api.com/v1";
process.env.GAI_MODEL = "custom-model"; 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.apiBase).toBe("https://custom.api.com/v1");
expect(config.model).toBe("custom-model"); expect(config.model).toBe("custom-model");
if (origBase) process.env.GAI_API_BASE = origBase; if (origBase) process.env.GAI_API_BASE = origBase;
else delete process.env.GAI_API_BASE; else delete process.env.GAI_API_BASE;
if (origModel) process.env.GAI_MODEL = origModel; if (origModel) process.env.GAI_MODEL = origModel;
else delete process.env.GAI_MODEL; else delete process.env.GAI_MODEL;
}); });
test("loadConfig reads from environment variables", async () => { test("loadConfig reads from environment variables", async () => {
const origBase = process.env.GAI_API_BASE; const origBase = process.env.GAI_API_BASE;
const origModel = process.env.GAI_MODEL; const origModel = process.env.GAI_MODEL;
process.env.GAI_API_BASE = "https://api.deepseek.com/v1"; process.env.GAI_API_BASE = "https://api.deepseek.com/v1";
process.env.GAI_MODEL = "deepseek-v4-flash"; 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.apiBase).toBe("https://api.deepseek.com/v1");
expect(config.model).toBe("deepseek-v4-flash"); expect(config.model).toBe("deepseek-v4-flash");
if (origBase) process.env.GAI_API_BASE = origBase; if (origBase) process.env.GAI_API_BASE = origBase;
else delete process.env.GAI_API_BASE; else delete process.env.GAI_API_BASE;
if (origModel) process.env.GAI_MODEL = origModel; if (origModel) process.env.GAI_MODEL = origModel;
else delete process.env.GAI_MODEL; else delete process.env.GAI_MODEL;
}); });
}); });
+19
View File
@@ -0,0 +1,19 @@
import { test, expect, describe } from "bun:test";
import { detectPlatformFromHostname } from "../src/pr";
describe("pr platform detection", () => {
test("detects supported hosted platforms", () => {
expect(detectPlatformFromHostname("github.com")).toBe("github");
expect(detectPlatformFromHostname("gitlab.com")).toBe("gitlab");
});
test("detects self-hosted platform hostnames", () => {
expect(detectPlatformFromHostname("gitlab.example.com")).toBe("gitlab");
expect(detectPlatformFromHostname("gitea.example.com")).toBe("gitea");
});
test("returns null for unknown hostnames", () => {
expect(detectPlatformFromHostname("git.example.com")).toBeNull();
expect(detectPlatformFromHostname(null)).toBeNull();
});
});