Changes: - Gate session-lifecycle heap steady state with a dhat soak - Unbreak merge lifecycle e2e after default model → grok-4.5 - Scan home-scope rules dirs at <root>/rules - Complete text-input paste and terminal parity - Gate project roles and personas - Use canonical editing in dialogs - Use canonical editing in search bars - Reject ambiguous MCP tool IDs - Harden Git operands for plugins - Simplify queue drain API - Pass RFC 9207 iss through MCP OAuth token exchange - Show leader roster when local agents map is empty - Use canonical editing in Persona views - Remove marketplace default-skills auto-install and purge old installs - Use canonical editing in extension forms - Add canonical dashboard text editing - Use canonical editing in settings - Add /summarize as a /recap alias - Restore previous agent when exiting dashboard - Use tool_choice auto for compaction - Settings toggle for snap-prompt-to-top on send - Update default models to grok-4.5 - Source login shell once for local bash (env + alias/function snapshot) - Template hardcoded param names in server-native tool descriptions - Fix System-Reminder XML tag injection in CLAUDE.md via agents_md - Fix remote workspace-server hardcoding LSP trust (repo code execution risk) - Clear orphaned tool-call updates at turn end - Suppress task wake after cancel - Send x-grok-client-identifier on direct API tool calls - Harden dashboard peek lease transitions - Host /btw side panel in live region (minimal mode) - Bound scroll presentation latency - Highlight multi-line constructs correctly in diffs and the file viewer - Block web_fetch non-public IPs; local opt-in is explicit-host only - Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness - Follow up clipboard delivery feedback - Use canonical editing in pickers - Route TextArea through canonical editor - Persistent "watching" status row; quieter turn markers - Gate sensitive edit targets - Expose agent registry counts and gate session churn on them - Default coding data sharing to opt-out until server preference applies - Wire chat attachment ids through gateway prompts - On auth refresh failure, issue retry - Forward preview provenance and computer lifecycle state - Document independent privacy controls and scope /privacy output - Strip SamplingError Display prefix on rate-limit UI copy - Stop dumping Cloudflare HTML into Retry failed - Disable in-place prompt edit (scroll jank on enter) - Strip forced ANSI color from gh pr view JSON - Plumb bash tool description onto ToolUsageCard wire
721 lines
26 KiB
Rust
721 lines
26 KiB
Rust
//! 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. The component
|
|
/// catalog is only consulted in indexed mode: its keys are defined as index
|
|
/// names, so the filesystem fallback ignores it.
|
|
pub fn scan_marketplace(root: &Path) -> MarketplaceScan {
|
|
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 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"));
|
|
}
|
|
}
|