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
|
@@ -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
|
|
@@ -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
|
|
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
|
|
@@ -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
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Skills
|
|
8
|
+
# Downloads and installs skill packs from the registry.
|
|
9
|
+
#
|
|
10
|
+
# Handles ETag caching, version comparison, and offline fallback.
|
|
11
|
+
# Installs to `.rubyn-code/skills/<pack>/` (project) by default,
|
|
12
|
+
# or `~/.rubyn-code/skills/<pack>/` with the --global flag.
|
|
13
|
+
class PackInstaller
|
|
14
|
+
MANIFEST_FILE = '.manifest.json'
|
|
15
|
+
|
|
16
|
+
# @param registry_client [RegistryClient]
|
|
17
|
+
# @param project_root [String] path to the project root
|
|
18
|
+
# @param global [Boolean] install to ~/.rubyn-code/skills/ instead of project
|
|
19
|
+
def initialize(registry_client:, project_root:, global: false)
|
|
20
|
+
@client = registry_client
|
|
21
|
+
@project_root = project_root
|
|
22
|
+
@global = global
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Install one or more packs by name.
|
|
26
|
+
#
|
|
27
|
+
# @param names [Array<String>] pack names
|
|
28
|
+
# @param update [Boolean] update without prompting if already installed
|
|
29
|
+
# @yield [event, data] progress events
|
|
30
|
+
# @yieldparam event [Symbol] :fetching, :downloading, :installed, :up_to_date, :error
|
|
31
|
+
# @yieldparam data [Hash] event-specific data
|
|
32
|
+
# @return [Array<Hash>] results per pack ({ name:, status:, files: })
|
|
33
|
+
def install(names, update: false, &block)
|
|
34
|
+
names.map { |name| install_single(name, update: update, &block) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Update all installed packs to their latest versions.
|
|
38
|
+
#
|
|
39
|
+
# @yield [event, data] progress events
|
|
40
|
+
# @return [Array<Hash>] results per pack
|
|
41
|
+
def update_all(&block)
|
|
42
|
+
installed = installed_packs
|
|
43
|
+
return [] if installed.empty?
|
|
44
|
+
|
|
45
|
+
installed.map { |pack| install_single(pack['name'], update: true, &block) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Remove an installed pack.
|
|
49
|
+
#
|
|
50
|
+
# @param name [String] pack name
|
|
51
|
+
# @return [Boolean] true if removed
|
|
52
|
+
def remove(name)
|
|
53
|
+
dir = pack_dir(name)
|
|
54
|
+
return false unless Dir.exist?(dir)
|
|
55
|
+
|
|
56
|
+
FileUtils.rm_rf(dir)
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# List installed packs with their metadata.
|
|
61
|
+
#
|
|
62
|
+
# @return [Array<Hash>] installed pack manifests
|
|
63
|
+
def installed_packs
|
|
64
|
+
dir = skills_base_dir
|
|
65
|
+
return [] unless Dir.exist?(dir)
|
|
66
|
+
|
|
67
|
+
Dir.children(dir)
|
|
68
|
+
.select { |d| File.directory?(File.join(dir, d)) }
|
|
69
|
+
.filter_map { |d| read_manifest(d) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Check if a specific pack is installed.
|
|
73
|
+
#
|
|
74
|
+
# @param name [String]
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def installed?(name)
|
|
77
|
+
File.exist?(manifest_path(name))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Read the installed manifest for a pack.
|
|
81
|
+
#
|
|
82
|
+
# @param name [String]
|
|
83
|
+
# @return [Hash, nil]
|
|
84
|
+
def read_manifest(name)
|
|
85
|
+
path = manifest_path(name)
|
|
86
|
+
return nil unless File.exist?(path)
|
|
87
|
+
|
|
88
|
+
JSON.parse(File.read(path))
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def install_single(name, update: false)
|
|
96
|
+
yield(:fetching, { name: name }) if block_given?
|
|
97
|
+
|
|
98
|
+
pack_meta = @client.fetch_pack(name)
|
|
99
|
+
files = pack_meta['files'] || []
|
|
100
|
+
|
|
101
|
+
existing = read_manifest(name)
|
|
102
|
+
if existing && !update
|
|
103
|
+
if existing['version'] == pack_meta['version']
|
|
104
|
+
yield(:up_to_date, { name: name, version: pack_meta['version'] }) if block_given?
|
|
105
|
+
return { name: name, status: :up_to_date, files: [] }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
etags = load_etags(name)
|
|
110
|
+
downloaded = download_files(name, files, etags)
|
|
111
|
+
|
|
112
|
+
yield(:downloading, { name: name, total: files.size, downloaded: downloaded.size }) if block_given?
|
|
113
|
+
|
|
114
|
+
write_manifest(name, pack_meta)
|
|
115
|
+
save_etags(name, etags)
|
|
116
|
+
|
|
117
|
+
yield(:installed, { name: name, version: pack_meta['version'], files: downloaded }) if block_given?
|
|
118
|
+
|
|
119
|
+
{ name: name, status: :installed, files: downloaded }
|
|
120
|
+
rescue RegistryError => e
|
|
121
|
+
yield(:error, { name: name, message: e.message }) if block_given?
|
|
122
|
+
{ name: name, status: :error, message: e.message }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def download_files(pack_name, files, etags)
|
|
126
|
+
dir = pack_dir(pack_name)
|
|
127
|
+
FileUtils.mkdir_p(dir)
|
|
128
|
+
|
|
129
|
+
downloaded = []
|
|
130
|
+
|
|
131
|
+
files.each do |file_info|
|
|
132
|
+
path = file_info['path']
|
|
133
|
+
result = @client.fetch_file(pack_name, path, etag: etags[path])
|
|
134
|
+
|
|
135
|
+
if result[:not_modified]
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
File.write(File.join(dir, path), result[:content])
|
|
140
|
+
etags[path] = result[:etag] if result[:etag]
|
|
141
|
+
downloaded << path
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
downloaded
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def write_manifest(name, pack_meta)
|
|
148
|
+
manifest = {
|
|
149
|
+
'name' => pack_meta['name'],
|
|
150
|
+
'displayName' => pack_meta['displayName'],
|
|
151
|
+
'version' => pack_meta['version'],
|
|
152
|
+
'installedAt' => Time.now.utc.iso8601,
|
|
153
|
+
'skillCount' => (pack_meta['files'] || []).size,
|
|
154
|
+
'files' => (pack_meta['files'] || []).map { |f| f['path'] }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
File.write(manifest_path(name), JSON.pretty_generate(manifest))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def load_etags(name)
|
|
161
|
+
path = etags_path(name)
|
|
162
|
+
return {} unless File.exist?(path)
|
|
163
|
+
|
|
164
|
+
JSON.parse(File.read(path))
|
|
165
|
+
rescue JSON::ParserError
|
|
166
|
+
{}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def save_etags(name, etags)
|
|
170
|
+
File.write(etags_path(name), JSON.pretty_generate(etags))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def pack_dir(name)
|
|
174
|
+
File.join(skills_base_dir, name)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def manifest_path(name)
|
|
178
|
+
File.join(pack_dir(name), MANIFEST_FILE)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def etags_path(name)
|
|
182
|
+
File.join(pack_dir(name), '.etags.json')
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def skills_base_dir
|
|
186
|
+
if @global
|
|
187
|
+
File.join(Config::Defaults::HOME_DIR, 'skills')
|
|
188
|
+
else
|
|
189
|
+
File.join(@project_root, '.rubyn-code', 'skills')
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|