@ksw8954/git-ai-commit 1.0.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 (77) hide show
  1. package/AGENTS.md +38 -0
  2. package/CRUSH.md +28 -0
  3. package/Makefile +32 -0
  4. package/README.md +145 -0
  5. package/dist/commands/ai.d.ts +35 -0
  6. package/dist/commands/ai.d.ts.map +1 -0
  7. package/dist/commands/ai.js +206 -0
  8. package/dist/commands/ai.js.map +1 -0
  9. package/dist/commands/commit.d.ts +17 -0
  10. package/dist/commands/commit.d.ts.map +1 -0
  11. package/dist/commands/commit.js +126 -0
  12. package/dist/commands/commit.js.map +1 -0
  13. package/dist/commands/config.d.ts +33 -0
  14. package/dist/commands/config.d.ts.map +1 -0
  15. package/dist/commands/config.js +141 -0
  16. package/dist/commands/config.js.map +1 -0
  17. package/dist/commands/configCommand.d.ts +20 -0
  18. package/dist/commands/configCommand.d.ts.map +1 -0
  19. package/dist/commands/configCommand.js +108 -0
  20. package/dist/commands/configCommand.js.map +1 -0
  21. package/dist/commands/git.d.ts +26 -0
  22. package/dist/commands/git.d.ts.map +1 -0
  23. package/dist/commands/git.js +150 -0
  24. package/dist/commands/git.js.map +1 -0
  25. package/dist/commands/loadEnv.d.ts +2 -0
  26. package/dist/commands/loadEnv.d.ts.map +1 -0
  27. package/dist/commands/loadEnv.js +11 -0
  28. package/dist/commands/loadEnv.js.map +1 -0
  29. package/dist/commands/prCommand.d.ts +16 -0
  30. package/dist/commands/prCommand.d.ts.map +1 -0
  31. package/dist/commands/prCommand.js +61 -0
  32. package/dist/commands/prCommand.js.map +1 -0
  33. package/dist/commands/tag.d.ts +17 -0
  34. package/dist/commands/tag.d.ts.map +1 -0
  35. package/dist/commands/tag.js +127 -0
  36. package/dist/commands/tag.js.map +1 -0
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +23 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/prompts/commit.d.ts +3 -0
  42. package/dist/prompts/commit.d.ts.map +1 -0
  43. package/dist/prompts/commit.js +101 -0
  44. package/dist/prompts/commit.js.map +1 -0
  45. package/dist/prompts/pr.d.ts +3 -0
  46. package/dist/prompts/pr.d.ts.map +1 -0
  47. package/dist/prompts/pr.js +58 -0
  48. package/dist/prompts/pr.js.map +1 -0
  49. package/dist/prompts/tag.d.ts +3 -0
  50. package/dist/prompts/tag.d.ts.map +1 -0
  51. package/dist/prompts/tag.js +42 -0
  52. package/dist/prompts/tag.js.map +1 -0
  53. package/eslint.config.js +35 -0
  54. package/jest.config.js +16 -0
  55. package/package.json +51 -0
  56. package/src/__tests__/ai.test.ts +185 -0
  57. package/src/__tests__/commitCommand.test.ts +155 -0
  58. package/src/__tests__/config.test.ts +238 -0
  59. package/src/__tests__/git.test.ts +88 -0
  60. package/src/__tests__/integration.test.ts +138 -0
  61. package/src/__tests__/prCommand.test.ts +121 -0
  62. package/src/__tests__/tagCommand.test.ts +197 -0
  63. package/src/commands/ai.ts +266 -0
  64. package/src/commands/commit.ts +215 -0
  65. package/src/commands/config.ts +182 -0
  66. package/src/commands/configCommand.ts +139 -0
  67. package/src/commands/git.ts +174 -0
  68. package/src/commands/history.ts +82 -0
  69. package/src/commands/loadEnv.ts +5 -0
  70. package/src/commands/log.ts +71 -0
  71. package/src/commands/prCommand.ts +108 -0
  72. package/src/commands/tag.ts +230 -0
  73. package/src/index.ts +29 -0
  74. package/src/prompts/commit.ts +105 -0
  75. package/src/prompts/pr.ts +64 -0
  76. package/src/prompts/tag.ts +48 -0
  77. package/tsconfig.json +19 -0
@@ -0,0 +1,266 @@
1
+ import OpenAI from 'openai';
2
+ import { generateCommitPrompt } from '../prompts/commit';
3
+ import { generateTagPrompt } from '../prompts/tag';
4
+ import { generatePullRequestPrompt } from '../prompts/pr';
5
+ import { SupportedLanguage } from './config';
6
+
7
+ export interface AIServiceConfig {
8
+ apiKey: string;
9
+ baseURL?: string;
10
+ model?: string;
11
+ language?: SupportedLanguage;
12
+ verbose?: boolean;
13
+ }
14
+
15
+ export interface CommitGenerationResult {
16
+ success: boolean;
17
+ message?: string;
18
+ error?: string;
19
+ }
20
+
21
+ export interface TagGenerationResult {
22
+ success: boolean;
23
+ notes?: string;
24
+ error?: string;
25
+ }
26
+
27
+ export interface PullRequestGenerationResult {
28
+ success: boolean;
29
+ message?: string;
30
+ error?: string;
31
+ }
32
+
33
+ export class AIService {
34
+ private openai: OpenAI;
35
+ private model: string;
36
+ private language: SupportedLanguage;
37
+ private verbose: boolean;
38
+
39
+ constructor(config: AIServiceConfig) {
40
+ this.openai = new OpenAI({
41
+ apiKey: config.apiKey,
42
+ baseURL: config.baseURL
43
+ });
44
+ this.model = config.model || 'zai-org/GLM-4.5-FP8';
45
+ this.language = config.language || 'ko';
46
+ this.verbose = config.verbose ?? true;
47
+ }
48
+
49
+ private debugLog(...args: unknown[]): void {
50
+ if (this.verbose) {
51
+ console.log(...args);
52
+ }
53
+ }
54
+
55
+ async generateCommitMessage(diff: string, extraInstructions?: string): Promise<CommitGenerationResult> {
56
+ try {
57
+ this.debugLog('Sending request to AI API...');
58
+ this.debugLog('Model:', this.model);
59
+ this.debugLog('Base URL:', this.openai.baseURL);
60
+
61
+ const customInstructions = extraInstructions && extraInstructions.trim().length > 0
62
+ ? `Git diff will be provided separately in the user message.\n\n## Additional User Instructions\n${extraInstructions.trim()}`
63
+ : 'Git diff will be provided separately in the user message.';
64
+
65
+ const response = await this.openai.chat.completions.create({
66
+ model: this.model,
67
+ messages: [
68
+ {
69
+ role: 'system',
70
+ content: generateCommitPrompt(
71
+ '',
72
+ customInstructions,
73
+ this.language
74
+ )
75
+ },
76
+ {
77
+ role: 'user',
78
+ content: `Git diff:\n${diff}`
79
+ }
80
+ ],
81
+ max_tokens: 3000,
82
+ temperature: 0.1
83
+ });
84
+
85
+ this.debugLog('API Response received:', JSON.stringify(response, null, 2));
86
+
87
+ const choice = response.choices[0];
88
+ const message = choice?.message?.content?.trim();
89
+
90
+ // Handle reasoning content if available (type assertion for custom API response)
91
+ const messageAny = choice?.message as any;
92
+ const reasoningMessage = messageAny?.reasoning_content?.trim();
93
+
94
+ // Try to extract commit message from reasoning content if regular content is null
95
+ let finalMessage = message;
96
+ if (!finalMessage && reasoningMessage) {
97
+ // Look for commit message pattern in reasoning content
98
+ const commitMatch = reasoningMessage.match(/(?:feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert): .+/);
99
+ if (commitMatch) {
100
+ finalMessage = commitMatch[0].trim();
101
+ } else {
102
+ // Look for any line that starts with conventional commit types
103
+ const typeMatch = reasoningMessage.match(/(?:feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)[^:]*: .+/);
104
+ if (typeMatch) {
105
+ finalMessage = typeMatch[0].trim();
106
+ } else {
107
+ // Try to find a short descriptive line
108
+ const lines = reasoningMessage.split('\n').filter((line: string) => line.trim().length > 0);
109
+ const shortLine = lines.find((line: string) => line.length < 100 && line.includes('version'));
110
+ finalMessage = shortLine ? `chore: ${shortLine.trim()}` : `chore: update files`;
111
+ }
112
+ }
113
+ }
114
+
115
+ if (!finalMessage) {
116
+ this.debugLog('No message found in response');
117
+ return {
118
+ success: false,
119
+ error: 'No commit message generated'
120
+ };
121
+ }
122
+
123
+ // Clean up the message
124
+ finalMessage = finalMessage.replace(/^(The commit message is:|Commit message:|Message:)\s*/, '');
125
+
126
+ // Ensure it follows conventional commit format
127
+ if (!finalMessage.match(/^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\(.+\))?!?: .+/)) {
128
+ // If it doesn't match the format, try to fix it
129
+ if (finalMessage.includes('version') || finalMessage.includes('update')) {
130
+ finalMessage = `chore: ${finalMessage}`;
131
+ } else if (finalMessage.includes('feature') || finalMessage.includes('add')) {
132
+ finalMessage = `feat: ${finalMessage}`;
133
+ } else if (finalMessage.includes('fix') || finalMessage.includes('bug')) {
134
+ finalMessage = `fix: ${finalMessage}`;
135
+ } else {
136
+ finalMessage = `chore: ${finalMessage}`;
137
+ }
138
+ }
139
+
140
+ return {
141
+ success: true,
142
+ message: finalMessage
143
+ };
144
+ } catch (error) {
145
+ console.error('API Error:', error);
146
+ return {
147
+ success: false,
148
+ error: error instanceof Error ? error.message : 'Failed to generate commit message'
149
+ };
150
+ }
151
+ }
152
+
153
+ async generateTagNotes(tagName: string, commitLog: string, extraInstructions?: string): Promise<TagGenerationResult> {
154
+ try {
155
+ this.debugLog('Sending request to AI API for tag notes...');
156
+ this.debugLog('Model:', this.model);
157
+ this.debugLog('Base URL:', this.openai.baseURL);
158
+
159
+ const customInstructions = extraInstructions && extraInstructions.trim().length > 0
160
+ ? `${extraInstructions.trim()}`
161
+ : '';
162
+
163
+ const response = await this.openai.chat.completions.create({
164
+ model: this.model,
165
+ messages: [
166
+ {
167
+ role: 'system',
168
+ content: generateTagPrompt(tagName, customInstructions, this.language)
169
+ },
170
+ {
171
+ role: 'user',
172
+ content: `Commit log:\n${commitLog}`
173
+ }
174
+ ],
175
+ max_tokens: 3000,
176
+ temperature: 0.2
177
+ });
178
+
179
+ const choice = response.choices[0];
180
+ const message = choice?.message?.content?.trim();
181
+
182
+ const messageAny = choice?.message as any;
183
+ const reasoningMessage = messageAny?.reasoning_content?.trim();
184
+
185
+ const finalNotes = message || reasoningMessage;
186
+
187
+ if (!finalNotes) {
188
+ this.debugLog('No notes found in response');
189
+ return {
190
+ success: false,
191
+ error: 'No tag notes generated'
192
+ };
193
+ }
194
+
195
+ return {
196
+ success: true,
197
+ notes: finalNotes.trim()
198
+ };
199
+ } catch (error) {
200
+ console.error('API Error:', error);
201
+ return {
202
+ success: false,
203
+ error: error instanceof Error ? error.message : 'Failed to generate tag notes'
204
+ };
205
+ }
206
+ }
207
+
208
+ async generatePullRequestMessage(
209
+ baseBranch: string,
210
+ compareBranch: string,
211
+ diff: string
212
+ ): Promise<PullRequestGenerationResult> {
213
+ try {
214
+ this.debugLog('Sending request to AI API for pull request message...');
215
+ this.debugLog('Model:', this.model);
216
+ this.debugLog('Base URL:', this.openai.baseURL);
217
+
218
+ const response = await this.openai.chat.completions.create({
219
+ model: this.model,
220
+ messages: [
221
+ {
222
+ role: 'system',
223
+ content: generatePullRequestPrompt(
224
+ baseBranch,
225
+ compareBranch,
226
+ '',
227
+ this.language
228
+ )
229
+ },
230
+ {
231
+ role: 'user',
232
+ content: `Git diff between ${baseBranch} and ${compareBranch}:\n${diff}`
233
+ }
234
+ ],
235
+ max_tokens: 4000,
236
+ temperature: 0.2
237
+ });
238
+
239
+ const choice = response.choices[0];
240
+ const message = choice?.message?.content?.trim();
241
+
242
+ const messageAny = choice?.message as any;
243
+ const reasoningMessage = messageAny?.reasoning_content?.trim();
244
+
245
+ const finalMessage = message || reasoningMessage;
246
+
247
+ if (!finalMessage) {
248
+ return {
249
+ success: false,
250
+ error: 'No pull request message generated'
251
+ };
252
+ }
253
+
254
+ return {
255
+ success: true,
256
+ message: finalMessage.trim()
257
+ };
258
+ } catch (error) {
259
+ console.error('API Error:', error);
260
+ return {
261
+ success: false,
262
+ error: error instanceof Error ? error.message : 'Failed to generate pull request message'
263
+ };
264
+ }
265
+ }
266
+ }
@@ -0,0 +1,215 @@
1
+ import { Command } from 'commander';
2
+ import readline from 'readline';
3
+ import { GitService, GitDiffResult } from './git';
4
+ import { AIService, AIServiceConfig } from './ai';
5
+ import { ConfigService } from './config';
6
+ import { LogService } from './log';
7
+
8
+ export interface CommitOptions {
9
+ apiKey?: string;
10
+ baseURL?: string;
11
+ model?: string;
12
+ push?: boolean;
13
+ messageOnly?: boolean;
14
+ prompt?: string;
15
+ }
16
+
17
+ export class CommitCommand {
18
+ private program: Command;
19
+
20
+ constructor() {
21
+ this.program = new Command('commit')
22
+ .description('Generate AI-powered commit message')
23
+ .option('-k, --api-key <key>', 'OpenAI API key (overrides env var)')
24
+ .option('-b, --base-url <url>', 'Custom API base URL (overrides env var)')
25
+ .option('--model <model>', 'Model to use (overrides env var)')
26
+ .option('-m, --message-only', 'Output only the generated commit message and skip git actions')
27
+ .option('-p, --push', 'Push current branch after creating the commit (implies --commit)')
28
+ .option('--prompt <text>', 'Additional instructions to append to the AI prompt for this commit')
29
+ .action(this.handleCommit.bind(this));
30
+ }
31
+
32
+ private async handleCommit(options: CommitOptions) {
33
+ const start = Date.now();
34
+ const safeArgs = {
35
+ ...options,
36
+ apiKey: options.apiKey ? '***' : undefined
37
+ } as Record<string, unknown>;
38
+ try {
39
+ const existingConfig = ConfigService.getConfig();
40
+
41
+ const mergedApiKey = options.apiKey || existingConfig.apiKey;
42
+ const mergedBaseURL = options.baseURL || existingConfig.baseURL;
43
+ const mergedModel = options.model || existingConfig.model;
44
+ const messageOnly = Boolean(options.messageOnly);
45
+
46
+ const log = (...args: unknown[]) => {
47
+ if (!messageOnly) {
48
+ console.log(...args);
49
+ }
50
+ };
51
+
52
+ ConfigService.validateConfig({
53
+ apiKey: mergedApiKey,
54
+ language: existingConfig.language
55
+ });
56
+
57
+ const aiConfig: AIServiceConfig = {
58
+ apiKey: mergedApiKey!,
59
+ baseURL: mergedBaseURL,
60
+ model: mergedModel,
61
+ language: existingConfig.language,
62
+ verbose: !messageOnly
63
+ };
64
+
65
+ log('Getting staged changes...');
66
+
67
+ const diffResult: GitDiffResult = await GitService.getStagedDiff();
68
+
69
+ if (!diffResult.success) {
70
+ console.error('Error:', diffResult.error);
71
+ await LogService.append({
72
+ command: 'commit',
73
+ args: safeArgs,
74
+ status: 'failure',
75
+ details: diffResult.error,
76
+ durationMs: Date.now() - start
77
+ });
78
+ process.exit(1);
79
+ }
80
+
81
+ log('Generating commit message...');
82
+
83
+ const aiService = new AIService(aiConfig);
84
+ const aiResult = await aiService.generateCommitMessage(diffResult.diff!, options.prompt);
85
+
86
+ if (!aiResult.success) {
87
+ console.error('Error:', aiResult.error);
88
+ await LogService.append({
89
+ command: 'commit',
90
+ args: safeArgs,
91
+ status: 'failure',
92
+ details: aiResult.error,
93
+ durationMs: Date.now() - start
94
+ });
95
+ process.exit(1);
96
+ }
97
+
98
+ if (typeof aiResult.message !== 'string') {
99
+ console.error('Error: Failed to generate commit message');
100
+ process.exit(1);
101
+ }
102
+
103
+ if (messageOnly) {
104
+ console.log(aiResult.message);
105
+ await LogService.append({
106
+ command: 'commit',
107
+ args: { ...safeArgs, messageOnly: true },
108
+ status: 'success',
109
+ details: 'message-only output',
110
+ durationMs: Date.now() - start
111
+ });
112
+ return;
113
+ }
114
+
115
+ console.log('\nGenerated commit message:');
116
+ console.log(aiResult.message);
117
+
118
+ const confirmed = await this.confirmCommit();
119
+
120
+ if (!confirmed) {
121
+ console.log('Commit cancelled by user.');
122
+ await LogService.append({
123
+ command: 'commit',
124
+ args: safeArgs,
125
+ status: 'cancelled',
126
+ details: 'user declined commit',
127
+ durationMs: Date.now() - start
128
+ });
129
+ return;
130
+ }
131
+
132
+ console.log('\nCreating commit...');
133
+ const commitSuccess = await GitService.createCommit(aiResult.message!);
134
+
135
+ if (commitSuccess) {
136
+ console.log('✅ Commit created successfully!');
137
+
138
+ const pushRequested = Boolean(options.push);
139
+ const pushFromConfig = !pushRequested && existingConfig.autoPush;
140
+ const shouldPush = pushRequested || pushFromConfig;
141
+
142
+ if (shouldPush) {
143
+ if (pushFromConfig) {
144
+ console.log('Auto push enabled in config; pushing to remote...');
145
+ } else {
146
+ console.log('Pushing to remote...');
147
+ }
148
+
149
+ const pushSuccess = await GitService.push();
150
+
151
+ if (pushSuccess) {
152
+ console.log('✅ Push completed successfully!');
153
+ } else {
154
+ console.error('❌ Failed to push to remote');
155
+ await LogService.append({
156
+ command: 'commit',
157
+ args: { ...safeArgs, push: true },
158
+ status: 'failure',
159
+ details: 'push failed',
160
+ durationMs: Date.now() - start
161
+ });
162
+ process.exit(1);
163
+ }
164
+ }
165
+ } else {
166
+ console.error('❌ Failed to create commit');
167
+ await LogService.append({
168
+ command: 'commit',
169
+ args: safeArgs,
170
+ status: 'failure',
171
+ details: 'git commit failed',
172
+ durationMs: Date.now() - start
173
+ });
174
+ process.exit(1);
175
+ }
176
+ await LogService.append({
177
+ command: 'commit',
178
+ args: safeArgs,
179
+ status: 'success',
180
+ durationMs: Date.now() - start
181
+ });
182
+ } catch (error) {
183
+ const message = error instanceof Error ? error.message : String(error);
184
+ console.error('Error:', message);
185
+ await LogService.append({
186
+ command: 'commit',
187
+ args: safeArgs,
188
+ status: 'failure',
189
+ details: message,
190
+ durationMs: Date.now() - start
191
+ });
192
+ process.exit(1);
193
+ }
194
+ }
195
+
196
+ private async confirmCommit(): Promise<boolean> {
197
+ const rl = readline.createInterface({
198
+ input: process.stdin,
199
+ output: process.stdout
200
+ });
201
+
202
+ const answer: string = await new Promise(resolve => {
203
+ rl.question('Proceed with git commit? (y/n): ', resolve);
204
+ });
205
+
206
+ rl.close();
207
+
208
+ const normalized = answer.trim().toLowerCase();
209
+ return normalized === 'y' || normalized === 'yes';
210
+ }
211
+
212
+ getCommand(): Command {
213
+ return this.program;
214
+ }
215
+ }
@@ -0,0 +1,182 @@
1
+ import fs from 'fs';
2
+ import { promises as fsPromises } from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ export type SupportedLanguage = 'ko' | 'en';
7
+
8
+ export interface EnvironmentConfig {
9
+ apiKey?: string;
10
+ baseURL?: string;
11
+ model?: string;
12
+ mode: 'custom' | 'openai';
13
+ language: SupportedLanguage;
14
+ autoPush: boolean;
15
+ }
16
+
17
+ interface StoredConfig {
18
+ apiKey?: string;
19
+ baseURL?: string;
20
+ model?: string;
21
+ mode?: 'custom' | 'openai';
22
+ language?: SupportedLanguage | string;
23
+ autoPush?: boolean;
24
+ }
25
+
26
+ const DEFAULT_MODEL = 'zai-org/GLM-4.5-FP8';
27
+ const DEFAULT_MODE: 'custom' | 'openai' = 'custom';
28
+ const DEFAULT_LANGUAGE: SupportedLanguage = 'ko';
29
+ const DEFAULT_AUTO_PUSH = false;
30
+
31
+ export class ConfigService {
32
+ private static getConfigFilePath(): string {
33
+ const overridePath = process.env.GIT_AI_COMMIT_CONFIG_PATH;
34
+ if (overridePath) {
35
+ return path.resolve(overridePath);
36
+ }
37
+
38
+ return path.join(os.homedir(), '.git-ai-commit', 'config.json');
39
+ }
40
+
41
+ private static loadFileConfig(): StoredConfig {
42
+ const filePath = this.getConfigFilePath();
43
+
44
+ try {
45
+ if (!fs.existsSync(filePath)) {
46
+ return {};
47
+ }
48
+
49
+ const raw = fs.readFileSync(filePath, 'utf-8');
50
+ if (!raw.trim()) {
51
+ return {};
52
+ }
53
+
54
+ const parsed = JSON.parse(raw);
55
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
56
+ } catch (error) {
57
+ console.warn('Warning: Failed to read configuration file. Falling back to environment variables.');
58
+ return {};
59
+ }
60
+ }
61
+
62
+ private static normalizeLanguage(language?: string): SupportedLanguage {
63
+ if (!language) {
64
+ return DEFAULT_LANGUAGE;
65
+ }
66
+
67
+ const normalized = language.toLowerCase();
68
+ return normalized === 'en' ? 'en' : 'ko';
69
+ }
70
+
71
+ private static normalizeMode(mode?: string): 'custom' | 'openai' {
72
+ if (!mode) {
73
+ return DEFAULT_MODE;
74
+ }
75
+
76
+ const normalized = mode.toLowerCase();
77
+ return normalized === 'openai' ? 'openai' : 'custom';
78
+ }
79
+
80
+ private static resolveEnvConfig(modeOverride?: 'custom' | 'openai'): EnvironmentConfig {
81
+ const resolvedMode = this.normalizeMode(modeOverride || process.env.AI_MODE);
82
+ const isOpenAI = resolvedMode === 'openai';
83
+
84
+ const apiKey = isOpenAI
85
+ ? process.env.OPENAI_API_KEY || process.env.AI_API_KEY
86
+ : process.env.AI_API_KEY || process.env.OPENAI_API_KEY;
87
+
88
+ const baseURL = isOpenAI
89
+ ? process.env.OPENAI_BASE_URL || process.env.AI_BASE_URL
90
+ : process.env.AI_BASE_URL || process.env.OPENAI_BASE_URL;
91
+
92
+ const model = isOpenAI
93
+ ? process.env.OPENAI_MODEL || process.env.AI_MODEL || DEFAULT_MODEL
94
+ : process.env.AI_MODEL || process.env.OPENAI_MODEL || DEFAULT_MODEL;
95
+
96
+ return {
97
+ apiKey: apiKey || undefined,
98
+ baseURL: baseURL || undefined,
99
+ model: model || DEFAULT_MODEL,
100
+ mode: resolvedMode,
101
+ language: DEFAULT_LANGUAGE,
102
+ autoPush: DEFAULT_AUTO_PUSH
103
+ };
104
+ }
105
+
106
+ static getConfig(): EnvironmentConfig {
107
+ const fileConfig = this.loadFileConfig();
108
+ const envConfig = this.resolveEnvConfig(fileConfig.mode);
109
+
110
+ const mode = this.normalizeMode(fileConfig.mode || envConfig.mode);
111
+ const apiKey = fileConfig.apiKey ?? envConfig.apiKey;
112
+ const baseURL = fileConfig.baseURL ?? envConfig.baseURL;
113
+ const model = fileConfig.model ?? envConfig.model ?? DEFAULT_MODEL;
114
+ const language = this.normalizeLanguage(fileConfig.language ?? envConfig.language);
115
+ const autoPush = typeof fileConfig.autoPush === 'boolean' ? fileConfig.autoPush : envConfig.autoPush;
116
+
117
+ return {
118
+ apiKey,
119
+ baseURL,
120
+ model,
121
+ mode,
122
+ language,
123
+ autoPush
124
+ };
125
+ }
126
+
127
+ static getEnvConfig(): EnvironmentConfig {
128
+ return this.getConfig();
129
+ }
130
+
131
+ static async updateConfig(updates: StoredConfig): Promise<void> {
132
+ const filePath = this.getConfigFilePath();
133
+ const current = this.loadFileConfig();
134
+
135
+ const next: StoredConfig = {
136
+ ...current,
137
+ ...updates
138
+ };
139
+
140
+ if (updates.language !== undefined) {
141
+ next.language = this.normalizeLanguage(updates.language);
142
+ }
143
+
144
+ if (updates.autoPush !== undefined) {
145
+ next.autoPush = Boolean(updates.autoPush);
146
+ }
147
+
148
+ if (updates.mode !== undefined) {
149
+ next.mode = this.normalizeMode(updates.mode);
150
+ }
151
+
152
+ if (next.model === DEFAULT_MODEL) {
153
+ delete next.model;
154
+ }
155
+
156
+ if (next.mode === DEFAULT_MODE) {
157
+ delete next.mode;
158
+ }
159
+
160
+ const sanitized = Object.entries(next).reduce<StoredConfig>((acc, [key, value]) => {
161
+ if (value !== undefined) {
162
+ acc[key as keyof StoredConfig] = value as any;
163
+ } else {
164
+ delete acc[key as keyof StoredConfig];
165
+ }
166
+ return acc;
167
+ }, {});
168
+
169
+ await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
170
+ await fsPromises.writeFile(filePath, JSON.stringify(sanitized, null, 2), 'utf-8');
171
+ }
172
+
173
+ static validateConfig(config: { apiKey?: string; language?: string }): void {
174
+ if (!config.apiKey) {
175
+ throw new Error('API key is required');
176
+ }
177
+
178
+ if (config.language && !['ko', 'en'].includes(config.language)) {
179
+ throw new Error('Unsupported language. Use "ko" or "en".');
180
+ }
181
+ }
182
+ }