Add named marketplaces to config
Plugins can reference a marketplace by name to inherit src/ref, or
specify src/ref directly. Marketplace entries support a manifest field
to override the marketplace.json path.

Assisted-by: Claude Opus 4.6 via pi
change znrppnmkpkwklpyvwyuslpzqsnmzrnxr
commit 077f8b9771949b995f155a2ceacc0c1b1c45e525
author Alpha Chen <alpha@kejadlen.dev>
date
parent nuvlnnrt
diff --git a/README.md b/README.md
index 455084f..41afd66 100644
--- a/README.md
+++ b/README.md
@@ -42,20 +42,28 @@ Config files live in `~/.config/pinch/conf.d/`. All `.yml` files are merged
 alphabetically. Duplicate skill names across files are a hard error.
 
 ```yml
+marketplaces:
+  my-skills:
+    src: https://github.com/user/skills-repo
+    ref: main                 # default ref for plugins from this marketplace
+    manifest: custom/path/marketplace.json  # optional, defaults to .claude-plugin/marketplace.json
+
 plugins:
   jj:
-    src: https://github.com/user/skills-repo
-    ref: main                 # branch or tag; resolved to a commit SHA in lockfile
+    marketplace: my-skills    # inherits src, ref, and manifest from the marketplace
 
   gh-pr:
-    src: https://github.com/user/skills-repo
-    ref: v1.2.0
-    marketplace: custom/path/marketplace.json  # optional override
+    marketplace: my-skills
+    ref: v1.2.0               # override the marketplace default ref
+
+  one-off-skill:
+    src: https://github.com/other/repo   # standalone plugin, no marketplace
+    ref: main
 ```
 
-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.
+Plugins can reference a named marketplace (inheriting `src` and `ref`) or
+specify `src`/`ref` directly. The plugin path within the repo is discovered
+from `.claude-plugin/marketplace.json` (or the marketplace's `manifest` override).
 
 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 4a6e0d0..ca2c9b6 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -9,7 +9,7 @@ pub struct Plugin {
     pub name: String,
     pub src: String,
     pub r#ref: String,
-    /// Override path to marketplace.json within the repo.
+    /// Path to marketplace.json within the repo.
     /// Defaults to `.claude-plugin/marketplace.json`.
     pub marketplace: Option<String>,
 }
@@ -36,27 +36,35 @@ pub fn load() -> Result<Vec<Plugin>> {
         bail!("no .yml files found in {}", config_dir.display());
     }
 
+    let mut marketplaces: BTreeMap<String, MarketplaceEntry> = BTreeMap::new();
     let mut plugins = Vec::new();
     let mut seen_names = HashSet::new();
 
-    for entry in entries {
+    // First pass: collect all marketplaces
+    for entry in &entries {
         let path = entry.path();
         let contents = std::fs::read_to_string(&path)
             .with_context(|| format!("failed to read {}", path.display()))?;
+        let file: ConfigFile = serde_yml::from_str(&contents)
+            .with_context(|| format!("failed to parse {}", path.display()))?;
 
-        #[derive(Deserialize)]
-        struct PluginEntry {
-            src: String,
-            r#ref: String,
-            marketplace: Option<String>,
-        }
-
-        #[derive(Deserialize)]
-        struct ConfigFile {
-            #[serde(default)]
-            plugins: BTreeMap<String, PluginEntry>,
+        for (name, mkt) in file.marketplaces {
+            if marketplaces.contains_key(&name) {
+                bail!(
+                    "duplicate marketplace '{}' (found in {})",
+                    name,
+                    path.display()
+                );
+            }
+            marketplaces.insert(name, mkt);
         }
+    }
 
+    // Second pass: resolve plugins
+    for entry in &entries {
+        let path = entry.path();
+        let contents = std::fs::read_to_string(&path)
+            .with_context(|| format!("failed to read {}", path.display()))?;
         let file: ConfigFile = serde_yml::from_str(&contents)
             .with_context(|| format!("failed to parse {}", path.display()))?;
 
@@ -68,14 +76,70 @@ pub fn load() -> Result<Vec<Plugin>> {
                     path.display()
                 );
             }
+
+            let (src, r#ref, marketplace_path) = match entry.marketplace {
+                Some(mkt_name) => {
+                    let mkt = marketplaces.get(&mkt_name).ok_or_else(|| {
+                        color_eyre::eyre::eyre!(
+                            "plugin '{}' references unknown marketplace '{}'",
+                            name,
+                            mkt_name
+                        )
+                    })?;
+                    (
+                        mkt.src.clone(),
+                        entry.r#ref.unwrap_or_else(|| mkt.r#ref.clone()),
+                        mkt.manifest.clone(),
+                    )
+                }
+                None => {
+                    let src = entry.src.ok_or_else(|| {
+                        color_eyre::eyre::eyre!(
+                            "plugin '{}': must specify either 'src' or 'marketplace'",
+                            name
+                        )
+                    })?;
+                    let r#ref = entry.r#ref.ok_or_else(|| {
+                        color_eyre::eyre::eyre!("plugin '{}': 'ref' is required", name)
+                    })?;
+                    (src, r#ref, None)
+                }
+            };
+
             plugins.push(Plugin {
                 name,
-                src: entry.src,
-                r#ref: entry.r#ref,
-                marketplace: entry.marketplace,
+                src,
+                r#ref,
+                marketplace: marketplace_path,
             });
         }
     }
 
     Ok(plugins)
 }
+
+#[derive(Deserialize)]
+struct MarketplaceEntry {
+    src: String,
+    r#ref: String,
+    /// Override path to marketplace.json within the repo.
+    manifest: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct PluginEntry {
+    /// Git repo URL. Required if no marketplace.
+    src: Option<String>,
+    /// Git ref. Required if no marketplace (optional override with marketplace).
+    r#ref: Option<String>,
+    /// Name of a marketplace defined in the config.
+    marketplace: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct ConfigFile {
+    #[serde(default)]
+    marketplaces: BTreeMap<String, MarketplaceEntry>,
+    #[serde(default)]
+    plugins: BTreeMap<String, PluginEntry>,
+}