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
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>,
+}