rubyn-code 0.2.2 → 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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. metadata +83 -1
@@ -5,31 +5,118 @@ module RubynCode
5
5
  module Commands
6
6
  class Model < Base
7
7
  def self.command_name = '/model'
8
- def self.description = 'Show or switch model (/model [name])'
9
-
10
- KNOWN_MODELS = %w[
11
- claude-haiku-4-5
12
- claude-sonnet-4-20250514
13
- claude-opus-4-20250514
14
- ].freeze
8
+ def self.description = 'Show or switch model (/model [provider:model])'
15
9
 
16
10
  def execute(args, ctx)
17
11
  name = args.first
12
+ return show_current(ctx) unless name
13
+
14
+ provider, model = parse_model_arg(name)
15
+ switch_model(provider, model, ctx)
16
+ end
18
17
 
19
- if name
20
- unless KNOWN_MODELS.include?(name)
21
- ctx.renderer.warning("Unknown model: #{name}")
22
- ctx.renderer.info("Known models: #{KNOWN_MODELS.join(', ')}")
23
- return
24
- end
18
+ private
25
19
 
26
- ctx.renderer.info("Model switched to #{name}")
27
- { action: :set_model, model: name }
20
+ # Parse "provider:model" or just "model".
21
+ # Examples:
22
+ # "openai:gpt-4o" → ["openai", "gpt-4o"]
23
+ # "claude-sonnet-4-20250514" → [nil, "claude-sonnet-4-20250514"]
24
+ # "anthropic:" → ["anthropic", nil]
25
+ def parse_model_arg(arg)
26
+ return [arg.chomp(':'), nil] if arg.end_with?(':')
27
+ return [Regexp.last_match(1), Regexp.last_match(2)] if arg.match(/\A([^:]+):(.+)\z/)
28
+
29
+ [nil, arg]
30
+ end
31
+
32
+ def switch_model(provider, model, ctx)
33
+ if provider
34
+ switch_provider_and_model(provider, model, ctx)
28
35
  else
29
- current = Config::Defaults::DEFAULT_MODEL
30
- ctx.renderer.info("Current model: #{current}")
31
- ctx.renderer.info("Available: #{KNOWN_MODELS.join(', ')}")
36
+ switch_model_only(model, ctx)
37
+ end
38
+ end
39
+
40
+ def switch_provider_and_model(provider, model, ctx)
41
+ validate_model_for_provider!(provider, model, ctx) if model
42
+ ctx.renderer.info("Switched to provider: #{provider}#{", model: #{model}" if model}")
43
+ { action: :set_provider, provider: provider, model: model }
44
+ end
45
+
46
+ def switch_model_only(model, ctx)
47
+ unless known_model?(model, ctx)
48
+ ctx.renderer.warning("Unknown model: #{model}")
49
+ show_available(ctx)
50
+ return
32
51
  end
52
+
53
+ ctx.renderer.info("Model switched to #{model}")
54
+ { action: :set_model, model: model }
55
+ end
56
+
57
+ def validate_model_for_provider!(provider, model, ctx)
58
+ adapter_models = models_for_provider(provider)
59
+ return if adapter_models.empty? # Unknown provider — can't validate
60
+ return if adapter_models.include?(model)
61
+
62
+ ctx.renderer.warning("Unknown model '#{model}' for #{provider}. Known: #{adapter_models.join(', ')}")
63
+ end
64
+
65
+ def show_current(ctx)
66
+ client = ctx.llm_client
67
+ provider = client.provider_name
68
+ current = client.model
69
+ ctx.renderer.info("Provider: #{provider}")
70
+ ctx.renderer.info("Current model: #{current}")
71
+ ctx.renderer.info("Available: #{client.models.join(', ')}")
72
+ show_other_providers(provider, ctx)
73
+ ctx.renderer.info('Tip: /model provider:model to switch providers (e.g., /model openai:gpt-4o)')
74
+ end
75
+
76
+ def show_other_providers(current_provider, ctx)
77
+ others = other_provider_entries(current_provider)
78
+ return if others.empty?
79
+
80
+ ctx.renderer.info('')
81
+ ctx.renderer.info('Other providers:')
82
+ others.each { |label| ctx.renderer.info(" #{label}") }
83
+ end
84
+
85
+ def other_provider_entries(current_provider)
86
+ providers = Config::Settings.new.data['providers']
87
+ return [] unless providers.is_a?(Hash)
88
+
89
+ providers.reject { |name, _| name == current_provider }.map do |name, cfg|
90
+ models = extract_config_models(cfg)
91
+ models.empty? ? name : "#{name}: #{models.join(', ')}"
92
+ end
93
+ end
94
+
95
+ def show_available(ctx)
96
+ client = ctx.llm_client
97
+ ctx.renderer.info("Available: #{client.models.join(', ')}")
98
+ ctx.renderer.info('Tip: /model provider:model to switch providers (e.g., /model openai:gpt-4o)')
99
+ end
100
+
101
+ def known_model?(model, ctx)
102
+ ctx.llm_client.models.include?(model)
103
+ end
104
+
105
+ def models_for_provider(provider)
106
+ case provider
107
+ when 'anthropic' then LLM::Adapters::Anthropic::AVAILABLE_MODELS
108
+ when 'openai' then LLM::Adapters::OpenAI::AVAILABLE_MODELS
109
+ else
110
+ cfg = Config::Settings.new.provider_config(provider)
111
+ extract_config_models(cfg)
112
+ end
113
+ end
114
+
115
+ def extract_config_models(cfg)
116
+ raw = cfg&.dig('models')
117
+ return [] unless raw
118
+
119
+ raw.is_a?(Hash) ? raw.values : Array(raw)
33
120
  end
34
121
  end
35
122
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # Saves the current session and starts a fresh conversation.
7
+ # Like pressing Escape in Claude Code — clears context without quitting.
8
+ class NewSession < Base
9
+ def self.command_name = '/new'
10
+ def self.description = 'Save current session and start a fresh conversation'
11
+ def self.aliases = ['/reset'].freeze
12
+
13
+ def execute(_args, ctx)
14
+ save_current_session(ctx)
15
+ clear_conversation(ctx)
16
+ new_session_id = generate_session_id
17
+
18
+ ctx.renderer.info('Session saved. Starting fresh.')
19
+ ctx.renderer.info("New session: #{new_session_id[0..7]}")
20
+
21
+ { action: :new_session, session_id: new_session_id }
22
+ end
23
+
24
+ private
25
+
26
+ def save_current_session(ctx)
27
+ ctx.session_persistence.save_session(
28
+ session_id: ctx.session_id,
29
+ project_path: ctx.project_root,
30
+ messages: ctx.conversation.messages,
31
+ model: Config::Defaults::DEFAULT_MODEL
32
+ )
33
+ end
34
+
35
+ def clear_conversation(ctx)
36
+ ctx.conversation.clear!
37
+ end
38
+
39
+ def generate_session_id
40
+ SecureRandom.hex(16)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -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
@@ -18,14 +18,9 @@ module RubynCode
18
18
  end
19
19
 
20
20
  def run
21
- ensure_home_dir!
22
- ensure_auth!
23
- setup_database!
24
- display_banner!
25
-
21
+ bootstrap!
26
22
  daemon = build_daemon
27
23
  daemon.start!
28
-
29
24
  display_shutdown_summary(daemon)
30
25
  rescue Interrupt
31
26
  @renderer.info("\nShutting down daemon...")
@@ -34,10 +29,20 @@ module RubynCode
34
29
  @renderer.error("Daemon failed: #{e.message}")
35
30
  RubynCode::Debug.warn(e.backtrace&.first(5)&.join("\n")) if @options[:debug]
36
31
  exit(1)
32
+ ensure
33
+ disconnect_mcp_clients!
37
34
  end
38
35
 
39
36
  private
40
37
 
38
+ def bootstrap!
39
+ ensure_home_dir!
40
+ ensure_auth!
41
+ setup_database!
42
+ setup_mcp_servers!
43
+ display_banner!
44
+ end
45
+
41
46
  def build_daemon
42
47
  Autonomous::Daemon.new(
43
48
  agent_name: @daemon_opts[:agent_name],
@@ -79,13 +84,16 @@ module RubynCode
79
84
  end
80
85
 
81
86
  def ensure_auth!
82
- unless Auth::TokenStore.valid?
83
- @renderer.error('No valid authentication found.')
84
- @renderer.info('Run `rubyn-code --auth` or set ANTHROPIC_API_KEY first.')
87
+ provider = @daemon_opts.fetch(:provider, Config::Defaults::DEFAULT_PROVIDER)
88
+
89
+ unless Auth::TokenStore.load_for_provider(provider)
90
+ @renderer.error("No valid authentication found for provider '#{provider}'.")
91
+ env_key = Config::Defaults::PROVIDER_ENV_KEYS.fetch(provider, "#{provider.upcase}_API_KEY")
92
+ @renderer.info("Set #{env_key} or run `rubyn-code --auth`.")
85
93
  exit(1)
86
94
  end
87
95
 
88
- @llm_client = LLM::Client.new
96
+ @llm_client = LLM::Client.new(provider: provider)
89
97
  end
90
98
 
91
99
  def setup_database!
@@ -96,9 +104,18 @@ module RubynCode
96
104
  end
97
105
 
98
106
  def display_banner!
107
+ display_banner_header!
108
+ display_banner_details!
109
+ display_banner_footer!
110
+ end
111
+
112
+ def display_banner_header!
99
113
  @renderer.info('╔══════════════════════════════════════╗')
100
114
  @renderer.info('║ GOLEM Daemon Starting ║')
101
115
  @renderer.info('╚══════════════════════════════════════╝')
116
+ end
117
+
118
+ def display_banner_details!
102
119
  @renderer.info(" Agent: #{@daemon_opts[:agent_name]}")
103
120
  @renderer.info(" Role: #{@daemon_opts[:role]}")
104
121
  @renderer.info(" Project: #{@project_root}")
@@ -106,6 +123,9 @@ module RubynCode
106
123
  @renderer.info(" Max cost: $#{@daemon_opts[:max_cost]}")
107
124
  @renderer.info(" Idle timeout: #{@daemon_opts[:idle_timeout]}s")
108
125
  @renderer.info(" Poll interval: #{@daemon_opts[:poll_interval]}s")
126
+ end
127
+
128
+ def display_banner_footer!
109
129
  @renderer.info('')
110
130
  @renderer.info('Waiting for tasks... (Ctrl-C to stop)')
111
131
  @renderer.info('Seed tasks via the REPL: /tasks or the task tool.')
@@ -122,7 +142,40 @@ module RubynCode
122
142
  @renderer.info('╚══════════════════════════════════════╝')
123
143
  @renderer.info(" Final state: #{status[:state]}")
124
144
  @renderer.info(" Tasks completed: #{status[:runs_completed]}")
125
- @renderer.info(" Total cost: $#{'%.4f' % status[:total_cost]}")
145
+ @renderer.info(format(' Total cost: $%.4f', status[:total_cost]))
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
126
179
  end
127
180
  end
128
181
  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