rubyn-code 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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -19
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +32 -3
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +56 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +9 -1
  7. data/lib/rubyn_code/agent/loop.rb +7 -0
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +10 -4
  9. data/lib/rubyn_code/agent/tool_processor.rb +21 -1
  10. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  11. data/lib/rubyn_code/auth/token_store.rb +50 -9
  12. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  13. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  14. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  15. data/lib/rubyn_code/cli/app.rb +32 -1
  16. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  17. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  18. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  19. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  20. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  21. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  22. data/lib/rubyn_code/cli/first_run.rb +159 -0
  23. data/lib/rubyn_code/cli/repl.rb +6 -1
  24. data/lib/rubyn_code/cli/repl_commands.rb +2 -1
  25. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  26. data/lib/rubyn_code/cli/repl_setup.rb +36 -0
  27. data/lib/rubyn_code/config/defaults.rb +1 -0
  28. data/lib/rubyn_code/config/schema.json +49 -0
  29. data/lib/rubyn_code/config/settings.rb +7 -4
  30. data/lib/rubyn_code/config/validator.rb +63 -0
  31. data/lib/rubyn_code/context/context_budget.rb +16 -1
  32. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  33. data/lib/rubyn_code/context/manager.rb +37 -3
  34. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  35. data/lib/rubyn_code/hooks/registry.rb +4 -0
  36. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  37. data/lib/rubyn_code/ide/client.rb +110 -0
  38. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  39. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  40. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  41. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  42. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  43. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  44. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  45. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  46. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  47. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  48. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  49. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  50. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  51. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  52. data/lib/rubyn_code/ide/handlers.rb +76 -0
  53. data/lib/rubyn_code/ide/protocol.rb +111 -0
  54. data/lib/rubyn_code/ide/server.rb +186 -0
  55. data/lib/rubyn_code/index/codebase_index.rb +67 -1
  56. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  57. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  58. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  59. data/lib/rubyn_code/llm/client.rb +29 -4
  60. data/lib/rubyn_code/mcp/config.rb +2 -1
  61. data/lib/rubyn_code/memory/search.rb +1 -0
  62. data/lib/rubyn_code/self_test.rb +315 -0
  63. data/lib/rubyn_code/skills/catalog.rb +66 -0
  64. data/lib/rubyn_code/skills/loader.rb +43 -0
  65. data/lib/rubyn_code/tasks/models.rb +1 -0
  66. data/lib/rubyn_code/tools/base.rb +13 -0
  67. data/lib/rubyn_code/tools/bash.rb +5 -0
  68. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  69. data/lib/rubyn_code/tools/executor.rb +61 -6
  70. data/lib/rubyn_code/tools/glob.rb +6 -0
  71. data/lib/rubyn_code/tools/grep.rb +6 -0
  72. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  73. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  74. data/lib/rubyn_code/tools/output_compressor.rb +6 -1
  75. data/lib/rubyn_code/tools/read_file.rb +6 -0
  76. data/lib/rubyn_code/tools/registry.rb +11 -0
  77. data/lib/rubyn_code/tools/write_file.rb +17 -0
  78. data/lib/rubyn_code/version.rb +1 -1
  79. data/lib/rubyn_code.rb +22 -0
  80. data/skills/rubyn_self_test.md +13 -1
  81. metadata +31 -1
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Provider < Base
7
+ def self.command_name = '/provider'
8
+ def self.description = 'Manage providers (/provider add|list|set-key)'
9
+
10
+ USAGE_LINES = [
11
+ 'Usage:',
12
+ ' /provider list List configured providers',
13
+ ' /provider add <name> <base_url> [opts] Add a provider',
14
+ ' /provider set-key <name> <key> Store an API key',
15
+ '',
16
+ 'Options for add:',
17
+ ' --key <api_key> API key (stored securely in tokens.yml)',
18
+ ' --format <openai|anthropic> API format (default: openai)',
19
+ ' --models <m1,m2,...> Comma-separated model names',
20
+ '',
21
+ 'Example:',
22
+ ' /provider add groq https://api.groq.com/openai/v1 --key gsk-xxx --models llama-3.3-70b'
23
+ ].freeze
24
+
25
+ FLAG_KEYS = { '--format' => :api_format, '--env-key' => :env_key, '--key' => :key }.freeze
26
+
27
+ def execute(args, ctx)
28
+ return show_usage(ctx) if args.empty?
29
+
30
+ case args.first
31
+ when 'add' then add_provider(args[1..], ctx)
32
+ when 'list' then list_providers(ctx)
33
+ when 'set-key' then set_key(args[1..], ctx)
34
+ else show_usage(ctx)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def add_provider(args, ctx)
41
+ name = args.shift
42
+ base_url = args.shift
43
+ return ctx.renderer.warning('Usage: /provider add <name> <base_url> [options]') unless name && base_url
44
+
45
+ opts = parse_flags(args)
46
+ persist_provider(name, base_url, opts)
47
+ confirm_added(name, base_url, opts, ctx)
48
+ end
49
+
50
+ def persist_provider(name, base_url, opts)
51
+ Config::Settings.new.add_provider(
52
+ name, base_url: base_url, env_key: opts[:env_key],
53
+ models: opts[:models], api_format: opts[:api_format]
54
+ )
55
+ Auth::TokenStore.save_provider_key(name, opts[:key]) if opts[:key]
56
+ end
57
+
58
+ def confirm_added(name, base_url, opts, ctx) # rubocop:disable Metrics/AbcSize -- sequential output lines
59
+ ctx.renderer.success("Provider '#{name}' added (#{opts[:api_format] || 'openai'} format)")
60
+ ctx.renderer.info(" base_url: #{base_url}")
61
+ ctx.renderer.info(' api_key: stored') if opts[:key]
62
+ ctx.renderer.info(" models: #{opts[:models].join(', ')}") unless opts[:models].empty?
63
+ ctx.renderer.info("Switch with: /model #{name}:#{opts[:models].first || '<model>'}")
64
+ end
65
+
66
+ def set_key(args, ctx)
67
+ name = args[0]
68
+ key = args[1]
69
+ return ctx.renderer.warning('Usage: /provider set-key <name> <key>') unless name && key
70
+
71
+ Auth::TokenStore.save_provider_key(name, key)
72
+ ctx.renderer.success("API key stored for '#{name}'")
73
+ end
74
+
75
+ def list_providers(ctx)
76
+ providers = Config::Settings.new.data['providers']
77
+ return ctx.renderer.info('No providers configured.') unless providers.is_a?(Hash) && providers.any?
78
+
79
+ providers.each { |name, cfg| ctx.renderer.info(" #{format_provider(name, cfg)}") }
80
+ end
81
+
82
+ def format_provider(name, cfg)
83
+ format_label = cfg.is_a?(Hash) && cfg['api_format'] ? " (#{cfg['api_format']})" : ''
84
+ models = extract_models(cfg)
85
+ model_label = models.empty? ? '' : " — #{models.join(', ')}"
86
+ "#{name}#{format_label}#{model_label}"
87
+ end
88
+
89
+ def parse_flags(args)
90
+ opts = { models: [], env_key: nil, api_format: nil, key: nil }
91
+ idx = 0
92
+ idx = parse_single_flag(args, idx, opts) while idx < args.length
93
+ opts
94
+ end
95
+
96
+ def parse_single_flag(args, idx, opts)
97
+ flag = args[idx]
98
+ return idx + 1 unless FLAG_KEYS.key?(flag) || flag == '--models'
99
+ return parse_models_flag(args, idx, opts) if flag == '--models'
100
+
101
+ opts[FLAG_KEYS[flag]] = args[idx + 1]
102
+ idx + 2
103
+ end
104
+
105
+ def parse_models_flag(args, idx, opts)
106
+ opts[:models] = args[idx + 1]&.split(',')&.map(&:strip) || []
107
+ idx + 2
108
+ end
109
+
110
+ def extract_models(cfg)
111
+ raw = cfg.is_a?(Hash) ? cfg['models'] : nil
112
+ return [] unless raw
113
+
114
+ raw.is_a?(Hash) ? raw.values : Array(raw)
115
+ end
116
+
117
+ def show_usage(ctx)
118
+ USAGE_LINES.each { |line| ctx.renderer.info(line) }
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -8,7 +8,13 @@ module RubynCode
8
8
  def self.description = 'Load a skill or list available skills'
9
9
 
10
10
  def execute(args, ctx)
11
- args.first ? load_skill(args.first, ctx) : list_skills(ctx)
11
+ return list_skills(ctx) if args.empty?
12
+
13
+ case args.first
14
+ when 'search' then search_skills(args[1..].join(' '), ctx)
15
+ when 'list' then list_by_category(args[1], ctx)
16
+ else load_skill(args.first, ctx)
17
+ end
12
18
  rescue StandardError => e
13
19
  ctx.renderer.error("Skill error: #{e.message}")
14
20
  end
@@ -22,9 +28,52 @@ module RubynCode
22
28
  end
23
29
 
24
30
  def list_skills(ctx)
25
- skills = ctx.skill_loader.catalog.list
31
+ skills = ctx.skill_loader.catalog.available
26
32
  ctx.renderer.info("Available skills (#{skills.size}):")
27
- skills.each { |skill| puts " /#{skill}" }
33
+ skills.each { |skill| puts " /#{skill[:name]}: #{skill[:description]}" }
34
+ end
35
+
36
+ def search_skills(term, ctx) # rubocop:disable Metrics/AbcSize -- readable sequential display logic
37
+ if term.nil? || term.strip.empty?
38
+ ctx.renderer.warning('Usage: /skill search <term>')
39
+ return
40
+ end
41
+
42
+ results = ctx.skill_loader.catalog.search(term.strip)
43
+ if results.empty?
44
+ ctx.renderer.info("No skills found matching '#{term.strip}'")
45
+ return
46
+ end
47
+
48
+ ctx.renderer.info("Skills matching '#{term.strip}' (#{results.size}):")
49
+ display_entries(results)
50
+ end
51
+
52
+ def list_by_category(category, ctx) # rubocop:disable Metrics/AbcSize -- readable sequential display logic
53
+ catalog = ctx.skill_loader.catalog
54
+ return list_categories(catalog, ctx) if category.nil? || category.strip.empty?
55
+
56
+ results = catalog.by_category(category.strip)
57
+ if results.empty?
58
+ ctx.renderer.info("No skills found in category '#{category.strip}'")
59
+ return
60
+ end
61
+
62
+ ctx.renderer.info("Skills in '#{category.strip}' (#{results.size}):")
63
+ display_entries(results)
64
+ end
65
+
66
+ def list_categories(catalog, ctx)
67
+ categories = catalog.categories
68
+ ctx.renderer.info("Skill categories (#{categories.size}):")
69
+ categories.each { |cat| puts " #{cat}" }
70
+ end
71
+
72
+ def display_entries(entries)
73
+ entries.each do |entry|
74
+ desc = entry[:description].to_s.empty? ? '' : " — #{entry[:description]}"
75
+ puts " /#{entry[:name]}#{desc}"
76
+ end
28
77
  end
29
78
  end
30
79
  end
@@ -29,6 +29,8 @@ module RubynCode
29
29
  @renderer.error("Daemon failed: #{e.message}")
30
30
  RubynCode::Debug.warn(e.backtrace&.first(5)&.join("\n")) if @options[:debug]
31
31
  exit(1)
32
+ ensure
33
+ disconnect_mcp_clients!
32
34
  end
33
35
 
34
36
  private
@@ -37,6 +39,7 @@ module RubynCode
37
39
  ensure_home_dir!
38
40
  ensure_auth!
39
41
  setup_database!
42
+ setup_mcp_servers!
40
43
  display_banner!
41
44
  end
42
45
 
@@ -141,6 +144,39 @@ module RubynCode
141
144
  @renderer.info(" Tasks completed: #{status[:runs_completed]}")
142
145
  @renderer.info(format(' Total cost: $%.4f', status[:total_cost]))
143
146
  end
147
+
148
+ # ── MCP Server Wiring ─────────────────────────────────────────
149
+
150
+ def setup_mcp_servers!
151
+ @mcp_clients = []
152
+ server_configs = MCP::Config.load(@project_root)
153
+ return if server_configs.empty?
154
+
155
+ server_configs.each do |config|
156
+ connect_mcp_server(config)
157
+ end
158
+ end
159
+
160
+ def connect_mcp_server(config)
161
+ client = MCP::Client.from_config(config)
162
+ client.connect!
163
+ MCP::ToolBridge.bridge(client)
164
+ @mcp_clients << client
165
+ @renderer.info(" MCP server '#{config[:name]}' connected (#{client.tools.size} tools)")
166
+ rescue StandardError => e
167
+ warn "[MCP] Failed to connect '#{config[:name]}': #{e.message}"
168
+ end
169
+
170
+ def disconnect_mcp_clients!
171
+ return if @mcp_clients.nil? || @mcp_clients.empty?
172
+
173
+ @mcp_clients.each do |client|
174
+ client.disconnect!
175
+ rescue StandardError => e
176
+ warn "[MCP] Error disconnecting '#{client.name}': #{e.message}"
177
+ end
178
+ @mcp_clients.clear
179
+ end
144
180
  end
145
181
  end
146
182
  end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-prompt'
4
+ require 'yaml'
5
+ require 'fileutils'
6
+
7
+ module RubynCode
8
+ module CLI
9
+ # Guided first-run setup wizard.
10
+ #
11
+ # Runs when no ~/.rubyn-code/config.yml exists (first launch).
12
+ # Walks the user through provider selection, API key configuration,
13
+ # and default budget setup.
14
+ #
15
+ # Skippable via --skip-setup flag or RUBYN_SKIP_SETUP=1 env var.
16
+ class FirstRun
17
+ PROVIDERS = {
18
+ 'Anthropic (recommended)' => 'anthropic',
19
+ 'OpenAI' => 'openai',
20
+ 'Other (configure later)' => 'other'
21
+ }.freeze
22
+
23
+ DEFAULT_BUDGET = 5.0
24
+
25
+ def initialize(config_path: Config::Defaults::CONFIG_FILE, prompt: nil)
26
+ @config_path = config_path
27
+ @tty_prompt = prompt
28
+ end
29
+
30
+ # Returns true if first-run setup should be triggered.
31
+ def self.needed?(config_path: Config::Defaults::CONFIG_FILE)
32
+ !File.exist?(config_path)
33
+ end
34
+
35
+ # Returns true if the user opted to skip setup.
36
+ def self.skipped?(skip_flag: false)
37
+ skip_flag || ENV['RUBYN_SKIP_SETUP'] == '1'
38
+ end
39
+
40
+ def run
41
+ display_welcome
42
+ provider = ask_provider
43
+ configure_api_key(provider)
44
+ budget = ask_budget
45
+ write_config(provider, budget)
46
+ display_summary
47
+ end
48
+
49
+ private
50
+
51
+ def tty_prompt
52
+ @tty_prompt ||= TTY::Prompt.new
53
+ end
54
+
55
+ def display_welcome
56
+ puts
57
+ puts "\e[1;36m#{'=' * 50}\e[0m"
58
+ puts "\e[1;36m Welcome to Rubyn Code!\e[0m"
59
+ puts "\e[1;36m Ruby & Rails Agentic Coding Assistant\e[0m"
60
+ puts "\e[1;36m#{'=' * 50}\e[0m"
61
+ puts
62
+ puts " Let's get you set up. This will only take a moment."
63
+ puts
64
+ end
65
+
66
+ def ask_provider
67
+ tty_prompt.select('Which AI provider would you like to use?', PROVIDERS)
68
+ end
69
+
70
+ def configure_api_key(provider)
71
+ case provider
72
+ when 'anthropic'
73
+ configure_anthropic
74
+ when 'openai'
75
+ configure_openai
76
+ when 'other'
77
+ puts
78
+ puts ' You can configure a custom provider later in ~/.rubyn-code/config.yml'
79
+ puts
80
+ end
81
+ end
82
+
83
+ def configure_anthropic
84
+ puts
85
+ puts ' Anthropic authentication options:'
86
+ puts ' 1. Set ANTHROPIC_API_KEY environment variable'
87
+ puts ' 2. Run `rubyn-code --auth` for OAuth (if you have a Claude account)'
88
+ puts
89
+
90
+ has_key = ENV.key?('ANTHROPIC_API_KEY')
91
+ if has_key
92
+ puts " \e[32m✓\e[0m ANTHROPIC_API_KEY is already set."
93
+ else
94
+ puts " \e[33m!\e[0m ANTHROPIC_API_KEY is not set."
95
+ puts ' Add it to your shell profile or set it before running rubyn-code.'
96
+ end
97
+ puts
98
+ end
99
+
100
+ def configure_openai
101
+ puts
102
+ has_key = ENV.key?('OPENAI_API_KEY')
103
+ if has_key
104
+ puts " \e[32m✓\e[0m OPENAI_API_KEY is already set."
105
+ else
106
+ puts " \e[33m!\e[0m OPENAI_API_KEY is not set."
107
+ puts ' Add it to your shell profile or set it before running rubyn-code.'
108
+ end
109
+ puts
110
+ end
111
+
112
+ def ask_budget
113
+ tty_prompt.ask(
114
+ 'Session budget in USD?',
115
+ default: DEFAULT_BUDGET.to_s,
116
+ convert: :float
117
+ )
118
+ end
119
+
120
+ def write_config(provider, budget)
121
+ dir = File.dirname(@config_path)
122
+ FileUtils.mkdir_p(dir, mode: 0o700) unless File.directory?(dir)
123
+
124
+ model = default_model(provider)
125
+ data = {
126
+ 'provider' => provider == 'other' ? 'anthropic' : provider,
127
+ 'model' => model,
128
+ 'session_budget_usd' => budget,
129
+ 'providers' => Config::Settings::DEFAULT_PROVIDER_MODELS.transform_values(&:dup)
130
+ }
131
+
132
+ File.write(@config_path, YAML.dump(data))
133
+ File.chmod(0o600, @config_path)
134
+ end
135
+
136
+ def default_model(provider)
137
+ return 'gpt-5.4' if provider == 'openai'
138
+
139
+ 'claude-opus-4-6'
140
+ end
141
+
142
+ def display_summary
143
+ puts
144
+ puts "\e[1;32m Setup complete!\e[0m"
145
+ puts
146
+ puts ' Quick-start commands:'
147
+ puts ' /help — Show all available commands'
148
+ puts ' /skill — List coding skills'
149
+ puts ' /doctor — Check environment health'
150
+ puts ' /cost — View session costs'
151
+ puts ' /compact — Compress conversation context'
152
+ puts ' /quit — Exit'
153
+ puts
154
+ puts " Config saved to: #{@config_path}"
155
+ puts
156
+ end
157
+ end
158
+ end
159
+ end
@@ -98,7 +98,7 @@ module RubynCode
98
98
  @stream_formatter&.feed(text)
99
99
  end
100
100
 
101
- def handle_message(input)
101
+ def handle_message(input) # rubocop:disable Metrics/AbcSize -- sequential steps with interrupt rescue
102
102
  @spinner.start
103
103
  @streaming_first_chunk = true
104
104
 
@@ -113,6 +113,11 @@ module RubynCode
113
113
  puts
114
114
  end
115
115
 
116
+ save_session!
117
+ rescue Interrupt
118
+ @spinner.stop
119
+ puts
120
+ @renderer.warning('Interrupted — session state preserved')
116
121
  save_session!
117
122
  rescue BudgetExceededError => e
118
123
  @spinner.error
@@ -21,7 +21,8 @@ module RubynCode
21
21
  Commands::Version, Commands::Review, Commands::Resume,
22
22
  Commands::Spawn, Commands::Doctor, Commands::Tokens,
23
23
  Commands::Plan, Commands::ContextInfo, Commands::Diff,
24
- Commands::Model, Commands::NewSession
24
+ Commands::Model, Commands::NewSession, Commands::Mcp,
25
+ Commands::Provider
25
26
  ].each { |cmd| @command_registry.register(cmd) }
26
27
  end
27
28
 
@@ -48,6 +48,7 @@ module RubynCode
48
48
  @renderer.info('Saving session...')
49
49
  save_session!
50
50
  @background_worker&.shutdown!
51
+ disconnect_mcp_clients!
51
52
  extract_learnings_if_needed
52
53
  decay_instincts
53
54
  @renderer.info("Session saved. Rubyn out. \u270C\uFE0F")
@@ -11,6 +11,7 @@ module RubynCode
11
11
  setup_services!
12
12
  setup_executor_callbacks!
13
13
  setup_hooks!
14
+ setup_mcp_servers!
14
15
  setup_agent_loop!
15
16
  end
16
17
 
@@ -140,6 +141,41 @@ module RubynCode
140
141
  dirs << user_skills if Dir.exist?(user_skills)
141
142
  dirs
142
143
  end
144
+
145
+ # ── MCP Server Wiring ─────────────────────────────────────────
146
+
147
+ def setup_mcp_servers!
148
+ @mcp_clients = []
149
+ server_configs = MCP::Config.load(@project_root)
150
+ return if server_configs.empty?
151
+
152
+ server_configs.each do |config|
153
+ connect_mcp_server(config)
154
+ end
155
+
156
+ at_exit { disconnect_mcp_clients! unless defined?(RSpec) }
157
+ end
158
+
159
+ def connect_mcp_server(config)
160
+ client = MCP::Client.from_config(config)
161
+ client.connect!
162
+ MCP::ToolBridge.bridge(client)
163
+ @mcp_clients << client
164
+ @renderer.info("MCP server '#{config[:name]}' connected (#{client.tools.size} tools)")
165
+ rescue StandardError => e
166
+ warn "[MCP] Failed to connect '#{config[:name]}': #{e.message}"
167
+ end
168
+
169
+ def disconnect_mcp_clients!
170
+ return if @mcp_clients.nil? || @mcp_clients.empty?
171
+
172
+ @mcp_clients.each do |client|
173
+ client.disconnect!
174
+ rescue StandardError => e
175
+ warn "[MCP] Error disconnecting '#{client.name}': #{e.message}"
176
+ end
177
+ @mcp_clients.clear
178
+ end
143
179
  end
144
180
  end
145
181
  end
@@ -12,6 +12,7 @@ module RubynCode
12
12
 
13
13
  DEFAULT_PROVIDER = 'anthropic'
14
14
  DEFAULT_MODEL = 'claude-opus-4-6'
15
+ MODEL_MODE = 'auto' # 'auto' or 'manual'
15
16
  MAX_ITERATIONS = 200
16
17
  MAX_SUB_AGENT_ITERATIONS = 200
17
18
  MAX_EXPLORE_AGENT_ITERATIONS = 200
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "provider": {
6
+ "type": "string",
7
+ "minLength": 1
8
+ },
9
+ "model": {
10
+ "type": "string",
11
+ "minLength": 1
12
+ },
13
+ "model_mode": {
14
+ "type": "string",
15
+ "enum": ["auto", "manual"]
16
+ },
17
+ "max_iterations": {
18
+ "type": "integer",
19
+ "minimum": 1,
20
+ "maximum": 1000
21
+ },
22
+ "max_sub_agent_iterations": {
23
+ "type": "integer",
24
+ "minimum": 1,
25
+ "maximum": 500
26
+ },
27
+ "max_output_chars": {
28
+ "type": "integer",
29
+ "minimum": 1000,
30
+ "maximum": 1000000
31
+ },
32
+ "context_threshold_tokens": {
33
+ "type": "integer",
34
+ "minimum": 10000,
35
+ "maximum": 200000
36
+ },
37
+ "session_budget_usd": {
38
+ "type": "number",
39
+ "minimum": 0.1,
40
+ "maximum": 100
41
+ },
42
+ "daily_budget_usd": {
43
+ "type": "number",
44
+ "minimum": 0.5,
45
+ "maximum": 500
46
+ }
47
+ },
48
+ "additionalProperties": true
49
+ }
@@ -10,7 +10,7 @@ module RubynCode
10
10
  class LoadError < StandardError; end
11
11
 
12
12
  CONFIGURABLE_KEYS = %i[
13
- provider model max_iterations max_sub_agent_iterations max_output_chars
13
+ provider model model_mode max_iterations max_sub_agent_iterations max_output_chars
14
14
  context_threshold_tokens micro_compact_keep_recent
15
15
  poll_interval idle_timeout
16
16
  session_budget_usd daily_budget_usd
@@ -21,6 +21,7 @@ module RubynCode
21
21
  DEFAULT_MAP = {
22
22
  provider: Defaults::DEFAULT_PROVIDER,
23
23
  model: Defaults::DEFAULT_MODEL,
24
+ model_mode: Defaults::MODEL_MODE,
24
25
  max_iterations: Defaults::MAX_ITERATIONS,
25
26
  max_sub_agent_iterations: Defaults::MAX_SUB_AGENT_ITERATIONS,
26
27
  max_output_chars: Defaults::MAX_OUTPUT_CHARS,
@@ -114,10 +115,11 @@ module RubynCode
114
115
  # @param env_key [String, nil] environment variable for the API key
115
116
  # @param models [Array<String>] available model names
116
117
  # @param pricing [Hash] model => [input_rate, output_rate]
117
- def add_provider(name, base_url:, env_key: nil, models: [], pricing: {})
118
+ # @param api_format [String, nil] API format ('openai' or 'anthropic')
119
+ def add_provider(name, base_url:, env_key: nil, models: [], pricing: {}, api_format: nil) # rubocop:disable Metrics/ParameterLists -- all optional kwargs with defaults
118
120
  @data['providers'] ||= {}
119
121
  @data['providers'][name.to_s] = build_provider_hash(
120
- base_url: base_url, env_key: env_key, models: models, pricing: pricing
122
+ base_url: base_url, env_key: env_key, models: models, pricing: pricing, api_format: api_format
121
123
  )
122
124
  save!
123
125
  end
@@ -176,8 +178,9 @@ module RubynCode
176
178
  nil
177
179
  end
178
180
 
179
- def build_provider_hash(base_url:, env_key:, models:, pricing:)
181
+ def build_provider_hash(base_url:, env_key:, models:, pricing:, api_format: nil)
180
182
  hash = { 'base_url' => base_url }
183
+ hash['api_format'] = api_format if api_format
181
184
  hash['env_key'] = env_key if env_key
182
185
  hash['models'] = models unless models.empty?
183
186
  hash['pricing'] = pricing unless pricing.empty?
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'json_schemer'
5
+
6
+ module RubynCode
7
+ module Config
8
+ class Validator
9
+ SCHEMA_PATH = File.expand_path('schema.json', __dir__)
10
+
11
+ def initialize
12
+ @raw_schema = JSON.parse(File.read(SCHEMA_PATH))
13
+ @schemer = JSONSchemer.schema(@raw_schema)
14
+ end
15
+
16
+ # Validates a single config key/value pair against the schema.
17
+ #
18
+ # @param key [String] the config key
19
+ # @param value [Object] the value to validate
20
+ # @return [Hash] { valid: true/false, errors: [String] }
21
+ def validate(key, value)
22
+ # If the key has no schema definition, accept any value
23
+ properties = @raw_schema.fetch('properties', {})
24
+ unless properties.key?(key.to_s)
25
+ return { valid: true, errors: [] }
26
+ end
27
+
28
+ doc = { key.to_s => value }
29
+ errors = @schemer.validate(doc).select { |e| e['data_pointer'] == "/#{key}" }
30
+
31
+ if errors.empty?
32
+ { valid: true, errors: [] }
33
+ else
34
+ messages = errors.map { |e| format_error(key, e) }
35
+ { valid: false, errors: messages }
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def format_error(key, error)
42
+ detail = error['type']
43
+ schema_node = error.fetch('schema', {})
44
+
45
+ parts = ["#{key}: invalid value"]
46
+ parts << "(expected #{detail})" if detail
47
+
48
+ if schema_node.key?('minimum') || schema_node.key?('maximum')
49
+ range_parts = []
50
+ range_parts << "min #{schema_node['minimum']}" if schema_node.key?('minimum')
51
+ range_parts << "max #{schema_node['maximum']}" if schema_node.key?('maximum')
52
+ parts << "[#{range_parts.join(', ')}]"
53
+ end
54
+
55
+ if schema_node.key?('enum')
56
+ parts << "allowed: #{schema_node['enum'].join(', ')}"
57
+ end
58
+
59
+ parts.join(' ')
60
+ end
61
+ end
62
+ end
63
+ end