@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.
Files changed (53) hide show
  1. package/.github/workflows/publish.yml +31 -5
  2. package/CHANGELOG.md +16 -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 +108 -11
  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 +9 -3
  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 +128 -13
  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 +10 -4
  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 {
@@ -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.baseURL);
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: 3000
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.baseURL);
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.baseURL);
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;
@@ -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 {
@@ -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?: 'custom' | 'openai';
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): 'custom' | 'openai' {
51
+ private validateMode(mode: string): AIMode {
46
52
  const normalized = mode?.toLowerCase();
47
- if (normalized !== 'custom' && normalized !== 'openai') {
48
- console.error('Mode must be either "custom" or "openai".');
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?: 'custom' | 'openai';
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);
@@ -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 = 50000;
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 = 400;
11
- const MAX_NEW_FILE_LINES = 200;
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');