caruso 0.6.3 → 0.7.1

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: e27842f0328ffd84a7b184987157b7bae790284f304d190896ad566250da4de2
4
+ data.tar.gz: 156462c94584cd858bf0bc30fe83ad71e1796c3022088147eb03dd39e4cc0712
5
5
  SHA512:
6
- metadata.gz: 48d8c7c701c7c1a380672ac77f3025dc69c90c4a5ef7d6fd6e72e50358e69440a9c57ff022f52f94c4970faead782ba04c223246bf766045e4508bca427cf4f8
7
- data.tar.gz: 293893a825850e919ca8141203f307c300e9fdba931f80aafadad15a766dd5f28ff6bd061eae53891c5a4da41f061e2b4b6bea5f0108d9b79396e63d82f49296
6
+ metadata.gz: c404d5dbe4128f2c63e8ab01ac39760aa1e131d73eeb80d7c4a5ca23fb47dde2d9f2f90508625d2c60d60903942e622e62bc14adc690fe5c43657acc8ca26e96
7
+ data.tar.gz: 530cb46037d8b3a67dfe3e8fca939d008b41d6ec580049392779ab047ada81321f4faadc17b00d2a2f77ff847f3bee92704929ec05803a15753fbfcbaf9824d7
data/.rubocop.yml CHANGED
@@ -60,6 +60,7 @@ Metrics/ClassLength:
60
60
  - 'spec/**/*'
61
61
  - 'lib/caruso/cli.rb' # CLI class has many Thor commands
62
62
  - 'lib/caruso/fetcher.rb' # Fetcher handles multiple sources
63
+ - 'lib/caruso/adapters/hook_adapter.rb' # Hook translation has many methods
63
64
 
64
65
  # Prefer descriptive block parameter names
65
66
  Lint/UnusedBlockArgument:
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,218 @@
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
+ # CC events map to Cursor events; matchers are lost (Cursor has no matcher concept).
11
+ EVENT_MAP = {
12
+ "PreToolUse" => "beforeShellExecution",
13
+ "PostToolUse" => "afterShellExecution",
14
+ "UserPromptSubmit" => "beforeSubmitPrompt",
15
+ "Stop" => "stop"
16
+ }.freeze
17
+
18
+ # PostToolUse with Write|Edit matchers maps to afterFileEdit instead.
19
+ FILE_EDIT_MATCHERS = /\A(Write|Edit|Write\|Edit|Edit\|Write|Notebook.*)\z/i
20
+
21
+ # Events with no Cursor equivalent.
22
+ UNSUPPORTED_EVENTS = %w[
23
+ SessionStart
24
+ SessionEnd
25
+ SubagentStop
26
+ PreCompact
27
+ Notification
28
+ PermissionRequest
29
+ ].freeze
30
+
31
+ # Contains translated hook commands keyed by event (for clean uninstall tracking).
32
+ attr_reader :translated_hooks
33
+
34
+ def adapt
35
+ @translated_hooks = {}
36
+
37
+ hooks_file = find_hooks_file
38
+ return [] unless hooks_file
39
+
40
+ plugin_hooks = parse_hooks_file(hooks_file)
41
+ return [] if plugin_hooks.nil? || plugin_hooks.empty?
42
+
43
+ cursor_hooks = translate_hooks(plugin_hooks)
44
+ return [] if cursor_hooks.empty?
45
+
46
+ # Copy any referenced scripts
47
+ copied_scripts = copy_hook_scripts(cursor_hooks, hooks_file)
48
+
49
+ # Merge into existing .cursor/hooks.json
50
+ merge_hooks(cursor_hooks)
51
+
52
+ # Store for tracking
53
+ @translated_hooks = cursor_hooks
54
+
55
+ # Return list of created/modified files for tracking
56
+ created = [".cursor/hooks.json"]
57
+ created += copied_scripts
58
+ created
59
+ end
60
+
61
+ private
62
+
63
+ def find_hooks_file
64
+ files.find { |f| File.basename(f) =~ /hooks\.json\z/ }
65
+ end
66
+
67
+ def parse_hooks_file(hooks_file)
68
+ content = SafeFile.read(hooks_file)
69
+ data = JSON.parse(content)
70
+ data["hooks"]
71
+ rescue JSON::ParserError => e
72
+ puts "Warning: Could not parse hooks.json: #{e.message}"
73
+ nil
74
+ end
75
+
76
+ def translate_hooks(plugin_hooks)
77
+ cursor_hooks = {}
78
+ skipped_events = []
79
+ skipped_prompts = 0
80
+
81
+ plugin_hooks.each do |event_name, matchers|
82
+ if UNSUPPORTED_EVENTS.include?(event_name)
83
+ skipped_events << event_name
84
+ next
85
+ end
86
+
87
+ result = translate_event_hooks(event_name, matchers)
88
+ result[:hooks].each { |event, entries| (cursor_hooks[event] ||= []).concat(entries) }
89
+ skipped_prompts += result[:skipped_prompts]
90
+ end
91
+
92
+ warn_skipped(skipped_events, skipped_prompts)
93
+ cursor_hooks
94
+ end
95
+
96
+ def translate_event_hooks(event_name, matchers)
97
+ hooks = {}
98
+ skipped = 0
99
+
100
+ matchers.each do |matcher_entry|
101
+ matcher = matcher_entry["matcher"]
102
+ (matcher_entry["hooks"] || []).each do |hook|
103
+ if hook["type"] == "prompt"
104
+ skipped += 1
105
+ next
106
+ end
107
+
108
+ command = hook["command"]
109
+ next unless command
110
+
111
+ cursor_event = resolve_cursor_event(event_name, matcher)
112
+ cursor_hook = { "command" => command }
113
+ cursor_hook["timeout"] = hook["timeout"] if hook["timeout"]
114
+ (hooks[cursor_event] ||= []) << cursor_hook
115
+ end
116
+ end
117
+
118
+ { hooks: hooks, skipped_prompts: skipped }
119
+ end
120
+
121
+ def warn_skipped(skipped_events, skipped_prompts)
122
+ if skipped_events.any?
123
+ unique_skipped = skipped_events.uniq
124
+ puts "Skipping #{unique_skipped.size} unsupported hook event(s): #{unique_skipped.join(', ')}"
125
+ puts " (Cursor has no equivalent for these Claude Code lifecycle events)"
126
+ end
127
+
128
+ return unless skipped_prompts.positive?
129
+
130
+ puts "Skipping #{skipped_prompts} prompt-based hook(s): Cursor does not support LLM evaluation in hooks"
131
+ end
132
+
133
+ def resolve_cursor_event(cc_event, matcher)
134
+ # PostToolUse with file-related matchers maps to afterFileEdit
135
+ if cc_event == "PostToolUse" && matcher && FILE_EDIT_MATCHERS.match?(matcher)
136
+ return "afterFileEdit"
137
+ end
138
+
139
+ EVENT_MAP[cc_event] || "afterShellExecution"
140
+ end
141
+
142
+ def rewrite_script_path(command)
143
+ plugin_script_dir = File.join("hooks", "caruso", marketplace_name, plugin_name)
144
+ command.gsub("${CLAUDE_PLUGIN_ROOT}", plugin_script_dir)
145
+ end
146
+
147
+ def plugin_root_from_hooks_file(hooks_file)
148
+ # Plugin root is parent of hooks/ dir (hooks.json is at <plugin_root>/hooks/hooks.json)
149
+ hooks_dir = File.dirname(hooks_file)
150
+ if File.basename(hooks_dir) == "hooks"
151
+ File.dirname(hooks_dir)
152
+ else
153
+ hooks_dir
154
+ end
155
+ end
156
+
157
+ def copy_hook_scripts(cursor_hooks, hooks_file)
158
+ plugin_root = plugin_root_from_hooks_file(hooks_file)
159
+
160
+ cursor_hooks.each_value.flat_map do |hook_entries|
161
+ hook_entries.filter_map { |hook| copy_single_script(hook, plugin_root) }
162
+ end
163
+ end
164
+
165
+ def copy_single_script(hook, plugin_root)
166
+ command = hook["command"]
167
+ return unless command&.include?("${CLAUDE_PLUGIN_ROOT}")
168
+
169
+ # Extract path after placeholder (handles "python3 ${CLAUDE_PLUGIN_ROOT}/script.py")
170
+ match = command.match(%r{\$\{CLAUDE_PLUGIN_ROOT\}/([^\s]+)})
171
+ return unless match
172
+
173
+ relative_path = match[1]
174
+ source_path = File.join(plugin_root, relative_path)
175
+ return unless File.exist?(source_path)
176
+
177
+ target_path = File.join(".cursor", "hooks", "caruso", marketplace_name, plugin_name, relative_path)
178
+ FileUtils.mkdir_p(File.dirname(target_path))
179
+ FileUtils.cp(source_path, target_path)
180
+ File.chmod(0o755, target_path)
181
+ puts "Copied hook script: #{target_path}"
182
+
183
+ hook["command"] = rewrite_script_path(command)
184
+ target_path
185
+ end
186
+
187
+ def merge_hooks(new_hooks)
188
+ hooks_path = File.join(".cursor", "hooks.json")
189
+ existing = read_existing_hooks(hooks_path)
190
+
191
+ # Merge: append new hook entries to existing event arrays
192
+ new_hooks.each do |event, entries|
193
+ existing[event] ||= []
194
+ entries.each do |entry|
195
+ # Deduplicate: don't add if an identical command already exists
196
+ unless existing[event].any? { |e| e["command"] == entry["command"] }
197
+ existing[event] << entry
198
+ end
199
+ end
200
+ end
201
+
202
+ FileUtils.mkdir_p(".cursor")
203
+ File.write(hooks_path, JSON.pretty_generate({ "version" => 1, "hooks" => existing }))
204
+ puts "Merged hooks into #{hooks_path}"
205
+ end
206
+
207
+ def read_existing_hooks(hooks_path)
208
+ return {} unless File.exist?(hooks_path)
209
+
210
+ data = JSON.parse(File.read(hooks_path))
211
+ data["hooks"] || {}
212
+ rescue JSON::ParserError
213
+ puts "Warning: Existing hooks.json is malformed, starting fresh"
214
+ {}
215
+ end
216
+ end
217
+ end
218
+ 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
@@ -29,6 +29,9 @@ module Caruso
29
29
  # Read marketplace name from marketplace.json
30
30
  marketplace_name = fetcher.extract_marketplace_name
31
31
 
32
+ # Register in the persistent marketplace registry (only after name is known)
33
+ fetcher.register_marketplace(marketplace_name)
34
+
32
35
  config_manager.add_marketplace(marketplace_name, url, source: source, ref: options[:ref])
33
36
 
34
37
  puts "Added marketplace '#{marketplace_name}' from #{url}"
@@ -227,12 +230,21 @@ module Caruso
227
230
  )
228
231
  created_filenames = adapter.adapt
229
232
 
230
- # Convert filenames to relative paths from project root
231
- created_files = created_filenames.map { |f| File.join(config_manager.target_dir, f) }
233
+ # Convert filenames to project-relative paths.
234
+ # Paths already starting with .cursor/ (commands, hooks) are already project-relative.
235
+ # Others (rules from SkillAdapter/MarkdownAdapter) are relative to target_dir.
236
+ created_files = created_filenames.map do |f|
237
+ if f.start_with?(".cursor/") || File.absolute_path?(f)
238
+ f
239
+ else
240
+ File.join(config_manager.target_dir, f)
241
+ end
242
+ end
232
243
 
233
244
  # Use composite key for uniqueness
234
245
  plugin_key = "#{plugin_name}@#{marketplace_name}"
235
- config_manager.add_plugin(plugin_key, created_files, marketplace_name: marketplace_name)
246
+ config_manager.add_plugin(plugin_key, created_files, marketplace_name: marketplace_name,
247
+ hooks: adapter.installed_hooks)
236
248
  puts "Installed #{plugin_name}!"
237
249
  end
238
250
 
@@ -455,8 +467,14 @@ module Caruso
455
467
  )
456
468
  created_filenames = adapter.adapt
457
469
 
458
- # Convert filenames to relative paths from project root
459
- created_files = created_filenames.map { |f| File.join(config_manager.target_dir, f) }
470
+ # Convert filenames to project-relative paths (same logic as install)
471
+ created_files = created_filenames.map do |f|
472
+ if f.start_with?(".cursor/") || File.absolute_path?(f)
473
+ f
474
+ else
475
+ File.join(config_manager.target_dir, f)
476
+ end
477
+ end
460
478
 
461
479
  # Cleanup: Delete files that are no longer present
462
480
  old_files = config_manager.get_installed_files(plugin_key)
@@ -470,7 +488,8 @@ module Caruso
470
488
  end
471
489
 
472
490
  # Update plugin in config
473
- config_manager.add_plugin(plugin_key, created_files, marketplace_name: marketplace_name)
491
+ config_manager.add_plugin(plugin_key, created_files, marketplace_name: marketplace_name,
492
+ hooks: adapter.installed_hooks)
474
493
  end
475
494
 
476
495
  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
@@ -72,6 +72,10 @@ module Caruso
72
72
  url = source_config["url"] || source_config["repo"]
73
73
  url = "https://github.com/#{url}.git" if source_config["source"] == "github" && !url.match?(/\Ahttps?:/)
74
74
 
75
+ # Store these for later registration
76
+ @clone_url = url
77
+ @clone_source_type = source_config["source"] || "git"
78
+
75
79
  URI.parse(url).path.split("/").last.sub(".git", "")
76
80
  target_path = cache_dir
77
81
 
@@ -80,10 +84,6 @@ module Caruso
80
84
  FileUtils.mkdir_p(File.dirname(target_path))
81
85
  Git.clone(url, target_path)
82
86
  checkout_ref if @ref
83
-
84
- # Add to registry
85
- source_type = source_config["source"] || "git"
86
- @registry.add_marketplace(@marketplace_name, url, target_path, ref: @ref, source: source_type)
87
87
  end
88
88
 
89
89
  target_path
@@ -92,6 +92,15 @@ module Caruso
92
92
  nil
93
93
  end
94
94
 
95
+ # Register the marketplace in the registry after name is known.
96
+ # Must be called after extract_marketplace_name or when marketplace_name is set.
97
+ def register_marketplace(name)
98
+ return unless @clone_url # Only register if we cloned something
99
+
100
+ @marketplace_name = name
101
+ @registry.add_marketplace(name, @clone_url, cache_dir, ref: @ref, source: @clone_source_type)
102
+ end
103
+
95
104
  def extract_marketplace_name
96
105
  marketplace_data = load_marketplace
97
106
  name = marketplace_data["name"]
@@ -106,7 +115,25 @@ module Caruso
106
115
  private
107
116
 
108
117
  def load_marketplace
109
- if local_path?
118
+ # Check github_repo? BEFORE local_path? because owner/repo format (e.g., "anthropics/claude-code")
119
+ # doesn't start with https:// but should be treated as a GitHub repo, not a local path
120
+ if github_repo?
121
+ # Clone repo and read marketplace.json from it
122
+ repo_path = clone_git_repo("url" => @marketplace_uri, "source" => "github")
123
+ # Try standard locations
124
+ json_path = File.join(repo_path, ".claude-plugin", "marketplace.json")
125
+ json_path = File.join(repo_path, "marketplace.json") unless File.exist?(json_path)
126
+
127
+ unless File.exist?(json_path)
128
+ raise "Could not find marketplace.json in #{@marketplace_uri}"
129
+ end
130
+
131
+ # Update marketplace_uri to point to the local file so relative paths work
132
+ @marketplace_uri = json_path
133
+ @base_dir = repo_path # Base dir is the repo root, regardless of where json is
134
+
135
+ JSON.parse(SafeFile.read(json_path))
136
+ elsif local_path?
110
137
  # If marketplace_uri is a directory, find marketplace.json in it
111
138
  if SafeDir.exist?(@marketplace_uri)
112
139
  json_path = File.join(@marketplace_uri, ".claude-plugin", "marketplace.json")
@@ -126,22 +153,6 @@ module Caruso
126
153
  end
127
154
 
128
155
  JSON.parse(SafeFile.read(@marketplace_uri))
129
- elsif github_repo?
130
- # Clone repo and read marketplace.json from it
131
- repo_path = clone_git_repo("url" => @marketplace_uri, "source" => "github")
132
- # Try standard locations
133
- json_path = File.join(repo_path, ".claude-plugin", "marketplace.json")
134
- json_path = File.join(repo_path, "marketplace.json") unless File.exist?(json_path)
135
-
136
- unless File.exist?(json_path)
137
- raise "Could not find marketplace.json in #{@marketplace_uri}"
138
- end
139
-
140
- # Update marketplace_uri to point to the local file so relative paths work
141
- @marketplace_uri = json_path
142
- @base_dir = repo_path # Base dir is the repo root, regardless of where json is
143
-
144
- JSON.parse(SafeFile.read(json_path))
145
156
  else
146
157
  response = Faraday.get(@marketplace_uri)
147
158
  JSON.parse(response.body)
@@ -180,12 +191,41 @@ module Caruso
180
191
 
181
192
  return [] unless SafeDir.exist?(plugin_path)
182
193
 
183
- # Start with default directories
184
- files = find_steering_files(plugin_path, plugin)
194
+ # Merge marketplace entry with plugin.json (marketplace takes precedence)
195
+ merged_plugin_data = merge_with_plugin_json(plugin, plugin_path)
196
+
197
+ files = find_steering_files(plugin_path, merged_plugin_data)
185
198
 
186
199
  files.uniq
187
200
  end
188
201
 
202
+ # Read plugin.json and merge with marketplace entry.
203
+ # Marketplace fields override plugin.json fields for component paths.
204
+ def merge_with_plugin_json(marketplace_entry, plugin_path)
205
+ plugin_json_path = File.join(plugin_path, ".claude-plugin", "plugin.json")
206
+ return marketplace_entry unless File.exist?(plugin_json_path)
207
+
208
+ begin
209
+ plugin_data = JSON.parse(SafeFile.read(plugin_json_path))
210
+ rescue JSON::ParserError => e
211
+ puts "Warning: Could not parse plugin.json: #{e.message}"
212
+ return marketplace_entry
213
+ end
214
+
215
+ component_fields = %w[commands agents skills hooks mcpServers]
216
+ merged = marketplace_entry.dup
217
+
218
+ component_fields.each do |field|
219
+ # Only use plugin.json value if marketplace entry doesn't specify this field
220
+ next if merged.key?(field)
221
+ next unless plugin_data.key?(field)
222
+
223
+ merged[field] = plugin_data[field]
224
+ end
225
+
226
+ merged
227
+ end
228
+
189
229
  def resolve_plugin_path(source)
190
230
  if source.is_a?(Hash) && %w[git github].include?(source["source"])
191
231
  clone_git_repo(source)
@@ -213,13 +253,13 @@ module Caruso
213
253
  # 1. ALWAYS scan default directories (Additive Strategy)
214
254
  files += glob_plugin_files(plugin_path, "commands", "**", "*.md")
215
255
  files += glob_plugin_files(plugin_path, "agents", "**", "*.md")
216
-
256
+
217
257
  # For skills, we want recursive default scan if 'skills/' exists
218
258
  # But careful: if we scan default 'skills' recursively here, and then scan strict paths from manifest...
219
259
  # Duplicate handling is fine via uniq.
220
260
  default_skills_path = File.join(plugin_path, "skills")
221
261
  if SafeDir.exist?(default_skills_path)
222
- files += find_recursive_component_files(plugin_path, "skills")
262
+ files += find_recursive_component_files(plugin_path, "skills")
223
263
  end
224
264
 
225
265
  # 2. Add manifest-defined paths (if present)
@@ -229,6 +269,9 @@ module Caruso
229
269
  files += find_recursive_component_files(plugin_path, plugin_data["skills"]) if plugin_data["skills"]
230
270
  end
231
271
 
272
+ # 3. Detect hooks files
273
+ files += find_hooks_files(plugin_path, plugin_data)
274
+
232
275
  # Filter out noise
233
276
  files.uniq.reject do |file|
234
277
  basename = File.basename(file).downcase
@@ -238,8 +281,8 @@ module Caruso
238
281
 
239
282
  # Helper to glob files safely
240
283
  def glob_plugin_files(plugin_path, *parts)
241
- pattern = PathSanitizer.safe_join(plugin_path, *parts)
242
- SafeDir.glob(pattern, base_dir: plugin_path)
284
+ pattern = PathSanitizer.safe_join(plugin_path, *parts)
285
+ SafeDir.glob(pattern, base_dir: plugin_path)
243
286
  end
244
287
 
245
288
  # For Commands/Agents: typically just markdown files, flat or shallow
@@ -263,35 +306,71 @@ module Caruso
263
306
  end
264
307
  files
265
308
  end
266
-
309
+
267
310
  # For SKILLS: Recursive fetch of EVERYTHING (scripts, assets, md)
268
311
  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
312
+ paths = [paths] if paths.is_a?(String)
313
+ return [] unless paths.is_a?(Array)
314
+
315
+ files = []
316
+ paths.each do |path|
317
+ full_path = resolve_safe_path(plugin_path, path)
318
+ next unless full_path
319
+
320
+ if File.file?(full_path)
321
+ files << full_path
322
+ elsif SafeDir.exist?(full_path, base_dir: plugin_path)
323
+ # Grab EVERYTHING recursively
324
+ glob_pattern = PathSanitizer.safe_join(full_path, "**", "*")
325
+ files += SafeDir.glob(glob_pattern, base_dir: plugin_path)
326
+ end
327
+ end
328
+ files
286
329
  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
330
+
331
+ def find_hooks_files(plugin_path, plugin_data)
332
+ files = []
333
+
334
+ # Default hooks/ directory
335
+ default_hooks = File.join(plugin_path, "hooks", "hooks.json")
336
+ files << default_hooks if File.exist?(default_hooks)
337
+
338
+ # Custom hooks from manifest (inline config or path-based)
339
+ return files unless plugin_data&.key?("hooks")
340
+
341
+ hooks_value = plugin_data["hooks"]
342
+ if hooks_value.is_a?(Hash)
343
+ inline_path = File.join(plugin_path, ".caruso_inline_hooks.json")
344
+ File.write(inline_path, JSON.pretty_generate(hooks_value))
345
+ files << inline_path
346
+ else
347
+ files += find_custom_hooks_paths(plugin_path, hooks_value)
348
+ end
349
+
350
+ files
351
+ end
352
+
353
+ def find_custom_hooks_paths(plugin_path, hooks_value)
354
+ files = []
355
+ [hooks_value].flatten.each do |path|
356
+ full_path = resolve_safe_path(plugin_path, path)
357
+ next unless full_path
358
+
359
+ if File.file?(full_path) && File.basename(full_path) == "hooks.json"
360
+ files << full_path
361
+ elsif SafeDir.exist?(full_path, base_dir: plugin_path)
362
+ candidate = File.join(full_path, "hooks.json")
363
+ files << candidate if File.exist?(candidate)
294
364
  end
365
+ end
366
+ files
367
+ end
368
+
369
+ def resolve_safe_path(plugin_path, relative_path)
370
+ PathSanitizer.sanitize_path(File.expand_path(relative_path, plugin_path), base_dir: plugin_path)
371
+ rescue PathSanitizer::PathTraversalError => e
372
+ warn "Skipping path outside plugin directory '#{relative_path}': #{e.message}"
373
+ nil
295
374
  end
296
375
 
297
376
  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.1"
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.1
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