Discover plugin paths from marketplace.json instead of config
Remove path from config. During update, read .claude-plugin/marketplace.json
to find each plugin's source path. Allow overriding the marketplace.json
location per plugin.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/Cargo.lock b/Cargo.lock
index accd018..650c6d5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -321,6 +321,7 @@ dependencies = [
"clap",
"color-eyre",
"serde",
+ "serde_json",
"serde_yml",
"tracing",
"tracing-subscriber",
@@ -387,6 +388,19 @@ dependencies = [
"syn",
]
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
[[package]]
name = "serde_yml"
version = "0.0.12"
@@ -554,3 +568,9 @@ name = "xdg"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
index 187ab7b..7b94347 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,5 +9,6 @@ color-eyre = "*"
tracing = "*"
tracing-subscriber = "*"
serde = { version = "*", features = ["derive"] }
+serde_json = "*"
serde_yml = "*"
xdg = "*"
diff --git a/README.md b/README.md
index 5f5f694..455084f 100644
--- a/README.md
+++ b/README.md
@@ -43,19 +43,19 @@ alphabetically. Duplicate skill names across files are a hard error.
```yml
plugins:
- - name: jj
+ jj:
src: https://github.com/user/skills-repo
- path: skills/jj # path within the repo to the skill directory
ref: main # branch or tag; resolved to a commit SHA in lockfile
- - name: gh-pr
+ gh-pr:
src: https://github.com/user/skills-repo
- path: skills/gh-pr
ref: v1.2.0
+ marketplace: custom/path/marketplace.json # optional override
```
-A single repo can provide multiple skills — just add more entries with different
-`path` values.
+The plugin path within the repo is discovered from the marketplace manifest
+(`.claude-plugin/marketplace.json` by default). The `marketplace` field lets you
+override this path per plugin.
Removing a skill is done by removing it from the config and re-running
`pinch install`.
diff --git a/src/config.rs b/src/config.rs
index a83659d..a9d9193 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,3 +1,4 @@
+use std::collections::BTreeMap;
use std::collections::HashSet;
use color_eyre::eyre::{Context, Result, bail};
@@ -6,15 +7,24 @@ use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct ConfigFile {
#[serde(default)]
- pub plugins: Vec<Plugin>,
+ pub plugins: BTreeMap<String, PluginEntry>,
}
#[derive(Debug, Clone, Deserialize)]
+pub struct PluginEntry {
+ pub src: String,
+ pub r#ref: String,
+ /// Override path to marketplace.json within the repo.
+ /// Defaults to `.claude-plugin/marketplace.json`.
+ pub marketplace: Option<String>,
+}
+
+#[derive(Debug, Clone)]
pub struct Plugin {
pub name: String,
pub src: String,
- pub path: String,
pub r#ref: String,
+ pub marketplace: Option<String>,
}
#[derive(Debug)]
@@ -54,15 +64,20 @@ pub fn load() -> Result<Config> {
let file: ConfigFile = serde_yml::from_str(&contents)
.with_context(|| format!("failed to parse {}", path.display()))?;
- for plugin in file.plugins {
- if !seen_names.insert(plugin.name.clone()) {
+ for (name, plugin) in file.plugins {
+ if !seen_names.insert(name.clone()) {
bail!(
"duplicate plugin name '{}' (found in {})",
- plugin.name,
+ name,
path.display()
);
}
- plugins.push(plugin);
+ plugins.push(Plugin {
+ name,
+ src: plugin.src,
+ r#ref: plugin.r#ref,
+ marketplace: plugin.marketplace,
+ });
}
}
diff --git a/src/main.rs b/src/main.rs
index de2bba3..e716227 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
mod config;
mod git;
mod lockfile;
+mod marketplace;
mod paths;
use clap::{Parser, Subcommand};
@@ -106,6 +107,16 @@ fn do_update(names: &[String]) -> Result<()> {
let rev = git::resolve_ref(&repo_path, &plugin.r#ref)?;
info!(" {} -> {}", plugin.r#ref, &rev[..12]);
+ // Checkout to read marketplace.json and discover skill path
+ git::checkout(&repo_path, &rev)?;
+ let source = marketplace::find_plugin_source(
+ &repo_path,
+ &plugin.name,
+ plugin.marketplace.as_deref(),
+ )?;
+ let path = source.trim_start_matches("./").to_string();
+ info!(" found at {}", path);
+
// Remove existing entry for this plugin if doing selective update
if !names.is_empty() {
lockfile.plugins.retain(|p| p.name != plugin.name);
@@ -114,7 +125,7 @@ fn do_update(names: &[String]) -> Result<()> {
lockfile.plugins.push(lockfile::LockedPlugin {
name: plugin.name.clone(),
src: plugin.src.clone(),
- path: plugin.path.clone(),
+ path,
rev,
});
}
diff --git a/src/marketplace.rs b/src/marketplace.rs
new file mode 100644
index 0000000..d765a24
--- /dev/null
+++ b/src/marketplace.rs
@@ -0,0 +1,51 @@
+use std::path::Path;
+
+use color_eyre::eyre::{Context, Result, bail};
+use serde::Deserialize;
+
+const DEFAULT_PATH: &str = ".claude-plugin/marketplace.json";
+
+#[derive(Debug, Deserialize)]
+pub struct Marketplace {
+ pub plugins: Vec<MarketplacePlugin>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct MarketplacePlugin {
+ pub name: String,
+ pub source: serde_json::Value,
+}
+
+/// Find the source path for a plugin by name in the marketplace.json.
+pub fn find_plugin_source(
+ repo_path: &Path,
+ plugin_name: &str,
+ marketplace_override: Option<&str>,
+) -> Result<String> {
+ let manifest_path = repo_path.join(marketplace_override.unwrap_or(DEFAULT_PATH));
+
+ let contents = std::fs::read_to_string(&manifest_path)
+ .with_context(|| format!("failed to read {}", manifest_path.display()))?;
+
+ let marketplace: Marketplace = serde_json::from_str(&contents)
+ .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
+
+ for mp in &marketplace.plugins {
+ if mp.name == plugin_name {
+ return match &mp.source {
+ serde_json::Value::String(s) => Ok(s.clone()),
+ _ => bail!(
+ "plugin '{}': unsupported source type in {} (only string paths are supported)",
+ plugin_name,
+ manifest_path.display()
+ ),
+ };
+ }
+ }
+
+ bail!(
+ "plugin '{}' not found in {}",
+ plugin_name,
+ manifest_path.display()
+ )
+}