commitgpt 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/README.md +208 -70
- data/commitgpt.gemspec +35 -0
- data/lib/commitgpt/cli.rb +46 -6
- data/lib/commitgpt/commit_ai.rb +390 -55
- data/lib/commitgpt/config_manager.rb +164 -0
- data/lib/commitgpt/provider_presets.rb +19 -0
- data/lib/commitgpt/setup_wizard.rb +314 -0
- data/lib/commitgpt/string.rb +4 -0
- data/lib/commitgpt/version.rb +1 -1
- metadata +19 -9
- data/.rspec +0 -3
- data/.rubocop.yml +0 -19
- data/CHANGELOG.md +0 -5
- data/CODE_OF_CONDUCT.md +0 -84
- data/Gemfile +0 -19
- data/Gemfile.lock +0 -73
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -12
data/lib/commitgpt/commit_ai.rb
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "httparty"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
4
6
|
require "json"
|
|
5
7
|
require "io/console"
|
|
8
|
+
require "tty-prompt"
|
|
6
9
|
require_relative "string"
|
|
10
|
+
require_relative "config_manager"
|
|
7
11
|
|
|
8
12
|
# CommitGpt based on GPT-3
|
|
9
13
|
module CommitGpt
|
|
10
14
|
# Commit AI roboter based on GPT-3
|
|
11
15
|
class CommitAi
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
attr_reader :api_key, :base_url, :model, :diff_len
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
provider_config = ConfigManager.get_active_provider_config
|
|
20
|
+
|
|
21
|
+
if provider_config
|
|
22
|
+
@api_key = provider_config["api_key"]
|
|
23
|
+
@base_url = provider_config["base_url"]
|
|
24
|
+
@model = provider_config["model"]
|
|
25
|
+
@diff_len = provider_config["diff_len"] || 32768
|
|
26
|
+
else
|
|
27
|
+
@api_key = nil
|
|
28
|
+
@base_url = nil
|
|
29
|
+
@model = nil
|
|
30
|
+
@diff_len = 32768
|
|
31
|
+
end
|
|
32
|
+
end
|
|
16
33
|
|
|
17
34
|
def aicm(verbose: false)
|
|
18
35
|
exit(1) unless welcome
|
|
@@ -22,8 +39,55 @@ module CommitGpt
|
|
|
22
39
|
puts diff
|
|
23
40
|
puts "\n"
|
|
24
41
|
end
|
|
25
|
-
|
|
26
|
-
|
|
42
|
+
|
|
43
|
+
loop do
|
|
44
|
+
ai_commit_message = message(diff) || exit(1)
|
|
45
|
+
action = confirm_commit(ai_commit_message)
|
|
46
|
+
|
|
47
|
+
case action
|
|
48
|
+
when :commit
|
|
49
|
+
commit_command = "git commit -m \"#{ai_commit_message}\""
|
|
50
|
+
puts "\n▲ Executing: #{commit_command}".orange
|
|
51
|
+
system(commit_command)
|
|
52
|
+
puts "\n\n"
|
|
53
|
+
puts `git log -1`
|
|
54
|
+
break
|
|
55
|
+
when :regenerate
|
|
56
|
+
puts "\n"
|
|
57
|
+
next
|
|
58
|
+
when :edit
|
|
59
|
+
prompt = TTY::Prompt.new
|
|
60
|
+
new_message = prompt.ask("Enter your commit message:")
|
|
61
|
+
if new_message && !new_message.strip.empty?
|
|
62
|
+
commit_command = "git commit -m \"#{new_message}\""
|
|
63
|
+
system(commit_command)
|
|
64
|
+
puts "\n"
|
|
65
|
+
puts `git log -1`
|
|
66
|
+
else
|
|
67
|
+
puts "▲ Commit aborted (empty message).".red
|
|
68
|
+
end
|
|
69
|
+
break
|
|
70
|
+
when :exit
|
|
71
|
+
puts "▲ Exit without commit.".yellow
|
|
72
|
+
break
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def list_models
|
|
78
|
+
headers = {
|
|
79
|
+
"Content-Type" => "application/json",
|
|
80
|
+
"User-Agent" => "Ruby/#{RUBY_VERSION}"
|
|
81
|
+
}
|
|
82
|
+
headers["Authorization"] = "Bearer #{@api_key}" if @api_key
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
response = HTTParty.get("#{@base_url}/models", headers: headers)
|
|
86
|
+
models = response["data"] || []
|
|
87
|
+
models.each { |m| puts m["id"] }
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
puts "▲ Failed to list models: #{e.message}".red
|
|
90
|
+
end
|
|
27
91
|
end
|
|
28
92
|
|
|
29
93
|
def list_models
|
|
@@ -44,47 +108,164 @@ module CommitGpt
|
|
|
44
108
|
|
|
45
109
|
private
|
|
46
110
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
111
|
+
def confirm_commit(message)
|
|
112
|
+
prompt = TTY::Prompt.new
|
|
113
|
+
|
|
114
|
+
begin
|
|
115
|
+
prompt.select("Action:") do |menu|
|
|
116
|
+
menu.choice "Commit", :commit
|
|
117
|
+
menu.choice "Regenerate", :regenerate
|
|
118
|
+
menu.choice "Edit", :edit
|
|
119
|
+
menu.choice "Exit without commit", :exit
|
|
120
|
+
end
|
|
121
|
+
rescue TTY::Reader::InputInterrupt, Interrupt
|
|
122
|
+
:exit
|
|
123
|
+
end
|
|
56
124
|
end
|
|
57
125
|
|
|
58
126
|
def message(diff = nil)
|
|
59
|
-
|
|
60
|
-
ai_commit_message = generate_commit(diff)
|
|
61
|
-
return nil if ai_commit_message.nil?
|
|
62
|
-
|
|
63
|
-
puts "#{"▲ Commit message: ".green}git commit -am \"#{ai_commit_message}\"\n\n"
|
|
64
|
-
ai_commit_message
|
|
127
|
+
generate_commit(diff)
|
|
65
128
|
end
|
|
66
129
|
|
|
67
130
|
def git_diff
|
|
68
|
-
|
|
131
|
+
exclusions = '":(exclude)Gemfile.lock" ":(exclude)package-lock.json" ":(exclude)yarn.lock" ":(exclude)pnpm-lock.yaml"'
|
|
132
|
+
diff_cached = `git diff --cached . #{exclusions}`.chomp
|
|
133
|
+
diff_unstaged = `git diff . #{exclusions}`.chomp
|
|
69
134
|
|
|
70
|
-
if
|
|
71
|
-
|
|
72
|
-
|
|
135
|
+
if !diff_unstaged.empty?
|
|
136
|
+
if !diff_cached.empty?
|
|
137
|
+
# Scenario: Mixed state (some staged, some not)
|
|
138
|
+
puts "▲ You have both staged and unstaged changes:".yellow
|
|
139
|
+
|
|
140
|
+
staged_files = `git diff --cached --name-status . #{exclusions}`.chomp
|
|
141
|
+
unstaged_files = `git diff --name-status . #{exclusions}`.chomp
|
|
142
|
+
|
|
143
|
+
puts "\n #{'Staged changes:'.green}"
|
|
144
|
+
puts staged_files.gsub(/^/, " ")
|
|
145
|
+
|
|
146
|
+
puts "\n #{'Unstaged changes:'.red}"
|
|
147
|
+
puts unstaged_files.gsub(/^/, " ")
|
|
148
|
+
puts ""
|
|
149
|
+
|
|
150
|
+
prompt = TTY::Prompt.new
|
|
151
|
+
choice = prompt.select("How to proceed?") do |menu|
|
|
152
|
+
menu.choice "Include unstaged changes (git add .)", :add_all
|
|
153
|
+
menu.choice "Use staged changes only", :staged_only
|
|
154
|
+
menu.choice "Exit", :exit
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
case choice
|
|
158
|
+
when :add_all
|
|
159
|
+
puts "▲ Running git add .".yellow
|
|
160
|
+
system("git add .")
|
|
161
|
+
diff_cached = `git diff --cached . #{exclusions}`.chomp
|
|
162
|
+
when :exit
|
|
163
|
+
return nil
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
# Scenario: Only unstaged changes
|
|
167
|
+
choice = prompt_no_staged_changes
|
|
168
|
+
case choice
|
|
169
|
+
when :add_all
|
|
170
|
+
puts "▲ Running git add .".yellow
|
|
171
|
+
system("git add .")
|
|
172
|
+
diff_cached = `git diff --cached . #{exclusions}`.chomp
|
|
173
|
+
if diff_cached.empty?
|
|
174
|
+
puts "▲ Still no changes to commit.".red
|
|
175
|
+
return nil
|
|
176
|
+
end
|
|
177
|
+
when :exit
|
|
178
|
+
return nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
elsif diff_cached.empty?
|
|
182
|
+
# Scenario: No changes at all (staged or unstaged)
|
|
183
|
+
# Check if there are ANY unstaged files (maybe untracked?)
|
|
184
|
+
# git status --porcelain includes untracked files
|
|
185
|
+
git_status = `git status --porcelain`.chomp
|
|
186
|
+
if git_status.empty?
|
|
187
|
+
puts "▲ No changes to commit. Working tree clean.".yellow
|
|
188
|
+
return nil
|
|
189
|
+
else
|
|
190
|
+
# Only untracked files? Or ignored files?
|
|
191
|
+
# If diff_unstaged is empty but git status is not, it usually means untracked files.
|
|
192
|
+
# Let's offer to add them too.
|
|
193
|
+
choice = prompt_no_staged_changes
|
|
194
|
+
case choice
|
|
195
|
+
when :add_all
|
|
196
|
+
puts "▲ Running git add .".yellow
|
|
197
|
+
system("git add .")
|
|
198
|
+
diff_cached = `git diff --cached . #{exclusions}`.chomp
|
|
199
|
+
when :exit
|
|
200
|
+
return nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
73
203
|
end
|
|
74
204
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
205
|
+
diff = diff_cached
|
|
206
|
+
|
|
207
|
+
if diff.length > @diff_len
|
|
208
|
+
choice = prompt_diff_handling(diff.length, @diff_len)
|
|
209
|
+
case choice
|
|
210
|
+
when :truncate
|
|
211
|
+
puts "▲ Truncating diff to #{@diff_len} chars...".yellow
|
|
212
|
+
diff = diff[0...@diff_len]
|
|
213
|
+
when :unlimited
|
|
214
|
+
puts "▲ Using full diff (#{diff.length} chars)...".yellow
|
|
215
|
+
when :exit
|
|
216
|
+
return nil
|
|
217
|
+
end
|
|
78
218
|
end
|
|
79
219
|
|
|
80
220
|
diff
|
|
81
221
|
end
|
|
82
222
|
|
|
223
|
+
def prompt_no_staged_changes
|
|
224
|
+
puts "▲ No staged changes found (but unstaged/untracked files exist).".yellow
|
|
225
|
+
prompt = TTY::Prompt.new
|
|
226
|
+
begin
|
|
227
|
+
prompt.select("Choose an option:") do |menu|
|
|
228
|
+
menu.choice "Run 'git add .' to stage all changes", :add_all
|
|
229
|
+
menu.choice "Exit (stage files manually)", :exit
|
|
230
|
+
end
|
|
231
|
+
rescue TTY::Reader::InputInterrupt, Interrupt
|
|
232
|
+
:exit
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def prompt_diff_handling(current_len, max_len)
|
|
237
|
+
puts "▲ The diff is too large (#{current_len} chars, max #{max_len}).".yellow
|
|
238
|
+
prompt = TTY::Prompt.new
|
|
239
|
+
begin
|
|
240
|
+
prompt.select("Choose an option:") do |menu|
|
|
241
|
+
menu.choice "Use first #{max_len} characters to generate commit message", :truncate
|
|
242
|
+
menu.choice "Use unlimited characters (may fail or be slow)", :unlimited
|
|
243
|
+
menu.choice "Exit", :exit
|
|
244
|
+
end
|
|
245
|
+
rescue TTY::Reader::InputInterrupt, Interrupt
|
|
246
|
+
:exit
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
83
250
|
def welcome
|
|
84
251
|
puts "\n▲ Welcome to AI Commits!".green
|
|
85
252
|
|
|
86
|
-
|
|
87
|
-
|
|
253
|
+
# Check if config exists
|
|
254
|
+
unless ConfigManager.config_exists?
|
|
255
|
+
puts "▲ Configuration not found. Generating default config...".yellow
|
|
256
|
+
ConfigManager.generate_default_configs
|
|
257
|
+
puts "▲ Please run 'aicm setup' to configure your provider.".red
|
|
258
|
+
return false
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Check if active provider is configured
|
|
262
|
+
if @api_key.nil? || @api_key.empty?
|
|
263
|
+
puts "▲ No active provider configured. Please run 'aicm setup'.".red
|
|
264
|
+
return false
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
if @model.nil? || @model.empty?
|
|
268
|
+
puts "▲ No model selected. Please run 'aicm setup'.".red
|
|
88
269
|
return false
|
|
89
270
|
end
|
|
90
271
|
|
|
@@ -116,45 +297,199 @@ module CommitGpt
|
|
|
116
297
|
}
|
|
117
298
|
]
|
|
118
299
|
|
|
300
|
+
# Check config for disable_reasoning support (default true if not set)
|
|
301
|
+
provider_config = ConfigManager.get_active_provider_config
|
|
302
|
+
can_disable_reasoning = provider_config.key?("can_disable_reasoning") ? provider_config["can_disable_reasoning"] : true
|
|
303
|
+
# Get configured max_tokens or default to 2000
|
|
304
|
+
configured_max_tokens = provider_config["max_tokens"] || 2000
|
|
305
|
+
|
|
119
306
|
payload = {
|
|
120
|
-
model:
|
|
307
|
+
model: @model,
|
|
121
308
|
messages: messages,
|
|
122
|
-
temperature: 0.
|
|
123
|
-
|
|
124
|
-
disable_reasoning: true
|
|
309
|
+
temperature: 0.5,
|
|
310
|
+
stream: true
|
|
125
311
|
}
|
|
126
312
|
|
|
313
|
+
if can_disable_reasoning
|
|
314
|
+
payload[:disable_reasoning] = true
|
|
315
|
+
payload[:max_tokens] = 300
|
|
316
|
+
else
|
|
317
|
+
payload[:max_tokens] = configured_max_tokens
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Initial UI feedback (only on first try)
|
|
321
|
+
puts "....... Generating your AI commit message ......".gray unless defined?(@is_retrying) && @is_retrying
|
|
322
|
+
|
|
323
|
+
full_content = ""
|
|
324
|
+
full_reasoning = ""
|
|
325
|
+
printed_reasoning = false
|
|
326
|
+
printed_content_prefix = false
|
|
327
|
+
stop_stream = false
|
|
328
|
+
|
|
329
|
+
uri = URI("#{@base_url}/chat/completions")
|
|
330
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
331
|
+
http.use_ssl = (uri.scheme == "https")
|
|
332
|
+
http.read_timeout = 120
|
|
333
|
+
|
|
334
|
+
request = Net::HTTP::Post.new(uri)
|
|
335
|
+
request["Content-Type"] = "application/json"
|
|
336
|
+
request["Authorization"] = "Bearer #{@api_key}" if @api_key
|
|
337
|
+
request.body = payload.to_json
|
|
338
|
+
|
|
127
339
|
begin
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
340
|
+
http.request(request) do |response|
|
|
341
|
+
if response.code != "200"
|
|
342
|
+
# Parse error body
|
|
343
|
+
error_body = response.read_body
|
|
344
|
+
result = JSON.parse(error_body) rescue nil
|
|
345
|
+
|
|
346
|
+
error_msg = if result
|
|
347
|
+
result.dig("error", "message") || result["error"] || result["message"]
|
|
348
|
+
else
|
|
349
|
+
error_body
|
|
350
|
+
end
|
|
133
351
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
352
|
+
if error_msg.nil? || error_msg.to_s.strip.empty?
|
|
353
|
+
error_msg = "HTTP #{response.code}"
|
|
354
|
+
error_msg += " Raw: #{error_body}" unless error_body.to_s.strip.empty?
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
if can_disable_reasoning && (error_msg =~ /parameter|reasoning|unsupported/i || response.code == "400")
|
|
358
|
+
puts "▲ Provider does not support 'disable_reasoning'. Updating config and retrying...".yellow
|
|
359
|
+
ConfigManager.update_provider(provider_config["name"], { "can_disable_reasoning" => false })
|
|
360
|
+
@is_retrying = true
|
|
361
|
+
return generate_commit(diff)
|
|
362
|
+
else
|
|
363
|
+
puts "▲ API Error: #{error_msg}".red
|
|
364
|
+
return nil
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Process Streaming Response
|
|
369
|
+
buffer = ""
|
|
370
|
+
response.read_body do |chunk|
|
|
371
|
+
break if stop_stream
|
|
137
372
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
373
|
+
buffer += chunk
|
|
374
|
+
while (line_end = buffer.index("\n"))
|
|
375
|
+
line = buffer.slice!(0, line_end + 1).strip
|
|
376
|
+
next if line.empty?
|
|
377
|
+
next unless line.start_with?("data: ")
|
|
143
378
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
379
|
+
data_str = line[6..-1]
|
|
380
|
+
next if data_str == "[DONE]"
|
|
381
|
+
|
|
382
|
+
begin
|
|
383
|
+
data = JSON.parse(data_str)
|
|
384
|
+
delta = data.dig("choices", 0, "delta")
|
|
385
|
+
next unless delta
|
|
386
|
+
|
|
387
|
+
# Handle Reasoning
|
|
388
|
+
reasoning_chunk = delta["reasoning_content"] || delta["reasoning"]
|
|
389
|
+
if reasoning_chunk && !reasoning_chunk.empty?
|
|
390
|
+
unless printed_reasoning
|
|
391
|
+
puts "\nThinking...".gray
|
|
392
|
+
end
|
|
393
|
+
print reasoning_chunk.gray
|
|
394
|
+
full_reasoning += reasoning_chunk
|
|
395
|
+
printed_reasoning = true
|
|
396
|
+
$stdout.flush
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Handle Content
|
|
400
|
+
content_chunk = delta["content"]
|
|
401
|
+
if content_chunk && !content_chunk.empty?
|
|
402
|
+
if printed_reasoning && !printed_content_prefix
|
|
403
|
+
puts "" # Newline after reasoning block
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
unless printed_content_prefix
|
|
407
|
+
print "\n▲ Commit message: git commit -am \"".green
|
|
408
|
+
printed_content_prefix = true
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Prevent infinite loops/repetitive garbage
|
|
412
|
+
if full_content.length + content_chunk.length > 300
|
|
413
|
+
stop_stream = true
|
|
414
|
+
break
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
print content_chunk.green
|
|
418
|
+
full_content += content_chunk
|
|
419
|
+
$stdout.flush
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Handle Usage (some providers send usage at the end)
|
|
423
|
+
if data["usage"]
|
|
424
|
+
@last_usage = data["usage"]
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
rescue JSON::ParserError
|
|
428
|
+
# Partial JSON, wait for more data
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
151
432
|
end
|
|
152
433
|
rescue StandardError => e
|
|
153
434
|
puts "▲ Error: #{e.message}".red
|
|
154
435
|
return nil
|
|
155
436
|
end
|
|
437
|
+
|
|
438
|
+
# Close the quote
|
|
439
|
+
puts "\"".green if printed_content_prefix
|
|
440
|
+
|
|
441
|
+
# Post-processing Logic (Retry if empty content)
|
|
442
|
+
if (full_content.nil? || full_content.strip.empty?) && (full_reasoning && !full_reasoning.strip.empty?)
|
|
443
|
+
if can_disable_reasoning
|
|
444
|
+
puts "\n▲ Model returned reasoning despite 'disable_reasoning: true'. Updating config and retrying...".yellow
|
|
445
|
+
ConfigManager.update_provider(provider_config["name"], { "can_disable_reasoning" => false })
|
|
446
|
+
@is_retrying = true
|
|
447
|
+
return generate_commit(diff)
|
|
448
|
+
else
|
|
449
|
+
puts "\n▲ Model output truncated (Reasoning consumed all #{configured_max_tokens} tokens).".red
|
|
450
|
+
prompt = TTY::Prompt.new
|
|
451
|
+
choice = prompt.select("Choose an action:") do |menu|
|
|
452
|
+
menu.choice "Double max_tokens to #{configured_max_tokens * 2}", :double
|
|
453
|
+
menu.choice "Set custom max_tokens...", :custom
|
|
454
|
+
menu.choice "Abort", :abort
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
new_max = case choice
|
|
458
|
+
when :double
|
|
459
|
+
configured_max_tokens * 2
|
|
460
|
+
when :custom
|
|
461
|
+
prompt.ask("Enter new max_tokens:", convert: :int)
|
|
462
|
+
when :abort
|
|
463
|
+
return nil
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
if new_max
|
|
467
|
+
puts "▲ Updating max_tokens to #{new_max} and retrying...".yellow
|
|
468
|
+
ConfigManager.update_provider(provider_config["name"], { "max_tokens" => new_max })
|
|
469
|
+
@is_retrying = true
|
|
470
|
+
return generate_commit(diff)
|
|
471
|
+
end
|
|
472
|
+
return nil
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
if full_content.empty? && full_reasoning.empty?
|
|
477
|
+
puts "▲ No response from AI.".red
|
|
478
|
+
return nil
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Print usage info if available (saved from stream or approximated)
|
|
482
|
+
if defined?(@last_usage) && @last_usage
|
|
483
|
+
puts "\n...... Tokens: #{@last_usage['total_tokens']} (Prompt: #{@last_usage['prompt_tokens']}, Completion: #{@last_usage['completion_tokens']})\n\n".gray
|
|
484
|
+
@last_usage = nil
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Reset retrying flag
|
|
488
|
+
@is_retrying = false
|
|
156
489
|
|
|
157
|
-
|
|
490
|
+
# Take only the first non-empty line to avoid repetition or multi-line garbage
|
|
491
|
+
first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
|
|
492
|
+
first_line&.gsub(/\A["']|["']\z/, "") || ""
|
|
158
493
|
end
|
|
159
494
|
end
|
|
160
495
|
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "provider_presets"
|
|
6
|
+
require_relative "string"
|
|
7
|
+
|
|
8
|
+
module CommitGpt
|
|
9
|
+
# Manages configuration files for CommitGPT
|
|
10
|
+
class ConfigManager
|
|
11
|
+
class << self
|
|
12
|
+
# Get the config directory path
|
|
13
|
+
def config_dir
|
|
14
|
+
File.expand_path("~/.config/commitgpt")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Get main config file path
|
|
18
|
+
def main_config_path
|
|
19
|
+
File.join(config_dir, "config.yml")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Get local config file path
|
|
23
|
+
def local_config_path
|
|
24
|
+
File.join(config_dir, "config.local.yml")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Ensure config directory exists
|
|
28
|
+
def ensure_config_dir
|
|
29
|
+
FileUtils.mkdir_p(config_dir) unless Dir.exist?(config_dir)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Check if config files exist
|
|
33
|
+
def config_exists?
|
|
34
|
+
File.exist?(main_config_path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Load and merge configuration files
|
|
38
|
+
def load_config
|
|
39
|
+
return nil unless config_exists?
|
|
40
|
+
|
|
41
|
+
main_config = YAML.load_file(main_config_path) || {}
|
|
42
|
+
local_config = File.exist?(local_config_path) ? YAML.load_file(local_config_path) : {}
|
|
43
|
+
|
|
44
|
+
merge_configs(main_config, local_config)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get active provider configuration
|
|
48
|
+
def get_active_provider_config
|
|
49
|
+
config = load_config
|
|
50
|
+
return nil if config.nil? || config["active_provider"].nil?
|
|
51
|
+
|
|
52
|
+
active_provider = config["active_provider"]
|
|
53
|
+
providers = config["providers"] || []
|
|
54
|
+
|
|
55
|
+
providers.find { |p| p["name"] == active_provider }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Save main config
|
|
59
|
+
def save_main_config(config)
|
|
60
|
+
ensure_config_dir
|
|
61
|
+
File.write(main_config_path, config.to_yaml)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Save local config
|
|
65
|
+
def save_local_config(config)
|
|
66
|
+
ensure_config_dir
|
|
67
|
+
File.write(local_config_path, config.to_yaml)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Generate default configuration files
|
|
71
|
+
def generate_default_configs
|
|
72
|
+
ensure_config_dir
|
|
73
|
+
|
|
74
|
+
# Generate main config with all providers but empty models
|
|
75
|
+
providers = PROVIDER_PRESETS.map do |preset|
|
|
76
|
+
{
|
|
77
|
+
"name" => preset[:value],
|
|
78
|
+
"model" => "",
|
|
79
|
+
"diff_len" => 32768,
|
|
80
|
+
"base_url" => preset[:base_url]
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
main_config = {
|
|
85
|
+
"providers" => providers,
|
|
86
|
+
"active_provider" => ""
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Generate local config with empty API keys
|
|
90
|
+
local_providers = PROVIDER_PRESETS.map do |preset|
|
|
91
|
+
{
|
|
92
|
+
"name" => preset[:value],
|
|
93
|
+
"api_key" => ""
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
local_config = {
|
|
98
|
+
"providers" => local_providers
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
save_main_config(main_config)
|
|
102
|
+
save_local_config(local_config)
|
|
103
|
+
|
|
104
|
+
# Remind user to add config.local.yml to .gitignore
|
|
105
|
+
puts "▲ Generated default configuration files.".green
|
|
106
|
+
puts "▲ Remember to add ~/.config/commitgpt/config.local.yml to your .gitignore".yellow
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Get list of configured providers (with API keys)
|
|
110
|
+
def configured_providers
|
|
111
|
+
config = load_config
|
|
112
|
+
return [] if config.nil?
|
|
113
|
+
|
|
114
|
+
providers = config["providers"] || []
|
|
115
|
+
providers.select { |p| p["api_key"] && !p["api_key"].empty? }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Update provider configuration
|
|
119
|
+
def update_provider(provider_name, main_attrs = {}, local_attrs = {})
|
|
120
|
+
# Update main config
|
|
121
|
+
main_config = YAML.load_file(main_config_path)
|
|
122
|
+
provider = main_config["providers"].find { |p| p["name"] == provider_name }
|
|
123
|
+
provider&.merge!(main_attrs)
|
|
124
|
+
save_main_config(main_config)
|
|
125
|
+
|
|
126
|
+
# Update local config
|
|
127
|
+
local_config = File.exist?(local_config_path) ? YAML.load_file(local_config_path) : { "providers" => [] }
|
|
128
|
+
local_provider = local_config["providers"].find { |p| p["name"] == provider_name }
|
|
129
|
+
if local_provider
|
|
130
|
+
local_provider.merge!(local_attrs)
|
|
131
|
+
else
|
|
132
|
+
local_config["providers"] << { "name" => provider_name }.merge(local_attrs)
|
|
133
|
+
end
|
|
134
|
+
save_local_config(local_config)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Set active provider
|
|
138
|
+
def set_active_provider(provider_name)
|
|
139
|
+
main_config = YAML.load_file(main_config_path)
|
|
140
|
+
main_config["active_provider"] = provider_name
|
|
141
|
+
save_main_config(main_config)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
# Merge main config with local config (local overrides main)
|
|
147
|
+
def merge_configs(main_config, local_config)
|
|
148
|
+
result = main_config.dup
|
|
149
|
+
|
|
150
|
+
# Merge provider-specific settings
|
|
151
|
+
main_providers = main_config["providers"] || []
|
|
152
|
+
local_providers = local_config["providers"] || []
|
|
153
|
+
|
|
154
|
+
merged_providers = main_providers.map do |main_provider|
|
|
155
|
+
local_provider = local_providers.find { |lp| lp["name"] == main_provider["name"] }
|
|
156
|
+
local_provider ? main_provider.merge(local_provider) : main_provider
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
result["providers"] = merged_providers
|
|
160
|
+
result
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|