@lenadweb/aicmt 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/bin/aicmt.js +870 -0
- package/dist/bin/aicmt.js.map +1 -0
- package/dist/cli.js +876 -0
- package/dist/cli.js.map +1 -0
- package/package.json +54 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/cli.ts
|
|
31
|
+
var cli_exports = {};
|
|
32
|
+
__export(cli_exports, {
|
|
33
|
+
run: () => run
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(cli_exports);
|
|
36
|
+
var import_commander = require("commander");
|
|
37
|
+
|
|
38
|
+
// src/commands/commit.ts
|
|
39
|
+
var import_prompts = __toESM(require("prompts"));
|
|
40
|
+
|
|
41
|
+
// src/config.ts
|
|
42
|
+
var import_promises = __toESM(require("fs/promises"));
|
|
43
|
+
var import_node_os = __toESM(require("os"));
|
|
44
|
+
var import_node_path = __toESM(require("path"));
|
|
45
|
+
var import_zod = require("zod");
|
|
46
|
+
|
|
47
|
+
// src/constants.ts
|
|
48
|
+
var CONFIG_DIR_NAME = "aicmt";
|
|
49
|
+
var CONFIG_FILENAME = "config.json";
|
|
50
|
+
var DEFAULT_MODEL = "openai/gpt-4o-mini";
|
|
51
|
+
var DEFAULT_TEMPERATURE = 0.2;
|
|
52
|
+
var DEFAULT_MAX_TOKENS = 120;
|
|
53
|
+
var MIN_OUTPUT_TOKENS = 32;
|
|
54
|
+
var MAX_OUTPUT_TOKENS = 512;
|
|
55
|
+
|
|
56
|
+
// src/config.ts
|
|
57
|
+
var sharedConfigSchema = import_zod.z.object({
|
|
58
|
+
openrouterApiKey: import_zod.z.string().min(1).optional(),
|
|
59
|
+
model: import_zod.z.string().min(1).optional(),
|
|
60
|
+
format: import_zod.z.string().min(1).optional(),
|
|
61
|
+
instructions: import_zod.z.string().min(1).optional(),
|
|
62
|
+
temperature: import_zod.z.number().min(0).max(2).optional(),
|
|
63
|
+
maxTokens: import_zod.z.number().int().positive().optional()
|
|
64
|
+
}).strict();
|
|
65
|
+
var projectConfigSchema = sharedConfigSchema;
|
|
66
|
+
var globalConfigSchema = sharedConfigSchema.extend({
|
|
67
|
+
projects: import_zod.z.record(projectConfigSchema).default({})
|
|
68
|
+
}).strict();
|
|
69
|
+
function getDefaultConfigPath() {
|
|
70
|
+
var _a;
|
|
71
|
+
const base = (_a = process.env.XDG_CONFIG_HOME) == null ? void 0 : _a.trim();
|
|
72
|
+
const configDir = base ? import_node_path.default.join(base, CONFIG_DIR_NAME) : import_node_path.default.join(import_node_os.default.homedir(), ".config", CONFIG_DIR_NAME);
|
|
73
|
+
return import_node_path.default.join(configDir, CONFIG_FILENAME);
|
|
74
|
+
}
|
|
75
|
+
function resolveConfigPath(repoRoot, providedPath) {
|
|
76
|
+
if (providedPath) {
|
|
77
|
+
return import_node_path.default.isAbsolute(providedPath) ? providedPath : import_node_path.default.resolve(repoRoot, providedPath);
|
|
78
|
+
}
|
|
79
|
+
return getDefaultConfigPath();
|
|
80
|
+
}
|
|
81
|
+
function clampMaxTokens(value) {
|
|
82
|
+
if (value < MIN_OUTPUT_TOKENS) {
|
|
83
|
+
return MIN_OUTPUT_TOKENS;
|
|
84
|
+
}
|
|
85
|
+
if (value > MAX_OUTPUT_TOKENS) {
|
|
86
|
+
return MAX_OUTPUT_TOKENS;
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
function applyDefaults(config) {
|
|
91
|
+
return {
|
|
92
|
+
openrouterApiKey: config.openrouterApiKey,
|
|
93
|
+
model: config.model,
|
|
94
|
+
format: config.format,
|
|
95
|
+
instructions: config.instructions,
|
|
96
|
+
temperature: typeof config.temperature === "number" ? config.temperature : DEFAULT_TEMPERATURE,
|
|
97
|
+
maxTokens: typeof config.maxTokens === "number" ? clampMaxTokens(config.maxTokens) : DEFAULT_MAX_TOKENS
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function resolveProjectConfig(globalConfig, repoRoot) {
|
|
101
|
+
const { projects = {}, ...defaults } = globalConfig;
|
|
102
|
+
const projectConfig = projects[repoRoot] ?? {};
|
|
103
|
+
const merged = { ...defaults, ...projectConfig };
|
|
104
|
+
const missing = [];
|
|
105
|
+
if (!merged.openrouterApiKey) {
|
|
106
|
+
missing.push("openrouterApiKey");
|
|
107
|
+
}
|
|
108
|
+
if (!merged.model) {
|
|
109
|
+
missing.push("model");
|
|
110
|
+
}
|
|
111
|
+
if (!merged.format) {
|
|
112
|
+
missing.push("format");
|
|
113
|
+
}
|
|
114
|
+
if (!merged.instructions) {
|
|
115
|
+
missing.push("instructions");
|
|
116
|
+
}
|
|
117
|
+
if (missing.length > 0) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Missing ${missing.join(", ")} in config. Run aicmt init to set defaults.`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return applyDefaults({
|
|
123
|
+
openrouterApiKey: merged.openrouterApiKey,
|
|
124
|
+
model: merged.model,
|
|
125
|
+
format: merged.format,
|
|
126
|
+
instructions: merged.instructions,
|
|
127
|
+
temperature: merged.temperature,
|
|
128
|
+
maxTokens: merged.maxTokens
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async function loadGlobalConfig(configPath, options = {}) {
|
|
132
|
+
let raw;
|
|
133
|
+
try {
|
|
134
|
+
raw = await import_promises.default.readFile(configPath, "utf8");
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (options.allowMissing) {
|
|
137
|
+
return globalConfigSchema.parse({});
|
|
138
|
+
}
|
|
139
|
+
throw new Error(`Global config not found at ${configPath}`);
|
|
140
|
+
}
|
|
141
|
+
let parsed;
|
|
142
|
+
try {
|
|
143
|
+
parsed = JSON.parse(raw);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw new Error(`Invalid JSON in config: ${configPath}`);
|
|
146
|
+
}
|
|
147
|
+
const result = globalConfigSchema.safeParse(parsed);
|
|
148
|
+
if (!result.success) {
|
|
149
|
+
const message = result.error.issues.map((issue) => issue.message).join("; ");
|
|
150
|
+
throw new Error(`Invalid config: ${message}`);
|
|
151
|
+
}
|
|
152
|
+
return result.data;
|
|
153
|
+
}
|
|
154
|
+
async function saveGlobalConfig(configPath, config) {
|
|
155
|
+
const result = globalConfigSchema.safeParse(config);
|
|
156
|
+
if (!result.success) {
|
|
157
|
+
const message = result.error.issues.map((issue) => issue.message).join("; ");
|
|
158
|
+
throw new Error(`Refusing to write invalid config: ${message}`);
|
|
159
|
+
}
|
|
160
|
+
await import_promises.default.mkdir(import_node_path.default.dirname(configPath), { recursive: true });
|
|
161
|
+
const json = `${JSON.stringify(result.data, null, 2)}
|
|
162
|
+
`;
|
|
163
|
+
await import_promises.default.writeFile(configPath, json, "utf8");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/git.ts
|
|
167
|
+
var import_promises2 = __toESM(require("fs/promises"));
|
|
168
|
+
var import_node_os2 = __toESM(require("os"));
|
|
169
|
+
var import_node_path2 = __toESM(require("path"));
|
|
170
|
+
|
|
171
|
+
// src/utils.ts
|
|
172
|
+
var import_node_child_process = require("child_process");
|
|
173
|
+
var import_node_util = require("util");
|
|
174
|
+
var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
175
|
+
async function runCommand(command, args, options = {}) {
|
|
176
|
+
const result = await execFileAsync(command, args, {
|
|
177
|
+
...options,
|
|
178
|
+
encoding: "utf8",
|
|
179
|
+
maxBuffer: 10 * 1024 * 1024
|
|
180
|
+
});
|
|
181
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
182
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
183
|
+
return {
|
|
184
|
+
stdout: stdout.trimEnd(),
|
|
185
|
+
stderr: stderr.trimEnd()
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/git.ts
|
|
190
|
+
async function runGit(args, cwd) {
|
|
191
|
+
try {
|
|
192
|
+
return await runCommand("git", args, { cwd });
|
|
193
|
+
} catch (error) {
|
|
194
|
+
const message = error instanceof Error ? error.message : "Git command failed";
|
|
195
|
+
const stderr = (error == null ? void 0 : error.stderr) ?? "";
|
|
196
|
+
const detail = stderr.trim() || message;
|
|
197
|
+
throw new Error(detail);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function getRepoRoot(cwd) {
|
|
201
|
+
const result = await runGit(["rev-parse", "--show-toplevel"], cwd);
|
|
202
|
+
return result.stdout.trim();
|
|
203
|
+
}
|
|
204
|
+
async function isGitRepo(cwd) {
|
|
205
|
+
try {
|
|
206
|
+
const result = await runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
207
|
+
return result.stdout.trim() === "true";
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function extractPath(rawPath) {
|
|
213
|
+
const trimmed = rawPath.trim();
|
|
214
|
+
const arrowIndex = trimmed.indexOf("->");
|
|
215
|
+
if (arrowIndex === -1) {
|
|
216
|
+
return trimmed;
|
|
217
|
+
}
|
|
218
|
+
return trimmed.slice(arrowIndex + 2).trim();
|
|
219
|
+
}
|
|
220
|
+
function parseStatus(output) {
|
|
221
|
+
const staged = /* @__PURE__ */ new Set();
|
|
222
|
+
const unstaged = /* @__PURE__ */ new Set();
|
|
223
|
+
output.split("\n").map((line) => line.trimEnd()).filter(Boolean).forEach((line) => {
|
|
224
|
+
if (line.startsWith("??")) {
|
|
225
|
+
const filePath2 = extractPath(line.slice(2));
|
|
226
|
+
unstaged.add(filePath2);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const indexStatus = line[0];
|
|
230
|
+
const worktreeStatus = line[1];
|
|
231
|
+
const filePath = extractPath(line.slice(2));
|
|
232
|
+
if (indexStatus && indexStatus !== " ") {
|
|
233
|
+
staged.add(filePath);
|
|
234
|
+
}
|
|
235
|
+
if (worktreeStatus && worktreeStatus !== " ") {
|
|
236
|
+
unstaged.add(filePath);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
staged: Array.from(staged),
|
|
241
|
+
unstaged: Array.from(unstaged)
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
async function getStatus(repoRoot) {
|
|
245
|
+
const result = await runGit(["status", "--porcelain=v1"], repoRoot);
|
|
246
|
+
return parseStatus(result.stdout);
|
|
247
|
+
}
|
|
248
|
+
async function stageAll(repoRoot) {
|
|
249
|
+
await runGit(["add", "-A"], repoRoot);
|
|
250
|
+
}
|
|
251
|
+
async function getStagedDiff(repoRoot) {
|
|
252
|
+
const result = await runGit(["diff", "--cached"], repoRoot);
|
|
253
|
+
return result.stdout;
|
|
254
|
+
}
|
|
255
|
+
async function commitWithMessage(repoRoot, message) {
|
|
256
|
+
if (!message.includes("\n")) {
|
|
257
|
+
await runGit(["commit", "-m", message], repoRoot);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const tempDir = await import_promises2.default.mkdtemp(import_node_path2.default.join(import_node_os2.default.tmpdir(), "aicmt-"));
|
|
261
|
+
const tempPath = import_node_path2.default.join(tempDir, "commit-message.txt");
|
|
262
|
+
await import_promises2.default.writeFile(tempPath, message, "utf8");
|
|
263
|
+
try {
|
|
264
|
+
await runGit(["commit", "-F", tempPath], repoRoot);
|
|
265
|
+
} finally {
|
|
266
|
+
try {
|
|
267
|
+
await import_promises2.default.unlink(tempPath);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/openrouter.ts
|
|
274
|
+
var import_undici = require("undici");
|
|
275
|
+
var LARGE_NEW_FILE_LINE_LIMIT = 400;
|
|
276
|
+
var LARGE_NEW_FILE_HEAD_LINES = 120;
|
|
277
|
+
var LARGE_NEW_FILE_TAIL_LINES = 60;
|
|
278
|
+
function stripCodeFences(text) {
|
|
279
|
+
const trimmed = text.trim();
|
|
280
|
+
if (!trimmed.startsWith("```")) {
|
|
281
|
+
return trimmed;
|
|
282
|
+
}
|
|
283
|
+
return trimmed.replace(/^```[a-zA-Z]*\n?/, "").replace(/```$/, "").trim();
|
|
284
|
+
}
|
|
285
|
+
function parseJsonArray(text) {
|
|
286
|
+
const cleaned = stripCodeFences(text);
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(cleaned);
|
|
289
|
+
if (!Array.isArray(parsed)) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
const strings = parsed.map((entry) => typeof entry === "string" ? entry.trim() : "").filter(Boolean);
|
|
293
|
+
return strings.length ? strings : null;
|
|
294
|
+
} catch (error) {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function parseLines(text) {
|
|
299
|
+
return text.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*\d.\)\s]+/, "").trim()).filter(Boolean);
|
|
300
|
+
}
|
|
301
|
+
function splitDiffBlocks(diff) {
|
|
302
|
+
const lines = diff.split("\n");
|
|
303
|
+
const blocks = [];
|
|
304
|
+
let current = [];
|
|
305
|
+
for (const line of lines) {
|
|
306
|
+
if (line.startsWith("diff --git ")) {
|
|
307
|
+
if (current.length > 0) {
|
|
308
|
+
blocks.push(current);
|
|
309
|
+
}
|
|
310
|
+
current = [line];
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
current.push(line);
|
|
314
|
+
}
|
|
315
|
+
if (current.length > 0) {
|
|
316
|
+
blocks.push(current);
|
|
317
|
+
}
|
|
318
|
+
return blocks;
|
|
319
|
+
}
|
|
320
|
+
function isNewFileBlock(block) {
|
|
321
|
+
return block.some((line) => line.startsWith("new file mode")) || block.some((line) => line.startsWith("index 0000000.."));
|
|
322
|
+
}
|
|
323
|
+
function compressLargeNewFiles(diff) {
|
|
324
|
+
if (!diff.trim()) {
|
|
325
|
+
return diff;
|
|
326
|
+
}
|
|
327
|
+
const blocks = splitDiffBlocks(diff);
|
|
328
|
+
const compressed = blocks.map((block) => {
|
|
329
|
+
if (!isNewFileBlock(block)) {
|
|
330
|
+
return block;
|
|
331
|
+
}
|
|
332
|
+
const hunkStart = block.findIndex((line) => line.startsWith("@@"));
|
|
333
|
+
if (hunkStart === -1) {
|
|
334
|
+
return block;
|
|
335
|
+
}
|
|
336
|
+
const header = block.slice(0, hunkStart + 1);
|
|
337
|
+
const content = block.slice(hunkStart + 1);
|
|
338
|
+
const addedLineCount = content.filter(
|
|
339
|
+
(line) => line.startsWith("+") && !line.startsWith("+++")
|
|
340
|
+
).length;
|
|
341
|
+
if (addedLineCount <= LARGE_NEW_FILE_LINE_LIMIT) {
|
|
342
|
+
return block;
|
|
343
|
+
}
|
|
344
|
+
const head = content.slice(0, LARGE_NEW_FILE_HEAD_LINES);
|
|
345
|
+
const tail = content.slice(-LARGE_NEW_FILE_TAIL_LINES);
|
|
346
|
+
if (head.length + tail.length >= content.length) {
|
|
347
|
+
return block;
|
|
348
|
+
}
|
|
349
|
+
const omitted = content.length - head.length - tail.length;
|
|
350
|
+
const marker = `+... [truncated ${omitted} lines from large new file] ...`;
|
|
351
|
+
return [...header, ...head, marker, ...tail];
|
|
352
|
+
});
|
|
353
|
+
return compressed.flat().join("\n");
|
|
354
|
+
}
|
|
355
|
+
function normalizeMessages(messages) {
|
|
356
|
+
const unique = [];
|
|
357
|
+
for (const message of messages) {
|
|
358
|
+
if (!unique.includes(message)) {
|
|
359
|
+
unique.push(message);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (unique.length < 3) {
|
|
363
|
+
throw new Error("OpenRouter returned fewer than 3 messages");
|
|
364
|
+
}
|
|
365
|
+
return unique.slice(0, 3);
|
|
366
|
+
}
|
|
367
|
+
async function generateCommitMessages({
|
|
368
|
+
apiKey,
|
|
369
|
+
model,
|
|
370
|
+
instructions,
|
|
371
|
+
diff,
|
|
372
|
+
temperature,
|
|
373
|
+
maxTokens,
|
|
374
|
+
onDebug
|
|
375
|
+
}) {
|
|
376
|
+
var _a, _b, _c;
|
|
377
|
+
const trimmedDiff = diff.trim();
|
|
378
|
+
const diffText = trimmedDiff ? compressLargeNewFiles(trimmedDiff) : "[No diff available]";
|
|
379
|
+
const systemContent = [
|
|
380
|
+
"You generate git commit messages for staged changes.",
|
|
381
|
+
"Return ONLY a JSON array of exactly 3 strings.",
|
|
382
|
+
"Each string must be a commit message that matches the instructions.",
|
|
383
|
+
"Each option must summarize the full set of changes in this diff as a single commit.",
|
|
384
|
+
"Do not include any extra commentary or markdown.",
|
|
385
|
+
"",
|
|
386
|
+
"Instructions:",
|
|
387
|
+
instructions
|
|
388
|
+
].join("\n");
|
|
389
|
+
const prompt = diffText;
|
|
390
|
+
const payload = {
|
|
391
|
+
model,
|
|
392
|
+
messages: [
|
|
393
|
+
{
|
|
394
|
+
role: "system",
|
|
395
|
+
content: systemContent
|
|
396
|
+
},
|
|
397
|
+
{ role: "user", content: prompt }
|
|
398
|
+
]
|
|
399
|
+
};
|
|
400
|
+
if (typeof temperature === "number") {
|
|
401
|
+
payload.temperature = temperature;
|
|
402
|
+
}
|
|
403
|
+
if (typeof maxTokens === "number") {
|
|
404
|
+
payload.max_tokens = maxTokens;
|
|
405
|
+
}
|
|
406
|
+
onDebug == null ? void 0 : onDebug({ stage: "request", prompt, payload });
|
|
407
|
+
const response = await (0, import_undici.fetch)("https://openrouter.ai/api/v1/chat/completions", {
|
|
408
|
+
method: "POST",
|
|
409
|
+
headers: {
|
|
410
|
+
Authorization: `Bearer ${apiKey}`,
|
|
411
|
+
"Content-Type": "application/json",
|
|
412
|
+
"HTTP-Referer": "https://aicmt.local",
|
|
413
|
+
"X-Title": "aicmt"
|
|
414
|
+
},
|
|
415
|
+
body: JSON.stringify(payload)
|
|
416
|
+
});
|
|
417
|
+
const responseText = await response.text();
|
|
418
|
+
onDebug == null ? void 0 : onDebug({
|
|
419
|
+
stage: "response",
|
|
420
|
+
prompt,
|
|
421
|
+
payload,
|
|
422
|
+
responseText,
|
|
423
|
+
status: response.status
|
|
424
|
+
});
|
|
425
|
+
if (!response.ok) {
|
|
426
|
+
throw new Error(`OpenRouter error: ${response.status} ${responseText}`);
|
|
427
|
+
}
|
|
428
|
+
let data;
|
|
429
|
+
try {
|
|
430
|
+
data = JSON.parse(responseText);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
throw new Error("OpenRouter returned invalid JSON");
|
|
433
|
+
}
|
|
434
|
+
const content = ((_c = (_b = (_a = data.choices) == null ? void 0 : _a[0]) == null ? void 0 : _b.message) == null ? void 0 : _c.content) ?? "";
|
|
435
|
+
if (!content) {
|
|
436
|
+
throw new Error("OpenRouter returned empty content");
|
|
437
|
+
}
|
|
438
|
+
const jsonMessages = parseJsonArray(content);
|
|
439
|
+
if (jsonMessages) {
|
|
440
|
+
return normalizeMessages(jsonMessages);
|
|
441
|
+
}
|
|
442
|
+
const lineMessages = parseLines(content);
|
|
443
|
+
return normalizeMessages(lineMessages);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/commands/commit.ts
|
|
447
|
+
var promptOptions = {
|
|
448
|
+
onCancel: () => {
|
|
449
|
+
throw new Error("Cancelled");
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
async function runCommit({
|
|
453
|
+
cwd,
|
|
454
|
+
configPath,
|
|
455
|
+
dryRun = false,
|
|
456
|
+
verbose = false,
|
|
457
|
+
yes = false
|
|
458
|
+
}) {
|
|
459
|
+
const isRepo = await isGitRepo(cwd);
|
|
460
|
+
if (!isRepo) {
|
|
461
|
+
throw new Error("Not a git repository. Run inside a git project.");
|
|
462
|
+
}
|
|
463
|
+
const repoRoot = await getRepoRoot(cwd);
|
|
464
|
+
const resolvedConfigPath = resolveConfigPath(repoRoot, configPath);
|
|
465
|
+
const globalConfig = await loadGlobalConfig(resolvedConfigPath);
|
|
466
|
+
const config = resolveProjectConfig(globalConfig, repoRoot);
|
|
467
|
+
let status = await getStatus(repoRoot);
|
|
468
|
+
if (status.staged.length === 0 && status.unstaged.length === 0) {
|
|
469
|
+
throw new Error("No changes to commit.");
|
|
470
|
+
}
|
|
471
|
+
if (status.unstaged.length > 0) {
|
|
472
|
+
if (yes) {
|
|
473
|
+
await stageAll(repoRoot);
|
|
474
|
+
} else {
|
|
475
|
+
const { stage } = await (0, import_prompts.default)(
|
|
476
|
+
{
|
|
477
|
+
type: "confirm",
|
|
478
|
+
name: "stage",
|
|
479
|
+
message: "Unstaged changes detected. Stage all changes?",
|
|
480
|
+
initial: true
|
|
481
|
+
},
|
|
482
|
+
promptOptions
|
|
483
|
+
);
|
|
484
|
+
if (!stage) {
|
|
485
|
+
throw new Error("Aborted: commit requires all changes to be staged.");
|
|
486
|
+
}
|
|
487
|
+
await stageAll(repoRoot);
|
|
488
|
+
}
|
|
489
|
+
status = await getStatus(repoRoot);
|
|
490
|
+
}
|
|
491
|
+
if (status.staged.length === 0) {
|
|
492
|
+
throw new Error("No staged changes to commit.");
|
|
493
|
+
}
|
|
494
|
+
const diff = await getStagedDiff(repoRoot);
|
|
495
|
+
const debugInfo = {};
|
|
496
|
+
const messages = await generateCommitMessages({
|
|
497
|
+
apiKey: config.openrouterApiKey,
|
|
498
|
+
model: config.model,
|
|
499
|
+
instructions: config.instructions,
|
|
500
|
+
diff,
|
|
501
|
+
temperature: config.temperature,
|
|
502
|
+
maxTokens: config.maxTokens,
|
|
503
|
+
onDebug: (info) => {
|
|
504
|
+
if (info.stage === "request") {
|
|
505
|
+
debugInfo.request = info;
|
|
506
|
+
} else {
|
|
507
|
+
debugInfo.response = info;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
if (verbose) {
|
|
512
|
+
if (debugInfo.request) {
|
|
513
|
+
console.log("[aicmt] AI request payload:");
|
|
514
|
+
console.log(JSON.stringify(debugInfo.request.payload, null, 2));
|
|
515
|
+
console.log("[aicmt] AI request prompt:");
|
|
516
|
+
console.log(debugInfo.request.prompt);
|
|
517
|
+
}
|
|
518
|
+
if (debugInfo.response) {
|
|
519
|
+
const status2 = debugInfo.response.status ?? "unknown";
|
|
520
|
+
console.log(`[aicmt] AI response (status ${status2}):`);
|
|
521
|
+
console.log(debugInfo.response.responseText ?? "");
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
let finalMessage = messages[0] ?? "";
|
|
525
|
+
if (!yes) {
|
|
526
|
+
const choicePrompt = await (0, import_prompts.default)(
|
|
527
|
+
{
|
|
528
|
+
type: "select",
|
|
529
|
+
name: "selection",
|
|
530
|
+
message: "Choose a commit message",
|
|
531
|
+
choices: [
|
|
532
|
+
...messages.map((message, index) => ({
|
|
533
|
+
title: message,
|
|
534
|
+
value: message,
|
|
535
|
+
description: `Option ${index + 1}`
|
|
536
|
+
})),
|
|
537
|
+
{ title: "Custom message", value: "__custom", description: "Write your own" },
|
|
538
|
+
{ title: "Abort", value: "__abort", description: "Cancel commit" }
|
|
539
|
+
]
|
|
540
|
+
},
|
|
541
|
+
promptOptions
|
|
542
|
+
);
|
|
543
|
+
if (!choicePrompt.selection || choicePrompt.selection === "__abort") {
|
|
544
|
+
console.log("Commit cancelled.");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
finalMessage = String(choicePrompt.selection);
|
|
548
|
+
if (choicePrompt.selection === "__custom") {
|
|
549
|
+
const { customMessage } = await (0, import_prompts.default)(
|
|
550
|
+
{
|
|
551
|
+
type: "text",
|
|
552
|
+
name: "customMessage",
|
|
553
|
+
message: "Enter commit message",
|
|
554
|
+
validate: (value) => value.trim().length > 0 ? true : "Commit message is required."
|
|
555
|
+
},
|
|
556
|
+
promptOptions
|
|
557
|
+
);
|
|
558
|
+
finalMessage = String(customMessage || "").trim();
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (!finalMessage) {
|
|
562
|
+
throw new Error("Commit message is empty.");
|
|
563
|
+
}
|
|
564
|
+
if (!yes) {
|
|
565
|
+
const { confirm } = await (0, import_prompts.default)(
|
|
566
|
+
{
|
|
567
|
+
type: "confirm",
|
|
568
|
+
name: "confirm",
|
|
569
|
+
message: `Commit with message:
|
|
570
|
+
${finalMessage}
|
|
571
|
+
Proceed?`,
|
|
572
|
+
initial: true
|
|
573
|
+
},
|
|
574
|
+
promptOptions
|
|
575
|
+
);
|
|
576
|
+
if (!confirm) {
|
|
577
|
+
console.log("Commit cancelled.");
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (dryRun) {
|
|
582
|
+
console.log(`[dry-run] ${finalMessage}`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
await commitWithMessage(repoRoot, finalMessage);
|
|
586
|
+
console.log(`Commit created: ${finalMessage}`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// src/commands/init.ts
|
|
590
|
+
var import_prompts2 = __toESM(require("prompts"));
|
|
591
|
+
var formatChoices = [
|
|
592
|
+
{
|
|
593
|
+
value: "conventional-scope",
|
|
594
|
+
title: "Conventional Commits (type(scope): subject)",
|
|
595
|
+
description: "Best for structured history with scopes.",
|
|
596
|
+
instructions: [
|
|
597
|
+
"Use Conventional Commits: type(scope): subject.",
|
|
598
|
+
"Allowed types: feat, fix, docs, style, refactor, perf, test, chore, ci, build.",
|
|
599
|
+
"Subject <= 72 chars, imperative mood, no trailing period."
|
|
600
|
+
].join("\n")
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
value: "conventional",
|
|
604
|
+
title: "Conventional Commits (type: subject)",
|
|
605
|
+
description: "Structured without scopes.",
|
|
606
|
+
instructions: [
|
|
607
|
+
"Use Conventional Commits: type: subject.",
|
|
608
|
+
"Allowed types: feat, fix, docs, style, refactor, perf, test, chore, ci, build.",
|
|
609
|
+
"Subject <= 72 chars, imperative mood, no trailing period."
|
|
610
|
+
].join("\n")
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
value: "short",
|
|
614
|
+
title: "Short imperative summary",
|
|
615
|
+
description: "Simple single-line format.",
|
|
616
|
+
instructions: [
|
|
617
|
+
"Single-line summary, imperative mood, <= 72 chars, no trailing period."
|
|
618
|
+
].join("\n")
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
value: "detailed",
|
|
622
|
+
title: "Detailed subject + body",
|
|
623
|
+
description: "Subject line plus body details.",
|
|
624
|
+
instructions: [
|
|
625
|
+
"Subject <= 72 chars, imperative mood, no trailing period.",
|
|
626
|
+
"Blank line.",
|
|
627
|
+
"Body with bullet list of key changes (wrap at 100 chars)."
|
|
628
|
+
].join("\n")
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
value: "custom",
|
|
632
|
+
title: "Custom format",
|
|
633
|
+
description: "Provide your own instructions."
|
|
634
|
+
}
|
|
635
|
+
];
|
|
636
|
+
var promptOptions2 = {
|
|
637
|
+
onCancel: () => {
|
|
638
|
+
throw new Error("Cancelled");
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
function hasGlobalDefaults(config) {
|
|
642
|
+
return Boolean(
|
|
643
|
+
config.model || config.format || config.instructions || typeof config.temperature === "number" || typeof config.maxTokens === "number"
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
async function runInit({ cwd, configPath }) {
|
|
647
|
+
var _a;
|
|
648
|
+
const isRepo = await isGitRepo(cwd);
|
|
649
|
+
if (!isRepo) {
|
|
650
|
+
throw new Error("Not a git repository. Run inside a git project.");
|
|
651
|
+
}
|
|
652
|
+
const repoRoot = await getRepoRoot(cwd);
|
|
653
|
+
const resolvedConfigPath = resolveConfigPath(repoRoot, configPath);
|
|
654
|
+
const existingConfig = await loadGlobalConfig(resolvedConfigPath, {
|
|
655
|
+
allowMissing: true
|
|
656
|
+
});
|
|
657
|
+
const { format } = await (0, import_prompts2.default)(
|
|
658
|
+
{
|
|
659
|
+
type: "select",
|
|
660
|
+
name: "format",
|
|
661
|
+
message: "Choose commit message format",
|
|
662
|
+
choices: formatChoices.map((choice) => ({
|
|
663
|
+
title: choice.title,
|
|
664
|
+
value: choice.value,
|
|
665
|
+
description: choice.description
|
|
666
|
+
})),
|
|
667
|
+
initial: 0
|
|
668
|
+
},
|
|
669
|
+
promptOptions2
|
|
670
|
+
);
|
|
671
|
+
const selected = formatChoices.find((choice) => choice.value === format);
|
|
672
|
+
if (!selected) {
|
|
673
|
+
throw new Error("Invalid format selection.");
|
|
674
|
+
}
|
|
675
|
+
let baseInstructions = selected.instructions ?? "";
|
|
676
|
+
if (selected.value === "custom") {
|
|
677
|
+
const { customInstructions } = await (0, import_prompts2.default)(
|
|
678
|
+
{
|
|
679
|
+
type: "text",
|
|
680
|
+
name: "customInstructions",
|
|
681
|
+
message: "Describe the commit message format",
|
|
682
|
+
validate: (value) => value.trim().length > 0 ? true : "Please enter instructions."
|
|
683
|
+
},
|
|
684
|
+
promptOptions2
|
|
685
|
+
);
|
|
686
|
+
baseInstructions = String(customInstructions || "").trim();
|
|
687
|
+
}
|
|
688
|
+
const { extraInstructions } = await (0, import_prompts2.default)(
|
|
689
|
+
{
|
|
690
|
+
type: "text",
|
|
691
|
+
name: "extraInstructions",
|
|
692
|
+
message: "Additional instructions (optional)",
|
|
693
|
+
initial: ""
|
|
694
|
+
},
|
|
695
|
+
promptOptions2
|
|
696
|
+
);
|
|
697
|
+
const extra = String(extraInstructions || "").trim();
|
|
698
|
+
const instructions = extra ? `${baseInstructions}
|
|
699
|
+
${extra}` : baseInstructions;
|
|
700
|
+
const { model } = await (0, import_prompts2.default)(
|
|
701
|
+
{
|
|
702
|
+
type: "text",
|
|
703
|
+
name: "model",
|
|
704
|
+
message: "OpenRouter model",
|
|
705
|
+
initial: DEFAULT_MODEL,
|
|
706
|
+
validate: (value) => value.trim().length > 0 ? true : "Model is required."
|
|
707
|
+
},
|
|
708
|
+
promptOptions2
|
|
709
|
+
);
|
|
710
|
+
const { temperature } = await (0, import_prompts2.default)(
|
|
711
|
+
{
|
|
712
|
+
type: "number",
|
|
713
|
+
name: "temperature",
|
|
714
|
+
message: "Temperature (0-2)",
|
|
715
|
+
initial: DEFAULT_TEMPERATURE,
|
|
716
|
+
min: 0,
|
|
717
|
+
max: 2,
|
|
718
|
+
float: true
|
|
719
|
+
},
|
|
720
|
+
promptOptions2
|
|
721
|
+
);
|
|
722
|
+
const { maxTokens } = await (0, import_prompts2.default)(
|
|
723
|
+
{
|
|
724
|
+
type: "number",
|
|
725
|
+
name: "maxTokens",
|
|
726
|
+
message: `Max tokens (${MIN_OUTPUT_TOKENS}-${MAX_OUTPUT_TOKENS})`,
|
|
727
|
+
initial: DEFAULT_MAX_TOKENS,
|
|
728
|
+
min: MIN_OUTPUT_TOKENS,
|
|
729
|
+
max: MAX_OUTPUT_TOKENS
|
|
730
|
+
},
|
|
731
|
+
promptOptions2
|
|
732
|
+
);
|
|
733
|
+
const { scope } = await (0, import_prompts2.default)(
|
|
734
|
+
{
|
|
735
|
+
type: "select",
|
|
736
|
+
name: "scope",
|
|
737
|
+
message: "Apply settings to",
|
|
738
|
+
choices: [
|
|
739
|
+
{
|
|
740
|
+
title: "All projects (global defaults)",
|
|
741
|
+
value: "global",
|
|
742
|
+
description: "Used when a repo has no override"
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
title: "This repo only (override)",
|
|
746
|
+
value: "project",
|
|
747
|
+
description: "Use custom settings for this repository"
|
|
748
|
+
}
|
|
749
|
+
],
|
|
750
|
+
initial: 0
|
|
751
|
+
},
|
|
752
|
+
promptOptions2
|
|
753
|
+
);
|
|
754
|
+
const targetScope = scope === "project" ? "project" : "global";
|
|
755
|
+
if (targetScope === "global" && hasGlobalDefaults(existingConfig)) {
|
|
756
|
+
const { overwrite } = await (0, import_prompts2.default)(
|
|
757
|
+
{
|
|
758
|
+
type: "confirm",
|
|
759
|
+
name: "overwrite",
|
|
760
|
+
message: "Global defaults already exist. Overwrite?",
|
|
761
|
+
initial: false
|
|
762
|
+
},
|
|
763
|
+
promptOptions2
|
|
764
|
+
);
|
|
765
|
+
if (!overwrite) {
|
|
766
|
+
console.log("Init cancelled.");
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (targetScope === "project" && ((_a = existingConfig.projects) == null ? void 0 : _a[repoRoot])) {
|
|
771
|
+
const { overwrite } = await (0, import_prompts2.default)(
|
|
772
|
+
{
|
|
773
|
+
type: "confirm",
|
|
774
|
+
name: "overwrite",
|
|
775
|
+
message: "Config already exists for this repo. Overwrite?",
|
|
776
|
+
initial: false
|
|
777
|
+
},
|
|
778
|
+
promptOptions2
|
|
779
|
+
);
|
|
780
|
+
if (!overwrite) {
|
|
781
|
+
console.log("Init cancelled.");
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
let globalApiKey = existingConfig.openrouterApiKey ?? "";
|
|
786
|
+
if (globalApiKey) {
|
|
787
|
+
const { reuseKey } = await (0, import_prompts2.default)(
|
|
788
|
+
{
|
|
789
|
+
type: "confirm",
|
|
790
|
+
name: "reuseKey",
|
|
791
|
+
message: "Use existing global OpenRouter API key?",
|
|
792
|
+
initial: true
|
|
793
|
+
},
|
|
794
|
+
promptOptions2
|
|
795
|
+
);
|
|
796
|
+
if (!reuseKey) {
|
|
797
|
+
const { apiKey } = await (0, import_prompts2.default)(
|
|
798
|
+
{
|
|
799
|
+
type: "password",
|
|
800
|
+
name: "apiKey",
|
|
801
|
+
message: "OpenRouter API key",
|
|
802
|
+
validate: (value) => value.trim().length > 0 ? true : "API key is required."
|
|
803
|
+
},
|
|
804
|
+
promptOptions2
|
|
805
|
+
);
|
|
806
|
+
globalApiKey = String(apiKey || "").trim();
|
|
807
|
+
}
|
|
808
|
+
} else {
|
|
809
|
+
const { apiKey } = await (0, import_prompts2.default)(
|
|
810
|
+
{
|
|
811
|
+
type: "password",
|
|
812
|
+
name: "apiKey",
|
|
813
|
+
message: "OpenRouter API key",
|
|
814
|
+
validate: (value) => value.trim().length > 0 ? true : "API key is required."
|
|
815
|
+
},
|
|
816
|
+
promptOptions2
|
|
817
|
+
);
|
|
818
|
+
globalApiKey = String(apiKey || "").trim();
|
|
819
|
+
}
|
|
820
|
+
const projectConfig = {
|
|
821
|
+
model: String(model || DEFAULT_MODEL).trim(),
|
|
822
|
+
format: String(format || "custom"),
|
|
823
|
+
instructions: instructions.trim(),
|
|
824
|
+
temperature: typeof temperature === "number" && !Number.isNaN(temperature) ? temperature : DEFAULT_TEMPERATURE,
|
|
825
|
+
maxTokens: typeof maxTokens === "number" && !Number.isNaN(maxTokens) ? Math.round(maxTokens) : DEFAULT_MAX_TOKENS
|
|
826
|
+
};
|
|
827
|
+
const updatedConfig = {
|
|
828
|
+
...existingConfig,
|
|
829
|
+
projects: {
|
|
830
|
+
...existingConfig.projects ?? {}
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
updatedConfig.openrouterApiKey = globalApiKey;
|
|
834
|
+
if (targetScope === "global") {
|
|
835
|
+
updatedConfig.model = projectConfig.model;
|
|
836
|
+
updatedConfig.format = projectConfig.format;
|
|
837
|
+
updatedConfig.instructions = projectConfig.instructions;
|
|
838
|
+
updatedConfig.temperature = projectConfig.temperature;
|
|
839
|
+
updatedConfig.maxTokens = projectConfig.maxTokens;
|
|
840
|
+
} else {
|
|
841
|
+
updatedConfig.projects[repoRoot] = projectConfig;
|
|
842
|
+
}
|
|
843
|
+
await saveGlobalConfig(resolvedConfigPath, updatedConfig);
|
|
844
|
+
console.log(`Config saved to ${resolvedConfigPath}`);
|
|
845
|
+
if (targetScope === "global") {
|
|
846
|
+
console.log("Applied as global defaults for all projects.");
|
|
847
|
+
} else {
|
|
848
|
+
console.log(`Applied as override for ${repoRoot}.`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// src/cli.ts
|
|
853
|
+
async function run(argv = process.argv) {
|
|
854
|
+
const program = new import_commander.Command();
|
|
855
|
+
program.name("aicmt").description("AI-assisted git commits via OpenRouter").version("0.1.0");
|
|
856
|
+
program.command("init").description("Initialize aicmt in this repository").option("-c, --config <path>", "Path to global config file").action(async (options) => {
|
|
857
|
+
await runInit({ cwd: process.cwd(), configPath: options.config });
|
|
858
|
+
});
|
|
859
|
+
program.command("commit", { isDefault: true }).description("Generate and create a commit for staged changes").option("-c, --config <path>", "Path to global config file").option("--dry-run", "Show the chosen message without committing", false).option("-v, --verbose", "Show AI request and response logs", false).option("-y, --yes", "Skip prompts: stage all, pick first message", false).action(
|
|
860
|
+
async (options) => {
|
|
861
|
+
await runCommit({
|
|
862
|
+
cwd: process.cwd(),
|
|
863
|
+
configPath: options.config,
|
|
864
|
+
dryRun: Boolean(options.dryRun),
|
|
865
|
+
verbose: Boolean(options.verbose),
|
|
866
|
+
yes: Boolean(options.yes)
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
);
|
|
870
|
+
await program.parseAsync(argv);
|
|
871
|
+
}
|
|
872
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
873
|
+
0 && (module.exports = {
|
|
874
|
+
run
|
|
875
|
+
});
|
|
876
|
+
//# sourceMappingURL=cli.js.map
|