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 +4 -4
- data/.rubocop.yml +1 -0
- 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 +218 -0
- data/lib/caruso/adapters/markdown_adapter.rb +2 -2
- data/lib/caruso/adapters/skill_adapter.rb +29 -42
- data/lib/caruso/cli.rb +25 -6
- data/lib/caruso/config_manager.rb +30 -28
- data/lib/caruso/fetcher.rb +131 -52
- 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: e27842f0328ffd84a7b184987157b7bae790284f304d190896ad566250da4de2
|
|
4
|
+
data.tar.gz: 156462c94584cd858bf0bc30fe83ad71e1796c3022088147eb03dd39e4cc0712
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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,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
|
-
|
|
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
|
@@ -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
|
|
231
|
-
|
|
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
|
|
459
|
-
created_files = created_filenames.map
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
#
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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?
|
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.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
|