@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.
- package/AGENTS.md +38 -0
- package/CRUSH.md +28 -0
- package/Makefile +32 -0
- package/README.md +145 -0
- package/dist/commands/ai.d.ts +35 -0
- package/dist/commands/ai.d.ts.map +1 -0
- package/dist/commands/ai.js +206 -0
- package/dist/commands/ai.js.map +1 -0
- package/dist/commands/commit.d.ts +17 -0
- package/dist/commands/commit.d.ts.map +1 -0
- package/dist/commands/commit.js +126 -0
- package/dist/commands/commit.js.map +1 -0
- package/dist/commands/config.d.ts +33 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +141 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/configCommand.d.ts +20 -0
- package/dist/commands/configCommand.d.ts.map +1 -0
- package/dist/commands/configCommand.js +108 -0
- package/dist/commands/configCommand.js.map +1 -0
- package/dist/commands/git.d.ts +26 -0
- package/dist/commands/git.d.ts.map +1 -0
- package/dist/commands/git.js +150 -0
- package/dist/commands/git.js.map +1 -0
- package/dist/commands/loadEnv.d.ts +2 -0
- package/dist/commands/loadEnv.d.ts.map +1 -0
- package/dist/commands/loadEnv.js +11 -0
- package/dist/commands/loadEnv.js.map +1 -0
- package/dist/commands/prCommand.d.ts +16 -0
- package/dist/commands/prCommand.d.ts.map +1 -0
- package/dist/commands/prCommand.js +61 -0
- package/dist/commands/prCommand.js.map +1 -0
- package/dist/commands/tag.d.ts +17 -0
- package/dist/commands/tag.d.ts.map +1 -0
- package/dist/commands/tag.js +127 -0
- package/dist/commands/tag.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts/commit.d.ts +3 -0
- package/dist/prompts/commit.d.ts.map +1 -0
- package/dist/prompts/commit.js +101 -0
- package/dist/prompts/commit.js.map +1 -0
- package/dist/prompts/pr.d.ts +3 -0
- package/dist/prompts/pr.d.ts.map +1 -0
- package/dist/prompts/pr.js +58 -0
- package/dist/prompts/pr.js.map +1 -0
- package/dist/prompts/tag.d.ts +3 -0
- package/dist/prompts/tag.d.ts.map +1 -0
- package/dist/prompts/tag.js +42 -0
- package/dist/prompts/tag.js.map +1 -0
- package/eslint.config.js +35 -0
- package/jest.config.js +16 -0
- package/package.json +51 -0
- package/src/__tests__/ai.test.ts +185 -0
- package/src/__tests__/commitCommand.test.ts +155 -0
- package/src/__tests__/config.test.ts +238 -0
- package/src/__tests__/git.test.ts +88 -0
- package/src/__tests__/integration.test.ts +138 -0
- package/src/__tests__/prCommand.test.ts +121 -0
- package/src/__tests__/tagCommand.test.ts +197 -0
- package/src/commands/ai.ts +266 -0
- package/src/commands/commit.ts +215 -0
- package/src/commands/config.ts +182 -0
- package/src/commands/configCommand.ts +139 -0
- package/src/commands/git.ts +174 -0
- package/src/commands/history.ts +82 -0
- package/src/commands/loadEnv.ts +5 -0
- package/src/commands/log.ts +71 -0
- package/src/commands/prCommand.ts +108 -0
- package/src/commands/tag.ts +230 -0
- package/src/index.ts +29 -0
- package/src/prompts/commit.ts +105 -0
- package/src/prompts/pr.ts +64 -0
- package/src/prompts/tag.ts +48 -0
- 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
|
+
}
|