askcii 0.2.0 → 0.4.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.
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Askcii
4
+ module Commands
5
+ # Clears conversation history for the current session
6
+ class ClearHistoryCommand < BaseCommand
7
+ def execute
8
+ verbose "Clearing history for session: #{session_context}"
9
+
10
+ chat = Chat.find(context: session_context)
11
+
12
+ unless chat
13
+ puts "No session found to clear."
14
+ exit 0
15
+ end
16
+
17
+ message_count = chat.messages.count
18
+
19
+ # Delete all messages for this chat
20
+ chat.messages_dataset.delete
21
+
22
+ # Delete the chat itself
23
+ chat.delete
24
+
25
+ puts "Cleared session '#{session_context}' (#{message_count} messages removed)"
26
+
27
+ exit 0
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Askcii
4
+ module Commands
5
+ # Opens configuration management interface
6
+ class ConfigureCommand < BaseCommand
7
+ def execute
8
+ verbose "Opening configuration manager..."
9
+
10
+ require_relative '../configuration_manager'
11
+ ConfigurationManager.new.run
12
+
13
+ exit 0
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Askcii
4
+ module Commands
5
+ # Displays help information
6
+ class HelpCommand < BaseCommand
7
+ def execute
8
+ puts <<~HELP
9
+ askcii - Command-line interface for multiple LLM providers
10
+
11
+ Usage:
12
+ askcii [options] "Your prompt here"
13
+ echo "input" | askcii [options] "Your prompt"
14
+
15
+ Options:
16
+ -p, --private Create a private session (no history saved)
17
+ -r, --last-response Retrieve the last assistant response from current session
18
+ -c, --configure Open configuration management interface
19
+ -m, --model ID Use specific configuration by ID
20
+ -v, --verbose Show detailed information during execution
21
+ --session NAME Use specific session name (alternative to ASKCII_SESSION)
22
+ --list-sessions List all available sessions
23
+ --history Show current session history
24
+ --clear-history Clear current session history
25
+ -h, --help Show this help message
26
+
27
+ Environment Variables:
28
+ ASKCII_SESSION Session identifier for conversation persistence
29
+ ASKCII_API_KEY API key (legacy, use -c to configure)
30
+ ASKCII_API_ENDPOINT API endpoint (legacy)
31
+ ASKCII_MODEL_ID Model ID (legacy)
32
+
33
+ Examples:
34
+ askcii "Explain what a Ruby block is"
35
+ askcii -p "What is the capital of France?"
36
+ echo "def hello\nputs 'hi'\nend" | askcii "Explain this Ruby code"
37
+ askcii -m 2 "Use configuration #2"
38
+ askcii --session my-project "Continue conversation in my-project session"
39
+ ASKCII_SESSION=work askcii "Use work session"
40
+
41
+ Configuration:
42
+ Run 'askcii -c' to configure providers interactively.
43
+ Supports: OpenAI, Anthropic, Gemini, DeepSeek, OpenRouter, Ollama
44
+
45
+ For more information, visit: https://github.com/yourusername/askcii
46
+ HELP
47
+
48
+ exit 0
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Askcii
4
+ module Commands
5
+ # Displays conversation history for the current session
6
+ class HistoryCommand < BaseCommand
7
+ def execute
8
+ verbose "Retrieving history for session: #{session_context}"
9
+
10
+ chat = Chat.find(context: session_context)
11
+
12
+ unless chat
13
+ puts "No history found for this session."
14
+ exit 0
15
+ end
16
+
17
+ messages = chat.messages_dataset.order(:created_at).all
18
+
19
+ if messages.empty?
20
+ puts "No messages in this session."
21
+ exit 0
22
+ end
23
+
24
+ puts "Session: #{chat.context}"
25
+ puts "Model: #{chat.model_id}"
26
+ puts "Messages: #{messages.count}"
27
+ puts
28
+ puts "=" * 80
29
+ puts
30
+
31
+ messages.each do |msg|
32
+ role_label = msg.role.to_s.capitalize
33
+ timestamp = msg.created_at.strftime('%Y-%m-%d %H:%M:%S')
34
+
35
+ puts "#{role_label} (#{timestamp}):"
36
+ puts msg.content
37
+ puts
38
+ puts "-" * 80
39
+ puts
40
+ end
41
+
42
+ exit 0
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Askcii
4
+ module Commands
5
+ # Retrieves and displays the last assistant response from the current session
6
+ class LastResponseCommand < BaseCommand
7
+ def execute
8
+ verbose "Retrieving last response from session: #{session_context}"
9
+
10
+ chat = Chat.find(context: session_context)
11
+
12
+ unless chat
13
+ error! "No chat session found for context: #{session_context}"
14
+ end
15
+
16
+ last_message = chat.messages_dataset
17
+ .where(role: 'assistant')
18
+ .order(Sequel.desc(:created_at))
19
+ .first
20
+
21
+ unless last_message
22
+ error! "No assistant messages found in this session"
23
+ end
24
+
25
+ puts last_message.content
26
+
27
+ exit 0
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Askcii
4
+ module Commands
5
+ # Lists all available chat sessions
6
+ class ListSessionsCommand < BaseCommand
7
+ def execute
8
+ verbose "Listing all sessions..."
9
+
10
+ chats = Chat.order(Sequel.desc(:created_at)).all
11
+
12
+ if chats.empty?
13
+ puts "No sessions found."
14
+ exit 0
15
+ end
16
+
17
+ puts "Available sessions:"
18
+ puts
19
+
20
+ chats.each do |chat|
21
+ message_count = chat.messages.count
22
+ last_message = chat.messages_dataset.order(Sequel.desc(:created_at)).first
23
+ last_updated = last_message&.created_at || chat.created_at
24
+
25
+ puts "Session: #{chat.context}"
26
+ puts " Model: #{chat.model_id}"
27
+ puts " Messages: #{message_count}"
28
+ puts " Last updated: #{last_updated}"
29
+ puts
30
+ end
31
+
32
+ exit 0
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Askcii
4
+ # Validates configuration settings before use
5
+ class ConfigValidator
6
+ VALID_PROVIDERS = %w[openai anthropic gemini deepseek openrouter ollama].freeze
7
+
8
+ class << self
9
+ # Validates a configuration hash
10
+ # @param config [Hash] Configuration to validate
11
+ # @raise [ConfigurationError] if configuration is invalid
12
+ # @return [Hash] The validated configuration
13
+ def validate!(config)
14
+ validate_presence!(config)
15
+ validate_provider!(config)
16
+ validate_api_key!(config)
17
+ validate_model_id!(config)
18
+ validate_api_endpoint!(config)
19
+ config
20
+ end
21
+
22
+ # Checks if a provider name is valid
23
+ # @param name [String] Provider name
24
+ # @return [Boolean]
25
+ def valid_provider?(name)
26
+ VALID_PROVIDERS.include?(name.to_s.downcase)
27
+ end
28
+
29
+ # Lists all valid provider names
30
+ # @return [Array<String>]
31
+ def valid_providers
32
+ VALID_PROVIDERS
33
+ end
34
+
35
+ private
36
+
37
+ def validate_presence!(config)
38
+ raise ConfigurationError, "Configuration cannot be nil" if config.nil?
39
+ raise ConfigurationError, "Configuration cannot be empty" if config.empty?
40
+ end
41
+
42
+ def validate_provider!(config)
43
+ provider = config[:provider].to_s.strip
44
+ if provider.empty?
45
+ raise ConfigurationError, "Provider is required"
46
+ end
47
+
48
+ unless valid_provider?(provider)
49
+ raise ConfigurationError,
50
+ "Invalid provider '#{provider}'. Must be one of: #{VALID_PROVIDERS.join(', ')}"
51
+ end
52
+ end
53
+
54
+ def validate_api_key!(config)
55
+ provider = config[:provider].to_s.downcase
56
+
57
+ # Ollama doesn't require an API key
58
+ return if provider == 'ollama'
59
+
60
+ api_key = config[:api_key].to_s.strip
61
+ if api_key.empty?
62
+ raise ConfigurationError, "API key is required for #{provider}"
63
+ end
64
+
65
+ if api_key == 'blank'
66
+ raise ConfigurationError, "API key cannot be 'blank'"
67
+ end
68
+ end
69
+
70
+ def validate_model_id!(config)
71
+ model_id = config[:model_id].to_s.strip
72
+ if model_id.empty?
73
+ raise ConfigurationError, "Model ID is required"
74
+ end
75
+ end
76
+
77
+ def validate_api_endpoint!(config)
78
+ endpoint = config[:api_endpoint].to_s.strip
79
+ if endpoint.empty?
80
+ raise ConfigurationError, "API endpoint is required"
81
+ end
82
+
83
+ # Basic URL format validation
84
+ unless endpoint.start_with?('http://', 'https://')
85
+ raise ConfigurationError, "API endpoint must start with http:// or https://"
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Askcii
4
+ # Interactive terminal UI for managing provider configurations
4
5
  class ConfigurationManager
5
- PROVIDER_MAP = {
6
+ # Maps user selection numbers to provider identifiers
7
+ PROVIDER_MENU = {
6
8
  '1' => 'openai',
7
9
  '2' => 'anthropic',
8
10
  '3' => 'gemini',
@@ -11,41 +13,50 @@ module Askcii
11
13
  '6' => 'ollama'
12
14
  }.freeze
13
15
 
14
- DEFAULT_ENDPOINTS = {
15
- 'openai' => 'https://api.openai.com/v1',
16
- 'anthropic' => 'https://api.anthropic.com',
17
- 'gemini' => 'https://generativelanguage.googleapis.com/v1',
18
- 'deepseek' => 'https://api.deepseek.com/v1',
19
- 'openrouter' => 'https://openrouter.ai/api/v1',
20
- 'ollama' => 'http://localhost:11434/v1'
21
- }.freeze
22
-
23
16
  def run
24
- show_current_configurations
25
- show_menu
26
- handle_user_choice
17
+ loop do
18
+ show_current_configurations
19
+ show_menu
20
+ choice = handle_user_choice
21
+ break if choice == :exit
22
+ end
27
23
  end
28
24
 
29
25
  private
30
26
 
31
27
  def show_current_configurations
28
+ puts
32
29
  puts 'Configuration Management'
33
- puts '======================'
30
+ puts '=' * 50
31
+ puts
34
32
 
35
33
  configs = Askcii::Config.configurations
36
34
  default_id = Askcii::Config.default_configuration_id
37
35
 
38
36
  if configs.empty?
39
37
  puts 'No configurations found.'
38
+ puts 'Add a configuration to get started.'
40
39
  else
41
40
  puts 'Current configurations:'
41
+ puts
42
42
  configs.each do |config|
43
- marker = config['id'] == default_id ? ' (default)' : ''
44
- provider_info = config['provider'] ? " [#{config['provider']}]" : ''
45
- puts " #{config['id']}. #{config['name']}#{provider_info}#{marker}"
43
+ show_configuration_summary(config, default_id)
46
44
  end
47
- puts
48
45
  end
46
+ puts
47
+ end
48
+
49
+ def show_configuration_summary(config, default_id)
50
+ is_default = config['id'] == default_id
51
+ marker = is_default ? ' ★ (default)' : ''
52
+ provider_name = ProviderConfig.display_name(config['provider'])
53
+
54
+ puts " #{config['id']}. #{config['name']}"
55
+ puts " Provider: #{provider_name}"
56
+ puts " Model: #{config['model_id']}"
57
+ puts " Endpoint: #{config['api_endpoint']}"
58
+ puts "#{marker}"
59
+ puts
49
60
  end
50
61
 
51
62
  def show_menu
@@ -53,8 +64,9 @@ module Askcii
53
64
  puts ' 1. Add new configuration'
54
65
  puts ' 2. Set default configuration'
55
66
  puts ' 3. Delete configuration'
56
- puts ' 4. Exit'
57
- print 'Select option (1-4): '
67
+ puts ' 4. Test configuration'
68
+ puts ' 5. Exit'
69
+ print 'Select option (1-5): '
58
70
  end
59
71
 
60
72
  def handle_user_choice
@@ -63,20 +75,32 @@ module Askcii
63
75
  case choice
64
76
  when '1'
65
77
  add_new_configuration
78
+ :continue
66
79
  when '2'
67
80
  set_default_configuration
81
+ :continue
68
82
  when '3'
69
83
  delete_configuration
84
+ :continue
70
85
  when '4'
71
- puts 'Exiting.'
86
+ test_configuration
87
+ :continue
88
+ when '5'
89
+ puts 'Exiting configuration manager.'
90
+ :exit
72
91
  else
73
- puts 'Invalid option.'
92
+ puts 'Invalid option. Please select 1-5.'
93
+ :continue
74
94
  end
75
95
  end
76
96
 
77
97
  def add_new_configuration
98
+ puts
99
+ puts 'Add New Configuration'
100
+ puts '-' * 50
101
+
78
102
  print 'Enter configuration name: '
79
- name = $stdin.gets.chomp
103
+ name = $stdin.gets.chomp.strip
80
104
 
81
105
  provider = select_provider
82
106
  return unless provider
@@ -85,29 +109,66 @@ module Askcii
85
109
  return unless api_key || provider == 'ollama'
86
110
 
87
111
  endpoint = get_api_endpoint(provider)
88
- model_id = get_model_id
89
-
112
+ model_id = get_model_id(provider)
90
113
  return unless model_id
91
114
 
115
+ # Use model_id as name if no name provided
92
116
  name = model_id if name.empty?
93
- Askcii::Config.add_configuration(name, api_key || '', endpoint, model_id, provider)
94
- puts 'Configuration added successfully!'
117
+
118
+ # Build configuration hash
119
+ config = {
120
+ name: name,
121
+ api_key: api_key || '',
122
+ api_endpoint: endpoint,
123
+ model_id: model_id,
124
+ provider: provider
125
+ }
126
+
127
+ # Validate configuration
128
+ begin
129
+ ConfigValidator.validate!(config)
130
+ rescue ConfigurationError => e
131
+ puts "Validation failed: #{e.message}"
132
+ return
133
+ end
134
+
135
+ # Save configuration
136
+ Askcii::Config.add_configuration(
137
+ name: name,
138
+ api_key: api_key || '',
139
+ api_endpoint: endpoint,
140
+ model_id: model_id,
141
+ provider: provider
142
+ )
143
+
144
+ puts
145
+ puts '✓ Configuration added successfully!'
146
+ puts
147
+
148
+ # Offer to test the configuration
149
+ print 'Test this configuration now? (y/n): '
150
+ if $stdin.gets.chomp.downcase == 'y'
151
+ # Get the newly added configuration ID
152
+ configs = Askcii::Config.configurations
153
+ new_config = configs.last
154
+ test_specific_configuration(new_config) if new_config
155
+ end
95
156
  end
96
157
 
97
158
  def select_provider
159
+ puts
98
160
  puts 'Select provider:'
99
- puts ' 1. OpenAI'
100
- puts ' 2. Anthropic'
101
- puts ' 3. Gemini'
102
- puts ' 4. DeepSeek'
103
- puts ' 5. OpenRouter'
104
- puts ' 6. Ollama (no API key needed)'
105
- print 'Provider (1-6): '
106
-
107
- provider_choice = $stdin.gets.chomp
108
- provider = PROVIDER_MAP[provider_choice]
109
-
110
- if provider.nil?
161
+ ProviderConfig.list.each_with_index do |provider_id, index|
162
+ provider_meta = ProviderConfig.get(provider_id)
163
+ key_info = provider_meta[:requires_key] ? '' : ' (no API key needed)'
164
+ puts " #{index + 1}. #{provider_meta[:name]}#{key_info}"
165
+ end
166
+ print "Provider (1-#{ProviderConfig.list.size}): "
167
+
168
+ choice = $stdin.gets.chomp
169
+ provider = PROVIDER_MENU[choice]
170
+
171
+ if provider.nil? || !ProviderConfig.valid?(provider)
111
172
  puts 'Invalid provider selection.'
112
173
  return nil
113
174
  end
@@ -118,11 +179,12 @@ module Askcii
118
179
  def get_api_key(provider)
119
180
  return '' if provider == 'ollama'
120
181
 
121
- print "Enter #{provider.capitalize} API key: "
122
- api_key = $stdin.gets.chomp
182
+ provider_name = ProviderConfig.display_name(provider)
183
+ print "Enter #{provider_name} API key: "
184
+ api_key = $stdin.gets.chomp.strip
123
185
 
124
- if api_key.empty?
125
- puts 'API key is required for this provider.'
186
+ if api_key.empty? && ProviderConfig.requires_key?(provider)
187
+ puts "API key is required for #{provider_name}."
126
188
  return nil
127
189
  end
128
190
 
@@ -130,15 +192,17 @@ module Askcii
130
192
  end
131
193
 
132
194
  def get_api_endpoint(provider)
133
- default_endpoint = DEFAULT_ENDPOINTS[provider]
195
+ default_endpoint = ProviderConfig.default_endpoint(provider)
134
196
  print "Enter API endpoint (default: #{default_endpoint}): "
135
- api_endpoint = $stdin.gets.chomp
197
+ api_endpoint = $stdin.gets.chomp.strip
136
198
  api_endpoint.empty? ? default_endpoint : api_endpoint
137
199
  end
138
200
 
139
- def get_model_id
201
+ def get_model_id(provider)
202
+ examples = ProviderConfig.example_models(provider)
203
+ puts "Example models for #{ProviderConfig.display_name(provider)}: #{examples.join(', ')}"
140
204
  print 'Enter model ID: '
141
- model_id = $stdin.gets.chomp
205
+ model_id = $stdin.gets.chomp.strip
142
206
 
143
207
  if model_id.empty?
144
208
  puts 'Model ID is required.'
@@ -156,14 +220,15 @@ module Askcii
156
220
  return
157
221
  end
158
222
 
223
+ puts
159
224
  print 'Enter configuration ID to set as default: '
160
- new_default = $stdin.gets.chomp
225
+ new_default = $stdin.gets.chomp.strip
161
226
 
162
227
  if configs.any? { |c| c['id'] == new_default }
163
228
  Askcii::Config.set_default_configuration(new_default)
164
- puts "Configuration #{new_default} set as default."
229
+ puts "Configuration #{new_default} set as default."
165
230
  else
166
- puts 'Invalid configuration ID.'
231
+ puts 'Invalid configuration ID.'
167
232
  end
168
233
  end
169
234
 
@@ -175,18 +240,65 @@ module Askcii
175
240
  return
176
241
  end
177
242
 
243
+ puts
178
244
  print 'Enter configuration ID to delete: '
179
- delete_id = $stdin.gets.chomp
245
+ delete_id = $stdin.gets.chomp.strip
180
246
 
181
247
  if configs.any? { |c| c['id'] == delete_id }
248
+ print "Are you sure you want to delete configuration #{delete_id}? (y/n): "
249
+ return unless $stdin.gets.chomp.downcase == 'y'
250
+
182
251
  if Askcii::Config.delete_configuration(delete_id)
183
- puts "Configuration #{delete_id} deleted successfully."
252
+ puts "Configuration #{delete_id} deleted successfully."
184
253
  else
185
- puts 'Failed to delete configuration.'
254
+ puts 'Failed to delete configuration.'
186
255
  end
187
256
  else
188
- puts 'Invalid configuration ID.'
257
+ puts 'Invalid configuration ID.'
258
+ end
259
+ end
260
+
261
+ def test_configuration
262
+ configs = Askcii::Config.configurations
263
+
264
+ if configs.empty?
265
+ puts 'No configurations available to test.'
266
+ return
267
+ end
268
+
269
+ puts
270
+ print 'Enter configuration ID to test: '
271
+ config_id = $stdin.gets.chomp.strip
272
+
273
+ config = configs.find { |c| c['id'] == config_id }
274
+
275
+ if config
276
+ test_specific_configuration(config)
277
+ else
278
+ puts '✗ Invalid configuration ID.'
189
279
  end
190
280
  end
281
+
282
+ def test_specific_configuration(config)
283
+ puts
284
+ puts "Testing configuration: #{config['name']}"
285
+ puts '-' * 50
286
+
287
+ # Validate first
288
+ begin
289
+ symbolized_config = config.transform_keys(&:to_sym)
290
+ ConfigValidator.validate!(symbolized_config)
291
+ puts '✓ Configuration validation passed'
292
+ rescue ConfigurationError => e
293
+ puts "✗ Validation failed: #{e.message}"
294
+ return
295
+ end
296
+
297
+ # TODO: Add actual connectivity test with minimal LLM call
298
+ # For now, just validate the configuration structure
299
+ puts '✓ Configuration appears valid'
300
+ puts
301
+ puts 'Note: Actual connectivity testing will be added in a future update.'
302
+ end
191
303
  end
192
304
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Askcii
4
+ # Base error class for all Askcii errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when configuration is invalid or missing
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised when session operations fail
11
+ class SessionError < Error; end
12
+
13
+ # Raised when validation fails
14
+ class ValidationError < Error; end
15
+ end
@@ -4,9 +4,12 @@ module Askcii
4
4
  class Chat < Sequel::Model(Askcii.database[:chats])
5
5
  one_to_many :messages, class: 'Askcii::Message', key: :chat_id
6
6
 
7
- def to_llm
8
- current_config = Askcii::Config.current_configuration
9
- provider_symbol = current_config['provider'] ? current_config['provider'].to_sym : :openai
7
+ # Converts this Chat to a RubyLLM::Chat object with message history
8
+ # @param config [Hash] Configuration hash with provider and model settings
9
+ # @return [RubyLLM::Chat]
10
+ def to_llm(config)
11
+ provider = config[:provider] || config['provider'] || 'openai'
12
+ provider_symbol = provider.to_sym
10
13
 
11
14
  @chat = RubyLLM.chat(
12
15
  model: model_id,