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
change kmlmkuyqqqmzoqwzmpolmrknnuuvpzos
commit 3ac51aac7eb0afa80292fc3eb479b2a70f9fbde3
author Alpha Chen <alpha@kejadlen.dev>
date
parent toszknou
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()
+    )
+}