rubyn-code 0.4.0 → 0.5.1

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +247 -9
  3. data/lib/rubyn_code/agent/conversation.rb +2 -1
  4. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +2 -1
  5. data/lib/rubyn_code/agent/llm_caller.rb +4 -2
  6. data/lib/rubyn_code/agent/loop.rb +7 -3
  7. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +39 -0
  9. data/lib/rubyn_code/agent/tool_processor.rb +4 -2
  10. data/lib/rubyn_code/cli/app.rb +87 -13
  11. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  12. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  13. data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
  14. data/lib/rubyn_code/cli/commands/provider.rb +2 -1
  15. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  16. data/lib/rubyn_code/cli/commands/skill.rb +4 -2
  17. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  18. data/lib/rubyn_code/cli/repl.rb +11 -1
  19. data/lib/rubyn_code/cli/repl_commands.rb +3 -1
  20. data/lib/rubyn_code/cli/repl_setup.rb +38 -1
  21. data/lib/rubyn_code/cli/setup.rb +13 -0
  22. data/lib/rubyn_code/config/defaults.rb +2 -0
  23. data/lib/rubyn_code/config/settings.rb +5 -2
  24. data/lib/rubyn_code/context/context_budget.rb +2 -1
  25. data/lib/rubyn_code/context/manager.rb +3 -3
  26. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
  27. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
  28. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
  29. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
  30. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +6 -3
  31. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +132 -0
  32. data/lib/rubyn_code/ide/handlers/review_handler.rb +19 -2
  33. data/lib/rubyn_code/ide/handlers.rb +17 -2
  34. data/lib/rubyn_code/ide/protocol.rb +17 -1
  35. data/lib/rubyn_code/ide/server.rb +39 -1
  36. data/lib/rubyn_code/index/codebase_index.rb +2 -1
  37. data/lib/rubyn_code/learning/extractor.rb +4 -2
  38. data/lib/rubyn_code/llm/model_router.rb +2 -1
  39. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  40. data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
  41. data/lib/rubyn_code/megaplan/interview_session.rb +245 -0
  42. data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
  43. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  44. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  45. data/lib/rubyn_code/self_test.rb +2 -1
  46. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  47. data/lib/rubyn_code/skills/catalog.rb +10 -0
  48. data/lib/rubyn_code/skills/document.rb +8 -2
  49. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  50. data/lib/rubyn_code/skills/loader.rb +1 -1
  51. data/lib/rubyn_code/skills/matcher.rb +89 -0
  52. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  53. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  54. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  55. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  56. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  57. data/lib/rubyn_code/tools/executor.rb +4 -2
  58. data/lib/rubyn_code/tools/grep.rb +2 -1
  59. data/lib/rubyn_code/tools/ide_diagnostics.rb +3 -1
  60. data/lib/rubyn_code/tools/ide_symbols.rb +3 -1
  61. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  62. data/lib/rubyn_code/tools/output_compressor.rb +3 -6
  63. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  64. data/lib/rubyn_code/tools/web_search.rb +2 -1
  65. data/lib/rubyn_code/version.rb +1 -1
  66. data/lib/rubyn_code.rb +20 -0
  67. data/skills/megaplan/megaplan.md +156 -0
  68. data/skills/rubyn_self_test.md +75 -0
  69. metadata +25 -4
@@ -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,7 @@ 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
29
30
  rubyn-code --ide Start IDE server (VS Code extension)
30
31
  rubyn-code --permission-mode MODE Set permission mode (default, accept_edits, plan_only, auto, dont_ask, bypass)
31
32
  rubyn-code --version Show version
@@ -47,6 +48,9 @@ module RubynCode
47
48
  /cost Show usage costs
48
49
  /tasks List tasks
49
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
50
54
 
51
55
  Environment:
52
56
  Config: ~/.rubyn-code/config.yml
@@ -60,23 +64,25 @@ module RubynCode
60
64
  '--auth' => :auth, '--setup' => :setup
61
65
  }.freeze
62
66
  BOOLEAN_FLAGS = { '--yolo' => :yolo, '--debug' => :debug, '--skip-setup' => :skip_setup, '--ide' => :ide }.freeze
63
- VALUE_FLAGS = { '--permission-mode' => :permission_mode }.freeze
67
+ VALUE_FLAGS = { '--permission-mode' => :permission_mode, '--dir' => :workspace_dir }.freeze
64
68
  DAEMON_INT_FLAGS = { '--max-runs' => :max_runs, '--idle-timeout' => :idle_timeout,
65
69
  '--poll-interval' => :poll_interval }.freeze
66
70
  DAEMON_STR_FLAGS = { '--name' => :agent_name, '--role' => :role }.freeze
67
71
 
68
72
  private
69
73
 
70
- def dispatch_command(command) # rubocop:disable Metrics/CyclomaticComplexity -- unavoidable dispatch switch
74
+ # -- unavoidable dispatch switch
75
+ def dispatch_command(command)
71
76
  case command
72
- when :version then puts "rubyn-code #{RubynCode::VERSION}"
73
- when :auth then run_auth
74
- when :setup then run_setup
75
- when :help then display_help
76
- when :run then run_single_prompt(@options[:prompt])
77
- when :ide then run_ide
78
- when :daemon then run_daemon
79
- 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
80
86
  end
81
87
  end
82
88
 
@@ -116,6 +122,10 @@ module RubynCode
116
122
  options[:command] = :run
117
123
  options[:prompt] = argv[idx + 1]
118
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
119
129
  when 'daemon'
120
130
  options[:command] = :daemon
121
131
  parse_daemon_options!(argv, idx + 1, options)
@@ -155,7 +165,8 @@ module RubynCode
155
165
  idx
156
166
  end
157
167
 
158
- 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)
159
170
  arg = argv[idx]
160
171
  daemon = options[:daemon]
161
172
  if DAEMON_INT_FLAGS.key?(arg)
@@ -198,13 +209,76 @@ module RubynCode
198
209
 
199
210
  def run_ide
200
211
  mode = resolve_permission_mode
201
- IDE::Server.new(permission_mode: mode).run
212
+ IDE::Server.new(permission_mode: mode, workspace_path: @options[:workspace_dir]).run
202
213
  end
203
214
 
204
215
  def run_daemon
205
216
  DaemonRunner.new(@options).run
206
217
  end
207
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
+
208
282
  def run_repl
209
283
  maybe_first_run!
210
284
  REPL.new(
@@ -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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module CLI
5
+ module Commands
6
+ # `/megaplan` REPL command — mirrors the VS Code chat's chat-resident
7
+ # megaplan entry. Loads the megaplan skill (from the shared
8
+ # skills catalog) into the conversation, flips plan mode ON so the
9
+ # agent is restricted to read-only tools, and kicks off a
10
+ # conversational interview.
11
+ #
12
+ # The interview itself is chat-style here (multi-turn natural-
13
+ # language Q&A), not the structured question-card UI the VS Code
14
+ # extension uses. Same skill content driving both surfaces.
15
+ class Megaplan < Base
16
+ def self.command_name = '/megaplan'
17
+ def self.description = 'Plan a feature in phases (interview, then numbered phase docs)'
18
+ def self.aliases = ['/mega-plan'].freeze
19
+
20
+ def execute(args, ctx)
21
+ content = ctx.skill_loader.load('megaplan')
22
+ ctx.conversation.add_user_message("<skill>#{content}</skill>")
23
+ ctx.renderer.info('Megaplan mode — interviewer with read-only tools 🧠')
24
+
25
+ ctx.send_message(build_prompt(args.join(' ').strip))
26
+
27
+ { action: :set_plan_mode, enabled: true }
28
+ rescue StandardError => e
29
+ ctx.renderer.error("Megaplan error: #{e.message}")
30
+ nil
31
+ end
32
+
33
+ private
34
+
35
+ def build_prompt(feature)
36
+ base = <<~PROMPT.strip
37
+ Conduct a megaplan interview, following the megaplan skill loaded above.
38
+ Stay strictly read-only: you may inspect files, search code, and check
39
+ git state, but do NOT edit, write, run shell mutations, or call any
40
+ destructive tool. Ask ONE question at a time. When you have enough,
41
+ output the final phase breakdown as a numbered outline.
42
+ PROMPT
43
+ return base if feature.empty?
44
+
45
+ "#{base}\n\nThe feature to plan: #{feature}"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -55,7 +55,8 @@ module RubynCode
55
55
  Auth::TokenStore.save_provider_key(name, opts[:key]) if opts[:key]
56
56
  end
57
57
 
58
- def confirm_added(name, base_url, opts, ctx) # rubocop:disable Metrics/AbcSize -- sequential output lines
58
+ # -- sequential output lines
59
+ def confirm_added(name, base_url, opts, ctx)
59
60
  ctx.renderer.success("Provider '#{name}' added (#{opts[:api_format] || 'openai'} format)")
60
61
  ctx.renderer.info(" base_url: #{base_url}")
61
62
  ctx.renderer.info(' api_key: stored') if opts[:key]
@@ -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
@@ -33,7 +33,8 @@ module RubynCode
33
33
  skills.each { |skill| puts " /#{skill[:name]}: #{skill[:description]}" }
34
34
  end
35
35
 
36
- def search_skills(term, ctx) # rubocop:disable Metrics/AbcSize -- readable sequential display logic
36
+ # -- readable sequential display logic
37
+ def search_skills(term, ctx)
37
38
  if term.nil? || term.strip.empty?
38
39
  ctx.renderer.warning('Usage: /skill search <term>')
39
40
  return
@@ -49,7 +50,8 @@ module RubynCode
49
50
  display_entries(results)
50
51
  end
51
52
 
52
- def list_by_category(category, ctx) # rubocop:disable Metrics/AbcSize -- readable sequential display logic
53
+ # -- readable sequential display logic
54
+ def list_by_category(category, ctx)
53
55
  catalog = ctx.skill_loader.catalog
54
56
  return list_categories(catalog, ctx) if category.nil? || category.strip.empty?
55
57
 
@@ -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
@@ -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,7 +107,8 @@ module RubynCode
98
107
  @stream_formatter&.feed(text)
99
108
  end
100
109
 
101
- def handle_message(input) # rubocop:disable Metrics/AbcSize -- sequential steps with interrupt rescue
110
+ # -- sequential steps with interrupt rescue
111
+ def handle_message(input)
102
112
  @spinner.start
103
113
  @streaming_first_chunk = true
104
114
 
@@ -22,7 +22,9 @@ module RubynCode
22
22
  Commands::Spawn, Commands::Doctor, Commands::Tokens,
23
23
  Commands::Plan, Commands::ContextInfo, Commands::Diff,
24
24
  Commands::Model, Commands::NewSession, Commands::Mcp,
25
- Commands::Provider
25
+ Commands::Provider, Commands::InstallSkills,
26
+ Commands::RemoveSkills, Commands::Skills,
27
+ Commands::Megaplan
26
28
  ].each { |cmd| @command_registry.register(cmd) }
27
29
  end
28
30
 
@@ -42,9 +42,41 @@ module RubynCode
42
42
  @budget_enforcer = Observability::BudgetEnforcer.new(@db, session_id: current_session_id)
43
43
  @background_worker = Background::Worker.new(project_root: @project_root)
44
44
  @skill_loader = Skills::Loader.new(Skills::Catalog.new(skill_dirs))
45
+ @skill_matcher = build_skill_matcher
46
+ @web_skill_autoload = build_web_skill_autoload
45
47
  @session_persistence = Memory::SessionPersistence.new(@db)
46
48
  end
47
49
 
50
+ def build_skill_matcher
51
+ return nil unless skills_autoload_enabled?
52
+
53
+ Skills::Matcher.new(catalog: @skill_loader.catalog, project_root: @project_root)
54
+ end
55
+
56
+ def build_web_skill_autoload
57
+ return nil unless @skill_matcher && skills_autoload_enabled?
58
+
59
+ Skills::RegistryAutoload.new(
60
+ loader: @skill_loader,
61
+ matcher: @skill_matcher,
62
+ on_fetching: on_pack_fetching_callback
63
+ )
64
+ end
65
+
66
+ def skills_autoload_enabled?
67
+ Config::Settings.new.skills_autoload
68
+ rescue Config::Settings::LoadError
69
+ true
70
+ end
71
+
72
+ def on_skills_autoloaded_callback
73
+ ->(names) { @renderer.system_message("📚 Loaded: #{names.join(' · ')}") }
74
+ end
75
+
76
+ def on_pack_fetching_callback
77
+ ->(name) { @renderer.system_message("📥 Fetching skill pack '#{name}' from registry…") }
78
+ end
79
+
48
80
  def setup_executor_callbacks!
49
81
  @tool_executor.llm_client = @llm_client
50
82
  @tool_executor.background_worker = @background_worker
@@ -107,7 +139,10 @@ module RubynCode
107
139
  on_tool_call: ->(name, params) { handle_on_tool_call(name, params) },
108
140
  on_tool_result: ->(name, result, _is_error = false) { handle_on_tool_result(name, result) },
109
141
  on_text: ->(text) { handle_on_text(text) },
110
- skill_loader: @skill_loader, project_root: @project_root
142
+ on_skills_autoloaded: on_skills_autoloaded_callback,
143
+ skill_loader: @skill_loader, skill_matcher: @skill_matcher,
144
+ web_skill_autoload: @web_skill_autoload,
145
+ project_root: @project_root
111
146
  )
112
147
  end
113
148
 
@@ -139,6 +174,8 @@ module RubynCode
139
174
  dirs << project_skills if Dir.exist?(project_skills)
140
175
  user_skills = File.join(Config::Defaults::HOME_DIR, 'skills')
141
176
  dirs << user_skills if Dir.exist?(user_skills)
177
+ skill_packs = File.join(Config::Defaults::HOME_DIR, 'skill-packs')
178
+ dirs << skill_packs if Dir.exist?(skill_packs)
142
179
  dirs
143
180
  end
144
181