caruso 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Caruso
6
+ module Adapters
7
+ class MarkdownAdapter < Base
8
+ def adapt
9
+ created_files = []
10
+ files.each do |file_path|
11
+ content = SafeFile.read(file_path)
12
+ adapted_content = inject_metadata(content, file_path)
13
+
14
+ extension = agent == :cursor ? ".mdc" : ".md"
15
+ created_file = save_file(file_path, adapted_content, extension: extension)
16
+
17
+ created_files << created_file
18
+ end
19
+ created_files
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Caruso
6
+ module Adapters
7
+ class SkillAdapter < Base
8
+ def adapt
9
+ skill_file = files.find { |f| File.basename(f).casecmp("skill.md").zero? }
10
+ return [] unless skill_file
11
+
12
+ skill_root = File.dirname(skill_file)
13
+ skill_name = File.basename(skill_root)
14
+ other_files = files - [skill_file]
15
+
16
+ [adapt_skill(skill_file, skill_name)] + copy_assets(other_files, skill_root, skill_name)
17
+ end
18
+
19
+ private
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
+
29
+ def inject_skill_metadata(content, file_path, script_location)
30
+ # Inject script location into description
31
+ hint = "Scripts located at: #{script_location}"
32
+
33
+ if content.match?(/\A---\s*\n.*?\n---\s*\n/m)
34
+ # Update existing frontmatter
35
+ content.sub!(/^description: (.*)$/, "description: \\1. #{hint}")
36
+ ensure_cursor_globs(content)
37
+ else
38
+ # Create new frontmatter with hint
39
+ create_skill_frontmatter(file_path, hint) + content
40
+ end
41
+ end
42
+
43
+ def create_skill_frontmatter(file_path, hint)
44
+ filename = File.basename(file_path)
45
+ <<~YAML
46
+ ---
47
+ description: Imported skill from #{filename}. #{hint}
48
+ globs: []
49
+ alwaysApply: false
50
+ ---
51
+ YAML
52
+ end
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
+
61
+ def save_script(source_path, skill_name, relative_sub_path)
62
+ # Construct target path in .cursor/scripts
63
+ # .cursor/scripts/caruso/<marketplace>/<plugin>/<skill_name>/<relative_sub_path>
64
+
65
+ scripts_root = File.join(target_dir, "..", "scripts", "caruso", marketplace_name, plugin_name, skill_name)
66
+ # NOTE: target_dir passed to adapter is usually .cursor/rules.
67
+ # So .. -> .cursor -> scripts
68
+
69
+ target_path = File.join(scripts_root, relative_sub_path)
70
+ output_dir = File.dirname(target_path)
71
+
72
+ FileUtils.mkdir_p(output_dir)
73
+ FileUtils.cp(source_path, target_path)
74
+
75
+ # Make executable
76
+ File.chmod(0o755, target_path)
77
+
78
+ puts "Saved script: #{target_path}"
79
+
80
+ # Return relative path for tracking/reporting
81
+ # We start from .cursor (parent of target_dir) ideally?
82
+ # Or just return the absolute path for now?
83
+ target_path
84
+ end
85
+ end
86
+ end
87
+ end
data/lib/caruso/cli.rb CHANGED
@@ -227,12 +227,21 @@ module Caruso
227
227
  )
228
228
  created_filenames = adapter.adapt
229
229
 
230
- # Convert filenames to relative paths from project root
231
- created_files = created_filenames.map { |f| File.join(config_manager.target_dir, f) }
230
+ # Convert filenames to project-relative paths.
231
+ # Paths already starting with .cursor/ (commands, hooks) are already project-relative.
232
+ # Others (rules from SkillAdapter/MarkdownAdapter) are relative to target_dir.
233
+ created_files = created_filenames.map do |f|
234
+ if f.start_with?(".cursor/") || File.absolute_path?(f)
235
+ f
236
+ else
237
+ File.join(config_manager.target_dir, f)
238
+ end
239
+ end
232
240
 
233
241
  # Use composite key for uniqueness
234
242
  plugin_key = "#{plugin_name}@#{marketplace_name}"
235
- config_manager.add_plugin(plugin_key, created_files, marketplace_name: marketplace_name)
243
+ config_manager.add_plugin(plugin_key, created_files, marketplace_name: marketplace_name,
244
+ hooks: adapter.installed_hooks)
236
245
  puts "Installed #{plugin_name}!"
237
246
  end
238
247
 
@@ -455,8 +464,14 @@ module Caruso
455
464
  )
456
465
  created_filenames = adapter.adapt
457
466
 
458
- # Convert filenames to relative paths from project root
459
- created_files = created_filenames.map { |f| File.join(config_manager.target_dir, f) }
467
+ # Convert filenames to project-relative paths (same logic as install)
468
+ created_files = created_filenames.map do |f|
469
+ if f.start_with?(".cursor/") || File.absolute_path?(f)
470
+ f
471
+ else
472
+ File.join(config_manager.target_dir, f)
473
+ end
474
+ end
460
475
 
461
476
  # Cleanup: Delete files that are no longer present
462
477
  old_files = config_manager.get_installed_files(plugin_key)
@@ -470,7 +485,8 @@ module Caruso
470
485
  end
471
486
 
472
487
  # Update plugin in config
473
- config_manager.add_plugin(plugin_key, created_files, marketplace_name: marketplace_name)
488
+ config_manager.add_plugin(plugin_key, created_files, marketplace_name: marketplace_name,
489
+ hooks: adapter.installed_hooks)
474
490
  end
475
491
 
476
492
  def load_config
@@ -54,11 +54,7 @@ module Caruso
54
54
  raise Error, "Caruso not initialized. Run 'caruso init --ide=cursor' first."
55
55
  end
56
56
 
57
- project_data = load_project_config
58
- local_data = load_local_config
59
-
60
- # Merge data for easier access, but keep them conceptually separate
61
- project_data.merge(local_data)
57
+ load_project_config.merge(load_local_config)
62
58
  end
63
59
 
64
60
  def config_exists?
@@ -77,9 +73,7 @@ module Caruso
77
73
  load["ide"]
78
74
  end
79
75
 
80
- # Plugin Management
81
-
82
- def add_plugin(name, files, marketplace_name:)
76
+ def add_plugin(name, files, marketplace_name:, hooks: {})
83
77
  # Update project config (Intent)
84
78
  project_data = load_project_config
85
79
  project_data["plugins"] ||= {}
@@ -88,23 +82,32 @@ module Caruso
88
82
  }
89
83
  save_project_config(project_data)
90
84
 
91
- # Update local config (Files)
85
+ # Update local config (Files and Hooks)
92
86
  local_data = load_local_config
93
87
  local_data["installed_files"] ||= {}
94
88
  local_data["installed_files"][name] = files
89
+
90
+ # Track installed hook commands for clean uninstall
91
+ local_data["installed_hooks"] ||= {}
92
+ if hooks.empty?
93
+ local_data["installed_hooks"].delete(name)
94
+ else
95
+ local_data["installed_hooks"][name] = hooks
96
+ end
97
+
95
98
  save_local_config(local_data)
96
99
  end
97
100
 
98
101
  def remove_plugin(name)
99
- # Get files to remove from local config
102
+ # Get files and hooks to remove from local config
100
103
  local_data = load_local_config
101
104
  files = local_data.dig("installed_files", name) || []
105
+ hooks = local_data.dig("installed_hooks", name) || {}
102
106
 
103
107
  # Remove from local config
104
- if local_data["installed_files"]
105
- local_data["installed_files"].delete(name)
106
- save_local_config(local_data)
107
- end
108
+ local_data["installed_files"]&.delete(name)
109
+ local_data["installed_hooks"]&.delete(name)
110
+ save_local_config(local_data)
108
111
 
109
112
  # Remove from project config
110
113
  project_data = load_project_config
@@ -113,7 +116,7 @@ module Caruso
113
116
  save_project_config(project_data)
114
117
  end
115
118
 
116
- files
119
+ { files: files, hooks: hooks }
117
120
  end
118
121
 
119
122
  def list_plugins
@@ -121,15 +124,16 @@ module Caruso
121
124
  end
122
125
 
123
126
  def plugin_installed?(name)
124
- plugins = list_plugins
125
- plugins.key?(name)
127
+ list_plugins.key?(name)
126
128
  end
127
129
 
128
130
  def get_installed_files(name)
129
131
  load_local_config.dig("installed_files", name) || []
130
132
  end
131
133
 
132
- # Marketplace Management
134
+ def get_installed_hooks(name)
135
+ load_local_config.dig("installed_hooks", name) || {}
136
+ end
133
137
 
134
138
  def add_marketplace(name, url, source: "git", ref: nil)
135
139
  data = load_project_config
@@ -151,20 +155,23 @@ module Caruso
151
155
  end
152
156
 
153
157
  def remove_marketplace_with_plugins(marketplace_name)
154
- files_to_remove = []
158
+ result = { files: [], hooks: {} }
155
159
 
156
160
  # Find and remove all plugins associated with this marketplace
157
161
  installed_plugins = list_plugins
158
162
  installed_plugins.each do |plugin_key, details|
159
- if details["marketplace"] == marketplace_name
160
- files_to_remove.concat(remove_plugin(plugin_key))
161
- end
163
+ next unless details["marketplace"] == marketplace_name
164
+
165
+ plugin_result = remove_plugin(plugin_key)
166
+ result[:files].concat(plugin_result[:files])
167
+ result[:hooks].merge!(plugin_result[:hooks])
162
168
  end
163
169
 
164
170
  # Remove the marketplace itself
165
171
  remove_marketplace(marketplace_name)
166
172
 
167
- files_to_remove.uniq
173
+ result[:files] = result[:files].uniq
174
+ result
168
175
  end
169
176
 
170
177
  def list_marketplaces
@@ -175,11 +182,6 @@ module Caruso
175
182
  load_project_config.dig("marketplaces", name)
176
183
  end
177
184
 
178
- def get_marketplace_url(name)
179
- details = get_marketplace_details(name)
180
- details ? details["url"] : nil
181
- end
182
-
183
185
  private
184
186
 
185
187
  def load_project_config