feat: initial project setup with gai tool
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user