import * as path from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { COMMANDS_SUBDIR } from "./constants.js"; import { discoverCommands, discoverSkills } from "./discovery.js"; import { dirExists } from "./fs-utils.js"; import { writeProjectConfig } from "./project-config.js"; /** * Shared callbacks the extension wires into command handlers. Keeping these * here (rather than capturing extension-private state) makes commands.ts * independent of the closure in index.ts. */ export interface CommandHandlerDeps { /** True when commands and skills are currently active. */ isLoaded: () => boolean; /** Snapshot of currently loaded commands (used for /claude-unload counts). */ getCommands: () => readonly { name: string }[]; /** Snapshot of currently loaded skills (used for /claude-unload counts). */ getSkills: () => readonly { name: string }[]; /** Snapshot of currently detected collisions (used by /claude-commands). */ getCollisions: () => Map; /** Transition to unloaded -- clears in-memory state and updates the widget. */ unload: (ctx: ExtensionContext) => void; /** Transition to loaded -- re-runs discovery and updates the widget. */ load: (ctx: ExtensionContext) => void; } export function registerClaudeUnload(pi: ExtensionAPI, deps: CommandHandlerDeps): void { pi.registerCommand("claude-unload", { description: "Unload all Claude CLI commands and skills from the current session", handler: async (_args, ctx) => { if (!deps.isLoaded()) { ctx.ui.notify( "claude-compat is already unloaded. Use /claude-load to restore commands and skills.", "info", ); return; } const cmdCount = deps.getCommands().length; const skillCount = deps.getSkills().length; deps.unload(ctx); writeProjectConfig(ctx.cwd, { loaded: false }); const parts: string[] = ["claude-compat unloaded."]; if (cmdCount > 0) parts.push(`${cmdCount} command${cmdCount === 1 ? "" : "s"} disabled.`); if (skillCount > 0) parts.push(`${skillCount} skill${skillCount === 1 ? "" : "s"} disabled.`); parts.push("Use /claude-load to restore."); ctx.ui.notify(parts.join(" "), "info"); }, }); } export function registerClaudeLoad(pi: ExtensionAPI, deps: CommandHandlerDeps): void { pi.registerCommand("claude-load", { description: "Re-load Claude CLI commands and skills after an unload", handler: async (_args, ctx) => { if (deps.isLoaded()) { ctx.ui.notify( "claude-compat is already loaded. Use /claude-unload first if you want to toggle.", "info", ); return; } deps.load(ctx); writeProjectConfig(ctx.cwd, { loaded: true }); const commands = deps.getCommands(); const skills = deps.getSkills(); const parts: string[] = ["claude-compat re-loaded."]; if (commands.length > 0) { parts.push(`${commands.length} command${commands.length === 1 ? "" : "s"} active.`); } if (skills.length > 0) { parts.push(`${skills.length} skill${skills.length === 1 ? "" : "s"} active.`); } if (commands.length === 0 && skills.length === 0) { parts.push("No commands or skills found in the current project."); } ctx.ui.notify(parts.join(" "), "info"); }, }); } export function registerClaudeCommandsList(pi: ExtensionAPI, deps: CommandHandlerDeps): void { pi.registerCommand("claude-commands", { description: "List all loaded Claude CLI custom slash commands and skills", handler: async (_args, ctx) => { const loaded = deps.isLoaded(); const collisions = deps.getCollisions(); const lines: string[] = []; if (!loaded) { lines.push("Status: UNLOADED — commands and skills are currently disabled."); lines.push("Use /claude-load to restore.\n"); } const commands = discoverCommands(ctx.cwd); const skills = discoverSkills(ctx.cwd); if (commands.length === 0 && skills.length === 0 && collisions.size === 0) { const hasClaudeDir = dirExists(path.join(ctx.cwd, ".claude")); if (hasClaudeDir) { ctx.ui.notify( "No commands or skills found in .claude/commands/ or .claude/skills/.\n" + "Create .md files in commands/ or skill dirs with SKILL.md to add them.", "info", ); } else { ctx.ui.notify( "No .claude directory found.\n" + "Create it with: mkdir -p .claude/commands .claude/skills\n" + "Add .md files as commands or skill dirs with SKILL.md.", "info", ); } return; } if (commands.length > 0) { lines.push(`Commands (${commands.length}):\n`); for (const cmd of commands) { const collision = collisions.get(cmd.name); const collisionNote = collision ? ` [CONFLICT with ${collision}]` : ""; const unloadedNote = !loaded ? " [UNLOADED]" : ""; lines.push(` /${cmd.name}${collisionNote}${unloadedNote}`); lines.push(` ${path.join(COMMANDS_SUBDIR, cmd.relativePath)}`); if (cmd.description) { lines.push(` ${cmd.description}`); } lines.push(""); } lines.push("Usage: / [arguments]"); lines.push("$ARGUMENTS in command files is replaced by the provided arguments.\n"); } if (collisions.size > 0) { lines.push(`Collisions (${collisions.size}):\n`); for (const [name, source] of collisions) { lines.push(` /${name} - already registered by ${source}`); } lines.push("\nRename the .md file or move it to a subdirectory to avoid conflicts."); lines.push('e.g., rename "test.md" to "mytest.md" to get /mytest instead of /test.\n'); } if (skills.length > 0) { lines.push(`Skills (${skills.length}):\n`); for (const skill of skills) { const unloadedNote = !loaded ? " [UNLOADED]" : ""; lines.push(` /skill:${skill.name}${unloadedNote}`); lines.push(` ${skill.skillMdPath}`); if (skill.description) { lines.push(` ${skill.description}`); } lines.push(""); } lines.push("Skills are invoked with /skill: or used automatically by the agent."); } ctx.ui.notify(lines.join("\n"), "info"); }, }); }