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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user