From a058881b53b9303d31bc282b7bcd70b5be9590e3 Mon Sep 17 00:00:00 2001 From: Mplan Date: Tue, 9 Jun 2026 16:41:02 +0800 Subject: [PATCH] feat: initial project setup with gai tool --- .env.example | 6 ++ .gitignore | 34 +++++++ CLAUDE.md | 106 ++++++++++++++++++++ README.md | 15 +++ bun.lock | 26 +++++ index.ts | 256 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 21 ++++ src/ai.ts | 55 ++++++++++ src/clipboard.ts | 26 +++++ src/config.ts | 48 +++++++++ src/context.ts | 73 ++++++++++++++ src/git.ts | 110 ++++++++++++++++++++ src/prompt.ts | 56 +++++++++++ src/selector.ts | 62 ++++++++++++ src/terminal.ts | 7 ++ src/types.ts | 21 ++++ tsconfig.json | 30 ++++++ 17 files changed, 952 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 index.ts create mode 100644 package.json create mode 100644 src/ai.ts create mode 100644 src/clipboard.ts create mode 100644 src/config.ts create mode 100644 src/context.ts create mode 100644 src/git.ts create mode 100644 src/prompt.ts create mode 100644 src/selector.ts create mode 100644 src/terminal.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json 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 + } +}