feat: initial project setup with gai tool

This commit is contained in:
2026-06-09 16:41:02 +08:00
commit a058881b53
17 changed files with 952 additions and 0 deletions
+55
View File
@@ -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;
}
+26
View File
@@ -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;
}
+48
View File
@@ -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));
}
+73
View File
@@ -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
View File
@@ -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})`);
}
}
+56
View File
@@ -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");
}
+62
View File
@@ -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));
},
);
});
}
+7
View File
@@ -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";
+21
View File
@@ -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;
}