caruso 0.5.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +6 -1
- data/AGENTS.md +276 -0
- data/CHANGELOG.md +13 -0
- data/CLAUDE.md +1 -0
- data/README.md +13 -10
- data/SECURITY.md +98 -0
- data/caruso.gemspec +1 -1
- data/lib/caruso/adapter.rb +4 -1
- data/lib/caruso/cli.rb +32 -35
- data/lib/caruso/config_manager.rb +2 -0
- data/lib/caruso/fetcher.rb +51 -14
- data/lib/caruso/marketplace_registry.rb +3 -3
- data/lib/caruso/path_sanitizer.rb +59 -0
- data/lib/caruso/safe_dir.rb +54 -0
- data/lib/caruso/safe_file.rb +39 -0
- data/lib/caruso/version.rb +1 -1
- data/lib/caruso.rb +2 -0
- metadata +7 -2
- data/CLAUDE.md +0 -276
|
@@ -167,6 +167,7 @@ module Caruso
|
|
|
167
167
|
|
|
168
168
|
def load_project_config
|
|
169
169
|
return {} unless File.exist?(@project_config_path)
|
|
170
|
+
|
|
170
171
|
JSON.parse(File.read(@project_config_path))
|
|
171
172
|
rescue JSON::ParserError
|
|
172
173
|
{}
|
|
@@ -178,6 +179,7 @@ module Caruso
|
|
|
178
179
|
|
|
179
180
|
def load_local_config
|
|
180
181
|
return {} unless File.exist?(@local_config_path)
|
|
182
|
+
|
|
181
183
|
JSON.parse(File.read(@local_config_path))
|
|
182
184
|
rescue JSON::ParserError
|
|
183
185
|
{}
|
data/lib/caruso/fetcher.rb
CHANGED
|
@@ -5,6 +5,9 @@ require "faraday"
|
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require "uri"
|
|
7
7
|
require "git"
|
|
8
|
+
require_relative "safe_file"
|
|
9
|
+
require_relative "safe_dir"
|
|
10
|
+
require_relative "path_sanitizer"
|
|
8
11
|
|
|
9
12
|
module Caruso
|
|
10
13
|
class Fetcher
|
|
@@ -49,7 +52,7 @@ module Caruso
|
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
def update_cache
|
|
52
|
-
return unless
|
|
55
|
+
return unless SafeDir.exist?(cache_dir)
|
|
53
56
|
|
|
54
57
|
Dir.chdir(cache_dir) do
|
|
55
58
|
git = Git.open(".")
|
|
@@ -61,7 +64,7 @@ module Caruso
|
|
|
61
64
|
end
|
|
62
65
|
|
|
63
66
|
@registry.update_timestamp(@marketplace_name)
|
|
64
|
-
rescue => e
|
|
67
|
+
rescue StandardError => e
|
|
65
68
|
handle_git_error(e)
|
|
66
69
|
end
|
|
67
70
|
|
|
@@ -69,10 +72,10 @@ module Caruso
|
|
|
69
72
|
url = source_config["url"] || source_config["repo"]
|
|
70
73
|
url = "https://github.com/#{url}.git" if source_config["source"] == "github" && !url.match?(/\Ahttps?:/)
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
URI.parse(url).path.split("/").last.sub(".git", "")
|
|
73
76
|
target_path = cache_dir
|
|
74
77
|
|
|
75
|
-
unless
|
|
78
|
+
unless SafeDir.exist?(target_path)
|
|
76
79
|
# Clone the repository
|
|
77
80
|
FileUtils.mkdir_p(File.dirname(target_path))
|
|
78
81
|
Git.clone(url, target_path)
|
|
@@ -105,7 +108,7 @@ module Caruso
|
|
|
105
108
|
def load_marketplace
|
|
106
109
|
if local_path?
|
|
107
110
|
# If marketplace_uri is a directory, find marketplace.json in it
|
|
108
|
-
if
|
|
111
|
+
if SafeDir.exist?(@marketplace_uri)
|
|
109
112
|
json_path = File.join(@marketplace_uri, ".claude-plugin", "marketplace.json")
|
|
110
113
|
json_path = File.join(@marketplace_uri, "marketplace.json") unless File.exist?(json_path)
|
|
111
114
|
|
|
@@ -122,7 +125,7 @@ module Caruso
|
|
|
122
125
|
@base_dir = File.dirname(@base_dir)
|
|
123
126
|
end
|
|
124
127
|
|
|
125
|
-
JSON.parse(
|
|
128
|
+
JSON.parse(SafeFile.read(@marketplace_uri))
|
|
126
129
|
elsif github_repo?
|
|
127
130
|
# Clone repo and read marketplace.json from it
|
|
128
131
|
repo_path = clone_git_repo("url" => @marketplace_uri, "source" => "github")
|
|
@@ -138,7 +141,7 @@ module Caruso
|
|
|
138
141
|
@marketplace_uri = json_path
|
|
139
142
|
@base_dir = repo_path # Base dir is the repo root, regardless of where json is
|
|
140
143
|
|
|
141
|
-
JSON.parse(
|
|
144
|
+
JSON.parse(SafeFile.read(json_path))
|
|
142
145
|
else
|
|
143
146
|
response = Faraday.get(@marketplace_uri)
|
|
144
147
|
JSON.parse(response.body)
|
|
@@ -146,6 +149,10 @@ module Caruso
|
|
|
146
149
|
end
|
|
147
150
|
|
|
148
151
|
def github_repo?
|
|
152
|
+
# Fixed ReDoS: Add length check to prevent catastrophic backtracking
|
|
153
|
+
# GitHub URLs and owner/repo format should be reasonably short
|
|
154
|
+
return false if @marketplace_uri.length > 512
|
|
155
|
+
|
|
149
156
|
@marketplace_uri.match?(%r{\Ahttps://github\.com/[^/]+/[^/]+}) ||
|
|
150
157
|
@marketplace_uri.match?(%r{\A[^/]+/[^/]+\z}) # owner/repo format
|
|
151
158
|
end
|
|
@@ -153,7 +160,25 @@ module Caruso
|
|
|
153
160
|
def fetch_plugin(plugin)
|
|
154
161
|
source = plugin["source"]
|
|
155
162
|
plugin_path = resolve_plugin_path(source)
|
|
156
|
-
return [] unless plugin_path
|
|
163
|
+
return [] unless plugin_path
|
|
164
|
+
|
|
165
|
+
# Validate that plugin_path is safe before using it
|
|
166
|
+
# resolve_plugin_path returns paths from trusted sources:
|
|
167
|
+
# - cache_dir (under ~/.caruso/marketplaces/)
|
|
168
|
+
# - validated local paths relative to @base_dir
|
|
169
|
+
# However, we still validate to ensure no path traversal
|
|
170
|
+
begin
|
|
171
|
+
# For paths under ~/.caruso, validate against home directory
|
|
172
|
+
# For local paths, they're already validated in resolve_plugin_path
|
|
173
|
+
if plugin_path.start_with?(File.join(Dir.home, ".caruso"))
|
|
174
|
+
PathSanitizer.sanitize_path(plugin_path, base_dir: File.join(Dir.home, ".caruso"))
|
|
175
|
+
end
|
|
176
|
+
rescue PathSanitizer::PathTraversalError => e
|
|
177
|
+
warn "Invalid plugin path '#{plugin_path}': #{e.message}"
|
|
178
|
+
return []
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
return [] unless SafeDir.exist?(plugin_path)
|
|
157
182
|
|
|
158
183
|
# Start with default directories
|
|
159
184
|
files = find_steering_files(plugin_path)
|
|
@@ -186,7 +211,12 @@ module Caruso
|
|
|
186
211
|
end
|
|
187
212
|
|
|
188
213
|
def find_steering_files(plugin_path)
|
|
189
|
-
|
|
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")
|
|
218
|
+
|
|
219
|
+
SafeDir.glob(glob_pattern, base_dir: plugin_path).reject do |file|
|
|
190
220
|
basename = File.basename(file).downcase
|
|
191
221
|
["readme.md", "license.md"].include?(basename)
|
|
192
222
|
end
|
|
@@ -199,16 +229,23 @@ module Caruso
|
|
|
199
229
|
|
|
200
230
|
files = []
|
|
201
231
|
paths.each do |path|
|
|
202
|
-
# Resolve the path relative to plugin_path
|
|
203
|
-
|
|
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
|
|
204
240
|
|
|
205
241
|
# Handle both files and directories
|
|
206
242
|
if File.file?(full_path) && full_path.end_with?(".md")
|
|
207
243
|
basename = File.basename(full_path).downcase
|
|
208
244
|
files << full_path unless ["readme.md", "license.md"].include?(basename)
|
|
209
|
-
elsif
|
|
210
|
-
# Find all .md files in this directory
|
|
211
|
-
|
|
245
|
+
elsif SafeDir.exist?(full_path, base_dir: plugin_path)
|
|
246
|
+
# Find all .md files in this directory using safe_join
|
|
247
|
+
glob_pattern = PathSanitizer.safe_join(full_path, "**", "*.md")
|
|
248
|
+
SafeDir.glob(glob_pattern, base_dir: plugin_path).each do |file|
|
|
212
249
|
basename = File.basename(file).downcase
|
|
213
250
|
files << file unless ["readme.md", "license.md"].include?(basename)
|
|
214
251
|
end
|
|
@@ -15,7 +15,7 @@ module Caruso
|
|
|
15
15
|
def add_marketplace(name, url, install_location, ref: nil, source: "git")
|
|
16
16
|
data = load_registry
|
|
17
17
|
data[name] = {
|
|
18
|
-
"source" => source,
|
|
18
|
+
"source" => source, # github, git, url, local, directory
|
|
19
19
|
"url" => url,
|
|
20
20
|
"install_location" => install_location,
|
|
21
21
|
"last_updated" => Time.now.iso8601,
|
|
@@ -51,7 +51,7 @@ module Caruso
|
|
|
51
51
|
|
|
52
52
|
# Enhancement #3: Schema validation
|
|
53
53
|
def validate_marketplace_entry(entry)
|
|
54
|
-
required = [
|
|
54
|
+
required = %w[url install_location last_updated]
|
|
55
55
|
missing = required - entry.keys
|
|
56
56
|
|
|
57
57
|
unless missing.empty?
|
|
@@ -71,7 +71,7 @@ module Caruso
|
|
|
71
71
|
data = JSON.parse(File.read(@registry_path))
|
|
72
72
|
|
|
73
73
|
# Validate each entry
|
|
74
|
-
data.
|
|
74
|
+
data.each_value do |entry|
|
|
75
75
|
validate_marketplace_entry(entry)
|
|
76
76
|
end
|
|
77
77
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Caruso
|
|
6
|
+
# PathSanitizer provides utilities to validate and sanitize file paths
|
|
7
|
+
# to prevent path traversal attacks and ensure paths stay within expected boundaries
|
|
8
|
+
module PathSanitizer
|
|
9
|
+
class PathTraversalError < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Validates that a path doesn't contain path traversal sequences
|
|
12
|
+
# and stays within the expected base directory
|
|
13
|
+
#
|
|
14
|
+
# @param path [String] The path to validate
|
|
15
|
+
# @param base_dir [String] Optional base directory to validate against
|
|
16
|
+
# @return [String] The validated, normalized path
|
|
17
|
+
# @raise [PathTraversalError] if path contains traversal sequences or escapes base_dir
|
|
18
|
+
def self.sanitize_path(path, base_dir: nil)
|
|
19
|
+
return nil if path.nil? || path.empty?
|
|
20
|
+
|
|
21
|
+
# Normalize the path to resolve any . or .. components
|
|
22
|
+
normalized_path = Pathname.new(path).expand_path
|
|
23
|
+
|
|
24
|
+
# If base_dir is provided, ensure the path stays within it
|
|
25
|
+
if base_dir
|
|
26
|
+
normalized_base = Pathname.new(base_dir).expand_path
|
|
27
|
+
|
|
28
|
+
# Check if path is within base using relative_path_from
|
|
29
|
+
# This will raise ArgumentError if path is not within base
|
|
30
|
+
begin
|
|
31
|
+
relative = normalized_path.relative_path_from(normalized_base)
|
|
32
|
+
# Check that the relative path doesn't start with ..
|
|
33
|
+
if relative.to_s.start_with?("..")
|
|
34
|
+
raise PathTraversalError, "Path escapes base directory: #{path}"
|
|
35
|
+
end
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
# Paths on different drives/volumes
|
|
38
|
+
raise PathTraversalError, "Path is not within base directory: #{path}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
normalized_path.to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Safely joins path components, ensuring no traversal attacks
|
|
46
|
+
#
|
|
47
|
+
# @param base [String] The base directory
|
|
48
|
+
# @param *parts [String] Path components to join
|
|
49
|
+
# @return [String] The sanitized joined path
|
|
50
|
+
# @raise [PathTraversalError] if result would escape base directory
|
|
51
|
+
def self.safe_join(base, *parts)
|
|
52
|
+
# Join the parts
|
|
53
|
+
joined = File.join(base, *parts)
|
|
54
|
+
|
|
55
|
+
# Validate the result stays within base
|
|
56
|
+
sanitize_path(joined, base_dir: base)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "path_sanitizer"
|
|
4
|
+
|
|
5
|
+
module Caruso
|
|
6
|
+
class SafeDir
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
class SecurityError < Error; end
|
|
9
|
+
|
|
10
|
+
# Safely checks if a directory exists
|
|
11
|
+
#
|
|
12
|
+
# @param path [String] The path to check
|
|
13
|
+
# @param base_dir [String] Optional base directory to restrict access to
|
|
14
|
+
# @return [Boolean] true if directory exists and is safe
|
|
15
|
+
def self.exist?(path, base_dir: nil)
|
|
16
|
+
safe_path = sanitize(path, base_dir)
|
|
17
|
+
Dir.exist?(safe_path)
|
|
18
|
+
rescue SecurityError
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Safely globs files
|
|
23
|
+
#
|
|
24
|
+
# @param pattern [String] The glob pattern
|
|
25
|
+
# @param base_dir [String] Optional base directory to restrict access to
|
|
26
|
+
# @return [Array<String>] Matching file paths
|
|
27
|
+
def self.glob(pattern, base_dir: nil)
|
|
28
|
+
# For glob, we need to be careful. Ideally we validate the base of the pattern.
|
|
29
|
+
# If pattern contains wildcards, we can't easily sanitize the whole string as a path.
|
|
30
|
+
# So we rely on the caller to have constructed the pattern safely (e.g. using safe_join).
|
|
31
|
+
# But we can still check if the resolved paths are safe if base_dir is provided.
|
|
32
|
+
|
|
33
|
+
Dir.glob(pattern).select do |path|
|
|
34
|
+
if base_dir
|
|
35
|
+
begin
|
|
36
|
+
PathSanitizer.sanitize_path(path, base_dir: base_dir)
|
|
37
|
+
true
|
|
38
|
+
rescue PathSanitizer::PathTraversalError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.sanitize(path, base_dir)
|
|
48
|
+
PathSanitizer.sanitize_path(path, base_dir: base_dir)
|
|
49
|
+
rescue PathSanitizer::PathTraversalError => e
|
|
50
|
+
raise SecurityError, "Invalid directory path: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
private_class_method :sanitize
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "path_sanitizer"
|
|
4
|
+
|
|
5
|
+
module Caruso
|
|
6
|
+
class SafeFile
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
class NotFoundError < Error; end
|
|
9
|
+
class SecurityError < Error; end
|
|
10
|
+
|
|
11
|
+
# Safely reads a file from the filesystem
|
|
12
|
+
#
|
|
13
|
+
# @param path [String] The path to the file to read
|
|
14
|
+
# @param base_dir [String] Optional base directory to restrict access to
|
|
15
|
+
# @return [String] The file content
|
|
16
|
+
# @raise [NotFoundError] if the file does not exist or is not a file
|
|
17
|
+
# @raise [SecurityError] if the path is unsafe or outside base_dir
|
|
18
|
+
def self.read(path, base_dir: nil)
|
|
19
|
+
# 1. Sanitize and validate path
|
|
20
|
+
begin
|
|
21
|
+
safe_path = PathSanitizer.sanitize_path(path, base_dir: base_dir)
|
|
22
|
+
rescue PathSanitizer::PathTraversalError => e
|
|
23
|
+
raise SecurityError, "Invalid file path: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 2. Check existence and type
|
|
27
|
+
unless File.exist?(safe_path)
|
|
28
|
+
raise NotFoundError, "File not found: #{path}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
unless File.file?(safe_path)
|
|
32
|
+
raise NotFoundError, "Path is not a regular file: #{path}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 3. Read content
|
|
36
|
+
File.read(safe_path)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/caruso/version.rb
CHANGED
data/lib/caruso.rb
CHANGED
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.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Philipp Comans
|
|
@@ -132,12 +132,14 @@ extra_rdoc_files: []
|
|
|
132
132
|
files:
|
|
133
133
|
- ".rspec"
|
|
134
134
|
- ".rubocop.yml"
|
|
135
|
+
- AGENTS.md
|
|
135
136
|
- CHANGELOG.md
|
|
136
137
|
- CLAUDE.md
|
|
137
138
|
- IMPROVEMENTS.md
|
|
138
139
|
- LICENSE.txt
|
|
139
140
|
- README.md
|
|
140
141
|
- Rakefile
|
|
142
|
+
- SECURITY.md
|
|
141
143
|
- bin/caruso
|
|
142
144
|
- caruso.gemspec
|
|
143
145
|
- lib/caruso.rb
|
|
@@ -146,6 +148,9 @@ files:
|
|
|
146
148
|
- lib/caruso/config_manager.rb
|
|
147
149
|
- lib/caruso/fetcher.rb
|
|
148
150
|
- lib/caruso/marketplace_registry.rb
|
|
151
|
+
- lib/caruso/path_sanitizer.rb
|
|
152
|
+
- lib/caruso/safe_dir.rb
|
|
153
|
+
- lib/caruso/safe_file.rb
|
|
149
154
|
- lib/caruso/version.rb
|
|
150
155
|
- reference/marketplace.md
|
|
151
156
|
- reference/plugins.md
|
|
@@ -163,7 +168,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
163
168
|
requirements:
|
|
164
169
|
- - ">="
|
|
165
170
|
- !ruby/object:Gem::Version
|
|
166
|
-
version: 3.
|
|
171
|
+
version: 3.2.0
|
|
167
172
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
168
173
|
requirements:
|
|
169
174
|
- - ">="
|
data/CLAUDE.md
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
-
|
|
5
|
-
## Mission
|
|
6
|
-
|
|
7
|
-
Caruso is a Ruby gem CLI that bridges the gap between AI coding assistants. It fetches "steering documentation" (commands, agents, skills) from Claude Code Marketplaces and converts them to formats compatible with other IDEs, currently Cursor.
|
|
8
|
-
|
|
9
|
-
## Source of Truth: Claude Code Documentation
|
|
10
|
-
|
|
11
|
-
**IMPORTANT:** The official Claude Code marketplace and plugin specifications are located in `/Users/philipp/code/caruso/reference/`:
|
|
12
|
-
|
|
13
|
-
- `marketplace.md` - Marketplace structure and specification
|
|
14
|
-
- `plugins.md` - Plugin format and configuration
|
|
15
|
-
- `plugins_reference.md` - Component configuration fields and metadata
|
|
16
|
-
|
|
17
|
-
**These reference documents are the authoritative source for:**
|
|
18
|
-
- Marketplace.json schema and plugin metadata format
|
|
19
|
-
- Component configuration fields (`commands`, `agents`, `skills`, `hooks`, `mcpServers`)
|
|
20
|
-
- Expected directory structures and file patterns
|
|
21
|
-
- Metadata requirements and validation rules
|
|
22
|
-
|
|
23
|
-
When implementing features or fixing bugs related to marketplace compatibility, **always consult these reference files first**. If the implementation conflicts with the reference docs, the reference docs are correct and the code should be updated to match.
|
|
24
|
-
|
|
25
|
-
## Development Commands
|
|
26
|
-
|
|
27
|
-
### Build and Install
|
|
28
|
-
```bash
|
|
29
|
-
# Build the gem
|
|
30
|
-
gem build caruso.gemspec
|
|
31
|
-
|
|
32
|
-
# Install locally
|
|
33
|
-
gem install caruso-*.gem
|
|
34
|
-
|
|
35
|
-
# Verify installation
|
|
36
|
-
caruso version
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
### Testing
|
|
40
|
-
```bash
|
|
41
|
-
# Run offline tests only (default)
|
|
42
|
-
bundle exec rake spec
|
|
43
|
-
# or
|
|
44
|
-
bundle exec rspec
|
|
45
|
-
|
|
46
|
-
# Run all tests including live marketplace integration
|
|
47
|
-
bundle exec rake spec:all
|
|
48
|
-
# or
|
|
49
|
-
RUN_LIVE_TESTS=1 bundle exec rspec
|
|
50
|
-
|
|
51
|
-
# Run only live tests
|
|
52
|
-
bundle exec rake spec:live
|
|
53
|
-
|
|
54
|
-
# Run specific test file
|
|
55
|
-
bundle exec rspec spec/integration/plugin_spec.rb
|
|
56
|
-
|
|
57
|
-
# Run specific test
|
|
58
|
-
bundle exec rspec spec/integration/plugin_spec.rb:42
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
**Important:** Live tests (tagged with `:live`) interact with real marketplaces (anthropics/skills) and require network access. They can be slow (~7 minutes). Marketplace cache is stored in `~/.caruso/marketplaces/`. Integration tests set `CARUSO_TESTING_SKIP_CLONE=true` to skip Git cloning for fast offline testing.
|
|
62
|
-
|
|
63
|
-
### Linting
|
|
64
|
-
```bash
|
|
65
|
-
bundle exec rubocop
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
### Version Management
|
|
69
|
-
```bash
|
|
70
|
-
# Bump patch version (0.1.3 → 0.1.4)
|
|
71
|
-
bundle exec rake bump:patch
|
|
72
|
-
|
|
73
|
-
# Bump minor version (0.1.4 → 0.2.0)
|
|
74
|
-
bundle exec rake bump:minor
|
|
75
|
-
|
|
76
|
-
# Bump major version (0.1.4 → 1.0.0)
|
|
77
|
-
bundle exec rake bump:major
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## Architecture
|
|
81
|
-
|
|
82
|
-
### Core Pipeline: Fetch → Adapt → Track
|
|
83
|
-
|
|
84
|
-
Caruso follows a three-stage pipeline for plugin management:
|
|
85
|
-
|
|
86
|
-
1. **Fetch** (`Fetcher`) - Clones Git repositories, resolves marketplace.json, finds plugin markdown files
|
|
87
|
-
2. **Adapt** (`Adapter`) - Converts Claude Code markdown to target IDE format with metadata injection
|
|
88
|
-
3. **Track** (`ConfigManager`) - Records installations in `caruso.json` and `.caruso.local.json`
|
|
89
|
-
|
|
90
|
-
### Key Components
|
|
91
|
-
|
|
92
|
-
#### ConfigManager (`lib/caruso/config_manager.rb`)
|
|
93
|
-
Manages configuration and state. Splits data between:
|
|
94
|
-
|
|
95
|
-
**1. Project Config (`caruso.json`)**
|
|
96
|
-
- `ide`: Target IDE (currently only "cursor" supported)
|
|
97
|
-
- `target_dir`: Where to write converted files (`.cursor/rules` for Cursor)
|
|
98
|
-
- `marketplaces`: Name → URL mapping
|
|
99
|
-
- `plugins`: Name → metadata (marketplace source)
|
|
100
|
-
- `version`: Config schema version
|
|
101
|
-
|
|
102
|
-
**2. Local Config (`.caruso.local.json`)**
|
|
103
|
-
- `installed_files`: Plugin Name → Array of file paths
|
|
104
|
-
- `initialized_at`: Timestamp
|
|
105
|
-
|
|
106
|
-
Must run `caruso init --ide=cursor` before other commands. ConfigManager handles loading/saving both files and ensures `.caruso.local.json` is gitignored.
|
|
107
|
-
|
|
108
|
-
#### MarketplaceRegistry (`lib/caruso/marketplace_registry.rb`)
|
|
109
|
-
Manages persistent marketplace metadata registry at `~/.caruso/known_marketplaces.json`. Contains:
|
|
110
|
-
- `source`: Marketplace type (git, github, url, local, directory)
|
|
111
|
-
- `url`: Original marketplace URL
|
|
112
|
-
- `install_location`: Local cache path (e.g., `~/.caruso/marketplaces/skills/`)
|
|
113
|
-
- `last_updated`: ISO8601 timestamp of last update
|
|
114
|
-
- `ref`: Optional Git ref/branch/tag for pinning
|
|
115
|
-
|
|
116
|
-
**Key features:**
|
|
117
|
-
- **Schema validation**: Validates required fields and timestamp format on load
|
|
118
|
-
- **Corruption handling**: Backs up corrupted registry to `.corrupted.<timestamp>` and continues with empty registry
|
|
119
|
-
- **Timestamp tracking**: Updates `last_updated` when marketplace cache is refreshed
|
|
120
|
-
- **Source type tracking**: Enables future support for multiple marketplace sources
|
|
121
|
-
|
|
122
|
-
This registry enables persistent tracking of marketplace state across reboots, unlike the previous `/tmp` approach.
|
|
123
|
-
|
|
124
|
-
#### Fetcher (`lib/caruso/fetcher.rb`)
|
|
125
|
-
Resolves and fetches plugins from marketplaces. Supports:
|
|
126
|
-
- GitHub repos: `https://github.com/owner/repo`
|
|
127
|
-
- Git URLs: Any Git-cloneable URL
|
|
128
|
-
- Local paths: `./path/to/marketplace` or `./path/to/marketplace.json`
|
|
129
|
-
|
|
130
|
-
**Key behavior:**
|
|
131
|
-
- Clones Git repos to `~/.caruso/marketplaces/<marketplace-name>/` (persistent across reboots)
|
|
132
|
-
- Registers marketplace metadata in `~/.caruso/known_marketplaces.json` (via MarketplaceRegistry)
|
|
133
|
-
- Supports Git ref/branch pinning for version control
|
|
134
|
-
- Reads `marketplace.json` to find available plugins
|
|
135
|
-
- Supports custom component paths: plugins can specify `commands`, `agents`, `skills` arrays pointing to non-standard locations
|
|
136
|
-
- Scans standard directories: `{commands,agents,skills}/**/*.md`
|
|
137
|
-
- Excludes README.md and LICENSE.md files
|
|
138
|
-
- **Custom paths supplement (not replace) default directories** - this is critical
|
|
139
|
-
- Detects SSH authentication errors and provides helpful error messages
|
|
140
|
-
|
|
141
|
-
**marketplace.json structure:**
|
|
142
|
-
```json
|
|
143
|
-
{
|
|
144
|
-
"plugins": [
|
|
145
|
-
{
|
|
146
|
-
"name": "document-skills",
|
|
147
|
-
"description": "Work with documents",
|
|
148
|
-
"source": "./document-skills",
|
|
149
|
-
"skills": ["./document-skills/xlsx", "./document-skills/pdf"]
|
|
150
|
-
}
|
|
151
|
-
]
|
|
152
|
-
}
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
The `commands`, `agents`, and `skills` fields accept:
|
|
156
|
-
- String: `"./custom/path"`
|
|
157
|
-
- Array: `["./path1", "./path2"]`
|
|
158
|
-
|
|
159
|
-
Both files and directories are supported. Fetcher recursively finds all `.md` files.
|
|
160
|
-
|
|
161
|
-
#### Adapter (`lib/caruso/adapter.rb`)
|
|
162
|
-
Converts Claude Code markdown files to target IDE format. For Cursor:
|
|
163
|
-
- Renames `.md` → `.mdc`
|
|
164
|
-
- Injects YAML frontmatter with required Cursor metadata:
|
|
165
|
-
- `globs: []` - Enables semantic search (Apply Intelligently)
|
|
166
|
-
- `alwaysApply: false` - Prevents auto-application to every chat
|
|
167
|
-
- `description` - Preserved from original or generated
|
|
168
|
-
- Preserves existing frontmatter if present, adds missing fields
|
|
169
|
-
- Handles special case: `SKILL.md` → named after parent directory to avoid collisions
|
|
170
|
-
|
|
171
|
-
Returns array of created filenames (not full paths) for manifest tracking.
|
|
172
|
-
|
|
173
|
-
#### CLI (`lib/caruso/cli.rb`)
|
|
174
|
-
Thor-based CLI with nested commands:
|
|
175
|
-
- `caruso init [PATH] --ide=cursor`
|
|
176
|
-
- `caruso marketplace add URL [--ref=BRANCH]` - Add marketplace with optional Git ref pinning (name comes from marketplace.json)
|
|
177
|
-
- `caruso marketplace list` - List configured marketplaces
|
|
178
|
-
- `caruso marketplace remove NAME` - Remove marketplace from manifest and registry
|
|
179
|
-
- `caruso marketplace update [NAME]` - Update marketplace cache (all if no name given)
|
|
180
|
-
- `caruso marketplace info NAME` - Show detailed marketplace information from registry
|
|
181
|
-
- `caruso plugin install|uninstall|list|update|outdated`
|
|
182
|
-
|
|
183
|
-
**Important patterns:**
|
|
184
|
-
- All commands except `init` require existing `caruso.json` (enforced by `load_config` helper)
|
|
185
|
-
- Plugin install format: `plugin@marketplace` or just `plugin` (if only one marketplace configured)
|
|
186
|
-
- Update commands refresh marketplace cache (git pull) before fetching latest plugin files
|
|
187
|
-
- Marketplace add eagerly clones repos unless `CARUSO_TESTING_SKIP_CLONE` env var is set (used in tests)
|
|
188
|
-
- **Marketplace names always come from marketplace.json `name` field (required)** - no custom names allowed
|
|
189
|
-
- Errors use descriptive messages with suggestions (e.g., "use 'caruso marketplace add <url>'")
|
|
190
|
-
|
|
191
|
-
### Data Flow Example
|
|
192
|
-
|
|
193
|
-
User runs: `caruso plugin install document-skills@skills`
|
|
194
|
-
|
|
195
|
-
1. **CLI** parses command, loads config from `caruso.json`
|
|
196
|
-
2. **ConfigManager** looks up marketplace "skills" URL
|
|
197
|
-
3. **Fetcher** clones/updates marketplace repo to `~/.caruso/marketplaces/skills/`
|
|
198
|
-
4. **Fetcher** registers/updates marketplace metadata in MarketplaceRegistry
|
|
199
|
-
5. **Fetcher** reads `marketplace.json`, finds document-skills plugin
|
|
200
|
-
6. **Fetcher** scans standard directories + custom paths from `skills: [...]` array
|
|
201
|
-
7. **Fetcher** returns list of `.md` file paths
|
|
202
|
-
8. **Adapter** converts each file: adds frontmatter, renames to `.mdc`, writes to `.cursor/rules/caruso/`
|
|
203
|
-
9. **Adapter** returns created filenames
|
|
204
|
-
10. **ConfigManager** records plugin in `caruso.json` and files in `.caruso.local.json`
|
|
205
|
-
11. **CLI** prints success message
|
|
206
|
-
|
|
207
|
-
### Testing Architecture
|
|
208
|
-
|
|
209
|
-
Uses **Aruba** for CLI integration testing. Test structure:
|
|
210
|
-
- `spec/unit/` - Direct class testing (ConfigManager, Fetcher logic)
|
|
211
|
-
- `spec/integration/` - Full CLI workflow tests via Aruba subprocess execution
|
|
212
|
-
|
|
213
|
-
**Aruba helpers in spec_helper.rb:**
|
|
214
|
-
- `init_caruso(ide: "cursor")` - Runs init command with success assertion
|
|
215
|
-
- `add_marketplace(url, name)` - Adds marketplace with success assertion
|
|
216
|
-
- `config_file`, `manifest_file`, `load_config`, `load_manifest` - File access helpers
|
|
217
|
-
- `mdc_files` - Glob for `.cursor/rules/*.mdc` files
|
|
218
|
-
|
|
219
|
-
**Critical testing pattern:**
|
|
220
|
-
```ruby
|
|
221
|
-
run_command("caruso plugin install foo@bar")
|
|
222
|
-
expect(last_command_started).to be_successfully_executed # Always verify success first!
|
|
223
|
-
manifest = load_manifest # Then access results
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
**Why this matters:** If command fails, manifest might not exist (nil). Always assert success before accessing command results to prevent confusing test failures.
|
|
227
|
-
|
|
228
|
-
**Live tests:**
|
|
229
|
-
- Tagged with `:live` metadata
|
|
230
|
-
- Run only when `RUN_LIVE_TESTS=1` environment variable set
|
|
231
|
-
- Interact with real anthropics/skills marketplace
|
|
232
|
-
- Cache cleared once at test suite start for performance
|
|
233
|
-
- Use `sleep` for timestamp resolution (not `Timecop`) because Caruso runs as subprocess
|
|
234
|
-
|
|
235
|
-
**Timecop limitation:** Cannot mock time in subprocesses. When testing timestamp updates in plugin reinstall scenarios, use `sleep 1.1` (ISO8601 has second precision) instead of `Timecop.travel`.
|
|
236
|
-
|
|
237
|
-
## Marketplace Compatibility
|
|
238
|
-
|
|
239
|
-
Caruso supports the Claude Code marketplace specification with custom component paths:
|
|
240
|
-
|
|
241
|
-
- Standard structure: `{commands,agents,skills}/**/*.md`
|
|
242
|
-
- Custom paths: `"commands": ["./custom/path"]` in marketplace.json
|
|
243
|
-
- Both string and array formats supported
|
|
244
|
-
- Custom paths **supplement** defaults (they don't replace)
|
|
245
|
-
|
|
246
|
-
Example: anthropics/skills marketplace uses custom paths:
|
|
247
|
-
```json
|
|
248
|
-
{
|
|
249
|
-
"name": "document-skills",
|
|
250
|
-
"skills": ["./document-skills/xlsx", "./document-skills/pdf"]
|
|
251
|
-
}
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
Fetcher will scan both:
|
|
255
|
-
1. `./document-skills/skills/**/*.md` (default)
|
|
256
|
-
2. `./document-skills/xlsx/**/*.md` (custom)
|
|
257
|
-
3. `./document-skills/pdf/**/*.md` (custom)
|
|
258
|
-
|
|
259
|
-
Results are deduplicated with `.uniq`.
|
|
260
|
-
|
|
261
|
-
## Release Process
|
|
262
|
-
|
|
263
|
-
1. Run tests: `bundle exec rake spec:all`
|
|
264
|
-
2. Bump version: `bundle exec rake bump:patch` (or minor/major)
|
|
265
|
-
3. Update CHANGELOG.md with release notes
|
|
266
|
-
4. Commit: `git commit -m "chore: Bump version to X.Y.Z"`
|
|
267
|
-
5. Tag: `git tag -a vX.Y.Z -m "Release version X.Y.Z"`
|
|
268
|
-
6. Build: `gem build caruso.gemspec`
|
|
269
|
-
7. Install and test: `gem install caruso-X.Y.Z.gem && caruso version`
|
|
270
|
-
8. Push: `git push origin main --tags`
|
|
271
|
-
|
|
272
|
-
Version is managed in `lib/caruso/version.rb`.
|
|
273
|
-
|
|
274
|
-
# Memory
|
|
275
|
-
- The goal is a clean, correct, consistent implementation. Never implement fallbacks that hide errors or engage in defensive programming.
|
|
276
|
-
- Treat the vendor directory .cursor/rules/caruso/ as a build artifact
|