caruso 0.6.2 → 0.7.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.
@@ -180,17 +180,41 @@ module Caruso
180
180
 
181
181
  return [] unless SafeDir.exist?(plugin_path)
182
182
 
183
- # Start with default directories
184
- files = find_steering_files(plugin_path)
183
+ # Merge marketplace entry with plugin.json (marketplace takes precedence)
184
+ merged_plugin_data = merge_with_plugin_json(plugin, plugin_path)
185
185
 
186
- # Add custom paths if specified (they supplement defaults)
187
- files += find_custom_component_files(plugin_path, plugin["commands"]) if plugin["commands"]
188
- files += find_custom_component_files(plugin_path, plugin["agents"]) if plugin["agents"]
189
- files += find_custom_component_files(plugin_path, plugin["skills"]) if plugin["skills"]
186
+ files = find_steering_files(plugin_path, merged_plugin_data)
190
187
 
191
188
  files.uniq
192
189
  end
193
190
 
191
+ # Read plugin.json and merge with marketplace entry.
192
+ # Marketplace fields override plugin.json fields for component paths.
193
+ def merge_with_plugin_json(marketplace_entry, plugin_path)
194
+ plugin_json_path = File.join(plugin_path, ".claude-plugin", "plugin.json")
195
+ return marketplace_entry unless File.exist?(plugin_json_path)
196
+
197
+ begin
198
+ plugin_data = JSON.parse(SafeFile.read(plugin_json_path))
199
+ rescue JSON::ParserError => e
200
+ puts "Warning: Could not parse plugin.json: #{e.message}"
201
+ return marketplace_entry
202
+ end
203
+
204
+ component_fields = %w[commands agents skills hooks mcpServers]
205
+ merged = marketplace_entry.dup
206
+
207
+ component_fields.each do |field|
208
+ # Only use plugin.json value if marketplace entry doesn't specify this field
209
+ next if merged.key?(field)
210
+ next unless plugin_data.key?(field)
211
+
212
+ merged[field] = plugin_data[field]
213
+ end
214
+
215
+ merged
216
+ end
217
+
194
218
  def resolve_plugin_path(source)
195
219
  if source.is_a?(Hash) && %w[git github].include?(source["source"])
196
220
  clone_git_repo(source)
@@ -210,18 +234,47 @@ module Caruso
210
234
  end
211
235
  end
212
236
 
213
- def find_steering_files(plugin_path)
214
- # Validate plugin_path before using it in glob
215
- # This is safe because plugin_path comes from resolve_plugin_path which returns trusted paths
216
- # (either from cache_dir which is under ~/.caruso, or validated local paths)
217
- glob_pattern = PathSanitizer.safe_join(plugin_path, "{commands,agents,skills}", "**", "*.md")
237
+ # Find all steering files based on default locations and manifest overrides
238
+ # RETURNS: unique list of absolute file paths to fetch
239
+ def find_steering_files(plugin_path, plugin_data = nil)
240
+ files = []
241
+
242
+ # 1. ALWAYS scan default directories (Additive Strategy)
243
+ files += glob_plugin_files(plugin_path, "commands", "**", "*.md")
244
+ files += glob_plugin_files(plugin_path, "agents", "**", "*.md")
245
+
246
+ # For skills, we want recursive default scan if 'skills/' exists
247
+ # But careful: if we scan default 'skills' recursively here, and then scan strict paths from manifest...
248
+ # Duplicate handling is fine via uniq.
249
+ default_skills_path = File.join(plugin_path, "skills")
250
+ if SafeDir.exist?(default_skills_path)
251
+ files += find_recursive_component_files(plugin_path, "skills")
252
+ end
218
253
 
219
- SafeDir.glob(glob_pattern, base_dir: plugin_path).reject do |file|
254
+ # 2. Add manifest-defined paths (if present)
255
+ if plugin_data
256
+ files += find_custom_component_files(plugin_path, plugin_data["commands"]) if plugin_data["commands"]
257
+ files += find_custom_component_files(plugin_path, plugin_data["agents"]) if plugin_data["agents"]
258
+ files += find_recursive_component_files(plugin_path, plugin_data["skills"]) if plugin_data["skills"]
259
+ end
260
+
261
+ # 3. Detect hooks files
262
+ files += find_hooks_files(plugin_path, plugin_data)
263
+
264
+ # Filter out noise
265
+ files.uniq.reject do |file|
220
266
  basename = File.basename(file).downcase
221
- ["readme.md", "license.md"].include?(basename)
267
+ ["readme.md", "license.md", "plugin.json"].include?(basename) || File.directory?(file)
222
268
  end
223
269
  end
224
270
 
271
+ # Helper to glob files safely
272
+ def glob_plugin_files(plugin_path, *parts)
273
+ pattern = PathSanitizer.safe_join(plugin_path, *parts)
274
+ SafeDir.glob(pattern, base_dir: plugin_path)
275
+ end
276
+
277
+ # For Commands/Agents: typically just markdown files, flat or shallow
225
278
  def find_custom_component_files(plugin_path, paths)
226
279
  # Handle both string and array formats
227
280
  paths = [paths] if paths.is_a?(String)
@@ -229,31 +282,86 @@ module Caruso
229
282
 
230
283
  files = []
231
284
  paths.each do |path|
232
- # Resolve and sanitize the path relative to plugin_path
233
- # This ensures the path stays within plugin_path boundaries
234
- begin
235
- full_path = PathSanitizer.sanitize_path(File.expand_path(path, plugin_path), base_dir: plugin_path)
236
- rescue PathSanitizer::PathTraversalError => e
237
- warn "Skipping path outside plugin directory '#{path}': #{e.message}"
238
- next
239
- end
285
+ full_path = resolve_safe_path(plugin_path, path)
286
+ next unless full_path
240
287
 
241
- # Handle both files and directories
242
288
  if File.file?(full_path) && full_path.end_with?(".md")
243
- basename = File.basename(full_path).downcase
244
- files << full_path unless ["readme.md", "license.md"].include?(basename)
289
+ files << full_path
245
290
  elsif SafeDir.exist?(full_path, base_dir: plugin_path)
246
- # Find all .md files in this directory using safe_join
291
+ # Find all .md files in this directory
247
292
  glob_pattern = PathSanitizer.safe_join(full_path, "**", "*.md")
248
- SafeDir.glob(glob_pattern, base_dir: plugin_path).each do |file|
249
- basename = File.basename(file).downcase
250
- files << file unless ["readme.md", "license.md"].include?(basename)
251
- end
293
+ files += SafeDir.glob(glob_pattern, base_dir: plugin_path)
294
+ end
295
+ end
296
+ files
297
+ end
298
+
299
+ # For SKILLS: Recursive fetch of EVERYTHING (scripts, assets, md)
300
+ def find_recursive_component_files(plugin_path, paths)
301
+ paths = [paths] if paths.is_a?(String)
302
+ return [] unless paths.is_a?(Array)
303
+
304
+ files = []
305
+ paths.each do |path|
306
+ full_path = resolve_safe_path(plugin_path, path)
307
+ next unless full_path
308
+
309
+ if File.file?(full_path)
310
+ files << full_path
311
+ elsif SafeDir.exist?(full_path, base_dir: plugin_path)
312
+ # Grab EVERYTHING recursively
313
+ glob_pattern = PathSanitizer.safe_join(full_path, "**", "*")
314
+ files += SafeDir.glob(glob_pattern, base_dir: plugin_path)
252
315
  end
253
316
  end
254
317
  files
255
318
  end
256
319
 
320
+ def find_hooks_files(plugin_path, plugin_data)
321
+ files = []
322
+
323
+ # Default hooks/ directory
324
+ default_hooks = File.join(plugin_path, "hooks", "hooks.json")
325
+ files << default_hooks if File.exist?(default_hooks)
326
+
327
+ # Custom hooks from manifest (inline config or path-based)
328
+ return files unless plugin_data&.key?("hooks")
329
+
330
+ hooks_value = plugin_data["hooks"]
331
+ if hooks_value.is_a?(Hash)
332
+ inline_path = File.join(plugin_path, ".caruso_inline_hooks.json")
333
+ File.write(inline_path, JSON.pretty_generate(hooks_value))
334
+ files << inline_path
335
+ else
336
+ files += find_custom_hooks_paths(plugin_path, hooks_value)
337
+ end
338
+
339
+ files
340
+ end
341
+
342
+ def find_custom_hooks_paths(plugin_path, hooks_value)
343
+ files = []
344
+ [hooks_value].flatten.each do |path|
345
+ full_path = resolve_safe_path(plugin_path, path)
346
+ next unless full_path
347
+
348
+ if File.file?(full_path) && File.basename(full_path) == "hooks.json"
349
+ files << full_path
350
+ elsif SafeDir.exist?(full_path, base_dir: plugin_path)
351
+ candidate = File.join(full_path, "hooks.json")
352
+ files << candidate if File.exist?(candidate)
353
+ end
354
+ end
355
+ files
356
+ end
357
+
358
+ def resolve_safe_path(plugin_path, relative_path)
359
+ PathSanitizer.sanitize_path(File.expand_path(relative_path, plugin_path), base_dir: plugin_path)
360
+ rescue PathSanitizer::PathTraversalError => e
361
+ warn "Skipping path outside plugin directory '#{relative_path}': #{e.message}"
362
+ nil
363
+ end
364
+
257
365
  def local_path?
258
366
  !@marketplace_uri.match?(/\Ahttps?:/)
259
367
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "marketplace_registry"
4
5
 
5
6
  module Caruso
@@ -11,12 +12,12 @@ module Caruso
11
12
  end
12
13
 
13
14
  def remove_marketplace(name)
14
- # 1. Remove from config and get associated plugin files
15
- # This updates both project config (plugins) and local config (files)
16
- files_to_remove = config_manager.remove_marketplace_with_plugins(name)
15
+ # 1. Remove from config and get associated plugin files/hooks
16
+ result = config_manager.remove_marketplace_with_plugins(name)
17
17
 
18
- # 2. Delete the actual files
19
- delete_files(files_to_remove)
18
+ # 2. Delete the actual files and remove hooks
19
+ delete_files(result[:files])
20
+ remove_plugin_hooks(result[:hooks]) if result[:hooks] && !result[:hooks].empty?
20
21
 
21
22
  # 3. Clean up registry cache
22
23
  remove_from_registry(name)
@@ -24,21 +25,80 @@ module Caruso
24
25
 
25
26
  def remove_plugin(name)
26
27
  # 1. Remove from config
27
- files_to_remove = config_manager.remove_plugin(name)
28
+ result = config_manager.remove_plugin(name)
28
29
 
29
- # 2. Delete files
30
- delete_files(files_to_remove)
30
+ # 2. Delete files and remove hooks
31
+ delete_files(result[:files])
32
+ remove_plugin_hooks(result[:hooks]) if result[:hooks] && !result[:hooks].empty?
31
33
  end
32
34
 
33
35
  private
34
36
 
35
37
  def delete_files(files)
36
- files.each do |file|
38
+ # Skip hooks.json it's a merged file handled separately by remove_plugin_hooks
39
+ files.reject { |f| File.basename(f) == "hooks.json" && f.include?(".cursor") }.each do |file|
37
40
  full_path = File.join(config_manager.project_dir, file)
38
- if File.exist?(full_path)
39
- File.delete(full_path)
40
- puts " Deleted #{file}"
41
- end
41
+ next unless File.exist?(full_path)
42
+
43
+ File.delete(full_path)
44
+ puts " Deleted #{file}"
45
+ cleanup_empty_parents(full_path)
46
+ end
47
+ end
48
+
49
+ # Walk up from a deleted file's parent, removing empty directories
50
+ # until we hit .cursor/ itself or a non-empty directory.
51
+ def cleanup_empty_parents(file_path)
52
+ cursor_dir = File.join(config_manager.project_dir, ".cursor")
53
+ dir = File.dirname(file_path)
54
+
55
+ while dir != cursor_dir && dir.start_with?(cursor_dir)
56
+ break unless Dir.exist?(dir) && Dir.empty?(dir)
57
+
58
+ Dir.rmdir(dir)
59
+ dir = File.dirname(dir)
60
+ end
61
+ end
62
+
63
+ # Remove specific hook entries from .cursor/hooks.json using tracked metadata.
64
+ # installed_hooks is a hash: { "event_name" => [{ "command" => "..." }, ...] }
65
+ def remove_plugin_hooks(installed_hooks)
66
+ hooks_path = File.join(config_manager.project_dir, ".cursor", "hooks.json")
67
+ return unless File.exist?(hooks_path)
68
+
69
+ begin
70
+ data = JSON.parse(File.read(hooks_path))
71
+ hooks = data["hooks"] || {}
72
+ rescue JSON::ParserError
73
+ return
74
+ end
75
+
76
+ return unless remove_tracked_commands(hooks, installed_hooks)
77
+
78
+ hooks.reject! { |_, entries| entries.empty? }
79
+ write_or_delete_hooks(hooks_path, hooks)
80
+ end
81
+
82
+ def remove_tracked_commands(hooks, installed_hooks)
83
+ changed = false
84
+ installed_hooks.each do |event, entries|
85
+ next unless hooks[event]
86
+
87
+ commands_to_remove = entries.map { |e| e["command"] }.compact.to_set
88
+ before_count = hooks[event].length
89
+ hooks[event].reject! { |entry| commands_to_remove.include?(entry["command"]) }
90
+ changed = true if hooks[event].length != before_count
91
+ end
92
+ changed
93
+ end
94
+
95
+ def write_or_delete_hooks(hooks_path, hooks)
96
+ if hooks.empty?
97
+ File.delete(hooks_path)
98
+ puts " Deleted .cursor/hooks.json (empty after plugin removal)"
99
+ else
100
+ File.write(hooks_path, JSON.pretty_generate({ "version" => 1, "hooks" => hooks }))
101
+ puts " Updated .cursor/hooks.json (removed plugin hooks)"
42
102
  end
43
103
  end
44
104
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Caruso
4
- VERSION = "0.6.2"
4
+ VERSION = "0.7.0"
5
5
  end
data/package-lock.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "identify-missing-features",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {}
6
+ }