// claude-compat -- Claude CLI compatibility layer for pi. // // Discovers: // .claude/commands/**/*.md -> registered as pi slash commands // .claude/commands/test.md -> /test // .claude/commands/xyz/test1.md -> /xyz:test1 // .claude/skills/*/SKILL.md -> registered as pi skills via resources_discover // .claude/skills/my-skill/ -> /skill:my-skill // // Commands are re-discovered on session start, switch, fork, and tree // navigation so they stay current when changing projects. // // Features: // - Automatic discovery of Claude custom commands and skills // - $ARGUMENTS / ${ARGUMENTS} / {{ARGUMENTS}} placeholder replacement // - YAML frontmatter support for descriptions // - Collision detection with other extensions' commands // - /claude-commands command to list all loaded commands // - /claude-unload command to temporarily unload all commands and skills // - /claude-load command to re-load commands and skills after an unload // - Project-local persistence of the loaded/unloaded toggle under .pi/ // - Widget showing active command and skill count // - System prompt injection listing available commands and skills import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { loadCommandContent } from "./command-content.js"; import { registerClaudeCommandsList, registerClaudeLoad, registerClaudeUnload, type CommandHandlerDeps, } from "./commands.js"; import { STATE_ENTRY_TYPE } from "./constants.js"; import { discoverCommands, discoverSkills } from "./discovery.js"; import { readProjectConfig } from "./project-config.js"; import { buildSystemPromptSections } from "./system-prompt.js"; import type { ClaudeCommand, ClaudeSkill, PersistedState } from "./types.js"; import { updateWidget } from "./widget.js"; export default function claudeEnvLoaderExtension(pi: ExtensionAPI) { const registeredCommands = new Set(); // Commands skipped due to name collisions with other extensions. // Key: command name, Value: source that owns it. const collisions = new Map(); let currentCwd = ""; let currentCommands: ClaudeCommand[] = []; let currentSkills: ClaudeSkill[] = []; // When false, commands and skills are unloaded: no system prompt // injection, no skills returned via resources_discover, and command // handlers return early with an "unloaded" notice. let loaded = true; // ----------------------------------------------------------------------- // Core: discover & register commands and skills // ----------------------------------------------------------------------- function syncResources(ctx: ExtensionContext): void { currentCwd = ctx.cwd; if (loaded) { currentCommands = discoverCommands(ctx.cwd); currentSkills = discoverSkills(ctx.cwd); } else { // When unloaded, clear discovered resources but keep the // registeredCommands set intact so we don't try to re-register // existing commands when the extension is later re-loaded. currentCommands = []; currentSkills = []; } collisions.clear(); const existingCommands = new Map(); for (const cmd of pi.getCommands()) { existingCommands.set(cmd.name, cmd.sourceInfo?.source ?? "unknown"); } for (const cmd of currentCommands) { if (registeredCommands.has(cmd.name)) { // Already registered by us in a previous session/cwd -- no collision continue; } const existing = existingCommands.get(cmd.name); if (existing !== undefined) { collisions.set(cmd.name, existing); continue; } registerDiscoveredCommand(cmd); } updateWidget(ctx, { loaded, commands: currentCommands, skills: currentSkills, collisions }); } function registerDiscoveredCommand(cmd: ClaudeCommand): void { pi.registerCommand(cmd.name, { description: cmd.description, handler: async (args, ctx) => { if (!loaded) { ctx.ui.notify( `claude-compat is currently unloaded. Use /claude-load to restore commands and skills.`, "warning", ); return; } const content = loadCommandContent(ctx.cwd, cmd.name, args); if (content === null) { ctx.ui.notify( `Command file not found: .claude/commands/${cmd.relativePath}\n` + `This command may belong to a different project. Run /claude-commands to see available commands.`, "error", ); return; } if (!content.trim()) { ctx.ui.notify( `Command file is empty: .claude/commands/${cmd.relativePath}`, "warning", ); return; } pi.sendUserMessage(content); }, }); registeredCommands.add(cmd.name); } // ----------------------------------------------------------------------- // State persistence // ----------------------------------------------------------------------- function reconstructState(ctx: ExtensionContext): void { currentCwd = ctx.cwd; currentCommands = []; currentSkills = []; // Default to loaded; only flip if persisted state explicitly says otherwise. loaded = true; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "custom") continue; if (entry.customType === STATE_ENTRY_TYPE) { const data = entry.data as PersistedState | undefined; if (data?.commands) currentCommands = data.commands; if (data?.skills) currentSkills = data.skills; // The most recent state entry wins for the loaded flag. if (data?.loaded !== undefined) loaded = data.loaded; } } // Project-local config (if present) is the source of truth for the // loaded flag and overrides whatever the session replay produced. This // lets the unload/load decision survive across sessions and machines. const projectConfig = readProjectConfig(ctx.cwd); if (projectConfig?.loaded !== undefined) { loaded = projectConfig.loaded; } syncResources(ctx); persistState(); } function persistState(): void { pi.appendEntry(STATE_ENTRY_TYPE, { commands: currentCommands, skills: currentSkills, cwd: currentCwd, loaded, }); } // ----------------------------------------------------------------------- // Resources discovery // ----------------------------------------------------------------------- pi.on("resources_discover", (event) => { if (!loaded) { currentCwd = event.cwd; currentCommands = []; currentSkills = []; collisions.clear(); return undefined; } const commands = discoverCommands(event.cwd); const skills = discoverSkills(event.cwd); const skillPaths = skills.map(s => s.skillMdPath); currentCwd = event.cwd; currentCommands = commands; currentSkills = skills; collisions.clear(); const existingCommands = new Map(); for (const cmd of pi.getCommands()) { existingCommands.set(cmd.name, cmd.sourceInfo?.source ?? "unknown"); } for (const cmd of commands) { if (registeredCommands.has(cmd.name)) continue; const existing = existingCommands.get(cmd.name); if (existing !== undefined) { collisions.set(cmd.name, existing); continue; } registerDiscoveredCommand(cmd); } if (skillPaths.length > 0) { return { skillPaths }; } return undefined; }); // ----------------------------------------------------------------------- // Session events // ----------------------------------------------------------------------- pi.on("session_start", async (_e, ctx) => { reconstructState(ctx); }); pi.on("session_switch", async (_e, ctx) => { reconstructState(ctx); }); pi.on("session_fork", async (_e, ctx) => { reconstructState(ctx); }); pi.on("session_tree", async (_e, ctx) => { reconstructState(ctx); }); // ----------------------------------------------------------------------- // System prompt injection // ----------------------------------------------------------------------- pi.on("before_agent_start", (event) => { if (!loaded) return; const appended = buildSystemPromptSections(currentCommands, currentSkills, collisions); if (!appended) return; return { systemPrompt: event.systemPrompt + "\n\n" + appended, }; }); // ----------------------------------------------------------------------- // Slash commands -- /claude-unload, /claude-load, /claude-commands // ----------------------------------------------------------------------- const handlerDeps: CommandHandlerDeps = { isLoaded: () => loaded, getCommands: () => currentCommands, getSkills: () => currentSkills, getCollisions: () => collisions, unload: (ctx) => { currentCommands = []; currentSkills = []; collisions.clear(); loaded = false; updateWidget(ctx, { loaded, commands: currentCommands, skills: currentSkills, collisions }); persistState(); }, load: (ctx) => { loaded = true; syncResources(ctx); persistState(); }, }; registerClaudeUnload(pi, handlerDeps); registerClaudeLoad(pi, handlerDeps); registerClaudeCommandsList(pi, handlerDeps); }