Replace pinch with native pi packages
change umwsqrkxuovwtpqzlwswxtkzxownlqox
commit 72be1b5ba7f31c03bfd0b9301bd3bf82bfc279f3
author Alpha Chen <alpha@kejadlen.dev>
date
parent rlvzvoxo
diff --git a/AGENTS.md b/AGENTS.md
index 085e2680..a87b0a12 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,18 +49,11 @@ Skills live in `ai/skills/<name>/SKILL.md`. See the `skill-notes` skill for auth
 
 Pi loads these via `ai/pi/settings.json`, which points to `~/.dotfiles/ai/skills`. Ramekin containers have their own copy at `.config/ramekin/agent/skills/` — it's fine for these to drift; only update `ai/skills/`.
 
-## Pinch (Plugin Manager)
+## Pi Packages
 
-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 selected plugins into scope-appropriate directories, registering their skills automatically.
+Pi manages plugins natively via `pi install` / `pi remove` / `pi update` / `pi list`. Global packages are declared in `~/.pi/agent/settings.json` under `packages`. Project-scoped packages use `.pi/settings.json` with the `-l` flag.
 
-Plugins are scoped by which manifest defines them. Sources in the global manifest only install to `~/.pi/pinch/` (user scope). Sources referenced in a project manifest install to `.pi/pinch/` (project scope). This keeps user-specific plugins out of project repositories.
-
-- Global manifest: `~/.pi/agent/pinch.json` — user-wide sources
-- Project manifest: `.pi/pinch.json` — per-project, can reference global sources or define new ones
-- Lock files: `~/.pi/agent/pinch-lock.json` (user) and `.pi/pinch-lock.json` (project)
-- Commands: `/pinch:install`, `/pinch:update`, `/pinch:status`
-
-To add a plugin, add an entry to `pinch.json` and run `/pinch:install`. See the pinch skill for manifest format and troubleshooting.
+Use the object form with `skills` filters to load specific plugins from multi-plugin repos.
 
 ## Open Issues
 
diff --git a/ai/pi/extensions/pinch.ts b/ai/pi/extensions/pinch.ts
deleted file mode 100644
index 419adb43..00000000
--- a/ai/pi/extensions/pinch.ts
+++ /dev/null
@@ -1,813 +0,0 @@
-/**
- * Pinch — scoped plugin manager for pi
- *
- * Manages plugins from git repos declared in pinch.json manifests.
- * Two manifest locations define two scopes:
- *
- *   ~/.pi/agent/pinch.json   → user scope (applies everywhere)
- *   .pi/pinch.json           → project scope (per-repo)
- *
- * Scope determines where plugins are installed:
- *
- *   User scope    → ~/.pi/pinch/<source>/<plugin>/
- *   Project scope → .pi/pinch/<source>/<plugin>/
- *
- * A source belongs to project scope when it appears in the project
- * manifest (either as a new definition or referencing a global source).
- * Sources defined only in the global manifest stay in user scope,
- * keeping project directories free of user-specific plugins.
- *
- * Repos are cloned into $XDG_CACHE_HOME/pinch/<source>/ as a shared
- * cache. Only the requested plugins are copied into the scope directory.
- *
- * Plugin location within a repo is resolved by:
- * 1. Per-plugin `path` override in pinch.json (e.g. { "name": "foo", "path": "." })
- * 2. Source-level `path` field in pinch.json (e.g. "plugins")
- * 3. marketplace.json `source` field (e.g. "./plugins/playground")
- * 4. Repo root fallback (<repo>/<plugin>/)
- *
- * Lock files track exact commits per source, one per scope:
- *
- *   ~/.pi/agent/pinch-lock.json   → user scope
- *   .pi/pinch-lock.json           → project scope
- *
- * Commands:
- *   /pinch:install  — clone/fetch all sources, copy plugins, register skills
- *   /pinch:update   — pull latest for unpinned sources, re-copy plugins
- *   /pinch:reset    — remove all cached repo clones
- *   /pinch:status   — show installed plugins and their skills
- */
-
-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 ──────────────────────────────────────────────────────────────
-
-type Scope = "user" | "project";
-
-/** A plugin entry: either a name string or an object with name and path override. */
-type PluginSpec = string | { name: string; path: string };
-
-interface PinchSource {
-  repo: string;
-  ref?: string;
-  path?: string; // subdirectory containing plugins (e.g. "plugins")
-  plugins: PluginSpec[];
-}
-
-interface PinchConfig {
-  [name: string]: PinchSource;
-}
-
-/** Config split by scope — user (global-only) vs project (referenced in project manifest). */
-interface ScopedConfig {
-  user: PinchConfig;
-  project: PinchConfig;
-  errors: string[];
-}
-
-interface PinchLockEntry {
-  commit: string;
-  ref?: string; // informational — records which ref produced this commit
-}
-
-interface PinchLock {
-  [name: string]: PinchLockEntry;
-}
-
-// ── Plugin spec helpers ─────────────────────────────────────────────────
-
-function pluginName(spec: PluginSpec): string {
-  return typeof spec === "string" ? spec : spec.name;
-}
-
-function pluginPathOverride(spec: PluginSpec): string | undefined {
-  return typeof spec === "string" ? undefined : spec.path;
-}
-
-// ── Container detection ────────────────────────────────────────────────
-
-function inContainer(): boolean {
-  return fs.existsSync("/.dockerenv") || fs.existsSync("/run/.containerenv");
-}
-
-// ── Paths ──────────────────────────────────────────────────────────────
-
-/** 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);
-}
-
-/** User-scoped plugin install dir (~/.pi/pinch/) */
-function userPinchDir(): string {
-  return path.join(os.homedir(), ".pi", "pinch");
-}
-
-/** Project-local plugin install dir */
-function projectPinchDir(cwd: string): string {
-  return path.join(cwd, ".pi", "pinch");
-}
-
-/** Root install dir for a given scope */
-function pinchDir(scope: Scope, cwd: string): string {
-  return scope === "user" ? userPinchDir() : projectPinchDir(cwd);
-}
-
-/** Installed plugin dir */
-function installedPluginDir(scope: Scope, cwd: string, sourceName: string, pluginName: string): string {
-  return path.join(pinchDir(scope, cwd), sourceName, pluginName);
-}
-
-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 userLockPath(): string {
-  return path.join(os.homedir(), ".pi", "agent", "pinch-lock.json");
-}
-
-function projectLockPath(cwd: string): string {
-  return path.join(cwd, ".pi", "pinch-lock.json");
-}
-
-function lockPath(scope: Scope, cwd: string): string {
-  return scope === "user" ? userLockPath() : projectLockPath(cwd);
-}
-
-// ── Manifest / Lock ───────────────────────────────────────────────────
-
-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;
-    return raw;
-  } catch {
-    return null;
-  }
-}
-
-/**
- * Read and scope-split global (~/.pi/agent/pinch.json) and project
- * (.pi/pinch.json) manifests.
- *
- * Scope rules:
- * - A source that appears only in the global manifest → user scope.
- * - A source that appears in the project manifest (new or referencing
- *   a global source) → project scope.
- *
- * 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): ScopedConfig | null {
-  const globalRaw = readJsonFile(globalManifestPath());
-  const projectRaw = readJsonFile(projectManifestPath(cwd));
-  if (!globalRaw && !projectRaw) return null;
-
-  const globalSources: PinchConfig = {};
-  const errors: string[] = [];
-
-  // Parse 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;
-      globalSources[name] = entry as PinchSource;
-    }
-  }
-
-  const user: PinchConfig = {};
-  const project: PinchConfig = {};
-  const projectNames = new Set<string>();
-
-  // Parse project entries, tracking which names the project claims
-  if (projectRaw) {
-    for (const [name, entry] of Object.entries(projectRaw)) {
-      if (typeof entry !== "object" || entry === null) continue;
-      if (!("plugins" in entry)) continue;
-      projectNames.add(name);
-
-      if (name in globalSources) {
-        // 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;
-        }
-        project[name] = { ...globalSources[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;
-        }
-        project[name] = entry as PinchSource;
-      }
-    }
-  }
-
-  // Global sources not referenced by the project stay in user scope
-  for (const [name, source] of Object.entries(globalSources)) {
-    if (!projectNames.has(name)) {
-      user[name] = source;
-    }
-  }
-
-  const total = Object.keys(user).length + Object.keys(project).length;
-  if (total === 0 && errors.length === 0) return null;
-  return { user, project, errors };
-}
-
-/** Flat view of all sources across both scopes. */
-function allSources(sc: ScopedConfig): PinchConfig {
-  return { ...sc.user, ...sc.project };
-}
-
-/** Determine which scope a source belongs to. */
-function sourceScope(sc: ScopedConfig, name: string): Scope {
-  return name in sc.project ? "project" : "user";
-}
-
-function readLock(scope: Scope, cwd: string): PinchLock {
-  const p = lockPath(scope, cwd);
-  if (!fs.existsSync(p)) return {};
-  try {
-    return JSON.parse(fs.readFileSync(p, "utf-8")) as PinchLock;
-  } catch {
-    return {};
-  }
-}
-
-function writeLock(scope: Scope, cwd: string, lock: PinchLock): void {
-  const p = lockPath(scope, cwd);
-  fs.mkdirSync(path.dirname(p), { recursive: true });
-  fs.writeFileSync(p, 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: 120_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): { ok: boolean; error?: string } {
-  const dir = cachedRepoDir(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", "--template=", repo, dir]);
-  if (!result.ok) return { ok: false, error: `clone failed: ${result.stderr}` };
-  return { ok: true };
-}
-
-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 });
-  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): 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. Per-plugin `path` override from pinch.json plugin spec
- * 2. Source-level `path` in pinch.json → <cache>/<path>/<plugin>/
- * 3. marketplace.json `source` field → <cache>/<source>/
- * 4. Repo root fallback → <cache>/<plugin>/
- */
-function cachedPluginDir(name: string, source: PinchSource, plugin: string, pathOverride?: string): string {
-  const dir = cachedRepoDir(name);
-
-  if (pathOverride) return path.join(dir, pathOverride);
-
-  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 appropriate scope directory.
- * Removes the old copy first to ensure a clean state.
- */
-function installPlugin(scope: Scope, cwd: string, sourceName: string, source: PinchSource, plugin: string, pathOverride?: string): { ok: boolean; error?: string } {
-  const src = cachedPluginDir(sourceName, source, plugin, pathOverride);
-  if (!fs.existsSync(src)) {
-    return { ok: false, error: `plugin "${plugin}" not found in repo` };
-  }
-
-  const dest = installedPluginDir(scope, cwd, sourceName, plugin);
-  fs.rmSync(dest, { recursive: true, force: true });
-  copyDir(src, dest);
-  return { ok: true };
-}
-
-// ── Cleanup ────────────────────────────────────────────────────────────
-
-/**
- * Remove source/plugin dirs that are no longer in the config for a given scope.
- * Cleans up empty source directories after pruning individual plugins.
- */
-function pruneScopeDir(scopeConfig: PinchConfig, dir: string): string[] {
-  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 (!scopeConfig[entry]) {
-      // Entire source removed from this scope
-      fs.rmSync(full, { recursive: true, force: true });
-      pruned.push(entry);
-    } else {
-      // Prune plugins no longer in the list
-      const wantedPlugins = new Set(scopeConfig[entry].plugins.map(pluginName));
-      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 {}
-    }
-  }
-
-  return pruned;
-}
-
-/** Prune stale entries from both user and project scope directories. */
-function pruneStale(sc: ScopedConfig, cwd: string): string[] {
-  const pruned: string[] = [];
-  pruned.push(...pruneScopeDir(sc.user, userPinchDir()));
-  pruned.push(...pruneScopeDir(sc.project, projectPinchDir(cwd)));
-  return pruned;
-}
-
-// ── Skill discovery ────────────────────────────────────────────────────
-
-/**
- * Find all SKILL.md files inside a plugin's skills/ directory.
- */
-function findPluginSkills(dir: string): string[] {
-  const skillsDir = path.join(dir, "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 across both scopes.
- */
-function resolveSkillPaths(sc: ScopedConfig, cwd: string): string[] {
-  const paths: string[] = [];
-
-  for (const scope of ["user", "project"] as Scope[]) {
-    const config = scope === "user" ? sc.user : sc.project;
-    for (const [name, source] of Object.entries(config)) {
-      for (const spec of source.plugins) {
-        const dir = installedPluginDir(scope, cwd, name, pluginName(spec));
-        if (fs.existsSync(dir)) {
-          paths.push(...findPluginSkills(dir));
-        }
-      }
-    }
-  }
-
-  return paths;
-}
-
-// ── Install / Update logic ─────────────────────────────────────────────
-
-interface SyncResult {
-  installed: string[];
-  updated: string[];
-  copied: string[];
-  pruned: string[];
-  skills: string[];
-  errors: string[];
-}
-
-type Log = (msg: string) => void;
-
-function installAll(cwd: string, update: boolean, log: Log): SyncResult {
-  const sc = readConfig(cwd);
-  if (!sc) return { installed: [], updated: [], copied: [], pruned: [], errors: ['No pinch.json manifest found'], skills: [] };
-
-  const result: SyncResult = { installed: [], updated: [], copied: [], pruned: [], errors: [...sc.errors], skills: [] };
-
-  // Process each scope independently (separate lock files)
-  for (const scope of ["user", "project"] as Scope[]) {
-    const config = scope === "user" ? sc.user : sc.project;
-    if (Object.keys(config).length === 0) continue;
-
-    const lock = readLock(scope, cwd);
-    const newLock: PinchLock = {};
-
-    for (const [name, source] of Object.entries(config)) {
-      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) {
-        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) {
-        log(`fetching ${name}...`);
-        const fetch = cloneOrFetch(name, source.repo);
-        if (!fetch.ok) {
-          result.errors.push(`${name}: ${fetch.error}`);
-          if (lockedCommit) {
-            newLock[name] = { commit: lockedCommit, ...(source.ref ? { ref: source.ref } : {}) };
-          }
-          continue;
-        }
-      }
-
-      // Determine which ref to checkout
-      let targetRef = ref;
-      if (!update && lockedCommit && repoExists) {
-        targetRef = lockedCommit;
-      } else if (update && isPinned) {
-        targetRef = ref;
-      } else if (update) {
-        targetRef = "HEAD";
-      }
-
-      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) ?? "unknown";
-
-      if (update && lockedCommit && lockedCommit !== commit) {
-        result.updated.push(name);
-      }
-
-      newLock[name] = { commit, ...(source.ref ? { ref: source.ref } : {}) };
-
-      // Copy plugins into the scope-appropriate directory
-      for (const spec of source.plugins) {
-        const plugin = pluginName(spec);
-        const override = pluginPathOverride(spec);
-        log(`copying ${name}/${plugin} (${scope})...`);
-        const install = installPlugin(scope, cwd, name, source, plugin, override);
-        if (!install.ok) {
-          result.errors.push(`${name}: ${install.error}`);
-          continue;
-        }
-        result.copied.push(`${name}/${plugin}`);
-        const dest = installedPluginDir(scope, cwd, name, plugin);
-        const skills = findPluginSkills(dest);
-        result.skills.push(...skills.map((s) => {
-          const base = pinchDir(scope, cwd);
-          const rel = path.relative(base, s);
-          return rel.replace(/\/SKILL\.md$/, "");
-        }));
-      }
-    }
-
-    writeLock(scope, cwd, newLock);
-  }
-
-  result.pruned = pruneStale(sc, cwd);
-
-  return result;
-}
-
-// ── Extension ──────────────────────────────────────────────────────────
-
-export default function pinch(pi: ExtensionAPI) {
-  let cwd = "";
-
-  pi.on("resources_discover", (event) => {
-    cwd = event.cwd;
-    const sc = readConfig(cwd);
-    if (!sc) return;
-
-    const skillPaths = resolveSkillPaths(sc, cwd);
-    if (skillPaths.length > 0) {
-      return { skillPaths };
-    }
-  });
-
-  pi.on("session_start", async (_event, ctx) => {
-    const sc = readConfig(cwd);
-    if (!sc) return;
-
-    for (const err of sc.errors) {
-      ctx.ui.notify(`pinch: ${err}`, "error");
-    }
-
-    const config = allSources(sc);
-    const skillPaths = resolveSkillPaths(sc, cwd);
-    const totalPlugins = Object.values(config).reduce((n, s) => n + s.plugins.length, 0);
-    const installedPlugins = Object.entries(config).reduce((n, [name, source]) => {
-      const scope = sourceScope(sc, name);
-      return n + source.plugins.filter((spec) => fs.existsSync(installedPluginDir(scope, cwd, name, pluginName(spec)))).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) => {
-      if (inContainer()) {
-        ctx.ui.notify("pinch: cannot install inside a container — run on the host", "error");
-        return;
-      }
-      const log = (msg: string) => ctx.ui.notify(`pinch: ${msg}`, "info");
-      const result = installAll(cwd, false, log);
-      reportResult(result, ctx);
-
-      if (result.skills.length > 0 || result.pruned.length > 0) {
-        await ctx.reload();
-      }
-    },
-  });
-
-  pi.registerCommand("pinch:update", {
-    description: "Update plugins to latest (respects pinned refs)",
-    handler: async (_args, ctx) => {
-      if (inContainer()) {
-        ctx.ui.notify("pinch: cannot update inside a container — run on the host", "error");
-        return;
-      }
-      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 || result.pruned.length > 0) {
-        await ctx.reload();
-      }
-    },
-  });
-
-  pi.registerCommand("pinch:reset", {
-    description: "Remove all cached repo clones",
-    handler: async (_args, ctx) => {
-      if (inContainer()) {
-        ctx.ui.notify("pinch: cannot reset inside a container — run on the host", "error");
-        return;
-      }
-
-      const cache = cacheDir();
-      if (fs.existsSync(cache)) {
-        fs.rmSync(cache, { recursive: true, force: true });
-        ctx.ui.notify("pinch: cleared cache", "info");
-      } else {
-        ctx.ui.notify("pinch: nothing to reset", "info");
-      }
-    },
-  });
-
-  pi.registerCommand("pinch:status", {
-    description: "Show installed plugins and their skills",
-    handler: async (_args, ctx) => {
-      const sc = readConfig(cwd);
-      if (!sc) {
-        ctx.ui.notify('No pinch.json manifest found', "error");
-        return;
-      }
-
-      for (const err of sc.errors) {
-        ctx.ui.notify(`pinch: ${err}`, "error");
-      }
-
-      const lines: string[] = [];
-
-      for (const scope of ["user", "project"] as Scope[]) {
-        const config = scope === "user" ? sc.user : sc.project;
-        if (Object.keys(config).length === 0) continue;
-
-        const lock = readLock(scope, cwd);
-        lines.push(`── ${scope} ──`);
-
-        for (const [name, source] of Object.entries(config)) {
-          const lockEntry = lock[name];
-          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})`);
-
-          for (const spec of source.plugins) {
-            const plugin = pluginName(spec);
-            const pDir = installedPluginDir(scope, 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} (not installed)`);
-            }
-          }
-        }
-      }
-
-      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(`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/ai/pi/git/.gitignore b/ai/pi/git/.gitignore
new file mode 100644
index 00000000..d6b7ef32
--- /dev/null
+++ b/ai/pi/git/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/ai/pi/pinch-lock.json b/ai/pi/pinch-lock.json
deleted file mode 100644
index 83fed719..00000000
--- a/ai/pi/pinch-lock.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
-  "learning-opportunities": {
-    "commit": "e5f985d376461993253d285096ed0f4b4a095858",
-    "ref": "main"
-  },
-  "claude-plugins-official": {
-    "commit": "104d39be10b7b1380b2ae23a387a11a297b599c3",
-    "ref": "main"
-  },
-  "superpowers": {
-    "commit": "917e5f53b16b115b70a3a355ed5f4993b9f8b73d",
-    "ref": "main"
-  },
-  "ramekin": {
-    "commit": "d28fcacd30c2ec0856614d36744679a3f3bec243",
-    "ref": "main"
-  },
-  "ranger": {
-    "commit": "7e8b96503b65b3d383fa0d9366f6427c06142259",
-    "ref": "main"
-  }
-}
diff --git a/ai/pi/pinch.json b/ai/pi/pinch.json
deleted file mode 100644
index fde937ab..00000000
--- a/ai/pi/pinch.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
-  "learning-opportunities": {
-    "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"]
-  },
-  "superpowers": {
-    "repo": "https://github.com/obra/superpowers",
-    "ref": "main",
-    "plugins": ["superpowers"]
-  },
-  "ramekin": {
-    "repo": "https://git.kejadlen.dev/alpha/ramekin",
-    "ref": "main",
-    "plugins": [{ "name": "ramekin", "path": "." }]
-  },
-  "ranger": {
-    "repo": "https://git.kejadlen.dev/alpha/ranger",
-    "ref": "main",
-    "plugins": [{ "name": "ranger", "path": "." }]
-  }
-}
diff --git a/ai/pi/settings.json b/ai/pi/settings.json
index 1496c7e7..e3c1095e 100644
--- a/ai/pi/settings.json
+++ b/ai/pi/settings.json
@@ -1,10 +1,28 @@
 {
-  "lastChangelogVersion": "0.65.2",
-  "defaultProvider": "anthropic",
-  "defaultModel": "claude-opus-4-6",
+  "lastChangelogVersion": "0.72.1",
+  "defaultProvider": "zai",
   "skills": [
     "~/.dotfiles/ai/skills"
   ],
   "enableSkillCommands": true,
-  "defaultThinkingLevel": "high"
+  "defaultThinkingLevel": "high",
+  "defaultModel": "glm-5.1",
+  "packages": [
+    {
+      "source": "git:github.com/DrCatHicks/learning-opportunities",
+      "skills": [
+        "learning-opportunities/skills/**"
+      ]
+    },
+    "git:git.kejadlen.dev/alpha/ranger",
+    {
+      "source": "git:github.com/anthropics/claude-plugins-official",
+      "skills": [
+        "plugins/playground/skills/**"
+      ]
+    },
+    "git:git.kejadlen.dev/alpha/ramekin",
+    "git:github.com/obra/superpowers",
+    "git:github.com/hegeldev/hegel-skill"
+  ]
 }
\ No newline at end of file
diff --git a/ai/pi/skills/pinch/SKILL.md b/ai/pi/skills/pinch/SKILL.md
deleted file mode 100644
index 8da86899..00000000
--- a/ai/pi/skills/pinch/SKILL.md
+++ /dev/null
@@ -1,155 +0,0 @@
----
-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 scoped plugin manager for pi, implemented as a pi extension (`~/.pi/agent/extensions/pinch.ts`). It clones git repos containing plugins (with skills, etc.) and copies them into scope-appropriate directories.
-
-## Scoping
-
-Each source belongs to one of two scopes, determined by which manifests reference it:
-
-- User scope — sources defined only in `~/.pi/agent/pinch.json`. Plugins install to `~/.pi/pinch/`. Lock file: `~/.pi/agent/pinch-lock.json`.
-- Project scope — sources that appear in `.pi/pinch.json` (new definitions or references to global sources). Plugins install to `.pi/pinch/`. Lock file: `.pi/pinch-lock.json`.
-
-This separation keeps user-specific plugins out of project repositories.
-
-## Concepts
-
-- Source: a named git repo entry in `pinch.json` with a `repo` URL, optional `ref` (branch/tag/commit), optional `path` (subdirectory containing plugins), and a `plugins` array.
-- Plugin: a directory inside a source repo containing `skills/<skill-name>/SKILL.md`.
-- Lock file: tracks the exact commit per source for reproducible installs. One lock file per scope.
-
-## Manifest format
-
-Manifests are JSON files named `pinch.json`:
-
-- `~/.pi/agent/pinch.json` — global (user) manifest, applies everywhere
-- `.pi/pinch.json` — project manifest, 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, grouped by scope. |
-
-## Manifest rules
-
-The global manifest (`~/.pi/agent/pinch.json`) holds full source definitions with `repo`, `ref`, `path`, and `plugins`.
-
-The project manifest (`.pi/pinch.json`) can define new sources (with `repo`) or reference global sources by name with only `plugins`. When a key exists in the global manifest, `repo`/`ref`/`path` cannot be overridden — only `plugins` is allowed. Referencing a global source in the project manifest promotes it to project scope.
-
-```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 the install location).
-3. Add the entry to the appropriate `pinch.json` — global for user-wide plugins, project for repo-specific ones.
-
-```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/
-├── agent/
-│   ├── pinch.json                 # Global manifest
-│   ├── pinch-lock.json            # User-scope lock file
-│   └── extensions/
-│       └── pinch.ts               # The extension itself
-└── pinch/                         # User-scope installs
-    └── <source-name>/
-        └── <plugin>/
-            └── skills/
-                └── <skill>/
-                    └── SKILL.md
-
-.pi/                               # Project scope
-├── pinch.json                     # Project manifest (optional)
-├── pinch-lock.json                # Project-scope lock file
-└── pinch/
-    └── <source-name>/
-        └── <plugin>/
-            └── skills/
-                └── <skill>/
-                    └── SKILL.md
-```
-
-Repos are cloned once into the XDG cache. Only the requested plugin directories are copied into the scope-appropriate install location.
-
-## 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: the plugin must have `skills/<name>/SKILL.md` structure. Run `/pinch:status` to verify.
-- Wrong scope: if a user plugin appears in the project, check whether the project manifest references that source name. Remove the reference to keep it in user scope.