@ksw8954/git-ai-commit 1.1.8 → 1.2.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/.github/workflows/publish.yml +31 -5
- package/CHANGELOG.md +16 -0
- package/README.md +25 -4
- package/dist/commands/ai.d.ts +7 -1
- package/dist/commands/ai.d.ts.map +1 -1
- package/dist/commands/ai.js +108 -11
- package/dist/commands/ai.js.map +1 -1
- package/dist/commands/commit.d.ts +1 -0
- package/dist/commands/commit.d.ts.map +1 -1
- package/dist/commands/commit.js +37 -34
- package/dist/commands/commit.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +15 -5
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/config.d.ts +7 -2
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +37 -15
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/configCommand.d.ts +5 -1
- package/dist/commands/configCommand.d.ts.map +1 -1
- package/dist/commands/configCommand.js +30 -3
- package/dist/commands/configCommand.js.map +1 -1
- package/dist/commands/git.js +3 -3
- package/dist/commands/hookCommand.d.ts +14 -0
- package/dist/commands/hookCommand.d.ts.map +1 -0
- package/dist/commands/hookCommand.js +180 -0
- package/dist/commands/hookCommand.js.map +1 -0
- package/dist/commands/prCommand.d.ts.map +1 -1
- package/dist/commands/prCommand.js +3 -1
- package/dist/commands/prCommand.js.map +1 -1
- package/dist/commands/tag.d.ts.map +1 -1
- package/dist/commands/tag.js +9 -3
- package/dist/commands/tag.js.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/__tests__/ai.test.ts +486 -7
- package/src/__tests__/commitCommand.test.ts +111 -0
- package/src/__tests__/config.test.ts +24 -6
- package/src/__tests__/git.test.ts +421 -98
- package/src/__tests__/preCommit.test.ts +19 -0
- package/src/__tests__/tagCommand.test.ts +510 -17
- package/src/commands/ai.ts +128 -13
- package/src/commands/commit.ts +40 -34
- package/src/commands/completion.ts +15 -5
- package/src/commands/config.ts +46 -23
- package/src/commands/configCommand.ts +41 -8
- package/src/commands/git.ts +3 -3
- package/src/commands/hookCommand.ts +193 -0
- package/src/commands/prCommand.ts +3 -1
- package/src/commands/tag.ts +10 -4
- package/src/index.ts +3 -0
- package/src/schema/config.schema.json +72 -0
package/src/commands/ai.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
2
|
import { type ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions';
|
|
3
|
+
import { GoogleGenAI } from '@google/genai';
|
|
3
4
|
import { generateCommitPrompt } from '../prompts/commit';
|
|
4
5
|
import { generateTagPrompt } from '../prompts/tag';
|
|
5
6
|
import { generatePullRequestPrompt } from '../prompts/pr';
|
|
6
|
-
import { SupportedLanguage } from './config';
|
|
7
|
+
import { SupportedLanguage, AIMode } from './config';
|
|
7
8
|
|
|
8
9
|
export type ReasoningEffort = 'minimal' | 'low' | 'medium' | 'high';
|
|
9
10
|
|
|
@@ -15,6 +16,8 @@ export interface AIServiceConfig {
|
|
|
15
16
|
reasoningEffort?: ReasoningEffort;
|
|
16
17
|
language?: SupportedLanguage;
|
|
17
18
|
verbose?: boolean;
|
|
19
|
+
mode?: AIMode;
|
|
20
|
+
maxCompletionTokens?: number;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export interface CommitGenerationResult {
|
|
@@ -36,23 +39,35 @@ export interface PullRequestGenerationResult {
|
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
export class AIService {
|
|
39
|
-
private openai: OpenAI;
|
|
42
|
+
private openai: OpenAI | null = null;
|
|
43
|
+
private gemini: GoogleGenAI | null = null;
|
|
44
|
+
private mode: AIMode;
|
|
40
45
|
private model: string;
|
|
41
46
|
private fallbackModel?: string;
|
|
42
47
|
private reasoningEffort?: ReasoningEffort;
|
|
43
48
|
private language: SupportedLanguage;
|
|
44
49
|
private verbose: boolean;
|
|
50
|
+
private maxCompletionTokens?: number;
|
|
45
51
|
|
|
46
52
|
constructor(config: AIServiceConfig) {
|
|
47
|
-
this.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
this.mode = config.mode || 'custom';
|
|
54
|
+
|
|
55
|
+
if (this.mode === 'gemini') {
|
|
56
|
+
this.gemini = new GoogleGenAI({ apiKey: config.apiKey });
|
|
57
|
+
this.model = config.model || 'gemini-2.0-flash';
|
|
58
|
+
} else {
|
|
59
|
+
this.openai = new OpenAI({
|
|
60
|
+
apiKey: config.apiKey,
|
|
61
|
+
baseURL: config.baseURL
|
|
62
|
+
});
|
|
63
|
+
this.model = config.model || 'zai-org/GLM-4.5-FP8';
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
this.fallbackModel = config.fallbackModel;
|
|
53
67
|
this.reasoningEffort = config.reasoningEffort;
|
|
54
68
|
this.language = config.language || 'ko';
|
|
55
69
|
this.verbose = config.verbose ?? true;
|
|
70
|
+
this.maxCompletionTokens = config.maxCompletionTokens;
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
private debugLog(...args: unknown[]): void {
|
|
@@ -157,10 +172,110 @@ export class AIService {
|
|
|
157
172
|
return `${seconds}s`;
|
|
158
173
|
}
|
|
159
174
|
|
|
175
|
+
private async createGeminiStreamingCompletion(
|
|
176
|
+
request: ChatCompletionCreateParamsNonStreaming
|
|
177
|
+
): Promise<string> {
|
|
178
|
+
if (!this.gemini) throw new Error('Gemini client not initialized');
|
|
179
|
+
|
|
180
|
+
let waitingTimer: ReturnType<typeof setInterval> | null = null;
|
|
181
|
+
const startTime = Date.now();
|
|
182
|
+
let frameIndex = 0;
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
if (this.verbose) {
|
|
186
|
+
waitingTimer = setInterval(() => {
|
|
187
|
+
const frame = this.spinnerFrames[frameIndex++ % this.spinnerFrames.length];
|
|
188
|
+
const elapsed = this.formatElapsed(Date.now() - startTime);
|
|
189
|
+
process.stdout.write(`\r${frame} Waiting for response... (${elapsed})`);
|
|
190
|
+
}, 100);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const systemMessage = request.messages.find(m => m.role === 'system');
|
|
194
|
+
const userMessages = request.messages.filter(m => m.role !== 'system');
|
|
195
|
+
const contents = userMessages.map(m => ({
|
|
196
|
+
role: m.role === 'assistant' ? 'model' as const : 'user' as const,
|
|
197
|
+
parts: [{ text: typeof m.content === 'string' ? m.content : '' }]
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
const maxTokens = (request as unknown as Record<string, unknown>).max_completion_tokens as number | undefined
|
|
201
|
+
?? request.max_tokens
|
|
202
|
+
?? 3000;
|
|
203
|
+
|
|
204
|
+
const stream = await this.gemini.models.generateContentStream({
|
|
205
|
+
model: this.model,
|
|
206
|
+
contents,
|
|
207
|
+
config: {
|
|
208
|
+
...(systemMessage ? { systemInstruction: typeof systemMessage.content === 'string' ? systemMessage.content : '' } : {}),
|
|
209
|
+
maxOutputTokens: maxTokens,
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const contentChunks: string[] = [];
|
|
214
|
+
let chunkCount = 0;
|
|
215
|
+
let lastChunkUsage: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number } | undefined;
|
|
216
|
+
|
|
217
|
+
for await (const chunk of stream) {
|
|
218
|
+
if (chunk.usageMetadata) {
|
|
219
|
+
lastChunkUsage = chunk.usageMetadata;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const text = chunk.text;
|
|
223
|
+
if (text) {
|
|
224
|
+
contentChunks.push(text);
|
|
225
|
+
chunkCount++;
|
|
226
|
+
|
|
227
|
+
if (waitingTimer) {
|
|
228
|
+
clearInterval(waitingTimer);
|
|
229
|
+
waitingTimer = null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (this.verbose) {
|
|
233
|
+
const frame = this.spinnerFrames[frameIndex++ % this.spinnerFrames.length];
|
|
234
|
+
const elapsed = this.formatElapsed(Date.now() - startTime);
|
|
235
|
+
process.stdout.write(`\r${frame} Streaming... (${chunkCount} chunks, ${elapsed})`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (waitingTimer) {
|
|
241
|
+
clearInterval(waitingTimer);
|
|
242
|
+
waitingTimer = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (this.verbose) {
|
|
246
|
+
const elapsed = this.formatElapsed(Date.now() - startTime);
|
|
247
|
+
if (lastChunkUsage) {
|
|
248
|
+
const prompt = lastChunkUsage.promptTokenCount ?? 0;
|
|
249
|
+
const response = lastChunkUsage.candidatesTokenCount ?? 0;
|
|
250
|
+
process.stdout.write(`\r✅ Complete (response: ${response}, prompt: ${prompt} tokens, ${elapsed})\n`);
|
|
251
|
+
} else {
|
|
252
|
+
process.stdout.write(`\r✅ Complete (~${chunkCount} chunks, ${elapsed})\n`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return contentChunks.join('');
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (waitingTimer) {
|
|
259
|
+
clearInterval(waitingTimer);
|
|
260
|
+
waitingTimer = null;
|
|
261
|
+
}
|
|
262
|
+
if (this.verbose) {
|
|
263
|
+
process.stdout.write('\n');
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
160
269
|
private async createStreamingCompletion(
|
|
161
270
|
request: ChatCompletionCreateParamsNonStreaming,
|
|
162
271
|
attempt = 0
|
|
163
272
|
): Promise<string> {
|
|
273
|
+
if (this.mode === 'gemini') {
|
|
274
|
+
return this.createGeminiStreamingCompletion(request);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!this.openai) throw new Error('OpenAI client not initialized');
|
|
278
|
+
|
|
164
279
|
let waitingTimer: ReturnType<typeof setInterval> | null = null;
|
|
165
280
|
|
|
166
281
|
try {
|
|
@@ -353,7 +468,7 @@ export class AIService {
|
|
|
353
468
|
try {
|
|
354
469
|
this.debugLog('Sending request to AI API...');
|
|
355
470
|
this.debugLog('Model:', this.model);
|
|
356
|
-
this.debugLog('Base URL:', this.openai
|
|
471
|
+
this.debugLog('Base URL:', this.openai?.baseURL ?? 'Gemini native');
|
|
357
472
|
|
|
358
473
|
const customInstructions = extraInstructions && extraInstructions.trim().length > 0
|
|
359
474
|
? `Git diff will be provided separately in the user message.\n\n## Additional User Instructions\n${extraInstructions.trim()}`
|
|
@@ -375,7 +490,7 @@ export class AIService {
|
|
|
375
490
|
content: `Git diff:\n${diff}`
|
|
376
491
|
}
|
|
377
492
|
],
|
|
378
|
-
max_completion_tokens:
|
|
493
|
+
max_completion_tokens: this.maxCompletionTokens ?? 1000
|
|
379
494
|
});
|
|
380
495
|
|
|
381
496
|
let finalMessage = content.trim() || null;
|
|
@@ -491,7 +606,7 @@ export class AIService {
|
|
|
491
606
|
try {
|
|
492
607
|
this.debugLog('Sending request to AI API for tag notes...');
|
|
493
608
|
this.debugLog('Model:', this.model);
|
|
494
|
-
this.debugLog('Base URL:', this.openai
|
|
609
|
+
this.debugLog('Base URL:', this.openai?.baseURL ?? 'Gemini native');
|
|
495
610
|
|
|
496
611
|
const customInstructions = extraInstructions && extraInstructions.trim().length > 0
|
|
497
612
|
? `${extraInstructions.trim()}`
|
|
@@ -519,7 +634,7 @@ export class AIService {
|
|
|
519
634
|
content: userContent
|
|
520
635
|
}
|
|
521
636
|
],
|
|
522
|
-
max_completion_tokens: 3000
|
|
637
|
+
max_completion_tokens: this.maxCompletionTokens ?? 3000
|
|
523
638
|
});
|
|
524
639
|
|
|
525
640
|
const finalNotes = content.trim() || null;
|
|
@@ -553,7 +668,7 @@ export class AIService {
|
|
|
553
668
|
try {
|
|
554
669
|
this.debugLog('Sending request to AI API for pull request message...');
|
|
555
670
|
this.debugLog('Model:', this.model);
|
|
556
|
-
this.debugLog('Base URL:', this.openai
|
|
671
|
+
this.debugLog('Base URL:', this.openai?.baseURL ?? 'Gemini native');
|
|
557
672
|
|
|
558
673
|
const content = await this.createStreamingCompletion({
|
|
559
674
|
model: this.model,
|
|
@@ -572,7 +687,7 @@ export class AIService {
|
|
|
572
687
|
content: `Git diff between ${baseBranch} and ${compareBranch}:\n${diff}`
|
|
573
688
|
}
|
|
574
689
|
],
|
|
575
|
-
max_completion_tokens: 4000
|
|
690
|
+
max_completion_tokens: this.maxCompletionTokens ?? 4000
|
|
576
691
|
});
|
|
577
692
|
|
|
578
693
|
const finalMessage = content.trim() || null;
|
package/src/commands/commit.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Command } from "commander";
|
|
|
2
2
|
import readline from "readline";
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import path from "path";
|
|
5
|
-
import { spawn } from "child_process";
|
|
5
|
+
import { spawn, execFileSync } from "child_process";
|
|
6
6
|
import { GitService, GitDiffResult } from "./git";
|
|
7
7
|
import { AIService, AIServiceConfig } from "./ai";
|
|
8
8
|
import { ConfigService } from "./config";
|
|
@@ -46,6 +46,16 @@ export class CommitCommand {
|
|
|
46
46
|
.action(this.handleCommit.bind(this));
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
private isCommandAvailable(command: string): boolean {
|
|
50
|
+
const checker = process.platform === "win32" ? "where" : "which";
|
|
51
|
+
try {
|
|
52
|
+
execFileSync(checker, [command], { stdio: "ignore" });
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
49
59
|
private async runPreCommitHook(): Promise<void> {
|
|
50
60
|
// 1. Check for npm pre-commit script
|
|
51
61
|
const packageJsonPath = path.resolve(process.cwd(), "package.json");
|
|
@@ -88,43 +98,33 @@ export class CommitCommand {
|
|
|
88
98
|
// 2. Check for .pre-commit-config.yaml (Python/General pre-commit)
|
|
89
99
|
const preCommitConfigPath = path.resolve(process.cwd(), ".pre-commit-config.yaml");
|
|
90
100
|
if (fs.existsSync(preCommitConfigPath)) {
|
|
101
|
+
if (!this.isCommandAvailable("pre-commit")) {
|
|
102
|
+
console.warn("⚠️ .pre-commit-config.yaml found but 'pre-commit' is not installed. Skipping hooks.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
91
106
|
console.log("Found .pre-commit-config.yaml, running pre-commit hooks...");
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
await new Promise<void>((resolve, reject) => {
|
|
95
|
-
const child = spawn("pre-commit", ["run"], {
|
|
96
|
-
stdio: "inherit",
|
|
97
|
-
shell: true,
|
|
98
|
-
});
|
|
99
107
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
console.error(`❌ pre-commit hooks failed with code ${code}`);
|
|
106
|
-
reject(new Error(`pre-commit hooks failed with code ${code}`));
|
|
107
|
-
}
|
|
108
|
-
});
|
|
108
|
+
await new Promise<void>((resolve, reject) => {
|
|
109
|
+
const child = spawn("pre-commit", ["run"], {
|
|
110
|
+
stdio: "inherit",
|
|
111
|
+
shell: true,
|
|
112
|
+
});
|
|
109
113
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
child.on("close", (code) => {
|
|
115
|
+
if (code === 0) {
|
|
116
|
+
console.log("✅ pre-commit hooks passed");
|
|
117
|
+
resolve();
|
|
118
|
+
} else {
|
|
119
|
+
console.error(`❌ pre-commit hooks failed with code ${code}`);
|
|
120
|
+
reject(new Error(`pre-commit hooks failed with code ${code}`));
|
|
121
|
+
}
|
|
116
122
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (msg.includes("code 127") || msg.includes("ENOENT")) {
|
|
123
|
-
console.warn("⚠️ 'pre-commit' command not found, skipping hooks despite configuration file presence.");
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
throw error;
|
|
127
|
-
}
|
|
123
|
+
|
|
124
|
+
child.on("error", (err) => {
|
|
125
|
+
reject(err);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -165,6 +165,8 @@ export class CommitCommand {
|
|
|
165
165
|
reasoningEffort: existingConfig.reasoningEffort,
|
|
166
166
|
language: existingConfig.language,
|
|
167
167
|
verbose: !messageOnly,
|
|
168
|
+
mode: existingConfig.mode,
|
|
169
|
+
maxCompletionTokens: existingConfig.maxCompletionTokens,
|
|
168
170
|
};
|
|
169
171
|
|
|
170
172
|
log("Getting staged changes...");
|
|
@@ -210,6 +212,10 @@ export class CommitCommand {
|
|
|
210
212
|
process.exit(1);
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
|
|
216
|
+
if (existingConfig.coAuthor) {
|
|
217
|
+
aiResult.message = `${aiResult.message}\n\nCo-authored-by: ${existingConfig.coAuthor}`;
|
|
218
|
+
}
|
|
213
219
|
if (messageOnly) {
|
|
214
220
|
console.log(aiResult.message);
|
|
215
221
|
await LogService.append({
|
|
@@ -33,14 +33,14 @@ _git_ai_commit() {
|
|
|
33
33
|
local cur prev words cword
|
|
34
34
|
_init_completion || return
|
|
35
35
|
|
|
36
|
-
local commands="commit config pr tag history completion"
|
|
36
|
+
local commands="commit config pr tag history completion hook"
|
|
37
37
|
|
|
38
38
|
# Global options
|
|
39
39
|
local global_opts="-v --version -h --help"
|
|
40
40
|
|
|
41
41
|
# Command-specific options
|
|
42
42
|
local commit_opts="-k --api-key -b --base-url --model -m --message-only -p --push --prompt --no-verify"
|
|
43
|
-
local config_opts="-s --show -l --language --auto-push --no-auto-push -k --api-key -b --base-url -m --model --fallback-model --reasoning-effort --mode"
|
|
43
|
+
local config_opts="-s --show -l --language --auto-push --no-auto-push -k --api-key -b --base-url -m --model --fallback-model --reasoning-effort --mode --co-author --no-co-author"
|
|
44
44
|
local pr_opts="--base --compare -k --api-key -b --base-url --model"
|
|
45
45
|
local tag_opts="-k --api-key --base-url -m --model --message -t --base-tag --prompt"
|
|
46
46
|
local history_opts="-l --limit --json --clear"
|
|
@@ -71,10 +71,10 @@ _git_ai_commit() {
|
|
|
71
71
|
return
|
|
72
72
|
;;
|
|
73
73
|
--mode)
|
|
74
|
-
COMPREPLY=( \$(compgen -W "custom openai" -- "\${cur}") )
|
|
74
|
+
COMPREPLY=( \$(compgen -W "custom openai gemini" -- "\${cur}") )
|
|
75
75
|
return
|
|
76
76
|
;;
|
|
77
|
-
-k|--api-key|-b|--base-url|-m|--model)
|
|
77
|
+
-k|--api-key|-b|--base-url|-m|--model|--co-author)
|
|
78
78
|
return
|
|
79
79
|
;;
|
|
80
80
|
esac
|
|
@@ -119,6 +119,9 @@ _git_ai_commit() {
|
|
|
119
119
|
completion)
|
|
120
120
|
COMPREPLY=( \$(compgen -W "bash zsh" -- "\${cur}") )
|
|
121
121
|
;;
|
|
122
|
+
hook)
|
|
123
|
+
COMPREPLY=( \$(compgen -W "install uninstall status" -- "\${cur}") )
|
|
124
|
+
;;
|
|
122
125
|
esac
|
|
123
126
|
}
|
|
124
127
|
|
|
@@ -157,6 +160,7 @@ _git-ai-commit() {
|
|
|
157
160
|
'tag:Create an annotated git tag with AI-generated notes'
|
|
158
161
|
'history:Manage git-ai-commit command history'
|
|
159
162
|
'completion:Generate shell completion scripts'
|
|
163
|
+
'hook:Manage prepare-commit-msg git hook'
|
|
160
164
|
)
|
|
161
165
|
_describe -t commands 'git-ai-commit commands' commands
|
|
162
166
|
;;
|
|
@@ -190,7 +194,9 @@ _git-ai-commit() {
|
|
|
190
194
|
'--base-url[Persist API base URL]:url:' \\
|
|
191
195
|
'-m[Persist default AI model]:model:' \\
|
|
192
196
|
'--model[Persist default AI model]:model:' \\
|
|
193
|
-
'--mode[Persist AI mode]:mode:(custom openai)'
|
|
197
|
+
'--mode[Persist AI mode]:mode:(custom openai gemini)' \\
|
|
198
|
+
'--co-author[Set Co-authored-by trailer for commits]:author:' \\
|
|
199
|
+
'--no-co-author[Remove Co-authored-by trailer]'
|
|
194
200
|
;;
|
|
195
201
|
pr)
|
|
196
202
|
_arguments \\
|
|
@@ -236,6 +242,10 @@ _git-ai-commit() {
|
|
|
236
242
|
_arguments \\
|
|
237
243
|
'1:shell:(bash zsh)'
|
|
238
244
|
;;
|
|
245
|
+
hook)
|
|
246
|
+
_arguments \\
|
|
247
|
+
'1:action:(install uninstall status)'
|
|
248
|
+
;;
|
|
239
249
|
esac
|
|
240
250
|
;;
|
|
241
251
|
esac
|
package/src/commands/config.ts
CHANGED
|
@@ -7,15 +7,19 @@ export type SupportedLanguage = 'ko' | 'en';
|
|
|
7
7
|
|
|
8
8
|
export type ReasoningEffort = 'minimal' | 'low' | 'medium' | 'high';
|
|
9
9
|
|
|
10
|
+
export type AIMode = 'custom' | 'openai' | 'gemini';
|
|
11
|
+
|
|
10
12
|
export interface EnvironmentConfig {
|
|
11
13
|
apiKey?: string;
|
|
12
14
|
baseURL?: string;
|
|
13
15
|
model?: string;
|
|
14
16
|
fallbackModel?: string;
|
|
15
17
|
reasoningEffort?: ReasoningEffort;
|
|
16
|
-
mode:
|
|
18
|
+
mode: AIMode;
|
|
17
19
|
language: SupportedLanguage;
|
|
18
20
|
autoPush: boolean;
|
|
21
|
+
coAuthor?: string | false;
|
|
22
|
+
maxCompletionTokens?: number;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
interface StoredConfig {
|
|
@@ -24,15 +28,19 @@ interface StoredConfig {
|
|
|
24
28
|
model?: string;
|
|
25
29
|
fallbackModel?: string;
|
|
26
30
|
reasoningEffort?: ReasoningEffort | string;
|
|
27
|
-
mode?:
|
|
31
|
+
mode?: AIMode;
|
|
28
32
|
language?: SupportedLanguage | string;
|
|
29
33
|
autoPush?: boolean;
|
|
34
|
+
coAuthor?: string | false;
|
|
35
|
+
maxCompletionTokens?: number;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
const DEFAULT_MODEL = 'zai-org/GLM-4.5-FP8';
|
|
33
|
-
const DEFAULT_MODE:
|
|
39
|
+
const DEFAULT_MODE: AIMode = 'custom';
|
|
34
40
|
const DEFAULT_LANGUAGE: SupportedLanguage = 'ko';
|
|
35
41
|
const DEFAULT_AUTO_PUSH = false;
|
|
42
|
+
const DEFAULT_CO_AUTHOR = 'git-ai-commit <git-ai-commit@users.noreply.github.com>';
|
|
43
|
+
const CONFIG_SCHEMA_URL = 'https://raw.githubusercontent.com/onaries/git-ai-commit/main/src/schema/config.schema.json';
|
|
36
44
|
|
|
37
45
|
export class ConfigService {
|
|
38
46
|
private static getConfigFilePath(): string {
|
|
@@ -58,7 +66,8 @@ export class ConfigService {
|
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
const parsed = JSON.parse(raw);
|
|
61
|
-
|
|
69
|
+
const { $schema, ...config } = typeof parsed === 'object' && parsed !== null ? parsed : {} as Record<string, unknown>;
|
|
70
|
+
return config as StoredConfig;
|
|
62
71
|
} catch (error) {
|
|
63
72
|
console.warn('Warning: Failed to read configuration file. Falling back to environment variables.');
|
|
64
73
|
return {};
|
|
@@ -83,35 +92,42 @@ export class ConfigService {
|
|
|
83
92
|
return undefined;
|
|
84
93
|
}
|
|
85
94
|
|
|
86
|
-
private static normalizeMode(mode?: string):
|
|
95
|
+
private static normalizeMode(mode?: string): AIMode {
|
|
87
96
|
if (!mode) {
|
|
88
97
|
return DEFAULT_MODE;
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
const normalized = mode.toLowerCase();
|
|
92
|
-
|
|
101
|
+
if (normalized === 'openai') return 'openai';
|
|
102
|
+
if (normalized === 'gemini') return 'gemini';
|
|
103
|
+
return 'custom';
|
|
93
104
|
}
|
|
94
105
|
|
|
95
|
-
private static resolveEnvConfig(modeOverride?:
|
|
106
|
+
private static resolveEnvConfig(modeOverride?: AIMode): EnvironmentConfig {
|
|
96
107
|
const resolvedMode = this.normalizeMode(modeOverride || process.env.AI_MODE);
|
|
97
|
-
const isOpenAI = resolvedMode === 'openai';
|
|
98
|
-
|
|
99
|
-
const apiKey = isOpenAI
|
|
100
|
-
? process.env.OPENAI_API_KEY || process.env.AI_API_KEY
|
|
101
|
-
: process.env.AI_API_KEY || process.env.OPENAI_API_KEY;
|
|
102
108
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
let apiKey: string | undefined;
|
|
110
|
+
let baseURL: string | undefined;
|
|
111
|
+
let model: string;
|
|
112
|
+
|
|
113
|
+
if (resolvedMode === 'gemini') {
|
|
114
|
+
apiKey = process.env.GEMINI_API_KEY || process.env.AI_API_KEY;
|
|
115
|
+
baseURL = undefined;
|
|
116
|
+
model = process.env.AI_MODEL || 'gemini-2.0-flash';
|
|
117
|
+
} else if (resolvedMode === 'openai') {
|
|
118
|
+
apiKey = process.env.OPENAI_API_KEY || process.env.AI_API_KEY;
|
|
119
|
+
baseURL = process.env.OPENAI_BASE_URL || process.env.AI_BASE_URL;
|
|
120
|
+
model = process.env.OPENAI_MODEL || process.env.AI_MODEL || DEFAULT_MODEL;
|
|
121
|
+
} else {
|
|
122
|
+
apiKey = process.env.AI_API_KEY || process.env.OPENAI_API_KEY;
|
|
123
|
+
baseURL = process.env.AI_BASE_URL || process.env.OPENAI_BASE_URL;
|
|
124
|
+
model = process.env.AI_MODEL || process.env.OPENAI_MODEL || DEFAULT_MODEL;
|
|
125
|
+
}
|
|
110
126
|
|
|
111
127
|
return {
|
|
112
128
|
apiKey: apiKey || undefined,
|
|
113
129
|
baseURL: baseURL || undefined,
|
|
114
|
-
model
|
|
130
|
+
model,
|
|
115
131
|
mode: resolvedMode,
|
|
116
132
|
language: DEFAULT_LANGUAGE,
|
|
117
133
|
autoPush: DEFAULT_AUTO_PUSH
|
|
@@ -130,7 +146,10 @@ export class ConfigService {
|
|
|
130
146
|
const reasoningEffort = this.normalizeReasoningEffort(fileConfig.reasoningEffort);
|
|
131
147
|
const language = this.normalizeLanguage(fileConfig.language ?? envConfig.language);
|
|
132
148
|
const autoPush = typeof fileConfig.autoPush === 'boolean' ? fileConfig.autoPush : envConfig.autoPush;
|
|
133
|
-
|
|
149
|
+
const coAuthor = fileConfig.coAuthor === false ? false : (fileConfig.coAuthor || DEFAULT_CO_AUTHOR);
|
|
150
|
+
const maxCompletionTokens = typeof fileConfig.maxCompletionTokens === 'number' && fileConfig.maxCompletionTokens > 0
|
|
151
|
+
? fileConfig.maxCompletionTokens
|
|
152
|
+
: undefined;
|
|
134
153
|
return {
|
|
135
154
|
apiKey,
|
|
136
155
|
baseURL,
|
|
@@ -139,7 +158,9 @@ export class ConfigService {
|
|
|
139
158
|
reasoningEffort,
|
|
140
159
|
mode,
|
|
141
160
|
language,
|
|
142
|
-
autoPush
|
|
161
|
+
autoPush,
|
|
162
|
+
coAuthor,
|
|
163
|
+
maxCompletionTokens,
|
|
143
164
|
};
|
|
144
165
|
}
|
|
145
166
|
|
|
@@ -176,6 +197,8 @@ export class ConfigService {
|
|
|
176
197
|
delete next.mode;
|
|
177
198
|
}
|
|
178
199
|
|
|
200
|
+
// coAuthor: false means explicitly disabled — persist it so getConfig() sees it
|
|
201
|
+
|
|
179
202
|
const sanitized = Object.entries(next).reduce<StoredConfig>((acc, [key, value]) => {
|
|
180
203
|
if (value !== undefined) {
|
|
181
204
|
acc[key as keyof StoredConfig] = value as any;
|
|
@@ -186,7 +209,7 @@ export class ConfigService {
|
|
|
186
209
|
}, {});
|
|
187
210
|
|
|
188
211
|
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
|
189
|
-
await fsPromises.writeFile(filePath, JSON.stringify(sanitized, null, 2), 'utf-8');
|
|
212
|
+
await fsPromises.writeFile(filePath, JSON.stringify({ $schema: CONFIG_SCHEMA_URL, ...sanitized }, null, 2), 'utf-8');
|
|
190
213
|
}
|
|
191
214
|
|
|
192
215
|
static validateConfig(config: { apiKey?: string; language?: string }): void {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { ConfigService, SupportedLanguage } from './config';
|
|
2
|
+
import { ConfigService, SupportedLanguage, AIMode } from './config';
|
|
3
3
|
|
|
4
4
|
export interface ConfigOptions {
|
|
5
5
|
show?: boolean;
|
|
@@ -10,7 +10,10 @@ export interface ConfigOptions {
|
|
|
10
10
|
model?: string;
|
|
11
11
|
fallbackModel?: string;
|
|
12
12
|
reasoningEffort?: string;
|
|
13
|
-
mode?:
|
|
13
|
+
mode?: AIMode;
|
|
14
|
+
coAuthor?: string;
|
|
15
|
+
noCoAuthor?: boolean;
|
|
16
|
+
maxTokens?: string;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
export class ConfigCommand {
|
|
@@ -28,7 +31,10 @@ export class ConfigCommand {
|
|
|
28
31
|
.option('-m, --model <model>', 'Persist default AI model')
|
|
29
32
|
.option('--fallback-model <model>', 'Persist fallback model for rate limit (429) retry')
|
|
30
33
|
.option('--reasoning-effort <level>', 'Thinking effort for reasoning models (minimal | low | medium | high)')
|
|
31
|
-
.option('--mode <mode>', 'Persist AI mode (custom | openai)')
|
|
34
|
+
.option('--mode <mode>', 'Persist AI mode (custom | openai | gemini)')
|
|
35
|
+
.option('--co-author <value>', 'Set co-author for commits (e.g. "Name <email>")')
|
|
36
|
+
.option('--no-co-author', 'Disable co-author')
|
|
37
|
+
.option('--max-tokens <number>', 'Persist max completion tokens for AI responses')
|
|
32
38
|
.action(this.handleConfig.bind(this));
|
|
33
39
|
}
|
|
34
40
|
|
|
@@ -42,14 +48,14 @@ export class ConfigCommand {
|
|
|
42
48
|
return normalized;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
private validateMode(mode: string):
|
|
51
|
+
private validateMode(mode: string): AIMode {
|
|
46
52
|
const normalized = mode?.toLowerCase();
|
|
47
|
-
if (normalized !== 'custom' && normalized !== 'openai') {
|
|
48
|
-
console.error('Mode must be
|
|
53
|
+
if (normalized !== 'custom' && normalized !== 'openai' && normalized !== 'gemini') {
|
|
54
|
+
console.error('Mode must be one of: "custom", "openai", "gemini".');
|
|
49
55
|
process.exit(1);
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
return normalized;
|
|
58
|
+
return normalized as AIMode;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
private sanitizeStringValue(value?: string): string | undefined {
|
|
@@ -68,9 +74,11 @@ export class ConfigCommand {
|
|
|
68
74
|
model?: string;
|
|
69
75
|
fallbackModel?: string;
|
|
70
76
|
reasoningEffort?: string;
|
|
71
|
-
mode?:
|
|
77
|
+
mode?: AIMode;
|
|
72
78
|
language?: SupportedLanguage;
|
|
73
79
|
autoPush?: boolean;
|
|
80
|
+
coAuthor?: string | false;
|
|
81
|
+
maxCompletionTokens?: number;
|
|
74
82
|
} = {};
|
|
75
83
|
|
|
76
84
|
if (options.language) {
|
|
@@ -110,6 +118,26 @@ export class ConfigCommand {
|
|
|
110
118
|
updates.mode = this.validateMode(options.mode);
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
|
|
122
|
+
if (options.noCoAuthor) {
|
|
123
|
+
updates.coAuthor = false;
|
|
124
|
+
} else if (options.coAuthor !== undefined) {
|
|
125
|
+
const coAuthor = this.sanitizeStringValue(options.coAuthor);
|
|
126
|
+
if (coAuthor && !/.+<.+@.+>/.test(coAuthor)) {
|
|
127
|
+
console.error('Co-author must be in "Name <email>" format.');
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
updates.coAuthor = coAuthor;
|
|
131
|
+
}
|
|
132
|
+
if (options.maxTokens !== undefined) {
|
|
133
|
+
const parsed = parseInt(options.maxTokens, 10);
|
|
134
|
+
if (isNaN(parsed) || parsed <= 0) {
|
|
135
|
+
console.error('Max tokens must be a positive number.');
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
updates.maxCompletionTokens = parsed;
|
|
139
|
+
}
|
|
140
|
+
|
|
113
141
|
const hasUpdates = Object.keys(updates).length > 0;
|
|
114
142
|
|
|
115
143
|
if (!options.show && !hasUpdates) {
|
|
@@ -120,8 +148,11 @@ export class ConfigCommand {
|
|
|
120
148
|
console.log(' git-ai-commit config -k sk-xxx # Persist API key securely on disk');
|
|
121
149
|
console.log(' git-ai-commit config -b https://api.test # Persist custom API base URL');
|
|
122
150
|
console.log(' git-ai-commit config --mode openai # Use OpenAI-compatible environment defaults');
|
|
151
|
+
console.log(' git-ai-commit config --mode gemini # Use Gemini native SDK (GEMINI_API_KEY)');
|
|
123
152
|
console.log(' git-ai-commit config --model gpt-4o-mini # Persist preferred AI model');
|
|
124
153
|
console.log(' git-ai-commit config --fallback-model glm-4-flash # Fallback model for 429 retry');
|
|
154
|
+
console.log(' git-ai-commit config --co-author "Name <email>" # Set co-author for commits');
|
|
155
|
+
console.log(' git-ai-commit config --max-tokens 2000 # Set max completion tokens for AI');
|
|
125
156
|
return;
|
|
126
157
|
}
|
|
127
158
|
|
|
@@ -148,6 +179,8 @@ export class ConfigCommand {
|
|
|
148
179
|
console.log(`Fallback Model: ${config.fallbackModel || 'Not set'}`);
|
|
149
180
|
console.log(`Reasoning Effort: ${config.reasoningEffort || 'Not set (model default)'}`);
|
|
150
181
|
console.log(`Mode: ${config.mode || 'custom (default)'}`);
|
|
182
|
+
console.log(`Co-author: ${config.coAuthor === false ? 'disabled' : config.coAuthor}`);
|
|
183
|
+
console.log(`Max Completion Tokens: ${config.maxCompletionTokens || 'Not set (using per-command defaults)'}`);
|
|
151
184
|
} catch (error) {
|
|
152
185
|
console.error('Error reading configuration:', error instanceof Error ? error.message : error);
|
|
153
186
|
process.exit(1);
|
package/src/commands/git.ts
CHANGED
|
@@ -4,11 +4,11 @@ import { promisify } from 'util';
|
|
|
4
4
|
const execAsync = promisify(exec);
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
6
|
const GIT_DIFF_MAX_BUFFER = 50 * 1024 * 1024;
|
|
7
|
-
const MAX_DIFF_TOKENS =
|
|
7
|
+
const MAX_DIFF_TOKENS = 25000;
|
|
8
8
|
const APPROX_CHARS_PER_TOKEN = 4;
|
|
9
9
|
const MAX_DIFF_CHARS = MAX_DIFF_TOKENS * APPROX_CHARS_PER_TOKEN;
|
|
10
|
-
const MAX_FILE_LINES =
|
|
11
|
-
const MAX_NEW_FILE_LINES =
|
|
10
|
+
const MAX_FILE_LINES = 200;
|
|
11
|
+
const MAX_NEW_FILE_LINES = 100;
|
|
12
12
|
|
|
13
13
|
const splitDiffSections = (diff: string): string[] => {
|
|
14
14
|
const lines = diff.split('\n');
|