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
@@ -22,6 +22,12 @@ module RubynCode
22
22
  RISK_LEVEL = :write
23
23
  REQUIRES_CONFIRMATION = false
24
24
 
25
+ # Take the first line of the tool's output, which is already formatted
26
+ # as "Edited /path.rb (N replacements)".
27
+ def self.summarize(output, _args)
28
+ output.to_s.lines.first.to_s.chomp[0, 200]
29
+ end
30
+
25
31
  def execute(path:, old_text:, new_text:, replace_all: false)
26
32
  resolved = read_file_safely(path)
27
33
  content = File.read(resolved)
@@ -34,12 +40,34 @@ module RubynCode
34
40
  format_diff_result(path, content, old_text, new_text, replace_all)
35
41
  end
36
42
 
43
+ # Compute the proposed file content without writing to disk.
44
+ # Used by IDE mode to preview the edit in a diff view before the user
45
+ # accepts. Raises if old_text is missing or ambiguous, same as execute.
46
+ #
47
+ # @return [Hash] { content: String, type: 'modify' }
48
+ def preview_content(path:, old_text:, new_text:, replace_all: false)
49
+ resolved = read_file_safely(path)
50
+ content = File.read(resolved)
51
+
52
+ validate_occurrences!(path, content, old_text, replace_all)
53
+
54
+ { content: apply_replacement(content, old_text, new_text, replace_all), type: 'modify' }
55
+ end
56
+
37
57
  private
38
58
 
39
59
  def validate_occurrences!(path, content, old_text, replace_all)
40
60
  count = content.scan(old_text).length
41
61
 
42
- raise Error, "old_text not found in #{path}. No changes made." if count.zero?
62
+ # If exact match fails, try with normalized trailing whitespace on
63
+ # each line. Models sometimes strip or add trailing spaces/tabs.
64
+ if count.zero?
65
+ normalized_content = normalize_trailing_ws(content)
66
+ normalized_old = normalize_trailing_ws(old_text)
67
+ count = normalized_content.scan(normalized_old).length
68
+
69
+ raise Error, "old_text not found in #{path}. No changes made." if count.zero?
70
+ end
43
71
 
44
72
  return if replace_all || count == 1
45
73
 
@@ -49,7 +77,28 @@ module RubynCode
49
77
  end
50
78
 
51
79
  def apply_replacement(content, old_text, new_text, replace_all)
52
- replace_all ? content.gsub(old_text, new_text) : content.sub(old_text, new_text)
80
+ # Try exact match first
81
+ if content.include?(old_text)
82
+ return replace_all ? content.gsub(old_text, new_text) : content.sub(old_text, new_text)
83
+ end
84
+
85
+ # Fall back to normalized trailing-whitespace match
86
+ normalized_content = normalize_trailing_ws(content)
87
+ normalized_old = normalize_trailing_ws(old_text)
88
+
89
+ if normalized_content.include?(normalized_old)
90
+ if replace_all
91
+ normalized_content.gsub(normalized_old, new_text)
92
+ else
93
+ normalized_content.sub(normalized_old, new_text)
94
+ end
95
+ else
96
+ content.sub(old_text, new_text)
97
+ end
98
+ end
99
+
100
+ def normalize_trailing_ws(str)
101
+ str.gsub(/[^\S\n]+$/, '')
53
102
  end
54
103
 
55
104
  CONTEXT_LINES = 3 # rubocop:disable Lint/UselessConstantScoping
@@ -76,7 +125,7 @@ module RubynCode
76
125
  end
77
126
 
78
127
  def context_before(content, text)
79
- idx = content.index(text)
128
+ idx = find_index(content, text)
80
129
  return [] unless idx
81
130
 
82
131
  before = content[0...idx].lines.last(CONTEXT_LINES)
@@ -84,7 +133,7 @@ module RubynCode
84
133
  end
85
134
 
86
135
  def context_after(content, text)
87
- idx = content.index(text)
136
+ idx = find_index(content, text)
88
137
  return [] unless idx
89
138
 
90
139
  after_start = idx + text.length
@@ -93,11 +142,19 @@ module RubynCode
93
142
  end
94
143
 
95
144
  def find_line_number(content, text)
96
- idx = content.index(text)
145
+ idx = find_index(content, text)
97
146
  return nil unless idx
98
147
 
99
148
  content[0...idx].count("\n") + 1
100
149
  end
150
+
151
+ # Find the index of text in content, falling back to normalized match.
152
+ def find_index(content, text)
153
+ idx = content.index(text)
154
+ return idx if idx
155
+
156
+ normalize_trailing_ws(content).index(normalize_trailing_ws(text))
157
+ end
101
158
  end
102
159
 
103
160
  Registry.register(EditFile)
@@ -4,17 +4,20 @@ module RubynCode
4
4
  module Tools
5
5
  class Executor
6
6
  attr_reader :project_root, :output_compressor, :file_cache
7
- attr_accessor :llm_client, :background_worker, :on_agent_status, :db, :ask_user_callback
7
+ attr_accessor :llm_client, :background_worker, :on_agent_status, :db, :ask_user_callback,
8
+ :codebase_index, :ide_client
8
9
 
9
- def initialize(project_root:)
10
+ def initialize(project_root:, ide_client: nil)
10
11
  @project_root = File.expand_path(project_root)
12
+ @ide_client = ide_client
11
13
  @injections = {}
12
14
  @output_compressor = OutputCompressor.new
13
15
  @file_cache = FileCache.new
14
16
  Registry.load_all!
15
17
  end
16
18
 
17
- def execute(tool_name, params) # rubocop:disable Metrics/AbcSize -- maps tool errors to results
19
+ # -- maps tool errors to results
20
+ def execute(tool_name, params)
18
21
  # File cache intercept: serve cached reads, invalidate on writes
19
22
  cached = try_file_cache(tool_name, params)
20
23
  return cached if cached
@@ -23,6 +26,7 @@ module RubynCode
23
26
  filtered = filter_params(tool, params)
24
27
  raw = tool.truncate(tool.execute(**filtered).to_s)
25
28
  update_file_cache(tool_name, filtered, raw)
29
+ maybe_update_codebase_index(tool_name, filtered)
26
30
  @output_compressor.compress(tool_name, raw)
27
31
  rescue ToolNotFoundError => e
28
32
  error_result("Tool error: #{e.message}")
@@ -40,11 +44,24 @@ module RubynCode
40
44
  Registry.tool_definitions
41
45
  end
42
46
 
47
+ # Patterns that indicate a bash command writes to a file.
48
+ BASH_WRITE_PATTERNS = [
49
+ /(?:>>?)\s*(\S+)/, # > file or >> file
50
+ /\btee\s+(?:-a\s+)?(\S+)/, # tee file or tee -a file
51
+ /\bsed\s+-i\S*\s+.*\s(\S+)$/, # sed -i 's/...' file
52
+ /\bsed\s+-i\S*\s+.*\s(\S+)\s/ # sed -i 's/...' file (mid-command)
53
+ ].freeze
54
+
43
55
  private
44
56
 
45
57
  def build_tool(tool_name)
46
58
  tool_class = Registry.get(tool_name)
47
- tool = tool_class.new(project_root: project_root)
59
+ # IDE-aware tools accept an ide_client parameter.
60
+ if @ide_client && tool_class.method(:new).parameters.any? { |_, name| name == :ide_client }
61
+ tool = tool_class.new(project_root: project_root, ide_client: @ide_client)
62
+ else
63
+ tool = tool_class.new(project_root: project_root)
64
+ end
48
65
  inject_dependencies(tool, tool_name)
49
66
  tool
50
67
  end
@@ -57,7 +74,8 @@ module RubynCode
57
74
  allowed.empty? ? symbolized : symbolized.slice(*allowed)
58
75
  end
59
76
 
60
- def inject_dependencies(tool, tool_name) # rubocop:disable Metrics/CyclomaticComplexity -- tool-specific dependency injection
77
+ # -- tool-specific dependency injection
78
+ def inject_dependencies(tool, tool_name)
61
79
  case tool_name
62
80
  when 'spawn_agent', 'spawn_teammate'
63
81
  inject_agent_deps(tool)
@@ -88,13 +106,17 @@ module RubynCode
88
106
  end
89
107
 
90
108
  # Cache read_file results; invalidate on write_file/edit_file.
109
+ # Also detects bash commands that write to files (redirect, sed -i, tee).
91
110
  def update_file_cache(tool_name, params, _raw)
92
111
  path = resolve_cache_path(params)
93
- return unless path
94
112
 
95
113
  case tool_name
96
- when 'read_file' then @file_cache.read(path) # populates cache
97
- when 'write_file', 'edit_file' then @file_cache.on_write(path)
114
+ when 'read_file'
115
+ @file_cache.read(path) if path # populates cache
116
+ when 'write_file', 'edit_file'
117
+ @file_cache.on_write(path) if path
118
+ when 'bash'
119
+ invalidate_bash_write_targets(params)
98
120
  end
99
121
  rescue StandardError
100
122
  nil
@@ -109,6 +131,41 @@ module RubynCode
109
131
  nil
110
132
  end
111
133
 
134
+ # Trigger an incremental codebase index update after writing a Ruby file.
135
+ # Non-blocking: if the update fails, log and continue.
136
+ def maybe_update_codebase_index(tool_name, params)
137
+ return unless %w[write_file edit_file].include?(tool_name)
138
+ return unless @codebase_index
139
+
140
+ path = resolve_cache_path(params)
141
+ return unless path&.end_with?('.rb')
142
+
143
+ @codebase_index.update!
144
+ rescue StandardError => e
145
+ RubynCode::Debug.warn("CodebaseIndex incremental update failed: #{e.message}")
146
+ end
147
+
148
+ # Detect file paths that a bash command may have written to and
149
+ # invalidate them from the file cache.
150
+ def invalidate_bash_write_targets(params)
151
+ command = params[:command] || params['command']
152
+ return unless command.is_a?(String)
153
+
154
+ paths = extract_bash_write_paths(command)
155
+ paths.each do |p|
156
+ resolved = File.expand_path(p, @project_root)
157
+ @file_cache.on_write(resolved)
158
+ end
159
+ end
160
+
161
+ def extract_bash_write_paths(command)
162
+ paths = []
163
+ BASH_WRITE_PATTERNS.each do |pattern|
164
+ command.scan(pattern) { |match| paths << match[0] if match[0] }
165
+ end
166
+ paths.uniq
167
+ end
168
+
112
169
  def error_result(message)
113
170
  message
114
171
  end
@@ -21,6 +21,12 @@ module RubynCode
21
21
  RISK_LEVEL = :read
22
22
  REQUIRES_CONFIRMATION = false
23
23
 
24
+ def self.summarize(output, args)
25
+ pattern = args['pattern'] || args[:pattern] || ''
26
+ count = output.to_s.strip.empty? ? 0 : output.to_s.lines.count
27
+ "glob #{pattern} (#{count} files)"
28
+ end
29
+
24
30
  def execute(pattern:, path: nil)
25
31
  search_dir = resolve_search_dir(path)
26
32
  full_pattern = File.join(search_dir, pattern)
@@ -20,6 +20,13 @@ module RubynCode
20
20
  RISK_LEVEL = :read
21
21
  REQUIRES_CONFIRMATION = false
22
22
 
23
+ def self.summarize(output, args)
24
+ pattern = args['pattern'] || args[:pattern] || ''
25
+ count = output.to_s.lines.count
26
+ no_matches = count.zero? || output.to_s.start_with?('No matches')
27
+ no_matches ? "grep #{pattern} (0 matches)" : "grep #{pattern} (#{count} lines)"
28
+ end
29
+
23
30
  def execute(pattern:, path: nil, glob_filter: nil, max_results: 50)
24
31
  search_path = path ? safe_path(path) : project_root
25
32
  regex = Regexp.new(pattern)
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Tools
5
+ # Retrieves VS Code diagnostics (errors/warnings from the Problems panel)
6
+ # via the IDE RPC bridge. Only available when running in IDE mode.
7
+ class IdeDiagnostics < Base
8
+ TOOL_NAME = 'ide_diagnostics'
9
+ DESCRIPTION =
10
+ 'Get VS Code diagnostics (errors, warnings) for a file or the whole workspace. ' \
11
+ 'Only available in IDE mode.'
12
+ PARAMETERS = {
13
+ file: {
14
+ type: 'string',
15
+ description: 'File path to get diagnostics for. Omit to get all workspace diagnostics.'
16
+ }
17
+ }.freeze
18
+ RISK_LEVEL = :read
19
+
20
+ def initialize(project_root:, ide_client: nil)
21
+ super(project_root: project_root)
22
+ @ide_client = ide_client
23
+ end
24
+
25
+ def execute(**params)
26
+ unless @ide_client
27
+ return 'IDE diagnostics are only available when running inside VS Code.'
28
+ end
29
+
30
+ rpc_params = {}
31
+ rpc_params[:file] = params[:file] if params[:file]
32
+
33
+ result = @ide_client.request('ide/getDiagnostics', rpc_params, timeout: 10)
34
+ diagnostics = result['diagnostics'] || []
35
+
36
+ return 'No diagnostics found.' if diagnostics.empty?
37
+
38
+ lines = diagnostics.map do |d|
39
+ severity = d['severity']&.upcase || 'INFO'
40
+ source = d['source'] ? " (#{d['source']})" : ''
41
+ "#{severity}: #{d['file']}:#{d['line']} — #{d['message']}#{source}"
42
+ end
43
+
44
+ lines.join("\n")
45
+ end
46
+
47
+ def self.summarize(output, _args)
48
+ count = output.lines.count
49
+ "#{count} diagnostic(s)"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Tools
5
+ # Searches VS Code workspace symbols via the language server.
6
+ # Only available when running in IDE mode.
7
+ class IdeSymbols < Base
8
+ TOOL_NAME = 'ide_symbols'
9
+ DESCRIPTION =
10
+ 'Search workspace symbols (classes, methods, modules) via VS Code language server. ' \
11
+ 'Only available in IDE mode.'
12
+ PARAMETERS = {
13
+ query: {
14
+ type: 'string',
15
+ description: 'Symbol search query (e.g. "User", "authenticate")',
16
+ required: true
17
+ }
18
+ }.freeze
19
+ RISK_LEVEL = :read
20
+
21
+ def initialize(project_root:, ide_client: nil)
22
+ super(project_root: project_root)
23
+ @ide_client = ide_client
24
+ end
25
+
26
+ def execute(**params)
27
+ unless @ide_client
28
+ return 'IDE symbols are only available when running inside VS Code.'
29
+ end
30
+
31
+ query = params[:query] || ''
32
+ return 'Query is required.' if query.empty?
33
+
34
+ result = @ide_client.request('ide/getWorkspaceSymbols', { query: query }, timeout: 10)
35
+ symbols = result['symbols'] || []
36
+
37
+ return "No symbols found matching '#{query}'." if symbols.empty?
38
+
39
+ lines = symbols.first(50).map do |s|
40
+ container = s['containerName'] ? " (in #{s['containerName']})" : ''
41
+ line_info = s['line'] ? ":#{s['line']}" : ''
42
+ "#{s['kind']} #{s['name']}#{container} — #{s['file']}#{line_info}"
43
+ end
44
+
45
+ header = "Found #{symbols.size} symbol(s) matching '#{query}':"
46
+ ([header] + lines).join("\n")
47
+ end
48
+
49
+ def self.summarize(output, _args)
50
+ first_line = output.lines.first&.strip || ''
51
+ first_line.start_with?('Found') ? first_line : ''
52
+ end
53
+ end
54
+ end
55
+ end
@@ -36,7 +36,8 @@ module RubynCode
36
36
  skills_dirs = [
37
37
  File.expand_path('../../../skills', __dir__), # bundled gem skills
38
38
  File.join(project_root, '.rubyn-code', 'skills'), # project skills
39
- File.join(Dir.home, '.rubyn-code', 'skills') # global user skills
39
+ File.join(Dir.home, '.rubyn-code', 'skills'), # global user skills
40
+ File.join(Dir.home, '.rubyn-code', 'skill-packs') # registry-installed packs
40
41
  ]
41
42
  catalog = Skills::Catalog.new(skills_dirs.select { |d| Dir.exist?(d) })
42
43
  Skills::Loader.new(catalog)
@@ -63,7 +63,12 @@ module RubynCode
63
63
  failures = extract_spec_failures(lines)
64
64
  return summary_line.strip if failures.empty? && summary_line
65
65
 
66
- assemble_failure_report(failures, summary_line)
66
+ result = assemble_failure_report(failures, summary_line)
67
+
68
+ # Guard: if compression produced an empty string (no summary line
69
+ # and no extractable failures), return the original output so the
70
+ # agent still sees spec results.
71
+ result.nil? || result.strip.empty? ? output : result
67
72
  end
68
73
 
69
74
  def find_summary_line(lines)
@@ -98,7 +103,7 @@ module RubynCode
98
103
  failures
99
104
  end
100
105
 
101
- # rubocop:disable Metrics/AbcSize -- head/tail splitting requires coordinated arithmetic
106
+ # -- head/tail splitting requires coordinated arithmetic
102
107
  def head_tail(output, max_chars)
103
108
  lines = output.lines
104
109
  return output if lines.size <= 10
@@ -115,9 +120,8 @@ module RubynCode
115
120
  parts << tail_lines.join
116
121
  parts.join
117
122
  end
118
- # rubocop:enable Metrics/AbcSize
119
123
 
120
- # rubocop:disable Metrics/AbcSize -- diff hunk iteration with header extraction
124
+ # -- diff hunk iteration with header extraction
121
125
  def compress_diff(output, max_chars)
122
126
  hunks = output.split(/^(?=diff --git)/)
123
127
  return head_tail(output, max_chars) if hunks.size <= 1
@@ -135,7 +139,6 @@ module RubynCode
135
139
 
136
140
  result
137
141
  end
138
- # rubocop:enable Metrics/AbcSize
139
142
 
140
143
  def top_matches(output, max_chars)
141
144
  lines = output.lines
@@ -147,7 +150,7 @@ module RubynCode
147
150
  result
148
151
  end
149
152
 
150
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity -- multi-step tree collapse
153
+ # -- multi-step tree collapse
151
154
  def collapse_tree(output, max_chars)
152
155
  paths = output.lines.map(&:strip).reject(&:empty?)
153
156
  return output if output.length <= max_chars
@@ -159,7 +162,6 @@ module RubynCode
159
162
 
160
163
  head_tail(result, max_chars)
161
164
  end
162
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
163
165
 
164
166
  def take_lines_up_to(lines, max_chars)
165
167
  taken = []
@@ -17,6 +17,12 @@ module RubynCode
17
17
  RISK_LEVEL = :read
18
18
  REQUIRES_CONFIRMATION = false
19
19
 
20
+ def self.summarize(output, args)
21
+ path = args['path'] || args[:path] || ''
22
+ line_count = output.to_s.lines.count
23
+ "Read #{path} (#{line_count} lines)"
24
+ end
25
+
20
26
  def execute(path:, offset: nil, limit: nil)
21
27
  resolved = read_file_safely(path)
22
28
  lines = File.readlines(resolved)
@@ -33,15 +33,26 @@ module RubynCode
33
33
  @tools = {}
34
34
  end
35
35
 
36
+ # IDE-only tools that require an ide_client to function.
37
+ IDE_ONLY_TOOLS = %w[ide_diagnostics ide_symbols].freeze
38
+
36
39
  def load_all!
37
40
  tool_files = Dir[File.join(__dir__, '*.rb')]
38
41
  tool_files.each do |file|
39
42
  basename = File.basename(file, '.rb')
40
43
  next if %w[base registry schema executor].include?(basename)
44
+ next if IDE_ONLY_TOOLS.include?(basename)
41
45
 
42
46
  require_relative basename
43
47
  end
44
48
  end
49
+
50
+ # Register IDE-only tools when an ide_client is available.
51
+ def load_ide_tools!
52
+ IDE_ONLY_TOOLS.each do |name|
53
+ require_relative name
54
+ end
55
+ end
45
56
  end
46
57
  end
47
58
  end
@@ -24,7 +24,7 @@ module RubynCode
24
24
  }.freeze
25
25
  RISK_LEVEL = :read
26
26
 
27
- def execute(base_branch: 'main', focus: 'all')
27
+ def execute(base_branch: 'main', focus: 'all', pack_context: nil)
28
28
  error = validate_git_repo
29
29
  return error if error
30
30
 
@@ -37,7 +37,7 @@ module RubynCode
37
37
  diff = run_git("diff #{base_branch}...HEAD")
38
38
  return "No changes found between #{current} and #{base_branch}." if diff.strip.empty?
39
39
 
40
- build_full_review(current, base_branch, diff, focus)
40
+ build_full_review(current, base_branch, diff, focus, pack_context: pack_context)
41
41
  end
42
42
 
43
43
  FILE_CATEGORIES = [
@@ -80,8 +80,9 @@ module RubynCode
80
80
  [nil, "Error: Base branch '#{base_branch}' not found."]
81
81
  end
82
82
 
83
- def build_full_review(current, base_branch, diff, focus)
84
- review = build_review_header(current, base_branch)
83
+ def build_full_review(current, base_branch, diff, focus, pack_context: nil)
84
+ review = build_pack_context_section(pack_context)
85
+ review.concat(build_review_header(current, base_branch))
85
86
  review.concat(build_file_categories(base_branch))
86
87
  review.concat(build_focus_section(focus))
87
88
  review.concat(build_diff_section(diff))
@@ -158,6 +159,16 @@ module RubynCode
158
159
  ]
159
160
  end
160
161
 
162
+ def build_pack_context_section(pack_context)
163
+ return [] if pack_context.nil? || pack_context.strip.empty?
164
+
165
+ [
166
+ '## Skill Pack Context',
167
+ pack_context.strip,
168
+ ''
169
+ ]
170
+ end
171
+
161
172
  def run_git(command)
162
173
  `cd #{project_root} && git #{command} 2>/dev/null`
163
174
  end
@@ -85,7 +85,8 @@ module RubynCode
85
85
  html.scan(%r{<a[^>]+href="(https?://(?!lite\.duckduckgo)[^"]+)"[^>]*>(.*?)</a>}i)
86
86
  end
87
87
 
88
- def build_ddg_results(links, snippets, max) # rubocop:disable Metrics/AbcSize -- HTML parsing with filtering
88
+ # -- HTML parsing with filtering
89
+ def build_ddg_results(links, snippets, max)
89
90
  results = []
90
91
  links.each_with_index do |match, idx|
91
92
  break if results.length >= max
@@ -18,6 +18,12 @@ module RubynCode
18
18
 
19
19
  PREVIEW_LINES = 15
20
20
 
21
+ # Take the first line of the tool's output, which is already formatted
22
+ # as "Updated /path.rb (N bytes)" or "Created /path.rb (N bytes)".
23
+ def self.summarize(output, _args)
24
+ output.to_s.lines.first.to_s.chomp[0, 200]
25
+ end
26
+
21
27
  def execute(path:, content:)
22
28
  resolved = safe_path(path)
23
29
  existed = File.exist?(resolved)
@@ -29,6 +35,17 @@ module RubynCode
29
35
  format_result(path, bytes, existed, old_content, content)
30
36
  end
31
37
 
38
+ # Compute the proposed file content without writing to disk.
39
+ # Used by IDE mode to preview the write in a diff view (modify) or
40
+ # preview tab (create) before the user accepts.
41
+ #
42
+ # @return [Hash] { content: String, type: 'modify' | 'create' }
43
+ def preview_content(path:, content:)
44
+ resolved = safe_path(path)
45
+ type = File.exist?(resolved) ? 'modify' : 'create'
46
+ { content: content, type: type }
47
+ end
48
+
32
49
  private
33
50
 
34
51
  def format_result(path, bytes, existed, old_content, new_content)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubynCode
4
- VERSION = '0.3.0'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/rubyn_code.rb CHANGED
@@ -11,6 +11,10 @@ module RubynCode
11
11
  class StallDetectedError < Error; end
12
12
  class ToolNotFoundError < Error; end
13
13
  class ConfigError < Error; end
14
+ # Raised when the user refuses a tool invocation in IDE mode. Signals the
15
+ # agent loop to surface this as is_error: true so the model sees a refusal
16
+ # rather than a successful tool call returning a string like "denied".
17
+ class UserDeniedError < Error; end
14
18
 
15
19
  # Infrastructure
16
20
  autoload :Config, 'rubyn_code/config/settings'
@@ -29,6 +33,7 @@ module RubynCode
29
33
  # Auth
30
34
  module Auth
31
35
  autoload :OAuth, 'rubyn_code/auth/oauth'
36
+ autoload :KeyEncryption, 'rubyn_code/auth/key_encryption'
32
37
  autoload :TokenStore, 'rubyn_code/auth/token_store'
33
38
  autoload :Server, 'rubyn_code/auth/server'
34
39
  end
@@ -45,6 +50,7 @@ module RubynCode
45
50
  autoload :JsonParsing, 'rubyn_code/llm/adapters/json_parsing'
46
51
  autoload :PromptCaching, 'rubyn_code/llm/adapters/prompt_caching'
47
52
  autoload :Anthropic, 'rubyn_code/llm/adapters/anthropic'
53
+ autoload :AnthropicCompatible, 'rubyn_code/llm/adapters/anthropic_compatible'
48
54
  autoload :AnthropicStreaming, 'rubyn_code/llm/adapters/anthropic_streaming'
49
55
  autoload :OpenAI, 'rubyn_code/llm/adapters/openai'
50
56
  autoload :OpenAIStreaming, 'rubyn_code/llm/adapters/openai_streaming'
@@ -131,7 +137,16 @@ module RubynCode
131
137
  autoload :Loader, 'rubyn_code/skills/loader'
132
138
  autoload :Catalog, 'rubyn_code/skills/catalog'
133
139
  autoload :Document, 'rubyn_code/skills/document'
140
+ autoload :Matcher, 'rubyn_code/skills/matcher'
141
+ autoload :RegistryAutoload, 'rubyn_code/skills/registry_autoload'
134
142
  autoload :TtlManager, 'rubyn_code/skills/ttl_manager'
143
+ autoload :RegistryClient, 'rubyn_code/skills/registry_client'
144
+ autoload :PackManager, 'rubyn_code/skills/pack_manager'
145
+ autoload :PackInstaller, 'rubyn_code/skills/pack_installer'
146
+ autoload :PackContext, 'rubyn_code/skills/pack_context'
147
+ autoload :GemfileParser, 'rubyn_code/skills/gemfile_parser'
148
+ autoload :AutoSuggest, 'rubyn_code/skills/auto_suggest'
149
+ autoload :RegistryError, 'rubyn_code/skills/registry_client'
135
150
  end
136
151
 
137
152
  # Layer 6: Sub-Agents
@@ -220,6 +235,19 @@ module RubynCode
220
235
  autoload :Shortcut, 'rubyn_code/learning/shortcut'
221
236
  end
222
237
 
238
+ # IDE (VS Code extension server)
239
+ module IDE
240
+ autoload :Protocol, 'rubyn_code/ide/protocol'
241
+ autoload :Server, 'rubyn_code/ide/server'
242
+
243
+ module Adapters
244
+ autoload :ToolOutput, 'rubyn_code/ide/adapters/tool_output'
245
+ end
246
+ end
247
+
248
+ # Self-Test
249
+ autoload :SelfTest, 'rubyn_code/self_test'
250
+
223
251
  # Codebase Index
224
252
  module Index
225
253
  autoload :CodebaseIndex, 'rubyn_code/index/codebase_index'
@@ -234,6 +262,7 @@ module RubynCode
234
262
  autoload :Spinner, 'rubyn_code/cli/spinner'
235
263
  autoload :StreamFormatter, 'rubyn_code/cli/stream_formatter'
236
264
  autoload :Setup, 'rubyn_code/cli/setup'
265
+ autoload :FirstRun, 'rubyn_code/cli/first_run'
237
266
  autoload :DaemonRunner, 'rubyn_code/cli/daemon_runner'
238
267
  autoload :VersionCheck, 'rubyn_code/cli/version_check'
239
268
 
@@ -260,8 +289,13 @@ module RubynCode
260
289
  autoload :Plan, 'rubyn_code/cli/commands/plan'
261
290
  autoload :ContextInfo, 'rubyn_code/cli/commands/context_info'
262
291
  autoload :Diff, 'rubyn_code/cli/commands/diff'
292
+ autoload :Mcp, 'rubyn_code/cli/commands/mcp'
263
293
  autoload :Model, 'rubyn_code/cli/commands/model'
264
294
  autoload :NewSession, 'rubyn_code/cli/commands/new_session'
295
+ autoload :Provider, 'rubyn_code/cli/commands/provider'
296
+ autoload :InstallSkills, 'rubyn_code/cli/commands/install_skills'
297
+ autoload :RemoveSkills, 'rubyn_code/cli/commands/remove_skills'
298
+ autoload :Skills, 'rubyn_code/cli/commands/skills'
265
299
  end
266
300
  end
267
301