rubyn-code 0.3.0 → 0.5.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 +263 -21
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/conversation.rb +34 -4
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
- data/lib/rubyn_code/agent/llm_caller.rb +11 -1
- data/lib/rubyn_code/agent/loop.rb +14 -3
- data/lib/rubyn_code/agent/response_modes.rb +2 -1
- data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
- data/lib/rubyn_code/agent/tool_processor.rb +25 -3
- 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 +116 -11
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
- data/lib/rubyn_code/cli/commands/list_skills.rb +149 -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 +124 -0
- data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
- data/lib/rubyn_code/cli/commands/skill.rb +54 -3
- data/lib/rubyn_code/cli/commands/skills.rb +104 -0
- 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 +15 -0
- data/lib/rubyn_code/cli/repl_commands.rb +3 -1
- data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
- data/lib/rubyn_code/cli/repl_setup.rb +74 -1
- data/lib/rubyn_code/config/defaults.rb +3 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +12 -6
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/context_budget.rb +18 -2
- 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 +218 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -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 +112 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +69 -2
- data/lib/rubyn_code/learning/extractor.rb +4 -2
- 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/llm/model_router.rb +2 -1
- data/lib/rubyn_code/mcp/config.rb +2 -1
- data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
- data/lib/rubyn_code/output/diff_renderer.rb +3 -2
- data/lib/rubyn_code/self_test.rb +316 -0
- data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
- data/lib/rubyn_code/skills/catalog.rb +76 -0
- data/lib/rubyn_code/skills/document.rb +8 -2
- data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/skills/matcher.rb +89 -0
- data/lib/rubyn_code/skills/pack_context.rb +163 -0
- data/lib/rubyn_code/skills/pack_installer.rb +194 -0
- data/lib/rubyn_code/skills/pack_manager.rb +230 -0
- data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
- data/lib/rubyn_code/skills/registry_client.rb +241 -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 +65 -8
- data/lib/rubyn_code/tools/glob.rb +6 -0
- data/lib/rubyn_code/tools/grep.rb +7 -0
- data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
- data/lib/rubyn_code/tools/load_skill.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +9 -7
- data/lib/rubyn_code/tools/read_file.rb +6 -0
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/review_pr.rb +15 -4
- data/lib/rubyn_code/tools/web_search.rb +2 -1
- data/lib/rubyn_code/tools/write_file.rb +17 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +34 -0
- data/skills/rubyn_self_test.md +88 -1
- metadata +43 -1
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
# -- sequential output lines
|
|
59
|
+
def confirm_added(name, base_url, opts, ctx)
|
|
60
|
+
ctx.renderer.success("Provider '#{name}' added (#{opts[:api_format] || 'openai'} format)")
|
|
61
|
+
ctx.renderer.info(" base_url: #{base_url}")
|
|
62
|
+
ctx.renderer.info(' api_key: stored') if opts[:key]
|
|
63
|
+
ctx.renderer.info(" models: #{opts[:models].join(', ')}") unless opts[:models].empty?
|
|
64
|
+
ctx.renderer.info("Switch with: /model #{name}:#{opts[:models].first || '<model>'}")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def set_key(args, ctx)
|
|
68
|
+
name = args[0]
|
|
69
|
+
key = args[1]
|
|
70
|
+
return ctx.renderer.warning('Usage: /provider set-key <name> <key>') unless name && key
|
|
71
|
+
|
|
72
|
+
Auth::TokenStore.save_provider_key(name, key)
|
|
73
|
+
ctx.renderer.success("API key stored for '#{name}'")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def list_providers(ctx)
|
|
77
|
+
providers = Config::Settings.new.data['providers']
|
|
78
|
+
return ctx.renderer.info('No providers configured.') unless providers.is_a?(Hash) && providers.any?
|
|
79
|
+
|
|
80
|
+
providers.each { |name, cfg| ctx.renderer.info(" #{format_provider(name, cfg)}") }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def format_provider(name, cfg)
|
|
84
|
+
format_label = cfg.is_a?(Hash) && cfg['api_format'] ? " (#{cfg['api_format']})" : ''
|
|
85
|
+
models = extract_models(cfg)
|
|
86
|
+
model_label = models.empty? ? '' : " — #{models.join(', ')}"
|
|
87
|
+
"#{name}#{format_label}#{model_label}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse_flags(args)
|
|
91
|
+
opts = { models: [], env_key: nil, api_format: nil, key: nil }
|
|
92
|
+
idx = 0
|
|
93
|
+
idx = parse_single_flag(args, idx, opts) while idx < args.length
|
|
94
|
+
opts
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def parse_single_flag(args, idx, opts)
|
|
98
|
+
flag = args[idx]
|
|
99
|
+
return idx + 1 unless FLAG_KEYS.key?(flag) || flag == '--models'
|
|
100
|
+
return parse_models_flag(args, idx, opts) if flag == '--models'
|
|
101
|
+
|
|
102
|
+
opts[FLAG_KEYS[flag]] = args[idx + 1]
|
|
103
|
+
idx + 2
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse_models_flag(args, idx, opts)
|
|
107
|
+
opts[:models] = args[idx + 1]&.split(',')&.map(&:strip) || []
|
|
108
|
+
idx + 2
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def extract_models(cfg)
|
|
112
|
+
raw = cfg.is_a?(Hash) ? cfg['models'] : nil
|
|
113
|
+
return [] unless raw
|
|
114
|
+
|
|
115
|
+
raw.is_a?(Hash) ? raw.values : Array(raw)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def show_usage(ctx)
|
|
119
|
+
USAGE_LINES.each { |line| ctx.renderer.info(line) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
class RemoveSkills < Base
|
|
7
|
+
def self.command_name = '/remove-skills'
|
|
8
|
+
def self.description = 'Remove installed skill packs'
|
|
9
|
+
|
|
10
|
+
def execute(args, ctx)
|
|
11
|
+
if args.empty?
|
|
12
|
+
ctx.renderer.warning('Usage: /remove-skills <pack-name> [pack-name ...]')
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
pack_manager = RubynCode::Skills::PackManager.new
|
|
17
|
+
|
|
18
|
+
args.each { |name| remove_pack(name, pack_manager, ctx) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def remove_pack(name, pack_manager, ctx)
|
|
24
|
+
unless pack_manager.installed?(name)
|
|
25
|
+
ctx.renderer.warning("Pack '#{name}' is not installed.")
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
pack_manager.remove(name)
|
|
30
|
+
ctx.renderer.info("Removed skill pack '#{name}'.")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
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,54 @@ 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
|
+
# -- readable sequential display logic
|
|
37
|
+
def search_skills(term, ctx)
|
|
38
|
+
if term.nil? || term.strip.empty?
|
|
39
|
+
ctx.renderer.warning('Usage: /skill search <term>')
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
results = ctx.skill_loader.catalog.search(term.strip)
|
|
44
|
+
if results.empty?
|
|
45
|
+
ctx.renderer.info("No skills found matching '#{term.strip}'")
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
ctx.renderer.info("Skills matching '#{term.strip}' (#{results.size}):")
|
|
50
|
+
display_entries(results)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# -- readable sequential display logic
|
|
54
|
+
def list_by_category(category, ctx)
|
|
55
|
+
catalog = ctx.skill_loader.catalog
|
|
56
|
+
return list_categories(catalog, ctx) if category.nil? || category.strip.empty?
|
|
57
|
+
|
|
58
|
+
results = catalog.by_category(category.strip)
|
|
59
|
+
if results.empty?
|
|
60
|
+
ctx.renderer.info("No skills found in category '#{category.strip}'")
|
|
61
|
+
return
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
ctx.renderer.info("Skills in '#{category.strip}' (#{results.size}):")
|
|
65
|
+
display_entries(results)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def list_categories(catalog, ctx)
|
|
69
|
+
categories = catalog.categories
|
|
70
|
+
ctx.renderer.info("Skill categories (#{categories.size}):")
|
|
71
|
+
categories.each { |cat| puts " #{cat}" }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def display_entries(entries)
|
|
75
|
+
entries.each do |entry|
|
|
76
|
+
desc = entry[:description].to_s.empty? ? '' : " — #{entry[:description]}"
|
|
77
|
+
puts " /#{entry[:name]}#{desc}"
|
|
78
|
+
end
|
|
28
79
|
end
|
|
29
80
|
end
|
|
30
81
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
class Skills < Base
|
|
7
|
+
def self.command_name = '/skills'
|
|
8
|
+
def self.description = 'List installed skill packs or browse the registry'
|
|
9
|
+
|
|
10
|
+
def execute(args, ctx)
|
|
11
|
+
case args.first
|
|
12
|
+
when 'search' then search_registry(args[1..].join(' '), ctx)
|
|
13
|
+
when 'available' then list_available(ctx)
|
|
14
|
+
when nil, 'list' then list_installed(ctx)
|
|
15
|
+
else
|
|
16
|
+
ctx.renderer.warning(
|
|
17
|
+
"Unknown subcommand '#{args.first}'. Try: /skills, /skills available, /skills search <term>"
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
rescue RubynCode::Skills::RegistryError => e
|
|
21
|
+
ctx.renderer.error(e.message)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def list_installed(ctx)
|
|
27
|
+
packs = RubynCode::Skills::PackManager.new.installed
|
|
28
|
+
|
|
29
|
+
if packs.empty?
|
|
30
|
+
ctx.renderer.info(
|
|
31
|
+
'No skill packs installed. Use /skills available to browse or /install-skills <name> to install.'
|
|
32
|
+
)
|
|
33
|
+
return
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
ctx.renderer.info("Installed skill packs (#{packs.size}):")
|
|
37
|
+
packs.each { |pack| puts " #{format_installed_pack(pack)}" }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def list_available(ctx)
|
|
41
|
+
ctx.renderer.info('Fetching available packs from registry...')
|
|
42
|
+
result = RubynCode::Skills::RegistryClient.new.fetch_catalog
|
|
43
|
+
packs = result[:data]
|
|
44
|
+
return ctx.renderer.info('No packs found in the registry.') unless valid_results?(packs)
|
|
45
|
+
|
|
46
|
+
pack_manager = RubynCode::Skills::PackManager.new
|
|
47
|
+
ctx.renderer.info("Available skill packs (#{packs.size}):")
|
|
48
|
+
packs.each { |pack| puts " #{format_available_pack(pack, pack_manager)}" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def search_registry(term, ctx)
|
|
52
|
+
if term.nil? || term.strip.empty?
|
|
53
|
+
ctx.renderer.warning('Usage: /skills search <term>')
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
query = term.strip
|
|
58
|
+
ctx.renderer.info("Searching registry for '#{query}'...")
|
|
59
|
+
result = RubynCode::Skills::RegistryClient.new.search_packs(query)
|
|
60
|
+
packs = result[:data]
|
|
61
|
+
|
|
62
|
+
unless valid_results?(packs)
|
|
63
|
+
ctx.renderer.info("No packs found matching '#{query}'.")
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
ctx.renderer.info("Packs matching '#{query}' (#{packs.size}):")
|
|
68
|
+
packs.each { |pack| puts " #{format_pack_line(pack)}" }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def valid_results?(results)
|
|
72
|
+
results.is_a?(Array) && !results.empty?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def format_installed_pack(pack)
|
|
76
|
+
version = pack[:version] ? " v#{pack[:version]}" : ''
|
|
77
|
+
desc = pack[:description].to_s.empty? ? '' : " — #{pack[:description]}"
|
|
78
|
+
"#{pack[:name]}#{version}#{desc}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def format_available_pack(pack, pack_manager)
|
|
82
|
+
name = pack_name(pack)
|
|
83
|
+
installed = pack_manager.installed?(name) ? ' [installed]' : ''
|
|
84
|
+
"#{format_pack_line(pack)}#{installed}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def format_pack_line(pack)
|
|
88
|
+
name = pack_name(pack)
|
|
89
|
+
desc = pack_description(pack)
|
|
90
|
+
label = desc.empty? ? '' : " — #{desc}"
|
|
91
|
+
"#{name}#{label}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def pack_name(pack)
|
|
95
|
+
pack[:name] || pack['name']
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def pack_description(pack)
|
|
99
|
+
(pack[:description] || pack['description']).to_s
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
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
|
@@ -34,6 +34,7 @@ module RubynCode
|
|
|
34
34
|
|
|
35
35
|
@renderer.welcome
|
|
36
36
|
@version_check.notify
|
|
37
|
+
check_skill_suggestions!
|
|
37
38
|
|
|
38
39
|
at_exit { shutdown! }
|
|
39
40
|
|
|
@@ -44,6 +45,14 @@ module RubynCode
|
|
|
44
45
|
|
|
45
46
|
private
|
|
46
47
|
|
|
48
|
+
def check_skill_suggestions!
|
|
49
|
+
suggest = Skills::AutoSuggest.new(project_root: @project_root)
|
|
50
|
+
message = suggest.check
|
|
51
|
+
@renderer.info(message) if message
|
|
52
|
+
rescue StandardError
|
|
53
|
+
# Never block session start on suggestion failure
|
|
54
|
+
end
|
|
55
|
+
|
|
47
56
|
def run_input_loop
|
|
48
57
|
while @running
|
|
49
58
|
begin
|
|
@@ -98,6 +107,7 @@ module RubynCode
|
|
|
98
107
|
@stream_formatter&.feed(text)
|
|
99
108
|
end
|
|
100
109
|
|
|
110
|
+
# -- sequential steps with interrupt rescue
|
|
101
111
|
def handle_message(input)
|
|
102
112
|
@spinner.start
|
|
103
113
|
@streaming_first_chunk = true
|
|
@@ -113,6 +123,11 @@ module RubynCode
|
|
|
113
123
|
puts
|
|
114
124
|
end
|
|
115
125
|
|
|
126
|
+
save_session!
|
|
127
|
+
rescue Interrupt
|
|
128
|
+
@spinner.stop
|
|
129
|
+
puts
|
|
130
|
+
@renderer.warning('Interrupted — session state preserved')
|
|
116
131
|
save_session!
|
|
117
132
|
rescue BudgetExceededError => e
|
|
118
133
|
@spinner.error
|
|
@@ -21,7 +21,9 @@ 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, Commands::InstallSkills,
|
|
26
|
+
Commands::RemoveSkills, Commands::Skills
|
|
25
27
|
].each { |cmd| @command_registry.register(cmd) }
|
|
26
28
|
end
|
|
27
29
|
|