diff --git a/scripts/generate-pi-extension.ts b/scripts/generate-pi-extension.ts new file mode 100644 index 0000000..1cd3e19 --- /dev/null +++ b/scripts/generate-pi-extension.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * Generates `src/cli/src/config/pi-extension.ts` which embeds the pi extension + * sources (`mcpctl-pi.ts` + `mcp-http.ts`) as string constants, mirroring how + * prime-agent's `/mcpctl` switcher is embedded (`prime-agent-extension.ts`). + * + * Embedding matters: `mcpctl config pi` must work from an installed binary + * that has no access to the source tree. The installed extension files are this + * exact embedded source, so what the CLI ships is always what pi runs. + * + * Regenerate after editing the extension sources: + * npx tsx scripts/generate-pi-extension.ts + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const scriptsDir = import.meta.dirname; +const root = join(scriptsDir, '..'); +const piExtDir = join(root, 'src', 'pi-ext'); + +const MAIN = readFileSync(join(piExtDir, 'mcpctl-pi.ts'), 'utf-8'); +const HTTP = readFileSync(join(piExtDir, 'mcp-http.ts'), 'utf-8'); + +function embed(src) { + // JSON.stringify yields a quoted string literal we can inline directly. + return JSON.stringify(src); +} + +const out = `/** + * Embedded source of the mcpctl pi extension — DO NOT EDIT BY HAND. + * Generated by \`npx tsx scripts/generate-pi-extension.ts\` from + * \`src/pi-ext/mcpctl-pi.ts\` and \`src/pi-ext/mcp-http.ts\`. + * + * \`mcpctl config pi\` writes these verbatim into ~/.pi/agent/extensions/mcpctl/ + * (and registers them), so an installed binary with no source tree can still + * provision a working pi integration. + */ +export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const; + +export const PI_EXTENSION_FILES: Record = { + 'mcpctl-pi.ts': ${embed(MAIN)}, + 'mcp-http.ts': ${embed(HTTP)}, +}; +`; + +mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true }); +writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'pi-extension.ts'), out); +console.log('wrote src/cli/src/config/pi-extension.ts'); diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 9f0c263..676a5e7 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -13,6 +13,7 @@ import { findProjectMarker, writeProjectMarker } from '../utils/project-marker.j import { installManagedSessionHook } from '../utils/sessionhook.js'; import { installExtensionFiles, + installEmbeddedExtension, registerWithPi, piSkillsDir, piSettingsPath, @@ -506,7 +507,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .command('pi') .description('Install the pi extension + sync skills (native, no MCP client, no Claude)') .option('-p, --project ', 'Project name to make active') - .option('--extension-dir ', 'Path to the src/pi-ext sources to install (default: this checkout)') + .option('--extension-dir ', 'Source-tree src/pi-ext dir (dev only; default: embedded sources)') .option('--skip-skills', 'Skip the initial skills sync') .option('--settings ', 'pi settings.json path (default: ~/.pi/agent/settings.json)') .option('--pi-dir ', 'Override the pi agent home (default: ~/.pi/agent)') @@ -526,25 +527,18 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // custom --pi-dir is used (also keeps tests off the real ~/.mcpctl). const statePath = piHome ? join(opts.piDir!, 'pi-state.json') : piStatePath(); - // 1. Install the extension files into ~/.pi/agent/extensions/mcpctl/ - let srcDir: string = opts.extensionDir!; - if (!srcDir) { - // best-effort: locate this checkout's src/pi-ext from the running - // script (dist compile) or from cwd (tsx/dev). - const candidates = [ - resolve(import.meta.dirname ?? process.cwd(), '..', '..', '..', '..', 'src', 'pi-ext'), - resolve(import.meta.dirname ?? process.cwd(), '..', '..', '..', '..', '..', 'src', 'pi-ext'), - resolve(process.cwd(), 'src', 'pi-ext'), - ]; - const { existsSync } = await import('node:fs'); - srcDir = candidates.find((c) => existsSync(join(c, 'mcpctl-pi.ts'))) ?? candidates[0]!; - } + // 1. Install the extension files into ~/.pi/agent/extensions/mcpctl/. + // Default: write the embedded sources (works from an installed binary + // with no source tree). --extension-dir overrides with a source-tree + // checkout for dev iteration. try { - const written = await installExtensionFiles(srcDir, extDest); + const written = opts.extensionDir + ? await installExtensionFiles(opts.extensionDir, extDest) + : await installEmbeddedExtension(extDest); log(`Installed extension files:`); for (const w of written) log(` ${w}`); } catch (err: unknown) { - log(`Error: could not install extension from '${srcDir}': ${err instanceof Error ? err.message : String(err)}`); + log(`Error: could not install extension: ${err instanceof Error ? err.message : String(err)}`); process.exitCode = 1; return; } diff --git a/src/cli/src/config/pi-extension.ts b/src/cli/src/config/pi-extension.ts new file mode 100644 index 0000000..271e2e6 --- /dev/null +++ b/src/cli/src/config/pi-extension.ts @@ -0,0 +1,15 @@ +/** + * Embedded source of the mcpctl pi extension — DO NOT EDIT BY HAND. + * Generated by `npx tsx scripts/generate-pi-extension.ts` from + * `src/pi-ext/mcpctl-pi.ts` and `src/pi-ext/mcp-http.ts`. + * + * `mcpctl config pi` writes these verbatim into ~/.pi/agent/extensions/mcpctl/ + * (and registers them), so an installed binary with no source tree can still + * provision a working pi integration. + */ +export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const; + +export const PI_EXTENSION_FILES: Record = { + 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Pi tool names currently registered for the active project. */\n const projectToolNames = new Set();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = piToolName(project, t.name);\n if (!projectToolNames.has(piName)) {\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.add(piName);\n }\n names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n content: [{ type: \"text\", text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n return `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`;\n }\n const choice = await ctx.ui.select(\"mcpctl\", [\n { value: \"status\", label: \"Status\" },\n { value: \"switch\", label: \"Switch project\" },\n { value: \"refresh\", label: \"Refresh tools\" },\n { value: \"sync skills\", label: \"Sync skills (mcpctl skills sync)\" },\n { value: \"close\", label: \"Close\" },\n ]);\n if (!choice) return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync skills\") {\n try {\n const { execSync } = await import(\"node:child_process\");\n execSync(`mcpctl skills sync${activeProject ? ` -p ${activeProject}` : \"\"} --quiet`, {\n stdio: \"inherit\",\n cwd: process.cwd(),\n });\n ctx.ui.notify(\"mcpctl skills synced (into ~/.claude/skills)\", \"info\");\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await ctx.ui.select(\"Switch to project\", projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n ctx.ui.notify(`Switched to '${picked}': ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", + 'mcp-http.ts': "/**\n * Self-contained Streamable-HTTP JSON-RPC client for the pi extension.\n *\n * Vendored (no `@mcpctl/*` imports) so the extension is loadable standalone\n * by pi (`pi -e ./mcpctl-pi.ts` / settings `extensions`). Talks directly to\n * mcplocal's project MCP endpoint — the same one `mcpctl mcp` bridges to.\n *\n * Only Node builtins are imported.\n */\nimport http from 'node:http';\nimport https from 'node:https';\n\nexport interface McpHttpOptions {\n bearer?: string;\n headers?: Record;\n timeoutMs?: number;\n}\n\nexport interface ToolInfo {\n name: string;\n description?: string;\n inputSchema?: unknown;\n}\n\nexport interface ToolCallResult {\n content: Array<{ type: string; text?: string }>;\n isError?: boolean;\n}\n\ninterface HttpArgs {\n url: string;\n method: string;\n headers?: Record;\n body?: string;\n timeoutMs?: number;\n}\n\ninterface HttpResult {\n status: number;\n headers: http.IncomingHttpHeaders;\n body: string;\n}\n\nfunction rawHttpRequest(opts: HttpArgs): Promise {\n return new Promise((resolve, reject) => {\n const parsed = new URL(opts.url);\n const driver = parsed.protocol === 'https:' ? https : http;\n const req = driver.request(\n {\n hostname: parsed.hostname,\n port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),\n path: parsed.pathname + parsed.search,\n method: opts.method,\n headers: opts.headers,\n timeout: opts.timeoutMs ?? 30_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on('data', (chunk: Buffer) => chunks.push(chunk));\n res.on('end', () => {\n resolve({\n status: res.statusCode ?? 0,\n headers: res.headers,\n body: Buffer.concat(chunks).toString('utf-8'),\n });\n });\n },\n );\n req.on('error', reject);\n req.on('timeout', () => {\n req.destroy();\n reject(new Error('MCP request timed out'));\n });\n if (opts.body) req.write(opts.body);\n req.end();\n });\n}\n\nfunction parseSse(body: string): unknown[] {\n const messages: unknown[] = [];\n for (const line of body.split('\\n')) {\n if (line.startsWith('data: ')) {\n try {\n messages.push(JSON.parse(line.slice(6)));\n } catch {\n // skip malformed SSE line\n }\n }\n }\n return messages;\n}\n\nexport class McpProtocolError extends Error {\n constructor(public readonly code: number, message: string) {\n super(`MCP error ${code}: ${message}`);\n this.name = 'McpProtocolError';\n }\n}\n\nexport class McpTransportError extends Error {\n constructor(public readonly status: number, public readonly body: string, message?: string) {\n super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`);\n this.name = 'McpTransportError';\n }\n}\n\nexport interface McpResponse {\n result?: unknown;\n error?: { code: number; message: string };\n notifications: unknown[];\n}\n\nexport class McpHttpSession {\n private sessionId: string | undefined;\n private nextId = 1;\n\n constructor(\n public readonly url: string,\n private readonly options: McpHttpOptions = {},\n ) {}\n\n private buildHeaders(extra: Record = {}): Record {\n const headers: Record = {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json, text/event-stream',\n ...(this.options.headers ?? {}),\n ...extra,\n };\n if (this.sessionId) headers['mcp-session-id'] = this.sessionId;\n if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`;\n return headers;\n }\n\n /**\n * Send a JSON-RPC request, return the matched result AND any notifications\n * that were multiplexed into the same body (e.g. tools/list_changed).\n */\n async sendFull(method: string, params: Record = {}): Promise {\n const id = this.nextId++;\n const request = { jsonrpc: '2.0', id, method, params };\n const result = await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(request),\n timeoutMs: this.options.timeoutMs,\n });\n\n if (!this.sessionId) {\n const sid = result.headers['mcp-session-id'];\n if (typeof sid === 'string') this.sessionId = sid;\n }\n\n if (result.status >= 400) {\n let message = `HTTP ${result.status}`;\n try {\n const body = JSON.parse(result.body) as { error?: string | { message?: string } };\n const errField = body.error;\n if (typeof errField === 'string') message = errField;\n else if (errField && typeof errField === 'object' && typeof errField.message === 'string') {\n message = errField.message;\n }\n } catch {\n message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;\n }\n throw new McpTransportError(result.status, result.body, message);\n }\n\n const messages = result.headers['content-type']?.includes('text/event-stream')\n ? parseSse(result.body)\n : [JSON.parse(result.body)];\n\n const notifications: unknown[] = [];\n const matched = messages.find((m) => {\n const msg = m as { id?: unknown; method?: unknown };\n if (msg.method !== undefined && (msg.id === undefined || msg.id === null)) {\n notifications.push(msg);\n return false;\n }\n return msg.id === id;\n }) as { result?: unknown; error?: { code: number; message: string } } | undefined;\n\n const parsed = matched ?? (messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined);\n if (!parsed) throw new Error(`No response for ${method}`);\n if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message);\n return { result: parsed.result, error: undefined, notifications };\n }\n\n /** Convenience: return just the result, throwing on error. */\n async send(method: string, params: Record = {}): Promise {\n const res = await this.sendFull(method, params);\n return res.result;\n }\n\n async sendNotification(method: string, params: Record = {}): Promise {\n const notification = { jsonrpc: '2.0', method, params };\n await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(notification),\n timeoutMs: this.options.timeoutMs,\n }).catch(() => { /* best-effort */ });\n }\n\n async initialize(): Promise {\n return this.send('initialize', {\n protocolVersion: '2024-11-05',\n capabilities: {},\n clientInfo: { name: 'mcpctl-pi', version: '1.0.0' },\n });\n }\n\n /** List tools exposed by the endpoint. Returns raw result so caller sees capabilities. */\n async listTools(): Promise<{ tools: ToolInfo[]; raw: unknown }> {\n const raw = await this.send('tools/list');\n const tools = (raw as { tools?: ToolInfo[] }).tools ?? [];\n return { tools, raw };\n }\n\n /** Call a tool. */\n async callTool(name: string, args: Record = {}): Promise {\n return this.send('tools/call', { name, arguments: args }) as ToolCallResult;\n }\n\n /** Close the session (DELETE). Safe when no session has been negotiated. */\n async close(): Promise {\n if (this.sessionId === undefined) return;\n await rawHttpRequest({\n url: this.url,\n method: 'DELETE',\n headers: this.buildHeaders(),\n timeoutMs: 5_000,\n }).catch(() => { /* best-effort */ });\n this.sessionId = undefined;\n }\n}\n", +}; diff --git a/src/cli/src/utils/pi-settings.ts b/src/cli/src/utils/pi-settings.ts index 4739d17..db611a4 100644 --- a/src/cli/src/utils/pi-settings.ts +++ b/src/cli/src/utils/pi-settings.ts @@ -2,18 +2,23 @@ * Wiring helpers for the pi extension. * * `mcpctl config pi --project X`: - * 1. copies `src/pi-ext/` (extension + http client) into - * `~/.pi/agent/extensions/mcpctl/`, + * 1. writes the embedded extension sources (`mcpctl-pi.ts` + `mcp-http.ts`) + * into `~/.pi/agent/extensions/mcpctl/`, * 2. adds the extension path and the pi skills dir to * `~/.pi/agent/settings.json` (`extensions`, `skills` arrays), * 3. writes `~/.mcpctl/pi-state.json` so the active project survives across * sessions regardless of the shell's cwd. * + * The extension sources are embedded in the CLI (see + * `config/pi-extension.ts`) so an installed binary with no source tree can + * still provision a working pi integration. + * * Standalone: never touches `~/.claude/`, `~/.prime/`, or `.mcp.json`. */ import { readFile, writeFile, mkdir, copyFile, rename } from 'node:fs/promises'; import { dirname, join, basename, resolve } from 'node:path'; import { homedir } from 'node:os'; +import { PI_EXTENSION_FILES } from '../config/pi-extension.js'; export function piAgentDir(): string { return join(homedir(), '.pi', 'agent'); @@ -54,10 +59,27 @@ export function piStatePath(): string { return join(homedir(), '.mcpctl', 'pi-state.json'); } +/** + * Write the embedded extension sources into the pi extensions dir. This is the + * production path used by `mcpctl config pi` — it needs no source tree, so it + * works from an installed binary. The extension imports its http client via a + * sibling `./mcp-http.js`, so both files must land together. + */ +export async function installEmbeddedExtension(dest: string = piExtensionDir()): Promise { + await mkdir(dest, { recursive: true }); + const written: string[] = []; + for (const [name, src] of Object.entries(PI_EXTENSION_FILES)) { + const dst = join(dest, name); + await writeFile(dst, src, 'utf-8'); + written.push(dst); + } + return written; +} + /** * Copy the bundled extension sources (relative to this source tree's - * `src/pi-ext/`) into the pi extensions dir. The extension imports its http - * client via a sibling `./mcp-http.js`, so both files must land together. + * `src/pi-ext/`) into the pi extensions dir. Kept for tests / dev; production + * uses `installEmbeddedExtension` so installed binaries are self-contained. */ export async function installExtensionFiles( srcPiExtDir: string, diff --git a/src/cli/tests/utils/pi-settings.test.ts b/src/cli/tests/utils/pi-settings.test.ts index 2a9b127..721edae 100644 --- a/src/cli/tests/utils/pi-settings.test.ts +++ b/src/cli/tests/utils/pi-settings.test.ts @@ -6,6 +6,7 @@ import { agentInstallRoot } from '../../src/commands/skills.js'; import { registerWithPi, installExtensionFiles, + installEmbeddedExtension, } from '../../src/utils/pi-settings.js'; describe('skills agentInstallRoot', () => { @@ -89,4 +90,20 @@ describe('pi-settings util', () => { expect(readFileSync(join(destDir, 'mcpctl-pi.ts'), 'utf-8')).toBe('// extension\n'); expect(readFileSync(join(destDir, 'mcp-http.ts'), 'utf-8')).toBe('// client\n'); }); + + it('writes the embedded extension sources (self-contained, no source tree)', async () => { + const destDir = join(tmp, 'embedded-ext'); + + const written = await installEmbeddedExtension(destDir); + + expect(written).toHaveLength(2); + // Both filenames must be written. + expect(written.map((w) => w.replace(destDir + '/', ''))).toEqual(['mcpctl-pi.ts', 'mcp-http.ts']); + // The main file must be non-empty and import the sibling client. + const main = readFileSync(join(destDir, 'mcpctl-pi.ts'), 'utf-8'); + expect(main.length).toBeGreaterThan(1000); + expect(main).toContain('./mcp-http.js'); + const http = readFileSync(join(destDir, 'mcp-http.ts'), 'utf-8'); + expect(http).toContain('McpHttpSession'); + }); });