From e69b08ac017d56864231149e4a186ce3493e5ce8 Mon Sep 17 00:00:00 2001 From: Mplan Date: Tue, 16 Jun 2026 02:00:59 +0800 Subject: [PATCH] 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 --- src/commands/commit.ts | 418 +++++++++++++++++++++++++++++++++++++++++ src/commands/config.ts | 332 ++++++++++++++++++++++++++++++++ src/commands/pr.ts | 209 +++++++++++++++++++++ 3 files changed, 959 insertions(+) create mode 100644 src/commands/commit.ts create mode 100644 src/commands/config.ts create mode 100644 src/commands/pr.ts diff --git a/src/commands/commit.ts b/src/commands/commit.ts new file mode 100644 index 0000000..c901ec7 --- /dev/null +++ b/src/commands/commit.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..1822211 --- /dev/null +++ b/src/commands/config.ts @@ -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 = 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 { + const positional = args.positional; + + // gai config get + if (positional[0] === "get") { + const key = positional[1]; + if (!key) { + console.error(`\n ${RED()}Error: Usage: gai config get ${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 + 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 ${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); + 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; +} diff --git a/src/commands/pr.ts b/src/commands/pr.ts new file mode 100644 index 0000000..f1bb019 --- /dev/null +++ b/src/commands/pr.ts @@ -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 { + 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 { + 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 { + 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; +}