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.
- checksums.yaml +4 -4
- data/README.md +77 -19
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/conversation.rb +32 -3
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +56 -3
- data/lib/rubyn_code/agent/llm_caller.rb +9 -1
- data/lib/rubyn_code/agent/loop.rb +7 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +10 -4
- data/lib/rubyn_code/agent/tool_processor.rb +21 -1
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/token_store.rb +50 -9
- data/lib/rubyn_code/autonomous/daemon.rb +117 -14
- data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
- data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
- data/lib/rubyn_code/cli/app.rb +32 -1
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +32 -2
- data/lib/rubyn_code/cli/commands/provider.rb +123 -0
- data/lib/rubyn_code/cli/commands/skill.rb +52 -3
- data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/repl.rb +6 -1
- data/lib/rubyn_code/cli/repl_commands.rb +2 -1
- data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
- data/lib/rubyn_code/cli/repl_setup.rb +36 -0
- data/lib/rubyn_code/config/defaults.rb +1 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +7 -4
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/context_budget.rb +16 -1
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/manager.rb +37 -3
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +111 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +67 -1
- data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
- data/lib/rubyn_code/llm/client.rb +29 -4
- data/lib/rubyn_code/mcp/config.rb +2 -1
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/base.rb +13 -0
- data/lib/rubyn_code/tools/bash.rb +5 -0
- data/lib/rubyn_code/tools/edit_file.rb +62 -5
- data/lib/rubyn_code/tools/executor.rb +61 -6
- data/lib/rubyn_code/tools/glob.rb +6 -0
- data/lib/rubyn_code/tools/grep.rb +6 -0
- data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
- data/lib/rubyn_code/tools/output_compressor.rb +6 -1
- data/lib/rubyn_code/tools/read_file.rb +6 -0
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/write_file.rb +17 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +22 -0
- data/skills/rubyn_self_test.md +13 -1
- 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
|
-
|
|
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.
|
|
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
|
data/lib/rubyn_code/cli/repl.rb
CHANGED
|
@@ -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
|
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|