Skip to content
Merged
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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ agents = ["claude", "cursor", "codex", "opencode"]
| `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | -- |
| `opencode` | `.opencode` | `opencode.json` | -- | `.opencode/agents/*.md` |

Install and sync repair currently declared MCP entries while preserving undeclared servers and unrelated content in every existing target file. When no servers are declared, existing MCP files remain unchanged because dotagents has no durable evidence that it owns them. Unreadable or structurally incompatible files are reported and left unchanged.

Custom subagents are declared with `[[subagents]]` entries. dotagents writes generated runtime-specific files during `install` and repairs them during `sync`:

```toml
Expand Down
2 changes: 2 additions & 0 deletions docs/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ Config files generated per agent:
- VS Code: `.vscode/mcp.json` (JSON)
- OpenCode: `opencode.json` (JSON, shared)

Install and sync repair currently declared MCP entries while preserving undeclared servers and unrelated top-level content in every existing target file. When no servers are declared, existing MCP files remain unchanged because dotagents has no durable evidence that it owns them. Unreadable or structurally incompatible files are reported and left unchanged. Entries generated by an earlier declaration may therefore remain after that declaration is removed.

### Hooks

Each `[[hooks]]` entry requires `event` and `command`. Optional: `matcher` to filter by tool name.
Expand Down
3 changes: 3 additions & 0 deletions packages/dotagents/src/cli/commands/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ describe("runAdd", () => {
installed: [],
skipped: [],
pruned: [],
mcpWarnings: [],
hookWarnings: [],
subagentWarnings: [],
});
Expand Down Expand Up @@ -366,6 +367,7 @@ describe("runAdd", () => {
installed: [],
skipped: [],
pruned: [],
mcpWarnings: [],
hookWarnings: [],
subagentWarnings: [],
});
Expand All @@ -391,6 +393,7 @@ describe("runAdd", () => {
installed: [],
skipped: [],
pruned: [],
mcpWarnings: [],
hookWarnings: [],
subagentWarnings: [],
});
Expand Down
39 changes: 39 additions & 0 deletions packages/dotagents/src/cli/commands/install-user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,18 @@ describe("runInstall user scope", () => {
]);

await mkdir(sourceDir, { recursive: true });
await mkdir(homeDir, { recursive: true });
await writeFile(join(sourceDir, "SKILL.md"), SKILL_MD);
await writeFile(
join(homeDir, ".claude.json"),
JSON.stringify({
theme: "dark",
mcpServers: {
manual: { command: "manual" },
fixture: { command: "old" },
},
}),
);
const scope = resolveScope("user");
await mkdir(scope.root, { recursive: true });
await writeFile(
Expand All @@ -72,6 +83,11 @@ agents = ["claude"]
[[skills]]
name = "pdf"
source = "path:skill-source/pdf"

[[mcp]]
name = "fixture"
command = "node"
args = ["server.js"]
`,
);

Expand All @@ -86,6 +102,29 @@ source = "path:skill-source/pdf"
expect(stat.isSymbolicLink()).toBe(true);
expect(await readlink(skillsLink)).toBe(relative(join(homeDir, ".claude"), scope.skillsDir));

expect(JSON.parse(await readFile(join(homeDir, ".claude.json"), "utf-8"))).toEqual({
theme: "dark",
mcpServers: {
manual: { command: "manual" },
fixture: { command: "node", args: ["server.js"] },
},
});

const mcpPath = join(homeDir, ".claude.json");
const beforeEmptyInstall = await readFile(mcpPath, "utf-8");
await writeFile(
scope.configPath,
`version = 1
agents = ["claude"]

[[skills]]
name = "pdf"
source = "path:skill-source/pdf"
`,
);
await runInstall({ scope });
expect(await readFile(mcpPath, "utf-8")).toBe(beforeEmptyInstall);

const lockfile = await loadLockfile(scope.lockPath);
expect(lockfile!.skills["pdf"]).toEqual({ source: "path:skill-source/pdf" });
});
Expand Down
38 changes: 37 additions & 1 deletion packages/dotagents/src/cli/commands/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,16 +136,52 @@ describe("runInstall", () => {
);

const scope = resolveScope("project", projectRoot);
await runInstall({ scope });
const result = await runInstall({ scope });

const mcp = JSON.parse(await readFile(join(projectRoot, ".mcp.json"), "utf-8"));
expect(mcp.mcpServers.github).toBeDefined();
expect(result.mcpWarnings).toEqual([]);

// Agent symlinks should also be created
const stat = await lstat(join(projectRoot, ".claude", "skills"));
expect(stat.isSymbolicLink()).toBe(true);
});

it("preserves a pre-existing MCP config when no servers are declared", async () => {
const configPath = join(projectRoot, "agents.toml");
const mcpPath = join(projectRoot, ".mcp.json");
const content = JSON.stringify({
editor: "manual",
mcpServers: { manual: { command: "manual" } },
});
await writeFile(configPath, `version = 1\nagents = ["claude"]\n`);
await writeFile(mcpPath, content);

const scope = resolveScope("project", projectRoot);
await runInstall({ scope });

expect(await readFile(mcpPath, "utf-8")).toBe(content);
});

it("warns without changing an incompatible MCP config", async () => {
const configPath = join(projectRoot, "agents.toml");
const mcpPath = join(projectRoot, ".mcp.json");
const content = '{"mcpServers":[]}\n';
await writeFile(
configPath,
`version = 1\nagents = ["claude"]\n\n[[mcp]]\nname = "github"\ncommand = "github-mcp"\n`,
);
await writeFile(mcpPath, content);

const result = await runInstall({ scope: resolveScope("project", projectRoot) });

expect(result.mcpWarnings).toEqual([{
agent: "claude",
message: `Failed to read MCP config: ${mcpPath}`,
}]);
expect(await readFile(mcpPath, "utf-8")).toBe(content);
});

it("fails with --frozen when no lockfile exists", async () => {
await writeFile(
join(projectRoot, "agents.toml"),
Expand Down
7 changes: 6 additions & 1 deletion packages/dotagents/src/cli/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface InstallResult {
installed: string[];
skipped: string[];
pruned: string[];
mcpWarnings: { agent: string; message: string }[];
hookWarnings: { agent: string; message: string }[];
subagentWarnings: { agent: string; name: string; message: string }[];
}
Expand Down Expand Up @@ -65,14 +66,15 @@ export async function runInstall(opts: InstallOptions): Promise<InstallResult> {
subagents: subagents.subagents,
}, frozen);
await writeSkillSymlinks(config, scope);
await writeMcpRuntime(config, scope);
const mcpWarnings = await writeMcpRuntime(config, scope);
const hookWarnings = await writeHookRuntime(config, scope);
const subagentWarnings = await writeSubagentRuntime(config, scope, subagents.subagents, frozen);

return {
installed: skills.installed,
skipped: [],
pruned: skills.pruned,
mcpWarnings,
hookWarnings,
subagentWarnings,
};
Expand Down Expand Up @@ -109,6 +111,9 @@ export default async function install(args: string[], flags?: { user?: boolean }
chalk.yellow(`Pruned ${result.pruned.length} stale skill(s): ${result.pruned.join(", ")}`),
);
}
for (const w of result.mcpWarnings) {
console.log(chalk.yellow(` warn: ${w.message}`));
}
for (const w of result.hookWarnings) {
console.log(chalk.yellow(` warn: ${w.message}`));
}
Expand Down
12 changes: 9 additions & 3 deletions packages/dotagents/src/cli/commands/install/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { AgentsConfig } from "../../../config/schema.js";
import type { ScopeRoot } from "../../../scope.js";
import { ensureSkillsSymlink } from "../../../symlinks/manager.js";
import { skillSymlinkTargets } from "../../../targets/skill-symlinks.js";
import { projectMcpResolver, toMcpDeclarations, writeMcpConfigs } from "../../../targets/mcp-writer.js";
import { projectMcpResolver, reconcileMcpConfigs, toMcpDeclarations } from "../../../targets/mcp-writer.js";
import { projectHookResolver, toHookDeclarations, writeHookConfigs } from "../../../targets/hook-writer.js";
import { userMcpResolver } from "../../../targets/paths.js";
import {
Expand Down Expand Up @@ -32,11 +32,17 @@ export async function writeSkillSymlinks(
export async function writeMcpRuntime(
config: AgentsConfig,
scope: ScopeRoot,
): Promise<void> {
): Promise<{ agent: string; message: string }[]> {
const resolver = scope.scope === "user"
? userMcpResolver()
: projectMcpResolver(scope.root);
await writeMcpConfigs(config.agents, toMcpDeclarations(config.mcp), resolver);
const result = await reconcileMcpConfigs(
config.agents,
toMcpDeclarations(config.mcp),
resolver,
"apply",
);
return result.unresolved.map(({ agent, issue }) => ({ agent, message: issue }));
}

/** Writes project-scoped hook runtime config for configured agents. */
Expand Down
69 changes: 69 additions & 0 deletions packages/dotagents/src/cli/commands/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,75 @@ describe("runSync", () => {
expect(existsSync(join(projectRoot, ".mcp.json"))).toBe(true);
});

it("repairs MCP transport drift under unchanged server names", async () => {
await writeFile(
join(projectRoot, "agents.toml"),
`version = 1
agents = ["claude"]

[[mcp]]
name = "github"
command = "npx"
args = ["-y", "@mcp/server-github"]
env = ["GITHUB_TOKEN"]

[[mcp]]
name = "remote"
url = "https://mcp.example.com/sse"
headers = { Authorization = "Bearer tok" }
`,
);
const configPath = join(projectRoot, "agents.toml");
const mcpPath = join(projectRoot, ".mcp.json");
await writeFile(mcpPath, JSON.stringify({
editor: "manual",
mcpServers: {
manual: { command: "manual" },
github: { command: "old", args: ["old"], env: { GITHUB_TOKEN: "old" } },
remote: { type: "http", url: "https://old.example.com", headers: { Authorization: "old" } },
},
}));

const result = await runSync({ scope: resolveScope("project", projectRoot) });

expect(result.mcpRepaired).toBe(1);
expect(result.issues.filter(({ type }) => type === "mcp")).toEqual([]);
expect(JSON.parse(await readFile(mcpPath, "utf-8"))).toEqual({
editor: "manual",
mcpServers: {
manual: { command: "manual" },
github: {
command: "npx",
args: ["-y", "@mcp/server-github"],
env: { GITHUB_TOKEN: "${GITHUB_TOKEN}" },
},
remote: {
type: "http",
url: "https://mcp.example.com/sse",
headers: { Authorization: "Bearer tok" },
},
},
});

await writeFile(configPath, `version = 1\nagents = ["claude"]\n`);
const beforeEmptySync = await readFile(mcpPath, "utf-8");
const emptyResult = await runSync({ scope: resolveScope("project", projectRoot) });
expect(emptyResult.mcpRepaired).toBe(0);
expect(await readFile(mcpPath, "utf-8")).toBe(beforeEmptySync);

await writeFile(configPath, `version = 1\nagents = ["claude"]\n\n[[mcp]]\nname = "github"\ncommand = "npx"\n`);
const incompatible = '{"mcpServers":[]}\n';
await writeFile(mcpPath, incompatible);
const incompatibleResult = await runSync({ scope: resolveScope("project", projectRoot) });
expect(incompatibleResult.mcpRepaired).toBe(0);
expect(incompatibleResult.issues).toEqual([{
type: "mcp",
name: "claude",
message: `Failed to read MCP config: ${mcpPath}`,
}]);
expect(await readFile(mcpPath, "utf-8")).toBe(incompatible);
});

it("repairs agent-specific symlinks", async () => {
await writeFile(
join(projectRoot, "agents.toml"),
Expand Down
11 changes: 5 additions & 6 deletions packages/dotagents/src/cli/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { addSkillToConfig } from "../../config/writer.js";
import { writeAgentsGitignore, checkRootGitignoreEntries } from "../../gitignore/writer.js";
import { ensureSkillsSymlink, verifySymlinks } from "../../symlinks/manager.js";
import { skillSymlinkTargets } from "../../targets/skill-symlinks.js";
import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../targets/mcp-writer.js";
import { reconcileMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../targets/mcp-writer.js";
import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../targets/hook-writer.js";
import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../subagents/writer.js";
import { loadInstalledSubagents, pruneInstalledSubagents } from "../../subagents/store.js";
Expand Down Expand Up @@ -180,11 +180,10 @@ export async function runSync(opts: SyncOptions): Promise<SyncResult> {
const mcpServers = toMcpDeclarations(config.mcp);
const mcpResolver = scope.scope === "user" ? userMcpResolver() : projectMcpResolver(scope.root);

const mcpIssues = await verifyMcpConfigs(config.agents, mcpServers, mcpResolver);
if (mcpIssues.length > 0) {
await writeMcpConfigs(config.agents, mcpServers, mcpResolver);
mcpRepaired = mcpIssues.length;
for (const issue of mcpIssues) {
const mcpResult = await reconcileMcpConfigs(config.agents, mcpServers, mcpResolver, "apply");
mcpRepaired = mcpResult.written.length;
if (mcpResult.unresolved.length > 0) {
for (const issue of mcpResult.unresolved) {
issues.push({
type: "mcp",
name: issue.agent,
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down
Loading
Loading