prescient 0.6.0 → 0.8.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -268
  3. data/CHANGELOG.md +59 -0
  4. data/INTEGRATION_GUIDE.md +19 -1
  5. data/README.md +369 -21
  6. data/Steepfile +12 -12
  7. data/db/migrate/001_create_prescient_tables.rb +15 -16
  8. data/docker-compose.yml +22 -0
  9. data/examples/README.md +36 -1
  10. data/examples/custom_contexts.rb +4 -4
  11. data/examples/web_search.rb +34 -0
  12. data/exe/prescient +2 -2
  13. data/exe/prescient-mcp +7 -0
  14. data/lib/prescient/agent/audit_log.rb +37 -0
  15. data/lib/prescient/agent/cli_adapter.rb +29 -0
  16. data/lib/prescient/agent/configuration.rb +57 -0
  17. data/lib/prescient/agent/context.rb +56 -0
  18. data/lib/prescient/agent/error_serializer.rb +47 -0
  19. data/lib/prescient/agent/errors.rb +25 -0
  20. data/lib/prescient/agent/parser.rb +49 -0
  21. data/lib/prescient/agent/prompt_builder.rb +31 -0
  22. data/lib/prescient/agent/result.rb +36 -0
  23. data/lib/prescient/agent/runtime.rb +175 -0
  24. data/lib/prescient/agent/schema_validator.rb +215 -0
  25. data/lib/prescient/agent/tool_registry.rb +89 -0
  26. data/lib/prescient/agent.rb +22 -0
  27. data/lib/prescient/api.rb +346 -231
  28. data/lib/prescient/base.rb +370 -372
  29. data/lib/prescient/cli.rb +591 -402
  30. data/lib/prescient/client.rb +31 -4
  31. data/lib/prescient/configuration_loader.rb +511 -336
  32. data/lib/prescient/document_source.rb +114 -0
  33. data/lib/prescient/errors.rb +13 -3
  34. data/lib/prescient/mcp/authentication.rb +39 -0
  35. data/lib/prescient/mcp/configuration.rb +38 -0
  36. data/lib/prescient/mcp/rack.rb +243 -0
  37. data/lib/prescient/mcp/server.rb +202 -0
  38. data/lib/prescient/mcp/stdio.rb +42 -0
  39. data/lib/prescient/mcp.rb +8 -0
  40. data/lib/prescient/pgvector.rb +193 -189
  41. data/lib/prescient/provider/anthropic.rb +129 -125
  42. data/lib/prescient/provider/deepseek.rb +122 -118
  43. data/lib/prescient/provider/gemini.rb +153 -149
  44. data/lib/prescient/provider/huggingface.rb +191 -187
  45. data/lib/prescient/provider/mistral.rb +151 -147
  46. data/lib/prescient/provider/ollama.rb +168 -165
  47. data/lib/prescient/provider/openai.rb +174 -171
  48. data/lib/prescient/provider/xai.rb +122 -118
  49. data/lib/prescient/tool/search_api.rb +130 -0
  50. data/lib/prescient/tool/searxng.rb +128 -0
  51. data/lib/prescient/tool.rb +125 -0
  52. data/lib/prescient/version.rb +1 -1
  53. data/lib/prescient.rb +129 -55
  54. data/schema/prescient.configuration.schema.json +119 -0
  55. data/searxng/settings.yml +18 -0
  56. data/sig/prescient.rbs +228 -1
  57. metadata +33 -5
data/lib/prescient/cli.rb CHANGED
@@ -1,480 +1,669 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'optparse'
5
- require 'yaml'
6
- require_relative '../prescient'
7
-
8
- # Command-line interface for common Prescient operations.
9
- class Prescient::CLI
10
- # Supported output formats.
11
- # @return [Array<String>] Output format names
12
- FORMATS = ['text', 'json'].freeze
13
-
14
- # Schema URL and annotated starter configuration for `config example`.
15
- CONFIGURATION_EXAMPLE = <<~YAML
16
- # yaml-language-server: $schema=https://raw.githubusercontent.com/kanutocd/prescient/refs/heads/main/schema/prescient.configuration.schema.json
3
+ require "json"
4
+ require "optparse"
5
+ require "yaml"
6
+ require_relative "../prescient"
7
+
8
+ module Prescient
9
+ # Command-line interface for common Prescient operations.
10
+ # rubocop:disable Metrics/ClassLength
11
+ class CLI
12
+ # @return [Hash<String, Symbol>] CLI command dispatch table
13
+ COMMAND_HANDLERS = {
14
+ "providers" => :providers,
15
+ "health" => :health,
16
+ "generate" => :generate,
17
+ "embed" => :embed,
18
+ "search" => :search,
19
+ "agent" => :agent,
20
+ "config" => :config
21
+ }.freeze
22
+ # Supported output formats.
23
+ # @return [Array<String>] Output format names
24
+ FORMATS = %w[text json].freeze
25
+
26
+ # Schema URL and annotated starter configuration for `config example`.
27
+ CONFIGURATION_EXAMPLE = <<~YAML
28
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/kanutocd/prescient/refs/heads/main/schema/prescient.configuration.schema.json
29
+ #
30
+ # Prescient configuration example.
31
+ #
32
+ # Precedence, from lowest to highest:
33
+ # 1. Built-in defaults and provider environment variables.
34
+ # 2. Values in this YAML file.
35
+ # 3. Per-operation CLI overrides such as --provider and --chat-model.
36
+ #
37
+ # Use `prescient config validate` after editing this file.
38
+ # Keep credentials out of source control; use *_env references instead.
39
+ version: 1
40
+
41
+ # Global behavior.
42
+ default_provider: ollama
43
+ timeout: 30
44
+ retry_attempts: 3
45
+ retry_delay: 1.0
46
+ fallback_providers: []
47
+ sensitive_keys:
48
+ - api_key
49
+ - password
50
+ - token
51
+ - secret
52
+
53
+ providers:
54
+ # Local Ollama requires no API key.
55
+ ollama:
56
+ type: ollama
57
+ url: http://localhost:11434
58
+ embedding_model: nomic-embed-text
59
+ chat_model: llama3.2:3b
60
+ # prompt_templates:
61
+ # system_prompt: You are a concise assistant.
62
+ # no_context_template: "%<system_prompt>s\x5Cn\x5CnUser: %<query>s"
63
+ # with_context_template: "%<system_prompt>s\x5Cn\x5CnContext:\x5Cn%<context>s\x5Cn\x5CnUser: %<query>s"
64
+
65
+ # Uncomment a cloud provider and set its credential in the environment.
66
+ # openai:
67
+ # type: openai
68
+ # api_key_env: OPENAI_API_KEY
69
+ # embedding_model: text-embedding-3-small
70
+ # chat_model: gpt-4.1-mini
71
+ # prompt_templates:
72
+ # system_prompt: You are a concise assistant.
73
+ # no_context_template: "%<system_prompt>s\x5Cn\x5CnUser: %<query>s"
74
+
75
+ # anthropic:
76
+ # type: anthropic
77
+ # api_key_env: ANTHROPIC_API_KEY
78
+ # model: claude-sonnet-4-20250514
79
+
80
+ # gemini:
81
+ # type: gemini
82
+ # api_key_env: GEMINI_API_KEY
83
+ # embedding_model: gemini-embedding-001
84
+ # chat_model: gemini-2.5-flash
85
+
86
+ # mistral:
87
+ # type: mistral
88
+ # api_key_env: MISTRAL_API_KEY
89
+ # embedding_model: mistral-embed
90
+ # chat_model: mistral-large-latest
91
+
92
+ # DeepSeek supports text generation, but not embeddings.
93
+ # deepseek:
94
+ # type: deepseek
95
+ # api_key_env: DEEPSEEK_API_KEY
96
+ # chat_model: deepseek-v4-flash
97
+
98
+ # xai:
99
+ # type: xai
100
+ # api_key_env: XAI_API_KEY
101
+ # chat_model: grok-4.5
102
+
103
+ # huggingface:
104
+ # type: huggingface
105
+ # api_key_env: HUGGINGFACE_API_KEY
106
+ # embedding_model: sentence-transformers/all-MiniLM-L6-v2
107
+ # chat_model: google/gemma-2-2b-it
108
+
109
+ # External tools are opt-in and separate from AI providers. They can be
110
+ # used directly with `prescient search`, or as context with
111
+ # `prescient search --generate`.
112
+ #
113
+ # The CLI also registers `web_search` automatically when SEARXNG_URL is
114
+ # set and no YAML tool configuration is provided.
115
+ tools:
116
+ # Local SearXNG example. Uncomment this block to configure a tool in YAML.
117
+ # web_search:
118
+ # type: searxng
119
+ # url: http://localhost:8080
120
+ # timeout: 5
121
+ # max_results: 5
122
+ # language: en
123
+ # categories:
124
+ # - general
125
+ # - science
126
+ # max_response_bytes: 1048576
127
+
128
+ # SearchApi example. It uses SearchApi's Google engine by default and
129
+ # authenticates with a Bearer token from the environment.
130
+ # searchapi_web:
131
+ # type: searchapi
132
+ # api_key_env: SEARCHAPI_API_KEY
133
+ # engine: google
134
+ # location: New York
135
+ # hl: en
136
+ # gl: us
137
+ # timeout: 10
138
+ # max_results: 5
139
+
140
+ # Capability fallback. Adapters are tried in order, and fallback occurs
141
+ # only for transient connection or rate-limit failures.
142
+ # resilient_search:
143
+ # adapters:
144
+ # - type: searxng
145
+ # url_env: SEARXNG_URL
146
+ # - type: searchapi
147
+ # api_key_env: SEARCHAPI_API_KEY
148
+ # engine: google
149
+
150
+ # Prefer an environment reference when the URL differs by environment
151
+ # or should not be committed. Use `--tool research_search` to select a
152
+ # tool with a custom name.
153
+ # research_search:
154
+ # type: searxng
155
+ # url_env: SEARXNG_URL
156
+ # timeout_env: SEARXNG_TIMEOUT
157
+ # max_results_env: SEARXNG_MAX_RESULTS
158
+ # language_env: SEARXNG_LANGUAGE
159
+ # categories_env: SEARXNG_CATEGORIES
160
+
161
+ # Search results are returned directly by default. Add `--generate` to
162
+ # feed normalized results to the selected AI provider. Omit `--generate`
163
+ # when the caller should handle the search results itself.
164
+ YAML
165
+
166
+ # Raised when command-line arguments are invalid or incomplete.
167
+ class UsageError < StandardError; end
168
+
169
+ # Run the CLI and return a process exit status.
17
170
  #
18
- # Prescient configuration example.
19
- #
20
- # Precedence, from lowest to highest:
21
- # 1. Built-in defaults and provider environment variables.
22
- # 2. Values in this YAML file.
23
- # 3. Per-operation CLI overrides such as --provider and --chat-model.
171
+ # @param arguments [Array<String>] Command-line arguments
172
+ # @param input [IO] Input stream used for stdin prompts
173
+ # @param output [IO] Output stream for command results
174
+ # @param errors [IO] Output stream for diagnostics
175
+ # @return [Integer] Process exit status
176
+ def self.run(arguments, input: $stdin, output: $stdout, errors: $stderr)
177
+ new(arguments, input:, output:, errors:).run
178
+ rescue UsageError, OptionParser::ParseError => e
179
+ errors.puts "prescient: #{e.message}"
180
+ 2
181
+ rescue Prescient::Error => e
182
+ errors.puts "prescient: #{e.message}"
183
+ 1
184
+ end
185
+
186
+ # Initialize a CLI runner with injectable streams.
24
187
  #
25
- # Use `prescient config validate` after editing this file.
26
- # Keep credentials out of source control; use *_env references instead.
27
- version: 1
28
-
29
- # Global behavior.
30
- default_provider: ollama
31
- timeout: 30
32
- retry_attempts: 3
33
- retry_delay: 1.0
34
- fallback_providers: []
35
- sensitive_keys:
36
- - api_key
37
- - password
38
- - token
39
- - secret
40
-
41
- providers:
42
- # Local Ollama requires no API key.
43
- ollama:
44
- type: ollama
45
- url: http://localhost:11434
46
- embedding_model: nomic-embed-text
47
- chat_model: llama3.2:3b
48
- # prompt_templates:
49
- # system_prompt: You are a concise assistant.
50
- # no_context_template: "%<system_prompt>s\\n\\nUser: %<query>s"
51
- # with_context_template: "%<system_prompt>s\\n\\nContext:\\n%<context>s\\n\\nUser: %<query>s"
52
-
53
- # Uncomment a cloud provider and set its credential in the environment.
54
- # openai:
55
- # type: openai
56
- # api_key_env: OPENAI_API_KEY
57
- # embedding_model: text-embedding-3-small
58
- # chat_model: gpt-4.1-mini
59
- # prompt_templates:
60
- # system_prompt: You are a concise assistant.
61
- # no_context_template: "%<system_prompt>s\n\nUser: %<query>s"
62
-
63
- # anthropic:
64
- # type: anthropic
65
- # api_key_env: ANTHROPIC_API_KEY
66
- # model: claude-sonnet-4-20250514
67
-
68
- # gemini:
69
- # type: gemini
70
- # api_key_env: GEMINI_API_KEY
71
- # embedding_model: gemini-embedding-001
72
- # chat_model: gemini-2.5-flash
73
-
74
- # mistral:
75
- # type: mistral
76
- # api_key_env: MISTRAL_API_KEY
77
- # embedding_model: mistral-embed
78
- # chat_model: mistral-large-latest
79
-
80
- # DeepSeek supports text generation, but not embeddings.
81
- # deepseek:
82
- # type: deepseek
83
- # api_key_env: DEEPSEEK_API_KEY
84
- # chat_model: deepseek-v4-flash
85
-
86
- # xai:
87
- # type: xai
88
- # api_key_env: XAI_API_KEY
89
- # chat_model: grok-4.5
90
-
91
- # huggingface:
92
- # type: huggingface
93
- # api_key_env: HUGGINGFACE_API_KEY
94
- # embedding_model: sentence-transformers/all-MiniLM-L6-v2
95
- # chat_model: google/gemma-2-2b-it
96
- YAML
97
-
98
- # Raised when command-line arguments are invalid or incomplete.
99
- class UsageError < StandardError; end
100
-
101
- # Run the CLI and return a process exit status.
102
- #
103
- # @param arguments [Array<String>] Command-line arguments
104
- # @param input [IO] Input stream used for stdin prompts
105
- # @param output [IO] Output stream for command results
106
- # @param errors [IO] Output stream for diagnostics
107
- # @return [Integer] Process exit status
108
- def self.run(arguments, input: $stdin, output: $stdout, errors: $stderr)
109
- new(arguments, input:, output:, errors:).run
110
- rescue UsageError, OptionParser::ParseError => e
111
- errors.puts "prescient: #{e.message}"
112
- 2
113
- rescue Prescient::Error => e
114
- errors.puts "prescient: #{e.message}"
115
- 1
116
- end
188
+ # @param arguments [Array<String>] Command-line arguments
189
+ # @param input [IO] Input stream used for stdin prompts
190
+ # @param output [IO] Output stream for command results
191
+ # @param errors [IO] Output stream for diagnostics
192
+ def initialize(arguments, input:, output:, errors:)
193
+ @arguments = arguments.dup
194
+ @input = input
195
+ @output = output
196
+ @errors = errors
197
+ end
117
198
 
118
- # Initialize a CLI runner with injectable streams.
119
- #
120
- # @param arguments [Array<String>] Command-line arguments
121
- # @param input [IO] Input stream used for stdin prompts
122
- # @param output [IO] Output stream for command results
123
- # @param errors [IO] Output stream for diagnostics
124
- def initialize(arguments, input:, output:, errors:)
125
- @arguments = arguments.dup
126
- @input = input
127
- @output = output
128
- @errors = errors
129
- end
199
+ # Execute the CLI command and return its process status.
200
+ # @return [Integer] Process exit status
201
+ def run
202
+ config_path = extract_global_config_path
203
+ Prescient.load_configuration(config_path) if config_path || ENV["PRESCIENT_CONFIG"]
130
204
 
131
- # Execute the CLI command and return its process status.
132
- # @return [Integer] Process exit status
133
- def run
134
- config_path = extract_global_config_path
135
- Prescient.load_configuration(config_path) if config_path || ENV['PRESCIENT_CONFIG']
205
+ command = @arguments.shift
206
+ return print_help(2) unless command
136
207
 
137
- command = @arguments.shift
138
- return print_help(2) unless command
208
+ run_command(command)
209
+ end
139
210
 
140
- run_command(command)
141
- end
211
+ # Dispatch a parsed command to its handler.
212
+ # @param command [String] Command name
213
+ # @return [Integer] Process exit status
214
+ def run_command(command)
215
+ return print_help(0) if ["help", "--help", "-h"].include?(command)
216
+
217
+ handler = COMMAND_HANDLERS[command]
218
+ raise UsageError, "unknown command #{command.inspect}; run 'prescient help'" unless handler
142
219
 
143
- # Dispatch a parsed command to its handler.
144
- # @param command [String] Command name
145
- # @return [Integer] Process exit status
146
- def run_command(command)
147
- case command
148
- when 'providers' then providers
149
- when 'health' then health
150
- when 'generate' then generate
151
- when 'embed' then embed
152
- when 'config' then config
153
- when 'help', '--help', '-h' then print_help(0)
154
- else
155
- raise UsageError, "unknown command #{command.inspect}; run 'prescient help'"
220
+ send(handler)
156
221
  end
157
- end
158
222
 
159
- private
223
+ private
160
224
 
161
- def providers
162
- options = parse_options('List configured providers')
163
- return options if options.is_a?(Integer)
225
+ def providers
226
+ options = parse_options("List configured providers")
227
+ return options if options.is_a?(Integer)
164
228
 
165
- provider_list = Prescient.configuration.providers.map { |name, registration|
166
- { name: name.to_s, class: registration[:class].name }
167
- }
229
+ provider_list = Prescient.configuration.providers.map do |name, registration|
230
+ { name: name.to_s, class: registration[:class].name }
231
+ end
168
232
 
169
- if options[:format] == 'json'
170
- print_json(providers: provider_list)
171
- else
172
- provider_list.each { |provider| @output.puts "#{provider[:name]}\t#{provider[:class]}" }
233
+ if options[:format] == "json"
234
+ print_json(providers: provider_list)
235
+ else
236
+ provider_list.each { |provider| @output.puts "#{provider[:name]}\t#{provider[:class]}" }
237
+ end
238
+ 0
173
239
  end
174
- 0
175
- end
176
240
 
177
- def health
178
- options = parse_options('Check provider health')
179
- return options if options.is_a?(Integer)
241
+ def health
242
+ options = parse_options("Check provider health")
243
+ return options if options.is_a?(Integer)
180
244
 
181
- names = options[:provider] ? [options[:provider].to_sym] : Prescient.configuration.providers.keys
182
- raise UsageError, 'no providers are configured' if names.empty?
245
+ names = options[:provider] ? [options[:provider].to_sym] : Prescient.configuration.providers.keys
246
+ raise UsageError, "no providers are configured" if names.empty?
183
247
 
184
- results = names.to_h { |name| [name.to_s, Prescient.health_check(provider: name)] }
185
- output_health(results, options[:format])
186
- results.values.all? { |result| result[:reachable] != false } ? 0 : 1
187
- end
248
+ results = names.to_h { |name| [name.to_s, Prescient.health_check(provider: name)] }
249
+ output_health(results, options[:format])
250
+ results.values.all? { |result| result[:reachable] != false } ? 0 : 1
251
+ end
188
252
 
189
- def generate
190
- options = parse_options('Generate a text response', fallback: true)
191
- return options if options.is_a?(Integer)
253
+ def generate
254
+ options = parse_options("Generate a text response", fallback: true, documents: true)
255
+ return options if options.is_a?(Integer)
192
256
 
193
- prompt = read_text(options[:arguments], 'prompt')
194
- client = client_for(options)
195
- response = client.generate_response(prompt, **model_options(options))
257
+ prompt = read_text(options[:arguments], "prompt")
258
+ client = client_for(options)
259
+ context = if options[:json_file]
260
+ Prescient::DocumentSource::JsonFile.new(path: options[:json_file]).fetch
261
+ else
262
+ []
263
+ end
264
+ response = client.generate_response(prompt, context, **model_options(options))
196
265
 
197
- options[:format] == 'json' ? print_json(response) : @output.puts(response[:response])
198
- 0
199
- end
266
+ options[:format] == "json" ? print_json(response) : @output.puts(response[:response])
267
+ 0
268
+ end
200
269
 
201
- def embed
202
- options = parse_options('Generate an embedding', fallback: true)
203
- return options if options.is_a?(Integer)
270
+ def embed
271
+ options = parse_options("Generate an embedding", fallback: true)
272
+ return options if options.is_a?(Integer)
204
273
 
205
- text = read_text(options[:arguments], 'text')
206
- client = client_for(options)
207
- embedding = client.generate_embedding(text, **model_options(options))
274
+ text = read_text(options[:arguments], "text")
275
+ client = client_for(options)
276
+ embedding = client.generate_embedding(text, **model_options(options))
208
277
 
209
- if options[:format] == 'json'
210
- print_json(embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s)
211
- else
212
- @output.puts JSON.generate(embedding)
278
+ if options[:format] == "json"
279
+ print_json(embedding: embedding, dimensions: embedding.length, provider: client.provider_name.to_s)
280
+ else
281
+ @output.puts JSON.generate(embedding)
282
+ end
283
+ 0
213
284
  end
214
- 0
215
- end
216
285
 
217
- def config
218
- subcommand = @arguments.shift
219
- case subcommand
220
- when 'validate' then validate_config_command
221
- when 'example' then configuration_example_command
222
- else
223
- raise UsageError, "unknown config command #{subcommand.inspect}"
286
+ def search
287
+ options = parse_options(
288
+ "Search with a configured external tool",
289
+ fallback: true, tool: true, limit: true, generate: true
290
+ )
291
+ return options if options.is_a?(Integer)
292
+
293
+ query = read_text(options[:arguments], "query")
294
+ tool_name = (options[:tool] || "web_search").to_sym
295
+ return generate_search_response(query, tool_name, options) if options[:generate]
296
+
297
+ tool = Prescient.tool(tool_name)
298
+ raise UsageError, "tool not configured: #{tool_name}" unless tool
299
+
300
+ result = tool.search(query, limit: options[:limit])
301
+ if options[:format] == "json"
302
+ print_json(result)
303
+ else
304
+ print_search_results(result[:results])
305
+ end
306
+ 0
224
307
  end
225
- end
226
-
227
- def validate_config_command
228
- options = parse_options('Validate the current configuration')
229
- return options if options.is_a?(Integer)
230
308
 
231
- validate_configuration
232
- if options[:format] == 'json'
233
- print_json(valid: true, providers: Prescient.configuration.providers.keys.map(&:to_s))
234
- else
235
- @output.puts 'configuration valid'
309
+ def agent
310
+ require "prescient/agent"
311
+ options = parse_options("Run a bounded agent task", agent: true)
312
+ return options if options.is_a?(Integer)
313
+
314
+ task = read_text(options[:arguments], "task")
315
+ Prescient::Agent::CLIAdapter.new(output: @output, errors: @errors).run(
316
+ task:,
317
+ provider: options[:provider]&.to_sym,
318
+ tool_names: options.fetch(:tools, []),
319
+ max_loops: options[:max_loops] || Prescient::Agent::Configuration::DEFAULT_MAX_LOOPS,
320
+ format: options[:format],
321
+ provider_options: provider_options(options),
322
+ generation_options: model_options(options)
323
+ )
236
324
  end
237
- 0
238
- end
239
325
 
240
- def configuration_example_command
241
- options = parse_options('Generate an annotated YAML configuration example')
242
- return options if options.is_a?(Integer)
326
+ def generate_search_response(query, tool_name, options)
327
+ response = Prescient.search_and_generate(
328
+ query,
329
+ tool: tool_name,
330
+ provider: options[:provider]&.to_sym,
331
+ limit: options[:limit],
332
+ enable_fallback: options[:fallback],
333
+ provider_options: provider_options(options),
334
+ **model_options(options)
335
+ )
336
+ options[:format] == "json" ? print_json(response) : @output.puts(response[:response])
337
+ 0
338
+ end
243
339
 
244
- @output.write(CONFIGURATION_EXAMPLE)
245
- 0
246
- end
340
+ def print_search_results(results)
341
+ results.each do |item|
342
+ @output.puts item[:title]
343
+ @output.puts item[:url]
344
+ @output.puts item[:snippet] unless item[:snippet].empty?
345
+ @output.puts
346
+ end
347
+ end
247
348
 
248
- def validate_configuration
249
- configuration = Prescient.configuration
250
- unless configuration.provider(configuration.default_provider)
251
- raise Prescient::Error, 'default provider is not configured'
349
+ def config
350
+ subcommand = @arguments.shift
351
+ case subcommand
352
+ when "validate" then validate_config_command
353
+ when "example" then configuration_example_command
354
+ else
355
+ raise UsageError, "unknown config command #{subcommand.inspect}"
356
+ end
252
357
  end
253
358
 
254
- configuration.providers.each_key { |name| configuration.provider(name) }
255
- end
359
+ def validate_config_command
360
+ options = parse_options("Validate the current configuration")
361
+ return options if options.is_a?(Integer)
256
362
 
257
- def parse_options(description, fallback: false)
258
- options = { format: 'text', fallback: fallback }
259
- parser = OptionParser.new do |parser|
260
- parser.banner = "Usage: prescient #{@arguments.first || 'command'} [options]"
261
- parser.separator description
262
- add_common_options(parser, options)
263
- parser.on('--no-fallback', 'Disable provider fallback') { options[:fallback] = false } if fallback
264
- parser.on('-h', '--help', 'Show command help') do
265
- @output.puts parser
266
- throw :help_shown, 0
363
+ validate_configuration
364
+ if options[:format] == "json"
365
+ print_json(valid: true, providers: Prescient.configuration.providers.keys.map(&:to_s))
366
+ else
367
+ @output.puts "configuration valid"
267
368
  end
369
+ 0
268
370
  end
269
371
 
270
- result = catch(:help_shown) { parse_arguments(parser) }
271
- return result unless result.nil?
272
-
273
- options[:arguments] = @arguments
274
- options
275
- end
372
+ def configuration_example_command
373
+ options = parse_options("Generate an annotated YAML configuration example")
374
+ return options if options.is_a?(Integer)
276
375
 
277
- def add_common_options(parser, options)
278
- parser.on('--config PATH', 'Load configuration from a YAML file') do |value|
279
- options[:config] = value
376
+ @output.write(CONFIGURATION_EXAMPLE)
377
+ 0
280
378
  end
281
- parser.on('--format FORMAT', FORMATS, "Output format (#{FORMATS.join(', ')})") do |value|
282
- options[:format] = value
283
- end
284
- parser.on('--provider NAME', 'Use a specific provider') do |value|
285
- options[:provider] = value
286
- end
287
- add_model_options(parser, options)
288
- add_credential_options(parser, options)
289
- end
290
379
 
291
- # Parse command arguments and return nil when parsing completes.
292
- #
293
- # @param parser [OptionParser] Configured command option parser
294
- # @return [nil]
295
- def parse_arguments(parser)
296
- parser.parse!(@arguments)
297
- nil
298
- end
380
+ def validate_configuration
381
+ configuration = Prescient.configuration
382
+ unless configuration.provider(configuration.default_provider)
383
+ raise Prescient::Error, "default provider is not configured"
384
+ end
299
385
 
300
- def model_options(options)
301
- options[:model] ? { model: options[:model] } : {}
302
- end
386
+ configuration.providers.each_key do |name|
387
+ configuration.provider(name)
388
+ end
389
+ configuration.tools.each_key do |name|
390
+ configuration.tool(name)
391
+ end
392
+ end
303
393
 
304
- def client_for(options)
305
- validate_override_options(options)
306
- Prescient.client(
307
- options[:provider]&.to_sym,
308
- enable_fallback: options[:fallback],
309
- provider_options: provider_options(options),
310
- )
311
- rescue KeyError => e
312
- raise UsageError, "environment variable not set: #{e.key}"
313
- end
394
+ def parse_options(description, fallback: false, tool: false, limit: false, generate: false,
395
+ documents: false, agent: false)
396
+ options = { format: "text", fallback: fallback }
397
+ parser = OptionParser.new do |parser|
398
+ parser.banner = "Usage: prescient #{@arguments.first || "command"} [options]"
399
+ parser.separator description
400
+ parser.separator ""
401
+ parser.separator "Global options:"
402
+ add_common_options(parser, options)
403
+ add_optional_options(parser, options, fallback:, tool:, limit:, generate:, documents:, agent:)
404
+ parser.on("-h", "--help", "Show command help") do
405
+ @output.puts parser
406
+ throw :help_shown, 0
407
+ end
408
+ end
314
409
 
315
- def add_model_options(parser, options)
316
- parser.on('--model NAME', 'Override the configured model') do |value|
317
- options[:model] = value
318
- end
319
- parser.on('--embedding-model NAME', 'Override the embedding model') do |value|
320
- options[:embedding_model] = value
410
+ result = catch(:help_shown) { parse_arguments(parser) }
411
+ return result unless result.nil?
412
+
413
+ options[:arguments] = @arguments
414
+ options
321
415
  end
322
- parser.on('--chat-model NAME', 'Override the chat model') do |value|
323
- options[:chat_model] = value
416
+
417
+ def add_optional_options(parser, options, fallback:, tool:, limit:, generate:, documents:, agent:)
418
+ add_document_options(parser, options) if documents
419
+ parser.on("--no-fallback", "Disable provider fallback") { options[:fallback] = false } if fallback
420
+ add_tool_options(parser, options, tool:, limit:, generate:)
421
+ add_agent_options(parser, options) if agent
324
422
  end
325
- parser.on('--system-prompt TEXT', 'Override the system prompt') do |value|
326
- options[:system_prompt] = value
423
+
424
+ def add_agent_options(parser, options)
425
+ parser.separator ""
426
+ parser.separator "Agent options:"
427
+ options[:tools] = []
428
+ parser.on("--tool NAME", "Allow a configured tool (repeatable)") do |value|
429
+ options[:tools] << value.to_sym
430
+ end
431
+ parser.on("--max-loops COUNT", Integer, "Maximum agent iterations") { |value| options[:max_loops] = value }
327
432
  end
328
- parser.on('--no-context-template TEXT', 'Override the no-context prompt template') do |value|
329
- options[:no_context_template] = value
433
+
434
+ def add_tool_options(parser, options, tool:, limit:, generate:)
435
+ return unless tool || limit || generate
436
+
437
+ parser.separator ""
438
+ parser.separator "Search options:"
439
+ parser.on("--tool NAME", "Use a configured external tool") { |value| options[:tool] = value } if tool
440
+ parser.on("--generate", "Use search results as AI provider context") { options[:generate] = true } if generate
441
+ return unless limit
442
+
443
+ parser.on("--limit COUNT", Integer, "Limit the number of results") { |value| options[:limit] = value }
330
444
  end
331
- parser.on('--with-context-template TEXT', 'Override the with-context prompt template') do |value|
332
- options[:with_context_template] = value
445
+
446
+ def add_common_options(parser, options)
447
+ parser.on("--config PATH", "Load configuration from a YAML file") do |value|
448
+ options[:config] = value
449
+ end
450
+ parser.on("--format FORMAT", FORMATS, "Output format (#{FORMATS.join(", ")})") do |value|
451
+ options[:format] = value
452
+ end
453
+ parser.on("--provider NAME", "Use a specific provider") do |value|
454
+ options[:provider] = value
455
+ end
456
+ add_model_options(parser, options)
457
+ add_credential_options(parser, options)
333
458
  end
334
- parser.on('--prompt-templates-file PATH', 'Load prompt templates from a YAML file') do |value|
335
- options[:prompt_templates_file] = value
459
+
460
+ # Parse command arguments and return nil when parsing completes.
461
+ #
462
+ # @param parser [OptionParser] Configured command option parser
463
+ # @return [nil]
464
+ def parse_arguments(parser)
465
+ parser.parse!(@arguments)
466
+ nil
336
467
  end
337
- end
338
468
 
339
- def add_credential_options(parser, options)
340
- parser.on('--api-key KEY', 'Use an API key for this operation') do |value|
341
- options[:api_key] = value
469
+ def model_options(options)
470
+ options[:model] ? { model: options[:model] } : {}
342
471
  end
343
- parser.on('--api-key-env NAME', 'Read the API key from this environment variable') do |value|
344
- options[:api_key_env] = value
472
+
473
+ def client_for(options)
474
+ validate_override_options(options)
475
+ Prescient.client(
476
+ options[:provider]&.to_sym,
477
+ enable_fallback: options[:fallback],
478
+ provider_options: provider_options(options)
479
+ )
480
+ rescue KeyError => e
481
+ raise UsageError, "environment variable not set: #{e.key}"
345
482
  end
346
- end
347
483
 
348
- def validate_override_options(options)
349
- if options[:model] && (options[:embedding_model] || options[:chat_model])
350
- raise UsageError, '--model cannot be combined with --embedding-model or --chat-model'
484
+ def add_model_options(parser, options)
485
+ parser.on("--model NAME", "Override the configured model") do |value|
486
+ options[:model] = value
487
+ end
488
+ parser.on("--embedding-model NAME", "Override the embedding model") do |value|
489
+ options[:embedding_model] = value
490
+ end
491
+ parser.on("--chat-model NAME", "Override the chat model") do |value|
492
+ options[:chat_model] = value
493
+ end
494
+ parser.on("--system-prompt TEXT", "Override the system prompt") do |value|
495
+ options[:system_prompt] = value
496
+ end
497
+ parser.on("--no-context-template TEXT", "Override the no-context prompt template") do |value|
498
+ options[:no_context_template] = value
499
+ end
500
+ parser.on("--with-context-template TEXT", "Override the with-context prompt template") do |value|
501
+ options[:with_context_template] = value
502
+ end
503
+ parser.on("--prompt-templates-file PATH", "Load prompt templates from a YAML file") do |value|
504
+ options[:prompt_templates_file] = value
505
+ end
351
506
  end
352
- return unless options[:api_key] && options[:api_key_env]
353
507
 
354
- raise UsageError, '--api-key cannot be combined with --api-key-env'
355
- end
508
+ def add_credential_options(parser, options)
509
+ parser.on("--api-key KEY", "Use an API key for this operation") do |value|
510
+ options[:api_key] = value
511
+ end
512
+ parser.on("--api-key-env NAME", "Read the API key from this environment variable") do |value|
513
+ options[:api_key_env] = value
514
+ end
515
+ end
356
516
 
357
- def provider_options(options)
358
- {
359
- api_key: api_key_override(options),
360
- embedding_model: options[:embedding_model],
361
- chat_model: options[:chat_model],
362
- prompt_templates: prompt_templates(options),
363
- }.compact
364
- end
517
+ def add_document_options(parser, options)
518
+ parser.on("--json-file PATH", "Load JSON documents as generation context") do |value|
519
+ options[:json_file] = value
520
+ end
521
+ end
365
522
 
366
- def prompt_templates(options)
367
- templates = if options[:prompt_templates_file]
368
- data = YAML.safe_load_file(
369
- options[:prompt_templates_file],
370
- permitted_classes: [],
371
- permitted_symbols: [],
372
- aliases: true,
373
- )
374
- raise UsageError, 'prompt templates file must contain a mapping' unless data.is_a?(Hash)
375
-
376
- data.transform_keys(&:to_sym)
377
- else
378
- {}
379
- end
523
+ def validate_override_options(options)
524
+ if options[:model] && (options[:embedding_model] || options[:chat_model])
525
+ raise UsageError, "--model cannot be combined with --embedding-model or --chat-model"
526
+ end
527
+ return unless options[:api_key] && options[:api_key_env]
380
528
 
381
- [:system_prompt, :no_context_template, :with_context_template].each do |key|
382
- templates[key] = options[key] if options[key]
529
+ raise UsageError, "--api-key cannot be combined with --api-key-env"
383
530
  end
384
- templates.empty? ? nil : templates
385
- rescue Errno::ENOENT
386
- raise UsageError, "prompt templates file not found: #{options[:prompt_templates_file]}"
387
- rescue Psych::SyntaxError => e
388
- raise UsageError, "invalid prompt templates YAML: #{e.message}"
389
- end
390
-
391
- def api_key_override(options)
392
- return options[:api_key] if options[:api_key]
393
- return ENV.fetch(options[:api_key_env]) if options[:api_key_env]
394
531
 
395
- nil
396
- end
532
+ def provider_options(options)
533
+ {
534
+ api_key: api_key_override(options),
535
+ embedding_model: options[:embedding_model],
536
+ chat_model: options[:chat_model],
537
+ prompt_templates: prompt_templates(options)
538
+ }.compact
539
+ end
397
540
 
398
- def output_health(results, format)
399
- if format == 'json'
400
- print_json(results)
401
- else
402
- results.each do |name, result|
403
- @output.puts '%<name>-12s %<status>s' % { name: name, status: result[:status] || 'unknown' }
541
+ def prompt_templates(options)
542
+ templates = if options[:prompt_templates_file]
543
+ data = YAML.safe_load_file(
544
+ options[:prompt_templates_file],
545
+ permitted_classes: [],
546
+ permitted_symbols: [],
547
+ aliases: true
548
+ )
549
+ raise UsageError, "prompt templates file must contain a mapping" unless data.is_a?(Hash)
550
+
551
+ data.transform_keys(&:to_sym)
552
+ else
553
+ {}
554
+ end
555
+
556
+ %i[system_prompt no_context_template with_context_template].each do |key|
557
+ templates[key] = options[key] if options[key]
404
558
  end
559
+ templates.empty? ? nil : templates
560
+ rescue Errno::ENOENT
561
+ raise UsageError, "prompt templates file not found: #{options[:prompt_templates_file]}"
562
+ rescue Psych::SyntaxError => e
563
+ raise UsageError, "invalid prompt templates YAML: #{e.message}"
405
564
  end
406
- end
407
565
 
408
- def read_text(arguments, label)
409
- return arguments.join(' ') unless arguments.empty?
410
- return @input.read unless @input.tty?
566
+ def api_key_override(options)
567
+ return options[:api_key] if options[:api_key]
568
+ return ENV.fetch(options[:api_key_env]) if options[:api_key_env]
411
569
 
412
- raise UsageError, "missing #{label}; provide it as an argument or through stdin"
413
- end
570
+ nil
571
+ end
414
572
 
415
- def print_json(value)
416
- @output.puts JSON.generate(value)
417
- end
573
+ def output_health(results, format)
574
+ if format == "json"
575
+ print_json(results)
576
+ else
577
+ results.each do |name, result|
578
+ @output.puts format("%<name>-12s %<status>s", name: name, status: result[:status] || "unknown")
579
+ end
580
+ end
581
+ end
418
582
 
419
- def print_help(status)
420
- @output.puts <<~HELP
421
- Usage: prescient COMMAND [options]
422
-
423
- Commands:
424
- providers List configured providers
425
- health Check provider health
426
- generate TEXT Generate a text response
427
- embed TEXT Generate an embedding
428
- config validate Validate the current configuration
429
- config example Generate an annotated YAML configuration example
430
-
431
- Options:
432
- --config PATH Load configuration from a YAML file
433
- --provider NAME Select a provider
434
- --model NAME Override the selected operation's model
435
- --chat-model NAME Override the chat model
436
- --embedding-model NAME Override the embedding model
437
- --system-prompt TEXT Override the system prompt
438
- --no-context-template TEXT
439
- Override the no-context prompt template
440
- --with-context-template TEXT
441
- Override the with-context prompt template
442
- --prompt-templates-file PATH
443
- Load prompt templates from a YAML file
444
- --api-key KEY Use an API key for the operation
445
- --api-key-env NAME Read the API key from an environment variable
446
- --format FORMAT Use text or json output
447
- HELP
448
- status
449
- end
583
+ def read_text(arguments, label)
584
+ return arguments.join(" ") unless arguments.empty?
585
+ return @input.read unless @input.tty?
450
586
 
451
- def extract_global_config_path
452
- config_path = nil
453
- filtered_arguments = []
454
- index = 0
587
+ raise UsageError, "missing #{label}; provide it as an argument or through stdin"
588
+ end
455
589
 
456
- while index < @arguments.length
457
- argument = @arguments[index]
458
- if argument == '--config'
459
- value = @arguments[index + 1]
460
- raise UsageError, '--config requires a path' unless value
590
+ def print_json(value)
591
+ @output.puts JSON.generate(value)
592
+ end
461
593
 
462
- config_path = value
463
- index += 2
464
- next
465
- end
594
+ def print_help(status)
595
+ @output.puts <<~HELP
596
+ Usage: prescient COMMAND [options]
597
+
598
+ Commands:
599
+ providers List configured providers
600
+ health Check provider health
601
+ generate TEXT Generate a text response
602
+ embed TEXT Generate an embedding
603
+ search TEXT Search with a configured external tool
604
+ agent TEXT Run a bounded agent task
605
+ config validate Validate the current configuration
606
+ config example Generate an annotated YAML configuration example
607
+
608
+ Global options:
609
+ --config PATH Load configuration from a YAML file
610
+ --provider NAME Select a provider
611
+ --model NAME Override the selected operation's model
612
+ --chat-model NAME Override the chat model
613
+ --embedding-model NAME Override the embedding model
614
+ --system-prompt TEXT Override the system prompt
615
+ --no-context-template TEXT
616
+ Override the no-context prompt template
617
+ --with-context-template TEXT
618
+ Override the with-context prompt template
619
+ --prompt-templates-file PATH
620
+ Load prompt templates from a YAML file
621
+ --api-key KEY Use an API key for the operation
622
+ --api-key-env NAME Read the API key from an environment variable
623
+ --format FORMAT Use text or json output
624
+ --json-file PATH Load JSON documents as generation context
625
+
626
+ Search options:
627
+ --tool NAME Select an external tool for search
628
+ --generate Use search results as AI provider context
629
+ --limit COUNT Limit search results
630
+
631
+ Agent options:
632
+ --tool NAME Allow a configured tool (repeatable)
633
+ --max-loops COUNT Maximum agent iterations
634
+ HELP
635
+ status
636
+ end
466
637
 
467
- if argument.start_with?('--config=')
468
- config_path = argument.split('=', 2).last
638
+ def extract_global_config_path
639
+ config_path = nil
640
+ filtered_arguments = []
641
+ index = 0
642
+
643
+ while index < @arguments.length
644
+ argument = @arguments[index]
645
+ if argument == "--config"
646
+ value = @arguments[index + 1]
647
+ raise UsageError, "--config requires a path" unless value
648
+
649
+ config_path = value
650
+ index += 2
651
+ next
652
+ end
653
+
654
+ if argument.start_with?("--config=")
655
+ config_path = argument.split("=", 2).last
656
+ index += 1
657
+ next
658
+ end
659
+
660
+ filtered_arguments << argument
469
661
  index += 1
470
- next
471
662
  end
472
663
 
473
- filtered_arguments << argument
474
- index += 1
664
+ @arguments = filtered_arguments
665
+ config_path
475
666
  end
476
-
477
- @arguments = filtered_arguments
478
- config_path
479
667
  end
668
+ # rubocop:enable Metrics/ClassLength
480
669
  end