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,64 @@
1
+ # Shell Completions
2
+
3
+ This directory contains shell completion scripts for askcii.
4
+
5
+ ## Installation
6
+
7
+ ### Bash
8
+
9
+ Add the following to your `~/.bashrc` or `~/.bash_profile`:
10
+
11
+ ```bash
12
+ source /path/to/askcii/completions/askcii.bash
13
+ ```
14
+
15
+ Or copy the completion file to your bash completions directory:
16
+
17
+ ```bash
18
+ # On macOS with Homebrew:
19
+ cp completions/askcii.bash $(brew --prefix)/etc/bash_completion.d/askcii
20
+
21
+ # On Linux:
22
+ sudo cp completions/askcii.bash /etc/bash_completion.d/askcii
23
+ ```
24
+
25
+ ### Zsh
26
+
27
+ Add the following to your `~/.zshrc`:
28
+
29
+ ```zsh
30
+ fpath=(~/path/to/askcii/completions $fpath)
31
+ autoload -Uz compinit && compinit
32
+ ```
33
+
34
+ Or copy the completion file to your zsh completions directory:
35
+
36
+ ```bash
37
+ # Create completions directory if it doesn't exist
38
+ mkdir -p ~/.zsh/completions
39
+
40
+ # Copy the completion file
41
+ cp completions/askcii.zsh ~/.zsh/completions/_askcii
42
+
43
+ # Add to ~/.zshrc if not already present:
44
+ # fpath=(~/.zsh/completions $fpath)
45
+ # autoload -Uz compinit && compinit
46
+ ```
47
+
48
+ ## Features
49
+
50
+ - Completes all command-line flags
51
+ - Suggests configuration IDs after `-m` or `--model` flags (bash only)
52
+ - Provides contextual help for each flag
53
+
54
+ ## Reload Shell
55
+
56
+ After installation, reload your shell or source your configuration file:
57
+
58
+ ```bash
59
+ # For bash
60
+ source ~/.bashrc
61
+
62
+ # For zsh
63
+ source ~/.zshrc
64
+ ```
@@ -0,0 +1,29 @@
1
+ # bash completion for askcii
2
+
3
+ _askcii() {
4
+ local cur prev opts
5
+ COMPREPLY=()
6
+ cur="${COMP_WORDS[COMP_CWORD]}"
7
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
8
+ opts="-p --private -r --last-response -c --configure -m --model -v --verbose --session --list-sessions --history --clear-history -h --help"
9
+
10
+ # Complete configuration IDs after -m/--model
11
+ if [[ ${prev} == "-m" || ${prev} == "--model" ]]; then
12
+ # Try to get configuration IDs from askcii
13
+ local configs=$(askcii -c 2>/dev/null | grep -oP '^\s+\d+\.' | tr -d ' .')
14
+ COMPREPLY=( $(compgen -W "${configs}" -- ${cur}) )
15
+ return 0
16
+ fi
17
+
18
+ # Complete session names after --session
19
+ if [[ ${prev} == "--session" ]]; then
20
+ # Could list available sessions here
21
+ return 0
22
+ fi
23
+
24
+ # Complete flags
25
+ COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
26
+ return 0
27
+ }
28
+
29
+ complete -F _askcii askcii
@@ -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,58 +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
-
25
+ # Handle usage display
26
26
  if @cli.show_usage?
27
- puts @cli.usage_message
27
+ puts Commands::HelpCommand.new(@cli).execute
28
28
  exit 1
29
29
  end
30
30
 
31
- selected_config = determine_configuration
32
- configure_llm(selected_config)
33
-
34
- chat_session = ChatSession.new(@cli.options, selected_config)
35
- chat_session.handle_last_response if @cli.last_response?
36
-
37
- input = read_stdin_input
38
- chat_session.execute_chat(@cli.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
39
48
  end
40
49
 
41
50
  private
42
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
43
70
  def determine_configuration
71
+ # Priority 1: Explicit configuration ID from CLI
44
72
  if @cli.model_config_id
45
73
  config = Askcii::Config.get_configuration(@cli.model_config_id)
46
- 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"
47
76
  end
48
77
 
78
+ # Priority 2: Default configuration from database
49
79
  config = Askcii::Config.current_configuration
50
- 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']
51
94
 
52
- # Fallback to environment variables
53
95
  {
96
+ 'provider' => 'openai',
54
97
  'api_key' => ENV['ASKCII_API_KEY'],
55
- 'api_endpoint' => ENV['ASKCII_API_ENDPOINT'],
56
- '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)'
57
101
  }
58
102
  end
59
103
 
60
- def configure_llm(selected_config)
61
- Askcii.configure_llm(selected_config)
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)
62
108
  end
63
109
 
64
- def read_stdin_input
65
- 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
66
115
 
67
- $stdin.read
116
+ hash.transform_keys { |key| key.to_sym }
68
117
  end
69
118
  end
70
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.where(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
- @prompt.empty? && !configure? && !last_response?
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
@@ -50,8 +74,10 @@ module Askcii
50
74
  <<~USAGE
51
75
  Usage:
52
76
  askcii [options] 'Your prompt here'
53
- echo 'Your prompt here' | askcii 'Your prompt here'
54
- askcii 'Your prompt here' < prompt.txt
77
+ echo 'Your prompt here' | askcii # Use piped text as prompt
78
+ echo 'Context text' | askcii 'Your prompt here' # Use piped text as context
79
+ askcii 'Your prompt here' < prompt.txt # Use file content as context
80
+ cat prompt.txt | askcii # Use file content as prompt
55
81
  askcii -p (start a private session)
56
82
  askcii -r (to get the last response)
57
83
  askcii -c (manage configurations)
@@ -88,6 +114,26 @@ module Askcii
88
114
  @options[:model_config_id] = model_id
89
115
  end
90
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
+
91
137
  opts.on('-h', '--help', 'Show this help message') do
92
138
  @options[:help] = true
93
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