Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,27 @@
[package]
license = "Apache-2.0"
name = "xai-grok-plugin-marketplace"
version = "0.1.0"
edition.workspace = true
[dependencies]
dirs = { workspace = true }
dunce = { workspace = true }
fs2 = { workspace = true }
git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2"] }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
xai-tty-utils = { workspace = true }
xai-grok-agent = { workspace = true }
xai-grok-config = { workspace = true }
xai-hooks-plugins-types = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true

View file

@ -0,0 +1,276 @@
//! Parse the CI-generated `plugin-index.json` component catalog.
//!
//! Directory precedence mirrors `index::load_index`:
//! `.grok-plugin/plugin-index.json` (preferred), then
//! `.claude-plugin/plugin-index.json` — but only one filename is probed per
//! directory, and a present-but-unreadable/unparseable preferred catalog does
//! not fall back to the other directory (never serve possibly-stale data when
//! the authoritative file is broken). The catalog is presentation-layer
//! enrichment only: failures degrade to `None` and never fail a marketplace
//! listing.
use std::collections::HashMap;
use std::path::Path;
use serde::Deserialize;
use xai_hooks_plugins_types::PluginComponents;
/// Catalog format version this client understands.
const SUPPORTED_VERSION: u64 = 1;
/// Top-level `plugin-index.json` catalog, keyed by index plugin name.
#[derive(Debug, Clone, Deserialize)]
pub struct PluginCatalog {
pub version: u64,
#[serde(default)]
pub plugins: HashMap<String, CatalogEntry>,
}
/// Per-plugin catalog entry.
#[derive(Debug, Clone, Deserialize)]
pub struct CatalogEntry {
/// Commit the components were extracted from (required for URL-sourced
/// entries; optional for in-repo plugins).
#[serde(default)]
pub sha: Option<String>,
pub components: PluginComponents,
}
impl PluginCatalog {
/// Components for an index entry, gated on the pinned SHA for
/// URL-sourced entries: when `index_sha` is `Some`, the catalog entry
/// must carry an equal `sha` or the components are treated as absent.
pub fn components_for(
&self,
index_name: &str,
index_sha: Option<&str>,
) -> Option<&PluginComponents> {
let entry = self.plugins.get(index_name)?;
if let Some(expected) = index_sha
&& entry.sha.as_deref() != Some(expected)
{
tracing::debug!(
plugin = index_name,
catalog_sha = entry.sha.as_deref().unwrap_or(""),
index_sha = expected,
"marketplace catalog sha mismatch; hiding components"
);
return None;
}
Some(&entry.components)
}
}
/// Load `plugin-index.json` from a marketplace root, or `None` when absent,
/// malformed, or of an unsupported version. A missing file falls through to
/// the next candidate directory; a broken one does not (see module docs).
pub fn load_catalog(marketplace_root: &Path) -> Option<PluginCatalog> {
let candidates = [
marketplace_root
.join(".grok-plugin")
.join("plugin-index.json"),
marketplace_root
.join(".claude-plugin")
.join("plugin-index.json"),
];
for path in &candidates {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
tracing::warn!("failed to read {}: {e}", path.display());
return None;
}
};
let mut catalog: PluginCatalog = match serde_json::from_str(&content) {
Ok(catalog) => catalog,
Err(e) => {
tracing::warn!("failed to parse {}: {e}", path.display());
return None;
}
};
if catalog.version != SUPPORTED_VERSION {
tracing::warn!(
"unsupported plugin catalog version {} in {}",
catalog.version,
path.display()
);
return None;
}
for entry in catalog.plugins.values_mut() {
entry.components.sanitize();
}
return Some(catalog);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn write_catalog(dir: &Path, subdir: &str, content: &str) {
let d = dir.join(subdir);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("plugin-index.json"), content).unwrap();
}
const BASIC: &str = r#"{
"version": 1,
"plugins": {
"superpowers": {
"sha": "61f1903bed7b322c9745f6ba67095bc006de7e63",
"components": {
"skills": [
{ "name": "brainstorming", "description": "Structured ideation" }
],
"commands": [ { "name": "/brainstorm" } ],
"hooks": [ { "name": "PreToolUse", "description": "Bash" } ]
}
}
}
}"#;
#[test]
fn load_catalog_parses_grok_plugin_dir() {
let dir = tempfile::tempdir().unwrap();
write_catalog(dir.path(), ".grok-plugin", BASIC);
let catalog = load_catalog(dir.path()).unwrap();
let components = catalog.components_for("superpowers", None).unwrap();
assert_eq!(components.skills.len(), 1);
assert_eq!(components.skills[0].name, "brainstorming");
assert_eq!(
components.skills[0].description.as_deref(),
Some("Structured ideation")
);
assert_eq!(components.commands[0].name, "/brainstorm");
assert_eq!(components.hooks[0].name, "PreToolUse");
assert!(components.agents.is_empty());
}
#[test]
fn load_catalog_falls_back_to_claude_plugin_dir() {
let dir = tempfile::tempdir().unwrap();
write_catalog(dir.path(), ".claude-plugin", BASIC);
assert!(load_catalog(dir.path()).is_some());
}
#[test]
fn load_catalog_prefers_grok_dir_over_claude_dir() {
let dir = tempfile::tempdir().unwrap();
write_catalog(dir.path(), ".grok-plugin", BASIC);
write_catalog(
dir.path(),
".claude-plugin",
r#"{"version": 1, "plugins": {"other": {"components": {}}}}"#,
);
let catalog = load_catalog(dir.path()).unwrap();
assert!(catalog.plugins.contains_key("superpowers"));
assert!(!catalog.plugins.contains_key("other"));
}
#[test]
fn load_catalog_missing_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert!(load_catalog(dir.path()).is_none());
}
#[test]
fn load_catalog_malformed_returns_none() {
let dir = tempfile::tempdir().unwrap();
write_catalog(dir.path(), ".grok-plugin", "not json");
assert!(load_catalog(dir.path()).is_none());
}
#[test]
fn load_catalog_broken_preferred_does_not_fall_back() {
let dir = tempfile::tempdir().unwrap();
write_catalog(dir.path(), ".grok-plugin", "not json");
write_catalog(dir.path(), ".claude-plugin", BASIC);
assert!(load_catalog(dir.path()).is_none());
}
#[test]
fn load_catalog_unsupported_version_returns_none() {
let dir = tempfile::tempdir().unwrap();
write_catalog(
dir.path(),
".grok-plugin",
r#"{"version": 2, "plugins": {}}"#,
);
assert!(load_catalog(dir.path()).is_none());
}
#[test]
fn load_catalog_ignores_unknown_fields() {
let dir = tempfile::tempdir().unwrap();
write_catalog(
dir.path(),
".grok-plugin",
r#"{
"$schema": "https://x.ai/grok/plugin-index.schema.json",
"version": 1,
"generatedAt": "2026-06-09T12:00:00Z",
"plugins": {
"p": { "components": { "skills": [{"name": "s", "extra": 1}] }, "future": true }
}
}"#,
);
let catalog = load_catalog(dir.path()).unwrap();
assert_eq!(
catalog.components_for("p", None).unwrap().skills[0].name,
"s"
);
}
#[test]
fn load_catalog_sanitizes_entries() {
let dir = tempfile::tempdir().unwrap();
write_catalog(
dir.path(),
".grok-plugin",
r#"{
"version": 1,
"plugins": {
"p": { "components": { "skills": [{"name": "a\u001b[31mb", "description": "x\u0007y"}] } }
}
}"#,
);
let catalog = load_catalog(dir.path()).unwrap();
let components = catalog.components_for("p", None).unwrap();
assert_eq!(components.skills[0].name, "a[31mb");
assert_eq!(components.skills[0].description.as_deref(), Some("xy"));
}
#[test]
fn components_for_gates_on_sha() {
let dir = tempfile::tempdir().unwrap();
write_catalog(dir.path(), ".grok-plugin", BASIC);
let catalog = load_catalog(dir.path()).unwrap();
let pinned = "61f1903bed7b322c9745f6ba67095bc006de7e63";
assert!(
catalog
.components_for("superpowers", Some(pinned))
.is_some()
);
assert!(
catalog
.components_for("superpowers", Some("deadbeef"))
.is_none()
);
assert!(catalog.components_for("unknown", None).is_none());
}
#[test]
fn components_for_requires_catalog_sha_when_index_pinned() {
let dir = tempfile::tempdir().unwrap();
write_catalog(
dir.path(),
".grok-plugin",
r#"{"version": 1, "plugins": {"p": {"components": {"skills": [{"name": "s"}]}}}}"#,
);
let catalog = load_catalog(dir.path()).unwrap();
assert!(catalog.components_for("p", Some("abc123")).is_none());
assert!(catalog.components_for("p", None).is_some());
}
}

View file

@ -0,0 +1,417 @@
//! Parse marketplace sources from `~/.grok/config.toml`.
//!
//! Expected format:
//! ```toml
//! [[marketplace.sources]]
//! name = "xAI Official"
//! git = "https://github.com/xai-org/xai-plugin-marketplace.git"
//!
//! [[marketplace.sources]]
//! name = "Local Dev"
//! path = "~/dev/my-plugins"
//! ```
use std::path::PathBuf;
use serde::Deserialize;
use crate::types::{MarketplaceSource, SourceKind};
/// Raw TOML source entry.
#[derive(Debug, serde::Deserialize)]
struct RawSource {
name: String,
#[serde(default)]
path: Option<String>,
#[serde(default)]
git: Option<String>,
#[serde(default)]
branch: Option<String>,
}
/// Reads `[marketplace].sources` array. Returns empty vec if not configured.
pub fn load_sources(config: &toml::Value) -> Vec<MarketplaceSource> {
let Some(marketplace) = config.get("marketplace") else {
return Vec::new();
};
let Some(sources_val) = marketplace.get("sources") else {
return Vec::new();
};
let raw_sources: Vec<RawSource> = match serde_json::to_value(sources_val)
.ok()
.and_then(|v| serde_json::from_value(v).ok())
{
Some(s) => s,
None => {
// Try direct toml deserialization.
match sources_val.clone().try_into::<Vec<RawSource>>() {
Ok(s) => s,
Err(e) => {
tracing::warn!("failed to parse marketplace.sources: {e}");
return Vec::new();
}
}
}
};
raw_sources
.into_iter()
.filter_map(|raw| {
let kind = if let Some(git_url) = raw.git {
SourceKind::Git {
url: git_url,
branch: raw.branch,
}
} else if let Some(path_str) = raw.path {
// Expand ~ to home directory.
let expanded = if let Some(rest) = path_str.strip_prefix('~') {
dirs::home_dir()
.map(|h| {
h.join(rest.strip_prefix('/').unwrap_or(rest))
.to_string_lossy()
.to_string()
})
.unwrap_or(path_str.clone())
} else {
path_str
};
SourceKind::Local {
path: PathBuf::from(expanded),
}
} else {
tracing::warn!(
"marketplace source '{}' has neither 'path' nor 'git'",
raw.name
);
return None;
};
Some(MarketplaceSource {
name: raw.name,
kind,
})
})
.collect()
}
/// Source descriptor from settings JSON.
///
/// Discriminated by the inner `"source"` field:
/// - `{ "source": "git", "url": "..." }`
/// - `{ "source": "github", "repo": "owner/repo" }`
/// - `{ "source": "local", "path": "..." }`
#[derive(Debug, serde::Deserialize)]
#[serde(tag = "source", rename_all = "lowercase")]
enum SettingsSource {
Git { url: String },
Github { repo: String },
Local { path: String },
}
/// A single entry under `extraKnownMarketplaces` or `known_marketplaces.json`.
#[derive(Debug, serde::Deserialize)]
struct SettingsEntry {
source: SettingsSource,
}
/// Extract marketplace entries from a JSON object map (name -> config).
fn extract_marketplace_entries(
marketplaces: &serde_json::Map<String, serde_json::Value>,
seen_urls: &mut std::collections::HashSet<String>,
sources: &mut Vec<MarketplaceSource>,
) {
for (name, config) in marketplaces {
let entry: SettingsEntry = match SettingsEntry::deserialize(config) {
Ok(e) => e,
Err(_) => continue,
};
let kind = match entry.source {
SettingsSource::Git { url } => {
if !seen_urls.insert(url.clone()) {
continue;
}
SourceKind::Git { url, branch: None }
}
SettingsSource::Github { repo } => {
let url = format!("https://github.com/{repo}.git");
if !seen_urls.insert(url.clone()) {
continue;
}
SourceKind::Git { url, branch: None }
}
SettingsSource::Local { path: path_str } => {
let expanded = if let Some(rest) = path_str.strip_prefix('~') {
dirs::home_dir()
.map(|h| {
h.join(rest.strip_prefix('/').unwrap_or(rest))
.to_string_lossy()
.to_string()
})
.unwrap_or(path_str)
} else {
path_str
};
SourceKind::Local {
path: PathBuf::from(expanded),
}
}
};
sources.push(MarketplaceSource {
name: name.clone(),
kind,
});
}
}
/// Loads additional marketplace sources from `settings.json` (`extraKnownMarketplaces`)
/// and `known_marketplaces.json` files under `~/.grok/` and `~/.claude/`.
pub fn load_extra_sources_from_settings(existing: &[MarketplaceSource]) -> Vec<MarketplaceSource> {
let roots: Vec<PathBuf> = [
xai_grok_config::user_grok_home(),
dirs::home_dir().map(|h| h.join(".claude")),
]
.into_iter()
.flatten()
.collect();
load_extra_sources_from_settings_in(existing, &roots)
}
/// Like [`load_extra_sources_from_settings`] but reads from explicit `roots`
/// instead of `~/.grok`/`~/.claude`. Each root is checked for
/// `settings.local.json`, `settings.json` (`extraKnownMarketplaces` key), and
/// `plugins/known_marketplaces.json`. Lets callers (e.g. first-run auto-register
/// tests) stay isolated from the developer's real home dir.
pub fn load_extra_sources_from_settings_in(
existing: &[MarketplaceSource],
roots: &[PathBuf],
) -> Vec<MarketplaceSource> {
let mut sources = Vec::new();
// Seed seen_urls with URLs already in config.toml sources to avoid duplicates.
let mut seen_urls: std::collections::HashSet<String> = existing
.iter()
.filter_map(|s| match &s.kind {
SourceKind::Git { url, .. } => Some(url.clone()),
_ => None,
})
.collect();
// Order matters: all settings files across roots, then all
// known_marketplaces.json across roots — preserves the first-wins URL dedup
// in extract_marketplace_entries. Don't reorder without auditing UI impact.
for root in roots {
for settings_name in ["settings.local.json", "settings.json"] {
let path = root.join(settings_name);
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let json: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "malformed settings.json");
continue;
}
};
let Some(marketplaces) = json
.get("extraKnownMarketplaces")
.and_then(|v| v.as_object())
else {
continue;
};
extract_marketplace_entries(marketplaces, &mut seen_urls, &mut sources);
}
}
for root in roots {
let known = root.join("plugins").join("known_marketplaces.json");
let content = match std::fs::read_to_string(&known) {
Ok(c) => c,
Err(_) => continue,
};
let json: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = %known.display(), error = %e, "malformed known_marketplaces.json");
continue;
}
};
// known_marketplaces.json is a top-level object with the same shape as
// extraKnownMarketplaces (map of name → { source, ... }).
let Some(marketplaces) = json.as_object() else {
continue;
};
extract_marketplace_entries(marketplaces, &mut seen_urls, &mut sources);
}
sources
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_local_source() {
let config: toml::Value = toml::from_str(
r#"
[[marketplace.sources]]
name = "Local Dev"
path = "/home/user/plugins"
"#,
)
.unwrap();
let sources = load_sources(&config);
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].name, "Local Dev");
assert!(
matches!(&sources[0].kind, SourceKind::Local { path } if path == &PathBuf::from("/home/user/plugins"))
);
}
#[test]
fn parse_git_source() {
let config: toml::Value = toml::from_str(
r#"
[[marketplace.sources]]
name = "xAI Official"
git = "https://github.com/xai-org/xai-plugin-marketplace.git"
branch = "main"
"#,
)
.unwrap();
let sources = load_sources(&config);
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].name, "xAI Official");
assert!(
matches!(&sources[0].kind, SourceKind::Git { url, branch } if url.contains("xai-org") && branch.as_deref() == Some("main"))
);
}
#[test]
fn parse_mixed_sources() {
let config: toml::Value = toml::from_str(
r#"
[[marketplace.sources]]
name = "Local"
path = "/tmp/plugins"
[[marketplace.sources]]
name = "Remote"
git = "https://example.com/plugins.git"
"#,
)
.unwrap();
let sources = load_sources(&config);
assert_eq!(sources.len(), 2);
}
#[test]
fn empty_config_returns_empty() {
let config: toml::Value = toml::from_str("").unwrap();
assert!(load_sources(&config).is_empty());
}
#[test]
fn missing_sources_key_returns_empty() {
let config: toml::Value = toml::from_str("[marketplace]\n").unwrap();
assert!(load_sources(&config).is_empty());
}
#[test]
fn source_without_path_or_git_skipped() {
let config: toml::Value = toml::from_str(
r#"
[[marketplace.sources]]
name = "Bad"
"#,
)
.unwrap();
assert!(load_sources(&config).is_empty());
}
#[test]
fn extract_github_source() {
let json: serde_json::Value = serde_json::from_str(
r#"{
"my-marketplace": {
"source": {
"source": "github",
"repo": "anthropics/claude-plugins-official"
},
"installLocation": "/tmp/test",
"lastUpdated": "2026-04-10T00:00:00Z",
"autoUpdate": true
}
}"#,
)
.unwrap();
let marketplaces = json.as_object().unwrap();
let mut seen = std::collections::HashSet::new();
let mut sources = Vec::new();
extract_marketplace_entries(marketplaces, &mut seen, &mut sources);
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].name, "my-marketplace");
assert!(
matches!(&sources[0].kind, SourceKind::Git { url, .. } if url == "https://github.com/anthropics/claude-plugins-official.git")
);
}
#[test]
fn extract_git_source_with_url() {
let json: serde_json::Value = serde_json::from_str(
r#"{
"my-git-marketplace": {
"source": {
"source": "git",
"url": "git@github.com:org/repo.git"
}
}
}"#,
)
.unwrap();
let marketplaces = json.as_object().unwrap();
let mut seen = std::collections::HashSet::new();
let mut sources = Vec::new();
extract_marketplace_entries(marketplaces, &mut seen, &mut sources);
assert_eq!(sources.len(), 1);
assert!(
matches!(&sources[0].kind, SourceKind::Git { url, .. } if url == "git@github.com:org/repo.git")
);
}
#[test]
fn extract_deduplicates_urls() {
let json: serde_json::Value = serde_json::from_str(
r#"{
"a": {
"source": { "source": "github", "repo": "org/repo" }
},
"b": {
"source": { "source": "github", "repo": "org/repo" }
}
}"#,
)
.unwrap();
let marketplaces = json.as_object().unwrap();
let mut seen = std::collections::HashSet::new();
let mut sources = Vec::new();
extract_marketplace_entries(marketplaces, &mut seen, &mut sources);
assert_eq!(sources.len(), 1);
}
#[test]
fn extract_skips_entry_without_source() {
let json: serde_json::Value = serde_json::from_str(
r#"{
"no-source": {
"installLocation": "/tmp/test"
}
}"#,
)
.unwrap();
let marketplaces = json.as_object().unwrap();
let mut seen = std::collections::HashSet::new();
let mut sources = Vec::new();
extract_marketplace_entries(marketplaces, &mut seen, &mut sources);
assert!(sources.is_empty());
}
}

View file

@ -0,0 +1,20 @@
//! Error types for the marketplace crate.
use thiserror::Error;
/// Errors that can occur during marketplace operations.
#[derive(Debug, Error)]
pub enum MarketplaceError {
#[error("IO error at {path}: {source}")]
Io {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("JSON error: {0}")]
Json(String),
#[error("Git error: {0}")]
Git(String),
#[error("{0}")]
Other(String),
}

View file

@ -0,0 +1,508 @@
//! Git marketplace source support.
//!
//! Provides persistent caching of git marketplace repos.
//! Cache root: `~/.grok/marketplace-cache/<url-hash>/`
use std::fs::{File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use fs2::FileExt;
/// Default TTL for marketplace cache freshness (5 minutes).
const CACHE_TTL: Duration = Duration::from_secs(5 * 60);
const LOCK_TIMEOUT: Duration = Duration::from_secs(30);
const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncMode {
UseTtl,
Force,
}
pub struct SourceCacheLease {
pub path: PathBuf,
lock_file: File,
}
impl Drop for SourceCacheLease {
fn drop(&mut self) {
let _ = self.lock_file.unlock();
}
}
/// Sync a git marketplace source to the persistent cache.
///
/// Returns the path to the cached repo on success.
pub fn sync_source_cache(
url: &str,
branch: Option<&str>,
cache_root: &Path,
) -> Result<PathBuf, String> {
let lease = sync_source_cache_with_mode(url, branch, cache_root, SyncMode::UseTtl)?;
Ok(lease.path.clone())
}
pub fn force_sync_source_cache(
url: &str,
branch: Option<&str>,
cache_root: &Path,
) -> Result<PathBuf, String> {
let lease = sync_source_cache_with_mode(url, branch, cache_root, SyncMode::Force)?;
Ok(lease.path.clone())
}
pub fn sync_source_cache_with_mode(
url: &str,
branch: Option<&str>,
cache_root: &Path,
mode: SyncMode,
) -> Result<SourceCacheLease, String> {
let hash = cache_hash(url);
let cache_dir = cache_root.join(&hash);
let start = Instant::now();
std::fs::create_dir_all(cache_root).map_err(|e| format!("failed to create cache root: {e}"))?;
let lock_file = acquire_cache_lock(&cache_root.join(format!("{hash}.lock")), LOCK_TIMEOUT)?;
let result = sync_cache_locked(url, branch, &cache_dir, mode);
match &result {
Ok(()) => {
tracing::debug!(mode = ?mode, elapsed_ms = start.elapsed().as_millis(), "marketplace cache sync complete")
}
Err(error) => {
tracing::warn!(mode = ?mode, elapsed_ms = start.elapsed().as_millis(), error = %error, "marketplace cache sync failed")
}
}
result?;
Ok(SourceCacheLease {
path: cache_dir,
lock_file,
})
}
fn sync_cache_locked(
url: &str,
branch: Option<&str>,
cache_dir: &Path,
mode: SyncMode,
) -> Result<(), String> {
if cache_dir.join(".git").exists() {
if mode == SyncMode::UseTtl && is_cache_fresh(cache_dir) {
return Ok(());
}
fetch_reset_cached_repo(cache_dir, branch).or_else(|e| {
tracing::warn!(error = %e, "git fetch/reset failed, re-cloning marketplace cache");
reclone_repo(url, branch, cache_dir)
})
} else {
clone_repo(url, branch, cache_dir)
}
}
fn acquire_cache_lock(lock_path: &Path, timeout: Duration) -> Result<File, String> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(lock_path)
.map_err(|e| format!("failed to open cache lock {}: {e}", lock_path.display()))?;
let deadline = Instant::now() + timeout;
loop {
match file.try_lock_exclusive() {
Ok(()) => return Ok(file),
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
if Instant::now() >= deadline {
return Err(format!(
"cache lock timeout after {}s for {}",
timeout.as_secs(),
lock_path.display()
));
}
std::thread::sleep(LOCK_POLL_INTERVAL);
}
Err(e) => return Err(format!("failed to lock cache {}: {e}", lock_path.display())),
}
}
}
/// Check if the cache was fetched recently enough to skip fetching.
fn is_cache_fresh(cache_dir: &Path) -> bool {
let fetch_head = cache_dir.join(".git").join("FETCH_HEAD");
match std::fs::metadata(&fetch_head) {
Ok(meta) => meta
.modified()
.ok()
.and_then(|mtime| mtime.elapsed().ok())
.is_some_and(|age| age < CACHE_TTL),
Err(_) => false,
}
}
/// Get the default cache root directory.
pub fn default_cache_root() -> PathBuf {
xai_grok_config::grok_home().join("marketplace-cache")
}
/// Deterministic hash for a URL (used as cache directory name).
fn cache_hash(url: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
url.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
/// Clone a git repo with depth 1.
fn clone_repo(url: &str, branch: Option<&str>, dest: &Path) -> Result<(), String> {
// Try git2 first.
match clone_with_git2(url, branch, dest) {
Ok(()) => return Ok(()),
Err(e) => {
tracing::debug!("git2 clone failed, trying CLI: {e}");
// Clean up partial clone.
let _ = std::fs::remove_dir_all(dest);
}
}
// Fallback to git CLI.
clone_with_cli(url, branch, dest)
}
fn reclone_repo(url: &str, branch: Option<&str>, dest: &Path) -> Result<(), String> {
let parent = dest
.parent()
.ok_or_else(|| format!("cache path has no parent: {}", dest.display()))?;
let name = dest
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| format!("cache path has no file name: {}", dest.display()))?;
let suffix = format!("{}-{}", std::process::id(), unique_reclone_suffix());
let temp_dest = parent.join(format!(".{name}.reclone-{suffix}"));
let backup_dest = parent.join(format!(".{name}.backup-{suffix}"));
let _ = std::fs::remove_dir_all(&temp_dest);
let _ = std::fs::remove_dir_all(&backup_dest);
clone_repo(url, branch, &temp_dest).inspect_err(|_| {
let _ = std::fs::remove_dir_all(&temp_dest);
})?;
let had_existing = dest.exists();
if had_existing {
std::fs::rename(dest, &backup_dest)
.map_err(|e| format!("failed to move existing cache aside: {e}"))?;
}
match std::fs::rename(&temp_dest, dest) {
Ok(()) => {
if had_existing {
let _ = std::fs::remove_dir_all(&backup_dest);
}
Ok(())
}
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dest);
if had_existing && let Err(restore_err) = std::fs::rename(&backup_dest, dest) {
return Err(format!(
"failed to install recloned cache: {e}; failed to restore original cache: {restore_err}; original cache preserved at {}",
backup_dest.display()
));
}
Err(format!("failed to install recloned cache: {e}"))
}
}
}
fn unique_reclone_suffix() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
}
fn clone_with_git2(url: &str, branch: Option<&str>, dest: &Path) -> Result<(), String> {
let mut fetch_opts = git2::FetchOptions::new();
fetch_opts.depth(1);
let mut builder = git2::build::RepoBuilder::new();
builder.fetch_options(fetch_opts);
if let Some(b) = branch {
builder.branch(b);
}
builder
.clone(url, dest)
.map_err(|e| format!("git2 clone failed: {e}"))?;
Ok(())
}
/// Environment variables set on every git command to suppress interactive prompts.
pub const GIT_AUTH_SUPPRESSION_ENVS: [(&str, &str); 4] = [
("GIT_TERMINAL_PROMPT", "0"),
("GIT_ASKPASS", ""),
("GIT_LFS_SKIP_SMUDGE", "1"),
("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"),
];
/// Git command with auth/LFS/SSH prompt suppression and `--no-optional-locks`.
pub fn git_command() -> std::process::Command {
let mut cmd = std::process::Command::new("git");
xai_tty_utils::detach_std_command(&mut cmd);
cmd.stdin(std::process::Stdio::null());
cmd.envs(xai_tty_utils::pager_env());
for &(key, val) in &GIT_AUTH_SUPPRESSION_ENVS {
cmd.env(key, val);
}
cmd.arg("--no-optional-locks");
cmd
}
fn clone_with_cli(url: &str, branch: Option<&str>, dest: &Path) -> Result<(), String> {
let mut cmd = git_command();
cmd.args(["clone", "--depth", "1"]);
if let Some(b) = branch {
cmd.args(["--branch", b]);
}
cmd.arg(url).arg(dest.as_os_str());
let output = cmd
.output()
.map_err(|e| format!("failed to run git clone: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git clone failed: {stderr}"));
}
Ok(())
}
fn fetch_reset_cached_repo(repo_dir: &Path, branch: Option<&str>) -> Result<(), String> {
let branch_arg = branch.unwrap_or("HEAD");
let fetch_output = git_command()
.current_dir(repo_dir)
.args(["fetch", "--depth", "1", "origin", branch_arg])
.output()
.map_err(|e| format!("failed to run git fetch: {e}"))?;
if !fetch_output.status.success() {
let stderr = String::from_utf8_lossy(&fetch_output.stderr);
return Err(format!("git fetch failed: {stderr}"));
}
let checkout_output = git_command()
.current_dir(repo_dir)
.args(["checkout", "--detach", "FETCH_HEAD"])
.output()
.map_err(|e| format!("failed to run git checkout: {e}"))?;
if !checkout_output.status.success() {
let stderr = String::from_utf8_lossy(&checkout_output.stderr);
return Err(format!("git checkout failed: {stderr}"));
}
let reset_output = git_command()
.current_dir(repo_dir)
.args(["reset", "--hard", "FETCH_HEAD"])
.output()
.map_err(|e| format!("failed to run git reset: {e}"))?;
if !reset_output.status.success() {
let stderr = String::from_utf8_lossy(&reset_output.stderr);
return Err(format!("git reset failed: {stderr}"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_hash_is_deterministic() {
let url = "https://github.com/xai-org/xai-plugin-marketplace.git";
let h1 = cache_hash(url);
let h2 = cache_hash(url);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 16);
}
#[test]
fn cache_hash_differs_for_different_urls() {
let h1 = cache_hash("https://github.com/a/b.git");
let h2 = cache_hash("https://github.com/c/d.git");
assert_ne!(h1, h2);
}
#[test]
fn default_cache_root_under_grok() {
let root = default_cache_root();
assert!(root.to_string_lossy().contains("marketplace-cache"));
}
#[test]
fn sync_source_cache_uses_ttl_by_default() {
if !git_available() {
eprintln!("skipping git-dependent test: git binary not available");
return;
}
let remote = tempfile::tempdir().unwrap();
init_remote_repo(remote.path());
let cache_root = tempfile::tempdir().unwrap();
let url = remote.path().to_string_lossy();
let cache_dir = sync_source_cache(&url, Some("main"), cache_root.path()).unwrap();
let fetch_head = cache_dir.join(".git").join("FETCH_HEAD");
std::fs::write(&fetch_head, "ttl-sentinel").unwrap();
let second_cache_dir = sync_source_cache(&url, Some("main"), cache_root.path()).unwrap();
assert_eq!(second_cache_dir, cache_dir);
assert_eq!(
std::fs::read_to_string(&fetch_head).unwrap(),
"ttl-sentinel"
);
}
#[test]
fn force_sync_source_cache_ignores_fresh_fetch_head() {
if !git_available() {
eprintln!("skipping git-dependent test: git binary not available");
return;
}
let remote = tempfile::tempdir().unwrap();
init_remote_repo(remote.path());
let cache_root = tempfile::tempdir().unwrap();
let url = remote.path().to_string_lossy();
let cache_dir = sync_source_cache(&url, Some("main"), cache_root.path()).unwrap();
let first_head = current_head(&cache_dir);
add_commit(remote.path(), "second.txt", "second");
let forced_cache_dir =
force_sync_source_cache(&url, Some("main"), cache_root.path()).unwrap();
assert_eq!(forced_cache_dir, cache_dir);
assert_ne!(current_head(&cache_dir), first_head);
}
#[test]
fn cache_lease_blocks_concurrent_reclone_during_scan() {
let cache_root = tempfile::tempdir().unwrap();
let url = "https://example.com/repo.git";
let hash = cache_hash(url);
std::fs::create_dir_all(cache_root.path()).unwrap();
let lock_path = cache_root.path().join(format!("{hash}.lock"));
let lease = SourceCacheLease {
path: cache_root.path().join(&hash),
lock_file: acquire_cache_lock(&lock_path, Duration::from_millis(1)).unwrap(),
};
let start = Instant::now();
let err = acquire_cache_lock(&lock_path, Duration::from_millis(50)).unwrap_err();
assert!(err.contains("cache lock timeout"));
assert!(start.elapsed() >= Duration::from_millis(50));
drop(lease);
let _lock = acquire_cache_lock(&lock_path, Duration::from_millis(1)).unwrap();
}
#[test]
fn force_sync_source_cache_preserves_cache_when_reclone_fails() {
if !git_available() {
eprintln!("skipping git-dependent test: git binary not available");
return;
}
let remote = tempfile::tempdir().unwrap();
init_remote_repo(remote.path());
let cache_root = tempfile::tempdir().unwrap();
let url = remote.path().to_string_lossy();
let cache_dir = sync_source_cache(&url, Some("main"), cache_root.path()).unwrap();
std::fs::remove_dir_all(cache_dir.join(".git").join("objects")).unwrap();
std::fs::remove_dir_all(remote.path()).unwrap();
let result = force_sync_source_cache(&url, Some("main"), cache_root.path());
assert!(result.is_err());
assert!(cache_dir.exists());
assert_eq!(
std::fs::read_to_string(cache_dir.join("file.txt")).unwrap(),
"initial"
);
}
#[test]
fn force_sync_source_cache_reclones_corrupt_cache() {
if !git_available() {
eprintln!("skipping git-dependent test: git binary not available");
return;
}
let remote = tempfile::tempdir().unwrap();
init_remote_repo(remote.path());
let cache_root = tempfile::tempdir().unwrap();
let url = remote.path().to_string_lossy();
let cache_dir = sync_source_cache(&url, Some("main"), cache_root.path()).unwrap();
std::fs::remove_dir_all(cache_dir.join(".git").join("objects")).unwrap();
let forced_cache_dir =
force_sync_source_cache(&url, Some("main"), cache_root.path()).unwrap();
assert_eq!(forced_cache_dir, cache_dir);
assert!(cache_dir.join(".git").join("objects").exists());
assert_eq!(
std::fs::read_to_string(cache_dir.join("file.txt")).unwrap(),
"initial"
);
}
fn init_remote_repo(path: &Path) {
run_git(path, &["init", "--initial-branch", "main"]);
run_git(path, &["config", "user.email", "test@example.com"]);
run_git(path, &["config", "user.name", "Test User"]);
add_commit(path, "file.txt", "initial");
}
fn add_commit(repo: &Path, file: &str, contents: &str) {
std::fs::write(repo.join(file), contents).unwrap();
run_git(repo, &["add", file]);
run_git(repo, &["commit", "-m", file]);
}
fn current_head(repo: &Path) -> String {
let output = git_command()
.current_dir(repo)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
assert!(output.status.success());
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
fn git_available() -> bool {
let git_bin = std::env::var("GIT_BIN_PATH").unwrap_or_else(|_| "git".to_string());
std::process::Command::new(git_bin)
.arg("--version")
.stdin(std::process::Stdio::null())
.output()
.is_ok_and(|output| output.status.success())
}
fn run_git(dir: &Path, args: &[&str]) {
let git_bin = std::env::var("GIT_BIN_PATH").unwrap_or_else(|_| "git".to_string());
let output = std::process::Command::new(git_bin)
.current_dir(dir)
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", "")
.env("GIT_LFS_SKIP_SMUDGE", "1")
.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes")
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
}

View file

@ -0,0 +1,588 @@
//! Parse repo-level marketplace index.
//!
//! Catalog file lookup, in order: `.grok-plugin/marketplace.json` (preferred),
//! `.grok-plugin/plugin.json`, `.claude-plugin/marketplace.json`,
//! `.claude-plugin/plugin.json` (alternate layout compatibility).
//!
//! When present, an index is the preferred browse source — faster than
//! filesystem scanning and provides curated metadata (category, tags, homepage).
use std::path::Path;
use serde::Deserialize;
use crate::types::MarketplaceRelativePath;
/// Top-level marketplace index.
#[derive(Debug, Clone, Deserialize)]
pub struct MarketplaceIndex {
/// Marketplace display name.
pub name: String,
/// Marketplace description.
#[serde(default)]
pub description: Option<String>,
/// Owner info.
#[serde(default)]
pub owner: Option<IndexOwner>,
/// Indexed plugins.
#[serde(default)]
pub plugins: Vec<IndexEntry>,
}
/// Marketplace owner.
#[derive(Debug, Clone, Deserialize)]
pub struct IndexOwner {
pub name: String,
#[serde(default)]
pub email: Option<String>,
}
/// A single plugin entry in the marketplace index.
#[derive(Debug, Clone, Deserialize)]
pub struct IndexEntry {
/// Plugin name.
pub name: String,
/// Version string (from index metadata).
#[serde(default)]
pub version: Option<String>,
/// Human-readable description.
#[serde(default)]
pub description: Option<String>,
/// Category (e.g., "development", "productivity", "design").
#[serde(default)]
pub category: Option<String>,
/// Author info.
#[serde(default)]
pub author: Option<IndexAuthor>,
/// Source location within the marketplace repo.
#[serde(default)]
pub source: Option<IndexSource>,
/// Homepage URL.
#[serde(default)]
pub homepage: Option<String>,
/// Tags/keywords.
#[serde(default)]
pub tags: Vec<String>,
/// Matcher keywords used to associate the plugin with a user request.
#[serde(default)]
pub keywords: Vec<String>,
/// Matcher domains used to associate the plugin with a user request.
#[serde(default)]
pub domains: Vec<String>,
}
/// Author in an index entry.
#[derive(Debug, Clone, Deserialize)]
pub struct IndexAuthor {
pub name: String,
}
/// Source location in an index entry.
///
/// Accepts multiple formats:
/// - Object: `{ "type": "local", "path": "./plugins/foo" }`
/// - Object: `{ "source": "url", "url": "https://github.com/...", "ref": "main" }`
/// - Object: `{ "source": "url", "url": "https://...", "sha": "61f1903b..." }` (recommended for vendor pins)
/// - String: `"./plugins/foo"` (shorthand used by some marketplaces)
#[derive(Debug, Clone)]
pub struct IndexSource {
pub r#type: Option<String>,
pub path: Option<String>,
/// Remote git URL (used by superpowers-style marketplaces).
pub url: Option<String>,
/// Git ref (branch/tag/commit) for URL sources.
pub git_ref: Option<String>,
pub git_sha: Option<String>,
}
impl IndexSource {
/// Whether this source points to a remote git URL.
pub fn is_remote(&self) -> bool {
self.url.is_some()
}
}
impl<'de> Deserialize<'de> for IndexSource {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct IndexSourceVisitor;
impl<'de> de::Visitor<'de> for IndexSourceVisitor {
type Value = IndexSource;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(r#"a string path or object with "path" or "url" field"#)
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<IndexSource, E> {
Ok(IndexSource {
r#type: Some("local".into()),
path: Some(v.to_owned()),
url: None,
git_ref: None,
git_sha: None,
})
}
fn visit_map<M: de::MapAccess<'de>>(self, mut map: M) -> Result<IndexSource, M::Error> {
let mut r#type = None;
let mut source_field: Option<String> = None;
let mut path = None;
let mut url = None;
let mut git_ref = None;
let mut git_sha = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"type" => r#type = map.next_value()?,
// Superpowers format: `"source": "url"` as type discriminator.
"source" => source_field = map.next_value()?,
"path" => path = map.next_value()?,
"url" => url = map.next_value()?,
"ref" => git_ref = map.next_value()?,
"sha" => git_sha = map.next_value()?,
_ => {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
}
}
// Normalize: if `source` was used instead of `type`, adopt it.
if r#type.is_none() {
r#type = source_field;
}
Ok(IndexSource {
r#type,
path,
url,
git_ref,
git_sha,
})
}
}
deserializer.deserialize_any(IndexSourceVisitor)
}
}
impl IndexEntry {
/// Resolve the plugin path relative to the marketplace root.
/// Returns `None` for remote URL sources (use `remote_url()` instead).
pub fn resolved_path(&self) -> Option<String> {
Some(self.resolved_marketplace_path().ok()?.as_str().to_string())
}
pub fn resolved_marketplace_path(&self) -> Result<MarketplaceRelativePath, String> {
let Some(source) = self.source.as_ref() else {
return Err("missing source".into());
};
if source.is_remote() {
return Err("remote source has no local path".into());
}
let path = source
.path
.as_ref()
.ok_or_else(|| "missing source path".to_string())?;
MarketplaceRelativePath::parse(path).map_err(|e| e.to_string())
}
/// Get the remote git URL for URL-sourced plugins.
pub fn remote_url(&self) -> Option<(&str, Option<&str>)> {
let source = self.source.as_ref()?;
let url = source.url.as_deref()?;
Some((url, source.git_ref.as_deref()))
}
pub fn remote_sha(&self) -> Option<&str> {
self.source.as_ref()?.git_sha.as_deref()
}
pub fn remote_subdir(&self) -> Option<&str> {
let source = self.source.as_ref()?;
if !source.is_remote() {
return None;
}
source.path.as_deref()
}
}
/// Attempt to load the marketplace index from the given root directory.
///
/// Checks (in order):
/// 1. `.grok-plugin/marketplace.json` (preferred xAI convention)
/// 2. `.grok-plugin/plugin.json`
/// 3. `.claude-plugin/marketplace.json` (alternate layout compatibility)
/// 4. `.claude-plugin/plugin.json`
///
/// Returns `None` if no file exists. Returns `Err` if a file exists
/// but can't be parsed.
pub fn load_index(marketplace_root: &Path) -> Result<Option<MarketplaceIndex>, String> {
let grok_dir = marketplace_root.join(".grok-plugin");
let claude_dir = marketplace_root.join(".claude-plugin");
let candidates = [
grok_dir.join("marketplace.json"),
grok_dir.join("plugin.json"),
claude_dir.join("marketplace.json"),
claude_dir.join("plugin.json"),
];
for index_path in &candidates {
if !index_path.exists() {
continue;
}
let content = std::fs::read_to_string(index_path)
.map_err(|e| format!("failed to read {}: {e}", index_path.display()))?;
let index: MarketplaceIndex = serde_json::from_str(&content)
.map_err(|e| format!("failed to parse {}: {e}", index_path.display()))?;
return Ok(Some(index));
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_marketplace_json() {
let json = r#"{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "test-marketplace",
"description": "Test marketplace",
"owner": { "name": "Test" },
"plugins": [
{
"name": "test-plugin",
"description": "A test plugin",
"category": "development",
"author": { "name": "Test Author" },
"source": { "type": "local", "path": "./plugins/test-plugin" },
"homepage": "https://example.com",
"tags": ["test", "example"],
"keywords": ["kw1", "kw2"]
}
]
}"#;
let index: MarketplaceIndex = serde_json::from_str(json).unwrap();
assert_eq!(index.name, "test-marketplace");
assert_eq!(index.plugins.len(), 1);
assert_eq!(index.plugins[0].name, "test-plugin");
assert_eq!(index.plugins[0].category.as_deref(), Some("development"));
assert_eq!(index.plugins[0].tags, vec!["test", "example"]);
assert_eq!(index.plugins[0].keywords, vec!["kw1", "kw2"]);
assert_eq!(
index.plugins[0].resolved_path().as_deref(),
Some("plugins/test-plugin")
);
}
#[test]
fn load_index_missing_file() {
let dir = tempfile::tempdir().unwrap();
let result = load_index(dir.path());
assert!(result.unwrap().is_none());
}
#[test]
fn load_index_invalid_json() {
let dir = tempfile::tempdir().unwrap();
let claude_dir = dir.path().join(".claude-plugin");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(claude_dir.join("marketplace.json"), "not json").unwrap();
let result = load_index(dir.path());
assert!(result.is_err());
}
#[test]
fn load_index_valid_grok_dir() {
let dir = tempfile::tempdir().unwrap();
let grok_dir = dir.path().join(".grok-plugin");
std::fs::create_dir_all(&grok_dir).unwrap();
std::fs::write(
grok_dir.join("marketplace.json"),
r#"{"name": "grok", "plugins": []}"#,
)
.unwrap();
let result = load_index(dir.path()).unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().name, "grok");
}
#[test]
fn load_index_grok_dir_takes_precedence_over_claude_dir() {
let dir = tempfile::tempdir().unwrap();
for (sub, name) in [(".grok-plugin", "grok"), (".claude-plugin", "claude")] {
let d = dir.path().join(sub);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join("marketplace.json"),
format!(r#"{{"name": "{name}", "plugins": []}}"#),
)
.unwrap();
}
assert_eq!(load_index(dir.path()).unwrap().unwrap().name, "grok");
}
#[test]
fn load_index_valid() {
let dir = tempfile::tempdir().unwrap();
let claude_dir = dir.path().join(".claude-plugin");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("marketplace.json"),
r#"{"name": "test", "plugins": []}"#,
)
.unwrap();
let result = load_index(dir.path()).unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().name, "test");
}
#[test]
fn resolved_path_strips_dot_slash() {
let entry = IndexEntry {
name: "test".into(),
version: None,
description: None,
category: None,
author: None,
source: Some(IndexSource {
r#type: Some("local".into()),
path: Some("./plugins/my-plugin".into()),
url: None,
git_ref: None,
git_sha: None,
}),
homepage: None,
tags: vec![],
keywords: vec![],
domains: vec![],
};
assert_eq!(entry.resolved_path().as_deref(), Some("plugins/my-plugin"));
}
#[test]
fn resolved_path_rejects_traversal() {
let entry = IndexEntry {
name: "test".into(),
version: None,
description: None,
category: None,
author: None,
source: Some(IndexSource {
r#type: Some("local".into()),
path: Some("./plugins/../../secret".into()),
url: None,
git_ref: None,
git_sha: None,
}),
homepage: None,
tags: vec![],
keywords: vec![],
domains: vec![],
};
assert!(entry.resolved_path().is_none());
assert!(entry.resolved_marketplace_path().is_err());
}
#[test]
fn parse_string_source_format() {
// enterprise-style marketplace.json uses plain strings for source.
let json = r#"{
"name": "acme-marketplace",
"description": "Acme plugins",
"plugins": [
{
"name": "acme-browser",
"description": "Browser plugin",
"source": "./plugins/acme-browser"
}
]
}"#;
let index: MarketplaceIndex = serde_json::from_str(json).unwrap();
assert_eq!(index.plugins.len(), 1);
assert_eq!(index.plugins[0].name, "acme-browser");
assert!(index.plugins[0].keywords.is_empty());
assert_eq!(
index.plugins[0].resolved_path().as_deref(),
Some("plugins/acme-browser")
);
}
#[test]
fn parse_mixed_source_formats() {
// Both object and string source formats in the same index.
let json = r#"{
"name": "mixed",
"plugins": [
{ "name": "a", "source": { "type": "local", "path": "./plugins/a" } },
{ "name": "b", "source": "./plugins/b" }
]
}"#;
let index: MarketplaceIndex = serde_json::from_str(json).unwrap();
assert_eq!(index.plugins.len(), 2);
assert_eq!(
index.plugins[0].resolved_path().as_deref(),
Some("plugins/a")
);
assert_eq!(
index.plugins[1].resolved_path().as_deref(),
Some("plugins/b")
);
}
#[test]
fn parse_superpowers_url_source() {
let json = r#"{
"name": "superpowers-marketplace",
"plugins": [
{
"name": "superpowers",
"source": {
"source": "url",
"url": "https://github.com/obra/superpowers.git"
},
"description": "Core skills",
"version": "5.0.7"
}
]
}"#;
let index: MarketplaceIndex = serde_json::from_str(json).unwrap();
assert_eq!(index.plugins.len(), 1);
assert_eq!(index.plugins[0].name, "superpowers");
// resolved_path returns None for URL sources.
assert!(index.plugins[0].resolved_path().is_none());
// remote_url returns the URL.
let (url, git_ref) = index.plugins[0].remote_url().unwrap();
assert_eq!(url, "https://github.com/obra/superpowers.git");
assert!(git_ref.is_none());
}
#[test]
fn parse_superpowers_url_source_with_ref() {
let json = r#"{
"name": "test",
"plugins": [
{
"name": "superpowers-dev",
"source": {
"source": "url",
"url": "https://github.com/obra/superpowers.git",
"ref": "dev"
}
}
]
}"#;
let index: MarketplaceIndex = serde_json::from_str(json).unwrap();
let (url, git_ref) = index.plugins[0].remote_url().unwrap();
assert_eq!(url, "https://github.com/obra/superpowers.git");
assert_eq!(git_ref, Some("dev"));
}
#[test]
fn parse_url_source_with_sha() {
let json = r#"{
"name": "test",
"plugins": [
{
"name": "vercel",
"source": {
"source": "url",
"url": "https://github.com/vercel/vercel-plugin.git",
"sha": "61f1903bed7b322c9745f6ba67095bc006de7e63"
}
}
]
}"#;
let index: MarketplaceIndex = serde_json::from_str(json).unwrap();
assert_eq!(
index.plugins[0].remote_url().map(|(u, _)| u),
Some("https://github.com/vercel/vercel-plugin.git")
);
assert_eq!(
index.plugins[0].remote_sha(),
Some("61f1903bed7b322c9745f6ba67095bc006de7e63")
);
}
#[test]
fn url_source_with_path_exposes_remote_subdir() {
let json = r#"{
"name": "test",
"plugins": [
{
"name": "acme",
"source": {
"source": "url",
"url": "https://github.com/acme/agent-skills.git",
"sha": "61f1903bed7b322c9745f6ba67095bc006de7e63",
"path": "plugins/acme"
}
}
]
}"#;
let index: MarketplaceIndex = serde_json::from_str(json).unwrap();
assert_eq!(index.plugins[0].remote_subdir(), Some("plugins/acme"));
assert!(index.plugins[0].resolved_path().is_none());
}
#[test]
fn url_source_without_sha_returns_none() {
let json = r#"{
"name": "test",
"plugins": [
{
"name": "p",
"source": { "source": "url", "url": "https://example.com/repo.git" }
}
]
}"#;
let index: MarketplaceIndex = serde_json::from_str(json).unwrap();
assert!(index.plugins[0].remote_sha().is_none());
}
#[test]
fn parse_full_superpowers_marketplace() {
// Real-world superpowers-marketplace format.
let json = r#"{
"name": "superpowers-marketplace",
"owner": { "name": "Jesse Vincent" },
"metadata": { "description": "Skills", "version": "1.0.13" },
"plugins": [
{
"name": "superpowers",
"source": { "source": "url", "url": "https://github.com/obra/superpowers.git" },
"description": "Core skills",
"version": "5.0.7",
"strict": true
},
{
"name": "elements-of-style",
"source": { "source": "url", "url": "https://github.com/obra/the-elements-of-style.git" },
"description": "Writing guidance",
"version": "1.0.0"
}
]
}"#;
let index: MarketplaceIndex = serde_json::from_str(json).unwrap();
assert_eq!(index.name, "superpowers-marketplace");
assert_eq!(index.plugins.len(), 2);
// All are URL sources.
for entry in &index.plugins {
assert!(entry.resolved_path().is_none());
assert!(entry.remote_url().is_some());
}
}
}

View file

@ -0,0 +1,677 @@
//! Pure resolution logic for `grok plugin install <name>` marketplace refs.
use crate::types::{MarketplaceEntry, MarketplaceSource, SourceKind};
use crate::{canonical_github_owner_repo, is_official_source_url};
/// A parsed marketplace install ref: a plugin `name` with an optional source
/// `qualifier` (`owner/repo` for git, `local/<slug>` for local sources).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceRef {
pub name: String,
pub qualifier: Option<String>,
}
/// Recognize `<name>` / `<name>@<qualifier>` install args, leaving git URLs,
/// GitHub shorthand, and local paths (including Windows paths) for the existing
/// parser.
pub fn parse_marketplace_ref(arg: &str) -> Option<MarketplaceRef> {
if arg.contains("://") || arg.starts_with("git@") {
return None;
}
if arg.starts_with('/')
|| arg.starts_with('\\')
|| arg.starts_with('.')
|| arg.starts_with('~')
|| is_windows_drive_path(arg)
{
return None;
}
if arg.contains('#') {
return None;
}
let (name, qualifier) = match arg.split_once('@') {
Some((name, qualifier)) => (name, Some(qualifier)),
None => (arg, None),
};
if name.is_empty() || name.contains('/') || name.contains('\\') {
return None;
}
if qualifier.is_some_and(|q| q.trim().is_empty()) {
return None;
}
Some(MarketplaceRef {
name: name.to_string(),
qualifier: qualifier.map(str::to_string),
})
}
fn is_windows_drive_path(arg: &str) -> bool {
let bytes = arg.as_bytes();
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
/// Lowercase a source name and turn whitespace runs into single hyphens.
pub fn slugify(name: &str) -> String {
name.to_ascii_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join("-")
}
/// The qualifier a user would type to pin this source: `owner/repo` for a
/// GitHub git source, `git/<slug>` for a non-GitHub git source, `local/<slug>`
/// for a local source.
pub fn addressable_qualifier(source: &MarketplaceSource) -> String {
match &source.kind {
SourceKind::Git { url, .. } => canonical_github_owner_repo(url)
.unwrap_or_else(|| format!("git/{}", slugify(&source.name))),
SourceKind::Local { .. } => format!("local/{}", slugify(&source.name)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QualifierResolveError {
Unknown,
/// More than one registered source matches; payload is their indices.
Ambiguous(Vec<usize>),
}
/// Resolve a qualifier to exactly one registered source index.
///
/// A bare `owner/repo` matches GitHub git sources. `local/<slug>` and
/// `git/<slug>` match local/git sources by slugified name, and both also keep
/// the `owner/repo` interpretation so a GitHub source owned by `git`/`local`
/// still resolves. A qualifier also matches a source's registered `name`
/// (exactly, or slugified): `<plugin>@<marketplace-name>` is the only pin for
/// non-github.com hosts (e.g. GitHub Enterprise) that have no `owner/repo`
/// form. Matches spanning more than one source surface as
/// [`QualifierResolveError::Ambiguous`].
pub fn resolve_qualified_source(
qualifier: &str,
sources: &[MarketplaceSource],
) -> Result<usize, QualifierResolveError> {
let want = normalize_owner_repo_qualifier(qualifier);
let local_slug = qualifier.strip_prefix("local/");
let git_slug = qualifier.strip_prefix("git/");
let matched: Vec<usize> = sources
.iter()
.enumerate()
.filter(|(_, source)| {
let owner_repo = match &source.kind {
SourceKind::Git { url, .. } => {
canonical_github_owner_repo(url).as_deref() == Some(want.as_str())
}
SourceKind::Local { .. } => false,
};
let local = local_slug.is_some_and(|slug| {
matches!(&source.kind, SourceKind::Local { .. }) && slugify(&source.name) == slug
});
let git = git_slug.is_some_and(|slug| {
matches!(&source.kind, SourceKind::Git { .. }) && slugify(&source.name) == slug
});
let by_name = source.name == qualifier || slugify(&source.name) == slugify(qualifier);
owner_repo || local || git || by_name
})
.map(|(index, _)| index)
.collect();
match matched.as_slice() {
[] => Err(QualifierResolveError::Unknown),
[index] => Ok(*index),
_ => Err(QualifierResolveError::Ambiguous(matched)),
}
}
fn normalize_owner_repo_qualifier(qualifier: &str) -> String {
let trimmed = qualifier.trim();
let trimmed = trimmed.strip_suffix('/').unwrap_or(trimmed);
let trimmed = trimmed.strip_suffix(".git").unwrap_or(trimmed);
trimmed.to_ascii_lowercase()
}
/// One scanned marketplace entry tagged with the source it came from.
pub struct ScannedEntry<'a> {
pub source: &'a MarketplaceSource,
pub entry: &'a MarketplaceEntry,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BareNameSelection {
/// Index into the scanned slice of the entry to install.
pub chosen: usize,
/// How many other copies of the name exist (non-zero only when official
/// priority broke a tie).
pub other_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BareNameError {
NotFound,
/// Several sources provide the name and none is uniquely official; payload
/// is the matching indices into the scanned slice.
Ambiguous {
matched: Vec<usize>,
},
}
/// Choose which scanned entry to install for a bare `<name>` (case-insensitive).
///
/// One match wins outright. With several matches, a single official-source copy
/// wins (reporting the others); otherwise the result is ambiguous.
pub fn select_bare_name(
name: &str,
scanned: &[ScannedEntry<'_>],
) -> Result<BareNameSelection, BareNameError> {
let matched: Vec<usize> = scanned
.iter()
.enumerate()
.filter(|(_, candidate)| candidate.entry.name.eq_ignore_ascii_case(name))
.map(|(index, _)| index)
.collect();
match matched.as_slice() {
[] => Err(BareNameError::NotFound),
[index] => Ok(BareNameSelection {
chosen: *index,
other_count: 0,
}),
_ => {
let official: Vec<usize> = matched
.iter()
.copied()
.filter(|&index| match &scanned[index].source.kind {
SourceKind::Git { url, .. } => is_official_source_url(url),
SourceKind::Local { .. } => false,
})
.collect();
match official.as_slice() {
[index] => Ok(BareNameSelection {
chosen: *index,
other_count: matched.len() - 1,
}),
_ => Err(BareNameError::Ambiguous { matched }),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn git_source(name: &str, url: &str) -> MarketplaceSource {
MarketplaceSource {
name: name.to_string(),
kind: SourceKind::Git {
url: url.to_string(),
branch: None,
},
}
}
fn local_source(name: &str, path: &str) -> MarketplaceSource {
MarketplaceSource {
name: name.to_string(),
kind: SourceKind::Local {
path: PathBuf::from(path),
},
}
}
fn entry(name: &str) -> MarketplaceEntry {
MarketplaceEntry {
name: name.to_string(),
version: None,
description: None,
category: None,
author: None,
tags: Vec::new(),
keywords: Vec::new(),
domains: Vec::new(),
homepage: None,
relative_path: format!("plugins/{name}"),
skill_count: 0,
has_hooks: false,
has_agents: false,
has_mcp: false,
remote_url: None,
remote_ref: None,
remote_sha: None,
remote_subdir: None,
components: None,
}
}
fn scanned_entries<'a>(
pairs: &'a [(MarketplaceSource, MarketplaceEntry)],
) -> Vec<ScannedEntry<'a>> {
pairs
.iter()
.map(|(source, entry)| ScannedEntry { source, entry })
.collect()
}
#[test]
fn parse_bare_name() {
assert_eq!(
parse_marketplace_ref("sentry"),
Some(MarketplaceRef {
name: "sentry".into(),
qualifier: None,
})
);
}
#[test]
fn parse_name_with_owner_repo_qualifier() {
assert_eq!(
parse_marketplace_ref("sentry@xai-org/plugin-marketplace"),
Some(MarketplaceRef {
name: "sentry".into(),
qualifier: Some("xai-org/plugin-marketplace".into()),
})
);
}
#[test]
fn parse_name_with_local_slug_qualifier() {
assert_eq!(
parse_marketplace_ref("sentry@local/local-dev"),
Some(MarketplaceRef {
name: "sentry".into(),
qualifier: Some("local/local-dev".into()),
})
);
}
#[test]
fn parse_rejects_git_shorthand_with_ref() {
assert_eq!(parse_marketplace_ref("owner/repo@v1.0"), None);
}
#[test]
fn parse_rejects_urls_and_local_paths() {
assert_eq!(parse_marketplace_ref("https://github.com/owner/repo"), None);
assert_eq!(parse_marketplace_ref("git@github.com:owner/repo.git"), None);
assert_eq!(parse_marketplace_ref("./x"), None);
assert_eq!(parse_marketplace_ref("/abs"), None);
assert_eq!(parse_marketplace_ref("~/x"), None);
}
#[test]
fn parse_rejects_windows_paths() {
assert_eq!(parse_marketplace_ref(r"C:\Users\me\plugin"), None);
assert_eq!(parse_marketplace_ref("C:/Users/me/plugin"), None);
assert_eq!(parse_marketplace_ref(r"\\server\share\plugin"), None);
assert_eq!(parse_marketplace_ref(r"sub\plugin"), None);
}
#[test]
fn parse_rejects_trailing_or_whitespace_qualifier() {
assert_eq!(parse_marketplace_ref("sentry@"), None);
assert_eq!(parse_marketplace_ref("sentry@ "), None);
}
#[test]
fn parse_rejects_leading_at() {
assert_eq!(parse_marketplace_ref("@foo"), None);
}
#[test]
fn parse_rejects_fragment() {
assert_eq!(parse_marketplace_ref("sentry#sub"), None);
assert_eq!(
parse_marketplace_ref("sentry@xai-org/marketplace#sub"),
None
);
}
#[test]
fn parse_splits_on_first_at_only() {
assert_eq!(
parse_marketplace_ref("a@b@c"),
Some(MarketplaceRef {
name: "a".into(),
qualifier: Some("b@c".into()),
})
);
}
#[test]
fn slugify_lowercases_and_hyphenates_spaces() {
assert_eq!(slugify("Local Dev"), "local-dev");
assert_eq!(slugify("xAI Official"), "xai-official");
}
#[test]
fn addressable_qualifier_git_and_local() {
assert_eq!(
addressable_qualifier(&git_source(
"x",
"https://github.com/xai-org/plugin-marketplace.git"
)),
"xai-org/plugin-marketplace"
);
assert_eq!(
addressable_qualifier(&local_source("Local Dev", "/tmp/p")),
"local/local-dev"
);
}
#[test]
fn addressable_qualifier_non_github_git_uses_git_slug() {
assert_eq!(
addressable_qualifier(&git_source(
"Self Hosted",
"https://git.example.com/org/repo.git"
)),
"git/self-hosted"
);
}
#[test]
fn resolve_qualifier_matches_git_owner_repo_across_url_forms() {
for url in [
"https://github.com/xai-org/plugin-marketplace.git",
"git@github.com:xai-org/plugin-marketplace.git",
"ssh://git@github.com/xai-org/plugin-marketplace",
"https://GitHub.com/XAI-org/Plugin-Marketplace",
] {
let sources = [git_source("src", url)];
assert_eq!(
resolve_qualified_source("xai-org/plugin-marketplace", &sources),
Ok(0),
"url: {url}"
);
}
}
#[test]
fn resolve_qualifier_normalizes_dot_git_in_qualifier() {
let sources = [git_source(
"src",
"https://github.com/xai-org/plugin-marketplace",
)];
assert_eq!(
resolve_qualified_source("xai-org/plugin-marketplace.git", &sources),
Ok(0)
);
}
#[test]
fn resolve_qualifier_matches_local_by_slug() {
let sources = [
git_source(
"xAI Official",
"https://github.com/xai-org/plugin-marketplace.git",
),
local_source("Local Dev", "/tmp/plugins"),
];
assert_eq!(resolve_qualified_source("local/local-dev", &sources), Ok(1));
}
#[test]
fn resolve_qualifier_matches_non_github_git_by_slug() {
let sources = [
git_source(
"xAI Official",
"https://github.com/xai-org/plugin-marketplace.git",
),
git_source("Self Hosted", "https://git.example.com/org/repo.git"),
];
assert_eq!(resolve_qualified_source("git/self-hosted", &sources), Ok(1));
}
#[test]
fn resolve_qualifier_github_owner_named_git_round_trips() {
let sources = [git_source("X", "https://github.com/git/tools.git")];
assert_eq!(addressable_qualifier(&sources[0]), "git/tools");
assert_eq!(resolve_qualified_source("git/tools", &sources), Ok(0));
}
#[test]
fn resolve_qualifier_git_prefix_collision_is_ambiguous() {
let sources = [
git_source("X", "https://github.com/git/tools.git"),
git_source("Tools", "https://git.example.com/org/tools.git"),
];
assert_eq!(
resolve_qualified_source("git/tools", &sources),
Err(QualifierResolveError::Ambiguous(vec![0, 1]))
);
}
#[test]
fn resolve_qualifier_unknown_for_git_and_local() {
let sources = [
git_source(
"xAI Official",
"https://github.com/xai-org/plugin-marketplace.git",
),
local_source("Local Dev", "/tmp/plugins"),
];
assert_eq!(
resolve_qualified_source("other/repo", &sources),
Err(QualifierResolveError::Unknown)
);
assert_eq!(
resolve_qualified_source("local/nope", &sources),
Err(QualifierResolveError::Unknown)
);
}
#[test]
fn resolve_qualifier_ambiguous_lists_all_matching_indices() {
let sources = [
git_source(
"Mirror A",
"https://github.com/xai-org/plugin-marketplace.git",
),
git_source("Mirror B", "git@github.com:xai-org/plugin-marketplace.git"),
];
assert_eq!(
resolve_qualified_source("xai-org/plugin-marketplace", &sources),
Err(QualifierResolveError::Ambiguous(vec![0, 1]))
);
}
#[test]
fn resolve_qualifier_matches_marketplace_name_for_non_github_host() {
let sources = [git_source(
"internal-tools",
"git@github.example.com:acme/internal-tools.git",
)];
assert_eq!(resolve_qualified_source("internal-tools", &sources), Ok(0));
}
#[test]
fn resolve_qualifier_matches_name_case_and_space_insensitively() {
let sources = [git_source(
"Internal Tools",
"git@github.example.com:acme/internal-tools.git",
)];
assert_eq!(resolve_qualified_source("Internal Tools", &sources), Ok(0));
assert_eq!(resolve_qualified_source("internal-tools", &sources), Ok(0));
}
#[test]
fn resolve_qualifier_matches_local_source_by_name() {
let sources = [local_source("Local Dev", "/tmp/plugins")];
assert_eq!(resolve_qualified_source("Local Dev", &sources), Ok(0));
assert_eq!(resolve_qualified_source("local-dev", &sources), Ok(0));
}
#[test]
fn resolve_qualifier_duplicate_names_are_ambiguous() {
let sources = [
git_source("dup", "https://git.a.example.com/o/r.git"),
git_source("dup", "https://git.b.example.com/o/r.git"),
];
assert_eq!(
resolve_qualified_source("dup", &sources),
Err(QualifierResolveError::Ambiguous(vec![0, 1]))
);
}
#[test]
fn resolve_qualifier_name_vs_other_source_owner_repo_is_ambiguous() {
let sources = [
git_source(
"xAI Official",
"https://github.com/xai-org/plugin-marketplace.git",
),
git_source(
"xai-org/plugin-marketplace",
"git@github.example.com:mirror/xai.git",
),
];
assert_eq!(
resolve_qualified_source("xai-org/plugin-marketplace", &sources),
Err(QualifierResolveError::Ambiguous(vec![0, 1]))
);
}
#[test]
fn resolve_qualifier_name_and_owner_repo_same_source_resolves() {
let sources = [git_source(
"xai-org/plugin-marketplace",
"https://github.com/xai-org/plugin-marketplace.git",
)];
assert_eq!(
resolve_qualified_source("xai-org/plugin-marketplace", &sources),
Ok(0)
);
}
#[test]
fn resolve_qualifier_unknown_name_still_unknown() {
let sources = [git_source(
"internal-tools",
"git@github.example.com:x/y.git",
)];
assert_eq!(
resolve_qualified_source("nope", &sources),
Err(QualifierResolveError::Unknown)
);
}
#[test]
fn bare_name_single_match_selected() {
let pairs = [(
git_source("src", "https://github.com/o/r.git"),
entry("sentry"),
)];
let scanned = scanned_entries(&pairs);
assert_eq!(
select_bare_name("sentry", &scanned),
Ok(BareNameSelection {
chosen: 0,
other_count: 0,
})
);
}
#[test]
fn bare_name_matches_case_insensitively() {
let pairs = [(
git_source("src", "https://github.com/o/r.git"),
entry("Sentry"),
)];
let scanned = scanned_entries(&pairs);
assert_eq!(
select_bare_name("sentry", &scanned),
Ok(BareNameSelection {
chosen: 0,
other_count: 0,
})
);
}
#[test]
fn bare_name_official_priority_when_duplicate_in_official_and_third_party() {
let pairs = [
(
git_source("Third Party", "https://github.com/acme/marketplace.git"),
entry("sentry"),
),
(
git_source(
"xAI Official",
"https://github.com/xai-org/plugin-marketplace.git",
),
entry("sentry"),
),
];
let scanned = scanned_entries(&pairs);
assert_eq!(
select_bare_name("sentry", &scanned),
Ok(BareNameSelection {
chosen: 1,
other_count: 1,
})
);
}
#[test]
fn bare_name_ambiguous_when_no_official_match() {
let pairs = [
(
git_source("Third Party A", "https://github.com/acme/a.git"),
entry("sentry"),
),
(
git_source("Third Party B", "https://github.com/acme/b.git"),
entry("sentry"),
),
];
let scanned = scanned_entries(&pairs);
assert_eq!(
select_bare_name("sentry", &scanned),
Err(BareNameError::Ambiguous {
matched: vec![0, 1]
})
);
}
#[test]
fn bare_name_ambiguous_when_more_than_one_official_match() {
let pairs = [
(
git_source(
"Official Mirror A",
"https://github.com/xai-org/plugin-marketplace.git",
),
entry("sentry"),
),
(
git_source(
"Official Mirror B",
"git@github.com:xai-org/plugin-marketplace.git",
),
entry("sentry"),
),
];
let scanned = scanned_entries(&pairs);
assert_eq!(
select_bare_name("sentry", &scanned),
Err(BareNameError::Ambiguous {
matched: vec![0, 1]
})
);
}
#[test]
fn bare_name_not_found_when_no_entry_matches() {
let pairs = [(
git_source("src", "https://github.com/o/r.git"),
entry("other"),
)];
let scanned = scanned_entries(&pairs);
assert_eq!(
select_bare_name("sentry", &scanned),
Err(BareNameError::NotFound)
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,121 @@
//! Plugin marketplace browse and index crate.
//!
//! Provides marketplace source configuration, plugin discovery (indexed +
//! filesystem fallback), and install integration with the existing
//! `InstallRegistry` pipeline.
pub mod catalog;
pub mod config;
pub mod error;
pub mod git;
pub mod index;
pub mod install_resolve;
pub mod installer;
pub mod matcher;
pub mod scanner;
pub mod types;
pub use config::{
load_extra_sources_from_settings, load_extra_sources_from_settings_in, load_sources,
};
pub use error::MarketplaceError;
pub use scanner::scan_marketplace;
pub use types::*;
/// Display name of the official xAI marketplace source.
pub const OFFICIAL_SOURCE_NAME: &str = "xAI Official";
/// Git URL of the official xAI marketplace source. Auto-registered on first run.
pub const OFFICIAL_SOURCE_GIT_URL: &str = "https://github.com/xai-org/plugin-marketplace.git";
/// Whether `url` is the official xAI marketplace source, normalizing case, a
/// `www.` prefix, a trailing `/` or `.git`, and HTTPS/SSH forms before comparing.
pub fn is_official_source_url(url: &str) -> bool {
canonical_github_owner_repo(url).as_deref() == Some("xai-org/plugin-marketplace")
}
/// Normalized lowercase `owner/repo` from a GitHub URL (HTTPS/http/ssh/scp,
/// `www.`, trailing `.git`/`/`), or `None` if not a GitHub URL.
pub(crate) fn canonical_github_owner_repo(url: &str) -> Option<String> {
let s = url.trim();
let s = s.strip_suffix('/').unwrap_or(s);
let s = s.strip_suffix(".git").unwrap_or(s);
let lower = s.to_ascii_lowercase();
let rest = lower
.strip_prefix("https://")
.or_else(|| lower.strip_prefix("http://"))
.or_else(|| lower.strip_prefix("ssh://"))
.unwrap_or(&lower);
let rest = rest.strip_prefix("git@").unwrap_or(rest);
let rest = rest.strip_prefix("www.").unwrap_or(rest);
let owner_repo = rest
.strip_prefix("github.com/")
.or_else(|| rest.strip_prefix("github.com:"))?;
if owner_repo.is_empty() {
None
} else {
Some(owner_repo.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_official_matches_canonical_https() {
assert!(is_official_source_url(OFFICIAL_SOURCE_GIT_URL));
assert!(is_official_source_url(
"https://github.com/xai-org/plugin-marketplace"
));
}
#[test]
fn is_official_matches_ssh_form() {
assert!(is_official_source_url(
"git@github.com:xai-org/plugin-marketplace.git"
));
assert!(is_official_source_url(
"git@github.com:xai-org/plugin-marketplace"
));
assert!(is_official_source_url(
"ssh://git@github.com/xai-org/plugin-marketplace.git"
));
assert!(is_official_source_url(
"ssh://git@github.com/xai-org/plugin-marketplace"
));
}
#[test]
fn is_official_rejects_unrelated_urls() {
assert!(!is_official_source_url(
"https://github.com/anthropics/claude-plugins-official.git"
));
assert!(!is_official_source_url(
"https://github.com/xai-org/some-other-repo.git"
));
assert!(!is_official_source_url(""));
}
#[test]
fn is_official_matches_noncanonical_forms() {
assert!(is_official_source_url(
"https://GitHub.com/XAI-org/Plugin-Marketplace"
));
assert!(is_official_source_url(
"https://github.com/xai-org/plugin-marketplace/"
));
assert!(is_official_source_url(
"https://github.com/xai-org/plugin-marketplace.git/"
));
assert!(is_official_source_url(
"http://github.com/xai-org/plugin-marketplace"
));
assert!(is_official_source_url(
"https://www.github.com/xai-org/plugin-marketplace.git"
));
assert!(is_official_source_url(
"git@github.com:XAI-org/plugin-marketplace.git"
));
}
}

View file

@ -0,0 +1,257 @@
//! Pure keyword matcher over marketplace plugin metadata.
//!
//! The matcher is a thin reader: matches live as data in the marketplace index
//! (`keywords` and `domains`), augmented by the plugin's `name`. There is no
//! `regex` dependency — matching is substring search guarded by ASCII word
//! boundaries.
use std::cmp::Reverse;
/// A plugin to match a draft against, borrowing data from a marketplace entry.
pub struct KeywordCandidate<'a> {
pub name: &'a str,
pub domains: &'a [String],
pub keywords: &'a [String],
}
/// Return the index of the single candidate whose keyword matches `draft`.
///
/// Returns `None` when `draft` has fewer than 3 characters or nothing matches.
/// A candidate's effective keywords are its explicit `keywords`, its `domains`
/// (each normalized: scheme, leading `www.`, and path stripped), and its
/// `name`. Longer keywords take precedence; a keyword matches only when the
/// occurrence is flanked by ASCII word boundaries.
pub fn match_plugin_keyword(draft: &str, candidates: &[KeywordCandidate<'_>]) -> Option<usize> {
if draft.chars().count() < 3 {
return None;
}
let draft_lc = draft.to_ascii_lowercase();
let haystack = draft_lc.as_bytes();
let mut pairs: Vec<(String, usize)> = Vec::new();
for (idx, candidate) in candidates.iter().enumerate() {
for keyword in effective_keywords(candidate) {
pairs.push((keyword, idx));
}
}
pairs.sort_by_key(|(keyword, _)| Reverse(keyword.len()));
pairs
.iter()
.find(|(keyword, _)| keyword_matches(haystack, keyword.as_bytes()))
.map(|(_, idx)| *idx)
}
fn effective_keywords(candidate: &KeywordCandidate<'_>) -> Vec<String> {
let mut keywords = Vec::new();
for keyword in candidate.keywords {
let normalized = keyword.trim().to_ascii_lowercase();
if !normalized.is_empty() {
keywords.push(normalized);
}
}
for domain in candidate.domains {
if let Some(normalized) = normalize_domain(domain) {
keywords.push(normalized);
}
}
let name = candidate.name.trim().to_ascii_lowercase();
if !name.is_empty() {
keywords.push(name);
}
keywords
}
fn normalize_domain(domain: &str) -> Option<String> {
let trimmed = domain.trim();
let after_scheme = match trimmed.find("://") {
Some(i) => &trimmed[i + 3..],
None => trimmed,
};
let host = after_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or(after_scheme)
.to_ascii_lowercase();
let host = host.strip_prefix("www.").unwrap_or(&host);
if host.is_empty() {
None
} else {
Some(host.to_string())
}
}
fn keyword_matches(haystack: &[u8], keyword: &[u8]) -> bool {
if keyword.is_empty() {
return false;
}
let len = haystack.len();
haystack
.windows(keyword.len())
.enumerate()
.any(|(start, window)| {
if window != keyword {
return false;
}
let end = start + keyword.len();
let start_ok = start == 0 || is_word(haystack[start - 1]) != is_word(haystack[start]);
let end_ok = end == len || is_word(haystack[end - 1]) != is_word(haystack[end]);
start_ok && end_ok
})
}
fn is_word(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
#[cfg(test)]
mod tests {
use super::*;
fn candidate<'a>(
name: &'a str,
domains: &'a [String],
keywords: &'a [String],
) -> KeywordCandidate<'a> {
KeywordCandidate {
name,
domains,
keywords,
}
}
#[test]
fn longest_keyword_takes_precedence() {
let short = vec!["editor".to_string()];
let long = vec!["code editor".to_string()];
let candidates = [
candidate("plugin-a", &[], &short),
candidate("plugin-b", &[], &long),
];
assert_eq!(
match_plugin_keyword("my code editor rocks", &candidates),
Some(1)
);
}
#[test]
fn equal_length_keywords_break_ties_by_insertion_order() {
let first = vec!["wave".to_string()];
let second = vec!["atom".to_string()];
let candidates = [
candidate("plugin-a", &[], &first),
candidate("plugin-b", &[], &second),
];
assert_eq!(match_plugin_keyword("wave and atom", &candidates), Some(0));
}
#[test]
fn domains_match_inside_pasted_urls() {
let domains = vec!["figma.com".to_string()];
let none: Vec<String> = Vec::new();
let candidates = [candidate("design-app", &domains, &none)];
assert_eq!(
match_plugin_keyword("open https://www.figma.com/board/x please", &candidates),
Some(0)
);
assert_eq!(
match_plugin_keyword("open figma.com please", &candidates),
Some(0)
);
assert_eq!(match_plugin_keyword("open figma please", &candidates), None);
}
#[test]
fn domains_accept_full_urls_and_normalize() {
let domains = vec!["https://www.vercel.com/dashboard".to_string()];
let none: Vec<String> = Vec::new();
let candidates = [candidate("vercel", &domains, &none)];
assert_eq!(
match_plugin_keyword("deploy via https://vercel.com/x", &candidates),
Some(0)
);
}
#[test]
fn unrelated_url_does_not_match_keyword_only_candidate() {
let kw = vec!["vercel".to_string()];
let none: Vec<String> = Vec::new();
let candidates = [candidate("vercel", &none, &kw)];
assert_eq!(
match_plugin_keyword("https://github.com/xai-org/plugin-marketplace", &candidates),
None
);
}
#[test]
fn normalize_domain_strips_scheme_www_and_path() {
assert_eq!(
normalize_domain("https://www.notion.so/product/foo").as_deref(),
Some("notion.so")
);
assert_eq!(
normalize_domain("http://figma.com").as_deref(),
Some("figma.com")
);
assert_eq!(
normalize_domain("notion.so/app").as_deref(),
Some("notion.so")
);
assert_eq!(
normalize_domain("https://WWW.Example.COM/x?y=1#z").as_deref(),
Some("example.com")
);
assert_eq!(normalize_domain(""), None);
assert_eq!(normalize_domain("https://"), None);
}
#[test]
fn name_is_used_as_fallback() {
let none: Vec<String> = Vec::new();
let candidates = [candidate("obsidian", &[], &none)];
assert_eq!(
match_plugin_keyword("open obsidian now", &candidates),
Some(0)
);
}
#[test]
fn draft_below_min_length_never_matches() {
let keywords = vec!["go".to_string()];
let candidates = [candidate("go", &[], &keywords)];
assert_eq!(match_plugin_keyword("go", &candidates), None);
let git = vec!["git".to_string()];
let candidates = [candidate("git", &[], &git)];
assert_eq!(match_plugin_keyword("git", &candidates), Some(0));
}
#[test]
fn no_match_returns_none() {
let keywords = vec!["kubernetes".to_string()];
let candidates = [candidate("k8s-tool", &[], &keywords)];
assert_eq!(match_plugin_keyword("hello world", &candidates), None);
assert_eq!(match_plugin_keyword("anything", &[]), None);
}
#[test]
fn substring_without_word_boundary_does_not_match() {
let keywords = vec!["box".to_string()];
let candidates = [candidate("box", &[], &keywords)];
assert_eq!(match_plugin_keyword("i love boxing", &candidates), None);
assert_eq!(match_plugin_keyword("i love box", &candidates), Some(0));
}
#[test]
fn keywords_with_dots_match_literally() {
let keywords = vec!["notion.so".to_string()];
let candidates = [candidate("notes", &[], &keywords)];
assert_eq!(
match_plugin_keyword("visit notion.so today", &candidates),
Some(0)
);
assert_eq!(
match_plugin_keyword("visit notionxso today", &candidates),
None
);
}
}

View file

@ -0,0 +1,785 @@
//! Marketplace plugin discovery.
//!
//! Supports two modes:
//! 1. **Indexed:** if a catalog index file exists (see `index::load_index` for
//! the lookup order — `.grok-plugin/marketplace.json` is preferred), use it.
//! 2. **Filesystem fallback:** walk `plugins/*/` and resolve manifests directly.
use std::path::Path;
use crate::catalog;
use crate::index;
use crate::types::{MarketplaceEntry, MarketplaceScan};
/// Scan a marketplace directory for plugins, reporting whether a
/// `plugin-index.json` component catalog was loaded.
///
/// Tries indexed mode first, falls back to filesystem scanning.
pub fn scan_marketplace(root: &Path) -> MarketplaceScan {
let MarketplaceScan {
entries: mut plugins,
catalog_loaded,
} = scan_plugins(root);
// Also scan `default-skills/` as a virtual plugin if present.
let default_skills_dir = root.join("default-skills");
if default_skills_dir.is_dir() {
// default-skills/ has skills at root level (each subdir is a skill),
// not under a skills/ subdirectory. Count SKILL.md files directly.
let skill_count = std::fs::read_dir(&default_skills_dir)
.ok()
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| e.path().join("SKILL.md").exists())
.count()
})
.unwrap_or(0);
if skill_count > 0 {
let mut entry = scan_single_plugin(&default_skills_dir, "default-skills");
// Override skill_count since scan_single_plugin looks under skills/.
entry.skill_count = skill_count;
plugins.push(entry);
}
}
MarketplaceScan {
entries: plugins,
catalog_loaded,
}
}
/// Core plugin scanning — tries indexed mode first, falls back to filesystem.
///
/// The component catalog is only consulted in indexed mode: its keys are
/// defined as index names, so the filesystem fallback ignores it.
fn scan_plugins(root: &Path) -> MarketplaceScan {
// Try indexed mode.
match index::load_index(root) {
Ok(Some(idx)) => {
tracing::debug!(
"using marketplace index: {} ({} plugins)",
idx.name,
idx.plugins.len()
);
let plugin_catalog = catalog::load_catalog(root);
let mut plugins = Vec::new();
for entry in &idx.plugins {
// URL-sourced entries: build entry from index metadata only
// (the actual repo is cloned at install time, not scan time).
if let Some((url, git_ref)) = entry.remote_url() {
let discovered = MarketplaceEntry {
name: entry.name.clone(),
version: entry.version.clone(),
description: entry.description.clone(),
category: entry.category.clone(),
author: entry.author.as_ref().map(|a| a.name.clone()),
tags: entry.tags.clone(),
keywords: entry.keywords.clone(),
domains: entry.domains.clone(),
homepage: entry.homepage.clone(),
relative_path: entry.name.clone(),
skill_count: 0,
has_hooks: false,
has_agents: false,
has_mcp: false,
remote_url: Some(url.to_string()),
remote_ref: git_ref.map(|s| s.to_string()),
remote_sha: entry.remote_sha().map(|s| s.to_string()),
remote_subdir: entry.remote_subdir().map(|s| s.to_string()),
components: entry.remote_sha().and_then(|sha| {
plugin_catalog
.as_ref()
.and_then(|c| c.components_for(&entry.name, Some(sha)).cloned())
}),
};
plugins.push(discovered);
continue;
}
let rel_path = match entry.resolved_marketplace_path() {
Ok(p) => p,
Err(e) => {
tracing::warn!(
"marketplace index entry '{}' has invalid source path: {}",
entry.name,
e
);
continue;
}
};
let plugin_dir = match rel_path.join_under(root) {
Ok(path) => path,
Err(e) => {
tracing::warn!(
"marketplace index entry '{}' source path escapes marketplace root: {}",
entry.name,
e
);
continue;
}
};
if !plugin_dir.is_dir() {
tracing::warn!(
"marketplace index entry '{}' points to non-existent dir: {}",
entry.name,
plugin_dir.display()
);
continue;
}
let mut discovered = scan_single_plugin(&plugin_dir, rel_path.as_str());
// Enrich from index metadata.
if discovered.description.is_none() {
discovered.description = entry.description.clone();
}
discovered.category = entry.category.clone();
discovered.tags = entry.tags.clone();
discovered.keywords = entry.keywords.clone();
discovered.domains = entry.domains.clone();
discovered.homepage = entry.homepage.clone();
if discovered.author.is_none() {
discovered.author = entry.author.as_ref().map(|a| a.name.clone());
}
discovered.components = plugin_catalog
.as_ref()
.and_then(|c| c.components_for(&entry.name, None).cloned());
plugins.push(discovered);
}
MarketplaceScan {
entries: plugins,
catalog_loaded: plugin_catalog.is_some(),
}
}
Ok(None) => {
// No index — filesystem fallback.
MarketplaceScan {
entries: scan_filesystem(root),
catalog_loaded: false,
}
}
Err(e) => {
// Invalid index — warn and fall back.
tracing::warn!("marketplace index invalid, falling back to scan: {e}");
MarketplaceScan {
entries: scan_filesystem(root),
catalog_loaded: false,
}
}
}
}
/// Filesystem fallback: walk `plugins/*/` and discover each.
fn scan_filesystem(root: &Path) -> Vec<MarketplaceEntry> {
let plugins_dir = root.join("plugins");
if !plugins_dir.is_dir() {
return Vec::new();
}
let mut entries: Vec<_> = match std::fs::read_dir(&plugins_dir) {
Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
Err(e) => {
tracing::warn!("failed to read plugins dir: {e}");
return Vec::new();
}
};
entries.sort_by_key(|e| e.file_name());
let mut plugins = Vec::new();
for entry in entries {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let rel_path = format!("plugins/{name}");
plugins.push(scan_single_plugin(&path, &rel_path));
}
plugins
}
/// Scan a single plugin directory for metadata and components.
fn scan_single_plugin(plugin_dir: &Path, relative_path: &str) -> MarketplaceEntry {
// Load manifest using runtime conventions.
let manifest_result = xai_grok_agent::plugins::manifest::load_manifest(plugin_dir);
let manifest = match &manifest_result {
Ok(xai_grok_agent::plugins::manifest::ManifestLoadResult::Found(m)) => Some(m.as_ref()),
_ => None,
};
let dir_name = plugin_dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let name = manifest
.map(|m| m.name.clone())
.unwrap_or_else(|| dir_name.clone());
let version = manifest.and_then(|m| m.version.clone());
let description = manifest.and_then(|m| m.description.clone());
let author = manifest.and_then(|m| m.author.as_ref().and_then(|a| a.name.clone()));
// Count components using manifest conventions with defaults.
let (skill_count, has_hooks, has_agents, has_mcp) = if let Some(m) = manifest {
let skill_dirs = m.skill_dirs(plugin_dir);
let sc = skill_dirs
.iter()
.filter(|d| d.is_dir())
.flat_map(|d| std::fs::read_dir(d).ok())
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().join("SKILL.md").exists())
.count();
let hk = m.hooks_path(plugin_dir).is_some_and(|p| p.exists());
let ag = m.agent_dirs(plugin_dir).iter().any(|d| {
d.is_dir()
&& std::fs::read_dir(d)
.ok()
.is_some_and(|mut rd| rd.next().is_some())
});
let mc = m.mcp_config_path(plugin_dir).is_some_and(|p| p.exists());
(sc, hk, ag, mc)
} else {
// No manifest — check defaults.
let skills_dir = plugin_dir.join("skills");
let sc = if skills_dir.is_dir() {
std::fs::read_dir(&skills_dir)
.ok()
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| e.path().join("SKILL.md").exists())
.count()
})
.unwrap_or(0)
} else {
0
};
let hk = plugin_dir.join("hooks").join("hooks.json").exists();
let ag = plugin_dir.join("agents").is_dir();
let mc = plugin_dir.join(".mcp.json").exists();
(sc, hk, ag, mc)
};
MarketplaceEntry {
name,
version,
description,
category: None,
author,
tags: Vec::new(),
keywords: Vec::new(),
domains: Vec::new(),
homepage: None,
relative_path: relative_path.to_string(),
skill_count,
has_hooks,
has_agents,
has_mcp,
remote_url: None,
remote_ref: None,
remote_sha: None,
remote_subdir: None,
components: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Create a minimal plugin directory with a manifest.
fn make_plugin(dir: &Path, name: &str, version: &str) {
let plugin_dir = dir.join("plugins").join(name);
let claude_dir = plugin_dir.join(".claude-plugin");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("plugin.json"),
format!(r#"{{"name":"{name}","version":"{version}","description":"Test {name}"}}"#),
)
.unwrap();
// Add a skill.
let skill_dir = plugin_dir.join("skills").join("my-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "# My Skill").unwrap();
}
#[test]
fn filesystem_scan_discovers_plugins() {
let dir = tempfile::tempdir().unwrap();
make_plugin(dir.path(), "plugin-a", "1.0.0");
make_plugin(dir.path(), "plugin-b", "2.0.0");
let plugins = scan_marketplace(dir.path()).entries;
assert_eq!(plugins.len(), 2);
assert_eq!(plugins[0].name, "plugin-a");
assert_eq!(plugins[0].version.as_deref(), Some("1.0.0"));
assert_eq!(plugins[0].skill_count, 1);
assert!(plugins[0].keywords.is_empty());
assert_eq!(plugins[1].name, "plugin-b");
}
#[test]
fn indexed_scan_uses_index() {
let dir = tempfile::tempdir().unwrap();
make_plugin(dir.path(), "indexed-plugin", "1.0.0");
// Create marketplace index.
let claude_dir = dir.path().join(".claude-plugin");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("marketplace.json"),
r#"{
"name": "test-marketplace",
"plugins": [{
"name": "indexed-plugin",
"description": "From index",
"category": "development",
"source": { "type": "local", "path": "./plugins/indexed-plugin" },
"tags": ["test"],
"keywords": ["editor", "code"]
}]
}"#,
)
.unwrap();
let plugins = scan_marketplace(dir.path()).entries;
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "indexed-plugin");
assert_eq!(plugins[0].category.as_deref(), Some("development"));
assert_eq!(plugins[0].tags, vec!["test"]);
assert_eq!(plugins[0].keywords, vec!["editor", "code"]);
// Version comes from per-plugin manifest, not index.
assert_eq!(plugins[0].version.as_deref(), Some("1.0.0"));
}
#[test]
fn url_sourced_entry_carries_keywords() {
let dir = tempfile::tempdir().unwrap();
let grok_dir = dir.path().join(".grok-plugin");
std::fs::create_dir_all(&grok_dir).unwrap();
std::fs::write(
grok_dir.join("marketplace.json"),
r#"{
"name": "kw-marketplace",
"plugins": [{
"name": "remote-plugin",
"source": { "source": "url", "url": "https://github.com/acme/remote-plugin.git" },
"homepage": "https://acme.example.com",
"tags": ["t1"],
"keywords": ["acme", "remote tool"],
"domains": ["acme.example.com"]
}]
}"#,
)
.unwrap();
let plugins = scan_marketplace(dir.path()).entries;
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "remote-plugin");
assert_eq!(
plugins[0].remote_url.as_deref(),
Some("https://github.com/acme/remote-plugin.git")
);
assert_eq!(plugins[0].tags, vec!["t1"]);
assert_eq!(plugins[0].keywords, vec!["acme", "remote tool"]);
assert_eq!(plugins[0].domains, vec!["acme.example.com"]);
assert!(plugins[0].remote_subdir.is_none());
}
#[test]
fn url_sourced_entry_with_path_sets_remote_subdir() {
let dir = tempfile::tempdir().unwrap();
let grok_dir = dir.path().join(".grok-plugin");
std::fs::create_dir_all(&grok_dir).unwrap();
std::fs::write(
grok_dir.join("marketplace.json"),
r#"{
"name": "acme-marketplace",
"plugins": [{
"name": "acme",
"source": {
"source": "url",
"url": "https://github.com/acme/agent-skills.git",
"sha": "61f1903bed7b322c9745f6ba67095bc006de7e63",
"path": "plugins/acme"
}
}]
}"#,
)
.unwrap();
let plugins = scan_marketplace(dir.path()).entries;
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "acme");
assert_eq!(
plugins[0].remote_url.as_deref(),
Some("https://github.com/acme/agent-skills.git")
);
assert_eq!(plugins[0].remote_subdir.as_deref(), Some("plugins/acme"));
}
#[test]
fn indexed_scan_rejects_traversal_path() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
make_plugin(outside.path(), "escaped-plugin", "1.0.0");
let claude_dir = dir.path().join(".claude-plugin");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("marketplace.json"),
r#"{
"name": "test-marketplace",
"plugins": [{
"name": "escaped-plugin",
"source": { "type": "local", "path": "../escaped-plugin" }
}]
}"#,
)
.unwrap();
let plugins = scan_marketplace(dir.path()).entries;
assert!(plugins.is_empty());
}
#[test]
fn indexed_scan_rejects_symlink_escape() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
make_plugin(outside.path(), "escaped-plugin", "1.0.0");
#[cfg(unix)]
std::os::unix::fs::symlink(
outside.path().join("plugins").join("escaped-plugin"),
dir.path().join("escaped"),
)
.unwrap();
let claude_dir = dir.path().join(".claude-plugin");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("marketplace.json"),
r#"{
"name": "test-marketplace",
"plugins": [{
"name": "escaped-plugin",
"source": { "type": "local", "path": "escaped" }
}]
}"#,
)
.unwrap();
let plugins = scan_marketplace(dir.path()).entries;
assert!(plugins.is_empty());
}
#[test]
fn grok_plugin_dir_index_drives_scan_end_to_end() {
let dir = tempfile::tempdir().unwrap();
make_plugin(dir.path(), "grok-plugin", "1.0.0");
let grok_dir = dir.path().join(".grok-plugin");
std::fs::create_dir_all(&grok_dir).unwrap();
std::fs::write(
grok_dir.join("marketplace.json"),
r#"{
"name": "grok-marketplace",
"plugins": [{
"name": "grok-plugin",
"description": "From the .grok-plugin index",
"category": "design",
"source": { "type": "local", "path": "./plugins/grok-plugin" },
"tags": ["grok"]
}]
}"#,
)
.unwrap();
let plugins = scan_marketplace(dir.path()).entries;
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "grok-plugin");
assert_eq!(plugins[0].category.as_deref(), Some("design"));
assert_eq!(plugins[0].tags, vec!["grok"]);
assert!(plugins[0].keywords.is_empty());
}
#[test]
fn invalid_index_falls_back_to_filesystem() {
let dir = tempfile::tempdir().unwrap();
make_plugin(dir.path(), "fallback-plugin", "1.0.0");
let claude_dir = dir.path().join(".claude-plugin");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(claude_dir.join("marketplace.json"), "not valid json").unwrap();
let plugins = scan_marketplace(dir.path()).entries;
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "fallback-plugin");
}
#[test]
fn empty_marketplace() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("plugins")).unwrap();
let plugins = scan_marketplace(dir.path()).entries;
assert!(plugins.is_empty());
}
#[test]
fn no_plugins_dir() {
let dir = tempfile::tempdir().unwrap();
let plugins = scan_marketplace(dir.path()).entries;
assert!(plugins.is_empty());
}
#[test]
fn plugin_with_hooks() {
let dir = tempfile::tempdir().unwrap();
let plugin_dir = dir.path().join("plugins").join("hooked");
std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap();
std::fs::write(
plugin_dir.join("hooks").join("hooks.json"),
r#"{"hooks":{}}"#,
)
.unwrap();
let plugins = scan_marketplace(dir.path()).entries;
assert_eq!(plugins.len(), 1);
assert!(plugins[0].has_hooks);
assert_eq!(plugins[0].name, "hooked");
}
fn write_grok_file(dir: &Path, file: &str, content: &str) {
let grok_dir = dir.join(".grok-plugin");
std::fs::create_dir_all(&grok_dir).unwrap();
std::fs::write(grok_dir.join(file), content).unwrap();
}
#[test]
fn catalog_attaches_components_to_indexed_local_entry() {
let dir = tempfile::tempdir().unwrap();
make_plugin(dir.path(), "plugin-a", "1.0.0");
write_grok_file(
dir.path(),
"marketplace.json",
r#"{
"name": "m",
"plugins": [
{ "name": "plugin-a", "source": { "type": "local", "path": "./plugins/plugin-a" } }
]
}"#,
);
write_grok_file(
dir.path(),
"plugin-index.json",
r#"{
"version": 1,
"plugins": {
"plugin-a": {
"components": {
"skills": [ { "name": "my-skill", "description": "Does things" } ],
"commands": [ { "name": "/go" } ]
}
}
}
}"#,
);
let scan = scan_marketplace(dir.path());
assert!(scan.catalog_loaded);
assert_eq!(scan.entries.len(), 1);
let components = scan.entries[0].components.as_ref().unwrap();
assert_eq!(components.skills[0].name, "my-skill");
assert_eq!(
components.skills[0].description.as_deref(),
Some("Does things")
);
assert_eq!(components.commands[0].name, "/go");
// Legacy scan fields still populated alongside catalog data.
assert_eq!(scan.entries[0].skill_count, 1);
}
#[test]
fn catalog_lookup_keyed_by_index_name_not_manifest_name() {
let dir = tempfile::tempdir().unwrap();
// Manifest name "plugin-a" diverges from index name "index-name".
make_plugin(dir.path(), "plugin-a", "1.0.0");
write_grok_file(
dir.path(),
"marketplace.json",
r#"{
"name": "m",
"plugins": [
{ "name": "index-name", "source": { "type": "local", "path": "./plugins/plugin-a" } }
]
}"#,
);
write_grok_file(
dir.path(),
"plugin-index.json",
r#"{
"version": 1,
"plugins": {
"index-name": { "components": { "skills": [ { "name": "indexed-skill" } ] } },
"plugin-a": { "components": { "skills": [ { "name": "wrong-skill" } ] } }
}
}"#,
);
let scan = scan_marketplace(dir.path());
assert_eq!(scan.entries.len(), 1);
assert_eq!(scan.entries[0].name, "plugin-a");
let components = scan.entries[0].components.as_ref().unwrap();
assert_eq!(components.skills[0].name, "indexed-skill");
}
fn url_marketplace_index(sha_field: &str) -> String {
format!(
r#"{{
"name": "m",
"plugins": [{{
"name": "remote-plugin",
"source": {{ "source": "url", "url": "https://example.com/r.git"{sha_field} }}
}}]
}}"#
)
}
const URL_CATALOG: &str = r#"{
"version": 1,
"plugins": {
"remote-plugin": {
"sha": "61f1903bed7b322c9745f6ba67095bc006de7e63",
"components": { "skills": [ { "name": "remote-skill" } ] }
}
}
}"#;
#[test]
fn url_entry_gets_components_when_sha_matches() {
let dir = tempfile::tempdir().unwrap();
write_grok_file(
dir.path(),
"marketplace.json",
&url_marketplace_index(r#", "sha": "61f1903bed7b322c9745f6ba67095bc006de7e63""#),
);
write_grok_file(dir.path(), "plugin-index.json", URL_CATALOG);
let scan = scan_marketplace(dir.path());
assert!(scan.catalog_loaded);
let components = scan.entries[0].components.as_ref().unwrap();
assert_eq!(components.skills[0].name, "remote-skill");
}
#[test]
fn url_entry_components_hidden_on_sha_mismatch() {
let dir = tempfile::tempdir().unwrap();
write_grok_file(
dir.path(),
"marketplace.json",
&url_marketplace_index(r#", "sha": "0000000000000000000000000000000000000000""#),
);
write_grok_file(dir.path(), "plugin-index.json", URL_CATALOG);
let scan = scan_marketplace(dir.path());
assert!(scan.catalog_loaded);
assert!(scan.entries[0].components.is_none());
}
#[test]
fn url_entry_without_pinned_sha_gets_no_components() {
let dir = tempfile::tempdir().unwrap();
write_grok_file(dir.path(), "marketplace.json", &url_marketplace_index(""));
write_grok_file(dir.path(), "plugin-index.json", URL_CATALOG);
let scan = scan_marketplace(dir.path());
assert!(scan.catalog_loaded);
assert!(scan.entries[0].components.is_none());
}
#[test]
fn malformed_catalog_degrades_to_no_components() {
let dir = tempfile::tempdir().unwrap();
make_plugin(dir.path(), "plugin-a", "1.0.0");
write_grok_file(
dir.path(),
"marketplace.json",
r#"{
"name": "m",
"plugins": [
{ "name": "plugin-a", "source": { "type": "local", "path": "./plugins/plugin-a" } }
]
}"#,
);
write_grok_file(dir.path(), "plugin-index.json", "not json");
let scan = scan_marketplace(dir.path());
assert!(!scan.catalog_loaded);
assert_eq!(scan.entries.len(), 1);
assert!(scan.entries[0].components.is_none());
}
#[test]
fn filesystem_fallback_ignores_catalog() {
let dir = tempfile::tempdir().unwrap();
make_plugin(dir.path(), "plugin-a", "1.0.0");
write_grok_file(
dir.path(),
"plugin-index.json",
r#"{
"version": 1,
"plugins": { "plugin-a": { "components": { "skills": [ { "name": "s" } ] } } }
}"#,
);
let scan = scan_marketplace(dir.path());
assert!(!scan.catalog_loaded);
assert_eq!(scan.entries.len(), 1);
assert!(scan.entries[0].components.is_none());
assert_eq!(scan.entries[0].skill_count, 1);
}
#[test]
fn default_skills_virtual_plugin_has_no_components() {
let dir = tempfile::tempdir().unwrap();
write_grok_file(
dir.path(),
"marketplace.json",
r#"{"name": "m", "plugins": []}"#,
);
write_grok_file(
dir.path(),
"plugin-index.json",
r#"{
"version": 1,
"plugins": { "default-skills": { "components": { "skills": [ { "name": "s" } ] } } }
}"#,
);
let skill_dir = dir.path().join("default-skills").join("a-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "# A Skill").unwrap();
let scan = scan_marketplace(dir.path());
assert!(scan.catalog_loaded);
assert_eq!(scan.entries.len(), 1);
assert_eq!(scan.entries[0].name, "default-skills");
assert_eq!(scan.entries[0].skill_count, 1);
assert!(scan.entries[0].components.is_none());
}
#[test]
fn root_plugin_json_preferred() {
let dir = tempfile::tempdir().unwrap();
let plugin_dir = dir.path().join("plugins").join("root-manifest");
std::fs::create_dir_all(&plugin_dir).unwrap();
std::fs::write(
plugin_dir.join("plugin.json"),
r#"{"name":"root-manifest","version":"2.0.0","description":"Root manifest"}"#,
)
.unwrap();
let plugins = scan_marketplace(dir.path()).entries;
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "root-manifest");
assert_eq!(plugins[0].version.as_deref(), Some("2.0.0"));
}
}

View file

@ -0,0 +1,292 @@
//! Core types for marketplace browse and install.
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MarketplacePathError {
Empty,
Absolute,
ParentComponent,
Prefix,
CurrentComponent,
EscapesRoot,
}
impl std::fmt::Display for MarketplacePathError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => f.write_str("marketplace path is empty"),
Self::Absolute => f.write_str("marketplace path must be relative"),
Self::ParentComponent => {
f.write_str("marketplace path must not contain parent components")
}
Self::Prefix => f.write_str("marketplace path must not contain a platform prefix"),
Self::CurrentComponent => {
f.write_str("marketplace path must not contain current-directory components")
}
Self::EscapesRoot => f.write_str("marketplace path escapes marketplace root"),
}
}
}
impl std::error::Error for MarketplacePathError {}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MarketplaceRelativePath(String);
impl MarketplaceRelativePath {
pub fn parse(input: &str) -> Result<Self, MarketplacePathError> {
let stripped = input.strip_prefix("./").unwrap_or(input);
if stripped.is_empty() {
return Err(MarketplacePathError::Empty);
}
let path = Path::new(stripped);
if path.is_absolute() {
return Err(MarketplacePathError::Absolute);
}
for segment in stripped.split(['/', '\\']) {
match segment {
"" => return Err(MarketplacePathError::Prefix),
"." => return Err(MarketplacePathError::CurrentComponent),
".." => return Err(MarketplacePathError::ParentComponent),
value if value.contains(':') => return Err(MarketplacePathError::Prefix),
_ => {}
}
}
let mut normalized = Vec::new();
for component in path.components() {
match component {
Component::Normal(part) => {
let part = part.to_str().ok_or(MarketplacePathError::Prefix)?;
for split in part.split('\\') {
match split {
"" => return Err(MarketplacePathError::Prefix),
"." => return Err(MarketplacePathError::CurrentComponent),
".." => return Err(MarketplacePathError::ParentComponent),
value if value.contains(':') => {
return Err(MarketplacePathError::Prefix);
}
value => normalized.push(value.to_string()),
}
}
}
Component::CurDir => return Err(MarketplacePathError::CurrentComponent),
Component::ParentDir => return Err(MarketplacePathError::ParentComponent),
Component::RootDir => return Err(MarketplacePathError::Absolute),
Component::Prefix(_) => return Err(MarketplacePathError::Prefix),
}
}
if normalized.is_empty() {
return Err(MarketplacePathError::Empty);
}
Ok(Self(normalized.join("/")))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn join_under(&self, root: &Path) -> Result<PathBuf, MarketplacePathError> {
let candidate = root.join(&self.0);
let canonical_root =
dunce::canonicalize(root).map_err(|_| MarketplacePathError::EscapesRoot)?;
let mut current = candidate.as_path();
let mut missing_suffix = Vec::new();
while !current.exists() {
let Some(name) = current.file_name() else {
return Err(MarketplacePathError::EscapesRoot);
};
missing_suffix.push(name.to_os_string());
current = current.parent().ok_or(MarketplacePathError::EscapesRoot)?;
}
let canonical_existing =
dunce::canonicalize(current).map_err(|_| MarketplacePathError::EscapesRoot)?;
// Fail-closed >MAX_PATH caveat: see workspace clippy.toml.
if !canonical_existing.starts_with(&canonical_root) {
return Err(MarketplacePathError::EscapesRoot);
}
let mut resolved = canonical_existing;
for component in missing_suffix.iter().rev() {
resolved.push(component);
}
Ok(resolved)
}
}
/// A configured marketplace source.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceSource {
/// User-facing display name.
pub name: String,
/// How to access the marketplace.
pub kind: SourceKind,
}
/// How to access a marketplace source.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SourceKind {
/// A local directory containing a `plugins/` subdirectory.
Local { path: PathBuf },
/// A git repo. Cloned/pulled to a persistent cache on refresh.
Git { url: String, branch: Option<String> },
}
/// A plugin found by scanning a marketplace.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceEntry {
/// Plugin name (from manifest or index).
pub name: String,
/// Version string (from manifest).
pub version: Option<String>,
/// Human-readable description.
pub description: Option<String>,
/// Category (from index, e.g., "development", "productivity").
pub category: Option<String>,
/// Author name.
pub author: Option<String>,
/// Tags/keywords (from index).
#[serde(default)]
pub tags: Vec<String>,
/// Matcher keywords (from index).
#[serde(default)]
pub keywords: Vec<String>,
/// Matcher domains (from index).
#[serde(default)]
pub domains: Vec<String>,
/// Homepage URL (from index).
pub homepage: Option<String>,
/// Relative path within marketplace (e.g., "plugins/xai-code-review").
pub relative_path: String,
/// Number of skills discovered.
pub skill_count: usize,
/// Whether the plugin has hooks.
pub has_hooks: bool,
/// Whether the plugin has agents.
pub has_agents: bool,
/// Whether the plugin has MCP configuration.
pub has_mcp: bool,
/// Remote git URL for URL-sourced plugins (not present for local plugins).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_url: Option<String>,
/// Git ref (branch/tag) for remote URL sources.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_subdir: Option<String>,
/// Structured inventory from the marketplace catalog (`plugin-index.json`).
/// `None` = no catalog data for this plugin.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub components: Option<xai_hooks_plugins_types::PluginComponents>,
}
/// Result of a marketplace scan, with catalog telemetry.
#[derive(Debug, Clone)]
pub struct MarketplaceScan {
pub entries: Vec<MarketplaceEntry>,
/// Whether a `plugin-index.json` catalog was loaded for this marketplace.
pub catalog_loaded: bool,
}
#[cfg(test)]
mod tests {
use super::{MarketplaceEntry, MarketplacePathError, MarketplaceRelativePath};
#[test]
fn marketplace_relative_path_rejects_absolute_parent_and_prefix() {
let rejected = [
"/plugins/foo",
"plugins/../secret",
"plugins/foo/.",
"C:\\plugins\\foo",
"\\\\server\\share\\plugins\\foo",
];
for path in rejected {
assert!(
MarketplaceRelativePath::parse(path).is_err(),
"path should be rejected: {path}"
);
}
assert_eq!(
MarketplaceRelativePath::parse("").unwrap_err(),
MarketplacePathError::Empty
);
}
#[test]
fn marketplace_relative_path_accepts_normalized_index_path() {
let path = MarketplaceRelativePath::parse("./plugins/foo").unwrap();
assert_eq!(path.as_str(), "plugins/foo");
let windows_style = MarketplaceRelativePath::parse("plugins\\foo").unwrap();
assert_eq!(windows_style.as_str(), "plugins/foo");
}
#[test]
fn marketplace_relative_path_join_under_rejects_symlink_escape() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
#[cfg(unix)]
{
std::os::unix::fs::symlink(outside.path(), dir.path().join("escape")).unwrap();
let path = MarketplaceRelativePath::parse("escape").unwrap();
assert_eq!(
path.join_under(dir.path()).unwrap_err(),
MarketplacePathError::EscapesRoot
);
}
}
#[test]
fn marketplace_relative_path_join_under_rejects_symlink_ancestor_escape() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
#[cfg(unix)]
{
std::os::unix::fs::symlink(outside.path(), dir.path().join("plugins")).unwrap();
let path = MarketplaceRelativePath::parse("plugins/evil").unwrap();
assert_eq!(
path.join_under(dir.path()).unwrap_err(),
MarketplacePathError::EscapesRoot
);
}
}
#[test]
fn discovered_plugin_serde_roundtrip() {
let plugin = MarketplaceEntry {
name: "test-plugin".into(),
version: Some("1.0.0".into()),
description: Some("A test plugin".into()),
category: Some("development".into()),
author: Some("Test Author".into()),
tags: vec!["test".into(), "example".into()],
keywords: vec!["notion.so".into()],
domains: vec!["notion.so".into()],
homepage: None,
relative_path: "plugins/test-plugin".into(),
skill_count: 3,
has_hooks: true,
has_agents: false,
has_mcp: false,
remote_url: None,
remote_ref: None,
remote_sha: None,
remote_subdir: None,
components: None,
};
let json = serde_json::to_string(&plugin).unwrap();
let parsed: MarketplaceEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "test-plugin");
assert_eq!(parsed.keywords, vec!["notion.so"]);
}
}