@kud/ai-conventional-commit-cli 0.2.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/LICENSE +7 -0
- package/README.md +204 -0
- package/dist/index.cjs +1029 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1006 -0
- package/package.json +62 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Cli, Command, Option } from "clipanion";
|
|
5
|
+
|
|
6
|
+
// src/workflow/generate.ts
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import ora from "ora";
|
|
9
|
+
|
|
10
|
+
// src/git.ts
|
|
11
|
+
import { simpleGit } from "simple-git";
|
|
12
|
+
import crypto from "crypto";
|
|
13
|
+
var git = simpleGit();
|
|
14
|
+
var ensureStagedChanges = async () => {
|
|
15
|
+
const status = await git.status();
|
|
16
|
+
return status.staged.length > 0;
|
|
17
|
+
};
|
|
18
|
+
var getStagedDiffRaw = async () => {
|
|
19
|
+
return git.diff(["--cached", "--unified=3", "--no-color"]);
|
|
20
|
+
};
|
|
21
|
+
var HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/;
|
|
22
|
+
var parseDiffFromRaw = (raw) => {
|
|
23
|
+
if (!raw.trim()) return [];
|
|
24
|
+
const lines = raw.split("\n");
|
|
25
|
+
const files = [];
|
|
26
|
+
let currentFile = null;
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
if (line.startsWith("diff --git a/")) {
|
|
29
|
+
const pathMatch = line.match(/diff --git a\/(.+?) b\/(.+)$/);
|
|
30
|
+
if (pathMatch) {
|
|
31
|
+
const file = pathMatch[2];
|
|
32
|
+
currentFile = { file, hunks: [], additions: 0, deletions: 0 };
|
|
33
|
+
files.push(currentFile);
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (line.startsWith("diff --git")) continue;
|
|
38
|
+
if (line.startsWith("index ")) continue;
|
|
39
|
+
if (line.startsWith("--- ")) continue;
|
|
40
|
+
if (line.startsWith("+++ ")) continue;
|
|
41
|
+
if (line.startsWith("@@")) {
|
|
42
|
+
if (!currentFile) continue;
|
|
43
|
+
const m = line.match(HUNK_HEADER_RE);
|
|
44
|
+
if (!m) continue;
|
|
45
|
+
const from = parseInt(m[1], 10);
|
|
46
|
+
const fromLen = parseInt(m[2] || "1", 10);
|
|
47
|
+
const to = parseInt(m[3], 10);
|
|
48
|
+
const toLen = parseInt(m[4] || "1", 10);
|
|
49
|
+
const ctx = m[5]?.trim() || "";
|
|
50
|
+
currentFile.hunks.push({
|
|
51
|
+
file: currentFile.file,
|
|
52
|
+
header: line,
|
|
53
|
+
from,
|
|
54
|
+
to,
|
|
55
|
+
added: toLen,
|
|
56
|
+
removed: fromLen,
|
|
57
|
+
lines: [],
|
|
58
|
+
hash: "",
|
|
59
|
+
functionContext: ctx || void 0
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (currentFile && currentFile.hunks.length) {
|
|
64
|
+
const hunk = currentFile.hunks[currentFile.hunks.length - 1];
|
|
65
|
+
hunk.lines.push(line);
|
|
66
|
+
if (line.startsWith("+") && !line.startsWith("+++")) currentFile.additions++;
|
|
67
|
+
if (line.startsWith("-") && !line.startsWith("---")) currentFile.deletions++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const f of files) {
|
|
71
|
+
for (const h of f.hunks) {
|
|
72
|
+
h.hash = crypto.createHash("sha1").update(f.file + h.header + h.lines.join("\n")).digest("hex").slice(0, 8);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return files;
|
|
76
|
+
};
|
|
77
|
+
var parseDiff = async () => {
|
|
78
|
+
const raw = await getStagedDiffRaw();
|
|
79
|
+
return parseDiffFromRaw(raw);
|
|
80
|
+
};
|
|
81
|
+
var getRecentCommitMessages = async (limit) => {
|
|
82
|
+
const log = await git.log({ maxCount: limit });
|
|
83
|
+
return log.all.map((e) => e.message);
|
|
84
|
+
};
|
|
85
|
+
var createCommit = async (title, body) => {
|
|
86
|
+
if (body) {
|
|
87
|
+
await git.commit([title, body].join("\n\n"));
|
|
88
|
+
} else {
|
|
89
|
+
await git.commit(title);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// src/style.ts
|
|
94
|
+
var buildStyleProfile = (messages) => {
|
|
95
|
+
if (!messages.length) {
|
|
96
|
+
return {
|
|
97
|
+
tense: "imperative",
|
|
98
|
+
avgTitleLength: 50,
|
|
99
|
+
usesScopes: false,
|
|
100
|
+
gitmojiRatio: 0,
|
|
101
|
+
topPrefixes: [],
|
|
102
|
+
conventionalRatio: 0
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const titles = messages.map((m) => m.split("\n")[0]);
|
|
106
|
+
const avgTitleLength = titles.reduce((a, c) => a + c.length, 0) / Math.max(1, titles.length);
|
|
107
|
+
const gitmojiCount = titles.filter((t) => /[\u{1F300}-\u{1FAFF}]/u.test(t)).length;
|
|
108
|
+
const usesScopesCount = titles.filter((t) => /^\w+\(.+\):/.test(t)).length;
|
|
109
|
+
const conventionalCount = titles.filter(
|
|
110
|
+
(t) => /^(feat|fix|chore|docs|refactor|test|ci|perf|style)(\(.+\))?: /.test(t)
|
|
111
|
+
).length;
|
|
112
|
+
const prefixes = /* @__PURE__ */ new Map();
|
|
113
|
+
for (const t of titles) {
|
|
114
|
+
const m = t.match(/^(\w+)(\(.+\))?:/);
|
|
115
|
+
if (m) prefixes.set(m[1], (prefixes.get(m[1]) || 0) + 1);
|
|
116
|
+
}
|
|
117
|
+
const topPrefixes = [...prefixes.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k]) => k);
|
|
118
|
+
return {
|
|
119
|
+
tense: "imperative",
|
|
120
|
+
avgTitleLength,
|
|
121
|
+
usesScopes: usesScopesCount / titles.length > 0.25,
|
|
122
|
+
gitmojiRatio: gitmojiCount / titles.length,
|
|
123
|
+
topPrefixes,
|
|
124
|
+
conventionalRatio: conventionalCount / titles.length
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// src/prompt.ts
|
|
129
|
+
var summarizeDiffForPrompt = (files, privacy) => {
|
|
130
|
+
if (privacy === "high") {
|
|
131
|
+
return files.map((f) => `file: ${f.file} (+${f.additions} -${f.deletions}) hunks:${f.hunks.length}`).join("\n");
|
|
132
|
+
}
|
|
133
|
+
if (privacy === "medium") {
|
|
134
|
+
return files.map(
|
|
135
|
+
(f) => `file: ${f.file}
|
|
136
|
+
` + f.hunks.map(
|
|
137
|
+
(h) => ` hunk ${h.hash} context:${h.functionContext || ""} +${h.added} -${h.removed}`
|
|
138
|
+
).join("\n")
|
|
139
|
+
).join("\n");
|
|
140
|
+
}
|
|
141
|
+
return files.map(
|
|
142
|
+
(f) => `file: ${f.file}
|
|
143
|
+
` + f.hunks.map(
|
|
144
|
+
(h) => `${h.header}
|
|
145
|
+
${h.lines.slice(0, 40).join("\n")}${h.lines.length > 40 ? "\n[truncated]" : ""}`
|
|
146
|
+
).join("\n")
|
|
147
|
+
).join("\n");
|
|
148
|
+
};
|
|
149
|
+
var buildGenerationMessages = (opts) => {
|
|
150
|
+
const { files, style, config, mode, desiredCommits } = opts;
|
|
151
|
+
const diff = summarizeDiffForPrompt(files, config.privacy);
|
|
152
|
+
const TYPE_MAP = {
|
|
153
|
+
feat: "A new feature or capability added for the user",
|
|
154
|
+
fix: "A bug fix resolving incorrect behavior",
|
|
155
|
+
chore: "Internal change with no user-facing impact",
|
|
156
|
+
docs: "Documentation-only changes",
|
|
157
|
+
refactor: "Code change that neither fixes a bug nor adds a feature",
|
|
158
|
+
test: "Adding or improving tests only",
|
|
159
|
+
ci: "Changes to CI configuration or scripts",
|
|
160
|
+
perf: "Performance improvement",
|
|
161
|
+
style: "Formatting or stylistic change (no logic)",
|
|
162
|
+
build: "Build system or dependency changes",
|
|
163
|
+
revert: "Revert a previous commit",
|
|
164
|
+
merge: "Merge branches (rare; only if truly a merge commit)",
|
|
165
|
+
security: "Security-related change or hardening",
|
|
166
|
+
release: "Version bump or release meta change"
|
|
167
|
+
};
|
|
168
|
+
const specLines = [];
|
|
169
|
+
specLines.push(
|
|
170
|
+
"Purpose: Generate high-quality Conventional Commit messages for the provided git diff."
|
|
171
|
+
);
|
|
172
|
+
specLines.push("Locale: en");
|
|
173
|
+
specLines.push(
|
|
174
|
+
'Output JSON Schema: { "commits": [ { "title": string, "body": string, "score": 0-100, "reasons": string[] } ], "meta": { "splitRecommended": boolean } }'
|
|
175
|
+
);
|
|
176
|
+
specLines.push("Primary Output Field: commits[ ].title");
|
|
177
|
+
specLines.push("Title Format: <type>(<optional-scope>): <subject>");
|
|
178
|
+
specLines.push("Max Title Length: 72 characters (hard limit)");
|
|
179
|
+
specLines.push("Types (JSON mapping follows on next line)");
|
|
180
|
+
specLines.push("TypeMap: " + JSON.stringify(TYPE_MAP));
|
|
181
|
+
specLines.push("Scope Rules: optional; if present, lowercase kebab-case; omit when unclear.");
|
|
182
|
+
specLines.push(
|
|
183
|
+
"Subject Rules: imperative mood, present tense, no leading capital unless proper noun, no trailing period."
|
|
184
|
+
);
|
|
185
|
+
specLines.push("Length Rule: Entire title line (including type/scope) must be <= 72 chars.");
|
|
186
|
+
specLines.push(
|
|
187
|
+
"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.")
|
|
188
|
+
);
|
|
189
|
+
specLines.push(
|
|
190
|
+
"Forbidden: breaking changes notation, exclamation mark after type unless truly semver-major (avoid unless diff clearly indicates)."
|
|
191
|
+
);
|
|
192
|
+
specLines.push("Fallback Type: use chore when no other type clearly fits.");
|
|
193
|
+
specLines.push("Consistency: prefer existing top prefixes: " + style.topPrefixes.join(", "));
|
|
194
|
+
specLines.push("Provide score (0-100) measuring clarity & specificity (higher is better).");
|
|
195
|
+
specLines.push(
|
|
196
|
+
"Provide reasons array citing concrete diff elements: filenames, functions, tests, metrics."
|
|
197
|
+
);
|
|
198
|
+
specLines.push(
|
|
199
|
+
`Reasoning Depth: ${config.reasoning || "low"} (low=minimal concise reasons, medium=balanced detail, high=very detailed). Adjust reasons verbosity accordingly.`
|
|
200
|
+
);
|
|
201
|
+
specLines.push("Return ONLY the JSON object. No surrounding text or markdown.");
|
|
202
|
+
specLines.push("Do not add fields not listed in schema.");
|
|
203
|
+
specLines.push("Never fabricate content not present or implied by the diff.");
|
|
204
|
+
specLines.push(
|
|
205
|
+
"If mode is split and multiple logical changes exist, set meta.splitRecommended=true."
|
|
206
|
+
);
|
|
207
|
+
return [
|
|
208
|
+
{
|
|
209
|
+
role: "system",
|
|
210
|
+
content: specLines.join("\n")
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
role: "user",
|
|
214
|
+
content: `Mode: ${mode}
|
|
215
|
+
RequestedCommitCount: ${desiredCommits || (mode === "split" ? "2-6" : 1)}
|
|
216
|
+
StyleFingerprint: ${JSON.stringify(style)}
|
|
217
|
+
Diff:
|
|
218
|
+
${diff}
|
|
219
|
+
Generate commit candidates now.`
|
|
220
|
+
}
|
|
221
|
+
];
|
|
222
|
+
};
|
|
223
|
+
var buildRefineMessages = (opts) => {
|
|
224
|
+
const { originalPlan, index, instructions, config } = opts;
|
|
225
|
+
const target = originalPlan.commits[index];
|
|
226
|
+
const spec = [];
|
|
227
|
+
spec.push("Purpose: Refine a single Conventional Commit message while preserving intent.");
|
|
228
|
+
spec.push("Locale: en");
|
|
229
|
+
spec.push("Input: one existing commit JSON object.");
|
|
230
|
+
spec.push(
|
|
231
|
+
'Output JSON Schema: { "commits": [ { "title": string, "body": string, "score": 0-100, "reasons": string[] } ] }'
|
|
232
|
+
);
|
|
233
|
+
spec.push("Title Format: <type>(<optional-scope>): <subject> (<=72 chars)");
|
|
234
|
+
spec.push("Subject: imperative, present tense, no trailing period.");
|
|
235
|
+
spec.push(
|
|
236
|
+
"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.")
|
|
237
|
+
);
|
|
238
|
+
spec.push("Preserve semantic meaning; only improve clarity, scope, brevity, conformity.");
|
|
239
|
+
spec.push("If instructions request scope or emoji, incorporate only if justified by content.");
|
|
240
|
+
spec.push("Return ONLY JSON (commits array length=1).");
|
|
241
|
+
return [
|
|
242
|
+
{ role: "system", content: spec.join("\n") },
|
|
243
|
+
{
|
|
244
|
+
role: "user",
|
|
245
|
+
content: `Current commit object:
|
|
246
|
+
${JSON.stringify(target, null, 2)}
|
|
247
|
+
Instructions:
|
|
248
|
+
${instructions.join("\n") || "None"}
|
|
249
|
+
Refine now.`
|
|
250
|
+
}
|
|
251
|
+
];
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// src/model/provider.ts
|
|
255
|
+
import { z } from "zod";
|
|
256
|
+
import { execa } from "execa";
|
|
257
|
+
var OpenCodeProvider = class {
|
|
258
|
+
constructor(model = "github-copilot/gpt-5") {
|
|
259
|
+
this.model = model;
|
|
260
|
+
}
|
|
261
|
+
name() {
|
|
262
|
+
return "opencode";
|
|
263
|
+
}
|
|
264
|
+
async chat(messages, _opts) {
|
|
265
|
+
const debug = process.env.AICC_DEBUG === "true";
|
|
266
|
+
const mockMode = process.env.AICC_DEBUG_PROVIDER === "mock";
|
|
267
|
+
const timeoutMs = parseInt(process.env.AICC_MODEL_TIMEOUT_MS || "45000", 10);
|
|
268
|
+
const eager = process.env.AICC_EAGER_PARSE !== "false";
|
|
269
|
+
const userAggregate = messages.map((m) => `${m.role.toUpperCase()}: ${m.content}`).join("\n\n");
|
|
270
|
+
const command = `Generate high-quality commit message candidates based on the staged git diff.`;
|
|
271
|
+
const fullPrompt = `${command}
|
|
272
|
+
|
|
273
|
+
Context:
|
|
274
|
+
${userAggregate}`;
|
|
275
|
+
if (mockMode) {
|
|
276
|
+
if (debug) console.error("[aicc][mock] Returning deterministic mock response");
|
|
277
|
+
return JSON.stringify({
|
|
278
|
+
commits: [
|
|
279
|
+
{
|
|
280
|
+
title: "chore: mock commit from provider",
|
|
281
|
+
body: "",
|
|
282
|
+
score: 80,
|
|
283
|
+
reasons: ["mock mode"]
|
|
284
|
+
}
|
|
285
|
+
],
|
|
286
|
+
meta: { splitRecommended: false }
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
const start = Date.now();
|
|
290
|
+
return await new Promise((resolve3, reject) => {
|
|
291
|
+
let resolved = false;
|
|
292
|
+
let acc = "";
|
|
293
|
+
const includeLogs = process.env.AICC_PRINT_LOGS === "true";
|
|
294
|
+
const args = ["run", fullPrompt, "--model", this.model];
|
|
295
|
+
if (includeLogs) args.push("--print-logs");
|
|
296
|
+
const subprocess = execa("opencode", args, {
|
|
297
|
+
timeout: timeoutMs,
|
|
298
|
+
input: ""
|
|
299
|
+
// immediately close stdin in case CLI waits for it
|
|
300
|
+
});
|
|
301
|
+
const finish = (value) => {
|
|
302
|
+
if (resolved) return;
|
|
303
|
+
resolved = true;
|
|
304
|
+
const elapsed = Date.now() - start;
|
|
305
|
+
if (debug) {
|
|
306
|
+
console.error(
|
|
307
|
+
`[aicc][provider] model=${this.model} elapsedMs=${elapsed} promptChars=${fullPrompt.length} bytesOut=${value.length}`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
resolve3(value);
|
|
311
|
+
};
|
|
312
|
+
const tryEager = () => {
|
|
313
|
+
if (!eager) return;
|
|
314
|
+
const first = acc.indexOf("{");
|
|
315
|
+
const last = acc.lastIndexOf("}");
|
|
316
|
+
if (first !== -1 && last !== -1 && last > first) {
|
|
317
|
+
const candidate = acc.slice(first, last + 1).trim();
|
|
318
|
+
try {
|
|
319
|
+
JSON.parse(candidate);
|
|
320
|
+
if (debug) console.error("[aicc][provider] eager JSON detected, terminating process");
|
|
321
|
+
subprocess.kill("SIGTERM");
|
|
322
|
+
finish(candidate);
|
|
323
|
+
} catch {
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
subprocess.stdout?.on("data", (chunk) => {
|
|
328
|
+
const text = chunk.toString();
|
|
329
|
+
acc += text;
|
|
330
|
+
tryEager();
|
|
331
|
+
});
|
|
332
|
+
subprocess.stderr?.on("data", (chunk) => {
|
|
333
|
+
if (debug) console.error("[aicc][provider][stderr]", chunk.toString().trim());
|
|
334
|
+
});
|
|
335
|
+
subprocess.then(({ stdout }) => {
|
|
336
|
+
if (!resolved) finish(stdout);
|
|
337
|
+
}).catch((e) => {
|
|
338
|
+
if (resolved) return;
|
|
339
|
+
const elapsed = Date.now() - start;
|
|
340
|
+
if (e.timedOut) {
|
|
341
|
+
return reject(
|
|
342
|
+
new Error(`Model call timed out after ${timeoutMs}ms (elapsed=${elapsed}ms)`)
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (debug) console.error("[aicc][provider] failure", e.stderr || e.message);
|
|
346
|
+
reject(new Error(e.stderr || e.message || "opencode invocation failed"));
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
var CommitSchema = z.object({
|
|
352
|
+
title: z.string().min(5).max(150),
|
|
353
|
+
body: z.string().optional().default(""),
|
|
354
|
+
score: z.number().min(0).max(100),
|
|
355
|
+
reasons: z.array(z.string()).optional().default([])
|
|
356
|
+
});
|
|
357
|
+
var PlanSchema = z.object({
|
|
358
|
+
commits: z.array(CommitSchema).min(1),
|
|
359
|
+
meta: z.object({
|
|
360
|
+
splitRecommended: z.boolean().optional()
|
|
361
|
+
}).optional()
|
|
362
|
+
});
|
|
363
|
+
var extractJSON = (raw) => {
|
|
364
|
+
const trimmed = raw.trim();
|
|
365
|
+
let jsonText = null;
|
|
366
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
367
|
+
jsonText = trimmed;
|
|
368
|
+
} else {
|
|
369
|
+
const match = raw.match(/\{[\s\S]*\}$/m);
|
|
370
|
+
if (match) jsonText = match[0];
|
|
371
|
+
}
|
|
372
|
+
if (!jsonText) throw new Error("No JSON object detected.");
|
|
373
|
+
let parsed;
|
|
374
|
+
try {
|
|
375
|
+
parsed = JSON.parse(jsonText);
|
|
376
|
+
} catch (e) {
|
|
377
|
+
throw new Error("Invalid JSON parse");
|
|
378
|
+
}
|
|
379
|
+
return PlanSchema.parse(parsed);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// src/plugins.ts
|
|
383
|
+
import { resolve } from "path";
|
|
384
|
+
async function loadPlugins(config, cwd = process.cwd()) {
|
|
385
|
+
const out = [];
|
|
386
|
+
for (const p of config.plugins || []) {
|
|
387
|
+
try {
|
|
388
|
+
const mod = await import(resolve(cwd, p));
|
|
389
|
+
if (mod.default) out.push(mod.default);
|
|
390
|
+
else if (mod.plugin) out.push(mod.plugin);
|
|
391
|
+
} catch (e) {
|
|
392
|
+
if (config.verbose) {
|
|
393
|
+
console.error("[plugin]", p, "failed to load", e);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
async function applyTransforms(candidates, plugins, ctx) {
|
|
400
|
+
let current = candidates;
|
|
401
|
+
for (const pl of plugins) {
|
|
402
|
+
if (pl.transformCandidates) {
|
|
403
|
+
current = await pl.transformCandidates(current, ctx);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return current;
|
|
407
|
+
}
|
|
408
|
+
async function runValidations(candidate, plugins, ctx) {
|
|
409
|
+
const errors = [];
|
|
410
|
+
for (const pl of plugins) {
|
|
411
|
+
if (pl.validateCandidate) {
|
|
412
|
+
const res = await pl.validateCandidate(candidate, ctx);
|
|
413
|
+
if (typeof res === "string") errors.push(res);
|
|
414
|
+
else if (Array.isArray(res)) errors.push(...res);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return errors;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/guardrails.ts
|
|
421
|
+
var SECRET_PATTERNS = [
|
|
422
|
+
/AWS_[A-Z0-9_]+/i,
|
|
423
|
+
/BEGIN RSA PRIVATE KEY/,
|
|
424
|
+
/-----BEGIN PRIVATE KEY-----/,
|
|
425
|
+
/ssh-rsa AAAA/
|
|
426
|
+
];
|
|
427
|
+
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;
|
|
428
|
+
var sanitizeTitle = (title, allowEmoji) => {
|
|
429
|
+
let t = title.trim();
|
|
430
|
+
if (allowEmoji) {
|
|
431
|
+
const multi = t.match(/^((?:[\p{Emoji}\p{So}\p{Sk}]+)[\p{Emoji}\p{So}\p{Sk}\s]*)+/u);
|
|
432
|
+
if (multi) {
|
|
433
|
+
const first = Array.from(multi[0].trim())[0];
|
|
434
|
+
t = first + " " + t.slice(multi[0].length).trimStart();
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
t = t.replace(/^([\p{Emoji}\p{So}\p{Sk}\p{P}]+\s*)+/u, "").trimStart();
|
|
438
|
+
}
|
|
439
|
+
return t;
|
|
440
|
+
};
|
|
441
|
+
var normalizeConventionalTitle = (title) => {
|
|
442
|
+
let original = title.trim();
|
|
443
|
+
let leadingEmoji = "";
|
|
444
|
+
const emojiCluster = original.match(/^[\p{Emoji}\p{So}\p{Sk}]+/u);
|
|
445
|
+
if (emojiCluster) {
|
|
446
|
+
leadingEmoji = Array.from(emojiCluster[0])[0];
|
|
447
|
+
}
|
|
448
|
+
let t = original.replace(/^([\p{Emoji}\p{So}\p{Sk}\p{P}]+\s*)+/u, "").trim();
|
|
449
|
+
const m = t.match(/^(\w+)(\(.+\))?:\s+(.*)$/);
|
|
450
|
+
let result;
|
|
451
|
+
if (m) {
|
|
452
|
+
const type = m[1].toLowerCase();
|
|
453
|
+
const scope = m[2] || "";
|
|
454
|
+
let subject = m[3].trim();
|
|
455
|
+
subject = subject.replace(/\.$/, "");
|
|
456
|
+
subject = subject.charAt(0).toLowerCase() + subject.slice(1);
|
|
457
|
+
result = `${type}${scope}: ${subject}`;
|
|
458
|
+
} else if (!/^\w+\(.+\)?: /.test(t)) {
|
|
459
|
+
t = t.replace(/\.$/, "");
|
|
460
|
+
t = t.charAt(0).toLowerCase() + t.slice(1);
|
|
461
|
+
result = `chore: ${t}`;
|
|
462
|
+
} else {
|
|
463
|
+
result = t;
|
|
464
|
+
}
|
|
465
|
+
if (leadingEmoji) {
|
|
466
|
+
result = `${leadingEmoji} ${result}`;
|
|
467
|
+
}
|
|
468
|
+
return result.slice(0, 72);
|
|
469
|
+
};
|
|
470
|
+
var checkCandidate = (candidate) => {
|
|
471
|
+
const errs = [];
|
|
472
|
+
if (candidate.title.length > 72) errs.push("Title exceeds 72 chars.");
|
|
473
|
+
if (!CONVENTIONAL_RE.test(candidate.title)) {
|
|
474
|
+
errs.push("Not a valid conventional commit title.");
|
|
475
|
+
}
|
|
476
|
+
if (/^[A-Z]/.test(candidate.title)) {
|
|
477
|
+
}
|
|
478
|
+
const body = candidate.body || "";
|
|
479
|
+
for (const pat of SECRET_PATTERNS) {
|
|
480
|
+
if (pat.test(body)) {
|
|
481
|
+
errs.push("Potential secret detected.");
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return errs;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// src/title-format.ts
|
|
489
|
+
var EMOJI_MAP = {
|
|
490
|
+
feat: "\u2728",
|
|
491
|
+
fix: "\u{1F41B}",
|
|
492
|
+
chore: "\u{1F9F9}",
|
|
493
|
+
docs: "\u{1F4DD}",
|
|
494
|
+
refactor: "\u267B\uFE0F",
|
|
495
|
+
test: "\u2705",
|
|
496
|
+
ci: "\u{1F916}",
|
|
497
|
+
perf: "\u26A1\uFE0F",
|
|
498
|
+
style: "\u{1F3A8}",
|
|
499
|
+
build: "\u{1F3D7}\uFE0F",
|
|
500
|
+
revert: "\u23EA",
|
|
501
|
+
merge: "\u{1F500}",
|
|
502
|
+
security: "\u{1F512}",
|
|
503
|
+
release: "\u{1F3F7}\uFE0F"
|
|
504
|
+
};
|
|
505
|
+
var MAX_LEN = 72;
|
|
506
|
+
var EMOJI_TYPE_RE = /^([\p{Emoji}\p{So}\p{Sk}])\s+(\w+)(\(.+\))?:\s+(.*)$/u;
|
|
507
|
+
var TYPE_RE = /^(\w+)(\(.+\))?:\s+(.*)$/;
|
|
508
|
+
var formatCommitTitle = (raw, opts) => {
|
|
509
|
+
const { allowGitmoji, mode = "standard" } = opts;
|
|
510
|
+
let norm = normalizeConventionalTitle(sanitizeTitle(raw, allowGitmoji));
|
|
511
|
+
if (!allowGitmoji || mode !== "gitmoji" && mode !== "gitmoji-pure") {
|
|
512
|
+
return norm.slice(0, MAX_LEN);
|
|
513
|
+
}
|
|
514
|
+
if (mode === "gitmoji-pure") {
|
|
515
|
+
let m2 = norm.match(EMOJI_TYPE_RE);
|
|
516
|
+
if (m2) {
|
|
517
|
+
const emoji = m2[1];
|
|
518
|
+
const subject = m2[4];
|
|
519
|
+
norm = `${emoji}: ${subject}`;
|
|
520
|
+
} else if (m2 = norm.match(TYPE_RE)) {
|
|
521
|
+
const type = m2[1];
|
|
522
|
+
const subject = m2[3];
|
|
523
|
+
const em = EMOJI_MAP[type] || "\u{1F527}";
|
|
524
|
+
norm = `${em}: ${subject}`;
|
|
525
|
+
} else if (!/^([\p{Emoji}\p{So}\p{Sk}])+:/u.test(norm)) {
|
|
526
|
+
norm = `\u{1F527}: ${norm}`;
|
|
527
|
+
}
|
|
528
|
+
return norm.slice(0, MAX_LEN);
|
|
529
|
+
}
|
|
530
|
+
let m = norm.match(EMOJI_TYPE_RE);
|
|
531
|
+
if (m) {
|
|
532
|
+
return norm.slice(0, MAX_LEN);
|
|
533
|
+
}
|
|
534
|
+
if (m = norm.match(TYPE_RE)) {
|
|
535
|
+
const type = m[1];
|
|
536
|
+
const scope = m[2] || "";
|
|
537
|
+
const subject = m[3];
|
|
538
|
+
const em = EMOJI_MAP[type] || "\u{1F527}";
|
|
539
|
+
norm = `${em} ${type}${scope}: ${subject}`;
|
|
540
|
+
} else if (!/^([\p{Emoji}\p{So}\p{Sk}])+\s+\w+.*:/u.test(norm)) {
|
|
541
|
+
norm = `\u{1F527} chore: ${norm}`;
|
|
542
|
+
}
|
|
543
|
+
return norm.slice(0, MAX_LEN);
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
// src/workflow/generate.ts
|
|
547
|
+
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
548
|
+
import { join } from "path";
|
|
549
|
+
import inquirer from "inquirer";
|
|
550
|
+
async function runGenerate(config) {
|
|
551
|
+
if (!await ensureStagedChanges()) {
|
|
552
|
+
console.log("No staged changes.");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const files = await parseDiff();
|
|
556
|
+
if (!files.length) {
|
|
557
|
+
console.log("No diff content detected after staging. Aborting.");
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
console.log("\n\u250C " + chalk.bold("ai-conventional-commit"));
|
|
561
|
+
console.log("\u2502");
|
|
562
|
+
console.log(`\u25C6 ${chalk.bold(`Detected ${files.length} staged files:`)}`);
|
|
563
|
+
for (const f of files) console.log(" " + f.file);
|
|
564
|
+
console.log("\u2502");
|
|
565
|
+
const spinner = ora({ text: " Starting", spinner: "dots" }).start();
|
|
566
|
+
function setPhase(label) {
|
|
567
|
+
spinner.text = " " + chalk.bold(label);
|
|
568
|
+
}
|
|
569
|
+
async function runStep(label, fn) {
|
|
570
|
+
setPhase(label);
|
|
571
|
+
try {
|
|
572
|
+
return await fn();
|
|
573
|
+
} catch (e) {
|
|
574
|
+
spinner.fail(`\u25C7 ${label} failed: ${e.message}`);
|
|
575
|
+
throw e;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
let style;
|
|
579
|
+
let plugins;
|
|
580
|
+
let messages;
|
|
581
|
+
let raw;
|
|
582
|
+
let plan;
|
|
583
|
+
let candidates = [];
|
|
584
|
+
const provider = new OpenCodeProvider(config.model);
|
|
585
|
+
style = await runStep("Profiling style", async () => {
|
|
586
|
+
const history = await getRecentCommitMessages(config.styleSamples);
|
|
587
|
+
return buildStyleProfile(history);
|
|
588
|
+
});
|
|
589
|
+
plugins = await runStep("Loading plugins", async () => loadPlugins(config));
|
|
590
|
+
messages = await runStep(
|
|
591
|
+
"Building prompt",
|
|
592
|
+
async () => buildGenerationMessages({ files, style, config, mode: "single" })
|
|
593
|
+
);
|
|
594
|
+
raw = await runStep(
|
|
595
|
+
"Calling model",
|
|
596
|
+
async () => provider.chat(messages, { maxTokens: config.maxTokens })
|
|
597
|
+
);
|
|
598
|
+
plan = await runStep("Parsing response", async () => extractJSON(raw));
|
|
599
|
+
candidates = await runStep(
|
|
600
|
+
"Analyzing changes",
|
|
601
|
+
async () => applyTransforms(plan.commits, plugins, { cwd: process.cwd(), env: process.env })
|
|
602
|
+
);
|
|
603
|
+
setPhase("Result found");
|
|
604
|
+
spinner.stopAndPersist({ symbol: "\u25C6", text: " " + chalk.bold("Result found:") });
|
|
605
|
+
candidates = candidates.map((c) => ({
|
|
606
|
+
...c,
|
|
607
|
+
title: formatCommitTitle(c.title, {
|
|
608
|
+
allowGitmoji: !!config.gitmoji,
|
|
609
|
+
mode: config.gitmojiMode || "standard"
|
|
610
|
+
})
|
|
611
|
+
}));
|
|
612
|
+
const chosen = candidates[0];
|
|
613
|
+
console.log(" " + chalk.yellow(chosen.title));
|
|
614
|
+
if (chosen.body) {
|
|
615
|
+
const indent = " ";
|
|
616
|
+
chosen.body.split("\n").forEach((line) => {
|
|
617
|
+
if (line.trim().length === 0) console.log(indent);
|
|
618
|
+
else console.log(indent + chalk.gray(line));
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
console.log("\u2502");
|
|
622
|
+
const pluginErrors = await runValidations(chosen, plugins, {
|
|
623
|
+
cwd: process.cwd(),
|
|
624
|
+
env: process.env
|
|
625
|
+
});
|
|
626
|
+
const guardErrors = checkCandidate(chosen);
|
|
627
|
+
const errors = [...pluginErrors, ...guardErrors];
|
|
628
|
+
if (errors.length) {
|
|
629
|
+
console.log(chalk.red("! Validation issues:"));
|
|
630
|
+
errors.forEach((e) => console.log(" -", e));
|
|
631
|
+
console.log("\u2502");
|
|
632
|
+
}
|
|
633
|
+
const yn = await selectYesNo();
|
|
634
|
+
if (!yn) {
|
|
635
|
+
console.log("Aborted.");
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
await createCommit(chosen.title, chosen.body);
|
|
639
|
+
saveSession({ plan, chosen, mode: "single" });
|
|
640
|
+
console.log(chalk.green("Commit created."));
|
|
641
|
+
}
|
|
642
|
+
function saveSession(data) {
|
|
643
|
+
const dir = ".git/.aicc-cache";
|
|
644
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
645
|
+
writeFileSync(join(dir, "last-session.json"), JSON.stringify(data, null, 2));
|
|
646
|
+
}
|
|
647
|
+
async function selectYesNo() {
|
|
648
|
+
const { choice } = await inquirer.prompt([
|
|
649
|
+
{
|
|
650
|
+
type: "list",
|
|
651
|
+
name: "choice",
|
|
652
|
+
message: " Use this commit message?",
|
|
653
|
+
choices: [
|
|
654
|
+
{ name: "\u25CF Yes", value: true },
|
|
655
|
+
{ name: "\u25CB No", value: false }
|
|
656
|
+
],
|
|
657
|
+
default: 0
|
|
658
|
+
}
|
|
659
|
+
]);
|
|
660
|
+
return choice;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/workflow/split.ts
|
|
664
|
+
import chalk2 from "chalk";
|
|
665
|
+
|
|
666
|
+
// src/cluster.ts
|
|
667
|
+
var topLevel = (file) => file.split("/")[0] || file;
|
|
668
|
+
var clusterHunks = (files) => {
|
|
669
|
+
const clusters = [];
|
|
670
|
+
const byDir = /* @__PURE__ */ new Map();
|
|
671
|
+
for (const f of files) {
|
|
672
|
+
for (const h of f.hunks) {
|
|
673
|
+
const dir = topLevel(f.file);
|
|
674
|
+
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
675
|
+
byDir.get(dir).push(h);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
for (const [dir, hunks] of byDir.entries()) {
|
|
679
|
+
if (hunks.length <= 5) {
|
|
680
|
+
clusters.push({
|
|
681
|
+
id: `dir-${dir}`,
|
|
682
|
+
files: [...new Set(hunks.map((h) => h.file))],
|
|
683
|
+
hunkHashes: hunks.map((h) => h.hash),
|
|
684
|
+
rationale: `Changes grouped by directory ${dir}`
|
|
685
|
+
});
|
|
686
|
+
} else {
|
|
687
|
+
const perFile = /* @__PURE__ */ new Map();
|
|
688
|
+
for (const h of hunks) {
|
|
689
|
+
if (!perFile.has(h.file)) perFile.set(h.file, []);
|
|
690
|
+
perFile.get(h.file).push(h);
|
|
691
|
+
}
|
|
692
|
+
for (const [file, list] of perFile.entries()) {
|
|
693
|
+
clusters.push({
|
|
694
|
+
id: `file-${file}`,
|
|
695
|
+
files: [file],
|
|
696
|
+
hunkHashes: list.map((h) => h.hash),
|
|
697
|
+
rationale: `Large directory; grouped by file ${file}`
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return clusters;
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
// src/workflow/split.ts
|
|
706
|
+
import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
|
|
707
|
+
import { join as join2 } from "path";
|
|
708
|
+
import inquirer2 from "inquirer";
|
|
709
|
+
async function runSplit(config, desired) {
|
|
710
|
+
if (!await ensureStagedChanges()) {
|
|
711
|
+
console.log("No staged changes.");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const files = await parseDiff();
|
|
715
|
+
if (!files.length) {
|
|
716
|
+
console.log("No diff content detected after staging. Aborting.");
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
console.log("\u25C7 Clustering changes");
|
|
720
|
+
clusterHunks(files);
|
|
721
|
+
console.log("\u25C7 Profiling style");
|
|
722
|
+
const history = await getRecentCommitMessages(config.styleSamples);
|
|
723
|
+
const style = buildStyleProfile(history);
|
|
724
|
+
console.log("\u25C7 Loading plugins");
|
|
725
|
+
const plugins = await loadPlugins(config);
|
|
726
|
+
console.log("\u25C7 Building prompt");
|
|
727
|
+
const messages = buildGenerationMessages({
|
|
728
|
+
files,
|
|
729
|
+
style,
|
|
730
|
+
config,
|
|
731
|
+
mode: "split",
|
|
732
|
+
desiredCommits: desired
|
|
733
|
+
});
|
|
734
|
+
const provider = new OpenCodeProvider(config.model);
|
|
735
|
+
console.log("\u25C7 Calling model for split plan");
|
|
736
|
+
let raw;
|
|
737
|
+
try {
|
|
738
|
+
raw = await provider.chat(messages, { maxTokens: config.maxTokens });
|
|
739
|
+
} catch (e) {
|
|
740
|
+
console.log(e.message);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
console.log("\u25C7 Parsing response");
|
|
744
|
+
let plan;
|
|
745
|
+
try {
|
|
746
|
+
plan = extractJSON(raw);
|
|
747
|
+
} catch (e) {
|
|
748
|
+
console.log("JSON parse error: " + e.message);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
console.log("\u25C7 Plan received");
|
|
752
|
+
let candidates = await applyTransforms(plan.commits, plugins, {
|
|
753
|
+
cwd: process.cwd(),
|
|
754
|
+
env: process.env
|
|
755
|
+
});
|
|
756
|
+
candidates = candidates.map((c) => ({
|
|
757
|
+
...c,
|
|
758
|
+
title: formatCommitTitle(c.title, {
|
|
759
|
+
allowGitmoji: !!config.gitmoji,
|
|
760
|
+
mode: config.gitmojiMode || "standard"
|
|
761
|
+
})
|
|
762
|
+
}));
|
|
763
|
+
console.log(chalk2.cyan("\nProposed commits:"));
|
|
764
|
+
candidates.forEach((c) => {
|
|
765
|
+
console.log(chalk2.yellow(`\u2022 ${c.title}`));
|
|
766
|
+
if (c.body) {
|
|
767
|
+
const indent = " ";
|
|
768
|
+
c.body.split("\n").forEach((line) => {
|
|
769
|
+
if (line.trim().length === 0) console.log(indent);
|
|
770
|
+
else console.log(indent + chalk2.gray(line));
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
const { ok } = await inquirer2.prompt([
|
|
775
|
+
{
|
|
776
|
+
type: "list",
|
|
777
|
+
name: "ok",
|
|
778
|
+
message: "Apply these commit messages?",
|
|
779
|
+
choices: [
|
|
780
|
+
{ name: "\u25CF Yes", value: true },
|
|
781
|
+
{ name: "\u25CB No", value: false }
|
|
782
|
+
],
|
|
783
|
+
default: 0
|
|
784
|
+
}
|
|
785
|
+
]);
|
|
786
|
+
if (!ok) {
|
|
787
|
+
console.log("Aborted.");
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
for (const candidate of candidates) {
|
|
791
|
+
const pluginErrors = await runValidations(candidate, plugins, {
|
|
792
|
+
cwd: process.cwd(),
|
|
793
|
+
env: process.env
|
|
794
|
+
});
|
|
795
|
+
const guardErrors = checkCandidate(candidate);
|
|
796
|
+
const errors = [...pluginErrors, ...guardErrors];
|
|
797
|
+
if (errors.length) {
|
|
798
|
+
console.log(chalk2.red("Skipping commit due to errors:"), candidate.title);
|
|
799
|
+
errors.forEach((e) => console.log(" -", e));
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
await createCommit(candidate.title, candidate.body);
|
|
803
|
+
console.log(chalk2.green("Committed: ") + candidate.title);
|
|
804
|
+
}
|
|
805
|
+
saveSession2({ plan, chosen: candidates, mode: "split" });
|
|
806
|
+
}
|
|
807
|
+
function saveSession2(data) {
|
|
808
|
+
const dir = ".git/.aicc-cache";
|
|
809
|
+
if (!existsSync2(dir)) mkdirSync2(dir, { recursive: true });
|
|
810
|
+
writeFileSync2(join2(dir, "last-session.json"), JSON.stringify(data, null, 2));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// src/workflow/refine.ts
|
|
814
|
+
import chalk3 from "chalk";
|
|
815
|
+
import ora2 from "ora";
|
|
816
|
+
import { readFileSync, existsSync as existsSync3 } from "fs";
|
|
817
|
+
import { join as join3 } from "path";
|
|
818
|
+
|
|
819
|
+
// src/workflow/util.ts
|
|
820
|
+
import readline from "readline";
|
|
821
|
+
var prompt = async (question, defaultValue) => {
|
|
822
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
823
|
+
return new Promise((res) => {
|
|
824
|
+
rl.question(question, (answer) => {
|
|
825
|
+
rl.close();
|
|
826
|
+
if (!answer && defaultValue) return res(defaultValue);
|
|
827
|
+
res(answer);
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
// src/workflow/refine.ts
|
|
833
|
+
function loadSession() {
|
|
834
|
+
const path = join3(".git/.aicc-cache", "last-session.json");
|
|
835
|
+
if (!existsSync3(path)) return null;
|
|
836
|
+
try {
|
|
837
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
838
|
+
} catch {
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
async function runRefine(config, options) {
|
|
843
|
+
const spinner = ora2("Loading last session").start();
|
|
844
|
+
const session = loadSession();
|
|
845
|
+
if (!session) {
|
|
846
|
+
spinner.fail("No previous session found.");
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
spinner.succeed("Session loaded");
|
|
850
|
+
const plan = session.plan;
|
|
851
|
+
const index = options.index ?? 0;
|
|
852
|
+
if (!plan.commits[index]) {
|
|
853
|
+
console.log("Invalid index.");
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const instructions = [];
|
|
857
|
+
if (options.shorter) instructions.push("Make the title shorter but keep meaning.");
|
|
858
|
+
if (options.longer) instructions.push("Add more specificity to the title.");
|
|
859
|
+
if (options.scope) instructions.push(`Add or adjust scope to: ${options.scope}`);
|
|
860
|
+
if (options.emoji) instructions.push("Add a relevant emoji prefix.");
|
|
861
|
+
if (!instructions.length) {
|
|
862
|
+
const add = await prompt("No refinement flags given. Enter custom instruction: ");
|
|
863
|
+
if (add.trim()) instructions.push(add.trim());
|
|
864
|
+
else {
|
|
865
|
+
console.log("Nothing to refine.");
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const provider = new OpenCodeProvider(config.model);
|
|
870
|
+
const messages = buildRefineMessages({
|
|
871
|
+
originalPlan: plan,
|
|
872
|
+
index,
|
|
873
|
+
instructions,
|
|
874
|
+
config
|
|
875
|
+
});
|
|
876
|
+
const raw = await provider.chat(messages, { maxTokens: config.maxTokens });
|
|
877
|
+
let refined;
|
|
878
|
+
try {
|
|
879
|
+
refined = extractJSON(raw);
|
|
880
|
+
} catch (e) {
|
|
881
|
+
console.error("Failed to parse refine response:", e.message);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
{
|
|
885
|
+
refined.commits[0].title = formatCommitTitle(refined.commits[0].title, {
|
|
886
|
+
allowGitmoji: !!config.gitmoji || !!options.emoji,
|
|
887
|
+
mode: config.gitmojiMode || "standard"
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
console.log(chalk3.cyan("\nRefined candidate:"));
|
|
891
|
+
console.log(chalk3.yellow(refined.commits[0].title));
|
|
892
|
+
if (refined.commits[0].body) {
|
|
893
|
+
const indent = " ";
|
|
894
|
+
refined.commits[0].body.split("\n").forEach((line) => {
|
|
895
|
+
if (line.trim().length === 0) console.log(indent);
|
|
896
|
+
else console.log(indent + chalk3.gray(line));
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
const accept = await prompt("Accept refined version? (Y/n) ", "y");
|
|
900
|
+
if (!/^n/i.test(accept)) {
|
|
901
|
+
plan.commits[index] = refined.commits[0];
|
|
902
|
+
console.log(chalk3.green("Refinement stored (not retro-committed)."));
|
|
903
|
+
} else {
|
|
904
|
+
console.log("Refinement discarded.");
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// src/config.ts
|
|
909
|
+
import { cosmiconfig } from "cosmiconfig";
|
|
910
|
+
import { resolve as resolve2 } from "path";
|
|
911
|
+
import { existsSync as existsSync4 } from "fs";
|
|
912
|
+
var DEFAULTS = {
|
|
913
|
+
model: process.env.AICC_MODEL || "github-copilot/gpt-5",
|
|
914
|
+
privacy: process.env.AICC_PRIVACY || "low",
|
|
915
|
+
gitmoji: process.env.AICC_GITMOJI === "true",
|
|
916
|
+
gitmojiMode: "standard",
|
|
917
|
+
styleSamples: parseInt(process.env.AICC_STYLE_SAMPLES || "120", 10),
|
|
918
|
+
maxTokens: parseInt(process.env.AICC_MAX_TOKENS || "512", 10),
|
|
919
|
+
cacheDir: ".git/.aicc-cache",
|
|
920
|
+
plugins: [],
|
|
921
|
+
verbose: process.env.AICC_VERBOSE === "true",
|
|
922
|
+
reasoning: process.env.AICC_REASONING || "low"
|
|
923
|
+
};
|
|
924
|
+
async function loadConfig(cwd = process.cwd()) {
|
|
925
|
+
const explorer = cosmiconfig("aicc");
|
|
926
|
+
const result = await explorer.search(cwd);
|
|
927
|
+
const cfg = {
|
|
928
|
+
...DEFAULTS,
|
|
929
|
+
...result?.config || {}
|
|
930
|
+
};
|
|
931
|
+
cfg.plugins = (cfg.plugins || []).filter((p) => {
|
|
932
|
+
const abs = resolve2(cwd, p);
|
|
933
|
+
return existsSync4(abs);
|
|
934
|
+
});
|
|
935
|
+
return cfg;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/index.ts
|
|
939
|
+
var GenerateCommand = class extends Command {
|
|
940
|
+
static paths = [[`generate`], [`run`], [`commit`], []];
|
|
941
|
+
gitmoji = Option.Boolean("--gitmoji", false, {
|
|
942
|
+
description: "Gitmoji mode: emoji acts as type (emoji: subject)"
|
|
943
|
+
});
|
|
944
|
+
gitmojiPure = Option.Boolean("--gitmoji-pure", false, {
|
|
945
|
+
description: "Pure gitmoji mode: emoji: subject (no type)"
|
|
946
|
+
});
|
|
947
|
+
reasoning = Option.String("--reasoning", { required: false });
|
|
948
|
+
async execute() {
|
|
949
|
+
const config = await loadConfig();
|
|
950
|
+
if (this.gitmoji || this.gitmojiPure) {
|
|
951
|
+
config.gitmoji = true;
|
|
952
|
+
config.gitmojiMode = this.gitmojiPure ? "gitmoji-pure" : "gitmoji";
|
|
953
|
+
}
|
|
954
|
+
if (this.reasoning) config.reasoning = this.reasoning;
|
|
955
|
+
await runGenerate(config);
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
var SplitCommand = class extends Command {
|
|
959
|
+
static paths = [[`split`]];
|
|
960
|
+
max = Option.String("--max", { description: "Max proposed commits", required: false });
|
|
961
|
+
gitmoji = Option.Boolean("--gitmoji", false);
|
|
962
|
+
gitmojiPure = Option.Boolean("--gitmoji-pure", false);
|
|
963
|
+
reasoning = Option.String("--reasoning", { required: false });
|
|
964
|
+
async execute() {
|
|
965
|
+
const config = await loadConfig();
|
|
966
|
+
if (this.gitmoji || this.gitmojiPure) {
|
|
967
|
+
config.gitmoji = true;
|
|
968
|
+
config.gitmojiMode = this.gitmojiPure ? "gitmoji-pure" : "gitmoji";
|
|
969
|
+
}
|
|
970
|
+
if (this.reasoning) config.reasoning = this.reasoning;
|
|
971
|
+
await runSplit(config, this.max ? parseInt(this.max, 10) : void 0);
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
var RefineCommand = class extends Command {
|
|
975
|
+
static paths = [[`refine`]];
|
|
976
|
+
shorter = Option.Boolean("--shorter", false);
|
|
977
|
+
longer = Option.Boolean("--longer", false);
|
|
978
|
+
scope = Option.String("--scope");
|
|
979
|
+
emoji = Option.Boolean("--emoji", false);
|
|
980
|
+
index = Option.String("--index");
|
|
981
|
+
reasoning = Option.String("--reasoning", { required: false });
|
|
982
|
+
async execute() {
|
|
983
|
+
const config = await loadConfig();
|
|
984
|
+
if (this.reasoning) config.reasoning = this.reasoning;
|
|
985
|
+
await runRefine(config, {
|
|
986
|
+
shorter: this.shorter,
|
|
987
|
+
longer: this.longer,
|
|
988
|
+
scope: this.scope,
|
|
989
|
+
emoji: this.emoji,
|
|
990
|
+
index: this.index ? parseInt(this.index, 10) : void 0
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
var cli = new Cli({
|
|
995
|
+
binaryLabel: "aicc",
|
|
996
|
+
binaryName: "aicc",
|
|
997
|
+
binaryVersion: "0.1.0"
|
|
998
|
+
});
|
|
999
|
+
cli.register(GenerateCommand);
|
|
1000
|
+
cli.register(SplitCommand);
|
|
1001
|
+
cli.register(RefineCommand);
|
|
1002
|
+
cli.runExit(process.argv.slice(2), {
|
|
1003
|
+
stdin: process.stdin,
|
|
1004
|
+
stdout: process.stdout,
|
|
1005
|
+
stderr: process.stderr
|
|
1006
|
+
});
|