caruso 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e38c7a5007af96bb708d286a62909f07f1c1aa3b3aa800baa4a23f36c1a35814
4
- data.tar.gz: 18a217953c0ec7c87bc6d9476c89bbca442fde6b45aec4614e4f58c183a3a760
3
+ metadata.gz: 9f6520caf8c5e8a4946369e9722ca36685a6c79b933b67336bc02b39b0fca359
4
+ data.tar.gz: d1b2f7b52ae5746909e173b75fc31e601417bca1ef55af889a56f802a8ae1d24
5
5
  SHA512:
6
- metadata.gz: 427385d4dca6b37150a1bcf487884db8979883d1e7e3d6caa353217b55609ad43dd97dfd1c85860379a6807c62eb37646eabe491540a54a683ec347e17edbc99
7
- data.tar.gz: f04abed100aef554d47572af9507c4d7701782b6321e5f48cfdee677f5cbe5ecd82dffab4e719b2328231ea8bee1a1a041c476ee3322fbdc8653c23333d81ed5
6
+ metadata.gz: 48d8c7c701c7c1a380672ac77f3025dc69c90c4a5ef7d6fd6e72e50358e69440a9c57ff022f52f94c4970faead782ba04c223246bf766045e4508bca427cf4f8
7
+ data.tar.gz: 293893a825850e919ca8141203f307c300e9fdba931f80aafadad15a766dd5f28ff6bd061eae53891c5a4da41f061e2b4b6bea5f0108d9b79396e63d82f49296
data/impl.md ADDED
@@ -0,0 +1,111 @@
1
+ # Implementation Plan - Evolve Caruso for Claude Code Support
2
+
3
+ Refining caruso to provide robust support for Claude Code's Commands, Skills, and Hooks, adapting them specifically for the Cursor environment.
4
+
5
+ ## Goal Description
6
+
7
+ The goal is to transform caruso from a simple markdown copier into a smart adapter that bridges the gap between Claude Code's plugin architecture and Cursor's steering mechanisms. This involves:
8
+
9
+ - **Skills**: deeply adapting skills by not just copying the markdown, but also bundling associated scripts/ and making them executable.
10
+ - **Commands**: mapping Claude Code commands to Cursor's new .cursor/commands/ structure.
11
+ - **Hooks**: translating Claude Code's hooks/hooks.json events into Cursor's .cursor/hooks.json format.
12
+ - **Fetcher**: upgrading the fetcher to retrieve auxiliary files (scripts, configs) beyond just `.md` files.
13
+
14
+ ## User Review Required
15
+
16
+ > [!NOTE]
17
+ > **Script Execution Security:** We will trust the "trusted workspace" model of Cursor. This means we will automatically fetch scripts and make them executable (`chmod +x`). Users relying on Caruso are expected to audit the plugins they install, similar to how they would audit an npm package.
18
+
19
+ ## Proposed Changes
20
+
21
+ ### 1. Fetcher Upgrades (`lib/caruso/fetcher.rb`)
22
+
23
+ The current fetcher is overly focused on *.md files. We need to broaden it to fetch associated resources using an Additive Strategy.
24
+
25
+ #### [MODIFY] `fetcher.rb`
26
+
27
+ - **Additive Discovery**: In `fetch_plugin`:
28
+ - **Logic**:
29
+ 1. **Check Manifest**: Look for `skills` field in `plugin` object (string or array).
30
+ 2. **If Present**: Recursively fetch all files in those specific paths.
31
+ 3. **If Absent**: Fallback to scanning the default `skills/` directory (recursively).
32
+ 4. **Other Components**: Continue scanning for `commands/`, `agents/`, `hooks/` as before.
33
+
34
+ - **Support Resource Types**:
35
+ - `skills`: Fetch `SKILL.md` AND recursively fetch `scripts/` directories if found within the skill path.
36
+
37
+ - `hooks`: Fetch the `hooks/hooks.json` file (or inline config).
38
+ - `commands`: Fetch markdown files.
39
+ - `agents`: Fetch markdown files.
40
+
41
+ ### 2. Adapter Architecture Refactor (`lib/caruso/`)
42
+
43
+ Refactor the monolithic Adapter class into a dispatcher with specialized strategies.
44
+
45
+ #### [MODIFY] `adapter.rb`
46
+ Change `Adapter#adapt` to identify the component type and delegate to the appropriate sub-adapter.
47
+
48
+ #### [NEW] `adapters/base.rb`
49
+ Shared logic for file writing, frontmatter injection, and path sanitization.
50
+
51
+ #### [NEW] `adapters/skill_adapter.rb`
52
+ **Input:** `skills/<name>/SKILL.md` + `skills/<name>/scripts/*`
53
+ **Output:**
54
+ - `.cursor/rules/caruso/<marketplace>/<skill>/<skill>.mdc` (The rule)
55
+ - `.cursor/scripts/caruso/<marketplace>/<skill>/*` (The scripts)
56
+
57
+ **Logic:**
58
+ 1. Copy scripts to `.cursor/scripts/caruso/<marketplace>/<skill>/`.
59
+ 2. Ensure scripts are executable (`chmod +x`).
60
+ 3. **Paths:** Do NOT rewrite paths in the markdown (to avoid messiness).
61
+ 4. **Context:** Inject a location hint into the Rule's frontmatter `description` or prepended content:
62
+ ```yaml
63
+ description: Imported from <skill>. Scripts located at: .cursor/scripts/caruso/<marketplace>/<skill>/
64
+ ```
65
+
66
+ #### [NEW] `adapters/agent_adapter.rb`
67
+ **Input:** `agents/<name>.md`
68
+ **Output:** `.cursor/rules/caruso/<marketplace>/agents/<name>.mdc`
69
+ **Logic:**
70
+ - Copy content.
71
+ - Wrap as a "Persona" rule if needed, or simple markdown rule.
72
+
73
+ #### [NEW] `adapters/command_adapter.rb`
74
+ **Input:** `commands/<name>.md`
75
+ **Output:** `.cursor/commands/<name>.md`
76
+ **Logic:**
77
+ - Basic markdown copy.
78
+ - Frontmatter cleanup (remove Claude-specific fields if necessary).
79
+
80
+ #### [NEW] `adapters/hook_adapter.rb`
81
+ **Input:** `hooks/hooks.json`
82
+ **Output:** Merged/Updated `.cursor/hooks.json`
83
+ **Logic:**
84
+ - Parse source JSON.
85
+ - Map events (e.g., `PostToolUse` -> `afterFileEdit`).
86
+ - Write/Merge into project's `hooks.json`.
87
+
88
+ ### 3. CLI Updates (`lib/caruso/cli.rb`)
89
+
90
+ #### [MODIFY] `cli.rb`
91
+ - Update `install` command to handle multiple file types returned by the upgraded fetcher.
92
+ - Ensure `uninstall` cleans up the directories (scripts, etc) correctly.
93
+
94
+ ## Verification Plan
95
+
96
+ ### Automated Tests
97
+ - **Fetcher Tests**: Mock a plugin structure with `scripts/` and verify they are returned in the file list.
98
+ - **Adapter Tests**:
99
+ - Feed a `SKILL.md` + script to `SkillAdapter` and verify output structure and chmod.
100
+ - Feed a `hooks.json` and verify the event translation.
101
+
102
+ ### Manual Verification
103
+ - **Skills**: Install a plugin with a script (e.g., a "linter" skill).
104
+ - Verify file exists at `.cursor/scripts/.../lint.sh`.
105
+ - Verify it is executable.
106
+ - Verify the Rule markdown is present.
107
+ - **Commands**: Install a command plugin.
108
+ - Verify file exists at `.cursor/commands/...`.
109
+ - Test running the command in Cursor (Cmd+K `/command`).
110
+ - **Hooks**: Install a hook plugin.
111
+ - Check `.cursor/hooks.json` contains the mapped event.
@@ -1,113 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
- require "yaml"
5
- require_relative "safe_file"
6
- require_relative "path_sanitizer"
3
+ require_relative "adapters/dispatcher"
7
4
 
8
5
  module Caruso
9
6
  class Adapter
10
- attr_reader :files, :target_dir, :agent, :marketplace_name, :plugin_name
11
-
7
+ # Preserving the interface for CLI compatibility
12
8
  def initialize(files, target_dir:, marketplace_name:, plugin_name:, agent: :cursor)
13
9
  @files = files
14
10
  @target_dir = target_dir
15
- @agent = agent
16
11
  @marketplace_name = marketplace_name
17
12
  @plugin_name = plugin_name
18
- FileUtils.mkdir_p(@target_dir)
13
+ @agent = agent
19
14
  end
20
15
 
21
16
  def adapt
22
- created_files = []
23
- files.each do |file_path|
24
- content = SafeFile.read(file_path)
25
-
26
- adapted_content = inject_metadata(content, file_path)
27
- created_file = save_file(file_path, adapted_content)
28
- created_files << created_file
29
- end
30
- created_files
31
- end
32
-
33
- private
34
-
35
- def inject_metadata(content, file_path)
36
- # Check if frontmatter exists
37
- if content.match?(/\A---\s*\n.*?\n---\s*\n/m)
38
- # If it exists, we might need to append to it or modify it
39
- # For now, we assume existing frontmatter is "good enough" but might need 'globs' for Cursor
40
- ensure_cursor_globs(content) if agent == :cursor
41
- else
42
- # No frontmatter, prepend it
43
- create_frontmatter(file_path) + content
44
- end
45
- end
46
-
47
- def ensure_cursor_globs(content)
48
- # Add required Cursor metadata fields if missing
49
- # globs: [] enables semantic search (Apply Intelligently)
50
- # alwaysApply: false means it won't apply to every chat session
51
-
52
- unless content.include?("globs:")
53
- content.sub!(/\A---\s*\n/, "---\nglobs: []\n")
54
- end
55
-
56
- unless content.include?("alwaysApply:")
57
- # Add after the first line of frontmatter
58
- content.sub!(/\A---\s*\n/, "---\nalwaysApply: false\n")
59
- end
60
-
61
- content
62
- end
63
-
64
- def create_frontmatter(file_path)
65
- filename = File.basename(file_path)
66
- <<~YAML
67
- ---
68
- description: Imported rule from #{filename}
69
- globs: []
70
- alwaysApply: false
71
- ---
72
- YAML
73
- end
74
-
75
- def save_file(original_path, content)
76
- filename = File.basename(original_path, ".*")
77
-
78
- # Rename SKILL.md to the skill name (parent directory) to avoid collisions
79
- if filename.casecmp("skill").zero?
80
- filename = File.basename(File.dirname(original_path))
81
- end
82
-
83
- extension = agent == :cursor ? ".mdc" : ".md"
84
- output_filename = "#{filename}#{extension}"
85
-
86
- # Extract component type from original path (commands/agents/skills)
87
- component_type = extract_component_type(original_path)
88
-
89
- # Build nested directory structure for Cursor
90
- # Build nested directory structure for Cursor
91
- # Structure: .cursor/rules/caruso/marketplace/plugin/component-type/file.mdc
92
- subdirs = File.join("caruso", marketplace_name, plugin_name, component_type)
93
- output_dir = File.join(@target_dir, subdirs)
94
- FileUtils.mkdir_p(output_dir)
95
- target_path = File.join(output_dir, output_filename)
96
-
97
- File.write(target_path, content)
98
- puts "Saved: #{target_path}"
99
-
100
- # Return relative path from target_dir
101
- File.join(subdirs, output_filename)
102
- end
103
-
104
- def extract_component_type(file_path)
105
- # Extract component type (commands/agents/skills) from path
106
- return "commands" if file_path.include?("/commands/")
107
- return "agents" if file_path.include?("/agents/")
108
- return "skills" if file_path.include?("/skills/")
109
-
110
- raise Caruso::Error, "Cannot determine component type from path: #{file_path}"
17
+ Caruso::Adapters::Dispatcher.adapt(
18
+ @files,
19
+ target_dir: @target_dir,
20
+ marketplace_name: @marketplace_name,
21
+ plugin_name: @plugin_name,
22
+ agent: @agent
23
+ )
111
24
  end
112
25
  end
113
26
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+ require_relative "../safe_file"
6
+ require_relative "../path_sanitizer"
7
+
8
+ module Caruso
9
+ module Adapters
10
+ class Base
11
+ attr_reader :files, :target_dir, :agent, :marketplace_name, :plugin_name
12
+
13
+ def initialize(files, target_dir:, marketplace_name:, plugin_name:, agent: :cursor)
14
+ @files = files
15
+ @target_dir = target_dir
16
+ @agent = agent
17
+ @marketplace_name = marketplace_name
18
+ @plugin_name = plugin_name
19
+ FileUtils.mkdir_p(@target_dir)
20
+ end
21
+
22
+ def adapt
23
+ raise NotImplementedError, "#{self.class.name}#adapt must be implemented"
24
+ end
25
+
26
+ protected
27
+
28
+ def save_file(relative_path, content, extension: nil)
29
+ filename = File.basename(relative_path, ".*")
30
+
31
+ # Preserve original extension if none provided
32
+ ext = extension || File.extname(relative_path)
33
+
34
+ # Rename SKILL.md to the skill name (parent directory) to avoid collisions
35
+ # This is specific to Skills, might move to SkillAdapter later, but keeping behavior for now
36
+ if filename.casecmp("skill").zero?
37
+ filename = File.basename(File.dirname(relative_path))
38
+ end
39
+
40
+ output_filename = "#{filename}#{ext}"
41
+
42
+ # Build nested directory structure: .cursor/rules/caruso/marketplace/plugin/component/file
43
+ # Component type is derived from the class name or passed in?
44
+ # For base, we might need a way to determine output path more flexibly.
45
+ # But sticking to current behavior:
46
+
47
+ component_type = extract_component_type(relative_path)
48
+ subdirs = File.join("caruso", marketplace_name, plugin_name, component_type)
49
+ output_dir = File.join(target_dir, subdirs)
50
+
51
+ FileUtils.mkdir_p(output_dir)
52
+ target_path = File.join(output_dir, output_filename)
53
+
54
+ File.write(target_path, content)
55
+ puts "Saved: #{target_path}"
56
+
57
+ File.join(subdirs, output_filename)
58
+ end
59
+
60
+ def extract_component_type(file_path)
61
+ # Extract component type (commands/agents/skills) from path
62
+ return "commands" if file_path.include?("/commands/")
63
+ return "agents" if file_path.include?("/agents/")
64
+ return "skills" if file_path.include?("/skills/")
65
+
66
+ # Fallback or specific handling for other types
67
+ "misc"
68
+ end
69
+
70
+ def inject_metadata(content, file_path)
71
+ if content.match?(/\A---\s*\n.*?\n---\s*\n/m)
72
+ ensure_cursor_globs(content) if agent == :cursor
73
+ else
74
+ create_frontmatter(file_path) + content
75
+ end
76
+ end
77
+
78
+ def ensure_cursor_globs(content)
79
+ unless content.include?("globs:")
80
+ content.sub!(/\A---\s*\n/, "---\nglobs: []\n")
81
+ end
82
+
83
+ unless content.include?("alwaysApply:")
84
+ content.sub!(/\A---\s*\n/, "---\nalwaysApply: false\n")
85
+ end
86
+
87
+ content
88
+ end
89
+
90
+ def create_frontmatter(file_path)
91
+ filename = File.basename(file_path)
92
+ <<~YAML
93
+ ---
94
+ description: Imported rule from #{filename}
95
+ globs: []
96
+ alwaysApply: false
97
+ ---
98
+ YAML
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "markdown_adapter"
4
+ require_relative "skill_adapter"
5
+
6
+ module Caruso
7
+ module Adapters
8
+ class Dispatcher
9
+ def self.adapt(files, target_dir:, marketplace_name:, plugin_name:, agent: :cursor)
10
+ created_files = []
11
+ remaining_files = files.dup
12
+
13
+ # 1. Identify and Process Skill Clusters
14
+ # Find all SKILL.md files to serve as anchors
15
+ skill_anchors = remaining_files.select { |f| File.basename(f).casecmp("skill.md").zero? }
16
+
17
+ skill_anchors.each do |anchor|
18
+ skill_dir = File.dirname(anchor)
19
+
20
+ # Find all files that belong to this skill's directory (recursive)
21
+ skill_cluster = remaining_files.select { |f| f.start_with?(skill_dir) }
22
+
23
+ # Use SkillAdapter for this cluster
24
+ adapter = SkillAdapter.new(
25
+ skill_cluster,
26
+ target_dir: target_dir,
27
+ marketplace_name: marketplace_name,
28
+ plugin_name: plugin_name,
29
+ agent: agent
30
+ )
31
+ created_files.concat(adapter.adapt)
32
+
33
+ # Remove processed files
34
+ remaining_files -= skill_cluster
35
+ end
36
+
37
+ # 2. Process Remaining Files (Commands, Agents, etc.) via MarkdownAdapter
38
+ if remaining_files.any?
39
+ adapter = MarkdownAdapter.new(
40
+ remaining_files,
41
+ target_dir: target_dir,
42
+ marketplace_name: marketplace_name,
43
+ plugin_name: plugin_name,
44
+ agent: agent
45
+ )
46
+ created_files.concat(adapter.adapt)
47
+ end
48
+
49
+ created_files
50
+ end
51
+ end
52
+ end
53
+ 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,100 @@
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
+ created_files = []
10
+
11
+ # Separate SKILL.md from other files (scripts, etc.)
12
+ skill_file = files.find { |f| File.basename(f).casecmp("skill.md").zero? }
13
+ other_files = files - [skill_file]
14
+
15
+ if skill_file
16
+ skill_root = File.dirname(skill_file)
17
+ skill_name = File.basename(skill_root)
18
+
19
+ # Inject script location hint
20
+ # Targeted script location: .cursor/scripts/caruso/<market>/<plugin>/<skill_name>/
21
+ script_location = ".cursor/scripts/caruso/#{marketplace_name}/#{plugin_name}/#{skill_name}/"
22
+
23
+ content = SafeFile.read(skill_file)
24
+ adapted_content = inject_skill_metadata(content, skill_file, script_location)
25
+
26
+ # Save as Rule (.mdc)
27
+ extension = agent == :cursor ? ".mdc" : ".md"
28
+ created_file = save_file(skill_file, adapted_content, extension: extension)
29
+ created_files << created_file
30
+
31
+ # Process Scripts/Assets -> .cursor/scripts/...
32
+ other_files.each do |file_path|
33
+ # Calculate path relative to the skill root
34
+ # e.g. source: .../skills/auth/scripts/login.sh
35
+ # root: .../skills/auth
36
+ # rel: scripts/login.sh
37
+ relative_path_from_root = file_path.sub(skill_root + "/", "")
38
+
39
+ created_script = save_script(file_path, skill_name, relative_path_from_root)
40
+ created_files << created_script
41
+ end
42
+ end
43
+
44
+ created_files
45
+ end
46
+
47
+ private
48
+
49
+ def inject_skill_metadata(content, file_path, script_location)
50
+ # Inject script location into description
51
+ hint = "Scripts located at: #{script_location}"
52
+
53
+ if content.match?(/\A---\s*\n.*?\n---\s*\n/m)
54
+ # Update existing frontmatter
55
+ content.sub!(/^description: (.*)$/, "description: \\1. #{hint}")
56
+ ensure_cursor_globs(content)
57
+ else
58
+ # Create new frontmatter with hint
59
+ create_skill_frontmatter(file_path, hint) + content
60
+ end
61
+ end
62
+
63
+ def create_skill_frontmatter(file_path, hint)
64
+ filename = File.basename(file_path)
65
+ <<~YAML
66
+ ---
67
+ description: Imported skill from #{filename}. #{hint}
68
+ globs: []
69
+ alwaysApply: false
70
+ ---
71
+ YAML
72
+ end
73
+
74
+ def save_script(source_path, skill_name, relative_sub_path)
75
+ # Construct target path in .cursor/scripts
76
+ # .cursor/scripts/caruso/<marketplace>/<plugin>/<skill_name>/<relative_sub_path>
77
+
78
+ scripts_root = File.join(target_dir, "..", "scripts", "caruso", marketplace_name, plugin_name, skill_name)
79
+ # Note: target_dir passed to adapter is usually .cursor/rules.
80
+ # So .. -> .cursor -> scripts
81
+
82
+ target_path = File.join(scripts_root, relative_sub_path)
83
+ output_dir = File.dirname(target_path)
84
+
85
+ FileUtils.mkdir_p(output_dir)
86
+ FileUtils.cp(source_path, target_path)
87
+
88
+ # Make executable
89
+ File.chmod(0755, target_path)
90
+
91
+ puts "Saved script: #{target_path}"
92
+
93
+ # Return relative path for tracking/reporting
94
+ # We start from .cursor (parent of target_dir) ideally?
95
+ # Or just return the absolute path for now?
96
+ target_path
97
+ end
98
+ end
99
+ end
100
+ end
@@ -181,12 +181,7 @@ module Caruso
181
181
  return [] unless SafeDir.exist?(plugin_path)
182
182
 
183
183
  # Start with default directories
184
- files = find_steering_files(plugin_path)
185
-
186
- # Add custom paths if specified (they supplement defaults)
187
- files += find_custom_component_files(plugin_path, plugin["commands"]) if plugin["commands"]
188
- files += find_custom_component_files(plugin_path, plugin["agents"]) if plugin["agents"]
189
- files += find_custom_component_files(plugin_path, plugin["skills"]) if plugin["skills"]
184
+ files = find_steering_files(plugin_path, plugin)
190
185
 
191
186
  files.uniq
192
187
  end
@@ -210,18 +205,44 @@ module Caruso
210
205
  end
211
206
  end
212
207
 
213
- def find_steering_files(plugin_path)
214
- # Validate plugin_path before using it in glob
215
- # This is safe because plugin_path comes from resolve_plugin_path which returns trusted paths
216
- # (either from cache_dir which is under ~/.caruso, or validated local paths)
217
- glob_pattern = PathSanitizer.safe_join(plugin_path, "{commands,agents,skills}", "**", "*.md")
208
+ # Find all steering files based on default locations and manifest overrides
209
+ # RETURNS: unique list of absolute file paths to fetch
210
+ def find_steering_files(plugin_path, plugin_data = nil)
211
+ files = []
218
212
 
219
- SafeDir.glob(glob_pattern, base_dir: plugin_path).reject do |file|
213
+ # 1. ALWAYS scan default directories (Additive Strategy)
214
+ files += glob_plugin_files(plugin_path, "commands", "**", "*.md")
215
+ files += glob_plugin_files(plugin_path, "agents", "**", "*.md")
216
+
217
+ # For skills, we want recursive default scan if 'skills/' exists
218
+ # But careful: if we scan default 'skills' recursively here, and then scan strict paths from manifest...
219
+ # Duplicate handling is fine via uniq.
220
+ default_skills_path = File.join(plugin_path, "skills")
221
+ if SafeDir.exist?(default_skills_path)
222
+ files += find_recursive_component_files(plugin_path, "skills")
223
+ end
224
+
225
+ # 2. Add manifest-defined paths (if present)
226
+ if plugin_data
227
+ files += find_custom_component_files(plugin_path, plugin_data["commands"]) if plugin_data["commands"]
228
+ files += find_custom_component_files(plugin_path, plugin_data["agents"]) if plugin_data["agents"]
229
+ files += find_recursive_component_files(plugin_path, plugin_data["skills"]) if plugin_data["skills"]
230
+ end
231
+
232
+ # Filter out noise
233
+ files.uniq.reject do |file|
220
234
  basename = File.basename(file).downcase
221
- ["readme.md", "license.md"].include?(basename)
235
+ ["readme.md", "license.md", "plugin.json"].include?(basename) || File.directory?(file)
222
236
  end
223
237
  end
224
238
 
239
+ # Helper to glob files safely
240
+ def glob_plugin_files(plugin_path, *parts)
241
+ pattern = PathSanitizer.safe_join(plugin_path, *parts)
242
+ SafeDir.glob(pattern, base_dir: plugin_path)
243
+ end
244
+
245
+ # For Commands/Agents: typically just markdown files, flat or shallow
225
246
  def find_custom_component_files(plugin_path, paths)
226
247
  # Handle both string and array formats
227
248
  paths = [paths] if paths.is_a?(String)
@@ -229,30 +250,49 @@ module Caruso
229
250
 
230
251
  files = []
231
252
  paths.each do |path|
232
- # Resolve and sanitize the path relative to plugin_path
233
- # This ensures the path stays within plugin_path boundaries
234
- begin
235
- full_path = PathSanitizer.sanitize_path(File.expand_path(path, plugin_path), base_dir: plugin_path)
236
- rescue PathSanitizer::PathTraversalError => e
237
- warn "Skipping path outside plugin directory '#{path}': #{e.message}"
238
- next
239
- end
253
+ full_path = resolve_safe_path(plugin_path, path)
254
+ next unless full_path
240
255
 
241
- # Handle both files and directories
242
256
  if File.file?(full_path) && full_path.end_with?(".md")
243
- basename = File.basename(full_path).downcase
244
- files << full_path unless ["readme.md", "license.md"].include?(basename)
257
+ files << full_path
245
258
  elsif SafeDir.exist?(full_path, base_dir: plugin_path)
246
- # Find all .md files in this directory using safe_join
259
+ # Find all .md files in this directory
247
260
  glob_pattern = PathSanitizer.safe_join(full_path, "**", "*.md")
248
- SafeDir.glob(glob_pattern, base_dir: plugin_path).each do |file|
249
- basename = File.basename(file).downcase
250
- files << file unless ["readme.md", "license.md"].include?(basename)
251
- end
261
+ files += SafeDir.glob(glob_pattern, base_dir: plugin_path)
252
262
  end
253
263
  end
254
264
  files
255
265
  end
266
+
267
+ # For SKILLS: Recursive fetch of EVERYTHING (scripts, assets, md)
268
+ def find_recursive_component_files(plugin_path, paths)
269
+ paths = [paths] if paths.is_a?(String)
270
+ return [] unless paths.is_a?(Array)
271
+
272
+ files = []
273
+ paths.each do |path|
274
+ full_path = resolve_safe_path(plugin_path, path)
275
+ next unless full_path
276
+
277
+ if File.file?(full_path)
278
+ files << full_path
279
+ elsif SafeDir.exist?(full_path, base_dir: plugin_path)
280
+ # Grab EVERYTHING recursively
281
+ glob_pattern = PathSanitizer.safe_join(full_path, "**", "*")
282
+ files += SafeDir.glob(glob_pattern, base_dir: plugin_path)
283
+ end
284
+ end
285
+ files
286
+ end
287
+
288
+ def resolve_safe_path(plugin_path, relative_path)
289
+ begin
290
+ PathSanitizer.sanitize_path(File.expand_path(relative_path, plugin_path), base_dir: plugin_path)
291
+ rescue PathSanitizer::PathTraversalError => e
292
+ warn "Skipping path outside plugin directory '#{relative_path}': #{e.message}"
293
+ nil
294
+ end
295
+ end
256
296
 
257
297
  def local_path?
258
298
  !@marketplace_uri.match?(/\Ahttps?:/)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Caruso
4
- VERSION = "0.6.2"
4
+ VERSION = "0.6.3"
5
5
  end