From 8b2babfa5db487391e9ed89d5ee8b25fce080a85 Mon Sep 17 00:00:00 2001 From: Mplan Date: Tue, 16 Jun 2026 02:01:02 +0800 Subject: [PATCH] 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 --- src/commands/changelog.ts | 81 +++++++++++++++++++++ src/commands/explain.ts | 129 +++++++++++++++++++++++++++++++++ src/commands/review.ts | 129 +++++++++++++++++++++++++++++++++ src/commands/suggest.ts | 146 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 485 insertions(+) create mode 100644 src/commands/changelog.ts create mode 100644 src/commands/explain.ts create mode 100644 src/commands/review.ts create mode 100644 src/commands/suggest.ts diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts new file mode 100644 index 0000000..4d3b723 --- /dev/null +++ b/src/commands/changelog.ts @@ -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 { + 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; +} diff --git a/src/commands/explain.ts b/src/commands/explain.ts new file mode 100644 index 0000000..3ab11b2 --- /dev/null +++ b/src/commands/explain.ts @@ -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 { + 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; +} diff --git a/src/commands/review.ts b/src/commands/review.ts new file mode 100644 index 0000000..4664dc8 --- /dev/null +++ b/src/commands/review.ts @@ -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 { + 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; +} diff --git a/src/commands/suggest.ts b/src/commands/suggest.ts new file mode 100644 index 0000000..970e88a --- /dev/null +++ b/src/commands/suggest.ts @@ -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 { + 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 { + const tty = isStdinTTY(); + if (tty) { + console.log(`\n ${BOLD()}${CYAN()}Suggesting branch names...${RESET()}\n`); + } + + try { + const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestBranchPrompt(diff)); + const suggestions = raw + .split("\n") + .map((line) => line.replace(/^[\d.\s\-*]+/, "").trim()) + .filter(Boolean); + + if (suggestions.length === 0) { + console.log(` ${DIM()}No suggestions generated.${RESET()}`); + return 0; + } + + for (const s of suggestions) { + console.log(` ${GREEN()}${s}${RESET()}`); + } + console.log(""); + } catch (err) { + console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + + return 0; +} + +async function handleSuggestType(config: Config, diff: string): Promise { + const tty = isStdinTTY(); + if (tty) { + console.log(`\n ${BOLD()}${CYAN()}Suggesting commit type...${RESET()}\n`); + } + + try { + const raw = await callAI(config, SUGGEST_SYSTEM_PROMPT, buildSuggestTypePrompt(diff)); + const type = raw.trim().toLowerCase(); + const validTypes = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"]; + + if (validTypes.includes(type)) { + console.log(` Suggested type: ${GREEN()}${BOLD()}${type}${RESET()}\n`); + } else { + console.log(` Suggested type: ${YELLOW()}${raw.trim()}${RESET()}`); + console.log(` ${DIM()}(Not a standard Conventional Commit type)${RESET()}\n`); + } + } catch (err) { + console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); + return 1; + } + + return 0; +}