-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.ts
More file actions
328 lines (283 loc) · 11 KB
/
cli.ts
File metadata and controls
328 lines (283 loc) · 11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
#!/usr/bin/env node
import { config } from "dotenv";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { homedir } from "node:os";
import { generateText } from "ai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { Command } from "commander";
import chalk from "chalk";
import ora from "ora";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Use ~/.cba/.env for config to persist across package updates
const configDir = join(homedir(), ".cba");
const configPath = join(configDir, ".env");
// Create config directory if it doesn't exist
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
config({
path: configPath,
quiet: true,
});
const execAsync = promisify(exec);
// Read version from package.json (in root directory)
function getPackageJson() {
const currentDir = join(__dirname, "package.json");
const parentDir = join(__dirname, "..", "package.json");
if (existsSync(currentDir)) {
return JSON.parse(readFileSync(currentDir, "utf8"));
} else if (existsSync(parentDir)) {
return JSON.parse(readFileSync(parentDir, "utf8"));
} else {
throw new Error("Could not find package.json in current or parent directory");
}
}
const packageJson = getPackageJson();
const program = new Command();
program
.name("cba")
.description(chalk.blue("AI-powered commit message generator"));
// Custom version handling with consistent formatting
program.version(packageJson.version, "-v, --version");
program.configureOutput({
writeOut: (str) => {
if (str.includes(packageJson.version)) {
showInfo();
console.log(chalk.gray(`version ${packageJson.version}`));
} else {
process.stdout.write(str);
}
}
});
function showInfo(showModel = false) {
if (showModel) {
const modelId = process.env.OPENROUTER_MODEL_ID || "deepseek/deepseek-chat-v3.1:free";
console.log(chalk.gray(`cba: commit-by-ai (using ${modelId})`));
} else {
console.log(chalk.gray("cba: commit-by-ai"));
}
}
program
.command("config")
.description(chalk.yellow("Manage configuration"))
.argument("<action>", "Action to perform (get|set)")
.argument("[key]", "Configuration key (api_key|model)")
.argument("[value]", "Configuration value")
.action(async (action: string, key?: string, value?: string) => {
showInfo();
let configData: Record<string, string> = {};
if (existsSync(configPath)) {
const envContent = readFileSync(configPath, "utf8");
envContent.split("\n").forEach((line) => {
if (line.trim() && !line.startsWith("#")) {
const [k, v] = line.split("=");
if (k && v) configData[k] = v;
}
});
}
const aliases: Record<string, string> = {
api_key: "OPENROUTER_API_KEY",
model: "OPENROUTER_MODEL_ID",
key: "OPENROUTER_API_KEY",
id: "OPENROUTER_MODEL_ID",
};
const fullKey = key ? aliases[key] || key : undefined;
if (action === "get") {
if (!key) {
console.log(chalk.cyan("Current configuration:"));
console.log(
chalk.green(
`OPENROUTER_API_KEY: ${
configData.OPENROUTER_API_KEY || "Not set"
}`
)
);
console.log(
chalk.green(
`OPENROUTER_MODEL_ID: ${
configData.OPENROUTER_MODEL_ID ||
"Not set (default: deepseek/deepseek-chat-v3.1:free)"
}`
)
);
} else {
console.log(
chalk.green(
`${fullKey}: ${configData[fullKey!] || "Not set"}`
)
);
}
} else if (action === "set") {
if (!key || !value) {
console.error(
chalk.red(
"Error: Both key and value are required for set action"
)
);
process.exit(1);
}
configData[fullKey!] = value;
let envContent = "";
Object.entries(configData).forEach(([k, v]) => {
if (v) {
envContent += `${k}=${v}\n`;
}
});
writeFileSync(configPath, envContent);
console.log(chalk.green(`Set ${fullKey} successfully`));
} else {
console.error(chalk.red('Error: Action must be "get" or "set"'));
process.exit(1);
}
});
program
.command("commit")
.description(chalk.yellow("Generate commit message for staged changes"))
.action(async () => {
showInfo(true);
await generateCommit();
});
// Main execution - default action
const args = process.argv.slice(2);
if (args.length === 0) {
showInfo(true);
generateCommit();
} else {
program.parse();
}
async function getStagedDiff(): Promise<string> {
try {
const { stdout } = await execAsync("git diff --staged");
return stdout.trim();
} catch (error: any) {
if (error.code === 128) {
// Git error: no staged changes
return "";
}
throw error;
}
}
async function generateCommitMessage(diff: string): Promise<{ message: string; usage?: any; providerMetadata?: any }> {
const apiKey = process.env.OPENROUTER_API_KEY || "";
const modelId = process.env.OPENROUTER_MODEL_ID || "deepseek/deepseek-chat-v3.1:free";
// API key is required for all models
if (!apiKey) {
throw new Error(
"OPENROUTER_API_KEY is required for all models. Get your API key from https://openrouter.ai/ and set it with: cba config set api_key <your-api-key>"
);
}
const openrouter = createOpenRouter({
apiKey,
});
const prompt = `Generate a concise, imperative-style Git commit message (under 72 characters for the subject) based on the following staged changes diff. Do not include any additional information, such as file names or file paths. If the diff is empty, return "No changes". Use prefixes to describe the type of change (e.g., "feat: ", "fix: ", "docs: ", etc.). Focus on what changed and why, without unnecessary details:\n\n${diff}`;
const system = `You are a helpful Git assistant. Always respond with just the commit message, no explanations.`;
const response = await generateText({
model: openrouter.chat(modelId, {
usage: {
include: true,
},
}),
messages: [
{
role: "system",
content: system,
},
{
role: "user",
content: prompt,
},
],
});
return {
message: response.text.trim(),
usage: response.usage,
providerMetadata: response.providerMetadata
};
}
async function generateCommit(): Promise<void> {
try {
// Check API key configuration first
const apiKey = process.env.OPENROUTER_API_KEY || "";
if (!apiKey) {
console.error(chalk.red("Error: API key required for all models"));
console.log(chalk.cyan("\nTo use commit-by-ai, you need an OpenRouter API key:"));
console.log(chalk.yellow("1. Get an API key from https://openrouter.ai/"));
console.log(chalk.yellow("2. Set up your configuration:"));
console.log(chalk.gray("\nSet API key:"));
console.log(chalk.green(" cba config set api_key <your-api-key>"));
console.log(chalk.gray("\nSet model (optional):"));
console.log(chalk.green(" cba config set model <your-model-id>"));
console.log(chalk.gray("\nPopular models:"));
console.log(chalk.gray(" - deepseek/deepseek-chat-v3.1:free (default)"));
console.log(chalk.gray(" - openai/gpt-5-mini"));
process.exit(1);
}
let diff = await getStagedDiff();
let autoAdded = false;
if (!diff) {
console.log(
chalk.yellow(
"No staged changes. Automatically staging all files with `git add .`..."
)
);
await execAsync("git add .");
autoAdded = true;
// Check again after adding files
diff = await getStagedDiff();
if (!diff) {
console.log(
chalk.blue("No changes to commit. Working tree is clean.")
);
process.exit(0);
}
}
const spinner = ora({
text: chalk.cyan("Generating commit message..."),
color: "blue",
spinner: "dots"
}).start();
const result = await generateCommitMessage(diff);
spinner.succeed(chalk.gray("Commit message generated!"));
console.log(chalk.cyan("\nSuggested commit message:\n"));
console.log(chalk.green(result.message));
console.log(
chalk.gray(
`\nUse it with: ${chalk.blue(`git commit -m "${result.message.replace(/"/g, '\\"')}"`)}`
)
);
if (autoAdded) {
console.log(
chalk.yellow(
"\nNote: All files were automatically staged for this commit."
)
);
}
// Display cost information if available
const modelId = process.env.OPENROUTER_MODEL_ID || "deepseek/deepseek-chat-v3.1:free";
if (result.providerMetadata?.openrouter?.usage) {
const openrouterUsage = result.providerMetadata.openrouter.usage;
const { cost, totalTokens } = openrouterUsage;
console.log(chalk.gray(`\nTokens used: ${totalTokens} total (${modelId})`));
// Only show cost if model is not free (doesn't contain :free in name)
if (!modelId.includes(':free') && cost !== undefined && cost !== null && Number(cost) > 0) {
console.log(chalk.gray(`Cost: $${Number(cost).toFixed(6)}`));
}
} else if (result.usage) {
// Fallback to basic token usage if providerMetadata is not available
const { totalTokens } = result.usage;
console.log(chalk.gray(`\nTokens used: ${totalTokens} total (${modelId})`));
// Only show cost if model is not free (doesn't contain :free in name)
if (!modelId.includes(':free')) {
console.log(chalk.gray(`Cost: Unknown (usage accounting not available)`));
}
}
} catch (error: any) {
console.error(chalk.red("Error:", error.message));
process.exit(1);
}
}