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
data/lib/rubyn_code/cli/app.rb
CHANGED
|
@@ -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
|
-
|
|
74
|
+
# -- unavoidable dispatch switch
|
|
75
|
+
def dispatch_command(command)
|
|
68
76
|
case command
|
|
69
|
-
when :version
|
|
70
|
-
when :auth
|
|
71
|
-
when :setup
|
|
72
|
-
when :help
|
|
73
|
-
when :run
|
|
74
|
-
when :
|
|
75
|
-
when :
|
|
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
|
-
|
|
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
|
-
|
|
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
|