Files
calvana/extensions/cross-agent.ts

266 lines
8.0 KiB
TypeScript

/**
* 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<string, string> } {
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<string, string> = {};
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<string>();
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);
});
}