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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +263 -21
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +34 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +11 -1
  7. data/lib/rubyn_code/agent/loop.rb +14 -3
  8. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
  10. data/lib/rubyn_code/agent/tool_processor.rb +25 -3
  11. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  12. data/lib/rubyn_code/auth/token_store.rb +50 -9
  13. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  14. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  15. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  16. data/lib/rubyn_code/cli/app.rb +116 -11
  17. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  18. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  19. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  20. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  21. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  22. data/lib/rubyn_code/cli/commands/provider.rb +124 -0
  23. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  24. data/lib/rubyn_code/cli/commands/skill.rb +54 -3
  25. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  27. data/lib/rubyn_code/cli/first_run.rb +159 -0
  28. data/lib/rubyn_code/cli/repl.rb +15 -0
  29. data/lib/rubyn_code/cli/repl_commands.rb +3 -1
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +74 -1
  32. data/lib/rubyn_code/config/defaults.rb +3 -0
  33. data/lib/rubyn_code/config/schema.json +49 -0
  34. data/lib/rubyn_code/config/settings.rb +12 -6
  35. data/lib/rubyn_code/config/validator.rb +63 -0
  36. data/lib/rubyn_code/context/context_budget.rb +18 -2
  37. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  38. data/lib/rubyn_code/context/manager.rb +37 -3
  39. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  40. data/lib/rubyn_code/hooks/registry.rb +4 -0
  41. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  42. data/lib/rubyn_code/ide/client.rb +110 -0
  43. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  44. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  45. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  46. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  47. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  48. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  49. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  50. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +218 -0
  51. data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -0
  52. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  53. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  54. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  55. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  56. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  57. data/lib/rubyn_code/ide/handlers.rb +76 -0
  58. data/lib/rubyn_code/ide/protocol.rb +112 -0
  59. data/lib/rubyn_code/ide/server.rb +186 -0
  60. data/lib/rubyn_code/index/codebase_index.rb +69 -2
  61. data/lib/rubyn_code/learning/extractor.rb +4 -2
  62. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  63. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  64. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  65. data/lib/rubyn_code/llm/client.rb +29 -4
  66. data/lib/rubyn_code/llm/model_router.rb +2 -1
  67. data/lib/rubyn_code/mcp/config.rb +2 -1
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  69. data/lib/rubyn_code/memory/search.rb +1 -0
  70. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  71. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  72. data/lib/rubyn_code/self_test.rb +316 -0
  73. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  74. data/lib/rubyn_code/skills/catalog.rb +76 -0
  75. data/lib/rubyn_code/skills/document.rb +8 -2
  76. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  77. data/lib/rubyn_code/skills/loader.rb +43 -0
  78. data/lib/rubyn_code/skills/matcher.rb +89 -0
  79. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  80. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  81. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  82. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  83. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  84. data/lib/rubyn_code/tasks/models.rb +1 -0
  85. data/lib/rubyn_code/tools/base.rb +13 -0
  86. data/lib/rubyn_code/tools/bash.rb +5 -0
  87. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  88. data/lib/rubyn_code/tools/executor.rb +65 -8
  89. data/lib/rubyn_code/tools/glob.rb +6 -0
  90. data/lib/rubyn_code/tools/grep.rb +7 -0
  91. data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
  92. data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
  93. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  94. data/lib/rubyn_code/tools/output_compressor.rb +9 -7
  95. data/lib/rubyn_code/tools/read_file.rb +6 -0
  96. data/lib/rubyn_code/tools/registry.rb +11 -0
  97. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  98. data/lib/rubyn_code/tools/web_search.rb +2 -1
  99. data/lib/rubyn_code/tools/write_file.rb +17 -0
  100. data/lib/rubyn_code/version.rb +1 -1
  101. data/lib/rubyn_code.rb +34 -0
  102. data/skills/rubyn_self_test.md +88 -1
  103. metadata +43 -1
@@ -2,7 +2,7 @@
2
2
 
3
3
  module RubynCode
4
4
  module CLI
5
- class App
5
+ class App # rubocop:disable Metrics/ClassLength -- CLI dispatch requires many small methods
6
6
  def self.start(argv)
7
7
  new(argv).run
8
8
  end
@@ -26,6 +26,9 @@ module RubynCode
26
26
  rubyn-code --resume [ID] Resume a previous session
27
27
  rubyn-code --setup Pin rubyn-code to bypass rbenv/rvm
28
28
  rubyn-code --auth Authenticate with Claude
29
+ rubyn-code --install-skills NAME Install a skill pack from the registry
30
+ rubyn-code --ide Start IDE server (VS Code extension)
31
+ rubyn-code --permission-mode MODE Set permission mode (default, accept_edits, plan_only, auto, dont_ask, bypass)
29
32
  rubyn-code --version Show version
30
33
  rubyn-code --help Show this help
31
34
 
@@ -45,6 +48,9 @@ module RubynCode
45
48
  /cost Show usage costs
46
49
  /tasks List tasks
47
50
  /skill [name] Load or list skills
51
+ /skills List installed and community skill packs
52
+ /install-skills Install skill packs from rubyn.ai
53
+ /remove-skills Remove an installed skill pack
48
54
 
49
55
  Environment:
50
56
  Config: ~/.rubyn-code/config.yml
@@ -57,22 +63,26 @@ module RubynCode
57
63
  '--help' => :help, '-h' => :help,
58
64
  '--auth' => :auth, '--setup' => :setup
59
65
  }.freeze
60
- BOOLEAN_FLAGS = { '--yolo' => :yolo, '--debug' => :debug }.freeze
66
+ BOOLEAN_FLAGS = { '--yolo' => :yolo, '--debug' => :debug, '--skip-setup' => :skip_setup, '--ide' => :ide }.freeze
67
+ VALUE_FLAGS = { '--permission-mode' => :permission_mode }.freeze
61
68
  DAEMON_INT_FLAGS = { '--max-runs' => :max_runs, '--idle-timeout' => :idle_timeout,
62
69
  '--poll-interval' => :poll_interval }.freeze
63
70
  DAEMON_STR_FLAGS = { '--name' => :agent_name, '--role' => :role }.freeze
64
71
 
65
72
  private
66
73
 
67
- def dispatch_command(command) # rubocop:disable Metrics/CyclomaticComplexity -- unavoidable dispatch switch
74
+ # -- unavoidable dispatch switch
75
+ def dispatch_command(command)
68
76
  case command
69
- when :version then puts "rubyn-code #{RubynCode::VERSION}"
70
- when :auth then run_auth
71
- when :setup then run_setup
72
- when :help then display_help
73
- when :run then run_single_prompt(@options[:prompt])
74
- when :daemon then run_daemon
75
- when :repl then run_repl
77
+ when :version then puts "rubyn-code #{RubynCode::VERSION}"
78
+ when :auth then run_auth
79
+ when :setup then run_setup
80
+ when :help then display_help
81
+ when :run then run_single_prompt(@options[:prompt])
82
+ when :ide then run_ide
83
+ when :daemon then run_daemon
84
+ when :install_skills then run_install_skills(@options[:install_skills_names])
85
+ when :repl then run_repl
76
86
  end
77
87
  end
78
88
 
@@ -83,6 +93,7 @@ module RubynCode
83
93
  idx = parse_single_option(argv, idx, options)
84
94
  idx += 1
85
95
  end
96
+ options[:command] = :ide if options[:ide]
86
97
  options
87
98
  end
88
99
 
@@ -93,6 +104,9 @@ module RubynCode
93
104
  options[:command] = SIMPLE_FLAGS[arg]
94
105
  elsif BOOLEAN_FLAGS.key?(arg)
95
106
  options[BOOLEAN_FLAGS[arg]] = true
107
+ elsif VALUE_FLAGS.key?(arg)
108
+ options[VALUE_FLAGS[arg]] = argv[idx + 1]
109
+ idx += 1
96
110
  else
97
111
  idx = parse_value_option(argv, idx, options)
98
112
  end
@@ -108,6 +122,10 @@ module RubynCode
108
122
  options[:command] = :run
109
123
  options[:prompt] = argv[idx + 1]
110
124
  idx + 1
125
+ when '--install-skills'
126
+ options[:command] = :install_skills
127
+ options[:install_skills_names] = collect_pack_names(argv, idx + 1)
128
+ argv.length - 1
111
129
  when 'daemon'
112
130
  options[:command] = :daemon
113
131
  parse_daemon_options!(argv, idx + 1, options)
@@ -147,7 +165,8 @@ module RubynCode
147
165
  idx
148
166
  end
149
167
 
150
- def parse_daemon_value_option(argv, idx, options) # rubocop:disable Metrics/AbcSize -- option dispatch with hash lookup
168
+ # -- option dispatch with hash lookup
169
+ def parse_daemon_value_option(argv, idx, options)
151
170
  arg = argv[idx]
152
171
  daemon = options[:daemon]
153
172
  if DAEMON_INT_FLAGS.key?(arg)
@@ -188,11 +207,80 @@ module RubynCode
188
207
  puts response
189
208
  end
190
209
 
210
+ def run_ide
211
+ mode = resolve_permission_mode
212
+ IDE::Server.new(permission_mode: mode).run
213
+ end
214
+
191
215
  def run_daemon
192
216
  DaemonRunner.new(@options).run
193
217
  end
194
218
 
219
+ def run_install_skills(names)
220
+ renderer = Renderer.new
221
+ client = Skills::RegistryClient.new
222
+ installer = Skills::PackInstaller.new(
223
+ registry_client: client,
224
+ project_root: Dir.pwd,
225
+ global: @options[:install_skills_global]
226
+ )
227
+
228
+ if names.empty? || names.include?('--update')
229
+ renderer.info('Checking for updates on all installed packs...')
230
+ results = installer.update_all do |event, data|
231
+ report_install_progress(renderer, event, data)
232
+ end
233
+ updated = results.select { |r| r[:status] == :installed }
234
+ if updated.empty?
235
+ renderer.info('All packs are up to date.')
236
+ else
237
+ renderer.info("Updated #{updated.size} pack(s).")
238
+ end
239
+ else
240
+ installer.install(names) do |event, data|
241
+ report_install_progress(renderer, event, data)
242
+ end
243
+ end
244
+ rescue Skills::RegistryError => e
245
+ renderer.error("Registry error: #{e.message}")
246
+ exit(1)
247
+ rescue StandardError => e
248
+ renderer.error("Install failed: #{e.message}")
249
+ exit(1)
250
+ end
251
+
252
+ def report_install_progress(renderer, event, data)
253
+ case event
254
+ when :fetching then renderer.info("Fetching #{data[:name]} from rubyn.ai...")
255
+ when :downloading then renderer.info(" Downloading #{data[:total]} skill files...")
256
+ when :installed
257
+ data[:files].each { |f| puts " → #{data[:name]}/#{f}" }
258
+ renderer.info("Installed #{data[:files].size} skills to .rubyn-code/skills/#{data[:name]}/")
259
+ when :up_to_date then renderer.info("#{data[:name]} is already up to date (v#{data[:version]}).")
260
+ when :error then renderer.error("Failed: #{data[:name]}: #{data[:message]}")
261
+ end
262
+ end
263
+
264
+ def collect_pack_names(argv, start)
265
+ names = []
266
+ idx = start
267
+ while idx < argv.length
268
+ arg = argv[idx]
269
+ break if arg.start_with?('-') && arg != '--update' && arg != '--global'
270
+
271
+ if arg == '--global'
272
+ @options ||= {}
273
+ @options[:install_skills_global] = true
274
+ else
275
+ names << arg
276
+ end
277
+ idx += 1
278
+ end
279
+ names
280
+ end
281
+
195
282
  def run_repl
283
+ maybe_first_run!
196
284
  REPL.new(
197
285
  session_id: @options[:session_id],
198
286
  project_root: Dir.pwd,
@@ -200,6 +288,23 @@ module RubynCode
200
288
  ).run
201
289
  end
202
290
 
291
+ def maybe_first_run!
292
+ return unless FirstRun.needed?
293
+ return if FirstRun.skipped?(skip_flag: @options[:skip_setup])
294
+
295
+ FirstRun.new.run
296
+ end
297
+
298
+ def resolve_permission_mode
299
+ if @options[:permission_mode]
300
+ @options[:permission_mode].to_sym
301
+ elsif @options[:yolo]
302
+ :bypass
303
+ else
304
+ :default
305
+ end
306
+ end
307
+
203
308
  def display_help
204
309
  puts HELP_TEXT
205
310
  end
@@ -14,6 +14,9 @@ module RubynCode
14
14
  check_auth
15
15
  check_skills
16
16
  check_project
17
+ check_mcp
18
+ check_codebase_index
19
+ check_skill_catalog
17
20
  ].freeze
18
21
 
19
22
  def execute(_args, ctx)
@@ -97,6 +100,76 @@ module RubynCode
97
100
  ['Project detected', true, "#{type} at #{ctx.project_root}"]
98
101
  end
99
102
 
103
+ def check_mcp(ctx)
104
+ config_path = File.join(ctx.project_root, MCP::Config::CONFIG_FILENAME)
105
+ return ['MCP connectivity', false, 'mcp.json not found'] unless File.exist?(config_path)
106
+
107
+ servers = MCP::Config.load(ctx.project_root)
108
+ return ['MCP connectivity', false, 'no servers configured'] if servers.empty?
109
+
110
+ reachable = servers.count { |s| mcp_server_reachable?(s) }
111
+ detail = "#{reachable}/#{servers.size} servers reachable"
112
+ ['MCP connectivity', reachable == servers.size, detail]
113
+ rescue StandardError => e
114
+ ['MCP connectivity', false, e.message]
115
+ end
116
+
117
+ def mcp_server_reachable?(server)
118
+ command = server[:command]
119
+ return false if command.nil? || command.empty?
120
+
121
+ # Check if the command binary exists on PATH
122
+ system("command -v #{command} > /dev/null 2>&1")
123
+ end
124
+
125
+ def check_codebase_index(ctx)
126
+ index_path = File.join(ctx.project_root, Index::CodebaseIndex::INDEX_DIR,
127
+ Index::CodebaseIndex::INDEX_FILE)
128
+ return ['Codebase index', false, 'index not found'] unless File.exist?(index_path)
129
+
130
+ mtime = File.mtime(index_path)
131
+ age_hours = ((Time.now - mtime) / 3600).round(1)
132
+ stale = age_hours > 24
133
+ detail = "#{age_hours}h old#{' (stale — consider reindexing)' if stale}"
134
+ ['Codebase index', !stale, detail]
135
+ rescue StandardError => e
136
+ ['Codebase index', false, e.message]
137
+ end
138
+
139
+ def check_skill_catalog(ctx)
140
+ catalog = ctx.skill_loader.catalog
141
+ entries = catalog.available
142
+ return ['Skill catalog', false, 'no skills found'] if entries.empty?
143
+
144
+ malformed = count_malformed_skills(catalog.skills_dirs)
145
+ detail = "#{entries.size} skills loaded"
146
+ detail += ", #{malformed} malformed" if malformed.positive?
147
+ ['Skill catalog', malformed.zero?, detail]
148
+ rescue StandardError => e
149
+ ['Skill catalog', false, e.message]
150
+ end
151
+
152
+ def count_malformed_skills(skills_dirs)
153
+ count = 0
154
+ skills_dirs.each do |dir|
155
+ next unless File.directory?(dir)
156
+
157
+ Dir.glob(File.join(dir, '**/*.md')).each do |path|
158
+ count += 1 unless valid_skill_file?(path)
159
+ end
160
+ end
161
+ count
162
+ end
163
+
164
+ def valid_skill_file?(path)
165
+ content = File.read(path, 1024, encoding: 'UTF-8')
166
+ .encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
167
+ doc = Skills::Document.parse(content, filename: path)
168
+ !doc.name.nil? && !doc.name.empty?
169
+ rescue StandardError
170
+ false
171
+ end
172
+
100
173
  def detect_project_type(root)
101
174
  return 'Rails' if File.exist?(File.join(root, 'config', 'application.rb'))
102
175
  return 'Ruby' if File.exist?(File.join(root, 'Rakefile'))
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class InstallSkills < Base
7
+ def self.command_name = '/install-skills'
8
+ def self.description = 'Install skill packs from the Rubyn registry'
9
+
10
+ def execute(args, ctx)
11
+ if args.empty?
12
+ ctx.renderer.warning('Usage: /install-skills <pack-name> [pack-name ...]')
13
+ return
14
+ end
15
+
16
+ pack_manager = RubynCode::Skills::PackManager.new
17
+ registry = RubynCode::Skills::RegistryClient.new
18
+
19
+ args.each { |name| install_pack(name, registry, pack_manager, ctx) }
20
+ rescue RubynCode::Skills::RegistryError => e
21
+ ctx.renderer.error(e.message)
22
+ end
23
+
24
+ private
25
+
26
+ def install_pack(name, registry, pack_manager, ctx)
27
+ if pack_manager.installed?(name)
28
+ ctx.renderer.warning("Pack '#{name}' is already installed. Use /remove-skills first to reinstall.")
29
+ return
30
+ end
31
+
32
+ ctx.renderer.info("Fetching pack '#{name}' from registry...")
33
+ result = registry.fetch_pack(name)
34
+ pack_manager.install(result[:data], etag: result[:etag])
35
+ ctx.renderer.info("Installed skill pack '#{name}' successfully.")
36
+ rescue RubynCode::Skills::RegistryError => e
37
+ ctx.renderer.error("Failed to install '#{name}': #{e.message}")
38
+ rescue ArgumentError => e
39
+ ctx.renderer.error("Invalid pack data for '#{name}': #{e.message}")
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class ListSkills < Base
7
+ def self.command_name = '/skills'
8
+ def self.description = 'List installed skills or browse the registry'
9
+
10
+ def execute(args, ctx)
11
+ if args.include?('--available')
12
+ list_available(ctx)
13
+ else
14
+ list_installed(ctx)
15
+ end
16
+ rescue Skills::RegistryError => e
17
+ ctx.renderer.error("Registry error: #{e.message}")
18
+ rescue StandardError => e
19
+ ctx.renderer.error("Skills error: #{e.message}")
20
+ end
21
+
22
+ private
23
+
24
+ def list_installed(ctx)
25
+ catalog = ctx.skill_loader.catalog
26
+ all_skills = catalog.available
27
+ buckets = partition_skills(all_skills, ctx)
28
+
29
+ ctx.renderer.info("Loaded skills (#{all_skills.size} total)")
30
+ puts
31
+
32
+ render_builtin(buckets[:builtin], catalog)
33
+ render_packs(buckets[:packs])
34
+ render_community(buckets[:community], buckets[:packs].any?)
35
+ end
36
+
37
+ def partition_skills(all_skills, ctx)
38
+ community_dir = File.join(ctx.project_root, '.rubyn-code', 'skills')
39
+ global_dir = File.join(Config::Defaults::HOME_DIR, 'skills')
40
+ pack_dir = File.join(Config::Defaults::HOME_DIR, 'skill-packs')
41
+
42
+ buckets = { builtin: [], community: [], packs: [] }
43
+ all_skills.each do |skill|
44
+ buckets[bucket_for(skill[:path].to_s, pack_dir, community_dir, global_dir)] << skill
45
+ end
46
+ buckets
47
+ end
48
+
49
+ def bucket_for(path, pack_dir, community_dir, global_dir)
50
+ return :packs if path.start_with?(pack_dir)
51
+ return :community if path.start_with?(community_dir) || path.start_with?(global_dir)
52
+
53
+ :builtin
54
+ end
55
+
56
+ def render_builtin(builtin, catalog)
57
+ puts " Built-in (#{builtin.size})"
58
+ builtin.group_by { |s| category_from_path(s[:path], catalog.skills_dirs) }
59
+ .sort_by { |cat, _| cat }
60
+ .each do |cat, skills|
61
+ label = cat.empty? ? 'general' : cat
62
+ puts " #{label}: #{skills.map { |s| s[:name] }.join(', ')}"
63
+ end
64
+ end
65
+
66
+ def render_packs(packs)
67
+ return if packs.empty?
68
+
69
+ puts
70
+ summary = packs.group_by { |s| pack_from_path(s[:path]) }
71
+ .sort
72
+ .map { |pack, skills| "#{pack} (#{skills.size})" }
73
+ .join(', ')
74
+ puts " Skill packs: #{summary}"
75
+ end
76
+
77
+ def render_community(community, packs_present)
78
+ if community.any?
79
+ puts
80
+ summary = community.group_by { |s| pack_from_path(s[:path]) }
81
+ .map { |pack, skills| "#{pack} (#{skills.size})" }
82
+ .join(', ')
83
+ puts " Community: #{summary}"
84
+ elsif !packs_present
85
+ puts
86
+ puts ' Skill packs: none installed'
87
+ puts ' Run /skills --available to browse, or /install-skills <name> to install.'
88
+ end
89
+ end
90
+
91
+ def list_available(ctx) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- readable catalog display
92
+ client = Skills::RegistryClient.new
93
+ catalog_data = client.fetch_catalog
94
+ packs = catalog_data['packs'] || []
95
+
96
+ if packs.empty?
97
+ ctx.renderer.info('No packs available in the registry.')
98
+ return
99
+ end
100
+
101
+ installer = Skills::PackInstaller.new(
102
+ registry_client: client,
103
+ project_root: ctx.project_root
104
+ )
105
+
106
+ ctx.renderer.info("Available skill packs (#{packs.size})")
107
+ puts
108
+
109
+ # Group by category
110
+ by_category = packs.group_by { |p| p['category'] || 'other' }
111
+ by_category.sort_by { |cat, _| cat }.each do |category, cat_packs|
112
+ puts " #{category.capitalize}"
113
+ cat_packs.each do |pack|
114
+ installed = installer.installed?(pack['name'])
115
+ marker = installed ? ' ✓' : ''
116
+ desc = pack['description']&.slice(0, 50) || ''
117
+ puts " #{pack['name'].ljust(20)} #{desc} (#{pack['skillCount']} skills)#{marker}"
118
+ end
119
+ puts
120
+ end
121
+
122
+ puts ' Install with: /install-skills <name>'
123
+ end
124
+
125
+ def category_from_path(path, skills_dirs)
126
+ skills_dirs.each do |dir|
127
+ expanded = File.expand_path(dir)
128
+ next unless path.to_s.start_with?(expanded)
129
+
130
+ relative = path.delete_prefix("#{expanded}/")
131
+ parts = relative.split('/')
132
+ return parts.size > 1 ? parts.first : ''
133
+ end
134
+ ''
135
+ end
136
+
137
+ def pack_from_path(path)
138
+ # Pack-style layouts: .rubyn-code/skills/<pack>/<file>.md
139
+ # or .rubyn-code/skill-packs/<pack>/<file>.md
140
+ parts = path.to_s.split('/')
141
+ idx = parts.rindex('skill-packs') || parts.rindex('skills')
142
+ return 'unknown' unless idx
143
+
144
+ parts[idx + 1] || 'unknown'
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ class Mcp < Base
7
+ def self.command_name = '/mcp'
8
+ def self.description = 'MCP server status'
9
+
10
+ def execute(_args, ctx)
11
+ configs = load_configs(ctx.project_root)
12
+
13
+ if configs.empty?
14
+ ctx.renderer.info('No MCP servers configured.')
15
+ puts ' Add servers to .rubyn-code/mcp.json — see docs/MCP.md for details.'
16
+ return
17
+ end
18
+
19
+ ctx.renderer.info("MCP servers (#{configs.size}):")
20
+ puts
21
+
22
+ configs.each { |cfg| render_server(cfg) }
23
+ end
24
+
25
+ private
26
+
27
+ def load_configs(project_root)
28
+ MCP::Config.load(project_root)
29
+ end
30
+
31
+ def render_server(cfg)
32
+ client = build_client(cfg)
33
+ status, tool_count = probe_server(client)
34
+ icon = status_icon(status)
35
+ tools_label = tool_count ? " (#{tool_count} tools)" : ''
36
+
37
+ puts " #{icon} #{cfg[:name]} [#{status}]#{tools_label}"
38
+ render_transport_info(cfg)
39
+ ensure
40
+ client&.disconnect! if client&.connected?
41
+ end
42
+
43
+ def build_client(cfg)
44
+ MCP::Client.from_config(cfg)
45
+ end
46
+
47
+ def probe_server(client)
48
+ client.connect!
49
+ tool_count = client.tools.size
50
+ [:connected, tool_count]
51
+ rescue StandardError
52
+ [:error, nil]
53
+ end
54
+
55
+ def render_transport_info(cfg)
56
+ if cfg[:url]
57
+ puts " transport: SSE url: #{cfg[:url]}"
58
+ else
59
+ puts " transport: stdio command: #{cfg[:command]} #{cfg[:args].join(' ')}".rstrip
60
+ end
61
+ end
62
+
63
+ def status_icon(status)
64
+ case status
65
+ when :connected then green('*')
66
+ when :error then red('x')
67
+ else yellow('?')
68
+ end
69
+ end
70
+
71
+ def green(text) = "\e[32m#{text}\e[0m"
72
+ def red(text) = "\e[31m#{text}\e[0m"
73
+ def yellow(text) = "\e[33m#{text}\e[0m"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -68,7 +68,28 @@ module RubynCode
68
68
  current = client.model
69
69
  ctx.renderer.info("Provider: #{provider}")
70
70
  ctx.renderer.info("Current model: #{current}")
71
- show_available(ctx)
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
72
93
  end
73
94
 
74
95
  def show_available(ctx)
@@ -85,9 +106,18 @@ module RubynCode
85
106
  case provider
86
107
  when 'anthropic' then LLM::Adapters::Anthropic::AVAILABLE_MODELS
87
108
  when 'openai' then LLM::Adapters::OpenAI::AVAILABLE_MODELS
88
- else []
109
+ else
110
+ cfg = Config::Settings.new.provider_config(provider)
111
+ extract_config_models(cfg)
89
112
  end
90
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)
120
+ end
91
121
  end
92
122
  end
93
123
  end