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
@@ -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('>', '&gt;')
51
76
  .gsub('"', '&quot;')
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