commitgpt 0.2.0 → 0.3.3

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.
@@ -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)
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' => 32_768,
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
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommitGpt
4
+ # Provider presets for common AI providers
5
+ PROVIDER_PRESETS = [
6
+ { label: 'Cerebras', value: 'cerebras', base_url: 'https://api.cerebras.ai/v1' },
7
+ { label: 'Ollama', value: 'ollama', base_url: 'http://127.0.0.1:11434/v1' },
8
+ { label: 'OpenAI', value: 'openai', base_url: 'https://api.openai.com/v1' },
9
+ { label: 'LLaMa.cpp', value: 'llamacpp', base_url: 'http://127.0.0.1:8080/v1' },
10
+ { label: 'LM Studio', value: 'lmstudio', base_url: 'http://127.0.0.1:1234/v1' },
11
+ { label: 'Llamafile', value: 'llamafile', base_url: 'http://127.0.0.1:8080/v1' },
12
+ { label: 'DeepSeek', value: 'deepseek', base_url: 'https://api.deepseek.com' },
13
+ { label: 'Groq', value: 'groq', base_url: 'https://api.groq.com/openai/v1' },
14
+ { label: 'Mistral', value: 'mistral', base_url: 'https://api.mistral.ai/v1' },
15
+ { label: 'Anthropic (Claude)', value: 'anthropic', base_url: 'https://api.anthropic.com/v1' },
16
+ { label: 'OpenRouter', value: 'openrouter', base_url: 'https://openrouter.ai/api/v1' },
17
+ { label: 'Google AI', value: 'gemini', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai' }
18
+ ].freeze
19
+ end
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-prompt'
4
+ require 'httparty'
5
+ require 'timeout'
6
+ require_relative 'config_manager'
7
+ require_relative 'provider_presets'
8
+ require_relative 'string'
9
+
10
+ module CommitGpt
11
+ # Interactive setup wizard for configuring AI providers
12
+ class SetupWizard
13
+ def initialize
14
+ @prompt = TTY::Prompt.new
15
+ end
16
+
17
+ # Main entry point for setup
18
+ def run
19
+ ConfigManager.ensure_config_dir
20
+ ConfigManager.generate_default_configs unless ConfigManager.config_exists?
21
+
22
+ provider_choice = select_provider
23
+ configure_provider(provider_choice)
24
+ end
25
+
26
+ # Switch to a different configured provider
27
+ def switch_provider
28
+ configured = ConfigManager.configured_providers
29
+
30
+ if configured.empty?
31
+ puts "▲ No providers configured. Please run 'aicm setup' first.".red
32
+ return
33
+ end
34
+
35
+ choices = configured.map do |p|
36
+ preset = PROVIDER_PRESETS.find { |pr| pr[:value] == p['name'] }
37
+ { name: preset ? preset[:label] : p['name'], value: p['name'] }
38
+ end
39
+
40
+ selected = @prompt.select('Choose your provider:', choices)
41
+
42
+ # Get current config for this provider
43
+ config = ConfigManager.load_config
44
+ provider = config['providers'].find { |p| p['name'] == selected }
45
+
46
+ # Fetch models and let user select
47
+ models = fetch_models_with_timeout(provider['base_url'], provider['api_key'])
48
+ return if models.nil?
49
+
50
+ model = select_model(models, provider['model'])
51
+
52
+ # Prompt for diff length
53
+ diff_len = prompt_diff_len(provider['diff_len'] || 32_768)
54
+
55
+ # Update config
56
+ ConfigManager.update_provider(selected, { 'model' => model, 'diff_len' => diff_len })
57
+ reset_provider_inference_params(selected)
58
+ ConfigManager.set_active_provider(selected)
59
+
60
+ preset = PROVIDER_PRESETS.find { |pr| pr[:value] == selected }
61
+ provider_label = preset ? preset[:label] : selected
62
+
63
+ puts "\nModel selected: #{model}".green
64
+ puts "Setup complete ✅ You're now using #{provider_label}.".green
65
+ end
66
+
67
+ # Change model for the active provider
68
+ def change_model
69
+ provider_config = ConfigManager.get_active_provider_config
70
+
71
+ if provider_config.nil? || provider_config['api_key'].nil? || provider_config['api_key'].empty?
72
+ puts "▲ No active provider configured. Please run 'aicm setup'.".red
73
+ return
74
+ end
75
+
76
+ # Fetch models and let user select
77
+ models = fetch_models_with_timeout(provider_config['base_url'], provider_config['api_key'])
78
+ return if models.nil?
79
+
80
+ model = select_model(models, provider_config['model'])
81
+
82
+ # Update config
83
+ ConfigManager.update_provider(provider_config['name'], { 'model' => model })
84
+ reset_provider_inference_params(provider_config['name'])
85
+
86
+ puts "\nModel selected: #{model}".green
87
+ end
88
+
89
+ private
90
+
91
+ # Select provider from list
92
+ def select_provider
93
+ config = ConfigManager.load_config
94
+ configured = config ? (config['providers'] || []) : []
95
+
96
+ choices = PROVIDER_PRESETS.map do |preset|
97
+ # Check if this provider already has an API key
98
+ provider_config = configured.find { |p| p['name'] == preset[:value] }
99
+ has_key = provider_config && provider_config['api_key'] && !provider_config['api_key'].empty?
100
+
101
+ label = preset[:label]
102
+ label = "✅ #{label}" if has_key
103
+ label = "#{label} (recommended)" if preset[:value] == 'cerebras'
104
+ label = "#{label} (local)" if %w[ollama llamacpp lmstudio llamafile].include?(preset[:value])
105
+
106
+ { name: label, value: preset[:value] }
107
+ end
108
+
109
+ choices << { name: 'Custom (OpenAI-compatible)', value: 'custom' }
110
+
111
+ @prompt.select('Choose your AI provider:', choices, per_page: 15)
112
+ end
113
+
114
+ # Configure selected provider
115
+ def configure_provider(provider_name)
116
+ if provider_name == 'custom'
117
+ configure_custom_provider
118
+ return
119
+ end
120
+
121
+ preset = PROVIDER_PRESETS.find { |p| p[:value] == provider_name }
122
+ base_url = preset[:base_url]
123
+ provider_label = preset[:label]
124
+
125
+ # Get existing API key if any
126
+ config = ConfigManager.load_config
127
+ existing_provider = config['providers'].find { |p| p['name'] == provider_name } if config
128
+
129
+ # Prompt for API key
130
+ api_key = prompt_api_key(provider_label, existing_provider&.dig('api_key'))
131
+ return if api_key.nil? # User cancelled
132
+
133
+ # Fetch models with timeout
134
+ models = fetch_models_with_timeout(base_url, api_key)
135
+ return if models.nil?
136
+
137
+ # Let user select model
138
+ model = select_model(models, existing_provider&.dig('model'))
139
+
140
+ # Prompt for diff length
141
+ diff_len = prompt_diff_len(existing_provider&.dig('diff_len') || 32_768)
142
+
143
+ # Save configuration
144
+ ConfigManager.update_provider(
145
+ provider_name,
146
+ { 'model' => model, 'diff_len' => diff_len },
147
+ { 'api_key' => api_key }
148
+ )
149
+ ConfigManager.set_active_provider(provider_name)
150
+
151
+ puts "\nModel selected: #{model}".green
152
+ puts "✅ Setup complete! You're now using #{provider_label}.".green
153
+ end
154
+
155
+ # Configure custom provider
156
+ def configure_custom_provider
157
+ provider_name = @prompt.ask('Enter provider name:') do |q|
158
+ q.required true
159
+ q.modify :strip, :down
160
+ end
161
+
162
+ base_url = @prompt.ask('Enter base URL:') do |q|
163
+ q.required true
164
+ q.default 'http://localhost:8080/v1'
165
+ end
166
+
167
+ api_key = @prompt.mask('Enter your API key (optional):') { |q| q.echo false }
168
+
169
+ # Fetch models
170
+ models = fetch_models_with_timeout(base_url, api_key)
171
+ return if models.nil?
172
+
173
+ model = select_model(models)
174
+ diff_len = prompt_diff_len(32_768)
175
+
176
+ # Add to presets dynamically (just for this session)
177
+ # Save to config
178
+ config = ConfigManager.load_config || { 'providers' => [], 'active_provider' => '' }
179
+
180
+ # Add or update provider in main config
181
+ existing = config['providers'].find { |p| p['name'] == provider_name }
182
+ if existing
183
+ existing.merge!({ 'model' => model, 'diff_len' => diff_len, 'base_url' => base_url })
184
+ else
185
+ config['providers'] << {
186
+ 'name' => provider_name,
187
+ 'model' => model,
188
+ 'diff_len' => diff_len,
189
+ 'base_url' => base_url
190
+ }
191
+ end
192
+ config['active_provider'] = provider_name
193
+ ConfigManager.save_main_config(config)
194
+
195
+ # Update local config
196
+ local_config = if File.exist?(ConfigManager.local_config_path)
197
+ YAML.load_file(ConfigManager.local_config_path)
198
+ else
199
+ { 'providers' => [] }
200
+ end
201
+
202
+ local_existing = local_config['providers'].find { |p| p['name'] == provider_name }
203
+ if local_existing
204
+ local_existing['api_key'] = api_key
205
+ else
206
+ local_config['providers'] << { 'name' => provider_name, 'api_key' => api_key }
207
+ end
208
+ ConfigManager.save_local_config(local_config)
209
+
210
+ puts "\nModel selected: #{model}".green
211
+ puts "✅ Setup complete! You're now using #{provider_name}.".green
212
+ end
213
+
214
+ # Prompt for API key
215
+ def prompt_api_key(_provider_name, existing_key)
216
+ message = if existing_key && !existing_key.empty?
217
+ 'Enter your API key (press Enter to keep existing):'
218
+ else
219
+ 'Enter your API key:'
220
+ end
221
+
222
+ key = @prompt.mask(message) { |q| q.echo false }
223
+
224
+ # If user pressed Enter and there's an existing key, use it
225
+ if key.empty? && existing_key && !existing_key.empty?
226
+ existing_key
227
+ else
228
+ key
229
+ end
230
+ end
231
+
232
+ # Fetch models from provider with timeout
233
+ def fetch_models_with_timeout(base_url, api_key)
234
+ puts 'Fetching available models...'.gray
235
+
236
+ models = nil
237
+ begin
238
+ Timeout.timeout(5) do
239
+ headers = {
240
+ 'Content-Type' => 'application/json',
241
+ 'User-Agent' => "Ruby/#{RUBY_VERSION}"
242
+ }
243
+ headers['Authorization'] = "Bearer #{api_key}" if api_key && !api_key.empty?
244
+
245
+ response = HTTParty.get("#{base_url}/models", headers: headers)
246
+
247
+ if response.code == 200
248
+ models = response['data'] || []
249
+ models = models.map { |m| m['id'] }.compact.sort
250
+ else
251
+ puts "▲ Failed to fetch models: HTTP #{response.code}".red
252
+ return nil
253
+ end
254
+ end
255
+ rescue Timeout::Error
256
+ puts '▲ Connection timeout (5s). Please check your network, base_url, and api_key.'.red
257
+ exit(0)
258
+ rescue StandardError => e
259
+ puts "▲ Error fetching models: #{e.message}".red
260
+ exit(0)
261
+ end
262
+
263
+ if models.nil? || models.empty?
264
+ puts '▲ No models found. Please check your configuration.'.red
265
+ exit(0)
266
+ end
267
+
268
+ models
269
+ end
270
+
271
+ # Let user select a model
272
+ def select_model(models, current_model = nil)
273
+ choices = models.map { |m| { name: m, value: m } }
274
+ choices << { name: 'Custom model name...', value: :custom }
275
+
276
+ # Set default to current model if it exists
277
+ default_index = if current_model && models.include?(current_model)
278
+ models.index(current_model) + 1 # +1 for 1-based index
279
+ else
280
+ 1
281
+ end
282
+
283
+ selected = @prompt.select('Choose your model:', choices, per_page: 15, default: default_index)
284
+
285
+ if selected == :custom
286
+ @prompt.ask('Enter custom model name:') do |q|
287
+ q.required true
288
+ q.modify :strip
289
+ end
290
+ else
291
+ selected
292
+ end
293
+ end
294
+
295
+ # Prompt for diff length
296
+ def prompt_diff_len(default = 32_768)
297
+ answer = @prompt.ask('Set the maximum diff length (Bytes) for generating commit message:') do |q|
298
+ q.default default.to_s
299
+ q.convert :int
300
+ end
301
+
302
+ answer || default
303
+ end
304
+
305
+ def reset_provider_inference_params(provider_name)
306
+ config = YAML.load_file(ConfigManager.main_config_path)
307
+ return unless config && config['providers']
308
+
309
+ provider = config['providers'].find { |p| p['name'] == provider_name }
310
+ if provider
311
+ provider.delete('can_disable_reasoning')
312
+ provider.delete('max_tokens')
313
+ ConfigManager.save_main_config(config)
314
+ end
315
+ end
316
+ end
317
+ end
@@ -21,4 +21,8 @@ class String
21
21
  def cyan
22
22
  "\e[36m#{self}\e[0m"
23
23
  end
24
+
25
+ def yellow
26
+ "\e[33m#{self}\e[0m"
27
+ end
24
28
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CommitGpt
4
- VERSION = "0.2.0"
4
+ VERSION = '0.3.3'
5
5
  end
data/lib/commitgpt.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "commitgpt/version"
4
- require_relative "commitgpt/commit_ai"
3
+ require_relative 'commitgpt/version'
4
+ require_relative 'commitgpt/commit_ai'
5
5
 
6
6
  module CommitGpt
7
7
  class Error < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: commitgpt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peng Zhang
@@ -15,28 +15,42 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.18'
18
+ version: '0.24'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.18'
25
+ version: '0.24'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: thor
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '1.2'
32
+ version: '1.4'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '1.2'
39
+ version: '1.4'
40
+ - !ruby/object:Gem::Dependency
41
+ name: tty-prompt
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.23'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.23'
40
54
  description: A CLI that writes your git commit messages for you with AI. Never write
41
55
  a commit message again.
42
56
  email:
@@ -46,20 +60,16 @@ executables:
46
60
  extensions: []
47
61
  extra_rdoc_files: []
48
62
  files:
49
- - ".rspec"
50
- - ".rubocop.yml"
51
- - CHANGELOG.md
52
- - CODE_OF_CONDUCT.md
53
- - Gemfile
54
- - Gemfile.lock
55
63
  - LICENSE
56
- - LICENSE.txt
57
64
  - README.md
58
- - Rakefile
59
65
  - bin/aicm
66
+ - commitgpt.gemspec
60
67
  - lib/commitgpt.rb
61
68
  - lib/commitgpt/cli.rb
62
69
  - lib/commitgpt/commit_ai.rb
70
+ - lib/commitgpt/config_manager.rb
71
+ - lib/commitgpt/provider_presets.rb
72
+ - lib/commitgpt/setup_wizard.rb
63
73
  - lib/commitgpt/string.rb
64
74
  - lib/commitgpt/version.rb
65
75
  homepage: https://github.com/ZPVIP/commitgpt
@@ -69,6 +79,7 @@ metadata:
69
79
  homepage_uri: https://github.com/ZPVIP/commitgpt
70
80
  source_code_uri: https://github.com/ZPVIP/commitgpt
71
81
  changelog_uri: https://github.com/ZPVIP/commitgpt/blob/master/CHANGELOG.md
82
+ rubygems_mfa_required: 'true'
72
83
  rdoc_options: []
73
84
  require_paths:
74
85
  - lib
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
data/.rubocop.yml DELETED
@@ -1,19 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 2.6
3
-
4
- Style/StringLiterals:
5
- Enabled: true
6
- EnforcedStyle: double_quotes
7
-
8
- Style/StringLiteralsInInterpolation:
9
- Enabled: true
10
- EnforcedStyle: double_quotes
11
-
12
- Layout/LineLength:
13
- Max: 160
14
-
15
- MethodLength:
16
- Max: 20
17
-
18
- Metrics/BlockLength:
19
- Max: 100
data/CHANGELOG.md DELETED
@@ -1,5 +0,0 @@
1
- ## [Unreleased]
2
-
3
- ## [0.1.0] - 2023-02-14
4
-
5
- - Initial release