rubyn-code 0.4.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -2
  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 +85 -11
  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/provider.rb +2 -1
  14. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  15. data/lib/rubyn_code/cli/commands/skill.rb +4 -2
  16. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  17. data/lib/rubyn_code/cli/repl.rb +11 -1
  18. data/lib/rubyn_code/cli/repl_commands.rb +2 -1
  19. data/lib/rubyn_code/cli/repl_setup.rb +38 -1
  20. data/lib/rubyn_code/config/defaults.rb +2 -0
  21. data/lib/rubyn_code/config/settings.rb +5 -2
  22. data/lib/rubyn_code/context/context_budget.rb +2 -1
  23. data/lib/rubyn_code/context/manager.rb +3 -3
  24. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +6 -3
  25. data/lib/rubyn_code/ide/handlers/review_handler.rb +19 -2
  26. data/lib/rubyn_code/ide/protocol.rb +2 -1
  27. data/lib/rubyn_code/index/codebase_index.rb +2 -1
  28. data/lib/rubyn_code/learning/extractor.rb +4 -2
  29. data/lib/rubyn_code/llm/model_router.rb +2 -1
  30. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  31. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  32. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  33. data/lib/rubyn_code/self_test.rb +2 -1
  34. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  35. data/lib/rubyn_code/skills/catalog.rb +10 -0
  36. data/lib/rubyn_code/skills/document.rb +8 -2
  37. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  38. data/lib/rubyn_code/skills/loader.rb +1 -1
  39. data/lib/rubyn_code/skills/matcher.rb +89 -0
  40. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  41. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  42. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  43. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  44. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  45. data/lib/rubyn_code/tools/executor.rb +4 -2
  46. data/lib/rubyn_code/tools/grep.rb +2 -1
  47. data/lib/rubyn_code/tools/ide_diagnostics.rb +3 -1
  48. data/lib/rubyn_code/tools/ide_symbols.rb +3 -1
  49. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  50. data/lib/rubyn_code/tools/output_compressor.rb +3 -6
  51. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  52. data/lib/rubyn_code/tools/web_search.rb +2 -1
  53. data/lib/rubyn_code/version.rb +1 -1
  54. data/lib/rubyn_code.rb +12 -0
  55. data/skills/rubyn_self_test.md +75 -0
  56. metadata +13 -1
@@ -97,7 +97,8 @@ module RubynCode
97
97
  table
98
98
  end
99
99
 
100
- def fill_lcs_row(table, row, old_lines, new_lines, col_count) # rubocop:disable Metrics/AbcSize -- LCS algorithm step
100
+ # -- LCS algorithm step
101
+ def fill_lcs_row(table, row, old_lines, new_lines, col_count)
101
102
  (1..col_count).each do |col|
102
103
  table[row][col] = if old_lines[row - 1] == new_lines[col - 1]
103
104
  table[row - 1][col - 1] + 1
@@ -121,7 +122,7 @@ module RubynCode
121
122
  result
122
123
  end
123
124
 
124
- def backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx) # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists -- LCS backtrack step requires all state
125
+ def backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx) # rubocop:disable Metrics/ParameterLists -- LCS backtrack step requires all state
125
126
  if lines_match?(old_lines, new_lines, old_idx, new_idx)
126
127
  result.unshift([:equal, old_idx - 1, new_idx - 1])
127
128
  [old_idx - 1, new_idx - 1]
@@ -53,7 +53,8 @@ module RubynCode
53
53
  record('File read (version.rb)', content.include?('VERSION ='))
54
54
  end
55
55
 
56
- def check_file_write_edit_cleanup # rubocop:disable Metrics/AbcSize -- sequential file ops
56
+ # -- sequential file ops
57
+ def check_file_write_edit_cleanup
57
58
  tmp = File.join(project_root, '.rubyn-code/self_test_tmp.rb')
58
59
  FileUtils.mkdir_p(File.dirname(tmp))
59
60
 
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'gemfile_parser'
5
+
6
+ module RubynCode
7
+ module Skills
8
+ # Suggests skill packs based on gems detected in the project's Gemfile.
9
+ #
10
+ # On session start, parses the Gemfile, queries the registry for matching
11
+ # packs, and shows a one-time suggestion. Tracks shown suggestions in
12
+ # `.rubyn-code/suggested.json` to avoid repeating.
13
+ class AutoSuggest
14
+ SUGGESTED_FILE = 'suggested.json'
15
+
16
+ # @param project_root [String]
17
+ # @param registry_client [RegistryClient]
18
+ def initialize(project_root:, registry_client: nil)
19
+ @project_root = project_root
20
+ @client = registry_client || RegistryClient.new
21
+ end
22
+
23
+ # Check for suggestable packs and return a display message if any.
24
+ # Returns nil if no suggestions or if all have been shown/dismissed.
25
+ #
26
+ # This method never raises — registry failures are silently swallowed
27
+ # to avoid blocking session start.
28
+ #
29
+ # @return [String, nil] suggestion message or nil
30
+ def check
31
+ gems = parse_gemfile
32
+ return nil if gems.empty?
33
+
34
+ suggestions = fetch_suggestions(gems)
35
+ return nil if suggestions.empty?
36
+
37
+ new_suggestions = filter_shown(suggestions)
38
+ return nil if new_suggestions.empty?
39
+
40
+ record_shown(new_suggestions)
41
+ format_message(new_suggestions)
42
+ rescue StandardError
43
+ nil
44
+ end
45
+
46
+ # Mark a pack as installed so it won't be suggested again.
47
+ #
48
+ # @param name [String] pack name
49
+ def mark_installed(name)
50
+ state = load_state
51
+ state['installed'] ||= []
52
+ state['installed'] << name unless state['installed'].include?(name)
53
+ save_state(state)
54
+ end
55
+
56
+ # Mark a suggestion as dismissed.
57
+ #
58
+ # @param name [String] pack name
59
+ def mark_dismissed(name)
60
+ state = load_state
61
+ state['dismissed'] ||= []
62
+ state['dismissed'] << name unless state['dismissed'].include?(name)
63
+ save_state(state)
64
+ end
65
+
66
+ private
67
+
68
+ def parse_gemfile
69
+ gemfile_path = File.join(@project_root, 'Gemfile')
70
+ return [] unless File.exist?(gemfile_path)
71
+
72
+ GemfileParser.gems(File.read(gemfile_path))
73
+ rescue StandardError
74
+ []
75
+ end
76
+
77
+ def fetch_suggestions(gems)
78
+ @client.fetch_suggestions(gems)
79
+ rescue RegistryError
80
+ []
81
+ end
82
+
83
+ def filter_shown(suggestions)
84
+ state = load_state
85
+ shown = Array(state['shown'])
86
+ installed = Array(state['installed'])
87
+ dismissed = Array(state['dismissed'])
88
+ skip = (shown + installed + dismissed).uniq
89
+
90
+ suggestions.reject { |s| skip.include?(s['name']) }
91
+ end
92
+
93
+ def record_shown(suggestions)
94
+ state = load_state
95
+ state['shown'] ||= []
96
+ suggestions.each do |s|
97
+ state['shown'] << s['name'] unless state['shown'].include?(s['name'])
98
+ end
99
+ save_state(state)
100
+ end
101
+
102
+ def format_message(suggestions)
103
+ gem_names = suggestions.map { |s| s['name'] }.join(', ')
104
+ details = suggestions.map { |s| "#{s['name']} (#{s['reason']})" }.join(', ')
105
+ install_cmd = "/install-skills #{suggestions.map { |s| s['name'] }.join(' ')}"
106
+
107
+ "Skill packs available: #{details}\n" \
108
+ "Run #{install_cmd} to install."
109
+ end
110
+
111
+ def load_state
112
+ path = state_path
113
+ return {} unless File.exist?(path)
114
+
115
+ JSON.parse(File.read(path))
116
+ rescue JSON::ParserError
117
+ {}
118
+ end
119
+
120
+ def save_state(state)
121
+ dir = File.dirname(state_path)
122
+ FileUtils.mkdir_p(dir)
123
+ File.write(state_path, JSON.pretty_generate(state))
124
+ end
125
+
126
+ def state_path
127
+ File.join(@project_root, '.rubyn-code', SUGGESTED_FILE)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -24,6 +24,13 @@ module RubynCode
24
24
  @index
25
25
  end
26
26
 
27
+ # Force the index to be rebuilt on next access. Used after installing
28
+ # a skill pack so newly-written files become discoverable in the same
29
+ # session.
30
+ def refresh!
31
+ @index = nil
32
+ end
33
+
27
34
  def list
28
35
  available.map { |e| e[:name] }
29
36
  end
@@ -103,6 +110,9 @@ module RubynCode
103
110
  name: name,
104
111
  description: doc.description,
105
112
  tags: doc.tags,
113
+ triggers: doc.triggers,
114
+ gems: doc.gems,
115
+ rails: doc.rails,
106
116
  path: File.expand_path(path)
107
117
  }
108
118
  rescue StandardError
@@ -7,12 +7,15 @@ module RubynCode
7
7
  class Document
8
8
  FRONTMATTER_PATTERN = /\A---\s*\n(.+?\n)---\s*\n(.*)\z/m
9
9
 
10
- attr_reader :name, :description, :tags, :body
10
+ attr_reader :name, :description, :tags, :triggers, :gems, :rails, :body
11
11
 
12
- def initialize(name:, description:, tags:, body:)
12
+ def initialize(name:, description:, tags:, body:, triggers: [], gems: [], rails: nil) # rubocop:disable Metrics/ParameterLists -- frontmatter-driven, keyword-only is clearer than a metadata hash
13
13
  @name = name
14
14
  @description = description
15
15
  @tags = tags
16
+ @triggers = triggers
17
+ @gems = gems
18
+ @rails = rails
16
19
  @body = body
17
20
  end
18
21
 
@@ -28,6 +31,9 @@ module RubynCode
28
31
  name: frontmatter['name'].to_s,
29
32
  description: frontmatter['description'].to_s,
30
33
  tags: Array(frontmatter['tags']),
34
+ triggers: Array(frontmatter['triggers']).map(&:to_s),
35
+ gems: Array(frontmatter['gems']).map(&:to_s),
36
+ rails: frontmatter['rails']&.to_s,
31
37
  body: match[2].to_s.strip
32
38
  )
33
39
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Skills
5
+ # Parses Gemfiles to extract gem names for skill pack matching.
6
+ # Handles standard gem declarations and grouped gems.
7
+ #
8
+ # Recognized patterns:
9
+ # gem 'stripe'
10
+ # gem 'stripe', '~> 8.0'
11
+ # gem 'pundit', require: false
12
+ # gem 'sidekiq', '>= 6.0', group: :workers
13
+ #
14
+ # Does NOT match comments or source declarations.
15
+ class GemfileParser
16
+ GEM_PATTERN = /
17
+ ^\s*gem\s+
18
+ ['"]([^'"]+)['"]
19
+ /x
20
+
21
+ # Extract unique gem names from Gemfile content.
22
+ #
23
+ # @param content [String] raw Gemfile content
24
+ # @return [Array<String>] gem names (lowercase)
25
+ def self.gems(content)
26
+ new(content).gems
27
+ end
28
+
29
+ def initialize(content)
30
+ @content = content
31
+ end
32
+
33
+ def gems
34
+ return [] if @content.to_s.strip.empty?
35
+
36
+ @content.scan(GEM_PATTERN).flatten.map { |m| m.strip.downcase }.uniq
37
+ end
38
+ end
39
+ end
40
+ end
@@ -42,7 +42,7 @@ module RubynCode
42
42
  # @param codebase_index [RubynCode::Index::CodebaseIndex, nil]
43
43
  # @param project_profile [Object, nil] reserved for future profile-based hints
44
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
45
+ def suggest_skills(codebase_index: nil, project_profile: nil) # rubocop:disable Lint/UnusedMethodArgument
46
46
  return [] unless codebase_index
47
47
 
48
48
  suggestions = []
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'gemfile_parser'
4
+
5
+ module RubynCode
6
+ module Skills
7
+ # Selects which skills to auto-load for a given user message.
8
+ #
9
+ # A skill is selected when:
10
+ # - its `triggers:` frontmatter list has at least one case-insensitive
11
+ # substring hit against the user message, AND
12
+ # - every gem in its `gems:` list is present in the project's Gemfile
13
+ # (skills with no `gems:` are unrestricted; if there is no Gemfile,
14
+ # gem gating is skipped entirely), AND
15
+ # - it has not already been selected by this matcher in the current
16
+ # session (per-instance dedup).
17
+ #
18
+ # Match scope is the latest user turn only. There is no cap on the
19
+ # number of matches per turn.
20
+ class Matcher
21
+ def initialize(catalog:, project_root: nil)
22
+ @catalog = catalog
23
+ @project_root = project_root
24
+ @loaded = Set.new
25
+ end
26
+
27
+ # Find skills whose triggers match `user_message`.
28
+ #
29
+ # @param user_message [String]
30
+ # @return [Array<Hash>] catalog entries to load
31
+ def match(user_message)
32
+ text = user_message.to_s.downcase
33
+ return [] if text.empty?
34
+
35
+ available_gems_set = available_gems
36
+ @catalog.available.filter_map do |entry|
37
+ next unless eligible?(entry, text, available_gems_set)
38
+
39
+ @loaded << entry[:name]
40
+ entry
41
+ end
42
+ end
43
+
44
+ # Names of skills selected so far in this session.
45
+ def loaded
46
+ @loaded.to_a
47
+ end
48
+
49
+ private
50
+
51
+ def eligible?(entry, text, available_gems_set)
52
+ return false if @loaded.include?(entry[:name])
53
+
54
+ triggers = entry[:triggers]
55
+ return false if triggers.nil? || triggers.empty?
56
+ return false unless triggers_match?(triggers, text)
57
+
58
+ gem_gate_pass?(entry[:gems], available_gems_set)
59
+ end
60
+
61
+ def triggers_match?(triggers, text)
62
+ triggers.any? { |t| text.include?(t.to_s.downcase) }
63
+ end
64
+
65
+ # Pass when:
66
+ # - skill declares no gem dependencies, or
67
+ # - we can't read a Gemfile (no project root / no Gemfile / parse error),
68
+ # so we don't gate based on missing data, or
69
+ # - every required gem is in the Gemfile.
70
+ def gem_gate_pass?(required_gems, available_gems_set)
71
+ return true if required_gems.nil? || required_gems.empty?
72
+ return true if available_gems_set.nil?
73
+
74
+ required_gems.all? { |g| available_gems_set.include?(g.to_s.downcase) }
75
+ end
76
+
77
+ def available_gems
78
+ return nil unless @project_root
79
+
80
+ gemfile = File.join(@project_root, 'Gemfile')
81
+ return nil unless File.exist?(gemfile)
82
+
83
+ GemfileParser.gems(File.read(gemfile)).to_set
84
+ rescue StandardError
85
+ nil
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'gemfile_parser'
4
+ require_relative 'registry_client'
5
+ require_relative 'loader'
6
+ require_relative 'catalog'
7
+
8
+ module RubynCode
9
+ module Skills
10
+ # Builds additional skill context for PR reviews based on detected gems.
11
+ #
12
+ # On GitHub App runs (or any run where a Gemfile is present in the repo):
13
+ # 1. Parse the Gemfile for gem names
14
+ # 2. Fetch matching packs from the registry API
15
+ # 3. Format pack skills into a context block for the review agent
16
+ #
17
+ # The GitHub App has access to ALL packs as a premium differentiator.
18
+ # No local `/install-skills` required — everything is fetched transparently.
19
+ #
20
+ # Usage:
21
+ # context = PackContext.for_repo(
22
+ # project_root: '/path/to/repo',
23
+ # registry_url: 'https://rubyn.ai'
24
+ # )
25
+ # review_prompt = context.build_review_context(diff_content)
26
+ class PackContext
27
+ SKILL_NAME_OVERRIDES = {
28
+ 'stripe' => 'stripe/webhooks'
29
+ }.freeze
30
+
31
+ # Factory: build a PackContext for a given repo.
32
+ #
33
+ # @param project_root [String] path to the repository
34
+ # @param registry_url [String, nil] override for registry URL
35
+ # @return [PackContext] ready to build context
36
+ def self.for_repo(project_root:, registry_url: nil)
37
+ gemfile_path = File.join(project_root, 'Gemfile')
38
+ content = File.read(gemfile_path, encoding: 'UTF-8') if File.exist?(gemfile_path)
39
+ gems = content ? GemfileParser.gems(content) : []
40
+ client = RegistryClient.new(base_url: registry_url)
41
+ new(gems: gems, registry_client: client)
42
+ end
43
+
44
+ # Build context for an external/GitHub App context where we need to
45
+ # fetch the full pack content (not just local skills).
46
+ #
47
+ # @param pack_names [Array<String>] names of packs to load
48
+ # @return [String] context block to prepend to the review prompt
49
+ def self.for_packs(pack_names:, registry_client:)
50
+ new(gems: pack_names, registry_client: registry_client).build_context_block
51
+ end
52
+
53
+ attr_reader :gems
54
+
55
+ def initialize(gems:, registry_client:)
56
+ @gems = gems
57
+ @registry_client = registry_client
58
+ @cache = {}
59
+ end
60
+
61
+ # Returns the list of packs that matched detected gems.
62
+ # Some gems map to pack names (e.g. stripe → stripe/webhooks).
63
+ #
64
+ # Tradeoff: every detected gem that doesn't appear in SKILL_NAME_OVERRIDES
65
+ # is queried against the registry as a potential pack name. For a typical
66
+ # Rails app with 40-80 gems this means up to 80 sequential registry calls,
67
+ # each returning a 404 for unknown packs. This is intentional for now:
68
+ # - Responses are cached in @cache so repeated calls within a session are free
69
+ # - The GitHub App context is latency-tolerant (async review runs)
70
+ # - A future batch endpoint on the registry API can reduce this to one call
71
+ # If latency becomes a problem, add a KNOWN_PACKS allowlist and skip gems
72
+ # that are not in it before fetching.
73
+ #
74
+ # @return [Array<String>] pack names
75
+ def matched_packs
76
+ @matched_packs ||= gems.filter_map { |gem| pack_name_for(gem) }.uniq
77
+ end
78
+
79
+ # Fetch and cache pack content from registry.
80
+ #
81
+ # RegistryClient#fetch_pack returns a { data:, etag:, not_modified: } wrapper.
82
+ # We cache and return only the :data payload so callers work with pack attributes
83
+ # directly (e.g. :description, :files) rather than the transport envelope.
84
+ #
85
+ # @param pack_name [String]
86
+ # @return [Hash, nil] pack data or nil if not found
87
+ def fetch_pack(pack_name)
88
+ return @cache[pack_name] if @cache.key?(pack_name)
89
+
90
+ result = @registry_client.fetch_pack(pack_name)
91
+ @cache[pack_name] = result[:data]
92
+ rescue RegistryError
93
+ @cache[pack_name] = nil
94
+ end
95
+
96
+ # Build a context block listing all detected packs and their skills.
97
+ # This is prepended to the review prompt so the agent can apply them.
98
+ #
99
+ # @return [String] context block
100
+ def build_context_block
101
+ return '' if matched_packs.empty?
102
+
103
+ lines = []
104
+ lines << "\n## Pack-Informed Review Context"
105
+ intro = 'The following skill packs were detected from the Gemfile and ' \
106
+ 'are available for this review (via GitHub App access):'
107
+ lines << intro
108
+ lines << ''
109
+
110
+ matched_packs.each do |pack_name|
111
+ pack = fetch_pack(pack_name)
112
+ if pack.nil?
113
+ lines << "- **[#{pack_name}]** (pack not found in registry — skipped)"
114
+ next
115
+ end
116
+
117
+ lines << "### Pack: #{pack_name}"
118
+ lines << pack_description(pack)
119
+ lines << pack_skills(pack)
120
+ lines << ''
121
+ rescue StandardError
122
+ lines << "- **[#{pack_name}]** (failed to load — skipped)"
123
+ end
124
+
125
+ lines.join("\n")
126
+ end
127
+
128
+ private
129
+
130
+ def pack_name_for(gem)
131
+ SKILL_NAME_OVERRIDES[gem] || gem
132
+ end
133
+
134
+ def pack_description(pack)
135
+ desc = pack[:description] || pack['description'] || ''
136
+ desc.empty? ? '' : "#{desc}\n"
137
+ end
138
+
139
+ def pack_skills(pack)
140
+ files = pack[:files] || pack['files'] || []
141
+ return '' if files.empty?
142
+
143
+ # RegistryClient#fetch_files_with_content returns an Array of
144
+ # { filename: String, content: String } hashes — not a Hash keyed by name.
145
+ skills = files.map do |file|
146
+ filename = file[:filename] || file['filename'] || ''
147
+ skill_name = File.basename(filename, '.*')
148
+ skill_content = (file[:content] || file['content']).to_s
149
+ format_skill_block(skill_name, skill_content)
150
+ end
151
+ skills.join("\n")
152
+ end
153
+
154
+ def format_skill_block(name, content)
155
+ lines = []
156
+ lines << "<skill name=\"#{name}\">"
157
+ lines << content.strip
158
+ lines << '</skill>'
159
+ lines.join("\n")
160
+ end
161
+ end
162
+ end
163
+ end