pinch: XDG cache, marketplace.json, project manifest rules, container guard
- Clone repos into $XDG_CACHE_HOME/pinch/, copy only selected plugins into .pi/pinch/
- Resolve plugin paths via marketplace.json source field, explicit path, or root fallback
- Cache marketplace.json reads per source, clear on checkout
- Project manifests can define new sources or reference global ones (plugins only)
- Error if install/update is run inside a container
- Full clones instead of shallow (fixes lock file commit checkout)
- Skip symlinks in copyDir, clean empty source dirs on prune
- Reload on prune, progress output during install/update
- Add pinch skill with manifest format, commands, and troubleshooting
- Add claude-plugins-official source for playground plugin
- Document pinch in AGENTS.md
change lyqwvxqzmoswrnzyxspzmttlurwrrprw
commit 4b5a9b935fc56ad1ee51d75569cf970494d7ccc5
author Alpha Chen <alpha@kejadlen.dev>
date
parent kwyzsymn
diff --git a/.pi/.gitignore b/.pi/.gitignore
index 9ec2d4bb..2f6af6d4 100644
--- a/.pi/.gitignore
+++ b/.pi/.gitignore
@@ -3,3 +3,4 @@ agent/*
 !agent/keybindings.json
 !agent/pinch.json
 !agent/settings.json
+!agent/skills/
diff --git a/.pi/agent/extensions/pinch.ts b/.pi/agent/extensions/pinch.ts
index c80fcd54..e7468c60 100644
--- a/.pi/agent/extensions/pinch.ts
+++ b/.pi/agent/extensions/pinch.ts
@@ -5,15 +5,15 @@
  * Global manifest: ~/.pi/agent/pinch.json
  * Project manifest: .pi/pinch.json (overrides global by name)
  *
- * Repos are cloned directly into the project at
- * .pi/pinch/<source>/. Skills found in installed plugins are registered
- * via resources_discover.
+ * Repos are cloned into $XDG_CACHE_HOME/pinch/<source>/ as a shared cache.
+ * Only the requested plugins are copied into the project at
+ * .pi/pinch/<source>/<plugin>/. This keeps projects lightweight and works
+ * in bind-mounted containers (no symlinks pointing outside the mount).
  *
- * NOTE: An XDG-based shared cache with symlinks into the project would be
- * cleaner, but these paths need to work inside bind-mounted containers
- * where symlinks pointing outside the mount don't resolve. Hardlinks
- * can't span filesystems and don't work on directories. So we clone
- * directly into the project tree.
+ * Plugin location within a repo is resolved by:
+ * 1. Explicit `path` field in pinch.json (e.g. "plugins")
+ * 2. marketplace.json `source` field (e.g. "./plugins/playground")
+ * 3. Repo root fallback (<repo>/<plugin>/)
  *
  * Manifest format (pinch.json):
  *
@@ -21,7 +21,7 @@
  *     "anthropic": {
  *       "repo": "https://github.com/anthropics/claude-plugins-official",
  *       "ref": "main",
- *       "plugins": ["claude-md-management", "skill-creator"]
+ *       "plugins": ["playground", "skill-creator"]
  *     },
  *     "my-plugins": {
  *       "repo": "git@github.com:me/plugins.git",
@@ -38,12 +38,13 @@
  *     skills/<skill-name>/SKILL.md
  *
  * Commands:
- *   /pinch:install  — clone/fetch all sources, register skills
- *   /pinch:update   — pull latest for unpinned sources
+ *   /pinch:install  — clone/fetch all sources, copy plugins, register skills
+ *   /pinch:update   — pull latest for unpinned sources, re-copy plugins
  *   /pinch:status   — show installed plugins and their skills
  *
  * Lock file (.pi/pinch-lock.json) tracks exact commits per source.
- * Repos live in .pi/pinch/<source-name>/.
+ * The `ref` field in lock entries is informational only — checkout uses
+ * the commit hash directly for reproducibility.
  */
 
 import * as fs from "node:fs";
@@ -67,21 +68,40 @@ interface PinchConfig {
 
 interface PinchLockEntry {
   commit: string;
-  ref?: string;
+  ref?: string; // informational — records which ref produced this commit
 }
 
 interface PinchLock {
   [name: string]: PinchLockEntry;
 }
 
+// ── Container detection ────────────────────────────────────────────────
+
+function inContainer(): boolean {
+  return fs.existsSync("/.dockerenv") || fs.existsSync("/run/.containerenv");
+}
+
 // ── Paths ──────────────────────────────────────────────────────────────
 
-function pinchDir(cwd: string): string {
+/** XDG cache dir for repo clones */
+function cacheDir(): string {
+  const xdg = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
+  return path.join(xdg, "pinch");
+}
+
+/** Cached repo clone for a source */
+function cachedRepoDir(name: string): string {
+  return path.join(cacheDir(), name);
+}
+
+/** Project-local plugin install dir */
+function projectPinchDir(cwd: string): string {
   return path.join(cwd, ".pi", "pinch");
 }
 
-function repoDir(cwd: string, name: string): string {
-  return path.join(pinchDir(cwd), name);
+/** Installed plugin dir within the project */
+function installedPluginDir(cwd: string, sourceName: string, pluginName: string): string {
+  return path.join(projectPinchDir(cwd), sourceName, pluginName);
 }
 
 function globalManifestPath(): string {
@@ -98,15 +118,12 @@ function lockPath(cwd: string): string {
 
 // ── Manifest / Lock ───────────────────────────────────────────────────
 
-function readManifest(filePath: string): PinchConfig | null {
+function readJsonFile(filePath: string): Record<string, any> | null {
   if (!fs.existsSync(filePath)) return null;
   try {
     const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
     if (!raw || typeof raw !== "object") return null;
-    for (const v of Object.values(raw)) {
-      if (typeof v === "object" && v !== null && "repo" in (v as any)) return raw as PinchConfig;
-    }
-    return null;
+    return raw;
   } catch {
     return null;
   }
@@ -114,13 +131,63 @@ function readManifest(filePath: string): PinchConfig | null {
 
 /**
  * Merge global (~/.pi/agent/pinch.json) and project (.pi/pinch.json)
- * manifests. Project entries override global ones by name.
+ * manifests.
+ *
+ * - Global entries must have `repo` and `plugins`.
+ * - Project entries referencing a global key can only set `plugins` —
+ *   `repo`, `ref`, and `path` are disallowed (the global definition owns those).
+ * - Project entries with a new key are full source definitions (must have `repo`).
+ *
+ * Returns null if no valid config is found.
  */
-function readConfig(cwd: string): PinchConfig | null {
-  const global = readManifest(globalManifestPath());
-  const project = readManifest(projectManifestPath(cwd));
-  if (!global && !project) return null;
-  return { ...global, ...project };
+interface ConfigResult {
+  config: PinchConfig;
+  errors: string[];
+}
+
+function readConfig(cwd: string): ConfigResult | null {
+  const globalRaw = readJsonFile(globalManifestPath());
+  const projectRaw = readJsonFile(projectManifestPath(cwd));
+  if (!globalRaw && !projectRaw) return null;
+
+  const config: PinchConfig = {};
+  const errors: string[] = [];
+
+  // Load global entries (must have repo)
+  if (globalRaw) {
+    for (const [name, entry] of Object.entries(globalRaw)) {
+      if (typeof entry !== "object" || entry === null) continue;
+      if (!("repo" in entry) || !("plugins" in entry)) continue;
+      config[name] = entry as PinchSource;
+    }
+  }
+
+  // Merge project entries
+  if (projectRaw) {
+    for (const [name, entry] of Object.entries(projectRaw)) {
+      if (typeof entry !== "object" || entry === null) continue;
+      if (!("plugins" in entry)) continue;
+
+      if (name in config) {
+        // Referencing a global source — only plugins allowed
+        if ("repo" in entry || "ref" in entry || "path" in entry) {
+          errors.push(`${name}: project manifest cannot override repo/ref/path of global source — only plugins allowed`);
+          continue;
+        }
+        config[name] = { ...config[name], plugins: (entry as PinchSource).plugins };
+      } else {
+        // New project-only source — full definition required
+        if (!("repo" in entry)) {
+          errors.push(`${name}: source must have repo`);
+          continue;
+        }
+        config[name] = entry as PinchSource;
+      }
+    }
+  }
+
+  if (Object.keys(config).length === 0 && errors.length === 0) return null;
+  return { config, errors };
 }
 
 function readLock(cwd: string): PinchLock {
@@ -146,7 +213,7 @@ function git(args: string[], opts?: { cwd?: string }): { ok: boolean; stdout: st
       cwd: opts?.cwd,
       encoding: "utf-8",
       stdio: ["pipe", "pipe", "pipe"],
-      timeout: 60_000,
+      timeout: 120_000,
     });
     return { ok: true, stdout: result.trim(), stderr: "" };
   } catch (e: any) {
@@ -158,8 +225,8 @@ function git(args: string[], opts?: { cwd?: string }): { ok: boolean; stdout: st
   }
 }
 
-function cloneOrFetch(name: string, repo: string, cwd: string): { ok: boolean; error?: string } {
-  const dir = repoDir(cwd, name);
+function cloneOrFetch(name: string, repo: string): { ok: boolean; error?: string } {
+  const dir = cachedRepoDir(name);
 
   if (fs.existsSync(path.join(dir, ".git"))) {
     const result = git(["fetch", "--all", "--prune"], { cwd: dir });
@@ -168,13 +235,13 @@ function cloneOrFetch(name: string, repo: string, cwd: string): { ok: boolean; e
   }
 
   fs.mkdirSync(path.dirname(dir), { recursive: true });
-  const result = git(["clone", "--depth", "1", "--no-single-branch", repo, dir]);
+  const result = git(["clone", repo, dir]);
   if (!result.ok) return { ok: false, error: `clone failed: ${result.stderr}` };
   return { ok: true };
 }
 
-function checkout(name: string, ref: string, cwd: string): { ok: boolean; commit?: string; error?: string } {
-  const dir = repoDir(cwd, name);
+function checkout(name: string, ref: string): { ok: boolean; commit?: string; error?: string } {
+  const dir = cachedRepoDir(name);
 
   let target = ref;
   const originRef = git(["rev-parse", "--verify", `origin/${ref}`], { cwd: dir });
@@ -189,22 +256,138 @@ function checkout(name: string, ref: string, cwd: string): { ok: boolean; commit
   return { ok: true, commit: rev.stdout };
 }
 
-function getCurrentCommit(name: string, cwd: string): string | undefined {
-  const dir = repoDir(cwd, name);
+function getCurrentCommit(name: string): string | undefined {
+  const dir = cachedRepoDir(name);
   const rev = git(["rev-parse", "HEAD"], { cwd: dir });
   return rev.ok ? rev.stdout : undefined;
 }
 
+// ── Marketplace ────────────────────────────────────────────────────────
+
+interface MarketplaceEntry {
+  name: string;
+  source: string | { source: string; url?: string; path?: string };
+  [key: string]: any;
+}
+
+interface Marketplace {
+  plugins?: MarketplaceEntry[];
+  [key: string]: any;
+}
+
+/** Per-source cache of marketplace plugin name → source path */
+const marketplaceCache = new Map<string, Map<string, string> | null>();
+
+/**
+ * Read .claude-plugin/marketplace.json from a repo.
+ * Returns a cached map of plugin name → relative source path.
+ */
+function readMarketplace(dir: string): Map<string, string> | null {
+  if (marketplaceCache.has(dir)) return marketplaceCache.get(dir)!;
+
+  const mp = path.join(dir, ".claude-plugin", "marketplace.json");
+  if (!fs.existsSync(mp)) {
+    marketplaceCache.set(dir, null);
+    return null;
+  }
+
+  try {
+    const data = JSON.parse(fs.readFileSync(mp, "utf-8")) as Marketplace;
+    if (!data.plugins) {
+      marketplaceCache.set(dir, null);
+      return null;
+    }
+
+    const map = new Map<string, string>();
+    for (const entry of data.plugins) {
+      if (typeof entry.source === "string") {
+        map.set(entry.name, entry.source);
+      }
+    }
+    const result = map.size > 0 ? map : null;
+    marketplaceCache.set(dir, result);
+    return result;
+  } catch {
+    marketplaceCache.set(dir, null);
+    return null;
+  }
+}
+
+/** Clear the marketplace cache (call after checkout changes the working tree) */
+function clearMarketplaceCache(): void {
+  marketplaceCache.clear();
+}
+
+// ── Plugin resolution ──────────────────────────────────────────────────
+
+/**
+ * Resolve the source directory of a plugin within the cached repo.
+ *
+ * Resolution order:
+ * 1. Explicit `path` in pinch.json → <cache>/<path>/<plugin>/
+ * 2. marketplace.json `source` field → <cache>/<source>/
+ * 3. Repo root fallback → <cache>/<plugin>/
+ */
+function cachedPluginDir(name: string, source: PinchSource, plugin: string): string {
+  const dir = cachedRepoDir(name);
+
+  if (source.path) return path.join(dir, source.path, plugin);
+
+  const marketplace = readMarketplace(dir);
+  if (marketplace) {
+    const sourcePath = marketplace.get(plugin);
+    if (sourcePath) return path.join(dir, sourcePath);
+  }
+
+  return path.join(dir, plugin);
+}
+
+// ── Copy plugins ───────────────────────────────────────────────────────
+
+/** Recursively copy a directory, creating parents as needed. Skips symlinks. */
+function copyDir(src: string, dest: string): void {
+  fs.mkdirSync(dest, { recursive: true });
+  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
+    const srcPath = path.join(src, entry.name);
+    const destPath = path.join(dest, entry.name);
+    if (entry.isSymbolicLink()) {
+      continue; // skip symlinks — they may point outside the repo
+    } else if (entry.isDirectory()) {
+      copyDir(srcPath, destPath);
+    } else if (entry.isFile()) {
+      fs.copyFileSync(srcPath, destPath);
+    }
+  }
+}
+
+/**
+ * Copy a plugin from the cache into the project.
+ * Removes the old copy first to ensure a clean state.
+ */
+function installPlugin(cwd: string, sourceName: string, source: PinchSource, plugin: string): { ok: boolean; error?: string } {
+  const src = cachedPluginDir(sourceName, source, plugin);
+  if (!fs.existsSync(src)) {
+    return { ok: false, error: `plugin "${plugin}" not found in repo` };
+  }
+
+  const dest = installedPluginDir(cwd, sourceName, plugin);
+  fs.rmSync(dest, { recursive: true, force: true });
+  copyDir(src, dest);
+  return { ok: true };
+}
+
 // ── Cleanup ────────────────────────────────────────────────────────────
 
 /**
- * Remove source dirs under .pi/pinch/ that are no longer in the config.
+ * Remove source/plugin dirs under .pi/pinch/ that are no longer in the config.
+ * Cleans up empty source directories after pruning individual plugins.
  */
 function pruneStale(config: PinchConfig, cwd: string): string[] {
-  const dir = pinchDir(cwd);
+  const dir = projectPinchDir(cwd);
   if (!fs.existsSync(dir)) return [];
 
   const pruned: string[] = [];
+
   for (const entry of fs.readdirSync(dir)) {
     const full = path.join(dir, entry);
     try {
@@ -214,8 +397,32 @@ function pruneStale(config: PinchConfig, cwd: string): string[] {
     }
 
     if (!config[entry]) {
+      // Entire source removed
       fs.rmSync(full, { recursive: true, force: true });
       pruned.push(entry);
+    } else {
+      // Prune plugins no longer in the list
+      const wantedPlugins = new Set(config[entry].plugins);
+      for (const pluginEntry of fs.readdirSync(full)) {
+        const pluginFull = path.join(full, pluginEntry);
+        try {
+          if (!fs.statSync(pluginFull).isDirectory()) continue;
+        } catch {
+          continue;
+        }
+        if (!wantedPlugins.has(pluginEntry)) {
+          fs.rmSync(pluginFull, { recursive: true, force: true });
+          pruned.push(`${entry}/${pluginEntry}`);
+        }
+      }
+
+      // Remove empty source directory
+      try {
+        const remaining = fs.readdirSync(full);
+        if (remaining.length === 0) {
+          fs.rmdirSync(full);
+        }
+      } catch {}
     }
   }
 
@@ -224,21 +431,11 @@ function pruneStale(config: PinchConfig, cwd: string): string[] {
 
 // ── Skill discovery ────────────────────────────────────────────────────
 
-/**
- * Resolve the base directory for a source's plugins.
- * If `path` is set (e.g. "plugins"), plugins are under <repo>/<path>/<plugin>/.
- * Otherwise plugins are at the repo root: <repo>/<plugin>/.
- */
-function pluginBaseDir(cwd: string, name: string, source: PinchSource): string {
-  const dir = repoDir(cwd, name);
-  return source.path ? path.join(dir, source.path) : dir;
-}
-
 /**
  * Find all SKILL.md files inside a plugin's skills/ directory.
  */
-function findPluginSkills(pluginDir: string): string[] {
-  const skillsDir = path.join(pluginDir, "skills");
+function findPluginSkills(dir: string): string[] {
+  const skillsDir = path.join(dir, "skills");
   if (!fs.existsSync(skillsDir)) return [];
 
   const paths: string[] = [];
@@ -253,17 +450,17 @@ function findPluginSkills(pluginDir: string): string[] {
 }
 
 /**
- * Collect all skill paths from installed plugins.
+ * Collect all skill paths from installed plugins (project copies).
  */
 function resolveSkillPaths(config: PinchConfig, cwd: string): string[] {
   const paths: string[] = [];
 
   for (const [name, source] of Object.entries(config)) {
-    const base = pluginBaseDir(cwd, name, source);
-    if (!fs.existsSync(base)) continue;
-
     for (const plugin of source.plugins) {
-      paths.push(...findPluginSkills(path.join(base, plugin)));
+      const dir = installedPluginDir(cwd, name, plugin);
+      if (fs.existsSync(dir)) {
+        paths.push(...findPluginSkills(dir));
+      }
     }
   }
 
@@ -275,34 +472,41 @@ function resolveSkillPaths(config: PinchConfig, cwd: string): string[] {
 interface SyncResult {
   installed: string[];
   updated: string[];
+  copied: string[];
   pruned: string[];
   skills: string[];
   errors: string[];
 }
 
-function installAll(cwd: string, update: boolean): SyncResult {
-  const config = readConfig(cwd);
-  if (!config) return { installed: [], updated: [], pruned: [], errors: ['No pinch.json manifest found'], skills: [] };
+type Log = (msg: string) => void;
 
+function installAll(cwd: string, update: boolean, log: Log): SyncResult {
+  const cr = readConfig(cwd);
+  if (!cr) return { installed: [], updated: [], copied: [], pruned: [], errors: ['No pinch.json manifest found'], skills: [] };
+
+  const { config, errors: configErrors } = cr;
   const lock = readLock(cwd);
   const newLock: PinchLock = {};
-  const result: SyncResult = { installed: [], updated: [], pruned: [], errors: [], skills: [] };
+  const result: SyncResult = { installed: [], updated: [], copied: [], pruned: [], errors: [...configErrors], skills: [] };
 
   for (const [name, source] of Object.entries(config)) {
-    const repoExists = fs.existsSync(path.join(repoDir(cwd, name), ".git"));
+    const repoExists = fs.existsSync(path.join(cachedRepoDir(name), ".git"));
     const lockedCommit = lock[name]?.commit;
     const ref = source.ref ?? "HEAD";
     const isPinned = !!source.ref;
 
+    // Clone or fetch into cache
     if (!repoExists) {
-      const clone = cloneOrFetch(name, source.repo, cwd);
+      log(`cloning ${name} from ${source.repo}...`);
+      const clone = cloneOrFetch(name, source.repo);
       if (!clone.ok) {
         result.errors.push(`${name}: ${clone.error}`);
         continue;
       }
       result.installed.push(name);
     } else if (update) {
-      const fetch = cloneOrFetch(name, source.repo, cwd);
+      log(`fetching ${name}...`);
+      const fetch = cloneOrFetch(name, source.repo);
       if (!fetch.ok) {
         result.errors.push(`${name}: ${fetch.error}`);
         if (lockedCommit) {
@@ -312,6 +516,7 @@ function installAll(cwd: string, update: boolean): SyncResult {
       }
     }
 
+    // Determine which ref to checkout
     let targetRef = ref;
     if (!update && lockedCommit && repoExists) {
       targetRef = lockedCommit;
@@ -321,13 +526,15 @@ function installAll(cwd: string, update: boolean): SyncResult {
       targetRef = "HEAD";
     }
 
-    const co = checkout(name, targetRef, cwd);
+    log(`checking out ${name} at ${targetRef === ref ? ref : targetRef.slice(0, 8)}...`);
+    clearMarketplaceCache();
+    const co = checkout(name, targetRef);
     if (!co.ok) {
       result.errors.push(`${name}: ${co.error}`);
       continue;
     }
 
-    const commit = co.commit ?? getCurrentCommit(name, cwd) ?? "unknown";
+    const commit = co.commit ?? getCurrentCommit(name) ?? "unknown";
 
     if (update && lockedCommit && lockedCommit !== commit) {
       result.updated.push(name);
@@ -335,20 +542,19 @@ function installAll(cwd: string, update: boolean): SyncResult {
 
     newLock[name] = { commit, ...(source.ref ? { ref: source.ref } : {}) };
 
-    // Check plugins and their skills
-    const base = pluginBaseDir(cwd, name, source);
+    // Copy plugins from cache into project
     for (const plugin of source.plugins) {
-      const pluginDir = path.join(base, plugin);
-      if (!fs.existsSync(pluginDir)) {
-        result.errors.push(`${name}: plugin "${plugin}" not found`);
+      log(`copying ${name}/${plugin}...`);
+      const install = installPlugin(cwd, name, source, plugin);
+      if (!install.ok) {
+        result.errors.push(`${name}: ${install.error}`);
         continue;
       }
-      const skills = findPluginSkills(pluginDir);
-      if (skills.length === 0) {
-        result.errors.push(`${name}: plugin "${plugin}" has no skills`);
-      }
+      result.copied.push(`${name}/${plugin}`);
+      const dest = installedPluginDir(cwd, name, plugin);
+      const skills = findPluginSkills(dest);
       result.skills.push(...skills.map((s) => {
-        const rel = path.relative(pinchDir(cwd), s);
+        const rel = path.relative(projectPinchDir(cwd), s);
         return rel.replace(/\/SKILL\.md$/, "");
       }));
     }
@@ -367,24 +573,28 @@ export default function pinch(pi: ExtensionAPI) {
 
   pi.on("resources_discover", (event) => {
     cwd = event.cwd;
-    const config = readConfig(cwd);
-    if (!config) return;
+    const cr = readConfig(cwd);
+    if (!cr) return;
 
-    const skillPaths = resolveSkillPaths(config, cwd);
+    const skillPaths = resolveSkillPaths(cr.config, cwd);
     if (skillPaths.length > 0) {
       return { skillPaths };
     }
   });
 
   pi.on("session_start", async (_event, ctx) => {
-    const config = readConfig(cwd);
-    if (!config) return;
+    const cr = readConfig(cwd);
+    if (!cr) return;
 
+    for (const err of cr.errors) {
+      ctx.ui.notify(`pinch: ${err}`, "error");
+    }
+
+    const { config } = cr;
     const skillPaths = resolveSkillPaths(config, cwd);
     const totalPlugins = Object.values(config).reduce((n, s) => n + s.plugins.length, 0);
     const installedPlugins = Object.entries(config).reduce((n, [name, source]) => {
-      const base = pluginBaseDir(cwd, name, source);
-      return n + source.plugins.filter((p) => fs.existsSync(path.join(base, p))).length;
+      return n + source.plugins.filter((p) => fs.existsSync(installedPluginDir(cwd, name, p))).length;
     }, 0);
     const missing = totalPlugins - installedPlugins;
 
@@ -401,17 +611,15 @@ export default function pinch(pi: ExtensionAPI) {
   pi.registerCommand("pinch:install", {
     description: "Install plugins declared in pinch config",
     handler: async (_args, ctx) => {
-      const config = readConfig(cwd);
-      if (!config) {
-        ctx.ui.notify('No pinch.json manifest found', "error");
+      if (inContainer()) {
+        ctx.ui.notify("pinch: cannot install inside a container — run on the host", "error");
         return;
       }
-
-      ctx.ui.notify("pinch: installing...", "info");
-      const result = installAll(cwd, false);
+      const log = (msg: string) => ctx.ui.notify(`pinch: ${msg}`, "info");
+      const result = installAll(cwd, false, log);
       reportResult(result, ctx);
 
-      if (result.skills.length > 0) {
+      if (result.skills.length > 0 || result.pruned.length > 0) {
         await ctx.reload();
       }
     },
@@ -420,17 +628,15 @@ export default function pinch(pi: ExtensionAPI) {
   pi.registerCommand("pinch:update", {
     description: "Update plugins to latest (respects pinned refs)",
     handler: async (_args, ctx) => {
-      const config = readConfig(cwd);
-      if (!config) {
-        ctx.ui.notify('No pinch.json manifest found', "error");
+      if (inContainer()) {
+        ctx.ui.notify("pinch: cannot update inside a container — run on the host", "error");
         return;
       }
-
-      ctx.ui.notify("pinch: updating...", "info");
-      const result = installAll(cwd, true);
+      const log = (msg: string) => ctx.ui.notify(`pinch: ${msg}`, "info");
+      const result = installAll(cwd, true, log);
       reportResult(result, ctx);
 
-      if (result.updated.length > 0 || result.installed.length > 0) {
+      if (result.updated.length > 0 || result.installed.length > 0 || result.pruned.length > 0) {
         await ctx.reload();
       }
     },
@@ -439,35 +645,40 @@ export default function pinch(pi: ExtensionAPI) {
   pi.registerCommand("pinch:status", {
     description: "Show installed plugins and their skills",
     handler: async (_args, ctx) => {
-      const config = readConfig(cwd);
-      if (!config) {
+      const cr = readConfig(cwd);
+      if (!cr) {
         ctx.ui.notify('No pinch.json manifest found', "error");
         return;
       }
 
+      for (const err of cr.errors) {
+        ctx.ui.notify(`pinch: ${err}`, "error");
+      }
+
       const lock = readLock(cwd);
       const lines: string[] = [];
 
-      for (const [name, source] of Object.entries(config)) {
+      for (const [name, source] of Object.entries(cr.config)) {
         const lockEntry = lock[name];
-        const installed = fs.existsSync(path.join(repoDir(cwd, name), ".git"));
-        const status = installed ? "✓" : "✗";
+        const cached = fs.existsSync(path.join(cachedRepoDir(name), ".git"));
+        const status = cached ? "✓" : "✗";
         const commit = lockEntry?.commit?.slice(0, 8) ?? "—";
         const ref = source.ref ?? "HEAD";
 
         lines.push(`${status} ${name} (${ref} @ ${commit})`);
 
-        const base = pluginBaseDir(cwd, name, source);
         for (const plugin of source.plugins) {
-          const pluginDir = path.join(base, plugin);
-          const exists = fs.existsSync(pluginDir);
-          const skills = exists ? findPluginSkills(pluginDir) : [];
+          const pDir = installedPluginDir(cwd, name, plugin);
+          const exists = fs.existsSync(pDir);
+          const skills = exists ? findPluginSkills(pDir) : [];
           const skillNames = skills.map((s) => path.basename(path.dirname(s)));
 
           if (skills.length > 0) {
             lines.push(`  • ${plugin} (${skillNames.join(", ")})`);
+          } else if (exists) {
+            lines.push(`  • ${plugin}`);
           } else {
-            lines.push(`  ? ${plugin}`);
+            lines.push(`  ✗ ${plugin} (not installed)`);
           }
         }
       }
@@ -483,12 +694,15 @@ function reportResult(result: SyncResult, ctx: ExtensionContext): void {
   }
 
   const parts: string[] = [];
-  if (result.installed.length > 0) parts.push(`installed ${result.installed.length}`);
-  if (result.updated.length > 0) parts.push(`updated ${result.updated.length}`);
+  if (result.installed.length > 0) parts.push(`cloned ${result.installed.join(", ")}`);
+  if (result.updated.length > 0) parts.push(`updated ${result.updated.join(", ")}`);
+  if (result.copied.length > 0) parts.push(`copied ${result.copied.length} plugin(s)`);
   if (result.pruned.length > 0) parts.push(`pruned ${result.pruned.join(", ")}`);
   if (result.skills.length > 0) parts.push(`${result.skills.length} skill(s)`);
 
   if (parts.length > 0) {
     ctx.ui.notify(`pinch: ${parts.join(", ")}`, "info");
+  } else if (result.errors.length === 0) {
+    ctx.ui.notify("pinch: nothing to do", "info");
   }
 }
diff --git a/.pi/agent/pinch.json b/.pi/agent/pinch.json
index db3ecc35..2ac9f3f6 100644
--- a/.pi/agent/pinch.json
+++ b/.pi/agent/pinch.json
@@ -3,5 +3,10 @@
     "repo": "https://github.com/DrCatHicks/learning-opportunities",
     "ref": "main",
     "plugins": ["learning-opportunities"]
+  },
+  "claude-plugins-official": {
+    "repo": "https://github.com/anthropics/claude-plugins-official",
+    "ref": "main",
+    "plugins": ["playground"]
   }
 }
diff --git a/.pi/agent/skills/pinch/SKILL.md b/.pi/agent/skills/pinch/SKILL.md
new file mode 100644
index 00000000..4452d4c5
--- /dev/null
+++ b/.pi/agent/skills/pinch/SKILL.md
@@ -0,0 +1,137 @@
+---
+name: pinch
+description: Manage pi plugins from git repos via pinch — add/remove sources and plugins in pinch.json, install, update, and troubleshoot. Use when the user wants to add a plugin, configure a marketplace/source, or debug pinch issues.
+---
+
+# Pinch
+
+Pinch is a per-project plugin manager for pi, implemented as a pi extension (`~/.pi/agent/extensions/pinch.ts`). It clones git repos containing plugins (with skills, etc.) into `.pi/pinch/` and registers their skills automatically.
+
+## Concepts
+
+- **Source**: A named git repo entry in `pinch.json`. Each source has a `repo` URL, optional `ref` (branch/tag/commit), optional `path` (subdirectory containing plugins), and a `plugins` array listing which plugins to activate.
+- **Plugin**: A directory inside a source repo following Claude plugin conventions — contains `skills/<skill-name>/SKILL.md`.
+- **Lock file**: `.pi/pinch-lock.json` tracks the exact commit per source for reproducible installs.
+
+## Manifest format
+
+Manifests are JSON files named `pinch.json`:
+
+- **Global**: `~/.pi/agent/pinch.json` — applies to all projects
+- **Project**: `.pi/pinch.json` — project-specific, overrides global entries by name
+
+```json
+{
+  "source-name": {
+    "repo": "https://github.com/owner/repo",
+    "ref": "main",
+    "path": "plugins",
+    "plugins": ["plugin-a", "plugin-b"]
+  }
+}
+```
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `repo` | Yes | Git clone URL (HTTPS or SSH) |
+| `ref` | No | Branch, tag, or commit. Omit for HEAD. Pinned refs are not updated by `/pinch:update`. |
+| `path` | No | Subdirectory within the repo that contains the plugin directories. Auto-detected if omitted (scans one level deep for `.claude-plugin/plugin.json`). |
+| `plugins` | Yes | Array of plugin directory names to activate from this source |
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `/pinch:install` | Clone sources and register skills. Respects lock file for already-installed sources. |
+| `/pinch:update` | Fetch latest and update to newest commits. Pinned refs stay put. |
+| `/pinch:status` | Show installed sources, plugins, and their skills. |
+
+## Manifest rules
+
+- **Global manifest** (`~/.pi/agent/pinch.json`): Full source definitions with `repo`, `ref`, `path`, `plugins`.
+- **Project manifest** (`.pi/pinch.json`): Can define new sources (with `repo`), or reference global sources by name with only `plugins`. If a key exists in the global manifest, `repo`/`ref`/`path` cannot be overridden — only `plugins` is allowed.
+
+```json
+// Project manifest referencing a global source (plugins only)
+{
+  "claude-plugins-official": {
+    "plugins": ["playground", "skill-creator"]
+  }
+}
+
+// Project manifest with its own source (full definition, new key)
+{
+  "my-team-plugins": {
+    "repo": "git@github.com:team/plugins.git",
+    "ref": "main",
+    "plugins": ["our-plugin"]
+  }
+}
+```
+
+## Adding a plugin
+
+1. Identify the repo URL and which plugin(s) you want from it.
+2. Choose a short source name (used as the directory name under `.pi/pinch/`).
+3. Add the entry to the appropriate `pinch.json`:
+
+```json
+{
+  "anthropic": {
+    "repo": "https://github.com/anthropics/claude-plugins-official",
+    "ref": "main",
+    "plugins": ["playgrounds"]
+  }
+}
+```
+
+If the plugins live in a subdirectory of the repo (not at the root), set `path`:
+
+```json
+{
+  "my-source": {
+    "repo": "https://github.com/owner/repo",
+    "ref": "main",
+    "path": "plugins",
+    "plugins": ["the-plugin"]
+  }
+}
+```
+
+4. Run `/pinch:install` to clone and activate.
+
+## Removing a plugin
+
+- To stop using a single plugin from a source, remove it from the `plugins` array.
+- To remove an entire source, delete its key from `pinch.json`. The next `/pinch:install` prunes stale directories.
+
+## File layout
+
+```
+$XDG_CACHE_HOME/pinch/         # Shared repo cache (~/.cache/pinch/)
+└── <source-name>/             # Full repo clone
+
+.pi/
+├── pinch.json                  # Project manifest (optional)
+├── pinch-lock.json             # Lock file (auto-generated)
+└── pinch/
+    └── <source-name>/
+        └── <plugin>/           # Copied from cache (only selected plugins)
+            └── skills/
+                └── <skill>/
+                    └── SKILL.md
+
+~/.pi/agent/
+├── pinch.json                  # Global manifest
+└── extensions/
+    └── pinch.ts                # The extension itself
+```
+
+Repos are cloned once into the XDG cache. Only the requested plugin directories are copied into the project, keeping `.pi/pinch/` lightweight and working correctly in bind-mounted containers.
+
+## Troubleshooting
+
+- **"No pinch.json manifest found"**: Neither `~/.pi/agent/pinch.json` nor `.pi/pinch.json` exists or contains valid entries.
+- **Plugin not found after install**: Check that the plugin name in `plugins` matches the actual directory name in the repo. Use `path` if plugins are nested.
+- **Network errors on clone/fetch**: Pinch uses `git` directly — check that the repo URL is reachable and credentials are configured.
+- **Skills not loading**: Plugin must have `skills/<name>/SKILL.md` structure. Run `/pinch:status` to verify.
diff --git a/.pi/pinch-lock.json b/.pi/pinch-lock.json
new file mode 100644
index 00000000..b0b0f035
--- /dev/null
+++ b/.pi/pinch-lock.json
@@ -0,0 +1,10 @@
+{
+  "learning-opportunities": {
+    "commit": "e5f985d376461993253d285096ed0f4b4a095858",
+    "ref": "main"
+  },
+  "claude-plugins-official": {
+    "commit": "d5c15b861cd23e3102215c26020368ad5134dc47",
+    "ref": "main"
+  }
+}
diff --git a/AGENTS.md b/AGENTS.md
index 725e921e..19288396 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,6 +43,18 @@ tea comment -r alpha/dotfiles -l git.kejadlen.dev <index> "Comment body"
 - **No secrets in repo**: API keys are managed via 1Password / `op` CLI, not committed. `.envrc` is a local-only file (not tracked) and should never be committed.
 - **Submodules**: Use shallow clones with `--depth 1` when adding submodules.
 
+## Pinch (Plugin Manager)
+
+Pinch is a pi extension (`.pi/agent/extensions/pinch.ts`) that manages plugins from git repos. It caches repo clones in `$XDG_CACHE_HOME/pinch/` and copies only the selected plugins into `.pi/pinch/`, registering their skills automatically.
+
+- **Global manifest**: `~/.pi/agent/pinch.json` — applies everywhere
+- **Project manifest**: `.pi/pinch.json` — per-project, overrides global by name
+- **Lock file**: `.pi/pinch-lock.json` — exact commits, auto-generated
+- **Commands**: `/pinch:install`, `/pinch:update`, `/pinch:status`
+- **Skill**: `/skill:pinch` for full usage docs
+
+To add a plugin, add an entry to `pinch.json` and run `/pinch:install`. See the pinch skill for manifest format and troubleshooting.
+
 ## Open Issues
 
 Track work at: https://git.kejadlen.dev/alpha/dotfiles/issues