@ksw8954/git-ai-commit 1.1.7 → 1.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/.github/workflows/publish.yml +36 -0
- package/CHANGELOG.md +21 -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 +144 -22
- 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 +12 -2
- 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 +175 -24
- 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 +13 -2
- 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 {
|
|
@@ -178,23 +293,41 @@ export class AIService {
|
|
|
178
293
|
const streamParams = {
|
|
179
294
|
...request,
|
|
180
295
|
stream: true as const,
|
|
296
|
+
stream_options: { include_usage: true },
|
|
181
297
|
...(this.reasoningEffort ? { reasoning_effort: this.reasoningEffort } : {})
|
|
182
298
|
};
|
|
183
299
|
|
|
184
300
|
const stream = await this.openai.chat.completions.create(streamParams);
|
|
185
301
|
|
|
186
302
|
const contentChunks: string[] = [];
|
|
187
|
-
let
|
|
188
|
-
let
|
|
303
|
+
let reasoningChunks = 0;
|
|
304
|
+
let contentChunksCount = 0;
|
|
189
305
|
let phase: 'waiting' | 'thinking' | 'content' = 'waiting';
|
|
306
|
+
|
|
307
|
+
interface StreamUsage {
|
|
308
|
+
prompt_tokens?: number;
|
|
309
|
+
completion_tokens?: number;
|
|
310
|
+
total_tokens?: number;
|
|
311
|
+
completion_tokens_details?: {
|
|
312
|
+
reasoning_tokens?: number;
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
let finalUsage: StreamUsage | null = null;
|
|
190
316
|
|
|
191
317
|
for await (const chunk of stream) {
|
|
318
|
+
const chunkUsage = (chunk as unknown as { usage?: StreamUsage }).usage;
|
|
319
|
+
if (chunkUsage) {
|
|
320
|
+
finalUsage = chunkUsage;
|
|
321
|
+
}
|
|
322
|
+
|
|
192
323
|
const delta = chunk.choices[0]?.delta;
|
|
324
|
+
if (!delta) continue;
|
|
325
|
+
|
|
193
326
|
const content = delta?.content;
|
|
194
327
|
const reasoning = (delta as Record<string, unknown>)?.reasoning_content as string | undefined;
|
|
195
328
|
|
|
196
329
|
if (reasoning) {
|
|
197
|
-
|
|
330
|
+
reasoningChunks++;
|
|
198
331
|
|
|
199
332
|
if (phase === 'waiting' && waitingTimer) {
|
|
200
333
|
clearInterval(waitingTimer);
|
|
@@ -205,13 +338,13 @@ export class AIService {
|
|
|
205
338
|
if (this.verbose && phase === 'thinking') {
|
|
206
339
|
const frame = this.spinnerFrames[frameIndex++ % this.spinnerFrames.length];
|
|
207
340
|
const elapsed = this.formatElapsed(Date.now() - startTime);
|
|
208
|
-
process.stdout.write(`\r${frame} Thinking... (${
|
|
341
|
+
process.stdout.write(`\r${frame} Thinking... (${reasoningChunks} chunks, ${elapsed})`);
|
|
209
342
|
}
|
|
210
343
|
}
|
|
211
344
|
|
|
212
345
|
if (content) {
|
|
213
346
|
contentChunks.push(content);
|
|
214
|
-
|
|
347
|
+
contentChunksCount++;
|
|
215
348
|
|
|
216
349
|
if (phase !== 'content') {
|
|
217
350
|
if (waitingTimer) {
|
|
@@ -224,7 +357,7 @@ export class AIService {
|
|
|
224
357
|
if (this.verbose) {
|
|
225
358
|
const frame = this.spinnerFrames[frameIndex++ % this.spinnerFrames.length];
|
|
226
359
|
const elapsed = this.formatElapsed(Date.now() - startTime);
|
|
227
|
-
process.stdout.write(`\r${frame} Streaming
|
|
360
|
+
process.stdout.write(`\r${frame} Streaming... (${contentChunksCount} chunks, ${elapsed})`);
|
|
228
361
|
}
|
|
229
362
|
}
|
|
230
363
|
}
|
|
@@ -235,12 +368,30 @@ export class AIService {
|
|
|
235
368
|
}
|
|
236
369
|
|
|
237
370
|
if (this.verbose) {
|
|
238
|
-
const totalTokens = reasoningTokens + contentTokens;
|
|
239
371
|
const elapsed = this.formatElapsed(Date.now() - startTime);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
372
|
+
|
|
373
|
+
if (finalUsage) {
|
|
374
|
+
const reasoningTokens = finalUsage.completion_tokens_details?.reasoning_tokens ?? 0;
|
|
375
|
+
const completionTokens = finalUsage.completion_tokens ?? 0;
|
|
376
|
+
const promptTokens = finalUsage.prompt_tokens ?? 0;
|
|
377
|
+
|
|
378
|
+
// Gemini: completion_tokens is response-only (separate from reasoning)
|
|
379
|
+
// OpenAI: completion_tokens includes reasoning
|
|
380
|
+
const responseTokens = reasoningTokens > 0 && completionTokens > reasoningTokens
|
|
381
|
+
? completionTokens - reasoningTokens
|
|
382
|
+
: completionTokens;
|
|
383
|
+
|
|
384
|
+
if (reasoningTokens > 0) {
|
|
385
|
+
process.stdout.write(`\r✅ Complete (thinking: ${reasoningTokens}, response: ${responseTokens}, prompt: ${promptTokens} tokens, ${elapsed})\n`);
|
|
386
|
+
} else {
|
|
387
|
+
process.stdout.write(`\r✅ Complete (response: ${completionTokens}, prompt: ${promptTokens} tokens, ${elapsed})\n`);
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
const detail = reasoningChunks > 0
|
|
391
|
+
? `~${reasoningChunks + contentChunksCount} chunks (thinking: ~${reasoningChunks}, response: ~${contentChunksCount}), ${elapsed}`
|
|
392
|
+
: `~${contentChunksCount} chunks, ${elapsed}`;
|
|
393
|
+
process.stdout.write(`\r✅ Complete (${detail})\n`);
|
|
394
|
+
}
|
|
244
395
|
}
|
|
245
396
|
|
|
246
397
|
return contentChunks.join('');
|
|
@@ -317,7 +468,7 @@ export class AIService {
|
|
|
317
468
|
try {
|
|
318
469
|
this.debugLog('Sending request to AI API...');
|
|
319
470
|
this.debugLog('Model:', this.model);
|
|
320
|
-
this.debugLog('Base URL:', this.openai
|
|
471
|
+
this.debugLog('Base URL:', this.openai?.baseURL ?? 'Gemini native');
|
|
321
472
|
|
|
322
473
|
const customInstructions = extraInstructions && extraInstructions.trim().length > 0
|
|
323
474
|
? `Git diff will be provided separately in the user message.\n\n## Additional User Instructions\n${extraInstructions.trim()}`
|
|
@@ -339,7 +490,7 @@ export class AIService {
|
|
|
339
490
|
content: `Git diff:\n${diff}`
|
|
340
491
|
}
|
|
341
492
|
],
|
|
342
|
-
max_completion_tokens:
|
|
493
|
+
max_completion_tokens: this.maxCompletionTokens ?? 1000
|
|
343
494
|
});
|
|
344
495
|
|
|
345
496
|
let finalMessage = content.trim() || null;
|
|
@@ -455,7 +606,7 @@ export class AIService {
|
|
|
455
606
|
try {
|
|
456
607
|
this.debugLog('Sending request to AI API for tag notes...');
|
|
457
608
|
this.debugLog('Model:', this.model);
|
|
458
|
-
this.debugLog('Base URL:', this.openai
|
|
609
|
+
this.debugLog('Base URL:', this.openai?.baseURL ?? 'Gemini native');
|
|
459
610
|
|
|
460
611
|
const customInstructions = extraInstructions && extraInstructions.trim().length > 0
|
|
461
612
|
? `${extraInstructions.trim()}`
|
|
@@ -483,7 +634,7 @@ export class AIService {
|
|
|
483
634
|
content: userContent
|
|
484
635
|
}
|
|
485
636
|
],
|
|
486
|
-
max_completion_tokens: 3000
|
|
637
|
+
max_completion_tokens: this.maxCompletionTokens ?? 3000
|
|
487
638
|
});
|
|
488
639
|
|
|
489
640
|
const finalNotes = content.trim() || null;
|
|
@@ -517,7 +668,7 @@ export class AIService {
|
|
|
517
668
|
try {
|
|
518
669
|
this.debugLog('Sending request to AI API for pull request message...');
|
|
519
670
|
this.debugLog('Model:', this.model);
|
|
520
|
-
this.debugLog('Base URL:', this.openai
|
|
671
|
+
this.debugLog('Base URL:', this.openai?.baseURL ?? 'Gemini native');
|
|
521
672
|
|
|
522
673
|
const content = await this.createStreamingCompletion({
|
|
523
674
|
model: this.model,
|
|
@@ -536,7 +687,7 @@ export class AIService {
|
|
|
536
687
|
content: `Git diff between ${baseBranch} and ${compareBranch}:\n${diff}`
|
|
537
688
|
}
|
|
538
689
|
],
|
|
539
|
-
max_completion_tokens: 4000
|
|
690
|
+
max_completion_tokens: this.maxCompletionTokens ?? 4000
|
|
540
691
|
});
|
|
541
692
|
|
|
542
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 {
|