feat: initial project setup with gai tool
This commit is contained in:
@@ -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
|
||||
+34
@@ -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
|
||||
@@ -0,0 +1,106 @@
|
||||
|
||||
Default to using Bun instead of Node.js.
|
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
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 <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
||||
@@ -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.
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<Config> = {};
|
||||
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<string | null> {
|
||||
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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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<Config> {
|
||||
let config = { ...DEFAULT_CONFIG };
|
||||
|
||||
if (existsSync(CONFIG_PATH)) {
|
||||
try {
|
||||
const file = Bun.file(CONFIG_PATH);
|
||||
const json = (await file.json()) as Partial<Config>;
|
||||
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<Config>): Promise<void> {
|
||||
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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import type { FileEntry } from "./types";
|
||||
|
||||
export async function isGitRepo(): Promise<boolean> {
|
||||
try {
|
||||
await Bun.$`git rev-parse --is-inside-work-tree`.quiet();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRepoRoot(): Promise<string> {
|
||||
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<FileEntry[]> {
|
||||
try {
|
||||
const result =
|
||||
await Bun.$`git diff --cached --name-status`.quiet().text();
|
||||
return parseNameStatus(result);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUnstagedFiles(): Promise<FileEntry[]> {
|
||||
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<string> {
|
||||
try {
|
||||
const result = await Bun.$`git diff --staged`.quiet().text();
|
||||
return result.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentCommits(count = 10): Promise<string[]> {
|
||||
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<void> {
|
||||
if (paths.length === 0) return;
|
||||
await Bun.$`git add -- ${paths}`;
|
||||
}
|
||||
|
||||
export async function commit(message: string): Promise<void> {
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
@@ -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: <type>(<scope>): <description>
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -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<string[]> {
|
||||
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));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user