🚀
This commit is contained in:
265
extensions/cross-agent.ts
Normal file
265
extensions/cross-agent.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user