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.
@@ -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
  {}
@@ -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 Dir.exist?(cache_dir)
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
- repo_name = URI.parse(url).path.split("/").last.sub(".git", "")
75
+ URI.parse(url).path.split("/").last.sub(".git", "")
73
76
  target_path = cache_dir
74
77
 
75
- unless Dir.exist?(target_path)
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 File.directory?(@marketplace_uri)
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(File.read(@marketplace_uri))
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(File.read(json_path))
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 && Dir.exist?(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
- Dir.glob(File.join(plugin_path, "{commands,agents,skills}/**/*.md")).reject do |file|
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
- full_path = File.expand_path(path, plugin_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
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 Dir.exist?(full_path)
210
- # Find all .md files in this directory
211
- Dir.glob(File.join(full_path, "**/*.md")).each do |file|
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, # github, git, url, local, directory
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 = ["url", "install_location", "last_updated"]
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.each do |name, entry|
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Caruso
4
- VERSION = "0.5.4"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/caruso.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "caruso/safe_file"
4
+ require_relative "caruso/safe_dir"
3
5
  require_relative "caruso/version"
4
6
  require_relative "caruso/config_manager"
5
7
  require_relative "caruso/fetcher"
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.5.4
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.0.0
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