ai: Rename ai/AI.md to ai/_AGENTS.md, remove old .pi/agent/ tracked files
Underscore prefix distinguishes the user-level agent prompt from
project-level AGENTS.md files that tools auto-detect by convention.
.pi/agent/ tracked files removed — user config now lives at ai/pi/
(committed in previous change). .pi/.gitignore simplified to ignore
all of agent/ since it resolves through the symlink to ai/pi/.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/.pi/.gitignore b/.pi/.gitignore
index 2f6af6d4..3ba62490 100644
--- a/.pi/.gitignore
+++ b/.pi/.gitignore
@@ -1,6 +1 @@
-agent/*
-!agent/extensions/
-!agent/keybindings.json
-!agent/pinch.json
-!agent/settings.json
-!agent/skills/
+agent/
diff --git a/.pi/agent/extensions/README.md b/.pi/agent/extensions/README.md
deleted file mode 100644
index e5f6dd7f..00000000
--- a/.pi/agent/extensions/README.md
+++ /dev/null
@@ -1,109 +0,0 @@
-# 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/.pi/agent/extensions/claude-plugins.ts b/.pi/agent/extensions/claude-plugins.ts
deleted file mode 100644
index 11d475ba..00000000
--- a/.pi/agent/extensions/claude-plugins.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-/**
- * 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/.pi/agent/extensions/fork-tmux.ts b/.pi/agent/extensions/fork-tmux.ts
deleted file mode 100644
index 1fe42c90..00000000
--- a/.pi/agent/extensions/fork-tmux.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * 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/.pi/agent/extensions/jj-status.ts b/.pi/agent/extensions/jj-status.ts
deleted file mode 100644
index c8c49a9d..00000000
--- a/.pi/agent/extensions/jj-status.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-/**
- * 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/.pi/agent/extensions/model-identity.ts b/.pi/agent/extensions/model-identity.ts
deleted file mode 100644
index 8dbfd9a0..00000000
--- a/.pi/agent/extensions/model-identity.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * 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/.pi/agent/extensions/permission-gate.ts b/.pi/agent/extensions/permission-gate.ts
deleted file mode 100644
index ecfe279a..00000000
--- a/.pi/agent/extensions/permission-gate.ts
+++ /dev/null
@@ -1,439 +0,0 @@
-/**
- * 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/.pi/agent/extensions/pinch.ts b/.pi/agent/extensions/pinch.ts
deleted file mode 100644
index e7468c60..00000000
--- a/.pi/agent/extensions/pinch.ts
+++ /dev/null
@@ -1,708 +0,0 @@
-/**
- * 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/.pi/agent/keybindings.json b/.pi/agent/keybindings.json
deleted file mode 100644
index 0ddd327b..00000000
--- a/.pi/agent/keybindings.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "cursorUp": ["up", "ctrl+p"],
- "cursorDown": ["down", "ctrl+n"],
- "selectUp": ["up", "ctrl+p", "ctrl+k"],
- "selectDown": ["down", "ctrl+n", "ctrl+j"],
- "cycleModelForward": []
-}
diff --git a/.pi/agent/pinch.json b/.pi/agent/pinch.json
deleted file mode 100644
index 2ac9f3f6..00000000
--- a/.pi/agent/pinch.json
+++ /dev/null
@@ -1,12 +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"]
- }
-}
diff --git a/.pi/agent/settings.json b/.pi/agent/settings.json
deleted file mode 100644
index 83532683..00000000
--- a/.pi/agent/settings.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "lastChangelogVersion": "0.58.1",
- "defaultProvider": "anthropic",
- "defaultModel": "claude-opus-4-6",
- "skills": [
- "~/.claude/skills"
- ],
- "enableSkillCommands": true,
- "defaultThinkingLevel": "high"
-}
diff --git a/.pi/agent/skills/pinch/SKILL.md b/.pi/agent/skills/pinch/SKILL.md
deleted file mode 100644
index 4452d4c5..00000000
--- a/.pi/agent/skills/pinch/SKILL.md
+++ /dev/null
@@ -1,137 +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 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/ai/AI.md b/ai/_AGENTS.md
similarity index 100%
rename from ai/AI.md
rename to ai/_AGENTS.md
diff --git a/frork/_dotfiles.fnl b/frork/_dotfiles.fnl
index 4b290935..a35fcacb 100644
--- a/frork/_dotfiles.fnl
+++ b/frork/_dotfiles.fnl
@@ -26,7 +26,7 @@
(ok :symlink dest src))))
(ok :symlink "~/.dotfiles/ai/claude" "~/.claude")
-(ok :symlink "~/.dotfiles/ai/AI.md" "~/.claude/CLAUDE.md")
+(ok :symlink "~/.dotfiles/ai/_AGENTS.md" "~/.claude/CLAUDE.md")
(ok :directory "~/.pi")
(ok :symlink "~/.dotfiles/ai/pi" "~/.pi/agent")