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.
- checksums.yaml +7 -0
- data/.rspec +4 -0
- data/.rubocop.yml +146 -0
- data/CHANGELOG.md +213 -0
- data/CLAUDE.md +276 -0
- data/IMPROVEMENTS.md +337 -0
- data/LICENSE.txt +21 -0
- data/README.md +326 -0
- data/Rakefile +71 -0
- data/bin/caruso +6 -0
- data/caruso.gemspec +43 -0
- data/lib/caruso/adapter.rb +110 -0
- data/lib/caruso/cli.rb +532 -0
- data/lib/caruso/config_manager.rb +190 -0
- data/lib/caruso/fetcher.rb +248 -0
- data/lib/caruso/marketplace_registry.rb +102 -0
- data/lib/caruso/version.rb +5 -0
- data/lib/caruso.rb +22 -0
- data/reference/marketplace.md +433 -0
- data/reference/plugins.md +391 -0
- data/reference/plugins_reference.md +376 -0
- metadata +176 -0
|
@@ -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
|
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
|