caruso 0.6.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f6520caf8c5e8a4946369e9722ca36685a6c79b933b67336bc02b39b0fca359
4
- data.tar.gz: d1b2f7b52ae5746909e173b75fc31e601417bca1ef55af889a56f802a8ae1d24
3
+ metadata.gz: 8f7f1e5f2c4af1c26c2592aaa0cd5be588fa105ae3e7c8faf009275e881b2800
4
+ data.tar.gz: d740248a46af8e1559f7b02fb70b2660ff907b66e1acd7522c4e7a2692255938
5
5
  SHA512:
6
- metadata.gz: 48d8c7c701c7c1a380672ac77f3025dc69c90c4a5ef7d6fd6e72e50358e69440a9c57ff022f52f94c4970faead782ba04c223246bf766045e4508bca427cf4f8
7
- data.tar.gz: 293893a825850e919ca8141203f307c300e9fdba931f80aafadad15a766dd5f28ff6bd061eae53891c5a4da41f061e2b4b6bea5f0108d9b79396e63d82f49296
6
+ metadata.gz: f77816058f4901d123ba754005869b7e40d8f15be411df9c9fa1c96a261513a4aa420886396aa2cc32f719beaf5876b7ab102c4ee1e9105980402e4c5192a549
7
+ data.tar.gz: 7d85e8b21768c2978db8b29089920baa39a679163561eeba8ce8e012ad807068ff866d39a37e9411f1dec861232e5c19387c00daf4bb83e6e11673b294df1709
data/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-01-31
11
+
12
+ ### Added
13
+ - **Hooks Support**: Full translation of Claude Code hooks to Cursor hooks format
14
+ - Event translation: `PreToolUse` → `beforeShellExecution`, `PostToolUse` → `afterFileEdit`/`afterShellExecution`, `UserPromptSubmit` → `beforeSubmitPrompt`, `Stop` → `stop`
15
+ - Script copying with `${CLAUDE_PLUGIN_ROOT}` path rewriting
16
+ - Merges all plugin hooks into single `.cursor/hooks.json` file
17
+ - Tracks installed hooks for surgical removal on uninstall
18
+ - Skips unsupported events (`SessionStart`, `SessionEnd`, etc.) with warnings
19
+ - Skips prompt-based hooks (Cursor doesn't support LLM evaluation in hooks)
20
+ - **Commands Support**: New `CommandAdapter` writes slash commands to `.cursor/commands/`
21
+ - Commands now output as `.md` files in `.cursor/commands/caruso/<marketplace>/<plugin>/`
22
+ - Preserves frontmatter (`description`, `argument-hint`, `allowed-tools`, `model`)
23
+ - Converts bash execution markers (`!` prefix) to documentation notes
24
+ - **Plugin.json Parsing**: Reads component paths and inline configs from `.claude-plugin/plugin.json`
25
+ - Marketplace.json fields take precedence over plugin.json (conflict resolution)
26
+ - Supports inline hooks configuration
27
+ - Enables plugin-level metadata tracking
28
+ - **Comprehensive Test Coverage**: 14 new integration tests for hooks
29
+ - Two-plugin merge scenarios, cross-component plugins, marketplace removal cascade
30
+ - Plugin update idempotency, edge cases (malformed hooks, agent skipping, no-hooks plugins)
31
+ - Clean filesystem verification (orphan directory cleanup)
32
+
33
+ ### Changed
34
+ - **Dispatcher Architecture**: Refactored with class methods and step-by-step processing
35
+ - Skills → Commands → Hooks → Agents (skip with warning) → Unprocessed warnings
36
+ - Returns structured result: `{ files: [...], hooks: {...} }`
37
+ - **ConfigManager**: Now tracks installed hooks in `.caruso.local.json` for clean uninstall
38
+ - `add_plugin` accepts `hooks:` keyword argument
39
+ - `remove_plugin` returns `{ files: [...], hooks: {...} }`
40
+ - Added `get_installed_hooks` method
41
+ - **Remover**: Enhanced hook removal and orphan directory cleanup
42
+ - Removes specific hook commands from merged `.cursor/hooks.json` using tracked metadata
43
+ - Walks up directory tree removing empty dirs after file deletion
44
+ - Deletes `.cursor/hooks.json` if empty after plugin removal
45
+ - **CLI**: Updated `plugin install` and `plugin update` to pass hooks to ConfigManager
46
+
47
+ ### Fixed
48
+ - Orphan directories no longer left behind after uninstalling plugins with hook scripts
49
+ - Rubocop compliance across all source and spec files (0 offenses)
50
+
10
51
  ## [0.6.2] - 2025-12-17
11
52
 
12
53
  ### Fixed
@@ -4,6 +4,10 @@ require_relative "adapters/dispatcher"
4
4
 
5
5
  module Caruso
6
6
  class Adapter
7
+ # Hook commands installed by this adapter, keyed by Cursor event name.
8
+ # Populated after adapt() is called. Used for tracking during install.
9
+ attr_reader :installed_hooks
10
+
7
11
  # Preserving the interface for CLI compatibility
8
12
  def initialize(files, target_dir:, marketplace_name:, plugin_name:, agent: :cursor)
9
13
  @files = files
@@ -11,16 +15,19 @@ module Caruso
11
15
  @marketplace_name = marketplace_name
12
16
  @plugin_name = plugin_name
13
17
  @agent = agent
18
+ @installed_hooks = {}
14
19
  end
15
20
 
16
21
  def adapt
17
- Caruso::Adapters::Dispatcher.adapt(
22
+ result = Caruso::Adapters::Dispatcher.adapt(
18
23
  @files,
19
24
  target_dir: @target_dir,
20
25
  marketplace_name: @marketplace_name,
21
26
  plugin_name: @plugin_name,
22
27
  agent: @agent
23
28
  )
29
+ @installed_hooks = result.is_a?(Hash) ? (result[:hooks] || {}) : {}
30
+ result.is_a?(Hash) ? result[:files] : result
24
31
  end
25
32
  end
26
33
  end
@@ -27,10 +27,10 @@ module Caruso
27
27
 
28
28
  def save_file(relative_path, content, extension: nil)
29
29
  filename = File.basename(relative_path, ".*")
30
-
30
+
31
31
  # Preserve original extension if none provided
32
32
  ext = extension || File.extname(relative_path)
33
-
33
+
34
34
  # Rename SKILL.md to the skill name (parent directory) to avoid collisions
35
35
  # This is specific to Skills, might move to SkillAdapter later, but keeping behavior for now
36
36
  if filename.casecmp("skill").zero?
@@ -43,11 +43,11 @@ module Caruso
43
43
  # Component type is derived from the class name or passed in?
44
44
  # For base, we might need a way to determine output path more flexibly.
45
45
  # But sticking to current behavior:
46
-
46
+
47
47
  component_type = extract_component_type(relative_path)
48
48
  subdirs = File.join("caruso", marketplace_name, plugin_name, component_type)
49
49
  output_dir = File.join(target_dir, subdirs)
50
-
50
+
51
51
  FileUtils.mkdir_p(output_dir)
52
52
  target_path = File.join(output_dir, output_filename)
53
53
 
@@ -62,7 +62,7 @@ module Caruso
62
62
  return "commands" if file_path.include?("/commands/")
63
63
  return "agents" if file_path.include?("/agents/")
64
64
  return "skills" if file_path.include?("/skills/")
65
-
65
+
66
66
  # Fallback or specific handling for other types
67
67
  "misc"
68
68
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Caruso
6
+ module Adapters
7
+ class CommandAdapter < Base
8
+ def adapt
9
+ created_files = []
10
+ files.each do |file_path|
11
+ content = SafeFile.read(file_path)
12
+ adapted_content = adapt_command_content(content, file_path)
13
+
14
+ # Commands are flat .md files in .cursor/commands/
15
+ # NOT nested like rules, so we override the save behavior
16
+ created_file = save_command_file(file_path, adapted_content)
17
+
18
+ created_files << created_file
19
+ end
20
+ created_files
21
+ end
22
+
23
+ private
24
+
25
+ def adapt_command_content(content, file_path)
26
+ # Preserve or add frontmatter, but don't add Cursor-specific rule fields
27
+ if content.match?(/\A---\s*\n.*?\n---\s*\n/m)
28
+ # Frontmatter exists - preserve command-specific fields
29
+ preserve_command_frontmatter(content, file_path)
30
+ else
31
+ # No frontmatter - create minimal description
32
+ create_command_frontmatter(file_path) + content
33
+ end
34
+ end
35
+
36
+ def preserve_command_frontmatter(content, _file_path)
37
+ # Commands support: description, argument-hint, allowed-tools, model
38
+ # We don't need globs or alwaysApply (those are for rules)
39
+ # Just return content as-is, Claude Code commands are already compatible
40
+
41
+ # Add note about bash execution if it contains ! prefix
42
+ if content.include?("!`")
43
+ add_bash_execution_note(content)
44
+ else
45
+ content
46
+ end
47
+ end
48
+
49
+ def create_command_frontmatter(file_path)
50
+ filename = File.basename(file_path)
51
+ <<~YAML
52
+ ---
53
+ description: Command from #{filename}
54
+ ---
55
+ YAML
56
+ end
57
+
58
+ def add_bash_execution_note(content)
59
+ match = content.match(/\A---\s*\n(.*?)\n---\s*\n/m)
60
+ return content unless match
61
+
62
+ note = "\n**Note:** This command originally used Claude Code's `!` prefix for bash execution. " \
63
+ "Cursor does not support this feature. The bash commands are documented below for reference.\n"
64
+
65
+ "---\n#{match[1]}\n---\n#{note}#{match.post_match}"
66
+ end
67
+
68
+ def save_command_file(relative_path, content)
69
+ filename = File.basename(relative_path, ".*")
70
+ output_filename = "#{filename}.md"
71
+
72
+ # Commands go to .cursor/commands/caruso/<marketplace>/<plugin>/
73
+ # Flat structure, no component subdirectory
74
+ subdirs = File.join("caruso", marketplace_name, plugin_name)
75
+ output_dir = File.join(".cursor", "commands", subdirs)
76
+
77
+ FileUtils.mkdir_p(output_dir)
78
+ target_path = File.join(output_dir, output_filename)
79
+
80
+ File.write(target_path, content)
81
+ puts "Saved command: #{target_path}"
82
+
83
+ # Return relative path for tracking
84
+ File.join(".cursor/commands", subdirs, output_filename)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -2,51 +2,81 @@
2
2
 
3
3
  require_relative "markdown_adapter"
4
4
  require_relative "skill_adapter"
5
+ require_relative "command_adapter"
6
+ require_relative "hook_adapter"
5
7
 
6
8
  module Caruso
7
9
  module Adapters
8
10
  class Dispatcher
9
- def self.adapt(files, target_dir:, marketplace_name:, plugin_name:, agent: :cursor)
10
- created_files = []
11
- remaining_files = files.dup
12
-
13
- # 1. Identify and Process Skill Clusters
14
- # Find all SKILL.md files to serve as anchors
15
- skill_anchors = remaining_files.select { |f| File.basename(f).casecmp("skill.md").zero? }
16
-
17
- skill_anchors.each do |anchor|
18
- skill_dir = File.dirname(anchor)
19
-
20
- # Find all files that belong to this skill's directory (recursive)
21
- skill_cluster = remaining_files.select { |f| f.start_with?(skill_dir) }
22
-
23
- # Use SkillAdapter for this cluster
24
- adapter = SkillAdapter.new(
25
- skill_cluster,
26
- target_dir: target_dir,
27
- marketplace_name: marketplace_name,
28
- plugin_name: plugin_name,
29
- agent: agent
30
- )
31
- created_files.concat(adapter.adapt)
32
-
33
- # Remove processed files
34
- remaining_files -= skill_cluster
11
+ class << self
12
+ def adapt(files, target_dir:, marketplace_name:, plugin_name:, agent: :cursor)
13
+ ctx = { target_dir: target_dir, marketplace_name: marketplace_name, plugin_name: plugin_name, agent: agent }
14
+ remaining = files.dup
15
+ created = []
16
+
17
+ created.concat(process_skills(remaining, ctx))
18
+ created.concat(process_commands(remaining, ctx))
19
+
20
+ hook_result = process_hooks(remaining, ctx)
21
+ created.concat(hook_result[:created])
22
+
23
+ skip_agents(remaining)
24
+ warn_unprocessed(remaining)
25
+
26
+ { files: created, hooks: hook_result[:hooks] }
35
27
  end
36
28
 
37
- # 2. Process Remaining Files (Commands, Agents, etc.) via MarkdownAdapter
38
- if remaining_files.any?
39
- adapter = MarkdownAdapter.new(
40
- remaining_files,
41
- target_dir: target_dir,
42
- marketplace_name: marketplace_name,
43
- plugin_name: plugin_name,
44
- agent: agent
45
- )
46
- created_files.concat(adapter.adapt)
29
+ private
30
+
31
+ def process_skills(remaining, ctx)
32
+ created = []
33
+ skill_anchors = remaining.select { |f| File.basename(f).casecmp("skill.md").zero? }
34
+
35
+ skill_anchors.each do |anchor|
36
+ skill_dir = File.dirname(anchor)
37
+ skill_cluster = remaining.select { |f| f.start_with?(skill_dir) }
38
+
39
+ adapter = SkillAdapter.new(skill_cluster, **ctx)
40
+ created.concat(adapter.adapt)
41
+ remaining.delete_if { |f| skill_cluster.include?(f) }
42
+ end
43
+
44
+ created
47
45
  end
48
46
 
49
- created_files
47
+ def process_commands(remaining, ctx)
48
+ commands = remaining.select { |f| f.include?("/commands/") }
49
+ return [] unless commands.any?
50
+
51
+ adapter = CommandAdapter.new(commands, **ctx)
52
+ remaining.delete_if { |f| commands.include?(f) }
53
+ adapter.adapt
54
+ end
55
+
56
+ def process_hooks(remaining, ctx)
57
+ hooks_files = remaining.select { |f| File.basename(f) =~ /hooks\.json\z/ }
58
+ return { created: [], hooks: {} } unless hooks_files.any?
59
+
60
+ adapter = HookAdapter.new(hooks_files, **ctx)
61
+ created = adapter.adapt
62
+ remaining.delete_if { |f| hooks_files.include?(f) }
63
+ { created: created, hooks: adapter.translated_hooks }
64
+ end
65
+
66
+ def skip_agents(remaining)
67
+ agents = remaining.select { |f| f.include?("/agents/") }
68
+ return unless agents.any?
69
+
70
+ puts "Skipping #{agents.size} agent(s): Agents require context isolation not available in Cursor"
71
+ remaining.delete_if { |f| agents.include?(f) }
72
+ end
73
+
74
+ def warn_unprocessed(remaining)
75
+ return unless remaining.any?
76
+
77
+ puts "Warning: #{remaining.size} file(s) could not be categorized and were skipped:"
78
+ remaining.each { |f| puts " - #{f}" }
79
+ end
50
80
  end
51
81
  end
52
82
  end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "base"
6
+
7
+ module Caruso
8
+ module Adapters
9
+ class HookAdapter < Base
10
+ # Claude Code events that map to Cursor events.
11
+ # Each CC event maps to a single Cursor event name.
12
+ # Matchers are lost in translation (Cursor has no matcher concept).
13
+ EVENT_MAP = {
14
+ "PreToolUse" => "beforeShellExecution",
15
+ "PostToolUse" => "afterShellExecution",
16
+ "UserPromptSubmit" => "beforeSubmitPrompt",
17
+ "Stop" => "stop"
18
+ }.freeze
19
+
20
+ # PostToolUse with Write|Edit matchers should map to afterFileEdit instead.
21
+ # We detect this via the matcher pattern.
22
+ FILE_EDIT_MATCHERS = /\A(Write|Edit|Write\|Edit|Edit\|Write|Notebook.*)\z/i
23
+
24
+ # Events that have no Cursor equivalent and must be skipped.
25
+ UNSUPPORTED_EVENTS = %w[
26
+ SessionStart
27
+ SessionEnd
28
+ SubagentStop
29
+ PreCompact
30
+ Notification
31
+ PermissionRequest
32
+ ].freeze
33
+
34
+ # After adapt(), contains the translated hook commands keyed by event.
35
+ # Used by callers to track which hooks were installed for clean uninstall.
36
+ attr_reader :translated_hooks
37
+
38
+ def adapt
39
+ @translated_hooks = {}
40
+
41
+ hooks_file = find_hooks_file
42
+ return [] unless hooks_file
43
+
44
+ plugin_hooks = parse_hooks_file(hooks_file)
45
+ return [] if plugin_hooks.nil? || plugin_hooks.empty?
46
+
47
+ cursor_hooks = translate_hooks(plugin_hooks)
48
+ return [] if cursor_hooks.empty?
49
+
50
+ # Copy any referenced scripts
51
+ copied_scripts = copy_hook_scripts(cursor_hooks, hooks_file)
52
+
53
+ # Merge into existing .cursor/hooks.json
54
+ merge_hooks(cursor_hooks)
55
+
56
+ # Store for tracking
57
+ @translated_hooks = cursor_hooks
58
+
59
+ # Return list of created/modified files for tracking
60
+ created = [".cursor/hooks.json"]
61
+ created += copied_scripts
62
+ created
63
+ end
64
+
65
+ private
66
+
67
+ def find_hooks_file
68
+ # files array contains the hooks.json path passed in by the Dispatcher.
69
+ # Also matches .caruso_inline_hooks.json written by Fetcher for inline plugin.json hooks.
70
+ files.find { |f| File.basename(f) =~ /hooks\.json\z/ }
71
+ end
72
+
73
+ def parse_hooks_file(hooks_file)
74
+ content = SafeFile.read(hooks_file)
75
+ data = JSON.parse(content)
76
+ data["hooks"]
77
+ rescue JSON::ParserError => e
78
+ puts "Warning: Could not parse hooks.json: #{e.message}"
79
+ nil
80
+ end
81
+
82
+ def translate_hooks(plugin_hooks)
83
+ cursor_hooks = {}
84
+ skipped_events = []
85
+ skipped_prompts = 0
86
+
87
+ plugin_hooks.each do |event_name, matchers|
88
+ if UNSUPPORTED_EVENTS.include?(event_name)
89
+ skipped_events << event_name
90
+ next
91
+ end
92
+
93
+ result = translate_event_hooks(event_name, matchers)
94
+ result[:hooks].each { |event, entries| (cursor_hooks[event] ||= []).concat(entries) }
95
+ skipped_prompts += result[:skipped_prompts]
96
+ end
97
+
98
+ warn_skipped(skipped_events, skipped_prompts)
99
+ cursor_hooks
100
+ end
101
+
102
+ def translate_event_hooks(event_name, matchers)
103
+ hooks = {}
104
+ skipped = 0
105
+
106
+ matchers.each do |matcher_entry|
107
+ matcher = matcher_entry["matcher"]
108
+ (matcher_entry["hooks"] || []).each do |hook|
109
+ if hook["type"] == "prompt"
110
+ skipped += 1
111
+ next
112
+ end
113
+
114
+ command = hook["command"]
115
+ next unless command
116
+
117
+ cursor_event = resolve_cursor_event(event_name, matcher)
118
+ cursor_hook = { "command" => command }
119
+ cursor_hook["timeout"] = hook["timeout"] if hook["timeout"]
120
+ (hooks[cursor_event] ||= []) << cursor_hook
121
+ end
122
+ end
123
+
124
+ { hooks: hooks, skipped_prompts: skipped }
125
+ end
126
+
127
+ def warn_skipped(skipped_events, skipped_prompts)
128
+ if skipped_events.any?
129
+ unique_skipped = skipped_events.uniq
130
+ puts "Skipping #{unique_skipped.size} unsupported hook event(s): #{unique_skipped.join(', ')}"
131
+ puts " (Cursor has no equivalent for these Claude Code lifecycle events)"
132
+ end
133
+
134
+ return unless skipped_prompts.positive?
135
+
136
+ puts "Skipping #{skipped_prompts} prompt-based hook(s): Cursor does not support LLM evaluation in hooks"
137
+ end
138
+
139
+ def resolve_cursor_event(cc_event, matcher)
140
+ # PostToolUse with file-related matchers maps to afterFileEdit
141
+ if cc_event == "PostToolUse" && matcher && FILE_EDIT_MATCHERS.match?(matcher)
142
+ return "afterFileEdit"
143
+ end
144
+
145
+ EVENT_MAP[cc_event] || "afterShellExecution"
146
+ end
147
+
148
+ def rewrite_script_path(command)
149
+ plugin_script_dir = File.join("hooks", "caruso", marketplace_name, plugin_name)
150
+ command.gsub("${CLAUDE_PLUGIN_ROOT}", plugin_script_dir)
151
+ end
152
+
153
+ def plugin_root_from_hooks_file(hooks_file)
154
+ # ${CLAUDE_PLUGIN_ROOT} refers to the plugin root directory.
155
+ # hooks.json is typically at <plugin_root>/hooks/hooks.json,
156
+ # so the plugin root is the parent of the hooks/ directory.
157
+ hooks_dir = File.dirname(hooks_file)
158
+ if File.basename(hooks_dir) == "hooks"
159
+ File.dirname(hooks_dir)
160
+ else
161
+ hooks_dir
162
+ end
163
+ end
164
+
165
+ def copy_hook_scripts(cursor_hooks, hooks_file)
166
+ plugin_root = plugin_root_from_hooks_file(hooks_file)
167
+
168
+ cursor_hooks.each_value.flat_map do |hook_entries|
169
+ hook_entries.filter_map { |hook| copy_single_script(hook, plugin_root) }
170
+ end
171
+ end
172
+
173
+ def copy_single_script(hook, plugin_root)
174
+ command = hook["command"]
175
+ return unless command&.include?("${CLAUDE_PLUGIN_ROOT}")
176
+
177
+ relative_path = command.sub("${CLAUDE_PLUGIN_ROOT}/", "")
178
+ source_path = File.join(plugin_root, relative_path)
179
+ return unless File.exist?(source_path)
180
+
181
+ target_path = File.join(".cursor", "hooks", "caruso", marketplace_name, plugin_name, relative_path)
182
+ FileUtils.mkdir_p(File.dirname(target_path))
183
+ FileUtils.cp(source_path, target_path)
184
+ File.chmod(0o755, target_path)
185
+ puts "Copied hook script: #{target_path}"
186
+
187
+ hook["command"] = rewrite_script_path(command)
188
+ target_path
189
+ end
190
+
191
+ def merge_hooks(new_hooks)
192
+ hooks_path = File.join(".cursor", "hooks.json")
193
+ existing = read_existing_hooks(hooks_path)
194
+
195
+ # Merge: append new hook entries to existing event arrays
196
+ new_hooks.each do |event, entries|
197
+ existing[event] ||= []
198
+ entries.each do |entry|
199
+ # Deduplicate: don't add if an identical command already exists
200
+ unless existing[event].any? { |e| e["command"] == entry["command"] }
201
+ existing[event] << entry
202
+ end
203
+ end
204
+ end
205
+
206
+ FileUtils.mkdir_p(".cursor")
207
+ File.write(hooks_path, JSON.pretty_generate({ "version" => 1, "hooks" => existing }))
208
+ puts "Merged hooks into #{hooks_path}"
209
+ end
210
+
211
+ def read_existing_hooks(hooks_path)
212
+ return {} unless File.exist?(hooks_path)
213
+
214
+ data = JSON.parse(File.read(hooks_path))
215
+ data["hooks"] || {}
216
+ rescue JSON::ParserError
217
+ puts "Warning: Existing hooks.json is malformed, starting fresh"
218
+ {}
219
+ end
220
+ end
221
+ end
222
+ end
@@ -10,10 +10,10 @@ module Caruso
10
10
  files.each do |file_path|
11
11
  content = SafeFile.read(file_path)
12
12
  adapted_content = inject_metadata(content, file_path)
13
-
13
+
14
14
  extension = agent == :cursor ? ".mdc" : ".md"
15
15
  created_file = save_file(file_path, adapted_content, extension: extension)
16
-
16
+
17
17
  created_files << created_file
18
18
  end
19
19
  created_files
@@ -6,50 +6,30 @@ module Caruso
6
6
  module Adapters
7
7
  class SkillAdapter < Base
8
8
  def adapt
9
- created_files = []
10
-
11
- # Separate SKILL.md from other files (scripts, etc.)
12
9
  skill_file = files.find { |f| File.basename(f).casecmp("skill.md").zero? }
13
- other_files = files - [skill_file]
10
+ return [] unless skill_file
14
11
 
15
- if skill_file
16
- skill_root = File.dirname(skill_file)
17
- skill_name = File.basename(skill_root)
18
-
19
- # Inject script location hint
20
- # Targeted script location: .cursor/scripts/caruso/<market>/<plugin>/<skill_name>/
21
- script_location = ".cursor/scripts/caruso/#{marketplace_name}/#{plugin_name}/#{skill_name}/"
22
-
23
- content = SafeFile.read(skill_file)
24
- adapted_content = inject_skill_metadata(content, skill_file, script_location)
25
-
26
- # Save as Rule (.mdc)
27
- extension = agent == :cursor ? ".mdc" : ".md"
28
- created_file = save_file(skill_file, adapted_content, extension: extension)
29
- created_files << created_file
30
-
31
- # Process Scripts/Assets -> .cursor/scripts/...
32
- other_files.each do |file_path|
33
- # Calculate path relative to the skill root
34
- # e.g. source: .../skills/auth/scripts/login.sh
35
- # root: .../skills/auth
36
- # rel: scripts/login.sh
37
- relative_path_from_root = file_path.sub(skill_root + "/", "")
38
-
39
- created_script = save_script(file_path, skill_name, relative_path_from_root)
40
- created_files << created_script
41
- end
42
- end
12
+ skill_root = File.dirname(skill_file)
13
+ skill_name = File.basename(skill_root)
14
+ other_files = files - [skill_file]
43
15
 
44
- created_files
16
+ [adapt_skill(skill_file, skill_name)] + copy_assets(other_files, skill_root, skill_name)
45
17
  end
46
18
 
47
19
  private
48
20
 
21
+ def adapt_skill(skill_file, skill_name)
22
+ script_location = ".cursor/scripts/caruso/#{marketplace_name}/#{plugin_name}/#{skill_name}/"
23
+ content = SafeFile.read(skill_file)
24
+ adapted_content = inject_skill_metadata(content, skill_file, script_location)
25
+ extension = agent == :cursor ? ".mdc" : ".md"
26
+ save_file(skill_file, adapted_content, extension: extension)
27
+ end
28
+
49
29
  def inject_skill_metadata(content, file_path, script_location)
50
30
  # Inject script location into description
51
31
  hint = "Scripts located at: #{script_location}"
52
-
32
+
53
33
  if content.match?(/\A---\s*\n.*?\n---\s*\n/m)
54
34
  # Update existing frontmatter
55
35
  content.sub!(/^description: (.*)$/, "description: \\1. #{hint}")
@@ -71,25 +51,32 @@ module Caruso
71
51
  YAML
72
52
  end
73
53
 
54
+ def copy_assets(asset_files, skill_root, skill_name)
55
+ asset_files.map do |file_path|
56
+ relative_path = file_path.sub("#{skill_root}/", "")
57
+ save_script(file_path, skill_name, relative_path)
58
+ end
59
+ end
60
+
74
61
  def save_script(source_path, skill_name, relative_sub_path)
75
62
  # Construct target path in .cursor/scripts
76
63
  # .cursor/scripts/caruso/<marketplace>/<plugin>/<skill_name>/<relative_sub_path>
77
-
64
+
78
65
  scripts_root = File.join(target_dir, "..", "scripts", "caruso", marketplace_name, plugin_name, skill_name)
79
- # Note: target_dir passed to adapter is usually .cursor/rules.
66
+ # NOTE: target_dir passed to adapter is usually .cursor/rules.
80
67
  # So .. -> .cursor -> scripts
81
-
68
+
82
69
  target_path = File.join(scripts_root, relative_sub_path)
83
70
  output_dir = File.dirname(target_path)
84
-
71
+
85
72
  FileUtils.mkdir_p(output_dir)
86
73
  FileUtils.cp(source_path, target_path)
87
-
74
+
88
75
  # Make executable
89
- File.chmod(0755, target_path)
90
-
76
+ File.chmod(0o755, target_path)
77
+
91
78
  puts "Saved script: #{target_path}"
92
-
79
+
93
80
  # Return relative path for tracking/reporting
94
81
  # We start from .cursor (parent of target_dir) ideally?
95
82
  # Or just return the absolute path for now?
data/lib/caruso/cli.rb CHANGED
@@ -227,12 +227,21 @@ module Caruso
227
227
  )
228
228
  created_filenames = adapter.adapt
229
229
 
230
- # Convert filenames to relative paths from project root
231
- created_files = created_filenames.map { |f| File.join(config_manager.target_dir, f) }
230
+ # Convert filenames to project-relative paths.
231
+ # Paths already starting with .cursor/ (commands, hooks) are already project-relative.
232
+ # Others (rules from SkillAdapter/MarkdownAdapter) are relative to target_dir.
233
+ created_files = created_filenames.map do |f|
234
+ if f.start_with?(".cursor/") || File.absolute_path?(f)
235
+ f
236
+ else
237
+ File.join(config_manager.target_dir, f)
238
+ end
239
+ end
232
240
 
233
241
  # Use composite key for uniqueness
234
242
  plugin_key = "#{plugin_name}@#{marketplace_name}"
235
- config_manager.add_plugin(plugin_key, created_files, marketplace_name: marketplace_name)
243
+ config_manager.add_plugin(plugin_key, created_files, marketplace_name: marketplace_name,
244
+ hooks: adapter.installed_hooks)
236
245
  puts "Installed #{plugin_name}!"
237
246
  end
238
247
 
@@ -455,8 +464,14 @@ module Caruso
455
464
  )
456
465
  created_filenames = adapter.adapt
457
466
 
458
- # Convert filenames to relative paths from project root
459
- created_files = created_filenames.map { |f| File.join(config_manager.target_dir, f) }
467
+ # Convert filenames to project-relative paths (same logic as install)
468
+ created_files = created_filenames.map do |f|
469
+ if f.start_with?(".cursor/") || File.absolute_path?(f)
470
+ f
471
+ else
472
+ File.join(config_manager.target_dir, f)
473
+ end
474
+ end
460
475
 
461
476
  # Cleanup: Delete files that are no longer present
462
477
  old_files = config_manager.get_installed_files(plugin_key)
@@ -470,7 +485,8 @@ module Caruso
470
485
  end
471
486
 
472
487
  # Update plugin in config
473
- config_manager.add_plugin(plugin_key, created_files, marketplace_name: marketplace_name)
488
+ config_manager.add_plugin(plugin_key, created_files, marketplace_name: marketplace_name,
489
+ hooks: adapter.installed_hooks)
474
490
  end
475
491
 
476
492
  def load_config
@@ -54,11 +54,7 @@ module Caruso
54
54
  raise Error, "Caruso not initialized. Run 'caruso init --ide=cursor' first."
55
55
  end
56
56
 
57
- project_data = load_project_config
58
- local_data = load_local_config
59
-
60
- # Merge data for easier access, but keep them conceptually separate
61
- project_data.merge(local_data)
57
+ load_project_config.merge(load_local_config)
62
58
  end
63
59
 
64
60
  def config_exists?
@@ -77,9 +73,7 @@ module Caruso
77
73
  load["ide"]
78
74
  end
79
75
 
80
- # Plugin Management
81
-
82
- def add_plugin(name, files, marketplace_name:)
76
+ def add_plugin(name, files, marketplace_name:, hooks: {})
83
77
  # Update project config (Intent)
84
78
  project_data = load_project_config
85
79
  project_data["plugins"] ||= {}
@@ -88,23 +82,32 @@ module Caruso
88
82
  }
89
83
  save_project_config(project_data)
90
84
 
91
- # Update local config (Files)
85
+ # Update local config (Files and Hooks)
92
86
  local_data = load_local_config
93
87
  local_data["installed_files"] ||= {}
94
88
  local_data["installed_files"][name] = files
89
+
90
+ # Track installed hook commands for clean uninstall
91
+ local_data["installed_hooks"] ||= {}
92
+ if hooks.empty?
93
+ local_data["installed_hooks"].delete(name)
94
+ else
95
+ local_data["installed_hooks"][name] = hooks
96
+ end
97
+
95
98
  save_local_config(local_data)
96
99
  end
97
100
 
98
101
  def remove_plugin(name)
99
- # Get files to remove from local config
102
+ # Get files and hooks to remove from local config
100
103
  local_data = load_local_config
101
104
  files = local_data.dig("installed_files", name) || []
105
+ hooks = local_data.dig("installed_hooks", name) || {}
102
106
 
103
107
  # Remove from local config
104
- if local_data["installed_files"]
105
- local_data["installed_files"].delete(name)
106
- save_local_config(local_data)
107
- end
108
+ local_data["installed_files"]&.delete(name)
109
+ local_data["installed_hooks"]&.delete(name)
110
+ save_local_config(local_data)
108
111
 
109
112
  # Remove from project config
110
113
  project_data = load_project_config
@@ -113,7 +116,7 @@ module Caruso
113
116
  save_project_config(project_data)
114
117
  end
115
118
 
116
- files
119
+ { files: files, hooks: hooks }
117
120
  end
118
121
 
119
122
  def list_plugins
@@ -121,15 +124,16 @@ module Caruso
121
124
  end
122
125
 
123
126
  def plugin_installed?(name)
124
- plugins = list_plugins
125
- plugins.key?(name)
127
+ list_plugins.key?(name)
126
128
  end
127
129
 
128
130
  def get_installed_files(name)
129
131
  load_local_config.dig("installed_files", name) || []
130
132
  end
131
133
 
132
- # Marketplace Management
134
+ def get_installed_hooks(name)
135
+ load_local_config.dig("installed_hooks", name) || {}
136
+ end
133
137
 
134
138
  def add_marketplace(name, url, source: "git", ref: nil)
135
139
  data = load_project_config
@@ -151,20 +155,23 @@ module Caruso
151
155
  end
152
156
 
153
157
  def remove_marketplace_with_plugins(marketplace_name)
154
- files_to_remove = []
158
+ result = { files: [], hooks: {} }
155
159
 
156
160
  # Find and remove all plugins associated with this marketplace
157
161
  installed_plugins = list_plugins
158
162
  installed_plugins.each do |plugin_key, details|
159
- if details["marketplace"] == marketplace_name
160
- files_to_remove.concat(remove_plugin(plugin_key))
161
- end
163
+ next unless details["marketplace"] == marketplace_name
164
+
165
+ plugin_result = remove_plugin(plugin_key)
166
+ result[:files].concat(plugin_result[:files])
167
+ result[:hooks].merge!(plugin_result[:hooks])
162
168
  end
163
169
 
164
170
  # Remove the marketplace itself
165
171
  remove_marketplace(marketplace_name)
166
172
 
167
- files_to_remove.uniq
173
+ result[:files] = result[:files].uniq
174
+ result
168
175
  end
169
176
 
170
177
  def list_marketplaces
@@ -175,11 +182,6 @@ module Caruso
175
182
  load_project_config.dig("marketplaces", name)
176
183
  end
177
184
 
178
- def get_marketplace_url(name)
179
- details = get_marketplace_details(name)
180
- details ? details["url"] : nil
181
- end
182
-
183
185
  private
184
186
 
185
187
  def load_project_config
@@ -180,12 +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, plugin)
183
+ # Merge marketplace entry with plugin.json (marketplace takes precedence)
184
+ merged_plugin_data = merge_with_plugin_json(plugin, plugin_path)
185
+
186
+ files = find_steering_files(plugin_path, merged_plugin_data)
185
187
 
186
188
  files.uniq
187
189
  end
188
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
+
189
218
  def resolve_plugin_path(source)
190
219
  if source.is_a?(Hash) && %w[git github].include?(source["source"])
191
220
  clone_git_repo(source)
@@ -213,13 +242,13 @@ module Caruso
213
242
  # 1. ALWAYS scan default directories (Additive Strategy)
214
243
  files += glob_plugin_files(plugin_path, "commands", "**", "*.md")
215
244
  files += glob_plugin_files(plugin_path, "agents", "**", "*.md")
216
-
245
+
217
246
  # For skills, we want recursive default scan if 'skills/' exists
218
247
  # But careful: if we scan default 'skills' recursively here, and then scan strict paths from manifest...
219
248
  # Duplicate handling is fine via uniq.
220
249
  default_skills_path = File.join(plugin_path, "skills")
221
250
  if SafeDir.exist?(default_skills_path)
222
- files += find_recursive_component_files(plugin_path, "skills")
251
+ files += find_recursive_component_files(plugin_path, "skills")
223
252
  end
224
253
 
225
254
  # 2. Add manifest-defined paths (if present)
@@ -229,6 +258,9 @@ module Caruso
229
258
  files += find_recursive_component_files(plugin_path, plugin_data["skills"]) if plugin_data["skills"]
230
259
  end
231
260
 
261
+ # 3. Detect hooks files
262
+ files += find_hooks_files(plugin_path, plugin_data)
263
+
232
264
  # Filter out noise
233
265
  files.uniq.reject do |file|
234
266
  basename = File.basename(file).downcase
@@ -238,8 +270,8 @@ module Caruso
238
270
 
239
271
  # Helper to glob files safely
240
272
  def glob_plugin_files(plugin_path, *parts)
241
- pattern = PathSanitizer.safe_join(plugin_path, *parts)
242
- SafeDir.glob(pattern, base_dir: plugin_path)
273
+ pattern = PathSanitizer.safe_join(plugin_path, *parts)
274
+ SafeDir.glob(pattern, base_dir: plugin_path)
243
275
  end
244
276
 
245
277
  # For Commands/Agents: typically just markdown files, flat or shallow
@@ -263,35 +295,71 @@ module Caruso
263
295
  end
264
296
  files
265
297
  end
266
-
298
+
267
299
  # For SKILLS: Recursive fetch of EVERYTHING (scripts, assets, md)
268
300
  def find_recursive_component_files(plugin_path, paths)
269
- paths = [paths] if paths.is_a?(String)
270
- return [] unless paths.is_a?(Array)
271
-
272
- files = []
273
- paths.each do |path|
274
- full_path = resolve_safe_path(plugin_path, path)
275
- next unless full_path
276
-
277
- if File.file?(full_path)
278
- files << full_path
279
- elsif SafeDir.exist?(full_path, base_dir: plugin_path)
280
- # Grab EVERYTHING recursively
281
- glob_pattern = PathSanitizer.safe_join(full_path, "**", "*")
282
- files += SafeDir.glob(glob_pattern, base_dir: plugin_path)
283
- end
284
- end
285
- files
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)
315
+ end
316
+ end
317
+ files
286
318
  end
287
-
288
- def resolve_safe_path(plugin_path, relative_path)
289
- begin
290
- PathSanitizer.sanitize_path(File.expand_path(relative_path, plugin_path), base_dir: plugin_path)
291
- rescue PathSanitizer::PathTraversalError => e
292
- warn "Skipping path outside plugin directory '#{relative_path}': #{e.message}"
293
- nil
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)
294
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
295
363
  end
296
364
 
297
365
  def local_path?
@@ -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.3"
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
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: caruso
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.3
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philipp Comans
@@ -147,7 +147,9 @@ files:
147
147
  - lib/caruso.rb
148
148
  - lib/caruso/adapter.rb
149
149
  - lib/caruso/adapters/base.rb
150
+ - lib/caruso/adapters/command_adapter.rb
150
151
  - lib/caruso/adapters/dispatcher.rb
152
+ - lib/caruso/adapters/hook_adapter.rb
151
153
  - lib/caruso/adapters/markdown_adapter.rb
152
154
  - lib/caruso/adapters/skill_adapter.rb
153
155
  - lib/caruso/cli.rb
@@ -159,6 +161,7 @@ files:
159
161
  - lib/caruso/safe_dir.rb
160
162
  - lib/caruso/safe_file.rb
161
163
  - lib/caruso/version.rb
164
+ - package-lock.json
162
165
  - reference/claude_code_create_marketplaces.md
163
166
  - reference/claude_code_hooks.md
164
167
  - reference/claude_code_marketplaces.md