rubyn-code 0.3.0 → 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.
- checksums.yaml +4 -4
- data/README.md +77 -19
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/conversation.rb +32 -3
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +56 -3
- data/lib/rubyn_code/agent/llm_caller.rb +9 -1
- data/lib/rubyn_code/agent/loop.rb +7 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +10 -4
- data/lib/rubyn_code/agent/tool_processor.rb +21 -1
- 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 +32 -1
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -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 +123 -0
- data/lib/rubyn_code/cli/commands/skill.rb +52 -3
- 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 +6 -1
- data/lib/rubyn_code/cli/repl_commands.rb +2 -1
- data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
- data/lib/rubyn_code/cli/repl_setup.rb +36 -0
- data/lib/rubyn_code/config/defaults.rb +1 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +7 -4
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/context_budget.rb +16 -1
- 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 +215 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -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 +111 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +67 -1
- 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/mcp/config.rb +2 -1
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/loader.rb +43 -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 +61 -6
- data/lib/rubyn_code/tools/glob.rb +6 -0
- data/lib/rubyn_code/tools/grep.rb +6 -0
- data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
- data/lib/rubyn_code/tools/output_compressor.rb +6 -1
- data/lib/rubyn_code/tools/read_file.rb +6 -0
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/write_file.rb +17 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +22 -0
- data/skills/rubyn_self_test.md +13 -1
- metadata +31 -1
|
@@ -48,6 +48,10 @@ module RubynCode
|
|
|
48
48
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
|
+
def api_url
|
|
52
|
+
API_URL
|
|
53
|
+
end
|
|
54
|
+
|
|
51
55
|
# -- Auth ---------------------------------------------------------
|
|
52
56
|
|
|
53
57
|
def oauth_token?
|
|
@@ -88,7 +92,7 @@ module RubynCode
|
|
|
88
92
|
end
|
|
89
93
|
|
|
90
94
|
def post_request(body)
|
|
91
|
-
connection.post(
|
|
95
|
+
connection.post(api_url) do |req|
|
|
92
96
|
apply_headers(req)
|
|
93
97
|
req.body = JSON.generate(body)
|
|
94
98
|
end
|
|
@@ -113,7 +117,7 @@ module RubynCode
|
|
|
113
117
|
streamer = build_streamer(on_text)
|
|
114
118
|
error_chunks = []
|
|
115
119
|
|
|
116
|
-
response = streaming_connection.post(
|
|
120
|
+
response = streaming_connection.post(api_url) do |req|
|
|
117
121
|
apply_headers(req)
|
|
118
122
|
req.body = JSON.generate(body)
|
|
119
123
|
req.options.on_data = on_data_proc(streamer, error_chunks)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module LLM
|
|
5
|
+
module Adapters
|
|
6
|
+
# Adapter for Anthropic-compatible providers that use the Messages API format.
|
|
7
|
+
#
|
|
8
|
+
# Inherits all Anthropic logic but overrides the base URL, provider name,
|
|
9
|
+
# available models, and API key resolution.
|
|
10
|
+
class AnthropicCompatible < Anthropic
|
|
11
|
+
def initialize(provider:, base_url:, api_key: nil, available_models: [])
|
|
12
|
+
super()
|
|
13
|
+
@provider = provider
|
|
14
|
+
@base_url = base_url
|
|
15
|
+
@api_key = api_key
|
|
16
|
+
@available_models = available_models.freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def provider_name
|
|
20
|
+
@provider
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def models
|
|
24
|
+
@available_models
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def api_url
|
|
30
|
+
"#{@base_url}/messages"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ensure_valid_token!
|
|
34
|
+
resolve_api_key # raises if missing
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def oauth_token?
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def access_token
|
|
42
|
+
resolve_api_key
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resolve_api_key
|
|
46
|
+
return @api_key if @api_key
|
|
47
|
+
|
|
48
|
+
stored = Auth::TokenStore.load_provider_key(@provider)
|
|
49
|
+
return stored if stored
|
|
50
|
+
|
|
51
|
+
env_key = "#{@provider.upcase.tr('-', '_')}_API_KEY"
|
|
52
|
+
ENV.fetch(env_key) do
|
|
53
|
+
raise Client::AuthExpiredError,
|
|
54
|
+
"No #{@provider} API key configured. Set with: /provider set-key #{@provider} <key>"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -27,11 +27,15 @@ module RubynCode
|
|
|
27
27
|
def resolve_api_key
|
|
28
28
|
return @api_key if @api_key
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
stored = Auth::TokenStore.load_provider_key(@provider)
|
|
31
|
+
return stored if stored
|
|
32
|
+
|
|
33
|
+
env_key = "#{@provider.upcase.tr('-', '_')}_API_KEY"
|
|
31
34
|
ENV.fetch(env_key) do
|
|
32
35
|
return 'no-key-required' if local_provider?
|
|
33
36
|
|
|
34
|
-
raise Client::AuthExpiredError,
|
|
37
|
+
raise Client::AuthExpiredError,
|
|
38
|
+
"No #{@provider} API key configured. Set with: /provider set-key #{@provider} <key>"
|
|
35
39
|
end
|
|
36
40
|
end
|
|
37
41
|
|
|
@@ -18,8 +18,9 @@ module RubynCode
|
|
|
18
18
|
attr_accessor :model
|
|
19
19
|
|
|
20
20
|
def initialize(model: nil, provider: nil, adapter: nil)
|
|
21
|
-
|
|
22
|
-
@
|
|
21
|
+
settings = Config::Settings.new
|
|
22
|
+
@model = model || settings.model
|
|
23
|
+
@provider = provider || settings.provider
|
|
23
24
|
@adapter = adapter || resolve_adapter(@provider)
|
|
24
25
|
end
|
|
25
26
|
|
|
@@ -65,6 +66,22 @@ module RubynCode
|
|
|
65
66
|
|
|
66
67
|
private
|
|
67
68
|
|
|
69
|
+
def build_custom_adapter(provider, config, base_url, available_models)
|
|
70
|
+
case config.fetch('api_format', 'openai')
|
|
71
|
+
when 'anthropic'
|
|
72
|
+
Adapters::AnthropicCompatible.new(provider: provider, base_url: base_url, available_models: available_models)
|
|
73
|
+
else
|
|
74
|
+
Adapters::OpenAICompatible.new(provider: provider, base_url: base_url, available_models: available_models)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def extract_model_names(config)
|
|
79
|
+
raw = config&.dig('models')
|
|
80
|
+
return [] unless raw
|
|
81
|
+
|
|
82
|
+
raw.is_a?(Hash) ? raw.values : Array(raw)
|
|
83
|
+
end
|
|
84
|
+
|
|
68
85
|
# Builds the appropriate adapter for a given provider name.
|
|
69
86
|
def resolve_adapter(provider)
|
|
70
87
|
case provider
|
|
@@ -74,12 +91,20 @@ module RubynCode
|
|
|
74
91
|
config = Config::Settings.new.provider_config(provider)
|
|
75
92
|
base_url = config&.fetch('base_url', nil)
|
|
76
93
|
|
|
94
|
+
if config.nil?
|
|
95
|
+
raise ConfigError,
|
|
96
|
+
"Unknown provider '#{provider}'. " \
|
|
97
|
+
"Add it to config.yml under providers.#{provider} with base_url, env_key, and models."
|
|
98
|
+
end
|
|
99
|
+
|
|
77
100
|
unless base_url
|
|
78
101
|
raise ConfigError,
|
|
79
|
-
"
|
|
102
|
+
"Provider '#{provider}' is missing base_url in config.yml. " \
|
|
103
|
+
"Add base_url under providers.#{provider} (e.g., base_url: https://api.#{provider}.com/v1)"
|
|
80
104
|
end
|
|
81
105
|
|
|
82
|
-
|
|
106
|
+
available_models = extract_model_names(config)
|
|
107
|
+
build_custom_adapter(provider, config, base_url, available_models)
|
|
83
108
|
end
|
|
84
109
|
end
|
|
85
110
|
end
|
|
@@ -49,7 +49,8 @@ module RubynCode
|
|
|
49
49
|
def parse_servers(servers)
|
|
50
50
|
servers.map do |name, server_def|
|
|
51
51
|
{ name: name, command: server_def['command'],
|
|
52
|
-
args: Array(server_def['args']), env: expand_env(server_def['env'] || {})
|
|
52
|
+
args: Array(server_def['args']), env: expand_env(server_def['env'] || {}),
|
|
53
|
+
url: server_def['url'], timeout: server_def['timeout'] }
|
|
53
54
|
end
|
|
54
55
|
end
|
|
55
56
|
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
# Programmatic smoke test that exercises every major subsystem.
|
|
7
|
+
# Mirrors the checks in skills/rubyn_self_test.md but runs without
|
|
8
|
+
# the REPL or an LLM — suitable for CI and rake tasks.
|
|
9
|
+
#
|
|
10
|
+
# rubocop:disable Metrics/ClassLength -- intentionally comprehensive
|
|
11
|
+
class SelfTest
|
|
12
|
+
Result = Data.define(:name, :passed, :detail)
|
|
13
|
+
|
|
14
|
+
attr_reader :results, :project_root
|
|
15
|
+
|
|
16
|
+
def initialize(project_root: Dir.pwd)
|
|
17
|
+
@project_root = project_root
|
|
18
|
+
@results = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run! # rubocop:disable Naming/PredicateMethod -- returns bool but primary purpose is side effects
|
|
22
|
+
@results = []
|
|
23
|
+
|
|
24
|
+
check_file_operations
|
|
25
|
+
check_search
|
|
26
|
+
check_bash
|
|
27
|
+
check_git
|
|
28
|
+
check_specs
|
|
29
|
+
check_compressor_strategies
|
|
30
|
+
check_skills
|
|
31
|
+
check_config
|
|
32
|
+
check_codebase_index
|
|
33
|
+
check_slash_commands
|
|
34
|
+
check_architecture
|
|
35
|
+
|
|
36
|
+
print_scorecard
|
|
37
|
+
results.all?(&:passed)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# ── 1. File Operations ──────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def check_file_operations
|
|
45
|
+
check_file_read
|
|
46
|
+
check_file_write_edit_cleanup
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
record('File operations', false, e.message)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def check_file_read
|
|
52
|
+
content = File.read(File.join(project_root, 'lib/rubyn_code/version.rb'))
|
|
53
|
+
record('File read (version.rb)', content.include?('VERSION ='))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def check_file_write_edit_cleanup # rubocop:disable Metrics/AbcSize -- sequential file ops
|
|
57
|
+
tmp = File.join(project_root, '.rubyn-code/self_test_tmp.rb')
|
|
58
|
+
FileUtils.mkdir_p(File.dirname(tmp))
|
|
59
|
+
|
|
60
|
+
File.write(tmp, '# self-test')
|
|
61
|
+
record('File write (tmp)', File.exist?(tmp))
|
|
62
|
+
|
|
63
|
+
File.write(tmp, File.read(tmp).sub('# self-test', '# self-test passed'))
|
|
64
|
+
record('File edit (tmp)', File.read(tmp).include?('# self-test passed'))
|
|
65
|
+
|
|
66
|
+
File.delete(tmp)
|
|
67
|
+
record('File cleanup (tmp)', !File.exist?(tmp))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# ── 2. Search ───────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def check_search
|
|
73
|
+
rb_files = Dir.glob(File.join(project_root, 'lib/**/*.rb'))
|
|
74
|
+
record('Glob lib/**/*.rb', rb_files.size >= 50, "#{rb_files.size} files")
|
|
75
|
+
|
|
76
|
+
base_classes = rb_files.count { |f| File.read(f).match?(/class.*Base/) }
|
|
77
|
+
record('Grep class.*Base', base_classes >= 3, "#{base_classes} matches")
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
record('Search', false, e.message)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ── 3. Bash ─────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def check_bash
|
|
85
|
+
ruby_v = `ruby --version 2>&1`.strip
|
|
86
|
+
record('Bash: ruby --version', ruby_v.include?('ruby'), ruby_v)
|
|
87
|
+
|
|
88
|
+
rubocop_v = `bundle exec rubocop --version 2>&1`.strip
|
|
89
|
+
record('Bash: rubocop --version', rubocop_v.match?(/\d+\.\d+/), rubocop_v)
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
record('Bash', false, e.message)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# ── 4. Git ──────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def check_git
|
|
97
|
+
run_cmd('git status --short')
|
|
98
|
+
record('Git status', true)
|
|
99
|
+
|
|
100
|
+
log = run_cmd('git log --oneline -3')
|
|
101
|
+
record('Git log', log.match?(/^[0-9a-f]+/), log.lines.first&.strip)
|
|
102
|
+
|
|
103
|
+
run_cmd('git diff --stat')
|
|
104
|
+
record('Git diff', true)
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
record('Git', false, e.message)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# ── 5. Specs ────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def check_specs
|
|
112
|
+
%w[
|
|
113
|
+
spec/rubyn_code/tools/output_compressor_spec.rb
|
|
114
|
+
spec/rubyn_code/llm/model_router_spec.rb
|
|
115
|
+
].each { |spec| run_single_spec(spec) }
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
record('Specs', false, e.message)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run_single_spec(spec)
|
|
121
|
+
path = File.join(project_root, spec)
|
|
122
|
+
unless File.exist?(path)
|
|
123
|
+
record("RSpec: #{File.basename(spec)}", false, 'file not found')
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
output = run_cmd("bundle exec rspec #{path} --format progress 2>&1")
|
|
127
|
+
record("RSpec: #{File.basename(spec)}", output.include?('0 failures'))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ── 6. Output Compressor ────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
def check_compressor_strategies
|
|
133
|
+
compressor = Tools::OutputCompressor.new
|
|
134
|
+
verified = 0
|
|
135
|
+
|
|
136
|
+
verified += verify_head_tail(compressor)
|
|
137
|
+
verified += verify_spec_summary(compressor)
|
|
138
|
+
verified += verify_top_matches(compressor)
|
|
139
|
+
verified += verify_tree_collapse(compressor)
|
|
140
|
+
verified += verify_diff_hunks(compressor)
|
|
141
|
+
|
|
142
|
+
record('Compression strategies verified', verified >= 3, "#{verified}/5 active")
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
record('Output compressor', false, e.message)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def verify_head_tail(compressor)
|
|
148
|
+
big = (1..5000).to_a.join("\n")
|
|
149
|
+
compressed = compressor.compress('bash', big)
|
|
150
|
+
pass = compressed.length < big.length
|
|
151
|
+
record('Compressor: head_tail', pass)
|
|
152
|
+
pass ? 1 : 0
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def verify_spec_summary(compressor)
|
|
156
|
+
spec_out = run_cmd(
|
|
157
|
+
'bundle exec rspec spec/rubyn_code/tools/base_spec.rb --format documentation 2>&1'
|
|
158
|
+
)
|
|
159
|
+
compressed = compressor.compress('run_specs', spec_out)
|
|
160
|
+
pass = compressed.length < spec_out.length || compressed.include?('0 failures')
|
|
161
|
+
record('Compressor: spec_summary', pass)
|
|
162
|
+
pass ? 1 : 0
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def verify_top_matches(compressor)
|
|
166
|
+
grep_out = rb_files_with_def.join("\n")
|
|
167
|
+
compressed = compressor.compress('grep', grep_out)
|
|
168
|
+
pass = compressed.length <= grep_out.length
|
|
169
|
+
record('Compressor: top_matches', pass)
|
|
170
|
+
pass ? 1 : 0
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def verify_tree_collapse(compressor)
|
|
174
|
+
all_rb = Dir.glob(File.join(project_root, '**/*.rb')).join("\n")
|
|
175
|
+
compressed = compressor.compress('glob', all_rb)
|
|
176
|
+
pass = compressed.length <= all_rb.length
|
|
177
|
+
record('Compressor: tree_collapse', pass)
|
|
178
|
+
pass ? 1 : 0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def verify_diff_hunks(compressor)
|
|
182
|
+
diff = run_cmd('git log --oneline -1 --format=%H | xargs -I{} git diff {}~5..{} 2>/dev/null')
|
|
183
|
+
if diff.strip.empty?
|
|
184
|
+
record('Compressor: diff_hunks', true, 'SKIP — diff too small')
|
|
185
|
+
return 0
|
|
186
|
+
end
|
|
187
|
+
pass = compressor.compress('git_diff', diff).length <= diff.length
|
|
188
|
+
record('Compressor: diff_hunks', pass)
|
|
189
|
+
pass ? 1 : 0
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# ── 7. Skills ───────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
def check_skills
|
|
195
|
+
catalog = Skills::Catalog.new(project_root)
|
|
196
|
+
skills = catalog.available
|
|
197
|
+
record('Skills catalog', skills.size >= 10, "#{skills.size} skills")
|
|
198
|
+
rescue StandardError => e
|
|
199
|
+
record('Skills', false, e.message)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# ── 8. Config ───────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
def check_config
|
|
205
|
+
config_path = File.expand_path('~/.rubyn-code/config.yml')
|
|
206
|
+
if File.exist?(config_path)
|
|
207
|
+
record('Config (config.yml)', File.read(config_path).include?('provider'))
|
|
208
|
+
else
|
|
209
|
+
record('Config (config.yml)', false, 'not found')
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
profile = File.join(project_root, '.rubyn-code/project_profile.yml')
|
|
213
|
+
record('Config (project_profile)', File.exist?(profile),
|
|
214
|
+
File.exist?(profile) ? 'exists' : 'SKIP — first session')
|
|
215
|
+
rescue StandardError => e
|
|
216
|
+
record('Config', false, e.message)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# ── 9. Codebase Index ───────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
def check_codebase_index
|
|
222
|
+
path = File.join(project_root, '.rubyn-code/codebase_index.json')
|
|
223
|
+
record('Codebase index', File.exist?(path),
|
|
224
|
+
File.exist?(path) ? 'exists' : 'SKIP — first session')
|
|
225
|
+
rescue StandardError => e
|
|
226
|
+
record('Codebase index', false, e.message)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# ── 10. Slash Commands ──────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
def check_slash_commands
|
|
232
|
+
cmd_dir = File.join(project_root, 'lib/rubyn_code/cli/commands')
|
|
233
|
+
infra = %w[base.rb context.rb registry.rb]
|
|
234
|
+
cmds = Dir.glob(File.join(cmd_dir, '*.rb')).reject { |f| infra.include?(File.basename(f)) }
|
|
235
|
+
record('Slash commands', cmds.size >= 15, "#{cmds.size} commands")
|
|
236
|
+
rescue StandardError => e
|
|
237
|
+
record('Slash commands', false, e.message)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# ── 11. Architecture ────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
def check_architecture
|
|
243
|
+
check_autoloads
|
|
244
|
+
check_layer_dirs
|
|
245
|
+
check_core_modules
|
|
246
|
+
rescue StandardError => e
|
|
247
|
+
record('Architecture', false, e.message)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def check_autoloads
|
|
251
|
+
content = File.read(File.join(project_root, 'lib/rubyn_code.rb'))
|
|
252
|
+
autoloads = content.scan('autoload').size
|
|
253
|
+
record('Autoload entries', autoloads >= 40, "#{autoloads} entries")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def check_layer_dirs
|
|
257
|
+
dirs = Dir.glob(File.join(project_root, 'lib/rubyn_code/*/'))
|
|
258
|
+
record('Layer directories', dirs.size >= 14, "#{dirs.size} dirs")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def check_core_modules
|
|
262
|
+
content = File.read(File.join(project_root, 'lib/rubyn_code.rb'))
|
|
263
|
+
core = %w[Agent Tools Context Skills Memory Observability Learning]
|
|
264
|
+
found = core.select { |m| content.include?("module #{m}") }
|
|
265
|
+
record('Core modules', found.size == core.size, "#{found.size}/#{core.size}")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# ── Helpers ─────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
def record(name, passed, detail = nil)
|
|
271
|
+
@results << Result.new(name: name, passed: passed, detail: detail)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def run_cmd(cmd)
|
|
275
|
+
`cd #{project_root} && #{cmd} 2>&1`.strip
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def rb_files_with_def
|
|
279
|
+
Dir.glob(File.join(project_root, 'lib/**/*.rb')).flat_map do |f|
|
|
280
|
+
File.readlines(f).select { |l| l.include?('def ') }.map { |l| "#{f}:#{l.strip}" }
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def print_scorecard
|
|
285
|
+
puts
|
|
286
|
+
puts 'Rubyn Self-Test Results'
|
|
287
|
+
puts '=' * 50
|
|
288
|
+
results.each_with_index { |r, i| print_result(r, i + 1) }
|
|
289
|
+
print_summary
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def print_result(result, num)
|
|
293
|
+
icon = result.passed ? "\e[32m✅\e[0m" : "\e[31m❌\e[0m"
|
|
294
|
+
suffix = result.detail ? " — #{result.detail}" : ''
|
|
295
|
+
puts format(' %2<num>d. %<icon>s %<name>s%<suffix>s',
|
|
296
|
+
num: num, icon: icon, name: result.name, suffix: suffix)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def print_summary
|
|
300
|
+
passed = results.count(&:passed)
|
|
301
|
+
total = results.size
|
|
302
|
+
pct = total.positive? ? (passed * 100.0 / total).round : 0
|
|
303
|
+
failed = total - passed
|
|
304
|
+
|
|
305
|
+
puts '=' * 50
|
|
306
|
+
if failed.zero?
|
|
307
|
+
puts "\e[32mScore: #{passed}/#{total} (#{pct}%) — All systems go!\e[0m"
|
|
308
|
+
else
|
|
309
|
+
puts "\e[33mScore: #{passed}/#{total} (#{pct}%) — #{failed} failures\e[0m"
|
|
310
|
+
end
|
|
311
|
+
puts
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
# rubocop:enable Metrics/ClassLength
|
|
315
|
+
end
|
|
@@ -24,11 +24,53 @@ module RubynCode
|
|
|
24
24
|
@index
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
def list
|
|
28
|
+
available.map { |e| e[:name] }
|
|
29
|
+
end
|
|
30
|
+
|
|
27
31
|
def find(name)
|
|
28
32
|
entry = available.find { |e| e[:name] == name.to_s }
|
|
29
33
|
entry&.fetch(:path)
|
|
30
34
|
end
|
|
31
35
|
|
|
36
|
+
# Search skill content — matches against names, descriptions, and tags.
|
|
37
|
+
# Returns matching entries sorted by relevance (number of field matches).
|
|
38
|
+
#
|
|
39
|
+
# @param term [String] search term (case-insensitive)
|
|
40
|
+
# @return [Array<Hash>] matching entries with :name, :description, :path, :relevance
|
|
41
|
+
def search(term)
|
|
42
|
+
pattern = /#{Regexp.escape(term)}/i
|
|
43
|
+
matches = available.filter_map do |entry|
|
|
44
|
+
relevance = compute_relevance(entry, pattern)
|
|
45
|
+
next if relevance.zero?
|
|
46
|
+
|
|
47
|
+
entry.merge(relevance: relevance)
|
|
48
|
+
end
|
|
49
|
+
matches.sort_by { |e| -e[:relevance] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Filter skills by category (subdirectory name).
|
|
53
|
+
# Skills are organized in subdirectories under each skills_dir.
|
|
54
|
+
#
|
|
55
|
+
# @param category [String] category/directory name (e.g. "rails", "testing")
|
|
56
|
+
# @return [Array<Hash>] matching entries
|
|
57
|
+
def by_category(category)
|
|
58
|
+
normalized = category.to_s.downcase
|
|
59
|
+
available.select do |entry|
|
|
60
|
+
path_category(entry[:path]).downcase == normalized
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Return the list of unique categories derived from skill file paths.
|
|
65
|
+
#
|
|
66
|
+
# @return [Array<String>] sorted category names
|
|
67
|
+
def categories
|
|
68
|
+
available.map { |e| path_category(e[:path]) }
|
|
69
|
+
.reject(&:empty?)
|
|
70
|
+
.uniq
|
|
71
|
+
.sort
|
|
72
|
+
end
|
|
73
|
+
|
|
32
74
|
private
|
|
33
75
|
|
|
34
76
|
def build_index
|
|
@@ -60,11 +102,35 @@ module RubynCode
|
|
|
60
102
|
{
|
|
61
103
|
name: name,
|
|
62
104
|
description: doc.description,
|
|
105
|
+
tags: doc.tags,
|
|
63
106
|
path: File.expand_path(path)
|
|
64
107
|
}
|
|
65
108
|
rescue StandardError
|
|
66
109
|
nil
|
|
67
110
|
end
|
|
111
|
+
|
|
112
|
+
def compute_relevance(entry, pattern)
|
|
113
|
+
score = 0
|
|
114
|
+
score += 3 if entry[:name].to_s.match?(pattern)
|
|
115
|
+
score += 2 if entry[:description].to_s.match?(pattern)
|
|
116
|
+
Array(entry[:tags]).each { |tag| score += 1 if tag.to_s.match?(pattern) }
|
|
117
|
+
score
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Derive a category from the skill file path.
|
|
121
|
+
# The category is the immediate parent directory name relative to one of
|
|
122
|
+
# the skills_dirs. Skills at the top level of a skills_dir have no category.
|
|
123
|
+
def path_category(path)
|
|
124
|
+
skills_dirs.each do |dir|
|
|
125
|
+
expanded = File.expand_path(dir)
|
|
126
|
+
next unless path.start_with?(expanded)
|
|
127
|
+
|
|
128
|
+
relative = path.delete_prefix("#{expanded}/")
|
|
129
|
+
parts = relative.split('/')
|
|
130
|
+
return parts.size > 1 ? parts.first : ''
|
|
131
|
+
end
|
|
132
|
+
''
|
|
133
|
+
end
|
|
68
134
|
end
|
|
69
135
|
end
|
|
70
136
|
end
|
|
@@ -33,6 +33,31 @@ module RubynCode
|
|
|
33
33
|
catalog.descriptions
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Suggest skills based on what the codebase index reveals about the project.
|
|
37
|
+
#
|
|
38
|
+
# Inspects class names, parent classes, and file paths in the index to
|
|
39
|
+
# detect common Rails patterns (Devise, ActionMailer, ActiveJob, etc.)
|
|
40
|
+
# and returns matching skill names.
|
|
41
|
+
#
|
|
42
|
+
# @param codebase_index [RubynCode::Index::CodebaseIndex, nil]
|
|
43
|
+
# @param project_profile [Object, nil] reserved for future profile-based hints
|
|
44
|
+
# @return [Array<String>] suggested skill names (not loaded automatically)
|
|
45
|
+
def suggest_skills(codebase_index: nil, project_profile: nil) # rubocop:disable Lint/UnusedMethodArgument, Metrics/CyclomaticComplexity -- project_profile reserved for future use
|
|
46
|
+
return [] unless codebase_index
|
|
47
|
+
|
|
48
|
+
suggestions = []
|
|
49
|
+
node_names = codebase_index.nodes.map { |n| n['name'].to_s }
|
|
50
|
+
node_files = codebase_index.nodes.map { |n| n['file'].to_s }
|
|
51
|
+
|
|
52
|
+
suggestions << 'authentication' if detect_devise?(node_names, node_files)
|
|
53
|
+
suggestions << 'mailer' if detect_action_mailer?(node_names, node_files)
|
|
54
|
+
suggestions << 'background-job' if detect_active_job?(node_names, node_files)
|
|
55
|
+
|
|
56
|
+
suggestions
|
|
57
|
+
rescue StandardError
|
|
58
|
+
[]
|
|
59
|
+
end
|
|
60
|
+
|
|
36
61
|
private
|
|
37
62
|
|
|
38
63
|
def format_skill(doc)
|
|
@@ -50,6 +75,24 @@ module RubynCode
|
|
|
50
75
|
.gsub('>', '>')
|
|
51
76
|
.gsub('"', '"')
|
|
52
77
|
end
|
|
78
|
+
|
|
79
|
+
# Devise detection: look for Devise-related class names or config files.
|
|
80
|
+
def detect_devise?(node_names, node_files)
|
|
81
|
+
node_names.any? { |n| n.match?(/\bDevise\b/i) } ||
|
|
82
|
+
node_files.any? { |f| f.include?('devise') }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# ActionMailer detection: look for mailer classes or mailer directory.
|
|
86
|
+
def detect_action_mailer?(node_names, node_files)
|
|
87
|
+
node_names.any? { |n| n.match?(/Mailer\b/) } ||
|
|
88
|
+
node_files.any? { |f| f.include?('app/mailers/') }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ActiveJob detection: look for job classes or jobs directory.
|
|
92
|
+
def detect_active_job?(node_names, node_files)
|
|
93
|
+
node_names.any? { |n| n.match?(/Job\b/) } ||
|
|
94
|
+
node_files.any? { |f| f.include?('app/jobs/') }
|
|
95
|
+
end
|
|
53
96
|
end
|
|
54
97
|
end
|
|
55
98
|
end
|
|
@@ -37,6 +37,19 @@ module RubynCode
|
|
|
37
37
|
input_schema: Schema.build(parameters)
|
|
38
38
|
}
|
|
39
39
|
end
|
|
40
|
+
|
|
41
|
+
# One-line summary of a successful invocation, shown in the IDE's
|
|
42
|
+
# chat card. Default is empty so the UI renders a clean "Done"
|
|
43
|
+
# indicator. Override in subclasses that have a useful one-liner
|
|
44
|
+
# (e.g. "Edited app.rb (1 replacement)"). The full output still
|
|
45
|
+
# goes to the conversation untouched — this only affects the UI.
|
|
46
|
+
#
|
|
47
|
+
# @param output [String] what execute(**) returned
|
|
48
|
+
# @param args [Hash] the tool arguments (string-keyed)
|
|
49
|
+
# @return [String]
|
|
50
|
+
def summarize(_output, _args)
|
|
51
|
+
''
|
|
52
|
+
end
|
|
40
53
|
end
|
|
41
54
|
|
|
42
55
|
attr_reader :project_root
|
|
@@ -18,6 +18,11 @@ module RubynCode
|
|
|
18
18
|
RISK_LEVEL = :execute
|
|
19
19
|
REQUIRES_CONFIRMATION = true
|
|
20
20
|
|
|
21
|
+
def self.summarize(_output, args)
|
|
22
|
+
cmd = args['command'] || args[:command] || ''
|
|
23
|
+
"$ #{cmd[0, 180]}"
|
|
24
|
+
end
|
|
25
|
+
|
|
21
26
|
def execute(command:, timeout: 120)
|
|
22
27
|
validate_command!(command)
|
|
23
28
|
|