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