use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::{Context, Result};
pub fn registry_path() -> PathBuf {
crate::config::config_dir().join("marketplaces.json")
}
#[derive(Debug, Clone)]
pub struct MarketplaceRegistry {
entries: BTreeMap<String, String>,
}
impl MarketplaceRegistry {
pub fn load() -> Result<Self> {
let path = registry_path();
if !path.exists() {
return Ok(Self {
entries: BTreeMap::new(),
});
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let entries: BTreeMap<String, String> = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(Self { entries })
}
pub fn save(&self) -> Result<()> {
let path = registry_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(&self.entries)?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
pub fn add(&mut self, name: &str, owner_repo: &str) -> Result<bool> {
validate_marketplace_name(name)?;
validate_owner_repo(owner_repo)?;
let is_new = !self.entries.contains_key(name);
let previous = self
.entries
.insert(name.to_string(), owner_repo.to_string());
if let Err(e) = self.save() {
match previous {
Some(prev) => {
self.entries.insert(name.to_string(), prev);
}
None => {
self.entries.remove(name);
}
}
return Err(e);
}
Ok(is_new)
}
pub fn remove(&mut self, name: &str) -> Result<bool> {
let existed = self.entries.remove(name).is_some();
if existed {
self.save()?;
}
Ok(existed)
}
pub fn list(&self) -> Vec<(&str, &str)> {
self.entries
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect()
}
pub fn get(&self, name: &str) -> Option<&str> {
self.entries.get(name).map(|s| s.as_str())
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ResolvedPlugin {
pub name: String,
pub owner_repo: String,
pub description: String,
}
pub fn fetch_marketplace_index(marketplace_owner_repo: &str) -> Result<Vec<ResolvedPlugin>> {
let branches = ["main", "master"];
let manifest_paths = [
".collet-plugin/marketplace.json",
".claude-plugin/marketplace.json",
];
let mut last_err = String::new();
for branch in &branches {
for manifest_path in &manifest_paths {
let url = format!(
"https://raw.githubusercontent.com/{marketplace_owner_repo}/{branch}/{manifest_path}"
);
match fetch_url(&url) {
Ok(body) => {
return parse_index(marketplace_owner_repo, &body);
}
Err(e) => {
last_err = format!("{e}");
tracing::debug!(url = %url, error = %e, "Failed to fetch marketplace index");
}
}
}
}
anyhow::bail!(
"Could not fetch marketplace index for `{marketplace_owner_repo}`. \
Make sure the repo contains `.collet-plugin/marketplace.json` or `.claude-plugin/marketplace.json`. \
Last error: {last_err}"
)
}
pub fn resolve(spec: &str) -> Result<ResolvedPlugin> {
let (plugin_name, marketplace_name) = parse_at_spec(spec)?;
let registry = MarketplaceRegistry::load()?;
let marketplace_owner_repo = registry.get(marketplace_name).ok_or_else(|| {
let known: Vec<&str> = registry.list().iter().map(|(n, _)| *n).collect();
if known.is_empty() {
anyhow::anyhow!(
"Marketplace '{marketplace_name}' is not registered.\n\
Register it first: `/plugin marketplace add <owner/repo>`"
)
} else {
anyhow::anyhow!(
"Marketplace '{marketplace_name}' is not registered.\n\
Known marketplaces: {}\n\
Register with: `/plugin marketplace add <owner/repo>`",
known.join(", ")
)
}
})?;
let plugins = fetch_marketplace_index(marketplace_owner_repo)?;
plugins
.into_iter()
.find(|p| p.name == plugin_name)
.ok_or_else(|| {
anyhow::anyhow!(
"Plugin '{plugin_name}' not found in marketplace '{marketplace_name}'.\n\
Run `/plugin marketplace list` to see registered marketplaces."
)
})
}
pub fn derive_marketplace_name(owner_repo: &str) -> &str {
let repo = owner_repo.split('/').next_back().unwrap_or(owner_repo);
for suffix in &["-marketplace", "-plugins", "-registry", "-plugin"] {
if let Some(stripped) = repo.strip_suffix(suffix)
&& !stripped.is_empty()
{
return stripped;
}
}
repo
}
pub fn parse_at_spec(spec: &str) -> Result<(&str, &str)> {
let at = spec.rfind('@').ok_or_else(|| {
anyhow::anyhow!("Expected `<plugin>@<marketplace>` or `<owner/repo>` format, got `{spec}`")
})?;
let name = &spec[..at];
let marketplace = &spec[at + 1..];
if name.is_empty() || marketplace.is_empty() {
anyhow::bail!("Plugin name and marketplace name must not be empty in `{spec}`");
}
Ok((name, marketplace))
}
pub fn is_at_spec(spec: &str) -> bool {
spec.contains('@') && !spec.starts_with('@')
}
pub fn is_owner_repo(spec: &str) -> bool {
let parts: Vec<&str> = spec.splitn(2, '/').collect();
parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() && !spec.contains('@')
}
fn validate_marketplace_name(name: &str) -> Result<()> {
if name.is_empty() {
anyhow::bail!("Marketplace name cannot be empty");
}
if name.contains('\0') || name.contains('/') || name.contains('\\') {
anyhow::bail!("Invalid marketplace name: {:?}", name);
}
let valid = name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.');
if !valid {
anyhow::bail!(
"Invalid marketplace name {:?}: only ASCII alphanumeric, '-', '_', '.' allowed",
name
);
}
Ok(())
}
fn validate_owner_repo(owner_repo: &str) -> Result<()> {
let parts: Vec<&str> = owner_repo.splitn(2, '/').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
anyhow::bail!(
"Expected `owner/repo` format, got `{owner_repo}`\n\
Example: `obra/superpowers-marketplace`"
);
}
if parts[1].contains('/') {
anyhow::bail!("repo name must not contain slashes");
}
let valid_component = |s: &str| {
s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
};
if !valid_component(parts[0]) || !valid_component(parts[1]) {
anyhow::bail!(
"Invalid `owner/repo` `{owner_repo}`: only ASCII alphanumeric, '-', '_', '.' allowed"
);
}
Ok(())
}
fn parse_index(marketplace_owner_repo: &str, body: &str) -> Result<Vec<ResolvedPlugin>> {
let raw: serde_json::Value =
serde_json::from_str(body).context("Failed to parse marketplace.json")?;
let plugins = raw
.get("plugins")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("marketplace.json missing `plugins` array"))?;
let mut result = Vec::new();
for p in plugins {
let name = match p.get("name").and_then(|v| v.as_str()) {
Some(n) if !n.is_empty() => n.to_string(),
_ => continue,
};
if validate_marketplace_name(&name).is_err() {
tracing::debug!(
name,
"Skipping plugin with invalid name in marketplace.json"
);
continue;
}
let source = p
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("./")
.to_string();
let raw_desc = p
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let description = {
let mut out = String::with_capacity(raw_desc.len());
let mut chars = raw_desc.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
}
} else {
out.push(ch);
}
}
out
};
let owner_repo = if is_owner_repo(&source) {
validate_owner_repo(&source).with_context(|| {
format!("Invalid source `{source}` in marketplace.json for plugin `{name}`")
})?;
source
} else {
marketplace_owner_repo.to_string()
};
result.push(ResolvedPlugin {
name,
owner_repo,
description,
});
}
Ok(result)
}
fn fetch_url(url: &str) -> Result<String> {
if !url.starts_with("https://raw.githubusercontent.com/") {
anyhow::bail!(
"URL not allowed (must start with https://raw.githubusercontent.com/): {url}"
);
}
let output = std::process::Command::new("curl")
.args([
"--silent",
"--fail",
"--location",
"--max-time",
"15",
"--max-filesize",
"1048576",
"--proto",
"=https",
"--tlsv1.2",
"--",
url,
])
.output()
.context("Failed to run curl")?;
if !output.status.success() {
anyhow::bail!(
"curl failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
}
String::from_utf8(output.stdout).context("Response is not valid UTF-8")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_at_spec_valid() {
let (name, mkt) = parse_at_spec("superpowers@superpowers-marketplace").unwrap();
assert_eq!(name, "superpowers");
assert_eq!(mkt, "superpowers-marketplace");
}
#[test]
fn test_parse_at_spec_no_at() {
assert!(parse_at_spec("owner/repo").is_err());
}
#[test]
fn test_parse_at_spec_empty_name() {
assert!(parse_at_spec("@marketplace").is_err());
}
#[test]
fn test_parse_at_spec_empty_marketplace() {
assert!(parse_at_spec("plugin@").is_err());
}
#[test]
fn test_is_at_spec() {
assert!(is_at_spec("plugin@marketplace"));
assert!(!is_at_spec("owner/repo"));
assert!(!is_at_spec("@marketplace")); }
#[test]
fn test_is_owner_repo() {
assert!(is_owner_repo("owner/repo"));
assert!(!is_owner_repo("owner/repo@mkt"));
assert!(!is_owner_repo("noslash"));
assert!(!is_owner_repo("/repo"));
assert!(!is_owner_repo("owner/"));
}
#[test]
fn test_registry_add_remove() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("marketplaces.json");
std::fs::write(&path, "{}").unwrap();
let mut registry = MarketplaceRegistry {
entries: BTreeMap::new(),
};
assert!(registry.is_empty());
registry
.entries
.insert("test-mkt".to_string(), "owner/repo".to_string());
assert_eq!(registry.get("test-mkt"), Some("owner/repo"));
assert_eq!(registry.len(), 1);
let existed = registry.entries.remove("test-mkt").is_some();
assert!(existed);
assert!(registry.is_empty());
}
#[test]
fn test_validate_marketplace_name_valid() {
assert!(validate_marketplace_name("superpowers-marketplace").is_ok());
assert!(validate_marketplace_name("my.marketplace_1").is_ok());
}
#[test]
fn test_validate_marketplace_name_invalid() {
assert!(validate_marketplace_name("").is_err());
assert!(validate_marketplace_name("has/slash").is_err());
assert!(validate_marketplace_name("has space").is_err());
}
#[test]
fn test_parse_index_owner_repo_source() {
let body = serde_json::json!({
"plugins": [
{ "name": "superpowers", "source": "obra/superpowers", "description": "Cool" }
]
})
.to_string();
let plugins = parse_index("obra/superpowers-marketplace", &body).unwrap();
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "superpowers");
assert_eq!(plugins[0].owner_repo, "obra/superpowers");
}
#[test]
fn test_parse_index_relative_source() {
let body = serde_json::json!({
"plugins": [
{ "name": "epic", "source": "./", "description": "Self-hosted" }
]
})
.to_string();
let plugins = parse_index("epicsagas/epic-harness", &body).unwrap();
assert_eq!(plugins[0].owner_repo, "epicsagas/epic-harness");
}
#[test]
fn test_parse_index_missing_plugins() {
let body = serde_json::json!({ "name": "bad" }).to_string();
assert!(parse_index("owner/repo", &body).is_err());
}
#[test]
fn test_parse_index_rejects_malicious_source() {
let body = serde_json::json!({
"plugins": [
{ "name": "evil", "source": "evil owner/repo", "description": "" }
]
})
.to_string();
assert!(
parse_index("legitimate/marketplace", &body).is_err(),
"source with spaces should be rejected by validate_owner_repo"
);
let body2 = serde_json::json!({
"plugins": [
{ "name": "evil2", "source": "owner/repo?query=1", "description": "" }
]
})
.to_string();
assert!(
parse_index("legitimate/marketplace", &body2).is_err(),
"source with '?' should be rejected by validate_owner_repo"
);
}
#[test]
fn test_validate_owner_repo_strict() {
assert!(validate_owner_repo("owner/repo").is_ok());
assert!(validate_owner_repo("my-org/my_repo.js").is_ok());
assert!(validate_owner_repo("evil @host/repo").is_err());
assert!(validate_owner_repo("owner/repo?query").is_err());
assert!(validate_owner_repo("owner/repo#fragment").is_err());
}
#[test]
fn test_fetch_url_rejects_non_github_raw_urls() {
assert!(fetch_url("http://raw.githubusercontent.com/owner/repo/main/file").is_err());
assert!(fetch_url("https://evil.com/owner/repo/main/file").is_err());
assert!(fetch_url("ftp://raw.githubusercontent.com/owner/repo/main/file").is_err());
assert!(fetch_url("").is_err());
let err = fetch_url("https://example.com/file").unwrap_err();
assert!(err.to_string().contains("URL not allowed"), "got: {err}");
}
#[test]
fn test_fetch_url_accepts_valid_github_raw_prefix() {
let err = fetch_url(
"https://raw.githubusercontent.com/nonexistent-org/nonexistent-repo/main/file.json",
)
.unwrap_err();
assert!(
!err.to_string().contains("URL not allowed"),
"should not be blocked by whitelist: {err}"
);
}
#[test]
fn test_add_rollback_on_save_failure() {
let dir = tempfile::tempdir().unwrap();
let conflict = dir.path().join(".collet");
std::fs::create_dir_all(conflict.join("marketplaces.json")).unwrap();
let mut registry = MarketplaceRegistry {
entries: BTreeMap::new(),
};
let result = registry.add("test-mkt", "owner/repo");
if result.is_err() {
assert!(registry.is_empty(), "add() must roll back on save failure");
}
}
}