commit a058881b53b9303d31bc282b7bcd70b5be9590e3 Author: Mplan Date: Tue Jun 9 16:41:02 2026 +0800 feat: initial project setup with gai tool diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bbe3fc8 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# gai configuration +GAI_API_KEY=sk-your-api-key-here +GAI_API_BASE=https://api.openai.com/v1 +GAI_MODEL=gpt-4o +GAI_MAX_TOKENS=500 +GAI_TEMPERATURE=0.7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..10af204 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# git-ai + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.14. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..2ad37d9 --- /dev/null +++ b/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "git-ai", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.14.tgz", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.9.2", "https://registry.npmmirror.com/@types/node/-/node-25.9.2.tgz", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + + "bun-types": ["bun-types@1.3.14", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.14.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.24.6", "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..598dcaa --- /dev/null +++ b/index.ts @@ -0,0 +1,256 @@ +#!/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"; + +const args = process.argv.slice(2); + +function showHelp() { + console.log(` +${BOLD}gai${RESET} — AI-powered git commit message generator + +${BOLD}Usage:${RESET} + gai Generate commit message for staged/changed files + gai --auto Auto-stage all changed files + gai --dry-run Generate message without committing + gai config Configure API settings + gai --help Show this help message + gai --version Show version + +${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.openai.com/v1) + GAI_MODEL Model name (default: gpt-4o) + GAI_MAX_TOKENS Max tokens (default: 500) + GAI_TEMPERATURE Temperature (default: 0.7) +`); +} + +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 handleConfig() { + const config = await loadConfig(); + + 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}`); + + 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 = {}; + 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 { + console.log(` Current: ${DIM}${current}${RESET}`); + console.log(` Enter new message (empty to abort):`); + const newMsg = await ask(" > "); + return newMsg || null; +} + +async function main() { + if (args.includes("--help") || args.includes("-h")) { + showHelp(); + return; + } + + if (args.includes("--version") || args.includes("-v")) { + console.log("gai v0.1.0"); + return; + } + + if (args[0] === "config") { + await handleConfig(); + return; + } + + const autoMode = args.includes("--auto") || args.includes("-a"); + const dryRun = args.includes("--dry-run") || args.includes("-d"); + + 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 { + await commit(message); + console.log(`\n ${GREEN}Committed successfully!${RESET}`); + } 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 { + await commit(edited); + console.log(`\n ${GREEN}Committed successfully!${RESET}`); + } 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}`, + ); + } +} + +main().catch((err) => { + console.error(` ${RED}Unexpected error: ${err}${RESET}`); + process.exit(1); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..b5395ed --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "git-ai", + "version": "0.1.0", + "description": "AI-powered git commit message generator", + "module": "index.ts", + "type": "module", + "bin": { + "gai": "./index.ts" + }, + "scripts": { + "gai": "bun run index.ts", + "build": "bun build --compile index.ts --outfile gai" + }, + "private": false, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/src/ai.ts b/src/ai.ts new file mode 100644 index 0000000..b9a246a --- /dev/null +++ b/src/ai.ts @@ -0,0 +1,55 @@ +import type { Config } from "./types"; + +interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +interface ChatCompletionResponse { + choices?: Array<{ + message?: { + content?: string; + }; + }>; +} + +export async function generateCommitMessage( + config: Config, + systemPrompt: string, + userPrompt: string, +): Promise { + const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`; + + const messages: ChatMessage[] = [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ]; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ + model: config.model, + max_tokens: config.maxTokens, + temperature: config.temperature, + messages, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`API request failed (${response.status}): ${text}`); + } + + const data = (await response.json()) as ChatCompletionResponse; + const message = data.choices?.[0]?.message?.content?.trim(); + + if (!message) { + throw new Error("Empty response from AI"); + } + + return message; +} diff --git a/src/clipboard.ts b/src/clipboard.ts new file mode 100644 index 0000000..b42c988 --- /dev/null +++ b/src/clipboard.ts @@ -0,0 +1,26 @@ +export async function copyToClipboard(text: string): Promise { + 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"]); + } + + 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; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..69f3f1d --- /dev/null +++ b/src/config.ts @@ -0,0 +1,48 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { Config } from "./types"; + +const DEFAULT_CONFIG: Config = { + apiKey: "", + apiBase: "https://api.openai.com/v1", + model: "gpt-4o", + maxTokens: 500, + temperature: 0.7, +}; + +const CONFIG_DIR = join(homedir(), ".config", "gai"); +const CONFIG_PATH = join(CONFIG_DIR, "config.json"); + +export async function loadConfig(): Promise { + let config = { ...DEFAULT_CONFIG }; + + if (existsSync(CONFIG_PATH)) { + try { + const file = Bun.file(CONFIG_PATH); + const json = (await file.json()) as Partial; + config = { ...config, ...json }; + } catch {} + } + + if (process.env.GAI_API_KEY) config.apiKey = process.env.GAI_API_KEY; + if (process.env.GAI_API_BASE) config.apiBase = process.env.GAI_API_BASE; + if (process.env.GAI_MODEL) config.model = process.env.GAI_MODEL; + if (process.env.GAI_MAX_TOKENS) + config.maxTokens = parseInt(process.env.GAI_MAX_TOKENS); + if (process.env.GAI_TEMPERATURE) + config.temperature = parseFloat(process.env.GAI_TEMPERATURE); + + return config; +} + +export async function saveConfig(partial: Partial): Promise { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + + const current = await loadConfig(); + const merged = { ...current, ...partial }; + + await Bun.write(CONFIG_PATH, JSON.stringify(merged, null, 2)); +} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..bfdf14f --- /dev/null +++ b/src/context.ts @@ -0,0 +1,73 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const IGNORED_DIRS = new Set([ + "node_modules", + ".git", + "dist", + "out", + ".next", + "__pycache__", + ".cache", + ".turbo", + "coverage", + ".vscode", + ".idea", + "target", + "build", + ".gradle", + "vendor", +]); + +export async function collectProjectContext(repoRoot: string): Promise<{ + readme: string | null; + packageDescription: string | null; + structure: string | null; +}> { + let readme: string | null = null; + let packageDescription: string | null = null; + let structure: string | null = null; + + for (const name of ["README.md", "README.txt", "README.rst", "README"]) { + const filePath = join(repoRoot, name); + if (existsSync(filePath)) { + try { + const content = await Bun.file(filePath).text(); + readme = + content.length > 1500 + ? content.substring(0, 1500) + "\n... (truncated)" + : content; + } catch {} + break; + } + } + + const pkgPath = join(repoRoot, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = (await Bun.file(pkgPath).json()) as { description?: string }; + if (pkg.description) { + packageDescription = pkg.description; + } + } catch {} + } + + try { + const entries = readdirSync(repoRoot) + .filter((name) => !IGNORED_DIRS.has(name) && !name.startsWith(".")) + .map((name) => { + try { + return statSync(join(repoRoot, name)).isDirectory() + ? `${name}/` + : name; + } catch { + return name; + } + }); + if (entries.length > 0) { + structure = entries.join(", "); + } + } catch {} + + return { readme, packageDescription, structure }; +} diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..3c21578 --- /dev/null +++ b/src/git.ts @@ -0,0 +1,110 @@ +import type { FileEntry } from "./types"; + +export async function isGitRepo(): Promise { + try { + await Bun.$`git rev-parse --is-inside-work-tree`.quiet(); + return true; + } catch { + return false; + } +} + +export async function getRepoRoot(): Promise { + const result = await Bun.$`git rev-parse --show-toplevel`.quiet().text(); + return result.trim(); +} + +function statusToLabel(status: string): string { + switch (status[0]) { + case "A": + return "new"; + case "D": + return "deleted"; + case "R": + return "renamed"; + case "C": + return "copied"; + case "M": + return "modified"; + case "T": + return "type change"; + default: + return "modified"; + } +} + +function parseNameStatus(output: string): FileEntry[] { + return output + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const [status, ...pathParts] = line.split("\t"); + const path = pathParts[pathParts.length - 1] ?? ""; + return { path, status: status!, label: statusToLabel(status!) }; + }); +} + +export async function getStagedFiles(): Promise { + try { + const result = + await Bun.$`git diff --cached --name-status`.quiet().text(); + return parseNameStatus(result); + } catch { + return []; + } +} + +export async function getUnstagedFiles(): Promise { + const files: FileEntry[] = []; + + try { + const result = await Bun.$`git diff --name-status`.quiet().text(); + files.push(...parseNameStatus(result)); + } catch {} + + try { + const result = + await Bun.$`git ls-files --others --exclude-standard`.quiet().text(); + for (const path of result.trim().split("\n").filter(Boolean)) { + files.push({ path, status: "??", label: "new" }); + } + } catch {} + + return files; +} + +export async function getStagedDiff(): Promise { + try { + const result = await Bun.$`git diff --staged`.quiet().text(); + return result.trim(); + } catch { + return ""; + } +} + +export async function getRecentCommits(count = 10): Promise { + try { + const n = String(count); + const result = await Bun.$`git log --oneline -n ${n}`.quiet().text(); + return result.trim().split("\n").filter(Boolean); + } catch { + return []; + } +} + +export async function stageFiles(paths: string[]): Promise { + if (paths.length === 0) return; + await Bun.$`git add -- ${paths}`; +} + +export async function commit(message: string): Promise { + const proc = Bun.spawn(["git", "commit", "-m", message], { + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`git commit failed (exit code ${exitCode})`); + } +} diff --git a/src/prompt.ts b/src/prompt.ts new file mode 100644 index 0000000..806f3d8 --- /dev/null +++ b/src/prompt.ts @@ -0,0 +1,56 @@ +import type { ProjectContext } from "./types"; + +export const SYSTEM_PROMPT = `You are an expert at writing concise, meaningful git commit messages following the Conventional Commits specification. + +Format: (): + +Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert + +Rules: +1. Use imperative mood in description (e.g., "add feature" not "added feature") +2. Keep the first line (subject) under 72 characters +3. Scope is optional but recommended when the change area is clear +4. For breaking changes, add "!" after the scope: feat(scope)!: description +5. If the change needs explanation, add a body separated by a blank line — explain WHY, not WHAT +6. Match the language and style of recent commits if provided +7. Be specific — avoid vague messages like "update code" or "fix bugs" +8. Output ONLY the commit message text, no markdown, no code blocks, no prefixes`; + +export function buildPrompt(context: ProjectContext): 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(""); + } + + if (context.recentCommits.length > 0) { + parts.push("## Recent Commits (for style reference)"); + for (const c of context.recentCommits) { + parts.push(c); + } + parts.push(""); + } + + parts.push("## Staged Changes"); + parts.push("```diff"); + parts.push(context.diff); + parts.push("```"); + parts.push(""); + parts.push("Generate a commit message for the above changes."); + + return parts.join("\n"); +} diff --git a/src/selector.ts b/src/selector.ts new file mode 100644 index 0000000..0f6c6ff --- /dev/null +++ b/src/selector.ts @@ -0,0 +1,62 @@ +import * as readline from "node:readline"; +import type { FileEntry } from "./types"; +import { BOLD, GREEN, YELLOW, CYAN, RESET } from "./terminal"; + +export async function selectFiles( + stagedFiles: FileEntry[], + unstagedFiles: FileEntry[], +): Promise { + if (unstagedFiles.length === 0) return []; + + if (stagedFiles.length > 0) { + console.log(`\n ${BOLD}Staged files (will be included):${RESET}`); + for (const f of stagedFiles) { + console.log(` ${GREEN}✓${RESET} ${f.path} (${YELLOW}${f.label}${RESET})`); + } + } + + console.log(`\n ${BOLD}Unstaged files:${RESET}`); + for (let i = 0; i < unstagedFiles.length; i++) { + const f = unstagedFiles[i]!; + console.log( + ` ${CYAN}${i + 1}.${RESET} ${f.path} (${YELLOW}${f.label}${RESET})`, + ); + } + + console.log(""); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question( + " Enter files to stage (e.g. 1,3) or 'a' for all: ", + (answer) => { + rl.close(); + const trimmed = answer.trim().toLowerCase(); + + if (trimmed === "a" || trimmed === "all") { + resolve(unstagedFiles.map((f) => f.path)); + return; + } + + if (trimmed === "") { + resolve([]); + return; + } + + const indices = trimmed + .split(/[,\s]+/) + .map((s) => parseInt(s.trim())) + .filter( + (n) => !isNaN(n) && n >= 1 && n <= unstagedFiles.length, + ) + .map((n) => n - 1); + + const uniqueIndices = [...new Set(indices)]; + resolve(uniqueIndices.map((i) => unstagedFiles[i]!.path)); + }, + ); + }); +} diff --git a/src/terminal.ts b/src/terminal.ts new file mode 100644 index 0000000..769649c --- /dev/null +++ b/src/terminal.ts @@ -0,0 +1,7 @@ +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"; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..cc8e5d1 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,21 @@ +export interface Config { + apiKey: string; + apiBase: string; + model: string; + maxTokens: number; + temperature: number; +} + +export interface FileEntry { + path: string; + status: string; + label: string; +} + +export interface ProjectContext { + readme: string | null; + packageDescription: string | null; + structure: string | null; + recentCommits: string[]; + diff: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}