grok-build-upstream-mirror/crates/codegen/xai-grok-plugin-marketplace/src/scanner.rs
grokkybara[bot] 7cfcb20d2b Synced from monorepo
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
2026-07-18 19:48:28 +01:00

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"));
}
}