diff --git a/README.md b/README.md index f857995..6bb1634 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ The `agents` field tells dotagents which tools to configure. It handles skills s agents = ["claude", "cursor"] ``` -Supported agents: `claude`, `cursor`, `codex`, `vscode`, `opencode`. +Supported agents: `claude`, `cursor`, `codex`, `vscode`, `opencode`, `pi`. ## MCP Servers diff --git a/plugins/dotagents/skills/dotagents/references/config-schema.md b/plugins/dotagents/skills/dotagents/references/config-schema.md index 18678ec..36e3c45 100644 --- a/plugins/dotagents/skills/dotagents/references/config-schema.md +++ b/plugins/dotagents/skills/dotagents/references/config-schema.md @@ -20,7 +20,7 @@ agents = ["claude", "cursor"] # Optional, agent targets |-------|------|----------|---------|-------------| | `version` | integer | Yes | — | Schema version, must be `1` | | `gitignore` | boolean | No | `false` | Generate `.agents/.gitignore` for managed skills | -| `agents` | string[] | No | — | Agent targets: `claude`, `cursor`, `codex`, `vscode`, `opencode` | +| `agents` | string[] | No | — | Agent targets: `claude`, `cursor`, `codex`, `vscode`, `opencode`, `pi` | ## Project Section diff --git a/specs/SPEC.md b/specs/SPEC.md index 01c3884..cc9336a 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -158,6 +158,7 @@ A server must have either `command` (stdio) or `url` (HTTP), but not both. | `codex` | Codex | `.codex` | `.codex/config.toml` | TOML (shared) | | `vscode` | VS Code Copilot | `.vscode` | `.vscode/mcp.json` | JSON | | `opencode` | OpenCode | `.claude` | `opencode.json` | JSON (shared) | +| `pi` | Pi | `.pi` | — | — (no MCP) | Each agent has its own MCP config format. dotagents translates the universal `[[mcp]]` declarations into the format each tool expects during `install` and `sync`. diff --git a/src/agents/definitions/pi.ts b/src/agents/definitions/pi.ts new file mode 100644 index 0000000..1a975b1 --- /dev/null +++ b/src/agents/definitions/pi.ts @@ -0,0 +1,22 @@ +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { AgentDefinition } from "../types.js"; +import { UnsupportedFeature } from "../errors.js"; + +const pi: AgentDefinition = { + id: "pi", + displayName: "Pi", + configDir: ".pi", + skillsParentDir: ".pi", + userSkillsParentDirs: [join(homedir(), ".pi", "agent")], + mcp: undefined, + serializeServer() { + throw new UnsupportedFeature("pi", "MCP"); + }, + hooks: undefined, + serializeHooks() { + throw new UnsupportedFeature("pi", "hooks"); + }, +}; + +export default pi; diff --git a/src/agents/mcp-writer.test.ts b/src/agents/mcp-writer.test.ts index 6757c58..c10cc3a 100644 --- a/src/agents/mcp-writer.test.ts +++ b/src/agents/mcp-writer.test.ts @@ -249,6 +249,27 @@ describe("writeMcpConfigs", () => { await writeMcpConfigs(["cursor"], [STDIO_SERVER], projectMcpResolver(dir)); expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(true); }); + + it("returns warning for agents without MCP support", async () => { + const warnings = await writeMcpConfigs(["pi"], [STDIO_SERVER], projectMcpResolver(dir)); + expect(warnings).toHaveLength(1); + expect(warnings[0]!.agent).toBe("pi"); + expect(warnings[0]!.message).toContain("does not support MCP"); + }); + + it("writes configs for MCP-capable agents alongside no-MCP agents", async () => { + const warnings = await writeMcpConfigs( + ["claude", "pi", "cursor"], + [STDIO_SERVER], + projectMcpResolver(dir), + ); + + // pi warned, claude and cursor written + expect(warnings).toHaveLength(1); + expect(warnings[0]!.agent).toBe("pi"); + expect(existsSync(join(dir, ".mcp.json"))).toBe(true); + expect(existsSync(join(dir, ".cursor", "mcp.json"))).toBe(true); + }); }); describe("verifyMcpConfigs", () => { @@ -286,4 +307,10 @@ describe("verifyMcpConfigs", () => { const issues = await verifyMcpConfigs(["claude"], [], projectMcpResolver(dir)); expect(issues).toEqual([]); }); + + it("skips agents without MCP support", async () => { + await writeMcpConfigs(["claude"], [STDIO_SERVER], projectMcpResolver(dir)); + const issues = await verifyMcpConfigs(["claude", "pi"], [STDIO_SERVER], projectMcpResolver(dir)); + expect(issues).toEqual([]); + }); }); diff --git a/src/agents/mcp-writer.ts b/src/agents/mcp-writer.ts index 505b895..291b35d 100644 --- a/src/agents/mcp-writer.ts +++ b/src/agents/mcp-writer.ts @@ -13,6 +13,11 @@ export interface McpResolvedTarget { export type McpTargetResolver = (agentId: string, spec: McpConfigSpec) => McpResolvedTarget; +export interface McpWriteWarning { + agent: string; + message: string; +} + /** * Convert McpConfig entries (from agents.toml) to universal McpDeclarations. */ @@ -41,13 +46,15 @@ export function projectMcpResolver(projectRoot: string): McpTargetResolver { * Write MCP config files for each agent. * - Dedicated files (shared=false): written fresh each time. * - Shared files (shared=true): read existing, merge dotagents servers under the root key, write back. + * - Agents that don't support MCP: collected as warnings. */ export async function writeMcpConfigs( agentIds: string[], servers: McpDeclaration[], resolveTarget: McpTargetResolver, -): Promise { - if (servers.length === 0) return; +): Promise { + const warnings: McpWriteWarning[] = []; + if (servers.length === 0) return warnings; // Deduplicate by resolved filePath so shared files aren't written twice const seen = new Set(); @@ -56,6 +63,11 @@ export async function writeMcpConfigs( const agent = getAgent(id); if (!agent) continue; + if (!agent.mcp) { + warnings.push({ agent: id, message: `Agent "${agent.displayName}" does not support MCP` }); + continue; + } + const { mcp } = agent; const { filePath, shared } = resolveTarget(id, mcp); if (seen.has(filePath)) continue; @@ -75,6 +87,8 @@ export async function writeMcpConfigs( await freshWrite(filePath, mcp, serialized); } } + + return warnings; } /** @@ -95,6 +109,9 @@ export async function verifyMcpConfigs( const agent = getAgent(id); if (!agent) continue; + // Skip agents that don't support MCP + if (!agent.mcp) continue; + const { mcp } = agent; const { filePath } = resolveTarget(id, mcp); if (seen.has(filePath)) continue; diff --git a/src/agents/paths.test.ts b/src/agents/paths.test.ts index fd432bf..5f30a9d 100644 --- a/src/agents/paths.test.ts +++ b/src/agents/paths.test.ts @@ -77,4 +77,10 @@ describe("skill discovery paths", () => { expect(agent.skillsParentDir).toBeUndefined(); expect(agent.userSkillsParentDirs).toBeUndefined(); }); + + it("pi needs project and user symlinks", () => { + const agent = getAgent("pi")!; + expect(agent.skillsParentDir).toBe(".pi"); + expect(agent.userSkillsParentDirs).toEqual([join(home, ".pi", "agent")]); + }); }); diff --git a/src/agents/registry.test.ts b/src/agents/registry.test.ts index d237ce4..f515aa0 100644 --- a/src/agents/registry.test.ts +++ b/src/agents/registry.test.ts @@ -22,14 +22,15 @@ const STDIO_NO_ENV: McpDeclaration = { }; describe("allAgentIds", () => { - it("returns all 5 agents", () => { + it("returns all 6 agents", () => { const ids = allAgentIds(); expect(ids).toContain("claude"); expect(ids).toContain("cursor"); expect(ids).toContain("codex"); expect(ids).toContain("vscode"); expect(ids).toContain("opencode"); - expect(ids).toHaveLength(5); + expect(ids).toContain("pi"); + expect(ids).toHaveLength(6); }); }); @@ -101,8 +102,8 @@ describe("codex serializer", () => { }); it("has toml format and shared flag", () => { - expect(agent.mcp.format).toBe("toml"); - expect(agent.mcp.shared).toBe(true); + expect(agent.mcp!.format).toBe("toml"); + expect(agent.mcp!.shared).toBe(true); }); }); @@ -170,7 +171,28 @@ describe("opencode serializer", () => { }); it("shares config and reads .agents/ natively", () => { - expect(agent.mcp.shared).toBe(true); + expect(agent.mcp!.shared).toBe(true); expect(agent.skillsParentDir).toBeUndefined(); }); }); + +describe("pi agent", () => { + const agent = getAgent("pi")!; + + it("has no MCP support", () => { + expect(agent.mcp).toBeUndefined(); + }); + + it("throws UnsupportedFeature for serializeServer", () => { + expect(() => agent.serializeServer(STDIO_SERVER)).toThrow("MCP"); + }); + + it("throws UnsupportedFeature for serializeHooks", () => { + expect(() => agent.serializeHooks([])).toThrow("hooks"); + }); + + it("needs project and user symlinks", () => { + expect(agent.skillsParentDir).toBe(".pi"); + expect(agent.userSkillsParentDirs).toBeDefined(); + }); +}); diff --git a/src/agents/registry.ts b/src/agents/registry.ts index 8bffe05..25cdee9 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -4,8 +4,9 @@ import cursor from "./definitions/cursor.js"; import codex from "./definitions/codex.js"; import vscode from "./definitions/vscode.js"; import opencode from "./definitions/opencode.js"; +import pi from "./definitions/pi.js"; -const ALL_AGENTS: AgentDefinition[] = [claude, cursor, codex, vscode, opencode]; +const ALL_AGENTS: AgentDefinition[] = [claude, cursor, codex, vscode, opencode, pi]; const AGENT_REGISTRY = new Map( ALL_AGENTS.map((a) => [a.id, a]), diff --git a/src/agents/types.ts b/src/agents/types.ts index 0b3b908..3acb56a 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -91,8 +91,8 @@ export interface AgentDefinition { * Undefined if the agent reads ~/.agents/skills/ natively (no symlink needed). */ userSkillsParentDirs?: string[]; - /** MCP config file specification */ - mcp: McpConfigSpec; + /** MCP config file specification (undefined if agent doesn't support MCP) */ + mcp?: McpConfigSpec; /** Transforms universal MCP declaration to agent-specific format */ serializeServer: McpSerializer; /** Hook config file specification (undefined if agent doesn't support hooks) */ diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index a5b9242..b2f9723 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -44,6 +44,7 @@ export interface InstallOptions { export interface InstallResult { installed: string[]; skipped: string[]; + mcpWarnings: { agent: string; message: string }[]; hookWarnings: { agent: string; message: string }[]; } @@ -276,7 +277,7 @@ export async function runInstall(opts: InstallOptions): Promise { // 5. Write MCP config files const mcpResolver = scope.scope === "user" ? userMcpResolver() : projectMcpResolver(scope.root); - await writeMcpConfigs(config.agents, toMcpDeclarations(config.mcp), mcpResolver); + const mcpWarnings = await writeMcpConfigs(config.agents, toMcpDeclarations(config.mcp), mcpResolver); // 6. Write hook config files (skip for user scope) let hookWarnings: { agent: string; message: string }[] = []; @@ -288,7 +289,7 @@ export async function runInstall(opts: InstallOptions): Promise { ); } - return { installed, skipped, hookWarnings }; + return { installed, skipped, mcpWarnings, hookWarnings }; } export default async function install(args: string[], flags?: { user?: boolean }): Promise { @@ -314,7 +315,7 @@ export default async function install(args: string[], flags?: { user?: boolean } chalk.green(`Installed ${result.installed.length} skill(s): ${result.installed.join(", ")}`), ); } - for (const w of result.hookWarnings) { + for (const w of [...result.mcpWarnings, ...result.hookWarnings]) { console.log(chalk.yellow(` warn: ${w.message}`)); } } catch (err) {