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
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