pinch: extension for per-project plugin management
change kwyzsymnymqkmsomnstnqmymunnpllos
commit e9800bc1165622abd0948b59e9284cd00d780d01
author Alpha Chen <alpha@kejadlen.dev>
date
parent zomptknm
diff --git a/.config/git/ignore b/.config/git/ignore
index 9471776f..e7ba7f10 100644
--- a/.config/git/ignore
+++ b/.config/git/ignore
@@ -34,3 +34,6 @@ Session.vim
 # Claude
 CLAUDE.local.md
 **/.claude/settings.local.json
+
+# pinch
+.pi/pinch/
diff --git a/.config/pinch/conf.d/base.yml b/.config/pinch/conf.d/base.yml
deleted file mode 100644
index d6cdd37e..00000000
--- a/.config/pinch/conf.d/base.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-marketplaces:
-
-  claude-plugins-official:
-    src: https://github.com/anthropics/claude-plugins-official
-    version: main
-    plugins:
-      - claude-md-management
-
-  learning-opportunities:
-    src: https://github.com/DrCatHicks/learning-opportunities
-    version: main
-    plugins:
-      - learning-opportunities
diff --git a/.gitignore b/.gitignore
index db1ce696..ef75ef3d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,7 +21,6 @@ private
 !.config/jj
 !.config/nvim
 !.config/neovide
-!.config/pinch
 !.config/pip
 !.config/ripgrep
 !.config/sketchybar
@@ -37,16 +36,6 @@ private
 .docker/*
 !.docker/config.json
 
-# yes, this is ridiculous, but since we have *.local
-# ignored above and git doesn't look inside ignored
-# dirs, we have some hoops to jump through here
-!.local/
-.local/*
-!.local/share
-.local/share/*
-!.local/share/pinch
-.local/share/pinch/plugins
-
 .ssh/*
 !.ssh/config
 
diff --git a/.local/share/pinch/pinch-lock.yml b/.local/share/pinch/pinch-lock.yml
deleted file mode 100644
index 46fdf49f..00000000
--- a/.local/share/pinch/pinch-lock.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-plugins:
-  claude-md-management:
-    marketplace: claude-plugins-official
-    path: plugins/claude-md-management
-    version: '1.0.0'
-    rev: aa296ec81e8ccb49c9784f167c2c0aa625a86cec
-  learning-opportunities:
-    marketplace: learning-opportunities
-    path: learning-opportunities
-    version: '1.0.0'
-    rev: '3890a30f0e3ae29fa8ead6444855228d8e39ad89'
diff --git a/.pi/.gitignore b/.pi/.gitignore
index 8a8e90f0..9ec2d4bb 100644
--- a/.pi/.gitignore
+++ b/.pi/.gitignore
@@ -1,4 +1,5 @@
 agent/*
 !agent/extensions/
 !agent/keybindings.json
+!agent/pinch.json
 !agent/settings.json
diff --git a/.pi/agent/extensions/pinch.ts b/.pi/agent/extensions/pinch.ts
new file mode 100644
index 00000000..c80fcd54
--- /dev/null
+++ b/.pi/agent/extensions/pinch.ts
@@ -0,0 +1,494 @@
+/**
+ * Pinch — per-project plugin manager for pi
+ *
+ * Manages Claude plugins from git repos declared in pinch.json manifests.
+ * 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.
+ *
+ * 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.
+ *
+ * Manifest format (pinch.json):
+ *
+ *   {
+ *     "anthropic": {
+ *       "repo": "https://github.com/anthropics/claude-plugins-official",
+ *       "ref": "main",
+ *       "plugins": ["claude-md-management", "skill-creator"]
+ *     },
+ *     "my-plugins": {
+ *       "repo": "git@github.com:me/plugins.git",
+ *       "ref": "v1.2.0",
+ *       "path": "plugins",
+ *       "plugins": ["my-plugin"]
+ *     }
+ *   }
+ *
+ * Plugin structure (Claude plugin convention):
+ *
+ *   <plugin>/
+ *     .claude-plugin/plugin.json
+ *     skills/<skill-name>/SKILL.md
+ *
+ * Commands:
+ *   /pinch:install  — clone/fetch all sources, register skills
+ *   /pinch:update   — pull latest for unpinned sources
+ *   /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>/.
+ */
+
+import * as fs from "node:fs";
+import * as path from "node:path";
+import * as os from "node:os";
+import * as child_process from "node:child_process";
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+
+// ── Types ──────────────────────────────────────────────────────────────
+
+interface PinchSource {
+  repo: string;
+  ref?: string;
+  path?: string; // subdirectory containing plugins (e.g. "plugins")
+  plugins: string[];
+}
+
+interface PinchConfig {
+  [name: string]: PinchSource;
+}
+
+interface PinchLockEntry {
+  commit: string;
+  ref?: string;
+}
+
+interface PinchLock {
+  [name: string]: PinchLockEntry;
+}
+
+// ── Paths ──────────────────────────────────────────────────────────────
+
+function pinchDir(cwd: string): string {
+  return path.join(cwd, ".pi", "pinch");
+}
+
+function repoDir(cwd: string, name: string): string {
+  return path.join(pinchDir(cwd), name);
+}
+
+function globalManifestPath(): string {
+  return path.join(os.homedir(), ".pi", "agent", "pinch.json");
+}
+
+function projectManifestPath(cwd: string): string {
+  return path.join(cwd, ".pi", "pinch.json");
+}
+
+function lockPath(cwd: string): string {
+  return path.join(cwd, ".pi", "pinch-lock.json");
+}
+
+// ── Manifest / Lock ───────────────────────────────────────────────────
+
+function readManifest(filePath: string): PinchConfig | 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;
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Merge global (~/.pi/agent/pinch.json) and project (.pi/pinch.json)
+ * manifests. Project entries override global ones by name.
+ */
+function readConfig(cwd: string): PinchConfig | null {
+  const global = readManifest(globalManifestPath());
+  const project = readManifest(projectManifestPath(cwd));
+  if (!global && !project) return null;
+  return { ...global, ...project };
+}
+
+function readLock(cwd: string): PinchLock {
+  const p = lockPath(cwd);
+  if (!fs.existsSync(p)) return {};
+  try {
+    return JSON.parse(fs.readFileSync(p, "utf-8")) as PinchLock;
+  } catch {
+    return {};
+  }
+}
+
+function writeLock(cwd: string, lock: PinchLock): void {
+  fs.mkdirSync(path.dirname(lockPath(cwd)), { recursive: true });
+  fs.writeFileSync(lockPath(cwd), JSON.stringify(lock, null, 2) + "\n", "utf-8");
+}
+
+// ── Git operations ─────────────────────────────────────────────────────
+
+function git(args: string[], opts?: { cwd?: string }): { ok: boolean; stdout: string; stderr: string } {
+  try {
+    const result = child_process.execFileSync("git", args, {
+      cwd: opts?.cwd,
+      encoding: "utf-8",
+      stdio: ["pipe", "pipe", "pipe"],
+      timeout: 60_000,
+    });
+    return { ok: true, stdout: result.trim(), stderr: "" };
+  } catch (e: any) {
+    return {
+      ok: false,
+      stdout: (e.stdout ?? "").toString().trim(),
+      stderr: (e.stderr ?? "").toString().trim(),
+    };
+  }
+}
+
+function cloneOrFetch(name: string, repo: string, cwd: string): { ok: boolean; error?: string } {
+  const dir = repoDir(cwd, name);
+
+  if (fs.existsSync(path.join(dir, ".git"))) {
+    const result = git(["fetch", "--all", "--prune"], { cwd: dir });
+    if (!result.ok) return { ok: false, error: `fetch failed: ${result.stderr}` };
+    return { ok: true };
+  }
+
+  fs.mkdirSync(path.dirname(dir), { recursive: true });
+  const result = git(["clone", "--depth", "1", "--no-single-branch", 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);
+
+  let target = ref;
+  const originRef = git(["rev-parse", "--verify", `origin/${ref}`], { cwd: dir });
+  if (originRef.ok) {
+    target = `origin/${ref}`;
+  }
+
+  const result = git(["checkout", "--detach", target], { cwd: dir });
+  if (!result.ok) return { ok: false, error: `checkout ${ref} failed: ${result.stderr}` };
+
+  const rev = git(["rev-parse", "HEAD"], { cwd: dir });
+  return { ok: true, commit: rev.stdout };
+}
+
+function getCurrentCommit(name: string, cwd: string): string | undefined {
+  const dir = repoDir(cwd, name);
+  const rev = git(["rev-parse", "HEAD"], { cwd: dir });
+  return rev.ok ? rev.stdout : undefined;
+}
+
+// ── Cleanup ────────────────────────────────────────────────────────────
+
+/**
+ * Remove source dirs under .pi/pinch/ that are no longer in the config.
+ */
+function pruneStale(config: PinchConfig, cwd: string): string[] {
+  const dir = pinchDir(cwd);
+  if (!fs.existsSync(dir)) return [];
+
+  const pruned: string[] = [];
+  for (const entry of fs.readdirSync(dir)) {
+    const full = path.join(dir, entry);
+    try {
+      if (!fs.statSync(full).isDirectory()) continue;
+    } catch {
+      continue;
+    }
+
+    if (!config[entry]) {
+      fs.rmSync(full, { recursive: true, force: true });
+      pruned.push(entry);
+    }
+  }
+
+  return pruned;
+}
+
+// ── 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");
+  if (!fs.existsSync(skillsDir)) return [];
+
+  const paths: string[] = [];
+  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
+    if (!entry.isDirectory()) continue;
+    const skillMd = path.join(skillsDir, entry.name, "SKILL.md");
+    if (fs.existsSync(skillMd)) {
+      paths.push(skillMd);
+    }
+  }
+  return paths;
+}
+
+/**
+ * Collect all skill paths from installed plugins.
+ */
+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)));
+    }
+  }
+
+  return paths;
+}
+
+// ── Install / Update logic ─────────────────────────────────────────────
+
+interface SyncResult {
+  installed: string[];
+  updated: 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: [] };
+
+  const lock = readLock(cwd);
+  const newLock: PinchLock = {};
+  const result: SyncResult = { installed: [], updated: [], pruned: [], errors: [], skills: [] };
+
+  for (const [name, source] of Object.entries(config)) {
+    const repoExists = fs.existsSync(path.join(repoDir(cwd, name), ".git"));
+    const lockedCommit = lock[name]?.commit;
+    const ref = source.ref ?? "HEAD";
+    const isPinned = !!source.ref;
+
+    if (!repoExists) {
+      const clone = cloneOrFetch(name, source.repo, cwd);
+      if (!clone.ok) {
+        result.errors.push(`${name}: ${clone.error}`);
+        continue;
+      }
+      result.installed.push(name);
+    } else if (update) {
+      const fetch = cloneOrFetch(name, source.repo, cwd);
+      if (!fetch.ok) {
+        result.errors.push(`${name}: ${fetch.error}`);
+        if (lockedCommit) {
+          newLock[name] = { commit: lockedCommit, ...(source.ref ? { ref: source.ref } : {}) };
+        }
+        continue;
+      }
+    }
+
+    let targetRef = ref;
+    if (!update && lockedCommit && repoExists) {
+      targetRef = lockedCommit;
+    } else if (update && isPinned) {
+      targetRef = ref;
+    } else if (update) {
+      targetRef = "HEAD";
+    }
+
+    const co = checkout(name, targetRef, cwd);
+    if (!co.ok) {
+      result.errors.push(`${name}: ${co.error}`);
+      continue;
+    }
+
+    const commit = co.commit ?? getCurrentCommit(name, cwd) ?? "unknown";
+
+    if (update && lockedCommit && lockedCommit !== commit) {
+      result.updated.push(name);
+    }
+
+    newLock[name] = { commit, ...(source.ref ? { ref: source.ref } : {}) };
+
+    // Check plugins and their skills
+    const base = pluginBaseDir(cwd, name, source);
+    for (const plugin of source.plugins) {
+      const pluginDir = path.join(base, plugin);
+      if (!fs.existsSync(pluginDir)) {
+        result.errors.push(`${name}: plugin "${plugin}" not found`);
+        continue;
+      }
+      const skills = findPluginSkills(pluginDir);
+      if (skills.length === 0) {
+        result.errors.push(`${name}: plugin "${plugin}" has no skills`);
+      }
+      result.skills.push(...skills.map((s) => {
+        const rel = path.relative(pinchDir(cwd), s);
+        return rel.replace(/\/SKILL\.md$/, "");
+      }));
+    }
+  }
+
+  writeLock(cwd, newLock);
+  result.pruned = pruneStale(config, cwd);
+
+  return result;
+}
+
+// ── Extension ──────────────────────────────────────────────────────────
+
+export default function pinch(pi: ExtensionAPI) {
+  let cwd = "";
+
+  pi.on("resources_discover", (event) => {
+    cwd = event.cwd;
+    const config = readConfig(cwd);
+    if (!config) return;
+
+    const skillPaths = resolveSkillPaths(config, cwd);
+    if (skillPaths.length > 0) {
+      return { skillPaths };
+    }
+  });
+
+  pi.on("session_start", async (_event, ctx) => {
+    const config = readConfig(cwd);
+    if (!config) return;
+
+    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;
+    }, 0);
+    const missing = totalPlugins - installedPlugins;
+
+    if (missing > 0) {
+      ctx.ui.notify(
+        `pinch: ${missing} plugin(s) not installed — run /pinch:install`,
+        "warning"
+      );
+    } else if (skillPaths.length > 0) {
+      ctx.ui.notify(`pinch: ${skillPaths.length} skill(s) from ${installedPlugins} plugin(s)`, "info");
+    }
+  });
+
+  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");
+        return;
+      }
+
+      ctx.ui.notify("pinch: installing...", "info");
+      const result = installAll(cwd, false);
+      reportResult(result, ctx);
+
+      if (result.skills.length > 0) {
+        await ctx.reload();
+      }
+    },
+  });
+
+  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");
+        return;
+      }
+
+      ctx.ui.notify("pinch: updating...", "info");
+      const result = installAll(cwd, true);
+      reportResult(result, ctx);
+
+      if (result.updated.length > 0 || result.installed.length > 0) {
+        await ctx.reload();
+      }
+    },
+  });
+
+  pi.registerCommand("pinch:status", {
+    description: "Show installed plugins and their skills",
+    handler: async (_args, ctx) => {
+      const config = readConfig(cwd);
+      if (!config) {
+        ctx.ui.notify('No pinch.json manifest found', "error");
+        return;
+      }
+
+      const lock = readLock(cwd);
+      const lines: string[] = [];
+
+      for (const [name, source] of Object.entries(config)) {
+        const lockEntry = lock[name];
+        const installed = fs.existsSync(path.join(repoDir(cwd, name), ".git"));
+        const status = installed ? "✓" : "✗";
+        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 skillNames = skills.map((s) => path.basename(path.dirname(s)));
+
+          if (skills.length > 0) {
+            lines.push(`  • ${plugin} (${skillNames.join(", ")})`);
+          } else {
+            lines.push(`  ? ${plugin}`);
+          }
+        }
+      }
+
+      await ctx.ui.select("Pinch Status", lines);
+    },
+  });
+}
+
+function reportResult(result: SyncResult, ctx: ExtensionContext): void {
+  for (const err of result.errors) {
+    ctx.ui.notify(`pinch: ${err}`, "error");
+  }
+
+  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.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");
+  }
+}
diff --git a/.pi/agent/pinch.json b/.pi/agent/pinch.json
new file mode 100644
index 00000000..db3ecc35
--- /dev/null
+++ b/.pi/agent/pinch.json
@@ -0,0 +1,7 @@
+{
+  "learning-opportunities": {
+    "repo": "https://github.com/DrCatHicks/learning-opportunities",
+    "ref": "main",
+    "plugins": ["learning-opportunities"]
+  }
+}
diff --git a/.pi/agent/settings.json b/.pi/agent/settings.json
index f8b9e71a..83532683 100644
--- a/.pi/agent/settings.json
+++ b/.pi/agent/settings.json
@@ -1,5 +1,5 @@
 {
-  "lastChangelogVersion": "0.56.3",
+  "lastChangelogVersion": "0.58.1",
   "defaultProvider": "anthropic",
   "defaultModel": "claude-opus-4-6",
   "skills": [
diff --git a/bin/pinch b/bin/pinch
deleted file mode 100644
index 87e38753..00000000
--- a/bin/pinch
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/usr/bin/env dotslash
-
-{
-  "name": "pinch",
-  "platforms": {
-    "macos-aarch64": {
-      "size": 919512,
-      "hash": "blake3",
-      "digest": "d5fa8d08d16c0622c4b56af2b7adf780433e5f3f972c7040bd104694700f1320",
-      "format": "tar.gz",
-      "path": "pinch",
-      "providers": [
-        {
-          "url": "https://github.com/kejadlen/pinch/releases/download/v2026-02-19%2B5d7c190/pinch-aarch64-apple-darwin.tar.gz"
-        },
-        {
-          "type": "github-release",
-          "repo": "https://github.com/kejadlen/pinch",
-          "tag": "v2026-02-19+5d7c190",
-          "name": "pinch-aarch64-apple-darwin.tar.gz"
-        }
-      ]
-    }
-  }
-}