caruso 0.5.4

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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+
7
+ module Caruso
8
+ class ConfigManager
9
+ PROJECT_CONFIG_FILENAME = "caruso.json"
10
+ LOCAL_CONFIG_FILENAME = ".caruso.local.json"
11
+ SUPPORTED_IDES = ["cursor"].freeze
12
+ IDE_TARGET_DIRS = {
13
+ "cursor" => ".cursor/rules"
14
+ }.freeze
15
+
16
+ attr_reader :project_dir, :project_config_path, :local_config_path
17
+
18
+ def initialize(project_dir = Dir.pwd)
19
+ @project_dir = File.expand_path(project_dir)
20
+ @project_config_path = File.join(@project_dir, PROJECT_CONFIG_FILENAME)
21
+ @local_config_path = File.join(@project_dir, LOCAL_CONFIG_FILENAME)
22
+ end
23
+
24
+ def init(ide:)
25
+ unless SUPPORTED_IDES.include?(ide)
26
+ raise ArgumentError, "Unsupported IDE: #{ide}. Supported: #{SUPPORTED_IDES.join(', ')}"
27
+ end
28
+
29
+ if config_exists?
30
+ raise Error, "Caruso is already initialized in #{@project_dir}."
31
+ end
32
+
33
+ # Create project config (caruso.json)
34
+ project_config = {
35
+ "version" => "1.0.0",
36
+ "marketplaces" => {},
37
+ "plugins" => {}
38
+ }
39
+ save_project_config(project_config)
40
+
41
+ # Create local config (.caruso.local.json)
42
+ local_config = {
43
+ "ide" => ide,
44
+ "target_dir" => IDE_TARGET_DIRS[ide],
45
+ "installed_files" => {}
46
+ }
47
+ save_local_config(local_config)
48
+
49
+ { **project_config, **local_config }
50
+ end
51
+
52
+ def load
53
+ unless config_exists?
54
+ raise Error, "Caruso not initialized. Run 'caruso init --ide=cursor' first."
55
+ end
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)
62
+ end
63
+
64
+ def config_exists?
65
+ File.exist?(@project_config_path) && File.exist?(@local_config_path)
66
+ end
67
+
68
+ def target_dir
69
+ load["target_dir"]
70
+ end
71
+
72
+ def full_target_path
73
+ File.join(@project_dir, target_dir)
74
+ end
75
+
76
+ def ide
77
+ load["ide"]
78
+ end
79
+
80
+ # Plugin Management
81
+
82
+ def add_plugin(name, files, marketplace_name:)
83
+ # Update project config (Intent)
84
+ project_data = load_project_config
85
+ project_data["plugins"] ||= {}
86
+ project_data["plugins"][name] = {
87
+ "marketplace" => marketplace_name
88
+ }
89
+ save_project_config(project_data)
90
+
91
+ # Update local config (Files)
92
+ local_data = load_local_config
93
+ local_data["installed_files"] ||= {}
94
+ local_data["installed_files"][name] = files
95
+ save_local_config(local_data)
96
+ end
97
+
98
+ def remove_plugin(name)
99
+ # Get files to remove from local config
100
+ local_data = load_local_config
101
+ files = local_data.dig("installed_files", name) || []
102
+
103
+ # 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
+
109
+ # Remove from project config
110
+ project_data = load_project_config
111
+ if project_data["plugins"]
112
+ project_data["plugins"].delete(name)
113
+ save_project_config(project_data)
114
+ end
115
+
116
+ files
117
+ end
118
+
119
+ def list_plugins
120
+ load_project_config["plugins"] || {}
121
+ end
122
+
123
+ def plugin_installed?(name)
124
+ plugins = list_plugins
125
+ plugins.key?(name)
126
+ end
127
+
128
+ def get_installed_files(name)
129
+ load_local_config.dig("installed_files", name) || []
130
+ end
131
+
132
+ # Marketplace Management
133
+
134
+ def add_marketplace(name, url, source: "git", ref: nil)
135
+ data = load_project_config
136
+ data["marketplaces"] ||= {}
137
+ data["marketplaces"][name] = {
138
+ "url" => url,
139
+ "source" => source,
140
+ "ref" => ref
141
+ }.compact
142
+ save_project_config(data)
143
+ end
144
+
145
+ def remove_marketplace(name)
146
+ data = load_project_config
147
+ return unless data["marketplaces"]
148
+
149
+ data["marketplaces"].delete(name)
150
+ save_project_config(data)
151
+ end
152
+
153
+ def list_marketplaces
154
+ load_project_config["marketplaces"] || {}
155
+ end
156
+
157
+ def get_marketplace_details(name)
158
+ load_project_config.dig("marketplaces", name)
159
+ end
160
+
161
+ def get_marketplace_url(name)
162
+ details = get_marketplace_details(name)
163
+ details ? details["url"] : nil
164
+ end
165
+
166
+ private
167
+
168
+ def load_project_config
169
+ return {} unless File.exist?(@project_config_path)
170
+ JSON.parse(File.read(@project_config_path))
171
+ rescue JSON::ParserError
172
+ {}
173
+ end
174
+
175
+ def save_project_config(data)
176
+ File.write(@project_config_path, JSON.pretty_generate(data))
177
+ end
178
+
179
+ def load_local_config
180
+ return {} unless File.exist?(@local_config_path)
181
+ JSON.parse(File.read(@local_config_path))
182
+ rescue JSON::ParserError
183
+ {}
184
+ end
185
+
186
+ def save_local_config(data)
187
+ File.write(@local_config_path, JSON.pretty_generate(data))
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "faraday"
5
+ require "fileutils"
6
+ require "uri"
7
+ require "git"
8
+
9
+ module Caruso
10
+ class Fetcher
11
+ attr_reader :marketplace_uri
12
+
13
+ def initialize(marketplace_uri, marketplace_name: nil, ref: nil)
14
+ @marketplace_uri = marketplace_uri
15
+ @marketplace_name = marketplace_name
16
+ @ref = ref
17
+ @registry = MarketplaceRegistry.new
18
+ end
19
+
20
+ def cache_dir
21
+ # Cache directory based on URL for stability (name comes from marketplace.json)
22
+ url_based_name = extract_name_from_url(@marketplace_uri)
23
+ File.join(Dir.home, ".caruso", "marketplaces", url_based_name)
24
+ end
25
+
26
+ def fetch(plugin_name)
27
+ marketplace_data = load_marketplace
28
+ plugins = marketplace_data["plugins"] || []
29
+
30
+ plugin = plugins.find { |p| p["name"] == plugin_name }
31
+ unless plugin
32
+ available = plugins.map { |p| p["name"] }
33
+ raise PluginNotFoundError.new("Plugin '#{plugin_name}' not found in marketplace", available)
34
+ end
35
+
36
+ fetch_plugin(plugin)
37
+ end
38
+
39
+ def list_available_plugins
40
+ marketplace_data = load_marketplace
41
+ plugins = marketplace_data["plugins"] || []
42
+ plugins.map do |p|
43
+ {
44
+ name: p["name"],
45
+ description: p["description"],
46
+ version: p["version"]
47
+ }
48
+ end
49
+ end
50
+
51
+ def update_cache
52
+ return unless Dir.exist?(cache_dir)
53
+
54
+ Dir.chdir(cache_dir) do
55
+ git = Git.open(".")
56
+ if @ref
57
+ git.fetch("origin", @ref)
58
+ git.checkout(@ref)
59
+ end
60
+ git.pull("origin", "HEAD")
61
+ end
62
+
63
+ @registry.update_timestamp(@marketplace_name)
64
+ rescue => e
65
+ handle_git_error(e)
66
+ end
67
+
68
+ def clone_git_repo(source_config)
69
+ url = source_config["url"] || source_config["repo"]
70
+ url = "https://github.com/#{url}.git" if source_config["source"] == "github" && !url.match?(/\Ahttps?:/)
71
+
72
+ repo_name = URI.parse(url).path.split("/").last.sub(".git", "")
73
+ target_path = cache_dir
74
+
75
+ unless Dir.exist?(target_path)
76
+ # Clone the repository
77
+ FileUtils.mkdir_p(File.dirname(target_path))
78
+ Git.clone(url, target_path)
79
+ checkout_ref if @ref
80
+
81
+ # Add to registry
82
+ source_type = source_config["source"] || "git"
83
+ @registry.add_marketplace(@marketplace_name, url, target_path, ref: @ref, source: source_type)
84
+ end
85
+
86
+ target_path
87
+ rescue StandardError => e
88
+ handle_git_error(e)
89
+ nil
90
+ end
91
+
92
+ def extract_marketplace_name
93
+ marketplace_data = load_marketplace
94
+ name = marketplace_data["name"]
95
+
96
+ unless name
97
+ raise Caruso::Error, "Invalid marketplace: marketplace.json missing required 'name' field"
98
+ end
99
+
100
+ name
101
+ end
102
+
103
+ private
104
+
105
+ def load_marketplace
106
+ if local_path?
107
+ # If marketplace_uri is a directory, find marketplace.json in it
108
+ if File.directory?(@marketplace_uri)
109
+ json_path = File.join(@marketplace_uri, ".claude-plugin", "marketplace.json")
110
+ json_path = File.join(@marketplace_uri, "marketplace.json") unless File.exist?(json_path)
111
+
112
+ unless File.exist?(json_path)
113
+ raise Caruso::Error, "Could not find marketplace.json in #{@marketplace_uri}"
114
+ end
115
+
116
+ @marketplace_uri = json_path
117
+ end
118
+
119
+ @base_dir = File.dirname(@marketplace_uri)
120
+ # Heuristic: if we are in .claude-plugin, the real root is one level up
121
+ if File.basename(@base_dir) == ".claude-plugin"
122
+ @base_dir = File.dirname(@base_dir)
123
+ end
124
+
125
+ JSON.parse(File.read(@marketplace_uri))
126
+ elsif github_repo?
127
+ # Clone repo and read marketplace.json from it
128
+ repo_path = clone_git_repo("url" => @marketplace_uri, "source" => "github")
129
+ # Try standard locations
130
+ json_path = File.join(repo_path, ".claude-plugin", "marketplace.json")
131
+ json_path = File.join(repo_path, "marketplace.json") unless File.exist?(json_path)
132
+
133
+ unless File.exist?(json_path)
134
+ raise "Could not find marketplace.json in #{@marketplace_uri}"
135
+ end
136
+
137
+ # Update marketplace_uri to point to the local file so relative paths work
138
+ @marketplace_uri = json_path
139
+ @base_dir = repo_path # Base dir is the repo root, regardless of where json is
140
+
141
+ JSON.parse(File.read(json_path))
142
+ else
143
+ response = Faraday.get(@marketplace_uri)
144
+ JSON.parse(response.body)
145
+ end
146
+ end
147
+
148
+ def github_repo?
149
+ @marketplace_uri.match?(%r{\Ahttps://github\.com/[^/]+/[^/]+}) ||
150
+ @marketplace_uri.match?(%r{\A[^/]+/[^/]+\z}) # owner/repo format
151
+ end
152
+
153
+ def fetch_plugin(plugin)
154
+ source = plugin["source"]
155
+ plugin_path = resolve_plugin_path(source)
156
+ return [] unless plugin_path && Dir.exist?(plugin_path)
157
+
158
+ # Start with default directories
159
+ files = find_steering_files(plugin_path)
160
+
161
+ # Add custom paths if specified (they supplement defaults)
162
+ files += find_custom_component_files(plugin_path, plugin["commands"]) if plugin["commands"]
163
+ files += find_custom_component_files(plugin_path, plugin["agents"]) if plugin["agents"]
164
+ files += find_custom_component_files(plugin_path, plugin["skills"]) if plugin["skills"]
165
+
166
+ files.uniq
167
+ end
168
+
169
+ def resolve_plugin_path(source)
170
+ if source.is_a?(Hash) && %w[git github].include?(source["source"])
171
+ clone_git_repo(source)
172
+ elsif local_path? && source.is_a?(String) && (source.start_with?(".") || source.start_with?("/"))
173
+ if source.start_with?("/")
174
+ source
175
+ else
176
+ File.expand_path(source, @base_dir)
177
+ end
178
+ elsif source.is_a?(String) && source.match?(/\Ahttps?:/)
179
+ # Assume it's a git URL if it ends in .git or we treat it as such
180
+ clone_git_repo("url" => source)
181
+ else
182
+ # Fallback or error
183
+ puts "Warning: Could not resolve source for plugin: #{source}"
184
+ nil
185
+ end
186
+ end
187
+
188
+ def find_steering_files(plugin_path)
189
+ Dir.glob(File.join(plugin_path, "{commands,agents,skills}/**/*.md")).reject do |file|
190
+ basename = File.basename(file).downcase
191
+ ["readme.md", "license.md"].include?(basename)
192
+ end
193
+ end
194
+
195
+ def find_custom_component_files(plugin_path, paths)
196
+ # Handle both string and array formats
197
+ paths = [paths] if paths.is_a?(String)
198
+ return [] unless paths.is_a?(Array)
199
+
200
+ files = []
201
+ paths.each do |path|
202
+ # Resolve the path relative to plugin_path
203
+ full_path = File.expand_path(path, plugin_path)
204
+
205
+ # Handle both files and directories
206
+ if File.file?(full_path) && full_path.end_with?(".md")
207
+ basename = File.basename(full_path).downcase
208
+ 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|
212
+ basename = File.basename(file).downcase
213
+ files << file unless ["readme.md", "license.md"].include?(basename)
214
+ end
215
+ end
216
+ end
217
+ files
218
+ end
219
+
220
+ def local_path?
221
+ !@marketplace_uri.match?(/\Ahttps?:/)
222
+ end
223
+
224
+ def handle_git_error(error)
225
+ msg = error.message.to_s
226
+ if msg.include?("Permission denied") ||
227
+ msg.include?("publickey") ||
228
+ msg.include?("Could not read from remote")
229
+ raise Caruso::Error, "SSH authentication failed while accessing marketplace.\n" \
230
+ "Please ensure your SSH keys are configured for #{@marketplace_uri}"
231
+ end
232
+ raise error
233
+ end
234
+
235
+ def checkout_ref
236
+ return unless @ref
237
+
238
+ Dir.chdir(cache_dir) do
239
+ git = Git.open(".")
240
+ git.checkout(@ref)
241
+ end
242
+ end
243
+
244
+ def extract_name_from_url(url)
245
+ url.split("/").last.sub(".git", "")
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Caruso
7
+ class MarketplaceRegistry
8
+ REGISTRY_FILENAME = "known_marketplaces.json"
9
+
10
+ def initialize
11
+ @registry_path = File.join(Dir.home, ".caruso", REGISTRY_FILENAME)
12
+ end
13
+
14
+ # Enhancement #1: Add source type tracking
15
+ def add_marketplace(name, url, install_location, ref: nil, source: "git")
16
+ data = load_registry
17
+ data[name] = {
18
+ "source" => source, # github, git, url, local, directory
19
+ "url" => url,
20
+ "install_location" => install_location,
21
+ "last_updated" => Time.now.iso8601,
22
+ "ref" => ref
23
+ }.compact
24
+ save_registry(data)
25
+ end
26
+
27
+ def get_marketplace(name)
28
+ data = load_registry
29
+ data[name]
30
+ end
31
+
32
+ def update_timestamp(name)
33
+ data = load_registry
34
+ return unless data[name]
35
+
36
+ data[name]["last_updated"] = Time.now.iso8601
37
+ save_registry(data)
38
+ end
39
+
40
+ def list_marketplaces
41
+ load_registry
42
+ end
43
+
44
+ def remove_marketplace(name)
45
+ data = load_registry
46
+ data.delete(name)
47
+ save_registry(data)
48
+ end
49
+
50
+ private
51
+
52
+ # Enhancement #3: Schema validation
53
+ def validate_marketplace_entry(entry)
54
+ required = ["url", "install_location", "last_updated"]
55
+ missing = required - entry.keys
56
+
57
+ unless missing.empty?
58
+ raise Caruso::Error, "Invalid marketplace entry: missing #{missing.join(', ')}"
59
+ end
60
+
61
+ # Validate timestamp format
62
+ Time.iso8601(entry["last_updated"])
63
+ rescue ArgumentError
64
+ raise Caruso::Error, "Invalid timestamp format in marketplace entry"
65
+ end
66
+
67
+ # Enhancement #4: Registry corruption handling
68
+ def load_registry
69
+ return {} unless File.exist?(@registry_path)
70
+
71
+ data = JSON.parse(File.read(@registry_path))
72
+
73
+ # Validate each entry
74
+ data.each do |name, entry|
75
+ validate_marketplace_entry(entry)
76
+ end
77
+
78
+ data
79
+ rescue JSON::ParserError => e
80
+ handle_corrupted_registry(e)
81
+ {}
82
+ rescue Caruso::Error => e
83
+ warn "Warning: Invalid marketplace entry: #{e.message}"
84
+ warn "Continuing with partial registry data."
85
+ {}
86
+ end
87
+
88
+ def handle_corrupted_registry(error)
89
+ corrupted_path = "#{@registry_path}.corrupted.#{Time.now.to_i}"
90
+ FileUtils.cp(@registry_path, corrupted_path)
91
+
92
+ warn "Marketplace registry corrupted. Backup saved to: #{corrupted_path}"
93
+ warn "Error: #{error.message}"
94
+ warn "Starting with empty registry."
95
+ end
96
+
97
+ def save_registry(data)
98
+ FileUtils.mkdir_p(File.dirname(@registry_path))
99
+ File.write(@registry_path, JSON.pretty_generate(data))
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Caruso
4
+ VERSION = "0.5.4"
5
+ end
data/lib/caruso.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "caruso/version"
4
+ require_relative "caruso/config_manager"
5
+ require_relative "caruso/fetcher"
6
+ require_relative "caruso/adapter"
7
+ require_relative "caruso/marketplace_registry"
8
+
9
+ require_relative "caruso/cli"
10
+
11
+ module Caruso
12
+ class Error < StandardError; end
13
+
14
+ class PluginNotFoundError < Error
15
+ attr_reader :available_plugins
16
+
17
+ def initialize(message, available_plugins = [])
18
+ super(message)
19
+ @available_plugins = available_plugins
20
+ end
21
+ end
22
+ end