Update pi extensions and settings
Bridge Claude Code plugin skills into pi via new claude-plugins
extension. Add session-scoped write/edit auto-allow for tracked
files in permission-gate. Enable high thinking level.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/.pi/agent/extensions/claude-plugins.ts b/.pi/agent/extensions/claude-plugins.ts
new file mode 100644
index 00000000..11d475ba
--- /dev/null
+++ b/.pi/agent/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/.pi/agent/extensions/permission-gate.ts b/.pi/agent/extensions/permission-gate.ts
index 261e6d3e..ecfe279a 100644
--- a/.pi/agent/extensions/permission-gate.ts
+++ b/.pi/agent/extensions/permission-gate.ts
@@ -6,6 +6,11 @@
* 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).
@@ -150,6 +155,9 @@ let allowedCommands: CommandRule = BASE_COMMANDS;
*/
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
// ---------------------------------------------------------------------------
@@ -261,6 +269,16 @@ function isCommandAllowed(cmd: string): boolean {
// 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 ?? ""));
@@ -279,6 +297,12 @@ function isAllowed(toolName: string, input: Record<string, unknown>, ctx: Extens
);
}
+ 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;
@@ -297,6 +321,7 @@ export default function(pi: ExtensionAPI) {
// Reset state each session
allowedCommands = BASE_COMMANDS;
pendingProjectRules = null;
+ sessionAllowEdits = false;
const permPath = path.join(ctx.cwd, ".pi/permissions.json");
let raw: string;
@@ -360,6 +385,24 @@ export default function(pi: ExtensionAPI) {
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) {
diff --git a/.pi/agent/settings.json b/.pi/agent/settings.json
index 36e43037..02581e30 100644
--- a/.pi/agent/settings.json
+++ b/.pi/agent/settings.json
@@ -1,10 +1,10 @@
{
- "lastChangelogVersion": "0.52.6",
+ "lastChangelogVersion": "0.54.0",
"defaultProvider": "anthropic",
"defaultModel": "claude-opus-4-6",
"skills": [
"~/.claude/skills"
],
"enableSkillCommands": true,
- "defaultThinkingLevel": "off"
+ "defaultThinkingLevel": "high"
}
\ No newline at end of file