@kud/ai-conventional-commit-cli 0.8.2 → 0.10.0
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/index.cjs +1200 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.js +84 -20
- package/package.json +1 -1
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
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
CHANGED
|
@@ -196,14 +196,18 @@ var buildGenerationMessages = (opts) => {
|
|
|
196
196
|
);
|
|
197
197
|
specLines.push("Primary Output Field: commits[ ].title");
|
|
198
198
|
specLines.push("Title Format: <type>(<optional-scope>): <subject>");
|
|
199
|
-
specLines.push(
|
|
199
|
+
specLines.push(
|
|
200
|
+
"Title Length Guidance: Aim for <=50 chars ideal; absolute max 72 (do not exceed)."
|
|
201
|
+
);
|
|
200
202
|
specLines.push("Types (JSON mapping follows on next line)");
|
|
201
203
|
specLines.push("TypeMap: " + JSON.stringify(TYPE_MAP));
|
|
202
204
|
specLines.push("Scope Rules: optional; if present, lowercase kebab-case; omit when unclear.");
|
|
203
205
|
specLines.push(
|
|
204
206
|
"Subject Rules: imperative mood, present tense, no leading capital unless proper noun, no trailing period."
|
|
205
207
|
);
|
|
206
|
-
specLines.push(
|
|
208
|
+
specLines.push(
|
|
209
|
+
"Length Rule: Keep titles concise; prefer 50 or fewer chars; MUST be <=72 including type/scope."
|
|
210
|
+
);
|
|
207
211
|
specLines.push(
|
|
208
212
|
"Emoji Rule: " + (config.style === "gitmoji" || config.style === "gitmoji-pure" ? "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.")
|
|
209
213
|
);
|
|
@@ -487,11 +491,10 @@ var normalizeConventionalTitle = (title) => {
|
|
|
487
491
|
if (leadingEmoji) {
|
|
488
492
|
result = `${leadingEmoji} ${result}`;
|
|
489
493
|
}
|
|
490
|
-
return result
|
|
494
|
+
return result;
|
|
491
495
|
};
|
|
492
496
|
var checkCandidate = (candidate) => {
|
|
493
497
|
const errs = [];
|
|
494
|
-
if (candidate.title.length > 72) errs.push("Title exceeds 72 chars.");
|
|
495
498
|
if (!CONVENTIONAL_RE.test(candidate.title)) {
|
|
496
499
|
errs.push("Not a valid conventional commit title.");
|
|
497
500
|
}
|
|
@@ -524,14 +527,13 @@ var EMOJI_MAP = {
|
|
|
524
527
|
security: "\u{1F512}",
|
|
525
528
|
release: "\u{1F3F7}\uFE0F"
|
|
526
529
|
};
|
|
527
|
-
var MAX_LEN = 72;
|
|
528
530
|
var EMOJI_TYPE_RE = /^([\p{Emoji}\p{So}\p{Sk}])\s+(\w+)(\(.+\))?:\s+(.*)$/u;
|
|
529
531
|
var TYPE_RE = /^(\w+)(\(.+\))?:\s+(.*)$/;
|
|
530
532
|
var formatCommitTitle = (raw, opts) => {
|
|
531
533
|
const { allowGitmoji, mode = "standard" } = opts;
|
|
532
534
|
let norm = normalizeConventionalTitle(sanitizeTitle(raw, allowGitmoji));
|
|
533
535
|
if (!allowGitmoji || mode !== "gitmoji" && mode !== "gitmoji-pure") {
|
|
534
|
-
return norm
|
|
536
|
+
return norm;
|
|
535
537
|
}
|
|
536
538
|
if (mode === "gitmoji-pure") {
|
|
537
539
|
let m2 = norm.match(EMOJI_TYPE_RE);
|
|
@@ -547,11 +549,11 @@ var formatCommitTitle = (raw, opts) => {
|
|
|
547
549
|
} else if (!/^([\p{Emoji}\p{So}\p{Sk}])+:/u.test(norm)) {
|
|
548
550
|
norm = `\u{1F527}: ${norm}`;
|
|
549
551
|
}
|
|
550
|
-
return norm
|
|
552
|
+
return norm;
|
|
551
553
|
}
|
|
552
554
|
let m = norm.match(EMOJI_TYPE_RE);
|
|
553
555
|
if (m) {
|
|
554
|
-
return norm
|
|
556
|
+
return norm;
|
|
555
557
|
}
|
|
556
558
|
if (m = norm.match(TYPE_RE)) {
|
|
557
559
|
const type = m[1];
|
|
@@ -562,7 +564,7 @@ var formatCommitTitle = (raw, opts) => {
|
|
|
562
564
|
} else if (!/^([\p{Emoji}\p{So}\p{Sk}])+\s+\w+.*:/u.test(norm)) {
|
|
563
565
|
norm = `\u{1F527} chore: ${norm}`;
|
|
564
566
|
}
|
|
565
|
-
return norm
|
|
567
|
+
return norm;
|
|
566
568
|
};
|
|
567
569
|
|
|
568
570
|
// src/workflow/generate.ts
|
|
@@ -734,11 +736,42 @@ async function runGenerate(config) {
|
|
|
734
736
|
borderLine();
|
|
735
737
|
}
|
|
736
738
|
sectionTitle("Files");
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
739
|
+
(function renderDiffStat() {
|
|
740
|
+
const BAR_WIDTH = 32;
|
|
741
|
+
const nameLens = files.map((f) => f.file.length);
|
|
742
|
+
const maxName = Math.min(Math.max(...nameLens, 0), 50);
|
|
743
|
+
const deltas = files.map((f) => (f.additions || 0) + (f.deletions || 0));
|
|
744
|
+
const maxDelta = Math.max(...deltas, 1);
|
|
745
|
+
borderLine(
|
|
746
|
+
chalk2.dim(`Detected ${files.length} staged ${files.length === 1 ? "file" : "files"}:`)
|
|
747
|
+
);
|
|
748
|
+
let totalAdd = 0;
|
|
749
|
+
let totalDel = 0;
|
|
750
|
+
files.forEach((f) => {
|
|
751
|
+
const add = f.additions || 0;
|
|
752
|
+
const del = f.deletions || 0;
|
|
753
|
+
totalAdd += add;
|
|
754
|
+
totalDel += del;
|
|
755
|
+
if (add === 0 && del === 0) {
|
|
756
|
+
borderLine("\u2022 " + f.file);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const delta = add + del;
|
|
760
|
+
const barLen = Math.max(1, Math.round(delta / maxDelta * BAR_WIDTH));
|
|
761
|
+
const addPortion = Math.min(barLen, Math.round(barLen * (add / (delta || 1))));
|
|
762
|
+
const delPortion = barLen - addPortion;
|
|
763
|
+
const bar = chalk2.green("+".repeat(addPortion)) + chalk2.red("-".repeat(delPortion));
|
|
764
|
+
const counts = chalk2.green("+" + add) + " " + chalk2.red("-" + del);
|
|
765
|
+
const name = f.file.length > maxName ? f.file.slice(0, maxName - 1) + "\u2026" : f.file;
|
|
766
|
+
borderLine(name.padEnd(maxName) + " | " + counts.padEnd(12) + " " + bar);
|
|
767
|
+
});
|
|
768
|
+
borderLine(
|
|
769
|
+
chalk2.dim(
|
|
770
|
+
`${files.length} file${files.length === 1 ? "" : "s"} changed, ${totalAdd} insertion${totalAdd === 1 ? "" : "s"}(+), ${totalDel} deletion${totalDel === 1 ? "" : "s"}(-)`
|
|
771
|
+
)
|
|
772
|
+
);
|
|
773
|
+
borderLine();
|
|
774
|
+
})();
|
|
742
775
|
const phased = createPhasedSpinner(ora);
|
|
743
776
|
const runStep = (label, fn) => phased.step(label, fn);
|
|
744
777
|
let style;
|
|
@@ -886,11 +919,42 @@ async function runSplit(config, desired) {
|
|
|
886
919
|
borderLine();
|
|
887
920
|
}
|
|
888
921
|
sectionTitle("Files");
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
922
|
+
(function renderDiffStat() {
|
|
923
|
+
const BAR_WIDTH = 32;
|
|
924
|
+
const nameLens = files.map((f) => f.file.length);
|
|
925
|
+
const maxName = Math.min(Math.max(...nameLens, 0), 50);
|
|
926
|
+
const deltas = files.map((f) => (f.additions || 0) + (f.deletions || 0));
|
|
927
|
+
const maxDelta = Math.max(...deltas, 1);
|
|
928
|
+
borderLine(
|
|
929
|
+
chalk3.dim(`Detected ${files.length} staged ${files.length === 1 ? "file" : "files"}:`)
|
|
930
|
+
);
|
|
931
|
+
let totalAdd = 0;
|
|
932
|
+
let totalDel = 0;
|
|
933
|
+
files.forEach((f) => {
|
|
934
|
+
const add = f.additions || 0;
|
|
935
|
+
const del = f.deletions || 0;
|
|
936
|
+
totalAdd += add;
|
|
937
|
+
totalDel += del;
|
|
938
|
+
if (add === 0 && del === 0) {
|
|
939
|
+
borderLine("\u2022 " + f.file);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const delta = add + del;
|
|
943
|
+
const barLen = Math.max(1, Math.round(delta / maxDelta * BAR_WIDTH));
|
|
944
|
+
const addPortion = Math.min(barLen, Math.round(barLen * (add / (delta || 1))));
|
|
945
|
+
const delPortion = barLen - addPortion;
|
|
946
|
+
const bar = chalk3.green("+".repeat(addPortion)) + chalk3.red("-".repeat(delPortion));
|
|
947
|
+
const counts = chalk3.green("+" + add) + " " + chalk3.red("-" + del);
|
|
948
|
+
const name = f.file.length > maxName ? f.file.slice(0, maxName - 1) + "\u2026" : f.file;
|
|
949
|
+
borderLine(name.padEnd(maxName) + " | " + counts.padEnd(12) + " " + bar);
|
|
950
|
+
});
|
|
951
|
+
borderLine(
|
|
952
|
+
chalk3.dim(
|
|
953
|
+
`${files.length} file${files.length === 1 ? "" : "s"} changed, ${totalAdd} insertion${totalAdd === 1 ? "" : "s"}(+), ${totalDel} deletion${totalDel === 1 ? "" : "s"}(-)`
|
|
954
|
+
)
|
|
955
|
+
);
|
|
956
|
+
borderLine();
|
|
957
|
+
})();
|
|
894
958
|
const phased = createPhasedSpinner(ora2);
|
|
895
959
|
const runStep = (label, fn) => phased.step(label, fn);
|
|
896
960
|
await runStep("Clustering changes", async () => {
|
|
@@ -1122,7 +1186,7 @@ async function runRefine(config, options) {
|
|
|
1122
1186
|
// package.json
|
|
1123
1187
|
var package_default = {
|
|
1124
1188
|
name: "@kud/ai-conventional-commit-cli",
|
|
1125
|
-
version: "0.
|
|
1189
|
+
version: "0.10.0",
|
|
1126
1190
|
type: "module",
|
|
1127
1191
|
description: "Opinionated, style-aware AI assistant for crafting and splitting git commits (opencode-based, provider-agnostic).",
|
|
1128
1192
|
bin: {
|
package/package.json
CHANGED