Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions specs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
22 changes: 22 additions & 0 deletions src/agents/definitions/pi.ts
Original file line number Diff line number Diff line change
@@ -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;
27 changes: 27 additions & 0 deletions src/agents/mcp-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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([]);
});
});
21 changes: 19 additions & 2 deletions src/agents/mcp-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<void> {
if (servers.length === 0) return;
): Promise<McpWriteWarning[]> {
const warnings: McpWriteWarning[] = [];
if (servers.length === 0) return warnings;

// Deduplicate by resolved filePath so shared files aren't written twice
const seen = new Set<string>();
Expand All @@ -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;
Expand All @@ -75,6 +87,8 @@ export async function writeMcpConfigs(
await freshWrite(filePath, mcp, serialized);
}
}

return warnings;
}

/**
Expand All @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/agents/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")]);
});
});
32 changes: 27 additions & 5 deletions src/agents/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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();
});
});
3 changes: 2 additions & 1 deletion src/agents/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AgentDefinition>(
ALL_AGENTS.map((a) => [a.id, a]),
Expand Down
4 changes: 2 additions & 2 deletions src/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
7 changes: 4 additions & 3 deletions src/cli/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface InstallOptions {
export interface InstallResult {
installed: string[];
skipped: string[];
mcpWarnings: { agent: string; message: string }[];
hookWarnings: { agent: string; message: string }[];
}

Expand Down Expand Up @@ -276,7 +277,7 @@ export async function runInstall(opts: InstallOptions): Promise<InstallResult> {

// 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 }[] = [];
Expand All @@ -288,7 +289,7 @@ export async function runInstall(opts: InstallOptions): Promise<InstallResult> {
);
}

return { installed, skipped, hookWarnings };
return { installed, skipped, mcpWarnings, hookWarnings };
}

export default async function install(args: string[], flags?: { user?: boolean }): Promise<void> {
Expand All @@ -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) {
Expand Down