pi: user config at ai/pi/, symlink ~/.pi/agent
Pi user-level config (extensions, settings, keybindings, pinch
manifest, skills) now lives at ai/pi/ alongside ai/claude/ and
ai/skills/. Frork declares ~/.pi/agent → ~/.dotfiles/ai/pi.
The old ~/.pi → ~/.dotfiles/.pi symlink is replaced with a real
~/.pi directory containing just the agent symlink. Pi writes
runtime files (sessions, auth) into ai/pi/ — gitignored there.
Settings updated to point skills at ~/.dotfiles/ai/skills
instead of reaching through ~/.claude/skills.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/ai/pi/.gitignore b/ai/pi/.gitignore
new file mode 100644
index 00000000..71abe84c
--- /dev/null
+++ b/ai/pi/.gitignore
@@ -0,0 +1,3 @@
+sessions/
+auth.json
+approved-permissions.json
diff --git a/ai/pi/extensions/README.md b/ai/pi/extensions/README.md
new file mode 100644
index 00000000..e5f6dd7f
--- /dev/null
+++ b/ai/pi/extensions/README.md
@@ -0,0 +1,109 @@
+# Permission Gate
+
+Requires explicit user confirmation before every tool call, with
+declarative allowlists for commands that can run without prompting.
+
+## How It Works
+
+**Read tool** — auto-allowed for files tracked by `jj`, files in skills
+directories, and pi documentation.
+
+**Bash tool** — auto-allowed only if the command matches the allowlist
+and contains no shell chaining (`|`, `&&`, `;`, `` ` ``, `$()`).
+
+Everything else prompts for confirmation.
+
+## Base Allowlist
+
+The `BASE_COMMANDS` tree in `permission-gate.ts` defines the global
+allowlist. Each entry maps a command to a rule:
+
+```ts
+const BASE_COMMANDS: CommandRule = {
+ jj: ["diff", "log", "show", "status"],
+};
+```
+
+### Rule Types
+
+| Rule | Meaning | Example |
+|------|---------|---------|
+| `true` | Allow with any arguments | `ls: true` |
+| `string[]` | Allow only these subcommands | `jj: ["diff", "log"]` |
+| `{ ... }` | Recurse into subcommands | `gh: { pr: ["list", "view"] }` |
+| `(args) => boolean` | Custom predicate | `rake: (a) => a.startsWith("-T")` |
+
+Rules are recursive — arrays and objects can nest to any depth:
+
+```ts
+{
+ gh: {
+ pr: ["list", "view", "checks"],
+ issue: { list: true, view: true },
+ repo: true, // all repo subcommands
+ },
+}
+```
+
+`gh pr list --author me` walks `gh` → `pr` → `list` → `true` → allowed.
+`gh pr create` walks `gh` → `pr` → `create` → undefined → prompted.
+
+## Per-Project Overrides
+
+Projects can extend the allowlist by placing a `.pi/permissions.json`
+in the project root (next to `.pi/`):
+
+```json
+{
+ "allow": {
+ "cargo": ["test", "check", "clippy"],
+ "make": ["test", "lint"],
+ "docker": {
+ "compose": ["up", "down", "ps"]
+ }
+ }
+}
+```
+
+### Trust Model
+
+Project overrides are **not trusted automatically**. On the first tool
+call of a session (or when the file changes), the user sees a
+confirmation prompt:
+
+```
+Project wants to auto-allow:
+ cargo test
+ cargo check
+ cargo clippy
+ make test
+ make lint
+ docker compose up
+ docker compose down
+ docker compose ps
+
+Allow? [y/n]
+```
+
+Approvals are persisted in `~/.pi/agent/approved-permissions.json`,
+keyed by project directory and a SHA-256 hash of the file content.
+If `.pi/permissions.json` is modified, the hash won't match and the
+user is re-prompted.
+
+### Merge Behavior
+
+Project rules are **additive only** — they are deep-merged with the
+base allowlist. A project cannot narrow or remove base permissions.
+If the base already grants `true` at some node, the project's rules
+for that node are ignored (it's already fully allowed).
+
+### JSON-Only
+
+Project permissions use `JsonCommandRule`, which supports `true`,
+`string[]`, and nested objects — but not function predicates (since
+the file is JSON). Predicates are only available in `BASE_COMMANDS`.
+
+### Non-Interactive Mode
+
+If pi is running without a UI (e.g., RPC or print mode), project
+permissions that haven't been previously approved are silently skipped.
diff --git a/ai/pi/extensions/claude-plugins.ts b/ai/pi/extensions/claude-plugins.ts
new file mode 100644
index 00000000..11d475ba
--- /dev/null
+++ b/ai/pi/extensions/claude-plugins.ts
@@ -0,0 +1,218 @@
+/**
+ * Claude Plugins Bridge
+ *
+ * Loads skills from Claude Code's plugin cache (~/.claude/plugins/cache)
+ * and makes them available in pi. Claude's installed plugins become the
+ * source of truth — no duplicate configuration needed.
+ *
+ * Structure expected:
+ * ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/
+ * .claude-plugin/plugin.json
+ * skills/<skill-name>/SKILL.md
+ *
+ * When multiple versions of a plugin exist, the highest version is used.
+ * Skills under examples/ directories are skipped.
+ */
+
+import * as fs from "node:fs";
+import * as path from "node:path";
+import * as os from "node:os";
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+
+const CACHE_DIR = path.join(os.homedir(), ".claude", "plugins", "cache");
+
+interface PluginSkill {
+ name: string;
+ description: string;
+ path: string; // absolute path to SKILL.md
+ plugin: string; // plugin name from plugin.json
+}
+
+/**
+ * Compare version strings. Handles semver (1.2.3) and commit hashes
+ * (hex strings treated as equal — we pick the first found).
+ */
+function compareVersions(a: string, b: string): number {
+ const semverRe = /^(\d+)\.(\d+)\.(\d+)/;
+ const ma = a.match(semverRe);
+ const mb = b.match(semverRe);
+
+ if (ma && mb) {
+ for (let i = 1; i <= 3; i++) {
+ const diff = parseInt(ma[i]) - parseInt(mb[i]);
+ if (diff !== 0) return diff;
+ }
+ return 0;
+ }
+
+ // Non-semver (commit hashes, etc.) — no meaningful ordering
+ return 0;
+}
+
+/**
+ * Parse YAML-style frontmatter from a SKILL.md file.
+ */
+function parseFrontmatter(content: string): Record<string, string> {
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
+ if (!match) return {};
+
+ const result: Record<string, string> = {};
+ for (const line of match[1].split("\n")) {
+ const kv = line.match(/^(\w[\w-]*):\s*(.+)/);
+ if (kv) {
+ result[kv[1]] = kv[2].trim();
+ }
+ }
+ return result;
+}
+
+/**
+ * Discover skills from the Claude plugins cache.
+ */
+function discoverSkills(): PluginSkill[] {
+ if (!fs.existsSync(CACHE_DIR)) return [];
+
+ // Collect: marketplace -> plugin -> version[]
+ const plugins = new Map<string, { versions: string[]; marketplace: string; plugin: string }>();
+
+ for (const marketplace of safeReaddir(CACHE_DIR)) {
+ const marketDir = path.join(CACHE_DIR, marketplace);
+ if (!isDir(marketDir)) continue;
+
+ for (const plugin of safeReaddir(marketDir)) {
+ const pluginDir = path.join(marketDir, plugin);
+ if (!isDir(pluginDir)) continue;
+
+ const key = `${marketplace}/${plugin}`;
+ const entry = plugins.get(key) ?? { versions: [], marketplace, plugin };
+
+ for (const version of safeReaddir(pluginDir)) {
+ const versionDir = path.join(pluginDir, version);
+ if (!isDir(versionDir)) continue;
+
+ // Must have .claude-plugin/plugin.json to be a real plugin
+ const pluginJson = path.join(versionDir, ".claude-plugin", "plugin.json");
+ if (fs.existsSync(pluginJson)) {
+ entry.versions.push(version);
+ }
+ }
+
+ if (entry.versions.length > 0) {
+ plugins.set(key, entry);
+ }
+ }
+ }
+
+ // For each plugin, pick the latest version and collect skills
+ const skills: PluginSkill[] = [];
+
+ for (const [key, { versions, marketplace, plugin }] of plugins) {
+ const latest = versions.sort(compareVersions).at(-1)!;
+ const versionDir = path.join(CACHE_DIR, marketplace, plugin, latest);
+
+ // Read plugin name from plugin.json
+ let pluginName = plugin;
+ try {
+ const meta = JSON.parse(
+ fs.readFileSync(path.join(versionDir, ".claude-plugin", "plugin.json"), "utf-8")
+ );
+ if (meta.name) pluginName = meta.name;
+ } catch {}
+
+ // Find SKILL.md files under skills/ (not under examples/)
+ const skillsDir = path.join(versionDir, "skills");
+ if (!isDir(skillsDir)) continue;
+
+ for (const skillFile of findSkillFiles(skillsDir, versionDir)) {
+ try {
+ const content = fs.readFileSync(skillFile, "utf-8");
+ const fm = parseFrontmatter(content);
+ if (!fm.description) continue; // pi requires a description
+
+ const name = fm.name ?? path.basename(path.dirname(skillFile));
+ skills.push({ name, description: fm.description, path: skillFile, plugin: pluginName });
+ } catch {}
+ }
+ }
+
+ return skills;
+}
+
+/**
+ * Recursively find SKILL.md files, skipping examples/ directories.
+ */
+function findSkillFiles(dir: string, root: string): string[] {
+ const results: string[] = [];
+
+ for (const entry of safeReaddirEntries(dir)) {
+ const fullPath = path.join(dir, entry.name);
+
+ if (entry.isDirectory()) {
+ // Skip examples directories relative to the plugin root
+ const rel = path.relative(root, fullPath);
+ if (rel.startsWith("examples")) continue;
+
+ results.push(...findSkillFiles(fullPath, root));
+ } else if (entry.name === "SKILL.md") {
+ results.push(fullPath);
+ }
+ }
+
+ return results;
+}
+
+function isDir(p: string): boolean {
+ try {
+ return fs.statSync(p).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+function safeReaddir(dir: string): string[] {
+ try {
+ return fs.readdirSync(dir);
+ } catch {
+ return [];
+ }
+}
+
+function safeReaddirEntries(dir: string): fs.Dirent[] {
+ try {
+ return fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+}
+
+export default function claudePlugins(pi: ExtensionAPI) {
+ let skills: PluginSkill[] = [];
+
+ pi.on("session_start", async (_event, ctx) => {
+ skills = discoverSkills();
+ if (skills.length > 0) {
+ ctx.ui.notify(`Claude plugins: ${skills.length} skill(s) loaded`, "info");
+ }
+ });
+
+ pi.on("before_agent_start", async (event) => {
+ if (skills.length === 0) return;
+
+ const xml = skills
+ .map(
+ (s) =>
+ ` <skill>\n <name>${s.name}</name>\n <description>${escapeXml(s.description)}</description>\n <location>${s.path}</location>\n </skill>`
+ )
+ .join("\n");
+
+ return {
+ systemPrompt:
+ event.systemPrompt +
+ `\n\nThe following skills are available from Claude Code plugins.\nUse the read tool to load a skill's file when the task matches its description.\n\n<available_skills>\n${xml}\n</available_skills>\n`,
+ };
+ });
+}
+
+function escapeXml(s: string): string {
+ return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
+}
diff --git a/ai/pi/extensions/fork-tmux.ts b/ai/pi/extensions/fork-tmux.ts
new file mode 100644
index 00000000..1fe42c90
--- /dev/null
+++ b/ai/pi/extensions/fork-tmux.ts
@@ -0,0 +1,120 @@
+/**
+ * Fork Tmux - Fork the current session into a new tmux pane, window, or popup.
+ *
+ * Creates a branched copy of the current session and opens it
+ * in a new tmux target, leaving the current session untouched.
+ *
+ * Usage:
+ * /fork-tmux Popup (tmux 3.3+)
+ * /fork-tmux 80%x80% Popup with size
+ * /fork-tmux -h Horizontal split (right)
+ * /fork-tmux -v Vertical split (below)
+ * /fork-tmux -w New window
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+
+type Mode = "split-h" | "split-v" | "window" | "popup";
+
+function parseArgs(raw: string): { mode: Mode; extra: string[] } {
+ const parts = raw.trim().split(/\s+/).filter(Boolean);
+ let mode: Mode = "popup";
+ const extra: string[] = [];
+
+ for (const p of parts) {
+ if (p === "-h") mode = "split-h";
+ else if (p === "-v") mode = "split-v";
+ else if (p === "-w") mode = "window";
+ else extra.push(p);
+ }
+
+ return { mode, extra };
+}
+
+function shellEscape(s: string): string {
+ return "'" + s.replace(/'/g, "'\\''") + "'";
+}
+
+function findPi(): string {
+ // Resolve full path to pi so it works in minimal shell environments (e.g. tmux popups)
+ try {
+ const { execSync } = require("node:child_process");
+ return execSync("which pi", { encoding: "utf-8" }).trim();
+ } catch {
+ return "pi";
+ }
+}
+
+function buildShellCommand(mode: Mode, extra: string[], sessionFile: string): string {
+ const piCmd = `${findPi()} --session ${shellEscape(sessionFile)}`;
+
+ switch (mode) {
+ case "split-h":
+ return `tmux split-window -h -- ${piCmd}`;
+ case "split-v":
+ return `tmux split-window -v -- ${piCmd}`;
+ case "window":
+ return `tmux new-window -- ${piCmd}`;
+ case "popup": {
+ const sizeArgs: string[] = [];
+ if (extra[0]) {
+ const [w, h] = extra[0].split("x");
+ if (w) sizeArgs.push(`-w ${w}`);
+ if (h) sizeArgs.push(`-h ${h}`);
+ }
+ return `tmux display-popup -E ${sizeArgs.join(" ")} -- ${piCmd}`;
+ }
+ }
+}
+
+export default function (pi: ExtensionAPI) {
+ pi.registerCommand("fork-tmux", {
+ description: "Fork session into a tmux popup (default), pane (-h, -v), or window (-w)",
+ handler: async (args, ctx) => {
+ if (!ctx.hasUI) {
+ ctx.ui.notify("fork-tmux requires interactive mode", "error");
+ return;
+ }
+
+ if (!process.env.TMUX) {
+ ctx.ui.notify("Not inside a tmux session", "error");
+ return;
+ }
+
+ const sessionFile = ctx.sessionManager.getSessionFile();
+ if (!sessionFile) {
+ ctx.ui.notify("No session file (ephemeral session)", "error");
+ return;
+ }
+
+ const leafId = ctx.sessionManager.getLeafId();
+ if (!leafId) {
+ ctx.ui.notify("No entries to fork from", "error");
+ return;
+ }
+
+ const newSessionFile = ctx.sessionManager.createBranchedSession(leafId);
+ if (!newSessionFile) {
+ ctx.ui.notify("Failed to create branched session", "error");
+ return;
+ }
+
+ const { mode, extra } = parseArgs(args);
+ const cmd = buildShellCommand(mode, extra, newSessionFile);
+
+ const result = await pi.exec("bash", ["-c", cmd]);
+ if (result.code !== 0) {
+ ctx.ui.notify(`tmux failed (exit ${result.code}): ${result.stderr || result.stdout || "(no output)"}`, "error");
+ return;
+ }
+
+ const labels: Record<Mode, string> = {
+ "split-h": "pane (right)",
+ "split-v": "pane (below)",
+ "window": "window",
+ "popup": "popup",
+ };
+ ctx.ui.notify(`Forked into new ${labels[mode]}`, "success");
+ },
+ });
+}
diff --git a/ai/pi/extensions/jj-status.ts b/ai/pi/extensions/jj-status.ts
new file mode 100644
index 00000000..c8c49a9d
--- /dev/null
+++ b/ai/pi/extensions/jj-status.ts
@@ -0,0 +1,257 @@
+/**
+ * JJ Status Extension
+ *
+ * Replaces the default footer to show jj info instead of the git branch.
+ * The built-in footer shows "(detached)" in colocated jj repos because
+ * jj's git backend uses a detached HEAD. This extension swaps that for
+ * bookmark, distance, and change ID — matching p10k's jj prompt.
+ *
+ * All other footer behavior (token stats, context %, model, extension
+ * statuses) is preserved verbatim from the built-in footer.
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
+import { execSync } from "node:child_process";
+
+// --- jj info ---
+
+function getJJBranch(cwd: string): string | null {
+ try {
+ const bookmark = execSync(
+ `jj --ignore-working-copy --no-pager log --no-graph --limit 1 -r '
+ coalesce(
+ heads(::@ & (bookmarks() | remote_bookmarks() | tags())),
+ heads(@:: & (bookmarks() | remote_bookmarks() | tags())),
+ trunk()
+ )' -T 'separate(" ", bookmarks, tags)'`,
+ { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
+ ).trim().split(" ")[0]?.replace(/\*$/, "") ?? "";
+
+ let result = "";
+
+ if (bookmark) {
+ const where = bookmark.length > 32
+ ? bookmark.slice(0, 12) + "…" + bookmark.slice(-12)
+ : bookmark;
+ const after = execSync(
+ `jj --ignore-working-copy --no-pager log --no-graph -r '${bookmark}..@ & (~empty() | merges())' -T '"n"'`,
+ { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
+ ).length;
+ const before = execSync(
+ `jj --ignore-working-copy --no-pager log --no-graph -r '@..${bookmark} & (~empty() | merges())' -T '"n"'`,
+ { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
+ ).length;
+ result += where;
+ if (before > 0) result += `‹${before}`;
+ if (after > 0) result += `›${after}`;
+ }
+
+ const raw = execSync(
+ `jj --ignore-working-copy --no-pager log --no-graph --limit 1 -r @ -T '
+ separate("#",
+ change_id.shortest(4),
+ concat(
+ if(conflict, "!"),
+ if(divergent, "D"),
+ if(hidden, "H"),
+ if(immutable, "I"),
+ ),
+ )'`,
+ { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
+ ).trim();
+ const [changeId, flags] = raw.split("#");
+
+ if (changeId) {
+ if (result) result += " ";
+ result += changeId;
+ }
+ if (flags) result += ` ${flags}`;
+
+ return result || null;
+ } catch {
+ return null;
+ }
+}
+
+// --- footer (copied from built-in, replacing getGitBranch with getJJBranch) ---
+
+function sanitizeStatusText(text: string): string {
+ return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
+}
+
+function formatTokens(count: number): string {
+ if (count < 1000) return count.toString();
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
+ return `${Math.round(count / 1000000)}M`;
+}
+
+let cachedBranch: string | null = null;
+
+function refreshJJ(cwd: string) {
+ cachedBranch = getJJBranch(cwd);
+}
+
+export default function (pi: ExtensionAPI) {
+ pi.on("session_start", async (_event, ctx) => {
+ refreshJJ(ctx.cwd);
+
+ ctx.ui.setFooter((tui, theme, footerData) => ({
+ invalidate() {},
+ render(width: number): string[] {
+ // --- Line 1: pwd + jj branch + session name ---
+ let pwd = process.cwd();
+ const home = process.env.HOME || process.env.USERPROFILE;
+ if (home && pwd.startsWith(home)) {
+ pwd = `~${pwd.slice(home.length)}`;
+ }
+
+ // jj info instead of git branch
+ if (cachedBranch) {
+ pwd = `${pwd} (${cachedBranch})`;
+ }
+
+ const sessionName = ctx.sessionManager.getSessionName();
+ if (sessionName) {
+ pwd = `${pwd} • ${sessionName}`;
+ }
+
+ if (pwd.length > width) {
+ const half = Math.floor(width / 2) - 2;
+ if (half > 1) {
+ pwd = `${pwd.slice(0, half)}...${pwd.slice(-(half - 1))}`;
+ } else {
+ pwd = pwd.slice(0, Math.max(1, width));
+ }
+ }
+
+ // --- Line 2: token stats (left) + model (right) ---
+ let totalInput = 0;
+ let totalOutput = 0;
+ let totalCacheRead = 0;
+ let totalCacheWrite = 0;
+ let totalCost = 0;
+
+ for (const entry of ctx.sessionManager.getEntries()) {
+ if (entry.type === "message" && entry.message.role === "assistant") {
+ const m = entry.message as any;
+ totalInput += m.usage.input;
+ totalOutput += m.usage.output;
+ totalCacheRead += m.usage.cacheRead;
+ totalCacheWrite += m.usage.cacheWrite;
+ totalCost += m.usage.cost.total;
+ }
+ }
+
+ // Context % from last non-aborted assistant message
+ const branch = ctx.sessionManager.getBranch();
+ let contextTokens = 0;
+ for (let i = branch.length - 1; i >= 0; i--) {
+ const e = branch[i];
+ if (e.type === "message" && e.message.role === "assistant") {
+ const m = e.message as any;
+ if (m.stopReason !== "aborted") {
+ contextTokens = m.usage.input + m.usage.output + m.usage.cacheRead + m.usage.cacheWrite;
+ break;
+ }
+ }
+ }
+
+ const contextWindow = ctx.model?.contextWindow || 0;
+ const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
+
+ const statsParts: string[] = [];
+ if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);
+ if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);
+ if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
+ if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
+
+ const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
+ if (totalCost || usingSubscription) {
+ statsParts.push(`$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
+ }
+
+ const contextDisplay = `${contextPercentValue.toFixed(1)}%/${formatTokens(contextWindow)} (auto)`;
+ if (contextPercentValue > 90) {
+ statsParts.push(theme.fg("error", contextDisplay));
+ } else if (contextPercentValue > 70) {
+ statsParts.push(theme.fg("warning", contextDisplay));
+ } else {
+ statsParts.push(contextDisplay);
+ }
+
+ let statsLeft = statsParts.join(" ");
+ let statsLeftWidth = visibleWidth(statsLeft);
+ if (statsLeftWidth > width) {
+ const plain = statsLeft.replace(/\x1b\[[0-9;]*m/g, "");
+ statsLeft = `${plain.substring(0, width - 3)}...`;
+ statsLeftWidth = visibleWidth(statsLeft);
+ }
+
+ const minPadding = 2;
+ const modelName = ctx.model?.id || "no-model";
+ let rightSideWithoutProvider = modelName;
+ if (ctx.model?.reasoning) {
+ const level = pi.getThinkingLevel() || "off";
+ rightSideWithoutProvider =
+ level === "off" ? `${modelName} • thinking off` : `${modelName} • ${level}`;
+ }
+
+ let rightSide = rightSideWithoutProvider;
+ if (footerData.getAvailableProviderCount() > 1 && ctx.model) {
+ rightSide = `(${ctx.model.provider}) ${rightSideWithoutProvider}`;
+ if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {
+ rightSide = rightSideWithoutProvider;
+ }
+ }
+
+ const rightSideWidth = visibleWidth(rightSide);
+ const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;
+ let statsLine: string;
+ if (totalNeeded <= width) {
+ const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
+ statsLine = statsLeft + padding + rightSide;
+ } else {
+ const availableForRight = width - statsLeftWidth - minPadding;
+ if (availableForRight > 3) {
+ const plainRight = rightSide.replace(/\x1b\[[0-9;]*m/g, "");
+ const truncated = plainRight.substring(0, availableForRight);
+ const padding = " ".repeat(width - statsLeftWidth - truncated.length);
+ statsLine = statsLeft + padding + truncated;
+ } else {
+ statsLine = statsLeft;
+ }
+ }
+
+ const dimLeft = theme.fg("dim", statsLeft);
+ const remainder = statsLine.slice(statsLeft.length);
+ const dimRemainder = theme.fg("dim", remainder);
+ const lines = [theme.fg("dim", pwd), dimLeft + dimRemainder];
+
+ // --- Line 3 (optional): extension statuses ---
+ const extensionStatuses = footerData.getExtensionStatuses();
+ if (extensionStatuses.size > 0) {
+ const sorted = Array.from(extensionStatuses.entries())
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([, text]) => sanitizeStatusText(text));
+ const statusLine = sorted.join(" ");
+ lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
+ }
+
+ return lines;
+ },
+ }));
+ });
+
+ pi.on("tool_result", async (event, ctx) => {
+ if (event.toolName === "bash") {
+ const cmd = String(event.input.command ?? "").trimStart();
+ if (cmd.startsWith("jj ")) refreshJJ(ctx.cwd);
+ }
+ if (event.toolName === "write" || event.toolName === "edit") {
+ refreshJJ(ctx.cwd);
+ }
+ });
+}
diff --git a/ai/pi/extensions/model-identity.ts b/ai/pi/extensions/model-identity.ts
new file mode 100644
index 00000000..8dbfd9a0
--- /dev/null
+++ b/ai/pi/extensions/model-identity.ts
@@ -0,0 +1,26 @@
+/**
+ * Model Identity Extension
+ *
+ * Replaces the default Claude Code identity in the system prompt and
+ * injects the current model name so the model knows what it's running
+ * as (useful for Assisted-by footers, etc.)
+ */
+
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+
+export default function (pi: ExtensionAPI) {
+ pi.on("before_agent_start", async (event, ctx) => {
+ const model = ctx.model;
+ let prompt = event.systemPrompt;
+
+ // Replace the Claude Code identity preamble
+ prompt = prompt.replace(
+ /You are Claude Code, Anthropic.s official CLI for Claude\.\s*/,
+ "",
+ );
+
+ prompt += `\n\nYou are running as: ${model.name} (${model.provider}/${model.id}) via pi`;
+
+ return { systemPrompt: prompt };
+ });
+}
diff --git a/ai/pi/extensions/permission-gate.ts b/ai/pi/extensions/permission-gate.ts
new file mode 100644
index 00000000..ecfe279a
--- /dev/null
+++ b/ai/pi/extensions/permission-gate.ts
@@ -0,0 +1,439 @@
+/**
+ * Permission Gate Extension
+ *
+ * Requires explicit user confirmation before every tool call.
+ * Auto-allows read-only operations (read tool, non-destructive bash commands).
+ * Auto-allows reading from tracked files, skills directories, and pi docs.
+ * Shows tool name and a summary of the arguments, then asks to allow or block.
+ *
+ * Write/edit prompts offer "Allow for session" — once accepted, subsequent
+ * writes/edits to tracked files are auto-allowed for the remainder of the
+ * session. Writes to untracked files still require confirmation. Resets on
+ * session start.
+ *
+ * Allowed bash commands are configured declaratively in BASE_COMMANDS below.
+ * Projects can add to the allowlist via `.pi/permissions.json`, which requires
+ * a one-time user confirmation (re-prompted if the file changes).
+ */
+
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import { execSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+
+// ---------------------------------------------------------------------------
+// Allowed bash commands configuration
+//
+// Each entry maps a command/subcommand to its permission rule, forming a tree:
+//
+// true — allow with any further arguments
+// string[] — shorthand: allow these subcommands (each as true)
+// Record<string, CommandRule> — recurse into subcommands
+// (args: string) => boolean — custom predicate on remaining args
+//
+// Examples:
+// jj: ["diff", "log", "show", "status"],
+// gh: {
+// pr: ["list", "view", "checks"],
+// issue: ["list", "view"],
+// },
+// ---------------------------------------------------------------------------
+type CommandRule = true | string[] | ((args: string) => boolean) | { [subcommand: string]: CommandRule };
+
+/** JSON-safe subset of CommandRule (no predicates) for .pi/permissions.json */
+type JsonCommandRule = true | string[] | { [subcommand: string]: JsonCommandRule };
+
+const BASE_COMMANDS: CommandRule = {
+ gh: { "issue": ["list", "view"], "project": ["item-list", "list"], "repo": ["list"] },
+ jj: { "bookmark": ["list"], "diff": true, "log": true, "show": true, "st": true, "status": true },
+};
+
+// ---------------------------------------------------------------------------
+// Per-project permission overrides
+//
+// Projects can place a `.pi/permissions.json` in the project root:
+//
+// { "allow": { "cargo": ["test", "check"], "make": ["test"] } }
+//
+// On first use (or when the file changes), the user is shown a summary
+// and asked to confirm. Approvals are stored in ~/.pi/agent/ keyed by
+// a hash of the file content.
+// ---------------------------------------------------------------------------
+const APPROVALS_PATH = path.join(os.homedir(), ".pi/agent/approved-permissions.json");
+
+interface ApprovalRecord {
+ hash: string;
+}
+
+function loadApprovals(): Record<string, ApprovalRecord> {
+ try {
+ return JSON.parse(fs.readFileSync(APPROVALS_PATH, "utf-8"));
+ } catch {
+ return {};
+ }
+}
+
+function saveApprovals(approvals: Record<string, ApprovalRecord>): void {
+ fs.mkdirSync(path.dirname(APPROVALS_PATH), { recursive: true });
+ fs.writeFileSync(APPROVALS_PATH, JSON.stringify(approvals, null, 2) + "\n");
+}
+
+function hashContent(content: string): string {
+ return createHash("sha256").update(content).digest("hex");
+}
+
+/**
+ * Render a command rule tree as a human-readable list of allowed command paths.
+ * e.g. { cargo: ["test", "check"] } → ["cargo test", "cargo check"]
+ */
+function summarizeRules(rule: JsonCommandRule, prefix = ""): string[] {
+ if (rule === true) return [prefix || "(all commands)"];
+ if (Array.isArray(rule)) return rule.map((s) => `${prefix} ${s}`.trim());
+ const lines: string[] = [];
+ for (const [key, sub] of Object.entries(rule)) {
+ lines.push(...summarizeRules(sub, `${prefix} ${key}`.trim()));
+ }
+ return lines;
+}
+
+/**
+ * Deep-merge a JSON rule tree into an existing CommandRule tree.
+ * Project rules can only add — they cannot override `true` with something
+ * more restrictive, and predicates in the base are preserved.
+ */
+function mergeRules(base: CommandRule, override: JsonCommandRule): CommandRule {
+ // base is already fully permissive — nothing to add
+ if (base === true) return true;
+ // override grants blanket access at this level
+ if (override === true) return true;
+
+ // base is a predicate or array — wrap into object form to merge
+ if (typeof base === "function" || Array.isArray(base)) {
+ // Can't cleanly merge a predicate/array with an object override.
+ // Convert base array to object, keep predicates as-is.
+ if (Array.isArray(base) && !Array.isArray(override)) {
+ const obj: { [k: string]: CommandRule } = {};
+ for (const k of base) obj[k] = true;
+ return mergeRules(obj, override);
+ }
+ if (Array.isArray(base) && Array.isArray(override)) {
+ return [...new Set([...base, ...override])];
+ }
+ // predicate base + object override — keep predicate (it's more expressive)
+ return base;
+ }
+
+ // Both are objects (or override is array)
+ if (Array.isArray(override)) {
+ // Convert override array to object and merge
+ const obj: { [k: string]: JsonCommandRule } = {};
+ for (const k of override) obj[k] = true;
+ return mergeRules(base, obj);
+ }
+
+ // Both are objects — recurse
+ const merged: { [k: string]: CommandRule } = { ...base };
+ for (const [key, val] of Object.entries(override)) {
+ if (key in merged) {
+ merged[key] = mergeRules(merged[key], val);
+ } else {
+ merged[key] = val;
+ }
+ }
+ return merged;
+}
+
+/** The effective merged command tree, updated after approval. */
+let allowedCommands: CommandRule = BASE_COMMANDS;
+
+/**
+ * Pending project rules awaiting user approval. Set at session start if the
+ * project has a .pi/permissions.json that hasn't been approved yet. Cleared
+ * after the user accepts or rejects.
+ */
+let pendingProjectRules: { raw: string; rules: JsonCommandRule } | null = null;
+
+/** Per-session opt-in: when true, write/edit to tracked files are auto-allowed. */
+let sessionAllowEdits = false;
+
+// ---------------------------------------------------------------------------
+// Helpers for read-tool path checks
+// ---------------------------------------------------------------------------
+const PI_DOCS_PREFIX = path.join(
+ os.homedir(),
+ ".volta/tools/image/packages/@mariozechner/pi-coding-agent/"
+);
+
+let trackedFiles: Set<string> | null = null;
+let skillsDirs: Set<string> | null = null;
+
+function getTrackedFiles(cwd: string): Set<string> {
+ if (trackedFiles) return trackedFiles;
+ try {
+ const output = execSync("jj file list", { cwd, encoding: "utf-8" });
+ trackedFiles = new Set(output.trim().split("\n").map((f) => {
+ const resolved = path.resolve(cwd, f);
+ try { return fs.realpathSync(resolved); } catch { return resolved; }
+ }));
+ } catch {
+ trackedFiles = new Set();
+ }
+ return trackedFiles;
+}
+
+/**
+ * Derive skills directories from the system prompt's <available_skills> block.
+ * This reflects the actual loaded skills (including any added by extensions via
+ * resources_discover), rather than just what's configured in settings.json.
+ */
+function getSkillsDirectories(ctx: ExtensionContext): Set<string> {
+ if (skillsDirs) return skillsDirs;
+ const dirs = new Set<string>();
+ const prompt = ctx.getSystemPrompt();
+ const locationRe = /<location>(.*?)<\/location>/g;
+ let match;
+ while ((match = locationRe.exec(prompt)) !== null) {
+ const dir = path.dirname(match[1]);
+ dirs.add(dir);
+ // Also resolve symlinks so that paths through symlinked directories
+ // (e.g. ~/.claude -> ~/.dotfiles/ai/claude/) still match after
+ // realpathSync is applied to the file being read.
+ try { dirs.add(fs.realpathSync(dir)); } catch { /* ignore */ }
+ }
+ skillsDirs = dirs;
+ return skillsDirs;
+}
+
+function isInSkillsDirectory(filePath: string, ctx: ExtensionContext): boolean {
+ const dirs = getSkillsDirectories(ctx);
+ for (const dir of dirs) {
+ if (filePath.startsWith(dir + path.sep) || filePath === dir) return true;
+ }
+ return false;
+}
+
+// ---------------------------------------------------------------------------
+// Bash command parsing
+// ---------------------------------------------------------------------------
+
+/**
+ * Strip a leading `cd <cwd> && ` prefix from a command.
+ * The agent often wraps commands this way, which is a no-op but obscures the
+ * real command from permission checks.
+ */
+function stripCdToCwd(cmd: string, cwd: string): string {
+ const prefix = `cd ${cwd} && `;
+ if (cmd.startsWith(prefix)) return cmd.slice(prefix.length);
+ return cmd;
+}
+
+/** Returns true if the command string contains shell chaining, piping, or injection operators. */
+function hasChaining(cmd: string): boolean {
+ // Intentionally conservative — if the agent needs any of these it can
+ // ask for permission on the full command.
+ return /[;|&`\n]/.test(cmd)
+ || cmd.includes("$(")
+ || cmd.includes("<(") || cmd.includes(">(")
+ || cmd.includes("<<");
+}
+
+/**
+ * Walk the allowedCommands tree to check if a command is allowed.
+ * Arrays are shorthand for an object whose keys all map to `true`.
+ */
+function isCommandAllowed(cmd: string): boolean {
+ const tokens = cmd.trimStart().split(/\s+/);
+ let rule: CommandRule = allowedCommands;
+
+ for (let i = 0; i < tokens.length; i++) {
+ if (rule === true) return true;
+ if (typeof rule === "function") return rule(tokens.slice(i).join(" "));
+ if (Array.isArray(rule)) return rule.includes(tokens[i]);
+ // rule is a subcommand tree — look up the next token
+ const next = rule[tokens[i]];
+ if (next === undefined) return false;
+ rule = next;
+ }
+
+ // We consumed all tokens — allow if the final node is `true` or a
+ // predicate that accepts empty input. A tree node means we stopped at
+ // a branch (e.g. bare `jj` with no subcommand) — don't auto-allow that.
+ if (rule === true) return true;
+ if (typeof rule === "function") return rule("");
+ return false;
+}
+
+// ---------------------------------------------------------------------------
+// Main permission check
+// ---------------------------------------------------------------------------
+
+function isTrackedFile(filePath: string, ctx: ExtensionContext): boolean {
+ let realPath: string;
+ try {
+ realPath = fs.realpathSync(filePath);
+ } catch {
+ return false;
+ }
+ return getTrackedFiles(ctx.cwd).has(realPath);
+}
+
+function isAllowed(toolName: string, input: Record<string, unknown>, ctx: ExtensionContext): boolean {
+ if (toolName === "read") {
+ const filePath = path.resolve(ctx.cwd, String(input.path ?? ""));
+ // Resolve symlinks so a tracked symlink pointing outside the repo
+ // doesn't auto-allow reading its target.
+ let realPath: string;
+ try {
+ realPath = fs.realpathSync(filePath);
+ } catch {
+ return false; // Can't resolve — require prompt
+ }
+ return (
+ getTrackedFiles(ctx.cwd).has(realPath) ||
+ isInSkillsDirectory(realPath, ctx) ||
+ realPath.startsWith(PI_DOCS_PREFIX)
+ );
+ }
+
+ if (toolName === "write" || toolName === "edit") {
+ if (!sessionAllowEdits) return false;
+ const filePath = path.resolve(ctx.cwd, String(input.path ?? ""));
+ return isTrackedFile(filePath, ctx);
+ }
+
+ if (toolName === "bash") {
+ const cmd = stripCdToCwd(String(input.command ?? "").trimStart(), ctx.cwd);
+ if (hasChaining(cmd)) return false;
+ return isCommandAllowed(cmd);
+ }
+
+ return false;
+}
+
+// ---------------------------------------------------------------------------
+// Extension entry point
+// ---------------------------------------------------------------------------
+
+export default function(pi: ExtensionAPI) {
+ pi.on("session_start", async (_event, ctx) => {
+ // Reset state each session
+ allowedCommands = BASE_COMMANDS;
+ pendingProjectRules = null;
+ sessionAllowEdits = false;
+
+ const permPath = path.join(ctx.cwd, ".pi/permissions.json");
+ let raw: string;
+ try {
+ raw = fs.readFileSync(permPath, "utf-8");
+ } catch {
+ return; // No project permissions file
+ }
+
+ let projectRules: JsonCommandRule;
+ try {
+ const parsed = JSON.parse(raw);
+ projectRules = parsed.allow;
+ if (!projectRules) return;
+ } catch {
+ if (ctx.hasUI) ctx.ui.notify("Invalid .pi/permissions.json — ignoring", "warning");
+ return;
+ }
+
+ const hash = hashContent(raw);
+ const approvals = loadApprovals();
+
+ if (approvals[ctx.cwd]?.hash === hash) {
+ // Already approved this exact version
+ allowedCommands = mergeRules(BASE_COMMANDS, projectRules);
+ } else {
+ // Defer approval prompt to first tool_call (UI isn't ready here)
+ pendingProjectRules = { raw, rules: projectRules };
+ }
+ });
+
+ pi.on("tool_result", async (event) => {
+ if (event.toolName === "bash") {
+ const cmd = String(event.input.command ?? "").trimStart();
+ if (cmd.startsWith("jj ")) {
+ trackedFiles = null;
+ }
+ }
+ });
+
+ pi.on("tool_call", async (event, ctx) => {
+ if (!ctx.hasUI) return;
+
+ // Prompt for pending project permissions on first tool_call
+ if (pendingProjectRules) {
+ const pending = pendingProjectRules;
+ pendingProjectRules = null; // Clear so we only prompt once
+
+ const summary = summarizeRules(pending.rules);
+ const message = "Project wants to auto-allow:\n" + summary.map((s) => ` ${s}`).join("\n");
+ const approved = await ctx.ui.confirm("Project permissions", message);
+
+ if (approved) {
+ const approvals = loadApprovals();
+ approvals[ctx.cwd] = { hash: hashContent(pending.raw) };
+ saveApprovals(approvals);
+ allowedCommands = mergeRules(BASE_COMMANDS, pending.rules);
+ }
+ }
+
+ if (isAllowed(event.toolName, event.input, ctx)) return;
+
+ const summary = formatArgs(event.toolName, event.input);
+
+ // For write/edit, offer a "allow for session" option
+ if (event.toolName === "write" || event.toolName === "edit") {
+ const choice = await ctx.ui.select(`${event.toolName}: ${summary}`, [
+ "Allow once",
+ "Allow for session",
+ "Block",
+ ]);
+
+ if (choice === "Allow for session") {
+ sessionAllowEdits = true;
+ ctx.ui.notify("Edits allowed for this session (tracked files only)", "info");
+ return;
+ }
+ if (choice === "Allow once") return;
+ return { block: true, reason: "Blocked by user" };
+ }
+
+ const allowed = await ctx.ui.confirm(event.toolName, summary);
+
+ if (!allowed) {
+ return { block: true, reason: "Blocked by user" };
+ }
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Formatting
+// ---------------------------------------------------------------------------
+
+function formatArgs(toolName: string, input: Record<string, unknown>): string {
+ switch (toolName) {
+ case "bash":
+ return `$ ${input.command}`;
+ case "read":
+ return `${input.path}` + (input.offset ? ` (offset ${input.offset})` : "");
+ case "write":
+ return `${input.path} (${String(input.content ?? "").length} chars)`;
+ case "edit":
+ return `${input.path}`;
+ default: {
+ const keys = Object.keys(input);
+ if (keys.length === 0) return "(no arguments)";
+ return keys.map((k) => `${k}: ${truncate(String(input[k]), 80)}`).join("\n");
+ }
+ }
+}
+
+function truncate(s: string, max: number): string {
+ if (s.length <= max) return s;
+ return s.slice(0, max) + "…";
+}
diff --git a/ai/pi/extensions/pinch.ts b/ai/pi/extensions/pinch.ts
new file mode 100644
index 00000000..e7468c60
--- /dev/null
+++ b/ai/pi/extensions/pinch.ts
@@ -0,0 +1,708 @@
+/**
+ * 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 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).
+ *
+ * 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):
+ *
+ * {
+ * "anthropic": {
+ * "repo": "https://github.com/anthropics/claude-plugins-official",
+ * "ref": "main",
+ * "plugins": ["playground", "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, 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.
+ * The `ref` field in lock entries is informational only — checkout uses
+ * the commit hash directly for reproducibility.
+ */
+
+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; // 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 ──────────────────────────────────────────────────────────────
+
+/** 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");
+}
+
+/** 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 {
+ 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 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;
+ }
+}
+
+/**
+ * Merge global (~/.pi/agent/pinch.json) and project (.pi/pinch.json)
+ * 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.
+ */
+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 {
+ 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: 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", 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. 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/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 = projectPinchDir(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]) {
+ // 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 {}
+ }
+ }
+
+ 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 (project copies).
+ */
+function resolveSkillPaths(config: PinchConfig, cwd: string): string[] {
+ const paths: string[] = [];
+
+ for (const [name, source] of Object.entries(config)) {
+ for (const plugin of source.plugins) {
+ const dir = installedPluginDir(cwd, name, plugin);
+ 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 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: [], copied: [], pruned: [], errors: [...configErrors], skills: [] };
+
+ 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 from cache into project
+ for (const plugin of source.plugins) {
+ log(`copying ${name}/${plugin}...`);
+ const install = installPlugin(cwd, name, source, plugin);
+ if (!install.ok) {
+ result.errors.push(`${name}: ${install.error}`);
+ continue;
+ }
+ 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(projectPinchDir(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 cr = readConfig(cwd);
+ if (!cr) return;
+
+ const skillPaths = resolveSkillPaths(cr.config, cwd);
+ if (skillPaths.length > 0) {
+ return { skillPaths };
+ }
+ });
+
+ pi.on("session_start", async (_event, ctx) => {
+ 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]) => {
+ return n + source.plugins.filter((p) => fs.existsSync(installedPluginDir(cwd, name, 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) => {
+ 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:status", {
+ description: "Show installed plugins and their skills",
+ handler: async (_args, ctx) => {
+ 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(cr.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 plugin of source.plugins) {
+ 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} (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/keybindings.json b/ai/pi/keybindings.json
new file mode 100644
index 00000000..0ddd327b
--- /dev/null
+++ b/ai/pi/keybindings.json
@@ -0,0 +1,7 @@
+{
+ "cursorUp": ["up", "ctrl+p"],
+ "cursorDown": ["down", "ctrl+n"],
+ "selectUp": ["up", "ctrl+p", "ctrl+k"],
+ "selectDown": ["down", "ctrl+n", "ctrl+j"],
+ "cycleModelForward": []
+}
diff --git a/ai/pi/pinch.json b/ai/pi/pinch.json
new file mode 100644
index 00000000..2ac9f3f6
--- /dev/null
+++ b/ai/pi/pinch.json
@@ -0,0 +1,12 @@
+{
+ "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"]
+ }
+}
diff --git a/ai/pi/settings.json b/ai/pi/settings.json
new file mode 100644
index 00000000..0631e22f
--- /dev/null
+++ b/ai/pi/settings.json
@@ -0,0 +1,10 @@
+{
+ "lastChangelogVersion": "0.58.1",
+ "defaultProvider": "anthropic",
+ "defaultModel": "claude-opus-4-6",
+ "skills": [
+ "~/.dotfiles/ai/skills"
+ ],
+ "enableSkillCommands": true,
+ "defaultThinkingLevel": "high"
+}
\ No newline at end of file
diff --git a/ai/pi/skills/pinch/SKILL.md b/ai/pi/skills/pinch/SKILL.md
new file mode 100644
index 00000000..4452d4c5
--- /dev/null
+++ b/ai/pi/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/frork/_dotfiles.fnl b/frork/_dotfiles.fnl
index d527d959..4b290935 100644
--- a/frork/_dotfiles.fnl
+++ b/frork/_dotfiles.fnl
@@ -28,6 +28,9 @@
(ok :symlink "~/.dotfiles/ai/claude" "~/.claude")
(ok :symlink "~/.dotfiles/ai/AI.md" "~/.claude/CLAUDE.md")
+(ok :directory "~/.pi")
+(ok :symlink "~/.dotfiles/ai/pi" "~/.pi/agent")
+
;; moved to .config/git/config
(no :symlink :$HOME/.gitconfig :$HOME/.dotfiles/.gitconfig)