/** * Cross-Agent — Load commands, skills, and agents from other AI coding agents * * Scans .claude/, .gemini/, .codex/ directories (project + global) for: * commands/*.md → registered as /name * skills/ → listed as /skill:name (discovery only) * agents/*.md → listed as @name (discovery only) * * Usage: pi -e extensions/cross-agent.ts */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; import { join, basename } from "node:path"; import { homedir } from "node:os"; import { applyExtensionDefaults } from "./themeMap.ts"; import { wrapTextWithAnsi, visibleWidth } from "@mariozechner/pi-tui"; // --- Synthwave palette --- function bg(s: string): string { return `\x1b[48;2;52;20;58m${s}\x1b[49m`; } function pink(s: string): string { return `\x1b[38;2;255;126;219m${s}\x1b[39m`; } function cyan(s: string): string { return `\x1b[38;2;54;249;246m${s}\x1b[39m`; } function green(s: string): string { return `\x1b[38;2;114;241;184m${s}\x1b[39m`; } function yellow(s: string): string { return `\x1b[38;2;254;222;93m${s}\x1b[39m`; } function dim(s: string): string { return `\x1b[38;2;120;100;140m${s}\x1b[39m`; } function bold(s: string): string { return `\x1b[1m${s}\x1b[22m`; } interface Discovered { name: string; description: string; content: string; } interface SourceGroup { source: string; commands: Discovered[]; skills: string[]; agents: Discovered[]; } function parseFrontmatter(raw: string): { description: string; body: string; fields: Record } { const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); if (!match) return { description: "", body: raw, fields: {} }; const front = match[1]; const body = match[2]; const fields: Record = {}; for (const line of front.split("\n")) { const idx = line.indexOf(":"); if (idx > 0) fields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); } return { description: fields.description || "", body, fields }; } function expandArgs(template: string, args: string): string { const parts = args.split(/\s+/).filter(Boolean); let result = template; result = result.replace(/\$ARGUMENTS|\$@/g, args); for (let i = 0; i < parts.length; i++) { result = result.replaceAll(`$${i + 1}`, parts[i]); } return result; } function scanCommands(dir: string): Discovered[] { if (!existsSync(dir)) return []; const items: Discovered[] = []; try { for (const file of readdirSync(dir)) { if (!file.endsWith(".md")) continue; const raw = readFileSync(join(dir, file), "utf-8"); const { description, body } = parseFrontmatter(raw); items.push({ name: basename(file, ".md"), description: description || body.split("\n").find((l) => l.trim())?.trim() || "", content: body, }); } } catch {} return items; } function scanSkills(dir: string): string[] { if (!existsSync(dir)) return []; const names: string[] = []; try { for (const entry of readdirSync(dir)) { const skillFile = join(dir, entry, "SKILL.md"); const flatFile = join(dir, entry); if (existsSync(skillFile) && statSync(skillFile).isFile()) { names.push(entry); } else if (entry.endsWith(".md") && statSync(flatFile).isFile()) { names.push(basename(entry, ".md")); } } } catch {} return names; } function scanAgents(dir: string): Discovered[] { if (!existsSync(dir)) return []; const items: Discovered[] = []; try { for (const file of readdirSync(dir)) { if (!file.endsWith(".md")) continue; const raw = readFileSync(join(dir, file), "utf-8"); const { fields } = parseFrontmatter(raw); items.push({ name: fields.name || basename(file, ".md"), description: fields.description || "", content: raw, }); } } catch {} return items; } export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { applyExtensionDefaults(import.meta.url, ctx); const home = homedir(); const cwd = ctx.cwd; const providers = ["claude", "gemini", "codex"]; const groups: SourceGroup[] = []; for (const p of providers) { for (const [dir, label] of [ [join(cwd, `.${p}`), `.${p}`], [join(home, `.${p}`), `~/.${p}`], ] as const) { const commands = scanCommands(join(dir, "commands")); const skills = scanSkills(join(dir, "skills")); const agents = scanAgents(join(dir, "agents")); if (commands.length || skills.length || agents.length) { groups.push({ source: label, commands, skills, agents }); } } } // Also scan .pi/agents/ (pi-vs-cc pattern) const localAgents = scanAgents(join(cwd, ".pi", "agents")); if (localAgents.length) { groups.push({ source: ".pi/agents", commands: [], skills: [], agents: localAgents }); } // Register commands const seenCmds = new Set(); let totalCommands = 0; let totalSkills = 0; let totalAgents = 0; for (const g of groups) { totalSkills += g.skills.length; totalAgents += g.agents.length; for (const cmd of g.commands) { if (seenCmds.has(cmd.name)) continue; seenCmds.add(cmd.name); totalCommands++; pi.registerCommand(cmd.name, { description: `[${g.source}] ${cmd.description}`.slice(0, 120), handler: async (args) => { pi.sendUserMessage(expandArgs(cmd.content, args || "")); }, }); } } if (groups.length === 0) return; // We delay slightly so it doesn't get instantly overwritten by system-select's default startup notify setTimeout(() => { if (!ctx.hasUI) return; // Reduce max width slightly to ensure it never overflows and breaks the next line const width = Math.min((process.stdout.columns || 80) - 4, 100); const pad = bg(" ".repeat(width)); const lines: string[] = []; lines.push(""); // space from prev for (let i = 0; i < groups.length; i++) { const g = groups[i]; // Title with counts const counts: string[] = []; if (g.skills.length) counts.push(yellow("(") + green(`${g.skills.length}`) + dim(` skill${g.skills.length > 1 ? "s" : ""}`) + yellow(")")); if (g.commands.length) counts.push(yellow("(") + green(`${g.commands.length}`) + dim(` command${g.commands.length > 1 ? "s" : ""}`) + yellow(")")); if (g.agents.length) counts.push(yellow("(") + green(`${g.agents.length}`) + dim(` agent${g.agents.length > 1 ? "s" : ""}`) + yellow(")")); const countStr = counts.length ? " " + counts.join(" ") : ""; lines.push(pink(bold(` ${g.source}`)) + countStr); // Build body content const items: string[] = []; if (g.commands.length) { items.push( yellow("/") + g.commands.map((c) => cyan(c.name)).join(yellow(", /")) ); } if (g.skills.length) { items.push( yellow("/skill:") + g.skills.map((s) => cyan(s)).join(yellow(", /skill:")) ); } if (g.agents.length) { items.push( yellow("@") + g.agents.map((a) => green(a.name)).join(yellow(", @")) ); } const body = items.join("\n"); // Top padding lines.push(pad); // Wrap body text, cap at 3 rows const maxRows = 3; const innerWidth = width - 4; const wrapped = wrapTextWithAnsi(body, innerWidth); const totalItems = g.commands.length + g.skills.length + g.agents.length; const shown = wrapped.slice(0, maxRows); for (const wline of shown) { const vis = visibleWidth(wline); const fill = Math.max(0, width - vis - 4); lines.push(bg(" " + wline + " ".repeat(fill) + " ")); } if (wrapped.length > maxRows) { const overflow = dim(` ... ${totalItems - 15 > 0 ? totalItems - 15 : "more"} more`); const oVis = visibleWidth(overflow); const oFill = Math.max(0, width - oVis - 2); lines.push(bg(overflow + " ".repeat(oFill) + " ")); } // Bottom padding lines.push(pad); // Spacing between groups if (i < groups.length - 1) lines.push(""); } // We send it as "info" which forces it to be a raw text element in the chat // without the widget container, but preserving all our ANSI colors! ctx.ui.notify(lines.join("\n"), "info"); }, 100); }); }