feat(pi): add native pi integration — extension, config pi, skills --agent pi

- src/pi-ext/: self-contained pi extension (mcpctl-pi.ts + vendored
  mcp-http client) that talks JSON-RPC directly to mcplocal and registers
  project MCP tools as native pi tools. No MCP client, no ~/.claude.
- Persistent per-project session so gated projects ungate on begin_session.
- /mcpctl command: status, switch project (GUI), refresh tools, sync skills.
- mcpctl config pi: installs extension, wires pi settings, persists active
  project, syncs skills into ~/.pi/agent/skills.
- skills sync: add --agent pi (target install root).
- docs + tests.
This commit is contained in:
Michal
2026-08-08 16:23:02 +01:00
parent 2c8419eddb
commit 28f1a411fd
9 changed files with 1222 additions and 3 deletions

413
src/pi-ext/mcpctl-pi.ts Normal file
View File

@@ -0,0 +1,413 @@
/**
* mcpctl pi extension — native integration (no MCP client, no Claude).
*
* Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers
* each project tool as a native pi tool.
*
* KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a
* `mcp-session-id`. A gated project only exposes `begin_session` until it is
* called, after which the full tool set becomes callable — but only within
* THAT session. So we keep ONE persistent `McpHttpSession` per active project
* and route every tools/call through it, re-listing after each call to pick up
* the post-begin_session tool additions.
*
* Features:
* - project gating auto-ungates when begin_session is called
* - switching the active project from the pi TUI (/mcpctl)
* - a status line
*
* Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts
* or via settings: "extensions": ["/abs/path/to/mcpctl-pi.ts"]
*
* Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.
*/
import { Type, type TSchema } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import {
McpHttpSession,
type ToolInfo,
type McpHttpOptions,
} from "./mcp-http.js";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
// ── config / state ───────────────────────────────────────────────────────────
const McpctlDir = join(homedir(), ".mcpctl");
const ConfigPath = join(McpctlDir, "config.json");
const CredPath = join(McpctlDir, "credentials");
const PiStatePath = join(McpctlDir, "pi-state.json");
const ProjectMarker = ".mcpctl-project";
interface PiState {
project?: string;
}
async function readJson<T>(path: string): Promise<T | null> {
try {
return JSON.parse(await readFile(path, "utf-8")) as T;
} catch {
return null;
}
}
async function readConfig(): Promise<{ mcplocalUrl: string }> {
const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);
return { mcplocalUrl: cfg?.mcplocalUrl ?? "http://localhost:3200" };
}
async function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {
try {
const raw = await readFile(CredPath, "utf-8");
const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };
if (!parsed.token) return null;
return parsed;
} catch {
return null;
}
}
async function readPiState(): Promise<PiState> {
return (await readJson<PiState>(PiStatePath)) ?? {};
}
async function writePiState(state: PiState): Promise<void> {
await mkdir(McpctlDir, { recursive: true });
await writeFile(PiStatePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
}
/** Walk up directories looking for a `.mcpctl-project` marker. */
async function findProjectMarker(start = process.cwd()): Promise<string | null> {
const home = homedir();
let dir = start;
for (let i = 0; i < 50; i++) {
try {
const raw = await readFile(join(dir, ProjectMarker), "utf-8");
const project = raw.split("\n")[0]?.trim() ?? "";
if (project) return project;
} catch {
// continue walking
}
if (dir === home || dir === "/") break;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
/** Request the list of projects from mcpd (via mcplocal API passthrough). */
async function listProjects(mcplocalUrl: string, token?: string): Promise<string[]> {
const url = `${mcplocalUrl}/api/v1/projects`;
const headers: Record<string, string> = { Accept: "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);
const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };
const arr = Array.isArray(body) ? body : (body.data ?? []);
return arr.map((p) => p.name);
}
// ── JSON Schema → TypeBox ────────────────────────────────────────────────────
function convertSchema(inputSchema: unknown): TSchema {
if (!inputSchema || typeof inputSchema !== "object") {
return Type.Object({});
}
const s = inputSchema as {
type?: string;
properties?: Record<string, unknown>;
required?: string[];
items?: unknown;
enum?: unknown[];
};
const required = new Set(Array.isArray(s.required) ? s.required : []);
const props = s.properties ?? {};
const fields: Record<string, TSchema> = {};
for (const [name, raw] of Object.entries(props)) {
const schema = convertProp(raw);
fields[name] = required.has(name) ? schema : Type.Optional(schema);
}
return Type.Object(fields);
}
function convertProp(raw: unknown): TSchema {
if (!raw || typeof raw !== "object") return Type.Unknown();
const s = raw as {
type?: string | string[];
enum?: unknown[];
description?: string;
items?: unknown;
properties?: Record<string, unknown>;
};
const desc = typeof s.description === "string" ? s.description : undefined;
const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;
if (enumVals && enumVals.every((v) => typeof v === "string")) {
return StringEnum(enumVals as string[]);
}
if (enumVals && enumVals.every((v) => typeof v === "number")) {
const literals = enumVals.map((v) => Type.Literal(v));
return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);
}
let type = s.type;
if (Array.isArray(type)) type = type.find((t) => t !== "null");
type = type ?? (s.properties ? "object" : s.items ? "array" : "string");
if (type === "integer") type = "number";
switch (type) {
case "string":
return desc ? Type.String({ description: desc }) : Type.String();
case "number":
return desc ? Type.Number({ description: desc }) : Type.Number();
case "boolean":
return desc ? Type.Boolean({ description: desc }) : Type.Boolean();
case "array": {
const inner = s.items ? convertProp(s.items) : Type.Unknown();
return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);
}
case "object":
return convertSchema(s as unknown);
default:
return desc ? Type.Unknown({ description: desc }) : Type.Unknown();
}
}
/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */
function safeSegment(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "x";
}
function slugify(name: string): string {
return safeSegment(name);
}
/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */
function piToolName(project: string, mcpTool: string): string {
const proj = slugify(project);
const tool = mcpTool.replace(/^[a-z0-9_-]+\//i, (m) => slugify(m.slice(0, -1)) + "_") || mcpTool;
return `mc_${proj}_${safeSegment(tool)}`;
}
// ── the extension ────────────────────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
let mcplocalUrl = "http://localhost:3200";
let token: string | undefined;
let activeProject: string | null = null;
/** Persistent session for the active project — MUST be reused to keep gate state. */
let session: McpHttpSession | null = null;
/** Pi tool names currently registered for the active project. */
const projectToolNames = new Set<string>();
let activeCtx: ExtensionContext | null = null;
function endpointUrl(): string {
return `${mcplocalUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(activeProject ?? "")}/mcp`;
}
/** Build (or reuse) the persistent session for the active project. */
async function ensureSession(): Promise<McpHttpSession> {
if (session) return session;
const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };
const sess = new McpHttpSession(endpointUrl(), opts);
await sess.initialize();
await sess.sendNotification("notifications/initialized");
session = sess;
return sess;
}
async function closeSession(): Promise<void> {
const s = session;
session = null;
if (s) await s.close();
}
/**
* (Re)discover the active project's current tool set and register any new pi
* tools. Called on load and after every tool call (to pick up ungating).
*/
async function reconcileTools(project: string): Promise<{ tools: string[] }> {
const sess = await ensureSession();
const { tools } = await sess.listTools();
const names: string[] = [];
for (const t of tools) {
const piName = piToolName(project, t.name);
if (!projectToolNames.has(piName)) {
pi.registerTool(createTool(piName, t, project));
projectToolNames.add(piName);
}
names.push(piName);
}
// Activate built-ins/other extensions + all tools discovered in this project.
const active = pi.getActiveTools().filter((n) => !n.startsWith("mc_"));
pi.setActiveTools([...new Set([...active, ...names])]);
return { tools: names };
}
function createTool(piName: string, t: ToolInfo, project: string) {
const mcpName = t.name;
return {
name: piName,
label: `${project} :: ${mcpName}`,
description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,
parameters: convertSchema(t.inputSchema),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const sess = await ensureSession();
const res = await sess.callTool(mcpName, params ?? {});
// Re-list after every call — picks up post-begin_session tool additions.
try {
await reconcileTools(project);
} catch {
// non-fatal
}
const text = (res.content ?? [])
.filter((c) => c.type === "text")
.map((c) => c.text ?? "")
.join("\n");
if (res.isError) {
throw new Error(text || `${mcpName} returned an error`);
}
return {
content: [{ type: "text", text: text || "(no text content)" }],
details: { mcpTool: mcpName, project, raw: res },
};
},
};
}
function setStatus(msg?: string): void {
try {
activeCtx?.ui?.setStatus?.("mcpctl", msg ?? (activeProject ? `project: ${activeProject}` : "no project"));
} catch {
/* non-fatal */
}
}
// ── /mcpctl command ──
pi.registerCommand("mcpctl", {
description: "mcpctl integration: status, switch project, refresh tools",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
return `mcpctl: active project = ${activeProject ?? "none"}; mcplocal = ${mcplocalUrl}`;
}
const choice = await ctx.ui.select("mcpctl", [
{ value: "status", label: "Status" },
{ value: "switch", label: "Switch project" },
{ value: "refresh", label: "Refresh tools" },
{ value: "sync skills", label: "Sync skills (mcpctl skills sync)" },
{ value: "close", label: "Close" },
]);
if (!choice) return;
if (choice === "status") {
const cs = await readConfig();
const creds = await readCreds();
const info =
`mcplocal: ${cs.mcplocalUrl}\n` +
`user: ${creds?.mcpdUrl ?? "not logged in"}\n` +
`active project: ${activeProject ?? "none"}\n` +
`tools registered: ${projectToolNames.size}\n` +
` ${[...projectToolNames].join("\n ")}\n`;
await ctx.ui.editor("mcpctl status", info);
return;
}
if (choice === "switch") {
await switchProject(ctx);
return;
}
if (choice === "refresh") {
if (!activeProject) {
ctx.ui.notify("No active project — switch project first", "warning");
return;
}
try {
const r = await reconcileTools(activeProject);
ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, "info");
setStatus();
} catch (e) {
ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, "error");
}
return;
}
if (choice === "sync skills") {
try {
const { execSync } = await import("node:child_process");
execSync(`mcpctl skills sync${activeProject ? ` -p ${activeProject}` : ""} --quiet`, {
stdio: "inherit",
cwd: process.cwd(),
});
ctx.ui.notify("mcpctl skills synced (into ~/.claude/skills)", "info");
} catch (e) {
ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, "error");
}
}
},
});
async function switchProject(ctx: ExtensionContext): Promise<void> {
let projects: string[] = [];
try {
projects = await listProjects(mcplocalUrl, token);
} catch (e) {
ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, "error");
return;
}
if (projects.length === 0) {
ctx.ui.notify("No projects returned by mcpd", "warning");
return;
}
const picked = await ctx.ui.select("Switch to project", projects);
if (!picked) return;
// Tear down old project's session + active tools.
await closeSession();
const active = pi.getActiveTools().filter((n) => !n.startsWith("mc_"));
pi.setActiveTools([...active]);
projectToolNames.clear();
activeProject = picked;
await writePiState({ project: picked });
try {
const r = await reconcileTools(picked);
ctx.ui.notify(`Switched to '${picked}': ${r.tools.length} tool(s) ready`, "info");
setStatus();
} catch (e) {
ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, "error");
}
}
// ── session lifecycle ──
pi.on("session_start", async (_event, ctx) => {
activeCtx = ctx;
try { ctx.ui?.setStatus?.("mcpctl", "connecting..."); } catch { /* ignore */ }
const cfg = await readConfig();
mcplocalUrl = cfg.mcplocalUrl;
const creds = await readCreds();
token = creds?.token ?? undefined;
const piState = await readPiState();
const project = piState.project ?? (await findProjectMarker());
if (!project) {
try { ctx.ui?.setStatus?.("mcpctl", "no project (use /mcpctl)"); } catch { /* ignore */ }
return;
}
activeProject = project;
try {
const r = await reconcileTools(project);
if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, "info");
setStatus();
} catch (e) {
if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, "error");
}
});
pi.on("session_shutdown", () => {
void closeSession();
});
}