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
This commit is contained in:
2026-06-16 02:01:02 +08:00
parent e69b08ac01
commit 8b2babfa5d
4 changed files with 485 additions and 0 deletions
+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;
}