@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.
Files changed (53) hide show
  1. package/.github/workflows/publish.yml +36 -0
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +25 -4
  4. package/dist/commands/ai.d.ts +7 -1
  5. package/dist/commands/ai.d.ts.map +1 -1
  6. package/dist/commands/ai.js +144 -22
  7. package/dist/commands/ai.js.map +1 -1
  8. package/dist/commands/commit.d.ts +1 -0
  9. package/dist/commands/commit.d.ts.map +1 -1
  10. package/dist/commands/commit.js +37 -34
  11. package/dist/commands/commit.js.map +1 -1
  12. package/dist/commands/completion.d.ts.map +1 -1
  13. package/dist/commands/completion.js +15 -5
  14. package/dist/commands/completion.js.map +1 -1
  15. package/dist/commands/config.d.ts +7 -2
  16. package/dist/commands/config.d.ts.map +1 -1
  17. package/dist/commands/config.js +37 -15
  18. package/dist/commands/config.js.map +1 -1
  19. package/dist/commands/configCommand.d.ts +5 -1
  20. package/dist/commands/configCommand.d.ts.map +1 -1
  21. package/dist/commands/configCommand.js +30 -3
  22. package/dist/commands/configCommand.js.map +1 -1
  23. package/dist/commands/git.js +3 -3
  24. package/dist/commands/hookCommand.d.ts +14 -0
  25. package/dist/commands/hookCommand.d.ts.map +1 -0
  26. package/dist/commands/hookCommand.js +180 -0
  27. package/dist/commands/hookCommand.js.map +1 -0
  28. package/dist/commands/prCommand.d.ts.map +1 -1
  29. package/dist/commands/prCommand.js +3 -1
  30. package/dist/commands/prCommand.js.map +1 -1
  31. package/dist/commands/tag.d.ts.map +1 -1
  32. package/dist/commands/tag.js +12 -2
  33. package/dist/commands/tag.js.map +1 -1
  34. package/dist/index.js +3 -0
  35. package/dist/index.js.map +1 -1
  36. package/package.json +2 -1
  37. package/src/__tests__/ai.test.ts +486 -7
  38. package/src/__tests__/commitCommand.test.ts +111 -0
  39. package/src/__tests__/config.test.ts +24 -6
  40. package/src/__tests__/git.test.ts +421 -98
  41. package/src/__tests__/preCommit.test.ts +19 -0
  42. package/src/__tests__/tagCommand.test.ts +510 -17
  43. package/src/commands/ai.ts +175 -24
  44. package/src/commands/commit.ts +40 -34
  45. package/src/commands/completion.ts +15 -5
  46. package/src/commands/config.ts +46 -23
  47. package/src/commands/configCommand.ts +41 -8
  48. package/src/commands/git.ts +3 -3
  49. package/src/commands/hookCommand.ts +193 -0
  50. package/src/commands/prCommand.ts +3 -1
  51. package/src/commands/tag.ts +13 -2
  52. package/src/index.ts +3 -0
  53. package/src/schema/config.schema.json +72 -0
@@ -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.openai = new OpenAI({
48
- apiKey: config.apiKey,
49
- baseURL: config.baseURL
50
- });
51
- this.model = config.model || 'zai-org/GLM-4.5-FP8';
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 reasoningTokens = 0;
188
- let contentTokens = 0;
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
- reasoningTokens++;
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... (${reasoningTokens} tokens, ${elapsed})`);
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
- contentTokens++;
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 response... (${contentTokens} tokens, ${elapsed})`);
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
- const detail = reasoningTokens > 0
241
- ? `${totalTokens} tokens (thinking: ${reasoningTokens}, response: ${contentTokens}), ${elapsed}`
242
- : `${contentTokens} tokens, ${elapsed}`;
243
- process.stdout.write(`\r✅ Complete (${detail})\n`);
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.baseURL);
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: 3000
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.baseURL);
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.baseURL);
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;
@@ -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
- child.on("close", (code) => {
101
- if (code === 0) {
102
- console.log("✅ pre-commit hooks passed");
103
- resolve();
104
- } else {
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
- child.on("error", (err) => {
111
- // If pre-commit is not installed/found, we might want to warn instead of fail?
112
- // But usually 'error' event on spawn (with shell:true) is rare for command not found (it usually exits with 127).
113
- // However, if it fails to spawn, we reject.
114
- reject(err);
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
- } catch (error) {
118
- // If the error suggests command not found, we might warn.
119
- // But since we use shell:true, 'command not found' usually results in exit code 127, which goes to 'close' event.
120
- // So we catch the error from the promise rejection above.
121
- const msg = error instanceof Error ? error.message : String(error);
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
@@ -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: 'custom' | 'openai';
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?: 'custom' | 'openai';
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: 'custom' | 'openai' = 'custom';
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
- return typeof parsed === 'object' && parsed !== null ? parsed : {};
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): 'custom' | 'openai' {
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
- return normalized === 'openai' ? 'openai' : 'custom';
101
+ if (normalized === 'openai') return 'openai';
102
+ if (normalized === 'gemini') return 'gemini';
103
+ return 'custom';
93
104
  }
94
105
 
95
- private static resolveEnvConfig(modeOverride?: 'custom' | 'openai'): EnvironmentConfig {
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
- const baseURL = isOpenAI
104
- ? process.env.OPENAI_BASE_URL || process.env.AI_BASE_URL
105
- : process.env.AI_BASE_URL || process.env.OPENAI_BASE_URL;
106
-
107
- const model = isOpenAI
108
- ? process.env.OPENAI_MODEL || process.env.AI_MODEL || DEFAULT_MODEL
109
- : process.env.AI_MODEL || process.env.OPENAI_MODEL || DEFAULT_MODEL;
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: model || DEFAULT_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 {