19 Commits

Author SHA1 Message Date
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
23 changed files with 3069 additions and 805 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
+46 -21
View File
@@ -2,14 +2,14 @@
# gai
**AI-powered git commit message generator**
**AI-powered Git commit and pull request helper**
[![Version](https://img.shields.io/badge/version-0.1.0-blue.svg)](https://github.com/mplan/git-ai)
[![Release](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgit.catpl.top%2Fapi%2Fv1%2Frepos%2FMplan%2Fgai%2Freleases%2Flatest&query=%24.tag_name&label=release&color=blue)](https://git.catpl.top/Mplan/gai/releases)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
[![Bun](https://img.shields.io/badge/runtime-Bun-f9d71c.svg)](https://bun.sh)
[![TypeScript](https://img.shields.io/badge/lang-TypeScript-3178c6.svg)](https://www.typescriptlang.org/)
Generate **Conventional Commits** messages using AI based on your project context, code diff, and commit history.
Generate **Conventional Commits** messages and pull request descriptions using AI, based on your project context, code diff, branch changes, and commit history.
</div>
@@ -18,13 +18,14 @@ Generate **Conventional Commits** messages using AI — based on your project co
## Features
- **Interactive menu** — `gai` opens a menu, select actions with ↑/↓ + space/enter
- **3-layer context** — project overview, staged diff, and recent commit history
- **Context-aware commits** — reads project overview, staged diff, and recent commit history
- **Conventional Commits** — `feat(scope): description` format by default
- **Interactive file selection** — ↑/↓ to navigate, space to select, top-level "Select all"
- **Inline editing** — edit AI-generated messages right in the terminal with cursor movement
- **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, and more
- **AI-generated PRs** — create GitHub, Gitea, or GitLab pull requests with generated title and body
- **OpenAI-compatible API** — works with DeepSeek, OpenAI, Ollama, OpenRouter, and more
- **Review before commit** — confirm, edit, or abort the generated message
- **Zero dependencies** — built entirely on Bun native APIs
- **Bun-native runtime** — built on Bun APIs with no runtime npm dependencies
## Quick Start
@@ -40,6 +41,9 @@ gai
# Or directly generate a commit message
gai commit
# Or create an AI-generated pull request
gai pr
```
## Usage
@@ -49,6 +53,8 @@ gai Open interactive menu
gai commit Generate commit message (interactive file selection)
gai commit --auto Auto-stage all changed files
gai commit -d Generate message without committing
gai pr Create a PR with AI-generated title and body
gai pr --draft Create a draft PR
gai config Configure API settings
gai --help Show help
gai --version Show version
@@ -60,10 +66,12 @@ gai --version Show version
$ gai
gai
Choose a workflow
↑/↓ navigate · enter/space select · ctrl+c cancel
↑/↓ navigate, space/enter select
◉ commit Generate AI commit message
● commit Generate AI commit message
○ pr Create a PR with AI-generated title
○ config Configure API settings
```
### Commit Flow
@@ -74,14 +82,13 @@ $ gai commit
Staged files (will be included):
✓ src/git.ts (modified)
Unstaged files:
1. src/ai.ts (modified)
2. src/newfile.ts (new)
Select files to stage:
◉ Select all
○ src/ai.ts (modified)
◉ src/newfile.ts (new)
2 unstaged files available
↑/↓ navigate · space toggle · enter confirm · ←/backspace back · ctrl+c cancel
□ Select all
□ src/ai.ts modified
■ src/newfile.ts new
Generating commit message...
@@ -95,6 +102,22 @@ $ gai commit
1 file changed, 45 insertions(+), 12 deletions(-)
```
### Pull Request Flow
`gai pr` detects the remote platform from `origin`:
- GitHub remotes use the `gh` CLI
- Gitea remotes use the `tea` CLI
- GitLab remotes use the `glab` CLI
- Unknown remotes prompt you to choose a platform
The command compares your current branch against the default branch, pushes the branch if needed, generates a PR title/body from the branch commits and diff, then asks for confirmation before creating the PR.
```bash
gai pr
gai pr --draft
```
## Configuration
### Via `gai config` (interactive)
@@ -109,7 +132,7 @@ gai config
|---|---|---|
| `GAI_API_KEY` | — | **Required.** Your API key |
| `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_TEMPERATURE` | `0.7` | Sampling temperature |
@@ -120,7 +143,7 @@ Bun auto-loads `.env` — no dotenv needed:
```bash
GAI_API_KEY=sk-your-key
GAI_API_BASE=https://api.deepseek.com/v1
GAI_MODEL=deepseek-chat
GAI_MODEL=deepseek-v4-flash
```
### Using other providers
@@ -169,14 +192,16 @@ GAI_MODEL=anthropic/claude-sonnet-4
│ └─ Directory structure │
│ │
│ 2. Collect code changes │
─ git diff --staged
─ git diff --staged for commits
│ └─ branch diff for pull requests │
│ │
│ 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 │
│ │
│ 5. Review → Confirm → Commit
│ 5. Review → Confirm → Commit or Create PR
└─────────────────────────────────────────────┘
```
+246 -541
View File
@@ -1,569 +1,274 @@
#!/usr/bin/env bun
import * as readline from "node:readline";
import { loadConfig, saveConfig } from "./src/config";
import {
isGitRepo,
getRepoRoot,
getStagedFiles,
getUnstagedFiles,
getStagedDiff,
getRecentCommits,
stageFiles,
commit,
} from "./src/git";
import { selectFiles } from "./src/selector";
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";
// gai — AI-powered git commit and PR helper
// v0.2.0
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 { BACK, selectOne } from "./src/menu";
function showHelp() {
console.log(`
${BOLD}gai${RESET} — AI-powered git commit message generator
// ── Interactive Menu (default, no subcommand) ─────────────────────────
${BOLD}Usage:${RESET}
gai Open interactive menu
gai commit Generate commit message for staged/changed files
gai commit --auto Auto-stage all changed files
gai commit -d Generate message without committing
gai config Configure API settings
gai --help Show this help message
gai --version Show version
async function showMenu(): Promise<number> {
if (!isStdinTTY()) {
console.error("Error: Interactive menu requires a TTY. Use --help for usage.");
return 1;
}
${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());
while (true) {
const selected = await selectOne({
title: "gai",
subtitle: "AI-powered git helper — choose a workflow",
allowBack: false,
items: [
{ label: "commit", value: "commit", description: "Generate AI commit message" },
{ label: "pr", value: "pr", description: "Create a PR with AI-generated title" },
{ label: "explain", value: "explain", description: "Explain staged changes in plain language" },
{ label: "review", value: "review", description: "AI code review of staged changes" },
{ label: "changelog", value: "changelog", description: "Generate changelog from commits" },
{ label: "suggest", value: "suggest", description: "Suggest branch name or commit type" },
{ label: "amend", value: "amend", description: "Amend last commit with AI message" },
{ label: "config", value: "config", description: "Configure API settings" },
],
});
});
}
async function handleConfig() {
const config = await loadConfig();
if (selected === null || selected === BACK) return 0;
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}`);
// Build synthetic args for subcommand handlers
const fakeArgs: ParsedArgs = {
command: selected,
flags: {},
positional: [],
raw: [],
subcommand: {
name: selected,
description: "",
handler: async () => 0,
},
};
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();
}
}
let result: number;
switch (selected) {
case "commit":
result = await handleCommit(fakeArgs);
break;
case "pr":
result = await handlePR(fakeArgs);
break;
case "config":
result = await handleConfig(fakeArgs);
break;
case "explain":
result = await handleExplain(fakeArgs);
break;
case "review":
result = await handleReview(fakeArgs);
break;
case "changelog":
result = await handleChangelog(fakeArgs);
break;
case "suggest":
result = await handleSuggest(fakeArgs);
break;
case "amend":
fakeArgs.flags["amend"] = true;
result = await handleCommit(fakeArgs);
break;
default:
result = 0;
}
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;
label: string;
description: string;
}
const MENU_ACTIONS: MenuAction[] = [
{ key: "commit", label: "commit", description: "Generate AI commit message" },
{ key: "config", label: "config", description: "Configure API settings" },
];
async function showMenu(): Promise<void> {
const actions = MENU_ACTIONS;
let cursor = 0;
const headerLines = 4;
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();
return new Promise((resolve) => {
let escapeBuf = "";
function handleSeq(seq: string) {
if (seq === "\x1b[A" || seq === "\x1bOA") {
if (cursor > 0) {
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> {
const config = await loadConfig();
if (!config.apiKey) {
console.error(
` ${RED}Error: API key not set. Run ${BOLD}gai config${RESET}${RED} to configure.${RESET}`,
);
process.exit(1);
}
if (!(await isGitRepo())) {
console.error(` ${RED}Error: Not a git repository.${RESET}`);
process.exit(1);
}
const stagedFiles = await getStagedFiles();
const unstagedFiles = await getUnstagedFiles();
if (stagedFiles.length === 0 && unstagedFiles.length === 0) {
console.log(" Nothing to commit.");
return;
}
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.length > 0) {
await stageFiles(selected);
console.log(
` ${GREEN}Staged ${selected.length} file(s).${RESET}`,
);
}
}
}
const diff = await getStagedDiff();
if (!diff) {
console.log(" No staged changes to commit.");
return;
}
const MAX_DIFF_SIZE = 15000;
const truncatedDiff =
diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)"
: diff;
const repoRoot = await getRepoRoot();
const projectCtx = await collectProjectContext(repoRoot);
const recentCommits = await getRecentCommits(10);
const userPrompt = buildPrompt({
readme: projectCtx.readme,
packageDescription: projectCtx.packageDescription,
structure: projectCtx.structure,
recentCommits,
diff: truncatedDiff,
});
console.log("\n Generating commit message...");
let message: string;
try {
message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt);
} catch (err) {
console.error(
` ${RED}AI request failed: ${err instanceof Error ? err.message : err}${RESET}`,
);
process.exit(1);
}
if (dryRun) {
console.log(`\n ${BOLD}Generated commit message:${RESET}`);
console.log(` ${GREEN}${message}${RESET}`);
await copyToClipboard(message);
console.log(`\n ${DIM}(dry-run, message copied to clipboard)${RESET}`);
return;
}
const action = await confirmCommit(message);
if (action === "y") {
try {
const result = await commit(message);
printCommitResult(result, message);
} catch (err) {
console.error(
` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`,
);
process.exit(1);
}
} else if (action === "e") {
const edited = await editMessage(message);
if (edited) {
try {
const result = await commit(edited);
printCommitResult(result, edited);
} catch (err) {
console.error(
` ${RED}Commit failed: ${err instanceof Error ? err.message : err}${RESET}`,
);
process.exit(1);
}
} else {
console.log(" Aborted.");
}
} else {
const copied = await copyToClipboard(message);
console.log(
copied
? ` Aborted. Message copied to clipboard.`
: ` Aborted. Message: ${message}`,
);
// Return to menu unless they explicitly chose "back"
if (result !== 0) return result;
// Loop back to menu for another action
}
}
async function main() {
if (args.includes("--help") || args.includes("-h")) {
showHelp();
return;
}
// ── Command Definitions ────────────────────────────────────────────────
if (args.includes("--version") || args.includes("-v")) {
console.log("gai v0.1.0");
return;
}
const commands = registerCommands(
{
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();
return;
}
{
name: "commit",
aliases: ["c", "ci"],
description: "Generate AI commit message for staged changes",
usage: "gai commit [-a|--all] [-d|--dry-run] [-m|--message <msg>] [--amend]",
flags: [
{ long: "all", short: "a", type: "boolean", description: "Auto-stage all changed files" },
{ long: "auto", type: "boolean", description: "Alias for --all" },
{ long: "dry-run", short: "d", type: "boolean", description: "Generate message without committing" },
{ long: "message", short: "m", type: "string", description: "Use provided message (skip AI)" },
{ long: "amend", type: "boolean", description: "Amend the last commit with AI-generated message" },
],
examples: [
"gai commit",
"gai commit -a",
"gai commit -d",
"gai commit -m 'fix: typo in README'",
"gai commit --amend",
"git diff --staged | gai commit",
],
handler: handleCommit,
},
if (subcommand === "help") {
showHelp();
return;
}
{
name: "pr",
aliases: ["p"],
description: "Create a PR with AI-generated title and body",
usage: "gai pr [--draft]",
flags: [
{ long: "draft", type: "boolean", description: "Create as draft PR" },
],
examples: [
"gai pr",
"gai pr --draft",
],
handler: handlePR,
},
if (subcommand === "commit") {
const autoMode = args.includes("--auto") || args.includes("-a");
const dryRun = args.includes("--dry-run") || args.includes("-d");
await handleCommit(autoMode, dryRun);
return;
}
{
name: "config",
aliases: ["cfg"],
description: "Configure API settings",
usage: "gai config [get <key>|set <key> <value>|list]",
flags: [],
examples: [
"gai config",
"gai config list",
"gai config get model",
"gai config set model gpt-4o",
],
handler: handleConfig,
},
if (!subcommand) {
await showMenu();
return;
}
{
name: "explain",
aliases: ["x"],
description: "Explain staged changes in plain language",
usage: "gai explain [--unstaged]",
flags: [
{ long: "unstaged", short: "u", type: "boolean", description: "Explain unstaged changes instead of staged" },
{ long: "staged", short: "s", type: "boolean", description: "Explain staged changes (default)" },
],
examples: [
"gai explain",
"gai explain --unstaged",
"git diff main..feature | gai explain",
],
handler: handleExplain,
},
console.error(` ${RED}Unknown command: ${subcommand}${RESET}`);
showHelp();
process.exit(1);
}
{
name: "review",
aliases: ["r", "rv"],
description: "AI code review of staged changes",
usage: "gai review [--strict|--lenient] [--unstaged]",
flags: [
{ long: "strict", type: "boolean", description: "Thorough review, flag minor issues" },
{ long: "lenient", type: "boolean", description: "Focus only on major issues" },
{ long: "unstaged", short: "u", type: "boolean", description: "Review unstaged changes instead of staged" },
],
examples: [
"gai review",
"gai review --strict",
"gai review --unstaged",
"git diff main..feature | gai review",
],
handler: handleReview,
},
{
name: "changelog",
aliases: ["cl", "log"],
description: "Generate changelog from recent commits",
usage: "gai changelog [--from <ref>] [--to <ref>] [--count <n>]",
flags: [
{ long: "from", short: "f", type: "string", description: "Starting ref (tag or commit)" },
{ long: "to", short: "t", type: "string", description: "Ending ref (default: HEAD)" },
{ long: "count", short: "n", type: "string", description: "Number of recent commits (default: 20)" },
],
examples: [
"gai changelog",
"gai changelog --from v1.0.0",
"gai changelog --from v1.0.0 --to v1.1.0",
"gai changelog -n 50",
],
handler: handleChangelog,
},
{
name: "suggest",
aliases: ["sg"],
description: "Suggest branch name or commit type based on changes",
usage: "gai suggest [branch|type]",
flags: [
{ long: "unstaged", short: "u", type: "boolean", description: "Use unstaged changes" },
],
examples: [
"gai suggest branch",
"gai suggest type",
"git diff | gai suggest branch",
],
handler: handleSuggest,
},
);
// Keep the defs accessible for help command
const allCommandDefs = [...commands.values()].filter(
(c, i, arr) => arr.findIndex((x) => x.name === c.name) === i,
);
// ── Main ───────────────────────────────────────────────────────────────
process.on("SIGINT", () => {
process.stdout.write("\x1b[?25h");
process.stdout.write("\x1b[?25h"); // ensure cursor is shown
process.stdout.write("\n");
process.exit(130);
});
main().catch((err) => {
console.error(` ${RED}Unexpected error: ${err}${RESET}`);
process.exit(1);
});
const args = process.argv.slice(2);
// Initialize TTY detection early (before any command handlers run)
initTTY();
// Apply --no-color early
if (args.includes("--no-color")) {
setColorEnabled(false);
}
runCLI(args, commands)
.then((exitCode) => {
process.exit(exitCode);
})
.catch((err) => {
console.error(`\n Unexpected error: ${err.message ?? err}\n`);
process.exit(1);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gai",
"version": "0.1.0",
"version": "0.1.2",
"description": "AI-powered git commit message generator",
"module": "index.ts",
"type": "module",
+111 -43
View File
@@ -1,29 +1,20 @@
import type { Config } from "./types";
import type { Config, StreamCallbacks } from "./types";
interface ChatMessage {
role: "system" | "user" | "assistant";
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 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 {
let msg = raw.trim();
// Strip code fences if the whole response is wrapped
if (msg.startsWith("```") && msg.endsWith("```")) {
const lines = msg.split("\n");
if (lines.length > 2) {
@@ -35,24 +26,21 @@ function cleanMessage(raw: string): string {
return msg;
}
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function generateCommitMessage(
export async function callAI(
config: Config,
systemPrompt: string,
userPrompt: string,
retries = MAX_RETRIES,
callbacks?: StreamCallbacks,
): Promise<string> {
const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`;
const stream = callbacks != null;
const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
];
for (let attempt = 1; attempt <= retries; attempt++) {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch(url, {
method: "POST",
@@ -65,59 +53,139 @@ export async function generateCommitMessage(
max_tokens: config.maxTokens,
temperature: config.temperature,
messages,
stream,
}),
});
if (!response.ok) {
const text = await response.text();
if (response.status === 429 && attempt < retries) {
await sleep(RETRY_DELAY * attempt);
if (response.status === 429 && attempt < MAX_RETRIES) {
await sleep(RETRY_DELAY_MS * attempt);
continue;
}
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) {
throw new Error(
`API error: ${data.error.message ?? JSON.stringify(data.error)}`,
);
throw new Error(`API error: ${data.error.message ?? JSON.stringify(data.error)}`);
}
const raw = data.choices?.[0]?.message?.content;
const finishReason = data.choices?.[0]?.finish_reason;
if (raw && raw.trim()) {
return cleanMessage(raw);
}
if (raw && raw.trim()) return raw;
if (finishReason === "length") {
throw new Error(
"Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS.",
);
throw new Error("Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS.");
}
if (finishReason === "content_filter") {
throw new Error("Response blocked by content filter.");
}
if (attempt < retries) {
await sleep(RETRY_DELAY * attempt);
if (attempt < MAX_RETRIES) {
await sleep(RETRY_DELAY_MS * attempt);
continue;
}
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) {
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.includes("max_tokens")) 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 };
}
+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.2.0");
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> {
const commands: string[][] = [];
const commands: string[][] = [];
if (process.platform === "darwin") {
commands.push(["pbcopy"]);
} else if (process.platform === "linux") {
commands.push(["xclip", "-selection", "clipboard"]);
commands.push(["xsel", "--clipboard", "--input"]);
}
if (process.platform === "darwin") {
commands.push(["pbcopy"]);
} else if (process.platform === "linux") {
commands.push(["xclip", "-selection", "clipboard"]);
commands.push(["xsel", "--clipboard", "--input"]);
}
for (const cmd of commands) {
try {
const proc = Bun.spawn(cmd, {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
});
proc.stdin.write(text);
proc.stdin.end();
const exitCode = await proc.exited;
if (exitCode === 0) return true;
} catch {}
}
for (const cmd of commands) {
try {
const proc = Bun.spawn(cmd, {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
});
proc.stdin.write(text);
proc.stdin.end();
const exitCode = await proc.exited;
if (exitCode === 0) return true;
} catch {}
}
return false;
return false;
}
+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.
@@ -19,29 +21,17 @@ Rules:
export function buildPrompt(context: ProjectContext): string {
const parts: string[] = [];
if (
context.packageDescription ||
context.readme ||
context.structure
) {
if (context.packageDescription || context.readme || context.structure) {
parts.push("## Project Context");
if (context.packageDescription) {
parts.push(`Description: ${context.packageDescription}`);
}
if (context.structure) {
parts.push(`Structure: ${context.structure}`);
}
if (context.readme) {
parts.push(`README:\n${context.readme}`);
}
if (context.packageDescription) parts.push(`Description: ${context.packageDescription}`);
if (context.structure) parts.push(`Structure: ${context.structure}`);
if (context.readme) parts.push(`README:\n${context.readme}`);
parts.push("");
}
if (context.recentCommits.length > 0) {
parts.push("## Recent Commits (for style reference)");
for (const c of context.recentCommits) {
parts.push(c);
}
for (const c of context.recentCommits) parts.push(c);
parts.push("");
}
@@ -54,3 +44,187 @@ export function buildPrompt(context: ProjectContext): string {
return parts.join("\n");
}
// ── PR System Prompt ───────────────────────────────────────────────────
export const PR_SYSTEM_PROMPT = `You are an expert at writing clear, concise pull request titles and descriptions.
Format:
<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 { BOLD, GREEN, YELLOW, CYAN, DIM, RESET } from "./terminal";
const UP = "\x1b[A";
const DOWN = "\x1b[B";
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;
}
import { BOLD, GREEN, YELLOW, RESET } from "./terminal";
import { isStdinTTY } from "./tty";
import { BACK, selectMany } from "./menu";
import type { PromptBack } from "./menu";
export async function selectFiles(
stagedFiles: FileEntry[],
unstagedFiles: FileEntry[],
): Promise<string[]> {
): Promise<string[] | PromptBack> {
if (unstagedFiles.length === 0) return [];
if (stagedFiles.length > 0) {
process.stdout.write(`\n ${BOLD}Staged files (will be included):${RESET}\n`);
process.stdout.write(`\n ${BOLD()}Staged files (will be included):${RESET()}\n`);
for (const f of stagedFiles) {
process.stdout.write(` ${GREEN}${RESET} ${f.path} (${YELLOW}${f.label}${RESET})\n`);
process.stdout.write(` ${GREEN()}${RESET()} ${f.path} (${YELLOW()}${f.label}${RESET()})\n`);
}
}
const items: SelectItem[] = [
{ label: "Select all", path: "__all__", selected: false },
...unstagedFiles.map((f) => ({
label: `${f.path} (${f.label})`,
path: f.path,
selected: false,
if (!isStdinTTY()) return [];
const selected = await selectMany({
title: "Select files to stage",
subtitle: `${unstagedFiles.length} unstaged file${unstagedFiles.length > 1 ? "s" : ""} available`,
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";
export const GREEN = "\x1b[32m";
export const YELLOW = "\x1b[33m";
export const CYAN = "\x1b[36m";
export const RED = "\x1b[31m";
export const DIM = "\x1b[2m";
export const RESET = "\x1b[0m";
// Terminal styling utilities.
// Respects NO_COLOR convention, --no-color flag, and TTY detection.
import { isStdoutTTY } from "./tty";
let _enabled: boolean | null = null;
export function setColorEnabled(enabled: boolean): void {
_enabled = enabled;
}
export function isColorEnabled(): boolean {
if (_enabled !== null) return _enabled;
// Respect NO_COLOR: https://no-color.org/
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
return false;
}
if (!isStdoutTTY()) return false;
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
return true;
}
function s(code: string): string {
return isColorEnabled() ? code : "";
}
export const BOLD = () => s("\x1b[1m");
export const DIM = () => s("\x1b[2m");
export const GREEN = () => s("\x1b[32m");
export const YELLOW = () => s("\x1b[33m");
export const CYAN = () => s("\x1b[36m");
export const RED = () => s("\x1b[31m");
export const RESET = () => s("\x1b[0m");
+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[];
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");
});
});
+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();
});
});