@kud/ai-conventional-commit-cli 3.2.3 → 3.2.5
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/dist/{chunk-IWJLYYKM.js → chunk-EHJXGWTJ.js} +3 -19
- package/dist/index.cjs +1200 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.js +59 -43
- package/dist/{reword-3MH2DYFS.js → reword-BKQ7K33J.js} +1 -1
- package/dist/{reword-3JBE7MPQ.js → reword-QQO7UBGM.js} +2 -0
- package/package.json +1 -1
- package/dist/chunk-2WRUFO3O.js +0 -689
- package/dist/chunk-2X3JJVRR.js +0 -639
- package/dist/chunk-7FBRAH4R.js +0 -643
- package/dist/chunk-7U4J2ORD.js +0 -614
- package/dist/chunk-CC3NIT53.js +0 -650
- package/dist/chunk-CLQ6OPLU.js +0 -668
- package/dist/chunk-F3BOAVBY.js +0 -122
- package/dist/chunk-FYJNHXAR.js +0 -700
- package/dist/chunk-H4W6AMGZ.js +0 -549
- package/dist/chunk-HJR5M6U7.js +0 -120
- package/dist/chunk-HOUMTU6H.js +0 -699
- package/dist/chunk-KEEMHNNS.js +0 -628
- package/dist/chunk-OLEHSPCO.js +0 -707
- package/dist/chunk-RHXNXVGI.js +0 -621
- package/dist/chunk-SNV4RWS4.js +0 -696
- package/dist/chunk-W7OC77AV.js +0 -649
- package/dist/chunk-WFXVVHL2.js +0 -645
- package/dist/chunk-YIXP5EWA.js +0 -545
- package/dist/chunk-YRVQGOVW.js +0 -649
- package/dist/chunk-ZLYMV2NJ.js +0 -644
- package/dist/config-C3S4LWLD.js +0 -12
- package/dist/config-RHGCFLHQ.js +0 -12
- package/dist/reword-2ASH5EH5.js +0 -212
- package/dist/reword-6EWRZ6WZ.js +0 -212
- package/dist/reword-CZDYMQEV.js +0 -150
- package/dist/reword-D5YVSCPO.js +0 -212
- package/dist/reword-ESY2TLBT.js +0 -212
- package/dist/reword-FE5N4MGV.js +0 -150
- package/dist/reword-IN2D2J4H.js +0 -212
- package/dist/reword-JRE6KAF7.js +0 -212
- package/dist/reword-KIR2DMTO.js +0 -212
- package/dist/reword-KUE3IVBE.js +0 -212
- package/dist/reword-LIVSGNUN.js +0 -212
- package/dist/reword-MCQOCOZ2.js +0 -212
- package/dist/reword-NGEKVTD6.js +0 -212
- package/dist/reword-PEOUOAT7.js +0 -212
- package/dist/reword-Q7MES34W.js +0 -212
- package/dist/reword-T44WTP5I.js +0 -212
- package/dist/reword-UA3EG7DK.js +0 -212
- package/dist/reword-UE5IP5V3.js +0 -212
- package/dist/reword-VKG5G6CB.js +0 -212
- package/dist/reword-VRH7B6BE.js +0 -205
- package/dist/reword-WFCNTOEU.js +0 -203
- package/dist/reword-XWYWVQRZ.js +0 -212
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/index.ts
|
|
27
|
+
var import_clipanion = require("clipanion");
|
|
28
|
+
|
|
29
|
+
// src/workflow/generate.ts
|
|
30
|
+
var import_chalk2 = __toESM(require("chalk"), 1);
|
|
31
|
+
var import_ora = __toESM(require("ora"), 1);
|
|
32
|
+
|
|
33
|
+
// src/git.ts
|
|
34
|
+
var import_simple_git = require("simple-git");
|
|
35
|
+
var import_node_crypto = __toESM(require("crypto"), 1);
|
|
36
|
+
var git = (0, import_simple_git.simpleGit)();
|
|
37
|
+
var ensureStagedChanges = async () => {
|
|
38
|
+
const status = await git.status();
|
|
39
|
+
return status.staged.length > 0;
|
|
40
|
+
};
|
|
41
|
+
var getStagedDiffRaw = async () => {
|
|
42
|
+
return git.diff(["--cached", "--unified=3", "--no-color"]);
|
|
43
|
+
};
|
|
44
|
+
var HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/;
|
|
45
|
+
var parseDiffFromRaw = (raw) => {
|
|
46
|
+
if (!raw.trim()) return [];
|
|
47
|
+
const lines = raw.split("\n");
|
|
48
|
+
const files = [];
|
|
49
|
+
let currentFile = null;
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
if (line.startsWith("diff --git a/")) {
|
|
52
|
+
const pathMatch = line.match(/diff --git a\/(.+?) b\/(.+)$/);
|
|
53
|
+
if (pathMatch) {
|
|
54
|
+
const file = pathMatch[2];
|
|
55
|
+
currentFile = { file, hunks: [], additions: 0, deletions: 0 };
|
|
56
|
+
files.push(currentFile);
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (line.startsWith("diff --git")) continue;
|
|
61
|
+
if (line.startsWith("index ")) continue;
|
|
62
|
+
if (line.startsWith("--- ")) continue;
|
|
63
|
+
if (line.startsWith("+++ ")) continue;
|
|
64
|
+
if (line.startsWith("@@")) {
|
|
65
|
+
if (!currentFile) continue;
|
|
66
|
+
const m = line.match(HUNK_HEADER_RE);
|
|
67
|
+
if (!m) continue;
|
|
68
|
+
const from = parseInt(m[1], 10);
|
|
69
|
+
const fromLen = parseInt(m[2] || "1", 10);
|
|
70
|
+
const to = parseInt(m[3], 10);
|
|
71
|
+
const toLen = parseInt(m[4] || "1", 10);
|
|
72
|
+
const ctx = m[5]?.trim() || "";
|
|
73
|
+
currentFile.hunks.push({
|
|
74
|
+
file: currentFile.file,
|
|
75
|
+
header: line,
|
|
76
|
+
from,
|
|
77
|
+
to,
|
|
78
|
+
added: toLen,
|
|
79
|
+
removed: fromLen,
|
|
80
|
+
lines: [],
|
|
81
|
+
hash: "",
|
|
82
|
+
functionContext: ctx || void 0
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (currentFile && currentFile.hunks.length) {
|
|
87
|
+
const hunk = currentFile.hunks[currentFile.hunks.length - 1];
|
|
88
|
+
hunk.lines.push(line);
|
|
89
|
+
if (line.startsWith("+") && !line.startsWith("+++")) currentFile.additions++;
|
|
90
|
+
if (line.startsWith("-") && !line.startsWith("---")) currentFile.deletions++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const f of files) {
|
|
94
|
+
for (const h of f.hunks) {
|
|
95
|
+
h.hash = import_node_crypto.default.createHash("sha1").update(f.file + h.header + h.lines.join("\n")).digest("hex").slice(0, 8);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return files;
|
|
99
|
+
};
|
|
100
|
+
var parseDiff = async () => {
|
|
101
|
+
const raw = await getStagedDiffRaw();
|
|
102
|
+
return parseDiffFromRaw(raw);
|
|
103
|
+
};
|
|
104
|
+
var getRecentCommitMessages = async (limit) => {
|
|
105
|
+
const log = await git.log({ maxCount: limit });
|
|
106
|
+
return log.all.map((e) => e.message);
|
|
107
|
+
};
|
|
108
|
+
var createCommit = async (title, body) => {
|
|
109
|
+
if (body) {
|
|
110
|
+
await git.commit([title, body].join("\n\n"));
|
|
111
|
+
} else {
|
|
112
|
+
await git.commit(title);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// src/style.ts
|
|
117
|
+
var buildStyleProfile = (messages) => {
|
|
118
|
+
if (!messages.length) {
|
|
119
|
+
return {
|
|
120
|
+
tense: "imperative",
|
|
121
|
+
avgTitleLength: 50,
|
|
122
|
+
usesScopes: false,
|
|
123
|
+
gitmojiRatio: 0,
|
|
124
|
+
topPrefixes: [],
|
|
125
|
+
conventionalRatio: 0
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const titles = messages.map((m) => m.split("\n")[0]);
|
|
129
|
+
const avgTitleLength = titles.reduce((a, c) => a + c.length, 0) / Math.max(1, titles.length);
|
|
130
|
+
const gitmojiCount = titles.filter((t) => /[\u{1F300}-\u{1FAFF}]/u.test(t)).length;
|
|
131
|
+
const usesScopesCount = titles.filter((t) => /^\w+\(.+\):/.test(t)).length;
|
|
132
|
+
const conventionalCount = titles.filter(
|
|
133
|
+
(t) => /^(feat|fix|chore|docs|refactor|test|ci|perf|style)(\(.+\))?: /.test(t)
|
|
134
|
+
).length;
|
|
135
|
+
const prefixes = /* @__PURE__ */ new Map();
|
|
136
|
+
for (const t of titles) {
|
|
137
|
+
const m = t.match(/^(\w+)(\(.+\))?:/);
|
|
138
|
+
if (m) prefixes.set(m[1], (prefixes.get(m[1]) || 0) + 1);
|
|
139
|
+
}
|
|
140
|
+
const topPrefixes = [...prefixes.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k]) => k);
|
|
141
|
+
return {
|
|
142
|
+
tense: "imperative",
|
|
143
|
+
avgTitleLength,
|
|
144
|
+
usesScopes: usesScopesCount / titles.length > 0.25,
|
|
145
|
+
gitmojiRatio: gitmojiCount / titles.length,
|
|
146
|
+
topPrefixes,
|
|
147
|
+
conventionalRatio: conventionalCount / titles.length
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// src/prompt.ts
|
|
152
|
+
var summarizeDiffForPrompt = (files, privacy) => {
|
|
153
|
+
if (privacy === "high") {
|
|
154
|
+
return files.map((f) => `file: ${f.file} (+${f.additions} -${f.deletions}) hunks:${f.hunks.length}`).join("\n");
|
|
155
|
+
}
|
|
156
|
+
if (privacy === "medium") {
|
|
157
|
+
return files.map(
|
|
158
|
+
(f) => `file: ${f.file}
|
|
159
|
+
` + f.hunks.map(
|
|
160
|
+
(h) => ` hunk ${h.hash} context:${h.functionContext || ""} +${h.added} -${h.removed}`
|
|
161
|
+
).join("\n")
|
|
162
|
+
).join("\n");
|
|
163
|
+
}
|
|
164
|
+
return files.map(
|
|
165
|
+
(f) => `file: ${f.file}
|
|
166
|
+
` + f.hunks.map(
|
|
167
|
+
(h) => `${h.header}
|
|
168
|
+
${h.lines.slice(0, 40).join("\n")}${h.lines.length > 40 ? "\n[truncated]" : ""}`
|
|
169
|
+
).join("\n")
|
|
170
|
+
).join("\n");
|
|
171
|
+
};
|
|
172
|
+
var buildGenerationMessages = (opts) => {
|
|
173
|
+
const { files, style, config, mode, desiredCommits } = opts;
|
|
174
|
+
const diff = summarizeDiffForPrompt(files, config.privacy);
|
|
175
|
+
const TYPE_MAP = {
|
|
176
|
+
feat: "A new feature or capability added for the user",
|
|
177
|
+
fix: "A bug fix resolving incorrect behavior",
|
|
178
|
+
chore: "Internal change with no user-facing impact",
|
|
179
|
+
docs: "Documentation-only changes",
|
|
180
|
+
refactor: "Code change that neither fixes a bug nor adds a feature",
|
|
181
|
+
test: "Adding or improving tests only",
|
|
182
|
+
ci: "Changes to CI configuration or scripts",
|
|
183
|
+
perf: "Performance improvement",
|
|
184
|
+
style: "Formatting or stylistic change (no logic)",
|
|
185
|
+
build: "Build system or dependency changes",
|
|
186
|
+
revert: "Revert a previous commit",
|
|
187
|
+
merge: "Merge branches (rare; only if truly a merge commit)",
|
|
188
|
+
security: "Security-related change or hardening",
|
|
189
|
+
release: "Version bump or release meta change"
|
|
190
|
+
};
|
|
191
|
+
const specLines = [];
|
|
192
|
+
specLines.push(
|
|
193
|
+
"Purpose: Generate high-quality Conventional Commit messages for the provided git diff."
|
|
194
|
+
);
|
|
195
|
+
specLines.push("Locale: en");
|
|
196
|
+
specLines.push(
|
|
197
|
+
'Output JSON Schema: { "commits": [ { "title": string, "body": string, "score": 0-100, "reasons": string[] } ], "meta": { "splitRecommended": boolean } }'
|
|
198
|
+
);
|
|
199
|
+
specLines.push("Primary Output Field: commits[ ].title");
|
|
200
|
+
specLines.push("Title Format: <type>(<optional-scope>): <subject>");
|
|
201
|
+
specLines.push("Max Title Length: 72 characters (hard limit)");
|
|
202
|
+
specLines.push("Types (JSON mapping follows on next line)");
|
|
203
|
+
specLines.push("TypeMap: " + JSON.stringify(TYPE_MAP));
|
|
204
|
+
specLines.push("Scope Rules: optional; if present, lowercase kebab-case; omit when unclear.");
|
|
205
|
+
specLines.push(
|
|
206
|
+
"Subject Rules: imperative mood, present tense, no leading capital unless proper noun, no trailing period."
|
|
207
|
+
);
|
|
208
|
+
specLines.push("Length Rule: Entire title line (including type/scope) must be <= 72 chars.");
|
|
209
|
+
specLines.push(
|
|
210
|
+
"Emoji Rule: " + (config.gitmoji ? "OPTIONAL single leading gitmoji BEFORE the type only if confidently adds clarity; do not invent or stack; omit if unsure." : "Disallow all emojis and gitmoji codes; output must start directly with the type.")
|
|
211
|
+
);
|
|
212
|
+
specLines.push(
|
|
213
|
+
"Forbidden: breaking changes notation, exclamation mark after type unless truly semver-major (avoid unless diff clearly indicates)."
|
|
214
|
+
);
|
|
215
|
+
specLines.push("Fallback Type: use chore when no other type clearly fits.");
|
|
216
|
+
specLines.push("Consistency: prefer existing top prefixes: " + style.topPrefixes.join(", "));
|
|
217
|
+
specLines.push("Provide score (0-100) measuring clarity & specificity (higher is better).");
|
|
218
|
+
specLines.push(
|
|
219
|
+
"Provide reasons array citing concrete diff elements: filenames, functions, tests, metrics."
|
|
220
|
+
);
|
|
221
|
+
specLines.push("Return ONLY the JSON object. No surrounding text or markdown.");
|
|
222
|
+
specLines.push("Do not add fields not listed in schema.");
|
|
223
|
+
specLines.push("Never fabricate content not present or implied by the diff.");
|
|
224
|
+
specLines.push(
|
|
225
|
+
"If mode is split and multiple logical changes exist, set meta.splitRecommended=true."
|
|
226
|
+
);
|
|
227
|
+
return [
|
|
228
|
+
{
|
|
229
|
+
role: "system",
|
|
230
|
+
content: specLines.join("\n")
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
role: "user",
|
|
234
|
+
content: `Mode: ${mode}
|
|
235
|
+
RequestedCommitCount: ${desiredCommits || (mode === "split" ? "2-6" : 1)}
|
|
236
|
+
StyleFingerprint: ${JSON.stringify(style)}
|
|
237
|
+
Diff:
|
|
238
|
+
${diff}
|
|
239
|
+
Generate commit candidates now.`
|
|
240
|
+
}
|
|
241
|
+
];
|
|
242
|
+
};
|
|
243
|
+
var buildRefineMessages = (opts) => {
|
|
244
|
+
const { originalPlan, index, instructions, config } = opts;
|
|
245
|
+
const target = originalPlan.commits[index];
|
|
246
|
+
const spec = [];
|
|
247
|
+
spec.push("Purpose: Refine a single Conventional Commit message while preserving intent.");
|
|
248
|
+
spec.push("Locale: en");
|
|
249
|
+
spec.push("Input: one existing commit JSON object.");
|
|
250
|
+
spec.push(
|
|
251
|
+
'Output JSON Schema: { "commits": [ { "title": string, "body": string, "score": 0-100, "reasons": string[] } ] }'
|
|
252
|
+
);
|
|
253
|
+
spec.push("Title Format: <type>(<optional-scope>): <subject> (<=72 chars)");
|
|
254
|
+
spec.push("Subject: imperative, present tense, no trailing period.");
|
|
255
|
+
spec.push(
|
|
256
|
+
"Emoji Rule: " + (config.gitmoji ? "OPTIONAL single leading gitmoji BEFORE type if it adds clarity; omit if unsure." : "Disallow all emojis; start directly with the type.")
|
|
257
|
+
);
|
|
258
|
+
spec.push("Preserve semantic meaning; only improve clarity, scope, brevity, conformity.");
|
|
259
|
+
spec.push("If instructions request scope or emoji, incorporate only if justified by content.");
|
|
260
|
+
spec.push("Return ONLY JSON (commits array length=1).");
|
|
261
|
+
return [
|
|
262
|
+
{ role: "system", content: spec.join("\n") },
|
|
263
|
+
{
|
|
264
|
+
role: "user",
|
|
265
|
+
content: `Current commit object:
|
|
266
|
+
${JSON.stringify(target, null, 2)}
|
|
267
|
+
Instructions:
|
|
268
|
+
${instructions.join("\n") || "None"}
|
|
269
|
+
Refine now.`
|
|
270
|
+
}
|
|
271
|
+
];
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// src/model/provider.ts
|
|
275
|
+
var import_zod = require("zod");
|
|
276
|
+
var import_execa = require("execa");
|
|
277
|
+
var OpenCodeProvider = class {
|
|
278
|
+
constructor(model = "github-copilot/gpt-5") {
|
|
279
|
+
this.model = model;
|
|
280
|
+
}
|
|
281
|
+
name() {
|
|
282
|
+
return "opencode";
|
|
283
|
+
}
|
|
284
|
+
async chat(messages, _opts) {
|
|
285
|
+
const debug = process.env.AICC_DEBUG === "true";
|
|
286
|
+
const mockMode = process.env.AICC_DEBUG_PROVIDER === "mock";
|
|
287
|
+
const timeoutMs = parseInt(process.env.AICC_MODEL_TIMEOUT_MS || "45000", 10);
|
|
288
|
+
const eager = process.env.AICC_EAGER_PARSE !== "false";
|
|
289
|
+
const userAggregate = messages.map((m) => `${m.role.toUpperCase()}: ${m.content}`).join("\n\n");
|
|
290
|
+
const command = `Generate high-quality commit message candidates based on the staged git diff.`;
|
|
291
|
+
const fullPrompt = `${command}
|
|
292
|
+
|
|
293
|
+
Context:
|
|
294
|
+
${userAggregate}`;
|
|
295
|
+
if (mockMode) {
|
|
296
|
+
if (debug) console.error("[ai-cc][mock] Returning deterministic mock response");
|
|
297
|
+
return JSON.stringify({
|
|
298
|
+
commits: [
|
|
299
|
+
{
|
|
300
|
+
title: "chore: mock commit from provider",
|
|
301
|
+
body: "",
|
|
302
|
+
score: 80,
|
|
303
|
+
reasons: ["mock mode"]
|
|
304
|
+
}
|
|
305
|
+
],
|
|
306
|
+
meta: { splitRecommended: false }
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
const start = Date.now();
|
|
310
|
+
return await new Promise((resolve3, reject) => {
|
|
311
|
+
let resolved = false;
|
|
312
|
+
let acc = "";
|
|
313
|
+
const includeLogs = process.env.AICC_PRINT_LOGS === "true";
|
|
314
|
+
const args = ["run", fullPrompt, "--model", this.model];
|
|
315
|
+
if (includeLogs) args.push("--print-logs");
|
|
316
|
+
const subprocess = (0, import_execa.execa)("opencode", args, {
|
|
317
|
+
timeout: timeoutMs,
|
|
318
|
+
input: ""
|
|
319
|
+
// immediately close stdin in case CLI waits for it
|
|
320
|
+
});
|
|
321
|
+
const finish = (value) => {
|
|
322
|
+
if (resolved) return;
|
|
323
|
+
resolved = true;
|
|
324
|
+
const elapsed = Date.now() - start;
|
|
325
|
+
if (debug) {
|
|
326
|
+
console.error(
|
|
327
|
+
`[ai-cc][provider] model=${this.model} elapsedMs=${elapsed} promptChars=${fullPrompt.length} bytesOut=${value.length}`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
resolve3(value);
|
|
331
|
+
};
|
|
332
|
+
const tryEager = () => {
|
|
333
|
+
if (!eager) return;
|
|
334
|
+
const first = acc.indexOf("{");
|
|
335
|
+
const last = acc.lastIndexOf("}");
|
|
336
|
+
if (first !== -1 && last !== -1 && last > first) {
|
|
337
|
+
const candidate = acc.slice(first, last + 1).trim();
|
|
338
|
+
try {
|
|
339
|
+
JSON.parse(candidate);
|
|
340
|
+
if (debug) console.error("[ai-cc][provider] eager JSON detected, terminating process");
|
|
341
|
+
subprocess.kill("SIGTERM");
|
|
342
|
+
finish(candidate);
|
|
343
|
+
} catch {
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
subprocess.stdout?.on("data", (chunk) => {
|
|
348
|
+
const text = chunk.toString();
|
|
349
|
+
acc += text;
|
|
350
|
+
tryEager();
|
|
351
|
+
});
|
|
352
|
+
subprocess.stderr?.on("data", (chunk) => {
|
|
353
|
+
if (debug) console.error("[ai-cc][provider][stderr]", chunk.toString().trim());
|
|
354
|
+
});
|
|
355
|
+
subprocess.then(({ stdout }) => {
|
|
356
|
+
if (!resolved) finish(stdout);
|
|
357
|
+
}).catch((e) => {
|
|
358
|
+
if (resolved) return;
|
|
359
|
+
const elapsed = Date.now() - start;
|
|
360
|
+
if (e.timedOut) {
|
|
361
|
+
return reject(
|
|
362
|
+
new Error(`Model call timed out after ${timeoutMs}ms (elapsed=${elapsed}ms)`)
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
if (debug) console.error("[ai-cc][provider] failure", e.stderr || e.message);
|
|
366
|
+
reject(new Error(e.stderr || e.message || "opencode invocation failed"));
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
var CommitSchema = import_zod.z.object({
|
|
372
|
+
title: import_zod.z.string().min(5).max(150),
|
|
373
|
+
body: import_zod.z.string().optional().default(""),
|
|
374
|
+
score: import_zod.z.number().min(0).max(100),
|
|
375
|
+
reasons: import_zod.z.array(import_zod.z.string()).optional().default([])
|
|
376
|
+
});
|
|
377
|
+
var PlanSchema = import_zod.z.object({
|
|
378
|
+
commits: import_zod.z.array(CommitSchema).min(1),
|
|
379
|
+
meta: import_zod.z.object({
|
|
380
|
+
splitRecommended: import_zod.z.boolean().optional()
|
|
381
|
+
}).optional()
|
|
382
|
+
});
|
|
383
|
+
var extractJSON = (raw) => {
|
|
384
|
+
const trimmed = raw.trim();
|
|
385
|
+
let jsonText = null;
|
|
386
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
387
|
+
jsonText = trimmed;
|
|
388
|
+
} else {
|
|
389
|
+
const match = raw.match(/\{[\s\S]*\}$/m);
|
|
390
|
+
if (match) jsonText = match[0];
|
|
391
|
+
}
|
|
392
|
+
if (!jsonText) throw new Error("No JSON object detected.");
|
|
393
|
+
let parsed;
|
|
394
|
+
try {
|
|
395
|
+
parsed = JSON.parse(jsonText);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
throw new Error("Invalid JSON parse");
|
|
398
|
+
}
|
|
399
|
+
return PlanSchema.parse(parsed);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// src/plugins.ts
|
|
403
|
+
var import_node_path = require("path");
|
|
404
|
+
async function loadPlugins(config, cwd = process.cwd()) {
|
|
405
|
+
const out = [];
|
|
406
|
+
for (const p of config.plugins || []) {
|
|
407
|
+
try {
|
|
408
|
+
const mod = await import((0, import_node_path.resolve)(cwd, p));
|
|
409
|
+
if (mod.default) out.push(mod.default);
|
|
410
|
+
else if (mod.plugin) out.push(mod.plugin);
|
|
411
|
+
} catch (e) {
|
|
412
|
+
if (config.verbose) {
|
|
413
|
+
console.error("[plugin]", p, "failed to load", e);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
async function applyTransforms(candidates, plugins, ctx) {
|
|
420
|
+
let current = candidates;
|
|
421
|
+
for (const pl of plugins) {
|
|
422
|
+
if (pl.transformCandidates) {
|
|
423
|
+
current = await pl.transformCandidates(current, ctx);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return current;
|
|
427
|
+
}
|
|
428
|
+
async function runValidations(candidate, plugins, ctx) {
|
|
429
|
+
const errors = [];
|
|
430
|
+
for (const pl of plugins) {
|
|
431
|
+
if (pl.validateCandidate) {
|
|
432
|
+
const res = await pl.validateCandidate(candidate, ctx);
|
|
433
|
+
if (typeof res === "string") errors.push(res);
|
|
434
|
+
else if (Array.isArray(res)) errors.push(...res);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return errors;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// src/guardrails.ts
|
|
441
|
+
var SECRET_PATTERNS = [
|
|
442
|
+
/AWS_[A-Z0-9_]+/i,
|
|
443
|
+
/BEGIN RSA PRIVATE KEY/,
|
|
444
|
+
/-----BEGIN PRIVATE KEY-----/,
|
|
445
|
+
/ssh-rsa AAAA/
|
|
446
|
+
];
|
|
447
|
+
var CONVENTIONAL_RE = /^(?:([\p{Emoji}\p{So}\p{Sk}]+)\s+(feat|fix|chore|docs|refactor|test|ci|perf|style|build|revert|merge|security|release)(\(.+\))?:\s|([\p{Emoji}\p{So}\p{Sk}]+):\s.*|([\p{Emoji}\p{So}\p{Sk}]+):\s*$|(feat|fix|chore|docs|refactor|test|ci|perf|style|build|revert|merge|security|release)(\(.+\))?:\s)/u;
|
|
448
|
+
var sanitizeTitle = (title, allowEmoji) => {
|
|
449
|
+
let t = title.trim();
|
|
450
|
+
if (allowEmoji) {
|
|
451
|
+
const multi = t.match(/^((?:[\p{Emoji}\p{So}\p{Sk}]+)[\p{Emoji}\p{So}\p{Sk}\s]*)+/u);
|
|
452
|
+
if (multi) {
|
|
453
|
+
const first = Array.from(multi[0].trim())[0];
|
|
454
|
+
t = first + " " + t.slice(multi[0].length).trimStart();
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
t = t.replace(/^([\p{Emoji}\p{So}\p{Sk}\p{P}]+\s*)+/u, "").trimStart();
|
|
458
|
+
}
|
|
459
|
+
return t;
|
|
460
|
+
};
|
|
461
|
+
var normalizeConventionalTitle = (title) => {
|
|
462
|
+
let original = title.trim();
|
|
463
|
+
let leadingEmoji = "";
|
|
464
|
+
const emojiCluster = original.match(/^[\p{Emoji}\p{So}\p{Sk}]+/u);
|
|
465
|
+
if (emojiCluster) {
|
|
466
|
+
leadingEmoji = Array.from(emojiCluster[0])[0];
|
|
467
|
+
}
|
|
468
|
+
let t = original.replace(/^([\p{Emoji}\p{So}\p{Sk}\p{P}]+\s*)+/u, "").trim();
|
|
469
|
+
const m = t.match(/^(\w+)(\(.+\))?:\s+(.*)$/);
|
|
470
|
+
let result;
|
|
471
|
+
if (m) {
|
|
472
|
+
const type = m[1].toLowerCase();
|
|
473
|
+
const scope = m[2] || "";
|
|
474
|
+
let subject = m[3].trim();
|
|
475
|
+
subject = subject.replace(/\.$/, "");
|
|
476
|
+
subject = subject.charAt(0).toLowerCase() + subject.slice(1);
|
|
477
|
+
result = `${type}${scope}: ${subject}`;
|
|
478
|
+
} else if (!/^\w+\(.+\)?: /.test(t)) {
|
|
479
|
+
t = t.replace(/\.$/, "");
|
|
480
|
+
t = t.charAt(0).toLowerCase() + t.slice(1);
|
|
481
|
+
result = `chore: ${t}`;
|
|
482
|
+
} else {
|
|
483
|
+
result = t;
|
|
484
|
+
}
|
|
485
|
+
if (leadingEmoji) {
|
|
486
|
+
result = `${leadingEmoji} ${result}`;
|
|
487
|
+
}
|
|
488
|
+
return result.slice(0, 72);
|
|
489
|
+
};
|
|
490
|
+
var checkCandidate = (candidate) => {
|
|
491
|
+
const errs = [];
|
|
492
|
+
if (candidate.title.length > 72) errs.push("Title exceeds 72 chars.");
|
|
493
|
+
if (!CONVENTIONAL_RE.test(candidate.title)) {
|
|
494
|
+
errs.push("Not a valid conventional commit title.");
|
|
495
|
+
}
|
|
496
|
+
if (/^[A-Z]/.test(candidate.title)) {
|
|
497
|
+
}
|
|
498
|
+
const body = candidate.body || "";
|
|
499
|
+
for (const pat of SECRET_PATTERNS) {
|
|
500
|
+
if (pat.test(body)) {
|
|
501
|
+
errs.push("Potential secret detected.");
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return errs;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
// src/title-format.ts
|
|
509
|
+
var EMOJI_MAP = {
|
|
510
|
+
feat: "\u2728",
|
|
511
|
+
fix: "\u{1F41B}",
|
|
512
|
+
chore: "\u{1F9F9}",
|
|
513
|
+
docs: "\u{1F4DD}",
|
|
514
|
+
refactor: "\u267B\uFE0F",
|
|
515
|
+
test: "\u2705",
|
|
516
|
+
ci: "\u{1F916}",
|
|
517
|
+
perf: "\u26A1\uFE0F",
|
|
518
|
+
style: "\u{1F3A8}",
|
|
519
|
+
build: "\u{1F3D7}\uFE0F",
|
|
520
|
+
revert: "\u23EA",
|
|
521
|
+
merge: "\u{1F500}",
|
|
522
|
+
security: "\u{1F512}",
|
|
523
|
+
release: "\u{1F3F7}\uFE0F"
|
|
524
|
+
};
|
|
525
|
+
var MAX_LEN = 72;
|
|
526
|
+
var EMOJI_TYPE_RE = /^([\p{Emoji}\p{So}\p{Sk}])\s+(\w+)(\(.+\))?:\s+(.*)$/u;
|
|
527
|
+
var TYPE_RE = /^(\w+)(\(.+\))?:\s+(.*)$/;
|
|
528
|
+
var formatCommitTitle = (raw, opts) => {
|
|
529
|
+
const { allowGitmoji, mode = "standard" } = opts;
|
|
530
|
+
let norm = normalizeConventionalTitle(sanitizeTitle(raw, allowGitmoji));
|
|
531
|
+
if (!allowGitmoji || mode !== "gitmoji" && mode !== "gitmoji-pure") {
|
|
532
|
+
return norm.slice(0, MAX_LEN);
|
|
533
|
+
}
|
|
534
|
+
if (mode === "gitmoji-pure") {
|
|
535
|
+
let m2 = norm.match(EMOJI_TYPE_RE);
|
|
536
|
+
if (m2) {
|
|
537
|
+
const emoji = m2[1];
|
|
538
|
+
const subject = m2[4];
|
|
539
|
+
norm = `${emoji}: ${subject}`;
|
|
540
|
+
} else if (m2 = norm.match(TYPE_RE)) {
|
|
541
|
+
const type = m2[1];
|
|
542
|
+
const subject = m2[3];
|
|
543
|
+
const em = EMOJI_MAP[type] || "\u{1F527}";
|
|
544
|
+
norm = `${em}: ${subject}`;
|
|
545
|
+
} else if (!/^([\p{Emoji}\p{So}\p{Sk}])+:/u.test(norm)) {
|
|
546
|
+
norm = `\u{1F527}: ${norm}`;
|
|
547
|
+
}
|
|
548
|
+
return norm.slice(0, MAX_LEN);
|
|
549
|
+
}
|
|
550
|
+
let m = norm.match(EMOJI_TYPE_RE);
|
|
551
|
+
if (m) {
|
|
552
|
+
return norm.slice(0, MAX_LEN);
|
|
553
|
+
}
|
|
554
|
+
if (m = norm.match(TYPE_RE)) {
|
|
555
|
+
const type = m[1];
|
|
556
|
+
const scope = m[2] || "";
|
|
557
|
+
const subject = m[3];
|
|
558
|
+
const em = EMOJI_MAP[type] || "\u{1F527}";
|
|
559
|
+
norm = `${em} ${type}${scope}: ${subject}`;
|
|
560
|
+
} else if (!/^([\p{Emoji}\p{So}\p{Sk}])+\s+\w+.*:/u.test(norm)) {
|
|
561
|
+
norm = `\u{1F527} chore: ${norm}`;
|
|
562
|
+
}
|
|
563
|
+
return norm.slice(0, MAX_LEN);
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// src/workflow/generate.ts
|
|
567
|
+
var import_node_fs = require("fs");
|
|
568
|
+
var import_node_path2 = require("path");
|
|
569
|
+
var import_inquirer = __toESM(require("inquirer"), 1);
|
|
570
|
+
|
|
571
|
+
// src/workflow/panel.ts
|
|
572
|
+
var import_chalk = __toESM(require("chalk"), 1);
|
|
573
|
+
function buildPanel(opts) {
|
|
574
|
+
const termWidth = process.stdout.columns || 80;
|
|
575
|
+
const contentLines = opts.lines && opts.lines.length ? opts.lines : [];
|
|
576
|
+
const maxContent = Math.max(
|
|
577
|
+
opts.title ? stripAnsi(opts.title).length : 0,
|
|
578
|
+
...contentLines.map((l) => stripAnsi(l).length)
|
|
579
|
+
);
|
|
580
|
+
const innerWidth = Math.min(opts.width || maxContent, termWidth - 4);
|
|
581
|
+
const pad = (s) => {
|
|
582
|
+
const visible = stripAnsi(s).length;
|
|
583
|
+
const needed = innerWidth - visible;
|
|
584
|
+
return s + " ".repeat(Math.max(0, needed));
|
|
585
|
+
};
|
|
586
|
+
const top = "\u250C " + pad(opts.title ? import_chalk.default.bold(opts.title) : "") + " \u2510";
|
|
587
|
+
const body = contentLines.map((l) => "\u2502 " + pad(l) + " \u2502");
|
|
588
|
+
const bottom = "\u2514" + "\u2500".repeat(innerWidth + 2) + "\u2518";
|
|
589
|
+
return [top, ...body, bottom].join("\n");
|
|
590
|
+
}
|
|
591
|
+
function stripAnsi(str) {
|
|
592
|
+
return str.replace(/\u001B\[[0-9;]*m/g, "");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// src/workflow/generate.ts
|
|
596
|
+
async function animateHeader() {
|
|
597
|
+
const text = "ai-conventional-commit";
|
|
598
|
+
if (!process.stdout.isTTY || process.env.AICC_NO_ANIMATION) {
|
|
599
|
+
console.log("\n\u250C " + import_chalk2.default.bold(text));
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const palette = [
|
|
603
|
+
"#3a0d6d",
|
|
604
|
+
"#5a1ea3",
|
|
605
|
+
"#7a32d6",
|
|
606
|
+
"#9a4dff",
|
|
607
|
+
"#b267ff",
|
|
608
|
+
"#c37dff",
|
|
609
|
+
"#b267ff",
|
|
610
|
+
"#9a4dff",
|
|
611
|
+
"#7a32d6",
|
|
612
|
+
"#5a1ea3"
|
|
613
|
+
];
|
|
614
|
+
process.stdout.write("\n");
|
|
615
|
+
for (const color of palette) {
|
|
616
|
+
const frame = import_chalk2.default.bold.hex(color)(text);
|
|
617
|
+
process.stdout.write("\r\u250C " + frame);
|
|
618
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
619
|
+
}
|
|
620
|
+
process.stdout.write("\n");
|
|
621
|
+
}
|
|
622
|
+
async function runGenerate(config) {
|
|
623
|
+
if (!await ensureStagedChanges()) {
|
|
624
|
+
console.log("No staged changes.");
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const files = await parseDiff();
|
|
628
|
+
if (!files.length) {
|
|
629
|
+
console.log("No diff content detected after staging. Aborting.");
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const fileLines = [
|
|
633
|
+
import_chalk2.default.dim(`Detected ${files.length} staged ${files.length === 1 ? "file" : "files"}:`),
|
|
634
|
+
...files.map((f) => "\u2022 " + f.file)
|
|
635
|
+
];
|
|
636
|
+
if (process.stdout.isTTY) {
|
|
637
|
+
await animateHeader();
|
|
638
|
+
console.log(
|
|
639
|
+
buildPanel({
|
|
640
|
+
title: "Files",
|
|
641
|
+
lines: fileLines
|
|
642
|
+
})
|
|
643
|
+
);
|
|
644
|
+
} else {
|
|
645
|
+
console.log("\nFiles:");
|
|
646
|
+
fileLines.forEach((l) => console.log(" " + l));
|
|
647
|
+
}
|
|
648
|
+
const spinner = (0, import_ora.default)({ text: " Starting", spinner: "dots" }).start();
|
|
649
|
+
function setPhase(label) {
|
|
650
|
+
spinner.text = " " + import_chalk2.default.bold(label);
|
|
651
|
+
}
|
|
652
|
+
async function runStep(label, fn) {
|
|
653
|
+
setPhase(label);
|
|
654
|
+
try {
|
|
655
|
+
return await fn();
|
|
656
|
+
} catch (e) {
|
|
657
|
+
spinner.fail(`\u25C7 ${label} failed: ${e.message}`);
|
|
658
|
+
throw e;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
let style;
|
|
662
|
+
let plugins;
|
|
663
|
+
let messages;
|
|
664
|
+
let raw;
|
|
665
|
+
let plan;
|
|
666
|
+
let candidates = [];
|
|
667
|
+
const provider = new OpenCodeProvider(config.model);
|
|
668
|
+
style = await runStep("Profiling style", async () => {
|
|
669
|
+
const history = await getRecentCommitMessages(config.styleSamples);
|
|
670
|
+
return buildStyleProfile(history);
|
|
671
|
+
});
|
|
672
|
+
plugins = await runStep("Loading plugins", async () => loadPlugins(config));
|
|
673
|
+
messages = await runStep(
|
|
674
|
+
"Building prompt",
|
|
675
|
+
async () => buildGenerationMessages({ files, style, config, mode: "single" })
|
|
676
|
+
);
|
|
677
|
+
raw = await runStep(
|
|
678
|
+
"Calling model",
|
|
679
|
+
async () => provider.chat(messages, { maxTokens: config.maxTokens })
|
|
680
|
+
);
|
|
681
|
+
plan = await runStep("Parsing response", async () => extractJSON(raw));
|
|
682
|
+
candidates = await runStep(
|
|
683
|
+
"Analyzing changes",
|
|
684
|
+
async () => applyTransforms(plan.commits, plugins, { cwd: process.cwd(), env: process.env })
|
|
685
|
+
);
|
|
686
|
+
setPhase("Result found");
|
|
687
|
+
spinner.stopAndPersist({ symbol: "\u25C6", text: " " + import_chalk2.default.bold("Result found:") });
|
|
688
|
+
candidates = candidates.map((c) => ({
|
|
689
|
+
...c,
|
|
690
|
+
title: formatCommitTitle(c.title, {
|
|
691
|
+
allowGitmoji: !!config.gitmoji,
|
|
692
|
+
mode: config.gitmojiMode || "standard"
|
|
693
|
+
})
|
|
694
|
+
}));
|
|
695
|
+
const chosen = candidates[0];
|
|
696
|
+
const resultLines = [import_chalk2.default.white(chosen.title)];
|
|
697
|
+
if (chosen.body) {
|
|
698
|
+
chosen.body.split("\n").forEach((line) => {
|
|
699
|
+
if (line.trim().length === 0) resultLines.push("");
|
|
700
|
+
else resultLines.push(import_chalk2.default.white(line));
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
if (process.stdout.isTTY) {
|
|
704
|
+
console.log(
|
|
705
|
+
buildPanel({
|
|
706
|
+
title: "Result",
|
|
707
|
+
lines: resultLines
|
|
708
|
+
})
|
|
709
|
+
);
|
|
710
|
+
} else {
|
|
711
|
+
console.log("\nResult:");
|
|
712
|
+
resultLines.forEach((l) => console.log(" " + l));
|
|
713
|
+
}
|
|
714
|
+
const pluginErrors = await runValidations(chosen, plugins, {
|
|
715
|
+
cwd: process.cwd(),
|
|
716
|
+
env: process.env
|
|
717
|
+
});
|
|
718
|
+
const guardErrors = checkCandidate(chosen);
|
|
719
|
+
const errors = [...pluginErrors, ...guardErrors];
|
|
720
|
+
if (errors.length) {
|
|
721
|
+
const errorLines = ["Validation issues:", ...errors.map((e) => import_chalk2.default.red("\u2022 " + e))];
|
|
722
|
+
if (process.stdout.isTTY) {
|
|
723
|
+
console.log(
|
|
724
|
+
buildPanel({
|
|
725
|
+
title: "Checks",
|
|
726
|
+
lines: errorLines
|
|
727
|
+
})
|
|
728
|
+
);
|
|
729
|
+
} else {
|
|
730
|
+
console.log("\nValidation issues:");
|
|
731
|
+
errorLines.slice(1).forEach((l) => console.log(" " + l));
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const yn = await selectYesNo();
|
|
735
|
+
if (!yn) {
|
|
736
|
+
console.log("Aborted.");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
await createCommit(chosen.title, chosen.body);
|
|
740
|
+
saveSession({ plan, chosen, mode: "single" });
|
|
741
|
+
console.log(import_chalk2.default.green("Commit created."));
|
|
742
|
+
}
|
|
743
|
+
function saveSession(data) {
|
|
744
|
+
const dir = ".git/.aicc-cache";
|
|
745
|
+
if (!(0, import_node_fs.existsSync)(dir)) (0, import_node_fs.mkdirSync)(dir, { recursive: true });
|
|
746
|
+
(0, import_node_fs.writeFileSync)((0, import_node_path2.join)(dir, "last-session.json"), JSON.stringify(data, null, 2));
|
|
747
|
+
}
|
|
748
|
+
async function selectYesNo() {
|
|
749
|
+
const { choice } = await import_inquirer.default.prompt([
|
|
750
|
+
{
|
|
751
|
+
type: "list",
|
|
752
|
+
name: "choice",
|
|
753
|
+
message: " Use this commit message?",
|
|
754
|
+
choices: [
|
|
755
|
+
{ name: "Yes", value: true },
|
|
756
|
+
{ name: "No", value: false }
|
|
757
|
+
],
|
|
758
|
+
default: 0
|
|
759
|
+
}
|
|
760
|
+
]);
|
|
761
|
+
return choice;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// src/workflow/split.ts
|
|
765
|
+
var import_chalk3 = __toESM(require("chalk"), 1);
|
|
766
|
+
|
|
767
|
+
// src/cluster.ts
|
|
768
|
+
var topLevel = (file) => file.split("/")[0] || file;
|
|
769
|
+
var clusterHunks = (files) => {
|
|
770
|
+
const clusters = [];
|
|
771
|
+
const byDir = /* @__PURE__ */ new Map();
|
|
772
|
+
for (const f of files) {
|
|
773
|
+
for (const h of f.hunks) {
|
|
774
|
+
const dir = topLevel(f.file);
|
|
775
|
+
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
776
|
+
byDir.get(dir).push(h);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
for (const [dir, hunks] of byDir.entries()) {
|
|
780
|
+
if (hunks.length <= 5) {
|
|
781
|
+
clusters.push({
|
|
782
|
+
id: `dir-${dir}`,
|
|
783
|
+
files: [...new Set(hunks.map((h) => h.file))],
|
|
784
|
+
hunkHashes: hunks.map((h) => h.hash),
|
|
785
|
+
rationale: `Changes grouped by directory ${dir}`
|
|
786
|
+
});
|
|
787
|
+
} else {
|
|
788
|
+
const perFile = /* @__PURE__ */ new Map();
|
|
789
|
+
for (const h of hunks) {
|
|
790
|
+
if (!perFile.has(h.file)) perFile.set(h.file, []);
|
|
791
|
+
perFile.get(h.file).push(h);
|
|
792
|
+
}
|
|
793
|
+
for (const [file, list] of perFile.entries()) {
|
|
794
|
+
clusters.push({
|
|
795
|
+
id: `file-${file}`,
|
|
796
|
+
files: [file],
|
|
797
|
+
hunkHashes: list.map((h) => h.hash),
|
|
798
|
+
rationale: `Large directory; grouped by file ${file}`
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return clusters;
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
// src/workflow/split.ts
|
|
807
|
+
var import_node_fs2 = require("fs");
|
|
808
|
+
var import_node_path3 = require("path");
|
|
809
|
+
var import_inquirer2 = __toESM(require("inquirer"), 1);
|
|
810
|
+
async function runSplit(config, desired) {
|
|
811
|
+
if (!await ensureStagedChanges()) {
|
|
812
|
+
console.log("No staged changes.");
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
const files = await parseDiff();
|
|
816
|
+
if (!files.length) {
|
|
817
|
+
console.log("No diff content detected after staging. Aborting.");
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
console.log("\u25C7 Clustering changes");
|
|
821
|
+
clusterHunks(files);
|
|
822
|
+
console.log("\u25C7 Profiling style");
|
|
823
|
+
const history = await getRecentCommitMessages(config.styleSamples);
|
|
824
|
+
const style = buildStyleProfile(history);
|
|
825
|
+
console.log("\u25C7 Loading plugins");
|
|
826
|
+
const plugins = await loadPlugins(config);
|
|
827
|
+
console.log("\u25C7 Building prompt");
|
|
828
|
+
const messages = buildGenerationMessages({
|
|
829
|
+
files,
|
|
830
|
+
style,
|
|
831
|
+
config,
|
|
832
|
+
mode: "split",
|
|
833
|
+
desiredCommits: desired
|
|
834
|
+
});
|
|
835
|
+
const provider = new OpenCodeProvider(config.model);
|
|
836
|
+
console.log("\u25C7 Calling model for split plan");
|
|
837
|
+
let raw;
|
|
838
|
+
try {
|
|
839
|
+
raw = await provider.chat(messages, { maxTokens: config.maxTokens });
|
|
840
|
+
} catch (e) {
|
|
841
|
+
console.log(e.message);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
console.log("\u25C7 Parsing response");
|
|
845
|
+
let plan;
|
|
846
|
+
try {
|
|
847
|
+
plan = extractJSON(raw);
|
|
848
|
+
} catch (e) {
|
|
849
|
+
console.log("JSON parse error: " + e.message);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
console.log("\u25C7 Plan received");
|
|
853
|
+
let candidates = await applyTransforms(plan.commits, plugins, {
|
|
854
|
+
cwd: process.cwd(),
|
|
855
|
+
env: process.env
|
|
856
|
+
});
|
|
857
|
+
candidates = candidates.map((c) => ({
|
|
858
|
+
...c,
|
|
859
|
+
title: formatCommitTitle(c.title, {
|
|
860
|
+
allowGitmoji: !!config.gitmoji,
|
|
861
|
+
mode: config.gitmojiMode || "standard"
|
|
862
|
+
})
|
|
863
|
+
}));
|
|
864
|
+
console.log(import_chalk3.default.cyan("\nProposed commits:"));
|
|
865
|
+
candidates.forEach((c) => {
|
|
866
|
+
console.log(import_chalk3.default.yellow(`\u2022 ${c.title}`));
|
|
867
|
+
if (c.body) {
|
|
868
|
+
const indent = " ";
|
|
869
|
+
c.body.split("\n").forEach((line) => {
|
|
870
|
+
if (line.trim().length === 0) console.log(indent);
|
|
871
|
+
else console.log(indent + import_chalk3.default.gray(line));
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
const { ok } = await import_inquirer2.default.prompt([
|
|
876
|
+
{
|
|
877
|
+
type: "list",
|
|
878
|
+
name: "ok",
|
|
879
|
+
message: "Apply these commit messages?",
|
|
880
|
+
choices: [
|
|
881
|
+
{ name: "\u25CF Yes", value: true },
|
|
882
|
+
{ name: "\u25CB No", value: false }
|
|
883
|
+
],
|
|
884
|
+
default: 0
|
|
885
|
+
}
|
|
886
|
+
]);
|
|
887
|
+
if (!ok) {
|
|
888
|
+
console.log("Aborted.");
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
for (const candidate of candidates) {
|
|
892
|
+
const pluginErrors = await runValidations(candidate, plugins, {
|
|
893
|
+
cwd: process.cwd(),
|
|
894
|
+
env: process.env
|
|
895
|
+
});
|
|
896
|
+
const guardErrors = checkCandidate(candidate);
|
|
897
|
+
const errors = [...pluginErrors, ...guardErrors];
|
|
898
|
+
if (errors.length) {
|
|
899
|
+
console.log(import_chalk3.default.red("Skipping commit due to errors:"), candidate.title);
|
|
900
|
+
errors.forEach((e) => console.log(" -", e));
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
await createCommit(candidate.title, candidate.body);
|
|
904
|
+
console.log(import_chalk3.default.green("Committed: ") + candidate.title);
|
|
905
|
+
}
|
|
906
|
+
saveSession2({ plan, chosen: candidates, mode: "split" });
|
|
907
|
+
}
|
|
908
|
+
function saveSession2(data) {
|
|
909
|
+
const dir = ".git/.aicc-cache";
|
|
910
|
+
if (!(0, import_node_fs2.existsSync)(dir)) (0, import_node_fs2.mkdirSync)(dir, { recursive: true });
|
|
911
|
+
(0, import_node_fs2.writeFileSync)((0, import_node_path3.join)(dir, "last-session.json"), JSON.stringify(data, null, 2));
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// src/workflow/refine.ts
|
|
915
|
+
var import_chalk4 = __toESM(require("chalk"), 1);
|
|
916
|
+
var import_ora2 = __toESM(require("ora"), 1);
|
|
917
|
+
var import_node_fs3 = require("fs");
|
|
918
|
+
var import_node_path4 = require("path");
|
|
919
|
+
|
|
920
|
+
// src/workflow/util.ts
|
|
921
|
+
var import_node_readline = __toESM(require("readline"), 1);
|
|
922
|
+
var prompt = async (question, defaultValue) => {
|
|
923
|
+
const rl = import_node_readline.default.createInterface({ input: process.stdin, output: process.stdout });
|
|
924
|
+
return new Promise((res) => {
|
|
925
|
+
rl.question(question, (answer) => {
|
|
926
|
+
rl.close();
|
|
927
|
+
if (!answer && defaultValue) return res(defaultValue);
|
|
928
|
+
res(answer);
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
// src/workflow/refine.ts
|
|
934
|
+
function loadSession() {
|
|
935
|
+
const path = (0, import_node_path4.join)(".git/.aicc-cache", "last-session.json");
|
|
936
|
+
if (!(0, import_node_fs3.existsSync)(path)) return null;
|
|
937
|
+
try {
|
|
938
|
+
return JSON.parse((0, import_node_fs3.readFileSync)(path, "utf8"));
|
|
939
|
+
} catch {
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
async function runRefine(config, options) {
|
|
944
|
+
const spinner = (0, import_ora2.default)("Loading last session").start();
|
|
945
|
+
const session = loadSession();
|
|
946
|
+
if (!session) {
|
|
947
|
+
spinner.fail("No previous session found.");
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
spinner.succeed("Session loaded");
|
|
951
|
+
const plan = session.plan;
|
|
952
|
+
const index = options.index ?? 0;
|
|
953
|
+
if (!plan.commits[index]) {
|
|
954
|
+
console.log("Invalid index.");
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const instructions = [];
|
|
958
|
+
if (options.shorter) instructions.push("Make the title shorter but keep meaning.");
|
|
959
|
+
if (options.longer) instructions.push("Add more specificity to the title.");
|
|
960
|
+
if (options.scope) instructions.push(`Add or adjust scope to: ${options.scope}`);
|
|
961
|
+
if (options.emoji) instructions.push("Add a relevant emoji prefix.");
|
|
962
|
+
if (!instructions.length) {
|
|
963
|
+
const add = await prompt("No refinement flags given. Enter custom instruction: ");
|
|
964
|
+
if (add.trim()) instructions.push(add.trim());
|
|
965
|
+
else {
|
|
966
|
+
console.log("Nothing to refine.");
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const provider = new OpenCodeProvider(config.model);
|
|
971
|
+
const messages = buildRefineMessages({
|
|
972
|
+
originalPlan: plan,
|
|
973
|
+
index,
|
|
974
|
+
instructions,
|
|
975
|
+
config
|
|
976
|
+
});
|
|
977
|
+
const raw = await provider.chat(messages, { maxTokens: config.maxTokens });
|
|
978
|
+
let refined;
|
|
979
|
+
try {
|
|
980
|
+
refined = extractJSON(raw);
|
|
981
|
+
} catch (e) {
|
|
982
|
+
console.error("Failed to parse refine response:", e.message);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
{
|
|
986
|
+
refined.commits[0].title = formatCommitTitle(refined.commits[0].title, {
|
|
987
|
+
allowGitmoji: !!config.gitmoji || !!options.emoji,
|
|
988
|
+
mode: config.gitmojiMode || "standard"
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
console.log(import_chalk4.default.cyan("\nRefined candidate:"));
|
|
992
|
+
console.log(import_chalk4.default.yellow(refined.commits[0].title));
|
|
993
|
+
if (refined.commits[0].body) {
|
|
994
|
+
const indent = " ";
|
|
995
|
+
refined.commits[0].body.split("\n").forEach((line) => {
|
|
996
|
+
if (line.trim().length === 0) console.log(indent);
|
|
997
|
+
else console.log(indent + import_chalk4.default.gray(line));
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
const accept = await prompt("Accept refined version? (Y/n) ", "y");
|
|
1001
|
+
if (!/^n/i.test(accept)) {
|
|
1002
|
+
plan.commits[index] = refined.commits[0];
|
|
1003
|
+
console.log(import_chalk4.default.green("Refinement stored (not retro-committed)."));
|
|
1004
|
+
} else {
|
|
1005
|
+
console.log("Refinement discarded.");
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// src/config.ts
|
|
1010
|
+
var import_cosmiconfig = require("cosmiconfig");
|
|
1011
|
+
var import_node_path5 = require("path");
|
|
1012
|
+
var import_node_fs4 = require("fs");
|
|
1013
|
+
var DEFAULTS = {
|
|
1014
|
+
model: process.env.AICC_MODEL || "github-copilot/gpt-5",
|
|
1015
|
+
privacy: process.env.AICC_PRIVACY || "low",
|
|
1016
|
+
gitmoji: process.env.AICC_GITMOJI === "true",
|
|
1017
|
+
gitmojiMode: "standard",
|
|
1018
|
+
styleSamples: parseInt(process.env.AICC_STYLE_SAMPLES || "120", 10),
|
|
1019
|
+
maxTokens: parseInt(process.env.AICC_MAX_TOKENS || "512", 10),
|
|
1020
|
+
cacheDir: ".git/.aicc-cache",
|
|
1021
|
+
plugins: [],
|
|
1022
|
+
verbose: process.env.AICC_VERBOSE === "true"
|
|
1023
|
+
};
|
|
1024
|
+
async function loadConfig(cwd = process.cwd()) {
|
|
1025
|
+
const explorer = (0, import_cosmiconfig.cosmiconfig)("aicc");
|
|
1026
|
+
const result = await explorer.search(cwd);
|
|
1027
|
+
const cfg = {
|
|
1028
|
+
...DEFAULTS,
|
|
1029
|
+
...result?.config || {}
|
|
1030
|
+
};
|
|
1031
|
+
cfg.plugins = (cfg.plugins || []).filter((p) => {
|
|
1032
|
+
const abs = (0, import_node_path5.resolve)(cwd, p);
|
|
1033
|
+
return (0, import_node_fs4.existsSync)(abs);
|
|
1034
|
+
});
|
|
1035
|
+
return cfg;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// src/index.ts
|
|
1039
|
+
var import_module = require("module");
|
|
1040
|
+
var import_meta = {};
|
|
1041
|
+
var require2 = (0, import_module.createRequire)(import_meta.url);
|
|
1042
|
+
var pkgVersion = "0.0.0";
|
|
1043
|
+
try {
|
|
1044
|
+
const pkg = require2("../package.json");
|
|
1045
|
+
pkgVersion = pkg.version || pkgVersion;
|
|
1046
|
+
} catch {
|
|
1047
|
+
}
|
|
1048
|
+
var GenerateCommand = class extends import_clipanion.Command {
|
|
1049
|
+
static paths = [[`generate`], []];
|
|
1050
|
+
static usage = import_clipanion.Command.Usage({
|
|
1051
|
+
description: "Generate a conventional commit message for staged changes (aliases: run, commit).",
|
|
1052
|
+
details: `Generates a single commit message using AI with style + guardrails.
|
|
1053
|
+
Add --gitmoji or --gitmoji-pure to enable emoji styles.`,
|
|
1054
|
+
examples: [
|
|
1055
|
+
["Generate a commit with gitmoji style", "ai-conventional-commit generate --gitmoji"]
|
|
1056
|
+
]
|
|
1057
|
+
});
|
|
1058
|
+
gitmoji = import_clipanion.Option.Boolean("--gitmoji", false, {
|
|
1059
|
+
description: "Gitmoji mode (vs --gitmoji-pure): emoji + type (emoji: subject)"
|
|
1060
|
+
});
|
|
1061
|
+
gitmojiPure = import_clipanion.Option.Boolean("--gitmoji-pure", false, {
|
|
1062
|
+
description: "Pure gitmoji mode (vs --gitmoji): emoji only (emoji: subject)"
|
|
1063
|
+
});
|
|
1064
|
+
model = import_clipanion.Option.String("-m,--model", {
|
|
1065
|
+
required: false,
|
|
1066
|
+
description: "Model provider/name (e.g. github-copilot/gpt-5)"
|
|
1067
|
+
});
|
|
1068
|
+
async execute() {
|
|
1069
|
+
const config = await loadConfig();
|
|
1070
|
+
if (this.gitmoji || this.gitmojiPure) {
|
|
1071
|
+
config.gitmoji = true;
|
|
1072
|
+
config.gitmojiMode = this.gitmojiPure ? "gitmoji-pure" : "gitmoji";
|
|
1073
|
+
}
|
|
1074
|
+
if (this.model) config.model = this.model;
|
|
1075
|
+
await runGenerate(config);
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
var SplitCommand = class extends import_clipanion.Command {
|
|
1079
|
+
static paths = [[`split`]];
|
|
1080
|
+
static usage = import_clipanion.Command.Usage({
|
|
1081
|
+
description: "Propose multiple smaller conventional commits for current staged diff.",
|
|
1082
|
+
details: `Analyzes staged changes, groups them logically and suggests multiple commit messages.
|
|
1083
|
+
Use --max to limit the number of proposals.`,
|
|
1084
|
+
examples: [
|
|
1085
|
+
[
|
|
1086
|
+
"Split into at most 3 commits with gitmoji",
|
|
1087
|
+
"ai-conventional-commit split --max 3 --gitmoji"
|
|
1088
|
+
]
|
|
1089
|
+
]
|
|
1090
|
+
});
|
|
1091
|
+
max = import_clipanion.Option.String("--max", { description: "Max proposed commits", required: false });
|
|
1092
|
+
gitmoji = import_clipanion.Option.Boolean("--gitmoji", false, {
|
|
1093
|
+
description: "Gitmoji mode (vs --gitmoji-pure): emoji + type"
|
|
1094
|
+
});
|
|
1095
|
+
gitmojiPure = import_clipanion.Option.Boolean("--gitmoji-pure", false, {
|
|
1096
|
+
description: "Pure gitmoji mode (vs --gitmoji): emoji only"
|
|
1097
|
+
});
|
|
1098
|
+
model = import_clipanion.Option.String("-m,--model", {
|
|
1099
|
+
required: false,
|
|
1100
|
+
description: "Model provider/name override"
|
|
1101
|
+
});
|
|
1102
|
+
async execute() {
|
|
1103
|
+
const config = await loadConfig();
|
|
1104
|
+
if (this.gitmoji || this.gitmojiPure) {
|
|
1105
|
+
config.gitmoji = true;
|
|
1106
|
+
config.gitmojiMode = this.gitmojiPure ? "gitmoji-pure" : "gitmoji";
|
|
1107
|
+
}
|
|
1108
|
+
if (this.model) config.model = this.model;
|
|
1109
|
+
await runSplit(config, this.max ? parseInt(this.max, 10) : void 0);
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
var RefineCommand = class extends import_clipanion.Command {
|
|
1113
|
+
static paths = [[`refine`]];
|
|
1114
|
+
static usage = import_clipanion.Command.Usage({
|
|
1115
|
+
description: "Refine the last (or chosen) commit message with style rules.",
|
|
1116
|
+
details: `Allows targeted improvements: shorter/longer length, inject scope, add emoji, or select a specific index when multiple commits were generated earlier.`,
|
|
1117
|
+
examples: [
|
|
1118
|
+
["Shorten the last commit message", "ai-conventional-commit refine --shorter"],
|
|
1119
|
+
["Add a scope to the last commit", "ai-conventional-commit refine --scope ui"]
|
|
1120
|
+
]
|
|
1121
|
+
});
|
|
1122
|
+
shorter = import_clipanion.Option.Boolean("--shorter", false, { description: "Make message more concise" });
|
|
1123
|
+
longer = import_clipanion.Option.Boolean("--longer", false, { description: "Expand message with detail" });
|
|
1124
|
+
scope = import_clipanion.Option.String("--scope", { description: "Override/add scope (e.g. ui, api)" });
|
|
1125
|
+
emoji = import_clipanion.Option.Boolean("--emoji", false, { description: "Add appropriate gitmoji (non-pure)" });
|
|
1126
|
+
index = import_clipanion.Option.String("--index", {
|
|
1127
|
+
description: "Select commit index if multiple were generated"
|
|
1128
|
+
});
|
|
1129
|
+
model = import_clipanion.Option.String("-m,--model", {
|
|
1130
|
+
required: false,
|
|
1131
|
+
description: "Model provider/name override"
|
|
1132
|
+
});
|
|
1133
|
+
async execute() {
|
|
1134
|
+
const config = await loadConfig();
|
|
1135
|
+
if (this.model) config.model = this.model;
|
|
1136
|
+
await runRefine(config, {
|
|
1137
|
+
shorter: this.shorter,
|
|
1138
|
+
longer: this.longer,
|
|
1139
|
+
scope: this.scope,
|
|
1140
|
+
emoji: this.emoji,
|
|
1141
|
+
index: this.index ? parseInt(this.index, 10) : void 0
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
var HelpCommand = class extends import_clipanion.Command {
|
|
1146
|
+
static paths = [[`--help`], [`-h`]];
|
|
1147
|
+
// capture explicit help
|
|
1148
|
+
async execute() {
|
|
1149
|
+
this.context.stdout.write(globalHelp() + "\n");
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
function globalHelp() {
|
|
1153
|
+
return `ai-conventional-commit v${pkgVersion}
|
|
1154
|
+
|
|
1155
|
+
Usage:
|
|
1156
|
+
ai-conventional-commit [generate] [options] Generate a commit (default)
|
|
1157
|
+
ai-conventional-commit split [options] Propose multiple commits
|
|
1158
|
+
ai-conventional-commit refine [options] Refine last or indexed commit
|
|
1159
|
+
|
|
1160
|
+
Global Options:
|
|
1161
|
+
-m, --model <provider/name> Override model provider/name
|
|
1162
|
+
--gitmoji Gitmoji mode (emoji + type)
|
|
1163
|
+
--gitmoji-pure Gitmoji pure mode (emoji only)
|
|
1164
|
+
-h, --help Show this help
|
|
1165
|
+
-V, --version Show version
|
|
1166
|
+
|
|
1167
|
+
Refine Options:
|
|
1168
|
+
--shorter / --longer Adjust message length
|
|
1169
|
+
--scope <scope> Add or replace scope
|
|
1170
|
+
--emoji Add suitable gitmoji
|
|
1171
|
+
--index <n> Select commit index
|
|
1172
|
+
|
|
1173
|
+
Examples:
|
|
1174
|
+
ai-conventional-commit --gitmoji
|
|
1175
|
+
ai-conventional-commit split --max 3
|
|
1176
|
+
ai-conventional-commit refine --scope api --emoji
|
|
1177
|
+
`;
|
|
1178
|
+
}
|
|
1179
|
+
var VersionCommand = class extends import_clipanion.Command {
|
|
1180
|
+
static paths = [[`--version`], [`-V`]];
|
|
1181
|
+
async execute() {
|
|
1182
|
+
this.context.stdout.write(`${pkgVersion}
|
|
1183
|
+
`);
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
var cli = new import_clipanion.Cli({
|
|
1187
|
+
binaryLabel: "ai-conventional-commit",
|
|
1188
|
+
binaryName: "ai-conventional-commit",
|
|
1189
|
+
binaryVersion: pkgVersion
|
|
1190
|
+
});
|
|
1191
|
+
cli.register(GenerateCommand);
|
|
1192
|
+
cli.register(SplitCommand);
|
|
1193
|
+
cli.register(RefineCommand);
|
|
1194
|
+
cli.register(HelpCommand);
|
|
1195
|
+
cli.register(VersionCommand);
|
|
1196
|
+
cli.runExit(process.argv.slice(2), {
|
|
1197
|
+
stdin: process.stdin,
|
|
1198
|
+
stdout: process.stdout,
|
|
1199
|
+
stderr: process.stderr
|
|
1200
|
+
});
|