Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
785
crates/codegen/xai-grok-plugin-marketplace/src/scanner.rs
Normal file
785
crates/codegen/xai-grok-plugin-marketplace/src/scanner.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue