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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -0
- data/impl.md +111 -0
- data/lib/caruso/adapter.rb +16 -96
- data/lib/caruso/adapters/base.rb +102 -0
- data/lib/caruso/adapters/command_adapter.rb +88 -0
- data/lib/caruso/adapters/dispatcher.rb +83 -0
- data/lib/caruso/adapters/hook_adapter.rb +222 -0
- data/lib/caruso/adapters/markdown_adapter.rb +23 -0
- data/lib/caruso/adapters/skill_adapter.rb +87 -0
- data/lib/caruso/cli.rb +22 -6
- data/lib/caruso/config_manager.rb +30 -28
- data/lib/caruso/fetcher.rb +137 -29
- data/lib/caruso/remover.rb +73 -13
- data/lib/caruso/version.rb +1 -1
- data/package-lock.json +6 -0
- data/reference/claude_code_create_marketplaces.md +511 -0
- data/reference/claude_code_hooks.md +1137 -0
- data/reference/claude_code_plugins.md +769 -0
- data/reference/claude_code_slash_commands.md +515 -0
- data/reference/cursor_commands.md +90 -0
- data/reference/cursor_hooks.md +467 -0
- data/reference/cursor_modes.md +105 -0
- data/reference/cursor_rules.md +246 -0
- data/reference/steering_docs.md +57 -0
- data/tasks.md +22 -0
- metadata +20 -3
- data/reference/plugins_reference.md +0 -376
- /data/reference/{marketplace.md → claude_code_marketplaces.md} +0 -0
data/lib/caruso/fetcher.rb
CHANGED
|
@@ -180,17 +180,41 @@ module Caruso
|
|
|
180
180
|
|
|
181
181
|
return [] unless SafeDir.exist?(plugin_path)
|
|
182
182
|
|
|
183
|
-
#
|
|
184
|
-
|
|
183
|
+
# Merge marketplace entry with plugin.json (marketplace takes precedence)
|
|
184
|
+
merged_plugin_data = merge_with_plugin_json(plugin, plugin_path)
|
|
185
185
|
|
|
186
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
data/lib/caruso/remover.rb
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
28
|
+
result = config_manager.remove_plugin(name)
|
|
28
29
|
|
|
29
|
-
# 2. Delete files
|
|
30
|
-
delete_files(
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
data/lib/caruso/version.rb
CHANGED