@kud/ai-conventional-commit-cli 0.12.12 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -221,10 +221,31 @@ Resolves via cosmiconfig (JSON/YAML/etc). Example:
221
221
  "style": "gitmoji",
222
222
  "styleSamples": 120,
223
223
  "plugins": ["./src/sample-plugin/example-plugin.ts"],
224
- "maxTokens": 512
224
+ "maxTokens": 512,
225
+ "maxFileLines": 1000,
226
+ "skipFilePatterns": [
227
+ "**/package-lock.json",
228
+ "**/yarn.lock",
229
+ "**/*.d.ts",
230
+ "**/dist/**"
231
+ ]
225
232
  }
226
233
  ```
227
234
 
235
+ ### Configuration Options
236
+
237
+ - **`maxFileLines`** (default: 1000): Skip file content in AI prompt if diff line count exceeds this threshold (per file). Files are still committed - the AI just sees the filename and stats instead of full content. Helps avoid token overflow from extremely large files.
238
+
239
+ - **`skipFilePatterns`** (default: see below): Glob patterns for files whose content should be skipped in the AI prompt but still committed. Useful for generated files, lock files, and build artifacts.
240
+
241
+ **Default patterns:**
242
+ - Lock files: `**/package-lock.json`, `**/yarn.lock`, `**/pnpm-lock.yaml`, `**/bun.lockb`, `**/composer.lock`, `**/Gemfile.lock`, `**/Cargo.lock`, `**/poetry.lock`
243
+ - TypeScript declarations: `**/*.d.ts`
244
+ - Build output: `**/dist/**`, `**/build/**`, `**/.next/**`, `**/out/**`, `**/coverage/**`
245
+ - Minified files: `**/*.min.js`, `**/*.min.css`, `**/*.map`
246
+
247
+ To override, provide your own array in config. To disable entirely, set to `[]`.
248
+
228
249
  Environment overrides (prefix `AICC_`):
229
250
 
230
251
  ### Configuration Precedence
@@ -253,7 +274,9 @@ ai-conventional-commit models --interactive --save # pick + persist globally
253
274
  ai-conventional-commit models --current # show active model + source
254
275
  ```
255
276
 
256
- `MODEL`, `PRIVACY`, `STYLE`, `STYLE_SAMPLES`, `MAX_TOKENS`, `VERBOSE`, `MODEL_TIMEOUT_MS`, `DEBUG`, `PRINT_LOGS`, `DEBUG_PROVIDER=mock`.
277
+ `MODEL`, `PRIVACY`, `STYLE`, `STYLE_SAMPLES`, `MAX_TOKENS`, `MAX_FILE_LINES`, `VERBOSE`, `MODEL_TIMEOUT_MS`, `DEBUG`, `PRINT_LOGS`, `DEBUG_PROVIDER=mock`.
278
+
279
+ **Note:** `skipFilePatterns` cannot be set via environment variable - use config file or accept defaults.
257
280
 
258
281
  ## Refinement Workflow
259
282
 
@@ -0,0 +1,120 @@
1
+ // src/config.ts
2
+ import { cosmiconfig } from "cosmiconfig";
3
+ import { resolve, dirname, join } from "path";
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
5
+ import { homedir } from "os";
6
+ var DEFAULTS = {
7
+ model: process.env.AICC_MODEL || "github-copilot/gpt-4.1",
8
+ privacy: process.env.AICC_PRIVACY || "low",
9
+ style: process.env.AICC_STYLE || "standard",
10
+ styleSamples: parseInt(process.env.AICC_STYLE_SAMPLES || "120", 10),
11
+ maxTokens: parseInt(process.env.AICC_MAX_TOKENS || "512", 10),
12
+ maxFileLines: parseInt(process.env.AICC_MAX_FILE_LINES || "1000", 10),
13
+ skipFilePatterns: [
14
+ "**/package-lock.json",
15
+ "**/yarn.lock",
16
+ "**/pnpm-lock.yaml",
17
+ "**/bun.lockb",
18
+ "**/composer.lock",
19
+ "**/Gemfile.lock",
20
+ "**/Cargo.lock",
21
+ "**/poetry.lock",
22
+ "**/*.d.ts",
23
+ "**/dist/**",
24
+ "**/build/**",
25
+ "**/.next/**",
26
+ "**/out/**",
27
+ "**/coverage/**",
28
+ "**/*.min.js",
29
+ "**/*.min.css",
30
+ "**/*.map"
31
+ ],
32
+ cacheDir: ".git/.aicc-cache",
33
+ plugins: [],
34
+ verbose: process.env.AICC_VERBOSE === "true"
35
+ };
36
+ function getGlobalConfigPath() {
37
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
38
+ return resolve(base, "ai-conventional-commit-cli", "aicc.json");
39
+ }
40
+ function saveGlobalConfig(partial) {
41
+ const filePath = getGlobalConfigPath();
42
+ const dir = dirname(filePath);
43
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
44
+ let existing = {};
45
+ if (existsSync(filePath)) {
46
+ try {
47
+ existing = JSON.parse(readFileSync(filePath, "utf8")) || {};
48
+ } catch (e) {
49
+ if (process.env.AICC_VERBOSE === "true") {
50
+ console.error("[ai-cc] Failed to parse existing global config, overwriting.");
51
+ }
52
+ }
53
+ }
54
+ const merged = { ...existing, ...partial };
55
+ writeFileSync(filePath, JSON.stringify(merged, null, 2) + "\n", "utf8");
56
+ return filePath;
57
+ }
58
+ async function loadConfig(cwd = process.cwd()) {
59
+ return (await loadConfigDetailed(cwd)).config;
60
+ }
61
+ async function loadConfigDetailed(cwd = process.cwd()) {
62
+ let globalCfg = {};
63
+ const globalPath = getGlobalConfigPath();
64
+ if (existsSync(globalPath)) {
65
+ try {
66
+ globalCfg = JSON.parse(readFileSync(globalPath, "utf8")) || {};
67
+ } catch (e) {
68
+ if (process.env.AICC_VERBOSE === "true") {
69
+ console.error("[ai-cc] Failed to parse global config, ignoring.");
70
+ }
71
+ }
72
+ }
73
+ const explorer = cosmiconfig("aicc");
74
+ const result = await explorer.search(cwd);
75
+ const projectCfg = result?.config || {};
76
+ const envCfg = {};
77
+ if (process.env.AICC_MODEL) envCfg.model = process.env.AICC_MODEL;
78
+ if (process.env.AICC_PRIVACY) envCfg.privacy = process.env.AICC_PRIVACY;
79
+ if (process.env.AICC_STYLE) envCfg.style = process.env.AICC_STYLE;
80
+ if (process.env.AICC_STYLE_SAMPLES)
81
+ envCfg.styleSamples = parseInt(process.env.AICC_STYLE_SAMPLES, 10);
82
+ if (process.env.AICC_MAX_TOKENS) envCfg.maxTokens = parseInt(process.env.AICC_MAX_TOKENS, 10);
83
+ if (process.env.AICC_MAX_FILE_LINES)
84
+ envCfg.maxFileLines = parseInt(process.env.AICC_MAX_FILE_LINES, 10);
85
+ if (process.env.AICC_VERBOSE) envCfg.verbose = process.env.AICC_VERBOSE === "true";
86
+ const merged = {
87
+ ...DEFAULTS,
88
+ ...globalCfg,
89
+ ...projectCfg,
90
+ ...envCfg
91
+ };
92
+ merged.plugins = (merged.plugins || []).filter((p) => {
93
+ const abs = resolve(cwd, p);
94
+ return existsSync(abs);
95
+ });
96
+ if (!merged.skipFilePatterns) {
97
+ merged.skipFilePatterns = DEFAULTS.skipFilePatterns;
98
+ }
99
+ const sources = Object.keys(merged).reduce((acc, key) => {
100
+ const k = key;
101
+ let src = "default";
102
+ if (k in globalCfg) src = "global";
103
+ if (k in projectCfg) src = "project";
104
+ if (k in envCfg) src = "env";
105
+ acc[k] = src;
106
+ return acc;
107
+ }, {});
108
+ const withMeta = Object.assign(merged, { _sources: sources });
109
+ return {
110
+ config: withMeta,
111
+ raw: { defaults: DEFAULTS, global: globalCfg, project: projectCfg, env: envCfg }
112
+ };
113
+ }
114
+
115
+ export {
116
+ getGlobalConfigPath,
117
+ saveGlobalConfig,
118
+ loadConfig,
119
+ loadConfigDetailed
120
+ };
@@ -0,0 +1,586 @@
1
+ // src/prompt.ts
2
+ var matchesPattern = (filePath, pattern) => {
3
+ const regexPattern = pattern.replace(/\*\*/g, "\xA7DOUBLESTAR\xA7").replace(/\*/g, "[^/]*").replace(/§DOUBLESTAR§/g, ".*").replace(/\./g, "\\.").replace(/\?/g, ".");
4
+ const regex = new RegExp(`^${regexPattern}$`);
5
+ return regex.test(filePath);
6
+ };
7
+ var summarizeDiffForPrompt = (files, privacy, maxFileLines, skipFilePatterns) => {
8
+ const getTotalLines = (f) => {
9
+ return f.hunks.reduce((sum, h) => sum + h.lines.length, 0);
10
+ };
11
+ const shouldSkipFile = (filePath) => {
12
+ return skipFilePatterns.some((pattern) => matchesPattern(filePath, pattern));
13
+ };
14
+ if (privacy === "high") {
15
+ return files.map((f) => {
16
+ const totalLines = getTotalLines(f);
17
+ const patternSkipped = shouldSkipFile(f.file);
18
+ const sizeSkipped = totalLines > maxFileLines;
19
+ const skipped = patternSkipped || sizeSkipped;
20
+ const reason = patternSkipped ? "generated/lock file" : "large file";
21
+ return `file: ${f.file} (+${f.additions} -${f.deletions}) hunks:${f.hunks.length}${skipped ? ` [${reason}, content skipped]` : ""}`;
22
+ }).join("\n");
23
+ }
24
+ if (privacy === "medium") {
25
+ return files.map((f) => {
26
+ const totalLines = getTotalLines(f);
27
+ const patternSkipped = shouldSkipFile(f.file);
28
+ const sizeSkipped = totalLines > maxFileLines;
29
+ if (patternSkipped || sizeSkipped) {
30
+ const reason = patternSkipped ? "generated/lock file" : "large file";
31
+ return `file: ${f.file} (+${f.additions} -${f.deletions}) hunks:${f.hunks.length} [${reason}, content skipped]`;
32
+ }
33
+ return `file: ${f.file}
34
+ ` + f.hunks.map(
35
+ (h) => ` hunk ${h.hash} context:${h.functionContext || ""} +${h.added} -${h.removed}`
36
+ ).join("\n");
37
+ }).join("\n");
38
+ }
39
+ return files.map((f) => {
40
+ const totalLines = getTotalLines(f);
41
+ const patternSkipped = shouldSkipFile(f.file);
42
+ const sizeSkipped = totalLines > maxFileLines;
43
+ if (patternSkipped || sizeSkipped) {
44
+ const reason = patternSkipped ? "generated/lock file" : "large file";
45
+ return `file: ${f.file} (+${f.additions} -${f.deletions}) hunks:${f.hunks.length} [${reason}, content skipped]`;
46
+ }
47
+ return `file: ${f.file}
48
+ ` + f.hunks.map(
49
+ (h) => `${h.header}
50
+ ${h.lines.slice(0, 40).join("\n")}${h.lines.length > 40 ? "\n[truncated]" : ""}`
51
+ ).join("\n");
52
+ }).join("\n");
53
+ };
54
+ var buildGenerationMessages = (opts) => {
55
+ const { files, style, config, mode, desiredCommits } = opts;
56
+ const diff = summarizeDiffForPrompt(
57
+ files,
58
+ config.privacy,
59
+ config.maxFileLines,
60
+ config.skipFilePatterns
61
+ );
62
+ const TYPE_MAP = {
63
+ feat: "A new feature or capability added for the user",
64
+ fix: "A bug fix resolving incorrect behavior",
65
+ chore: "Internal change with no user-facing impact",
66
+ docs: "Documentation-only changes",
67
+ refactor: "Code change that neither fixes a bug nor adds a feature",
68
+ test: "Adding or improving tests only",
69
+ ci: "Changes to CI configuration or scripts",
70
+ perf: "Performance improvement",
71
+ style: "Formatting or stylistic change (no logic)",
72
+ build: "Build system or dependency changes",
73
+ revert: "Revert a previous commit",
74
+ merge: "Merge branches (rare; only if truly a merge commit)",
75
+ security: "Security-related change or hardening",
76
+ release: "Version bump or release meta change"
77
+ };
78
+ const specLines = [];
79
+ specLines.push(
80
+ "Purpose: Generate high-quality Conventional Commit messages for the provided git diff."
81
+ );
82
+ specLines.push("Locale: en");
83
+ specLines.push(
84
+ 'Output JSON Schema: { "commits": [ { "title": string, "body": string, "score": 0-100, "reasons": string[], "files"?: string[] } ], "meta": { "splitRecommended": boolean } }'
85
+ );
86
+ specLines.push("Primary Output Field: commits[ ].title");
87
+ specLines.push("Title Format (REQUIRED): <type>(<scope>): <subject>");
88
+ specLines.push(
89
+ "Title Length Guidance: Aim for <=50 chars ideal; absolute max 72 (do not exceed)."
90
+ );
91
+ specLines.push("Types (JSON mapping follows on next line)");
92
+ specLines.push("TypeMap: " + JSON.stringify(TYPE_MAP));
93
+ specLines.push(
94
+ "Scope Rules: ALWAYS include a concise lowercase kebab-case scope (derive from dominant directory, package, or feature); never omit."
95
+ );
96
+ specLines.push(
97
+ "Subject Rules: imperative mood, present tense, no leading capital unless proper noun, no trailing period."
98
+ );
99
+ specLines.push(
100
+ "Length Rule: Keep titles concise; prefer 50 or fewer chars; MUST be <=72 including type/scope."
101
+ );
102
+ specLines.push(
103
+ "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.")
104
+ );
105
+ specLines.push(
106
+ "Forbidden: breaking changes notation, exclamation mark after type unless truly semver-major (avoid unless diff clearly indicates)."
107
+ );
108
+ specLines.push("Fallback Type: use chore when no other type clearly fits.");
109
+ specLines.push("Consistency: prefer existing top prefixes: " + style.topPrefixes.join(", "));
110
+ specLines.push("Provide score (0-100) measuring clarity & specificity (higher is better).");
111
+ specLines.push(
112
+ "Provide reasons array citing concrete diff elements: filenames, functions, tests, metrics."
113
+ );
114
+ specLines.push(
115
+ 'When mode is split, WHERE POSSIBLE add a "files" array per commit listing the most relevant changed file paths (1-6, minimize overlap across commits).'
116
+ );
117
+ specLines.push("Return ONLY the JSON object. No surrounding text or markdown.");
118
+ specLines.push("Do not add fields not listed in schema.");
119
+ specLines.push("Never fabricate content not present or implied by the diff.");
120
+ specLines.push(
121
+ "If mode is split and multiple logical changes exist, set meta.splitRecommended=true."
122
+ );
123
+ return [
124
+ {
125
+ role: "system",
126
+ content: specLines.join("\n")
127
+ },
128
+ {
129
+ role: "user",
130
+ content: `Mode: ${mode}
131
+ RequestedCommitCount: ${desiredCommits || (mode === "split" ? "2-6" : 1)}
132
+ StyleFingerprint: ${JSON.stringify(style)}
133
+ Diff:
134
+ ${diff}
135
+ Generate commit candidates now.`
136
+ }
137
+ ];
138
+ };
139
+ var buildRefineMessages = (opts) => {
140
+ const { originalPlan, index, instructions, config } = opts;
141
+ const target = originalPlan.commits[index];
142
+ const spec = [];
143
+ spec.push("Purpose: Refine a single Conventional Commit message while preserving intent.");
144
+ spec.push("Locale: en");
145
+ spec.push("Input: one existing commit JSON object.");
146
+ spec.push(
147
+ 'Output JSON Schema: { "commits": [ { "title": string, "body": string, "score": 0-100, "reasons": string[] } ] }'
148
+ );
149
+ spec.push("Title Format (REQUIRED): <type>(<scope>): <subject> (<=72 chars)");
150
+ spec.push("Subject: imperative, present tense, no trailing period.");
151
+ spec.push(
152
+ "Emoji Rule: " + (config.style === "gitmoji" || config.style === "gitmoji-pure" ? "OPTIONAL single leading gitmoji BEFORE type if it adds clarity; omit if unsure." : "Disallow all emojis; start directly with the type.")
153
+ );
154
+ spec.push(
155
+ "Preserve semantic meaning; ensure a scope is present (infer one if missing); only improve clarity, brevity, conformity."
156
+ );
157
+ spec.push("If instructions request scope or emoji, incorporate only if justified by content.");
158
+ spec.push("Return ONLY JSON (commits array length=1).");
159
+ return [
160
+ { role: "system", content: spec.join("\n") },
161
+ {
162
+ role: "user",
163
+ content: `Current commit object:
164
+ ${JSON.stringify(target, null, 2)}
165
+ Instructions:
166
+ ${instructions.join("\n") || "None"}
167
+ Refine now.`
168
+ }
169
+ ];
170
+ };
171
+
172
+ // src/model/provider.ts
173
+ import { z } from "zod";
174
+ import { execa } from "execa";
175
+ var OpenCodeProvider = class {
176
+ constructor(model = "github-copilot/gpt-4.1") {
177
+ this.model = model;
178
+ }
179
+ name() {
180
+ return "opencode";
181
+ }
182
+ async chat(messages, _opts) {
183
+ const debug = process.env.AICC_DEBUG === "true";
184
+ const mockMode = process.env.AICC_DEBUG_PROVIDER === "mock";
185
+ const timeoutMs = parseInt(process.env.AICC_MODEL_TIMEOUT_MS || "120000", 10);
186
+ const eager = process.env.AICC_EAGER_PARSE !== "false";
187
+ const userAggregate = messages.map((m) => `${m.role.toUpperCase()}: ${m.content}`).join("\n\n");
188
+ const command = `Generate high-quality commit message candidates based on the staged git diff.`;
189
+ const fullPrompt = `${command}
190
+
191
+ Context:
192
+ ${userAggregate}`;
193
+ if (mockMode) {
194
+ if (debug) console.error("[ai-cc][mock] Returning deterministic mock response");
195
+ return JSON.stringify({
196
+ commits: [
197
+ {
198
+ title: "chore: mock commit from provider",
199
+ body: "",
200
+ score: 80,
201
+ reasons: ["mock mode"]
202
+ }
203
+ ],
204
+ meta: { splitRecommended: false }
205
+ });
206
+ }
207
+ const start = Date.now();
208
+ return await new Promise((resolve, reject) => {
209
+ let resolved = false;
210
+ let acc = "";
211
+ const includeLogs = process.env.AICC_PRINT_LOGS === "true";
212
+ const args = ["run", fullPrompt, "--model", this.model];
213
+ if (includeLogs) args.push("--print-logs");
214
+ const subprocess = execa("opencode", args, {
215
+ timeout: timeoutMs,
216
+ input: ""
217
+ // immediately close stdin in case CLI waits for it
218
+ });
219
+ const finish = (value) => {
220
+ if (resolved) return;
221
+ resolved = true;
222
+ const elapsed = Date.now() - start;
223
+ if (debug) {
224
+ console.error(
225
+ `[ai-cc][provider] model=${this.model} elapsedMs=${elapsed} promptChars=${fullPrompt.length} bytesOut=${value.length}`
226
+ );
227
+ }
228
+ resolve(value);
229
+ };
230
+ const tryEager = () => {
231
+ if (!eager) return;
232
+ const first = acc.indexOf("{");
233
+ const last = acc.lastIndexOf("}");
234
+ if (first !== -1 && last !== -1 && last > first) {
235
+ const candidate = acc.slice(first, last + 1).trim();
236
+ try {
237
+ JSON.parse(candidate);
238
+ if (debug) console.error("[ai-cc][provider] eager JSON detected, terminating process");
239
+ subprocess.kill("SIGTERM");
240
+ finish(candidate);
241
+ } catch {
242
+ }
243
+ }
244
+ };
245
+ subprocess.stdout?.on("data", (chunk) => {
246
+ const text = chunk.toString();
247
+ acc += text;
248
+ tryEager();
249
+ });
250
+ subprocess.stderr?.on("data", (chunk) => {
251
+ if (debug) console.error("[ai-cc][provider][stderr]", chunk.toString().trim());
252
+ });
253
+ subprocess.then(({ stdout }) => {
254
+ if (!resolved) finish(stdout);
255
+ }).catch((e) => {
256
+ if (resolved) return;
257
+ const elapsed = Date.now() - start;
258
+ if (e.timedOut) {
259
+ return reject(
260
+ new Error(`Model call timed out after ${timeoutMs}ms (elapsed=${elapsed}ms)`)
261
+ );
262
+ }
263
+ if (debug) console.error("[ai-cc][provider] failure", e.stderr || e.message);
264
+ reject(new Error(e.stderr || e.message || "opencode invocation failed"));
265
+ });
266
+ });
267
+ }
268
+ };
269
+ var CommitSchema = z.object({
270
+ title: z.string().min(5).max(150),
271
+ body: z.string().optional().default(""),
272
+ score: z.number().min(0).max(100),
273
+ reasons: z.array(z.string()).optional().default([]),
274
+ files: z.array(z.string()).optional().default([])
275
+ });
276
+ var PlanSchema = z.object({
277
+ commits: z.array(CommitSchema).min(1),
278
+ meta: z.object({
279
+ splitRecommended: z.boolean().optional()
280
+ }).optional()
281
+ });
282
+ var extractJSON = (raw) => {
283
+ const trimmed = raw.trim();
284
+ let jsonText = null;
285
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
286
+ jsonText = trimmed;
287
+ } else {
288
+ const match = raw.match(/\{[\s\S]*\}$/m);
289
+ if (match) jsonText = match[0];
290
+ }
291
+ if (!jsonText) throw new Error("No JSON object detected.");
292
+ let parsed;
293
+ try {
294
+ parsed = JSON.parse(jsonText);
295
+ } catch (e) {
296
+ throw new Error("Invalid JSON parse");
297
+ }
298
+ return PlanSchema.parse(parsed);
299
+ };
300
+
301
+ // src/guardrails.ts
302
+ var SECRET_PATTERNS = [
303
+ /AWS_[A-Z0-9_]+/i,
304
+ /BEGIN RSA PRIVATE KEY/,
305
+ /-----BEGIN PRIVATE KEY-----/,
306
+ /ssh-rsa AAAA/
307
+ ];
308
+ 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;
309
+ var sanitizeTitle = (title, allowEmoji) => {
310
+ let t = title.trim();
311
+ if (allowEmoji) {
312
+ const multi = t.match(/^((?:[\p{Emoji}\p{So}\p{Sk}]+)[\p{Emoji}\p{So}\p{Sk}\s]*)+/u);
313
+ if (multi) {
314
+ const first = Array.from(multi[0].trim())[0];
315
+ t = first + " " + t.slice(multi[0].length).trimStart();
316
+ }
317
+ } else {
318
+ t = t.replace(/^([\p{Emoji}\p{So}\p{Sk}\p{P}]+\s*)+/u, "").trimStart();
319
+ }
320
+ return t;
321
+ };
322
+ var normalizeConventionalTitle = (title) => {
323
+ let original = title.trim();
324
+ let leadingEmoji = "";
325
+ const emojiCluster = original.match(/^[\p{Emoji}\p{So}\p{Sk}]+/u);
326
+ if (emojiCluster) {
327
+ leadingEmoji = Array.from(emojiCluster[0])[0];
328
+ }
329
+ let t = original.replace(/^([\p{Emoji}\p{So}\p{Sk}\p{P}]+\s*)+/u, "").trim();
330
+ const m = t.match(/^(\w+)(\(.+\))?:\s+(.*)$/);
331
+ let result;
332
+ if (m) {
333
+ const type = m[1].toLowerCase();
334
+ const scope = m[2] || "";
335
+ let subject = m[3].trim();
336
+ subject = subject.replace(/\.$/, "");
337
+ subject = subject.charAt(0).toLowerCase() + subject.slice(1);
338
+ result = `${type}${scope}: ${subject}`;
339
+ } else if (!/^\w+\(.+\)?: /.test(t)) {
340
+ t = t.replace(/\.$/, "");
341
+ t = t.charAt(0).toLowerCase() + t.slice(1);
342
+ result = `chore: ${t}`;
343
+ } else {
344
+ result = t;
345
+ }
346
+ if (leadingEmoji) {
347
+ result = `${leadingEmoji} ${result}`;
348
+ }
349
+ return result;
350
+ };
351
+ var checkCandidate = (candidate) => {
352
+ const errs = [];
353
+ if (!CONVENTIONAL_RE.test(candidate.title)) {
354
+ errs.push("Not a valid conventional commit title.");
355
+ }
356
+ if (/^[A-Z]/.test(candidate.title)) {
357
+ }
358
+ const body = candidate.body || "";
359
+ for (const pat of SECRET_PATTERNS) {
360
+ if (pat.test(body)) {
361
+ errs.push("Potential secret detected.");
362
+ break;
363
+ }
364
+ }
365
+ return errs;
366
+ };
367
+
368
+ // src/title-format.ts
369
+ var EMOJI_MAP = {
370
+ feat: "\u2728",
371
+ fix: "\u{1F41B}",
372
+ chore: "\u{1F9F9}",
373
+ docs: "\u{1F4DD}",
374
+ refactor: "\u267B\uFE0F",
375
+ test: "\u2705",
376
+ ci: "\u{1F916}",
377
+ perf: "\u26A1\uFE0F",
378
+ style: "\u{1F3A8}",
379
+ build: "\u{1F3D7}\uFE0F",
380
+ revert: "\u23EA",
381
+ merge: "\u{1F500}",
382
+ security: "\u{1F512}",
383
+ release: "\u{1F3F7}\uFE0F"
384
+ };
385
+ var EMOJI_TYPE_RE = /^([\p{Emoji}\p{So}\p{Sk}])\s+(\w+)(\(.+\))?:\s+(.*)$/u;
386
+ var TYPE_RE = /^(\w+)(\(.+\))?:\s+(.*)$/;
387
+ var formatCommitTitle = (raw, opts) => {
388
+ const { allowGitmoji, mode = "standard" } = opts;
389
+ let norm = normalizeConventionalTitle(sanitizeTitle(raw, allowGitmoji));
390
+ if (!allowGitmoji || mode !== "gitmoji" && mode !== "gitmoji-pure") {
391
+ return norm;
392
+ }
393
+ if (mode === "gitmoji-pure") {
394
+ let m2 = norm.match(EMOJI_TYPE_RE);
395
+ if (m2) {
396
+ const emoji = m2[1];
397
+ const subject = m2[4];
398
+ norm = `${emoji}: ${subject}`;
399
+ } else if (m2 = norm.match(TYPE_RE)) {
400
+ const type = m2[1];
401
+ const subject = m2[3];
402
+ const em = EMOJI_MAP[type] || "\u{1F527}";
403
+ norm = `${em}: ${subject}`;
404
+ } else if (!/^([\p{Emoji}\p{So}\p{Sk}])+:/u.test(norm)) {
405
+ norm = `\u{1F527}: ${norm}`;
406
+ }
407
+ return norm;
408
+ }
409
+ let m = norm.match(EMOJI_TYPE_RE);
410
+ if (m) {
411
+ return norm;
412
+ }
413
+ if (m = norm.match(TYPE_RE)) {
414
+ const type = m[1];
415
+ const scope = m[2] || "";
416
+ const subject = m[3];
417
+ const em = EMOJI_MAP[type] || "\u{1F527}";
418
+ norm = `${em} ${type}${scope}: ${subject}`;
419
+ } else if (!/^([\p{Emoji}\p{So}\p{Sk}])+\s+\w+.*:/u.test(norm)) {
420
+ norm = `\u{1F527} chore: ${norm}`;
421
+ }
422
+ return norm;
423
+ };
424
+
425
+ // src/workflow/ui.ts
426
+ import chalk from "chalk";
427
+ function animateHeaderBase(text = "ai-conventional-commit", modelSegment) {
428
+ const mainText = text;
429
+ const modelSeg = modelSegment ? ` (using ${modelSegment})` : "";
430
+ if (!process.stdout.isTTY || process.env.AICC_NO_ANIMATION) {
431
+ if (modelSeg) console.log("\n\u250C " + chalk.bold(mainText) + chalk.dim(modelSeg));
432
+ else console.log("\n\u250C " + chalk.bold(mainText));
433
+ return Promise.resolve();
434
+ }
435
+ const palette = [
436
+ "#3a0d6d",
437
+ "#5a1ea3",
438
+ "#7a32d6",
439
+ "#9a4dff",
440
+ "#b267ff",
441
+ "#c37dff",
442
+ "#b267ff",
443
+ "#9a4dff",
444
+ "#7a32d6",
445
+ "#5a1ea3"
446
+ ];
447
+ process.stdout.write("\n");
448
+ return palette.reduce(async (p, color) => {
449
+ await p;
450
+ const frame = chalk.bold.hex(color)(mainText);
451
+ if (modelSeg) process.stdout.write("\r\u250C " + frame + chalk.dim(modelSeg));
452
+ else process.stdout.write("\r\u250C " + frame);
453
+ await new Promise((r) => setTimeout(r, 60));
454
+ }, Promise.resolve()).then(() => process.stdout.write("\n"));
455
+ }
456
+ function borderLine(content) {
457
+ if (!content) console.log("\u2502");
458
+ else console.log("\u2502 " + content);
459
+ }
460
+ function sectionTitle(label) {
461
+ console.log("\u2299 " + chalk.bold(label));
462
+ }
463
+ function abortMessage() {
464
+ console.log("\u2514 \u{1F645}\u200D\u2640\uFE0F No commit created.");
465
+ console.log();
466
+ }
467
+ function finalSuccess(opts) {
468
+ const elapsedMs = Date.now() - opts.startedAt;
469
+ const seconds = elapsedMs / 1e3;
470
+ const dur = seconds >= 0.1 ? seconds.toFixed(1) + "s" : elapsedMs + "ms";
471
+ const plural = opts.count !== 1;
472
+ if (plural) console.log(`\u2514 \u2728 ${opts.count} commits created in ${dur}.`);
473
+ else console.log(`\u2514 \u2728 commit created in ${dur}.`);
474
+ console.log();
475
+ }
476
+ function createPhasedSpinner(oraLib) {
477
+ const useAnim = process.stdout.isTTY && !process.env.AICC_NO_ANIMATION && !process.env.AICC_NO_SPINNER_ANIM;
478
+ const palette = [
479
+ "#3a0d6d",
480
+ "#5a1ea3",
481
+ "#7a32d6",
482
+ "#9a4dff",
483
+ "#b267ff",
484
+ "#c37dff",
485
+ "#b267ff",
486
+ "#9a4dff",
487
+ "#7a32d6",
488
+ "#5a1ea3"
489
+ ];
490
+ let label = "Starting";
491
+ let i = 0;
492
+ const spinner = oraLib({ text: chalk.bold(label), spinner: "dots" }).start();
493
+ let interval = null;
494
+ function frame() {
495
+ if (!useAnim) return;
496
+ spinner.text = chalk.bold.hex(palette[i])(label);
497
+ i = (i + 1) % palette.length;
498
+ }
499
+ if (useAnim) {
500
+ frame();
501
+ interval = setInterval(frame, 80);
502
+ }
503
+ function setLabel(next) {
504
+ label = next;
505
+ if (useAnim) {
506
+ i = 0;
507
+ frame();
508
+ } else {
509
+ spinner.text = chalk.bold(label);
510
+ }
511
+ }
512
+ function stopAnim() {
513
+ if (interval) {
514
+ clearInterval(interval);
515
+ interval = null;
516
+ }
517
+ }
518
+ return {
519
+ spinner,
520
+ async step(l, fn) {
521
+ setLabel(l);
522
+ try {
523
+ return await fn();
524
+ } catch (e) {
525
+ stopAnim();
526
+ const msg = `${l} failed: ${e?.message || e}`.replace(/^\s+/, "");
527
+ spinner.fail(msg);
528
+ throw e;
529
+ }
530
+ },
531
+ phase(l) {
532
+ setLabel(l);
533
+ },
534
+ stop() {
535
+ stopAnim();
536
+ spinner.stop();
537
+ }
538
+ };
539
+ }
540
+ function renderCommitBlock(opts) {
541
+ const dim = (s) => chalk.dim(s);
542
+ const white = (s) => chalk.white(s);
543
+ const msgColor = opts.messageLabelColor || dim;
544
+ const descColor = opts.descriptionLabelColor || dim;
545
+ const titleColor = opts.titleColor || white;
546
+ const bodyFirst = opts.bodyFirstLineColor || white;
547
+ const bodyRest = opts.bodyLineColor || white;
548
+ if (opts.fancy) {
549
+ const heading = opts.heading ? chalk.hex("#9a4dff").bold(opts.heading) : void 0;
550
+ if (heading) borderLine(heading);
551
+ borderLine(msgColor("Title:") + " " + titleColor(`${opts.indexPrefix || ""}${opts.title}`));
552
+ } else {
553
+ if (opts.heading) borderLine(chalk.bold(opts.heading));
554
+ if (!opts.hideMessageLabel)
555
+ borderLine(msgColor("Message:") + " " + titleColor(`${opts.indexPrefix || ""}${opts.title}`));
556
+ else
557
+ borderLine(msgColor("Title:") + " " + titleColor(`${opts.indexPrefix || ""}${opts.title}`));
558
+ }
559
+ borderLine();
560
+ if (opts.body) {
561
+ const lines = opts.body.split("\n");
562
+ lines.forEach((line, i) => {
563
+ if (line.trim().length === 0) borderLine();
564
+ else if (i === 0) {
565
+ borderLine(descColor("Description:"));
566
+ borderLine(bodyFirst(line));
567
+ } else borderLine(bodyRest(line));
568
+ });
569
+ }
570
+ }
571
+
572
+ export {
573
+ buildGenerationMessages,
574
+ buildRefineMessages,
575
+ OpenCodeProvider,
576
+ extractJSON,
577
+ checkCandidate,
578
+ formatCommitTitle,
579
+ animateHeaderBase,
580
+ borderLine,
581
+ sectionTitle,
582
+ abortMessage,
583
+ finalSuccess,
584
+ createPhasedSpinner,
585
+ renderCommitBlock
586
+ };
@@ -0,0 +1,12 @@
1
+ import {
2
+ getGlobalConfigPath,
3
+ loadConfig,
4
+ loadConfigDetailed,
5
+ saveGlobalConfig
6
+ } from "./chunk-HJR5M6U7.js";
7
+ export {
8
+ getGlobalConfigPath,
9
+ loadConfig,
10
+ loadConfigDetailed,
11
+ saveGlobalConfig
12
+ };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  loadConfig
4
- } from "./chunk-DCGUX6KW.js";
4
+ } from "./chunk-HJR5M6U7.js";
5
5
  import {
6
6
  OpenCodeProvider,
7
7
  abortMessage,
@@ -16,7 +16,7 @@ import {
16
16
  formatCommitTitle,
17
17
  renderCommitBlock,
18
18
  sectionTitle
19
- } from "./chunk-H4W6AMGZ.js";
19
+ } from "./chunk-WW3N76NL.js";
20
20
 
21
21
  // src/index.ts
22
22
  import { Cli, Command, Option } from "clipanion";
@@ -34,7 +34,7 @@ var ensureStagedChanges = async () => {
34
34
  return status.staged.length > 0;
35
35
  };
36
36
  var getStagedDiffRaw = async () => {
37
- return git.diff(["--cached", "--unified=3", "--no-color"]);
37
+ return git.diff(["--cached", "--unified=3", "--no-color", "-M"]);
38
38
  };
39
39
  var HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/;
40
40
  var parseDiffFromRaw = (raw) => {
@@ -54,6 +54,9 @@ var parseDiffFromRaw = (raw) => {
54
54
  }
55
55
  if (line.startsWith("diff --git")) continue;
56
56
  if (line.startsWith("index ")) continue;
57
+ if (line.startsWith("similarity index ")) continue;
58
+ if (line.startsWith("rename from ")) continue;
59
+ if (line.startsWith("rename to ")) continue;
57
60
  if (/^deleted file mode /.test(line)) {
58
61
  if (currentFile) currentFile.deleted = true;
59
62
  continue;
@@ -854,7 +857,7 @@ var ModelsCommand = class extends Command {
854
857
  });
855
858
  async execute() {
856
859
  if (this.current) {
857
- const { loadConfigDetailed } = await import("./config-Q7AKJSO4.js");
860
+ const { loadConfigDetailed } = await import("./config-C3S4LWLD.js");
858
861
  const { config } = await loadConfigDetailed();
859
862
  this.context.stdout.write(`${config.model} (source: ${config._sources.model})
860
863
  `);
@@ -898,7 +901,7 @@ var ModelsCommand = class extends Command {
898
901
  this.context.stdout.write(model + "\n");
899
902
  if (this.save) {
900
903
  try {
901
- const { saveGlobalConfig } = await import("./config-Q7AKJSO4.js");
904
+ const { saveGlobalConfig } = await import("./config-C3S4LWLD.js");
902
905
  const path = saveGlobalConfig({ model });
903
906
  this.context.stdout.write(`Saved as default model in ${path}
904
907
  `);
@@ -933,7 +936,7 @@ var ConfigShowCommand = class extends Command {
933
936
  });
934
937
  json = Option.Boolean("--json", false, { description: "Output JSON including _sources" });
935
938
  async execute() {
936
- const { loadConfigDetailed } = await import("./config-Q7AKJSO4.js");
939
+ const { loadConfigDetailed } = await import("./config-C3S4LWLD.js");
937
940
  const { config, raw } = await loadConfigDetailed();
938
941
  if (this.json) {
939
942
  this.context.stdout.write(JSON.stringify({ config, raw }, null, 2) + "\n");
@@ -958,7 +961,7 @@ var ConfigGetCommand = class extends Command {
958
961
  key = Option.String();
959
962
  withSource = Option.Boolean("--with-source", false, { description: "Append source label" });
960
963
  async execute() {
961
- const { loadConfigDetailed } = await import("./config-Q7AKJSO4.js");
964
+ const { loadConfigDetailed } = await import("./config-C3S4LWLD.js");
962
965
  const { config } = await loadConfigDetailed();
963
966
  const key = this.key;
964
967
  if (!(key in config)) {
@@ -1006,7 +1009,7 @@ var ConfigSetCommand = class extends Command {
1006
1009
  } catch {
1007
1010
  }
1008
1011
  }
1009
- const { saveGlobalConfig } = await import("./config-Q7AKJSO4.js");
1012
+ const { saveGlobalConfig } = await import("./config-C3S4LWLD.js");
1010
1013
  const path = saveGlobalConfig({ [this.key]: parsed });
1011
1014
  this.context.stdout.write(`Saved ${this.key} to ${path}
1012
1015
  `);
@@ -1027,7 +1030,7 @@ var RewordCommand = class extends Command {
1027
1030
  style = Option.String("--style", { required: false, description: "Title style override" });
1028
1031
  model = Option.String("-m,--model", { required: false, description: "Model override" });
1029
1032
  async execute() {
1030
- const { runReword } = await import("./reword-IN2D2J4H.js");
1033
+ const { runReword } = await import("./reword-Q7MES34W.js");
1031
1034
  const config = await loadConfig();
1032
1035
  if (this.style) config.style = this.style;
1033
1036
  if (this.model) config.model = this.model;
@@ -0,0 +1,212 @@
1
+ import {
2
+ OpenCodeProvider,
3
+ abortMessage,
4
+ animateHeaderBase,
5
+ borderLine,
6
+ buildRefineMessages,
7
+ createPhasedSpinner,
8
+ extractJSON,
9
+ finalSuccess,
10
+ formatCommitTitle,
11
+ renderCommitBlock,
12
+ sectionTitle
13
+ } from "./chunk-WW3N76NL.js";
14
+
15
+ // src/workflow/reword.ts
16
+ import chalk from "chalk";
17
+ import ora from "ora";
18
+ import inquirer from "inquirer";
19
+ import { simpleGit } from "simple-git";
20
+ var git = simpleGit();
21
+ async function getCommitMessage(hash) {
22
+ try {
23
+ const raw = await git.show([`${hash}`, "--quiet", "--format=%P%n%B"]);
24
+ const lines = raw.split("\n");
25
+ const parentsLine = lines.shift() || "";
26
+ const parents = parentsLine.trim().length ? parentsLine.trim().split(/\s+/) : [];
27
+ const message = lines.join("\n").trim();
28
+ if (!message) return null;
29
+ const [first, ...rest] = message.split("\n");
30
+ const body = rest.join("\n").trim() || void 0;
31
+ return { title: first, body, parents };
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+ async function isAncestor(ancestor, head) {
37
+ try {
38
+ const mb = (await git.raw(["merge-base", ancestor, head])).trim();
39
+ const anc = (await git.revparse([ancestor])).trim();
40
+ return mb === anc;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+ async function runReword(config, hash) {
46
+ const startedAt = Date.now();
47
+ const commit = await getCommitMessage(hash);
48
+ if (!commit) {
49
+ console.log(`Commit not found: ${hash}`);
50
+ return;
51
+ }
52
+ if (commit.parents.length > 1) {
53
+ console.log("Refusing to reword a merge commit (multiple parents).");
54
+ return;
55
+ }
56
+ if (process.stdout.isTTY) {
57
+ await animateHeaderBase("ai-conventional-commit", config.model);
58
+ borderLine();
59
+ }
60
+ sectionTitle("Original commit");
61
+ borderLine(chalk.yellow(commit.title));
62
+ if (commit.body) {
63
+ commit.body.split("\n").forEach((l) => l.trim().length ? borderLine(l) : borderLine());
64
+ }
65
+ borderLine();
66
+ const instructions = [
67
+ "Improve clarity & conformity to Conventional Commits while preserving meaning."
68
+ ];
69
+ const syntheticPlan = {
70
+ commits: [
71
+ {
72
+ title: commit.title,
73
+ body: commit.body,
74
+ score: 0,
75
+ reasons: []
76
+ }
77
+ ]
78
+ };
79
+ const provider = new OpenCodeProvider(config.model);
80
+ const phased = createPhasedSpinner(ora);
81
+ let refined = null;
82
+ try {
83
+ phased.phase("Preparing prompt");
84
+ const messages = buildRefineMessages({
85
+ originalPlan: syntheticPlan,
86
+ index: 0,
87
+ instructions,
88
+ config
89
+ });
90
+ phased.phase("Calling model");
91
+ const raw = await provider.chat(messages, { maxTokens: config.maxTokens });
92
+ phased.phase("Parsing response");
93
+ refined = await extractJSON(raw);
94
+ } catch (e) {
95
+ phased.spinner.fail("Reword failed: " + (e?.message || e));
96
+ return;
97
+ }
98
+ phased.stop();
99
+ if (!refined || !refined.commits.length) {
100
+ console.log("No refined commit produced.");
101
+ return;
102
+ }
103
+ const candidate = refined.commits[0];
104
+ candidate.title = formatCommitTitle(candidate.title, {
105
+ allowGitmoji: config.style === "gitmoji" || config.style === "gitmoji-pure",
106
+ mode: config.style
107
+ });
108
+ sectionTitle("Proposed commit");
109
+ renderCommitBlock({
110
+ title: chalk.yellow(candidate.title),
111
+ body: candidate.body,
112
+ hideMessageLabel: true
113
+ });
114
+ borderLine();
115
+ const resolvedHash = (await git.revparse([hash])).trim();
116
+ const headHash = (await git.revparse(["HEAD"])).trim();
117
+ const isHead = headHash === resolvedHash || headHash.startsWith(resolvedHash);
118
+ const { ok } = await inquirer.prompt([
119
+ {
120
+ type: "list",
121
+ name: "ok",
122
+ message: isHead ? "Amend HEAD with this message?" : "Apply rewrite (history will change)?",
123
+ choices: [
124
+ { name: "Yes", value: true },
125
+ { name: "No", value: false }
126
+ ],
127
+ default: 0
128
+ }
129
+ ]);
130
+ if (!ok) {
131
+ borderLine();
132
+ abortMessage();
133
+ return;
134
+ }
135
+ const full = candidate.body ? `${candidate.title}
136
+
137
+ ${candidate.body}` : candidate.title;
138
+ if (isHead) {
139
+ try {
140
+ await git.commit(full, { "--amend": null });
141
+ } catch (e) {
142
+ borderLine("Failed to amend HEAD: " + (e?.message || e));
143
+ borderLine();
144
+ abortMessage();
145
+ return;
146
+ }
147
+ borderLine();
148
+ finalSuccess({ count: 1, startedAt });
149
+ return;
150
+ }
151
+ const ancestorOk = await isAncestor(resolvedHash, headHash);
152
+ if (!ancestorOk) {
153
+ borderLine("Selected commit is not an ancestor of HEAD.");
154
+ borderLine("Cannot safely rewrite automatically.");
155
+ borderLine();
156
+ abortMessage();
157
+ return;
158
+ }
159
+ let mergesRange = "";
160
+ try {
161
+ mergesRange = (await git.raw(["rev-list", "--merges", `${resolvedHash}..HEAD`])).trim();
162
+ } catch {
163
+ }
164
+ if (mergesRange) {
165
+ sectionTitle("Unsafe automatic rewrite");
166
+ borderLine("Merge commits detected between target and HEAD.");
167
+ borderLine("Falling back to manual instructions (preserving previous behavior).");
168
+ borderLine();
169
+ sectionTitle("Apply manually");
170
+ borderLine(`1. git rebase -i ${resolvedHash}~1 --reword`);
171
+ borderLine("2. Mark the line as reword if needed.");
172
+ borderLine("3. Replace the message with:");
173
+ borderLine();
174
+ borderLine(candidate.title);
175
+ if (candidate.body) candidate.body.split("\n").forEach((l) => borderLine(l || void 0));
176
+ borderLine();
177
+ abortMessage();
178
+ return;
179
+ }
180
+ try {
181
+ const tree = (await git.raw(["show", "-s", "--format=%T", resolvedHash])).trim();
182
+ const parent = commit.parents[0];
183
+ const args = ["commit-tree", tree];
184
+ if (parent) args.push("-p", parent);
185
+ args.push("-m", full);
186
+ const newHash = (await git.raw(args)).trim();
187
+ const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
188
+ const rebaseTarget = currentBranch === "HEAD" ? "HEAD" : currentBranch;
189
+ await git.raw(["rebase", "--onto", newHash, resolvedHash, rebaseTarget]);
190
+ const afterBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
191
+ if (afterBranch === "HEAD" && rebaseTarget !== "HEAD") {
192
+ try {
193
+ await git.checkout([rebaseTarget]);
194
+ } catch {
195
+ }
196
+ }
197
+ sectionTitle("Updated commit");
198
+ borderLine(`Rewrote ${resolvedHash.slice(0, 7)} \u2192 ${newHash.slice(0, 7)}`);
199
+ renderCommitBlock({ title: candidate.title, body: candidate.body, hideMessageLabel: true });
200
+ borderLine();
201
+ finalSuccess({ count: 1, startedAt });
202
+ } catch (e) {
203
+ borderLine("Automatic rewrite failed: " + (e?.message || e));
204
+ borderLine("If a rebase is in progress, resolve conflicts then run: git rebase --continue");
205
+ borderLine("Or abort with: git rebase --abort");
206
+ borderLine();
207
+ abortMessage();
208
+ }
209
+ }
210
+ export {
211
+ runReword
212
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kud/ai-conventional-commit-cli",
3
- "version": "0.12.12",
3
+ "version": "0.13.1",
4
4
  "type": "module",
5
5
  "description": "Opinionated, style-aware AI assistant for crafting and splitting git commits (opencode-based, provider-agnostic).",
6
6
  "bin": {