askcii 0.3.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,27 @@
1
+ #compdef askcii
2
+
3
+ _askcii() {
4
+ local -a opts
5
+ opts=(
6
+ '-p[Start a private session]'
7
+ '--private[Start a private session]'
8
+ '-r[Output the last response]'
9
+ '--last-response[Output the last response]'
10
+ '-c[Manage configurations]'
11
+ '--configure[Manage configurations]'
12
+ '-m[Use specific configuration ID]:config_id:'
13
+ '--model[Use specific configuration ID]:config_id:'
14
+ '-v[Show detailed information]'
15
+ '--verbose[Show detailed information]'
16
+ '--session[Use specific session name]:session_name:'
17
+ '--list-sessions[List all available sessions]'
18
+ '--history[Show current session history]'
19
+ '--clear-history[Clear current session history]'
20
+ '-h[Show help message]'
21
+ '--help[Show help message]'
22
+ )
23
+
24
+ _arguments -s $opts '*:prompt text:'
25
+ }
26
+
27
+ _askcii "$@"
@@ -1,11 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'cli'
4
- require_relative 'configuration_manager'
5
- require_relative 'chat_session'
4
+ require_relative 'commands/base_command'
5
+ require_relative 'commands/help_command'
6
+ require_relative 'commands/configure_command'
7
+ require_relative 'commands/last_response_command'
8
+ require_relative 'commands/chat_command'
9
+ require_relative 'commands/list_sessions_command'
10
+ require_relative 'commands/history_command'
11
+ require_relative 'commands/clear_history_command'
6
12
 
7
13
  module Askcii
14
+ # Main application controller that routes to appropriate commands
8
15
  class Application
16
+ attr_reader :cli
17
+
9
18
  def initialize(args = ARGV.dup)
10
19
  @cli = CLI.new(args)
11
20
  end
@@ -13,74 +22,98 @@ module Askcii
13
22
  def run
14
23
  @cli.parse!
15
24
 
16
- if @cli.show_help?
17
- puts @cli.help_message
18
- exit 0
19
- end
20
-
21
- if @cli.configure?
22
- ConfigurationManager.new.run
23
- exit 0
24
- end
25
-
26
- selected_config = determine_configuration
27
- configure_llm(selected_config)
28
-
29
- chat_session = ChatSession.new(@cli.options, selected_config)
30
- chat_session.handle_last_response if @cli.last_response?
31
-
32
- prompt, input = determine_prompt_and_input
33
-
34
- if prompt.empty? && input.nil?
35
- puts @cli.usage_message
25
+ # Handle usage display
26
+ if @cli.show_usage?
27
+ puts Commands::HelpCommand.new(@cli).execute
36
28
  exit 1
37
29
  end
38
30
 
39
- chat_session.execute_chat(prompt, input)
31
+ # Determine and execute the appropriate command
32
+ command = determine_command
33
+ command.execute
34
+ rescue ConfigurationError => e
35
+ $stderr.puts "Configuration Error: #{e.message}"
36
+ $stderr.puts "Run 'askcii -c' to configure providers."
37
+ exit 1
38
+ rescue ValidationError => e
39
+ $stderr.puts "Validation Error: #{e.message}"
40
+ exit 1
41
+ rescue SessionError => e
42
+ $stderr.puts "Session Error: #{e.message}"
43
+ exit 1
44
+ rescue => e
45
+ $stderr.puts "Error: #{e.message}"
46
+ $stderr.puts e.backtrace.join("\n") if @cli.verbose?
47
+ exit 1
40
48
  end
41
49
 
42
50
  private
43
51
 
52
+ # Determines which command to execute based on CLI flags
53
+ # @return [Commands::BaseCommand]
54
+ def determine_command
55
+ return Commands::HelpCommand.new(@cli) if @cli.help?
56
+ return Commands::ConfigureCommand.new(@cli) if @cli.configure?
57
+ return Commands::ListSessionsCommand.new(@cli) if @cli.list_sessions?
58
+ return Commands::HistoryCommand.new(@cli) if @cli.history?
59
+ return Commands::ClearHistoryCommand.new(@cli) if @cli.clear_history?
60
+ return Commands::LastResponseCommand.new(@cli) if @cli.last_response?
61
+
62
+ # Default to chat command
63
+ config = determine_configuration
64
+ configure_llm(config)
65
+ Commands::ChatCommand.new(@cli, config)
66
+ end
67
+
68
+ # Determines which configuration to use based on CLI flags and defaults
69
+ # @return [Hash] Configuration hash
44
70
  def determine_configuration
71
+ # Priority 1: Explicit configuration ID from CLI
45
72
  if @cli.model_config_id
46
73
  config = Askcii::Config.get_configuration(@cli.model_config_id)
47
- return config if config
74
+ return symbolize_keys(config) if config
75
+ $stderr.puts "Warning: Configuration ID #{@cli.model_config_id} not found, using default"
48
76
  end
49
77
 
78
+ # Priority 2: Default configuration from database
50
79
  config = Askcii::Config.current_configuration
51
- return config if config
80
+ return symbolize_keys(config) if config
81
+
82
+ # Priority 3: Legacy environment variables or error
83
+ legacy_config = load_legacy_env_config
84
+ return symbolize_keys(legacy_config) if legacy_config
85
+
86
+ # No configuration available
87
+ raise ConfigurationError, "No configuration found. Run 'askcii -c' to configure."
88
+ end
89
+
90
+ # Loads legacy configuration from environment variables
91
+ # @return [Hash, nil]
92
+ def load_legacy_env_config
93
+ return nil unless ENV['ASKCII_API_KEY']
52
94
 
53
- # Fallback to environment variables
54
95
  {
96
+ 'provider' => 'openai',
55
97
  'api_key' => ENV['ASKCII_API_KEY'],
56
- 'api_endpoint' => ENV['ASKCII_API_ENDPOINT'],
57
- 'model_id' => ENV['ASKCII_MODEL_ID']
98
+ 'api_endpoint' => ENV['ASKCII_API_ENDPOINT'] || ProviderConfig.default_endpoint('openai'),
99
+ 'model_id' => ENV['ASKCII_MODEL_ID'] || 'gpt-3.5-turbo',
100
+ 'name' => 'Legacy (from env vars)'
58
101
  }
59
102
  end
60
103
 
61
- def configure_llm(selected_config)
62
- Askcii.configure_llm(selected_config)
63
- end
64
-
65
- def determine_prompt_and_input
66
- stdin_content = read_stdin_input
67
-
68
- if @cli.prompt.empty? && stdin_content
69
- # No prompt provided via args, use stdin as prompt
70
- [stdin_content.strip, nil]
71
- elsif !@cli.prompt.empty? && stdin_content
72
- # Both prompt and stdin provided, use stdin as input context
73
- [@cli.prompt, stdin_content]
74
- else
75
- # Only prompt provided (or neither)
76
- [@cli.prompt, nil]
77
- end
104
+ # Configures the LLM library with the selected configuration
105
+ # @param config [Hash] Configuration hash
106
+ def configure_llm(config)
107
+ Askcii.configure_llm(config)
78
108
  end
79
109
 
80
- def read_stdin_input
81
- return nil if $stdin.tty?
110
+ # Converts string keys to symbols for consistency
111
+ # @param hash [Hash] Hash with string keys
112
+ # @return [Hash] Hash with symbol keys
113
+ def symbolize_keys(hash)
114
+ return {} unless hash
82
115
 
83
- $stdin.read
116
+ hash.transform_keys { |key| key.to_sym }
84
117
  end
85
118
  end
86
119
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Askcii
6
+ # Factory for creating chat instances (private or persistent)
7
+ class ChatFactory
8
+ attr_reader :config, :private, :session_context
9
+
10
+ # @param config [Hash] Configuration with provider, api_key, model_id, etc.
11
+ # @param private [Boolean] Whether to create a private (non-persistent) chat
12
+ # @param session_context [String, nil] Session identifier for persistent chats
13
+ def initialize(config:, private: false, session_context: nil)
14
+ @config = config
15
+ @private = private
16
+ @session_context = session_context || generate_session_id
17
+ end
18
+
19
+ # Creates and returns a chat instance
20
+ # @return [RubyLLM::Chat] Chat instance ready for use
21
+ def create
22
+ private ? create_private_chat : create_persistent_chat
23
+ end
24
+
25
+ # Gets the system instruction from config or uses default
26
+ # @return [String] System instruction
27
+ def system_instruction
28
+ config[:system_instruction] ||
29
+ config['system_instruction'] ||
30
+ default_system_instruction
31
+ end
32
+
33
+ private
34
+
35
+ # Creates a private (ephemeral) chat with no database persistence
36
+ # @return [RubyLLM::Chat]
37
+ def create_private_chat
38
+ provider = config[:provider] || config['provider']
39
+ model_id = config[:model_id] || config['model_id']
40
+
41
+ RubyLLM.chat(
42
+ provider: provider.to_sym,
43
+ model_id: model_id,
44
+ assume_model_exists: true
45
+ )
46
+ end
47
+
48
+ # Creates a persistent chat backed by database
49
+ # @return [RubyLLM::Chat]
50
+ def create_persistent_chat
51
+ model_id = config[:model_id] || config['model_id']
52
+
53
+ # Find or create chat record
54
+ chat_record = Chat.find_or_create(context: session_context) do |chat|
55
+ chat.model_id = model_id
56
+ end
57
+
58
+ # Update model_id if it changed
59
+ if chat_record.model_id != model_id
60
+ chat_record.update(model_id: model_id)
61
+ end
62
+
63
+ # Convert to RubyLLM chat with history
64
+ chat_record.to_llm(config)
65
+ end
66
+
67
+ # Generates a unique session identifier
68
+ # @return [String] 32-character hex string
69
+ def generate_session_id
70
+ SecureRandom.hex(16)
71
+ end
72
+
73
+ # Default system instruction for terminal-friendly responses
74
+ # @return [String]
75
+ def default_system_instruction
76
+ 'You are a command line application. Provide concise, terminal-friendly responses. ' \
77
+ 'Assume technical proficiency. Minimize explanations unless requested.'
78
+ end
79
+ end
80
+ end
@@ -1,70 +1,97 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'securerandom'
3
+ require_relative 'chat_factory'
4
4
 
5
5
  module Askcii
6
+ # Orchestrates chat execution with LLM providers
6
7
  class ChatSession
7
- def initialize(options, selected_config)
8
- @options = options
9
- @selected_config = selected_config
8
+ attr_reader :config, :private, :session_context, :verbose
9
+
10
+ # @param config [Hash] Configuration with provider, api_key, model_id, etc.
11
+ # @param private [Boolean] Whether to create a private (non-persistent) chat
12
+ # @param session_context [String, nil] Session identifier for persistent chats
13
+ # @param verbose [Boolean] Whether to output verbose information
14
+ def initialize(config:, private: false, session_context: nil, verbose: false)
15
+ @config = config
16
+ @private = private
17
+ @session_context = session_context
18
+ @verbose = verbose
10
19
  end
11
20
 
12
- def handle_last_response
13
- return unless @options[:last_response]
21
+ # Executes a chat session with the given prompt
22
+ # @param prompt [String] User prompt
23
+ # @param input [String, nil] Additional input to prepend to prompt
24
+ def execute_chat(prompt, input = nil)
25
+ validate_prompt!(prompt)
26
+ warn_large_input!(prompt, input)
27
+
28
+ chat = create_chat
29
+ full_prompt = build_prompt(prompt, input)
14
30
 
15
- context = ENV['ASKCII_SESSION'] || SecureRandom.hex(8)
16
- model_id = @selected_config['model_id']
17
- chat_record = Askcii::Chat.find_or_create(context: context, model_id: model_id)
31
+ verbose_output "Sending prompt (#{full_prompt.bytesize} bytes)..."
18
32
 
19
- last_message = chat_record.messages.select { |msg| msg.role == 'assistant' }.last
20
- if last_message
21
- puts last_message.content
22
- exit 0
23
- else
24
- puts 'No previous response found.'
25
- exit 1
33
+ # Execute the chat with streaming
34
+ chat.ask(full_prompt) do |chunk|
35
+ print chunk.content
26
36
  end
37
+ puts '' # Ensure we end with a newline
27
38
  end
28
39
 
40
+ private
41
+
42
+ # Creates a chat instance using ChatFactory
43
+ # @return [RubyLLM::Chat]
29
44
  def create_chat
30
- if @options[:private]
31
- create_private_chat
32
- else
33
- create_persistent_chat
34
- end
35
- end
45
+ factory = ChatFactory.new(
46
+ config: config,
47
+ private: private,
48
+ session_context: session_context
49
+ )
36
50
 
37
- def execute_chat(prompt, input = nil)
38
- chat = create_chat
51
+ chat = factory.create
39
52
 
40
- chat.with_instructions 'You are a command line application. Your responses should be suitable to be read in a terminal. Your responses should only include the necessary text. Do not include any explanations unless prompted for it.'
53
+ # Apply system instructions
54
+ chat.with_instructions(factory.system_instruction)
41
55
 
42
- full_prompt = input ? "With the following text:\n\n#{input}\n\n#{prompt}" : prompt
56
+ verbose_output "Chat created (#{private ? 'private' : 'persistent'})"
43
57
 
44
- chat.ask(full_prompt) do |chunk|
45
- print chunk.content
46
- end
47
- puts ''
58
+ chat
48
59
  end
49
60
 
50
- private
61
+ # Validates that the prompt is not empty
62
+ # @param prompt [String]
63
+ # @raise [ValidationError] if prompt is empty
64
+ def validate_prompt!(prompt)
65
+ raise ValidationError, "Prompt cannot be empty" if prompt.to_s.strip.empty?
66
+ end
51
67
 
52
- def create_private_chat
53
- provider_symbol = @selected_config['provider'] ? @selected_config['provider'].to_sym : :openai
54
- model_id = @selected_config['model_id']
68
+ # Warns if input is very large
69
+ # @param prompt [String]
70
+ # @param input [String, nil]
71
+ def warn_large_input!(prompt, input)
72
+ combined = [input, prompt].compact.join("\n\n")
73
+ size_bytes = combined.bytesize
55
74
 
56
- RubyLLM.chat(
57
- model: model_id,
58
- provider: provider_symbol,
59
- assume_model_exists: true
60
- )
75
+ return unless size_bytes > 1_000_000 # 1MB
76
+
77
+ size_kb = size_bytes / 1024
78
+ $stderr.puts "Warning: Input size is #{size_kb}KB. This may be slow or fail."
79
+ end
80
+
81
+ # Builds the full prompt by combining input and prompt
82
+ # @param prompt [String]
83
+ # @param input [String, nil]
84
+ # @return [String]
85
+ def build_prompt(prompt, input)
86
+ return prompt unless input
87
+
88
+ "With the following text:\n\n#{input}\n\n#{prompt}"
61
89
  end
62
90
 
63
- def create_persistent_chat
64
- context = ENV['ASKCII_SESSION'] || SecureRandom.hex(8)
65
- model_id = @selected_config['model_id']
66
- chat_record = Askcii::Chat.find_or_create(context: context, model_id: model_id)
67
- chat_record.to_llm
91
+ # Outputs verbose information if verbose mode is enabled
92
+ # @param message [String]
93
+ def verbose_output(message)
94
+ $stderr.puts message if verbose
68
95
  end
69
96
  end
70
97
  end
data/lib/askcii/cli.rb CHANGED
@@ -23,7 +23,11 @@ module Askcii
23
23
  end
24
24
 
25
25
  def show_usage?
26
- false # Usage logic is now handled in Application class
26
+ @prompt.empty? && !configure? && !last_response? && !list_sessions? && !history? && !clear_history?
27
+ end
28
+
29
+ def help?
30
+ @options[:help]
27
31
  end
28
32
 
29
33
  def configure?
@@ -38,10 +42,30 @@ module Askcii
38
42
  @options[:private]
39
43
  end
40
44
 
45
+ def verbose?
46
+ @options[:verbose]
47
+ end
48
+
41
49
  def model_config_id
42
50
  @options[:model_config_id]
43
51
  end
44
52
 
53
+ def session
54
+ @options[:session]
55
+ end
56
+
57
+ def list_sessions?
58
+ @options[:list_sessions]
59
+ end
60
+
61
+ def history?
62
+ @options[:history]
63
+ end
64
+
65
+ def clear_history?
66
+ @options[:clear_history]
67
+ end
68
+
45
69
  def help_message
46
70
  option_parser.to_s
47
71
  end
@@ -90,6 +114,26 @@ module Askcii
90
114
  @options[:model_config_id] = model_id
91
115
  end
92
116
 
117
+ opts.on('-v', '--verbose', 'Show detailed information during execution') do
118
+ @options[:verbose] = true
119
+ end
120
+
121
+ opts.on('--session NAME', 'Use specific session name') do |session|
122
+ @options[:session] = session
123
+ end
124
+
125
+ opts.on('--list-sessions', 'List all available sessions') do
126
+ @options[:list_sessions] = true
127
+ end
128
+
129
+ opts.on('--history', 'Show current session history') do
130
+ @options[:history] = true
131
+ end
132
+
133
+ opts.on('--clear-history', 'Clear current session history') do
134
+ @options[:clear_history] = true
135
+ end
136
+
93
137
  opts.on('-h', '--help', 'Show this help message') do
94
138
  @options[:help] = true
95
139
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Askcii
4
+ module Commands
5
+ # Base class for all command implementations
6
+ class BaseCommand
7
+ attr_reader :cli, :config
8
+
9
+ # @param cli [CLI] Parsed CLI arguments
10
+ # @param config [Hash, nil] Configuration (if applicable)
11
+ def initialize(cli, config = nil)
12
+ @cli = cli
13
+ @config = config
14
+ end
15
+
16
+ # Executes the command
17
+ # @return [void]
18
+ def execute
19
+ raise NotImplementedError, "#{self.class} must implement #execute"
20
+ end
21
+
22
+ protected
23
+
24
+ # Prints error message to stderr and exits
25
+ # @param message [String] Error message
26
+ # @param exit_code [Integer] Exit code (default: 1)
27
+ def error!(message, exit_code: 1)
28
+ $stderr.puts "Error: #{message}"
29
+ exit exit_code
30
+ end
31
+
32
+ # Prints verbose output if verbose mode is enabled
33
+ # @param message [String] Message to print
34
+ def verbose(message)
35
+ $stderr.puts message if cli.verbose?
36
+ end
37
+
38
+ # Gets the session context from CLI or environment
39
+ # @return [String]
40
+ def session_context
41
+ cli.session || ENV['ASKCII_SESSION'] || SecureRandom.hex(16)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Askcii
4
+ module Commands
5
+ # Executes a chat session with the LLM
6
+ class ChatCommand < BaseCommand
7
+ def execute
8
+ validate_config!
9
+ display_config_info if cli.verbose?
10
+
11
+ # Read from stdin if available
12
+ stdin_input = read_stdin_input
13
+
14
+ # Execute the chat
15
+ require_relative '../chat_session'
16
+ session = ChatSession.new(
17
+ config: config,
18
+ private: cli.private?,
19
+ session_context: session_context,
20
+ verbose: cli.verbose?
21
+ )
22
+
23
+ session.execute_chat(cli.prompt, stdin_input)
24
+ end
25
+
26
+ private
27
+
28
+ def validate_config!
29
+ return if config
30
+
31
+ error! "No configuration available. Run 'askcii -c' to configure."
32
+ end
33
+
34
+ def display_config_info
35
+ provider = config[:provider] || config['provider']
36
+ model_id = config[:model_id] || config['model_id']
37
+ config_name = config[:name] || config['name'] || 'Unnamed'
38
+
39
+ verbose "Using configuration: #{config_name}"
40
+ verbose "Provider: #{provider}"
41
+ verbose "Model: #{model_id}"
42
+ verbose "Session: #{session_context}"
43
+ verbose "Private mode: #{cli.private?}"
44
+ end
45
+
46
+ def read_stdin_input
47
+ return nil if $stdin.tty?
48
+
49
+ verbose "Reading from stdin..."
50
+ input = $stdin.read.strip
51
+ verbose "Read #{input.bytesize} bytes from stdin"
52
+ input.empty? ? nil : input
53
+ end
54
+ end
55
+ end
56
+ end
@@ -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