- Static site: /manifesto, /live, /hire pages - Ship-log Pi extension: calvana_ship, calvana_oops, calvana_deploy tools - Docker + nginx deploy to calvana.quikcue.com - Terminal-ish dark aesthetic, mobile responsive - Auto-updating /live page from extension state
266 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
}
|