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 +4 -4
- data/CHANGELOG.md +41 -0
- data/lib/caruso/adapter.rb +8 -1
- data/lib/caruso/adapters/base.rb +5 -5
- data/lib/caruso/adapters/command_adapter.rb +88 -0
- data/lib/caruso/adapters/dispatcher.rb +67 -37
- data/lib/caruso/adapters/hook_adapter.rb +222 -0
- data/lib/caruso/adapters/markdown_adapter.rb +2 -2
- data/lib/caruso/adapters/skill_adapter.rb +29 -42
- data/lib/caruso/cli.rb +22 -6
- data/lib/caruso/config_manager.rb +30 -28
- data/lib/caruso/fetcher.rb +99 -31
- data/lib/caruso/remover.rb +73 -13
- data/lib/caruso/version.rb +1 -1
- data/package-lock.json +6 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f7f1e5f2c4af1c26c2592aaa0cd5be588fa105ae3e7c8faf009275e881b2800
|
|
4
|
+
data.tar.gz: d740248a46af8e1559f7b02fb70b2660ff907b66e1acd7522c4e7a2692255938
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/caruso/adapter.rb
CHANGED
|
@@ -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
|
data/lib/caruso/adapters/base.rb
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
+
return [] unless skill_file
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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(
|
|
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
|
|
231
|
-
|
|
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
|
|
459
|
-
created_files = created_filenames.map
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
data/lib/caruso/fetcher.rb
CHANGED
|
@@ -180,12 +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
|
+
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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?
|
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
data/package-lock.json
ADDED
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.
|
|
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
|