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
3 changes: 3 additions & 0 deletions src/agents/definitions/claude.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { join } from "node:path";
import { homedir } from "node:os";
import type { AgentDefinition } from "../types.js";
import { envRecord, httpServer, serializeClaudeHooks } from "./helpers.js";

Expand All @@ -6,6 +8,7 @@ const claude: AgentDefinition = {
displayName: "Claude Code",
configDir: ".claude",
skillsParentDir: ".claude",
userSkillsParentDirs: [join(homedir(), ".claude")],
mcp: {
filePath: ".mcp.json",
rootKey: "mcpServers",
Expand Down
4 changes: 3 additions & 1 deletion src/agents/definitions/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ const codex: AgentDefinition = {
id: "codex",
displayName: "Codex",
configDir: ".codex",
skillsParentDir: ".codex",
// reads .agents/skills/ natively at both project and user scope
skillsParentDir: undefined,
userSkillsParentDirs: undefined,
mcp: {
filePath: ".codex/config.toml",
rootKey: "mcp_servers",
Expand Down
3 changes: 3 additions & 0 deletions src/agents/definitions/cursor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { join } from "node:path";
import { homedir } from "node:os";
import type { AgentDefinition, HookDeclaration } from "../types.js";
import type { HookEvent } from "../../config/schema.js";
import claude from "./claude.js";
Expand All @@ -19,6 +21,7 @@ const cursor: AgentDefinition = {
displayName: "Cursor",
configDir: ".cursor",
skillsParentDir: ".cursor",
userSkillsParentDirs: [join(homedir(), ".cursor")],
mcp: {
filePath: ".cursor/mcp.json",
rootKey: "mcpServers",
Expand Down
4 changes: 3 additions & 1 deletion src/agents/definitions/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ const opencode: AgentDefinition = {
id: "opencode",
displayName: "OpenCode",
configDir: ".claude",
skillsParentDir: ".claude",
// reads .agents/skills/ natively at both project and user scope
skillsParentDir: undefined,
userSkillsParentDirs: undefined,
mcp: {
filePath: "opencode.json",
rootKey: "mcp",
Expand Down
2 changes: 1 addition & 1 deletion src/agents/definitions/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const vscode: AgentDefinition = {
id: "vscode",
displayName: "VS Code Copilot",
configDir: ".vscode",
skillsParentDir: ".vscode",
// reads .agents/skills/ natively at both project and user scope
mcp: {
filePath: ".vscode/mcp.json",
rootKey: "servers",
Expand Down
38 changes: 19 additions & 19 deletions src/agents/hook-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtemp, readFile, writeFile, rm, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { existsSync } from "node:fs";
import { writeHookConfigs, verifyHookConfigs, toHookDeclarations } from "./hook-writer.js";
import { writeHookConfigs, verifyHookConfigs, toHookDeclarations, projectHookResolver } from "./hook-writer.js";
import type { HookDeclaration } from "./types.js";
import type { HookConfig } from "../config/schema.js";

Expand Down Expand Up @@ -42,13 +42,13 @@ describe("writeHookConfigs", () => {
});

it("skips when no hooks declared", async () => {
const warnings = await writeHookConfigs(dir, ["claude"], []);
const warnings = await writeHookConfigs(["claude"], [], projectHookResolver(dir));
expect(warnings).toEqual([]);
expect(existsSync(join(dir, ".claude", "settings.json"))).toBe(false);
});

it("writes claude .claude/settings.json", async () => {
await writeHookConfigs(dir, ["claude"], HOOKS);
await writeHookConfigs(["claude"], HOOKS, projectHookResolver(dir));

const content = JSON.parse(await readFile(join(dir, ".claude", "settings.json"), "utf-8"));
expect(content.hooks.PreToolUse).toEqual([
Expand All @@ -60,7 +60,7 @@ describe("writeHookConfigs", () => {
});

it("writes cursor .cursor/hooks.json with version field", async () => {
await writeHookConfigs(dir, ["cursor"], HOOKS);
await writeHookConfigs(["cursor"], HOOKS, projectHookResolver(dir));

const content = JSON.parse(await readFile(join(dir, ".cursor", "hooks.json"), "utf-8"));
expect(content.version).toBe(1);
Expand All @@ -76,7 +76,7 @@ describe("writeHookConfigs", () => {
});

it("cursor drops matcher", async () => {
await writeHookConfigs(dir, ["cursor"], HOOKS);
await writeHookConfigs(["cursor"], HOOKS, projectHookResolver(dir));

const content = JSON.parse(await readFile(join(dir, ".cursor", "hooks.json"), "utf-8"));
// Cursor hooks should not contain matcher
Expand All @@ -88,30 +88,30 @@ describe("writeHookConfigs", () => {
});

it("writes vscode to same .claude/settings.json as claude", async () => {
await writeHookConfigs(dir, ["vscode"], HOOKS);
await writeHookConfigs(["vscode"], HOOKS, projectHookResolver(dir));

const content = JSON.parse(await readFile(join(dir, ".claude", "settings.json"), "utf-8"));
expect(content.hooks.PreToolUse).toBeDefined();
});

it("deduplicates shared file between claude and vscode", async () => {
await writeHookConfigs(dir, ["claude", "vscode"], HOOKS);
await writeHookConfigs(["claude", "vscode"], HOOKS, projectHookResolver(dir));

// Should only write once — both target .claude/settings.json
const content = JSON.parse(await readFile(join(dir, ".claude", "settings.json"), "utf-8"));
expect(content.hooks.PreToolUse).toHaveLength(1);
});

it("returns warnings for unsupported agents", async () => {
const warnings = await writeHookConfigs(dir, ["codex", "opencode"], HOOKS);
const warnings = await writeHookConfigs(["codex", "opencode"], HOOKS, projectHookResolver(dir));
expect(warnings).toHaveLength(2);
expect(warnings[0]!.agent).toBe("codex");
expect(warnings[1]!.agent).toBe("opencode");
expect(warnings[0]!.message).toContain("does not support");
});

it("writes supported agents and warns for unsupported ones", async () => {
const warnings = await writeHookConfigs(dir, ["claude", "codex"], HOOKS);
const warnings = await writeHookConfigs(["claude", "codex"], HOOKS, projectHookResolver(dir));
expect(warnings).toHaveLength(1);
expect(warnings[0]!.agent).toBe("codex");
expect(existsSync(join(dir, ".claude", "settings.json"))).toBe(true);
Expand All @@ -126,25 +126,25 @@ describe("writeHookConfigs", () => {
"utf-8",
);

await writeHookConfigs(dir, ["claude"], HOOKS);
await writeHookConfigs(["claude"], HOOKS, projectHookResolver(dir));

const content = JSON.parse(await readFile(join(claudeDir, "settings.json"), "utf-8"));
expect(content.permissions).toEqual({ allow: ["Read"] });
expect(content.hooks).toBeDefined();
});

it("is idempotent", async () => {
await writeHookConfigs(dir, ["claude"], HOOKS);
await writeHookConfigs(["claude"], HOOKS, projectHookResolver(dir));
const first = await readFile(join(dir, ".claude", "settings.json"), "utf-8");

await writeHookConfigs(dir, ["claude"], HOOKS);
await writeHookConfigs(["claude"], HOOKS, projectHookResolver(dir));
const second = await readFile(join(dir, ".claude", "settings.json"), "utf-8");

expect(first).toBe(second);
});

it("handles multiple agents including cursor", async () => {
await writeHookConfigs(dir, ["claude", "cursor"], HOOKS);
await writeHookConfigs(["claude", "cursor"], HOOKS, projectHookResolver(dir));

expect(existsSync(join(dir, ".claude", "settings.json"))).toBe(true);
expect(existsSync(join(dir, ".cursor", "hooks.json"))).toBe(true);
Expand All @@ -163,24 +163,24 @@ describe("verifyHookConfigs", () => {
});

it("returns no issues when configs match", async () => {
await writeHookConfigs(dir, ["claude"], HOOKS);
const issues = await verifyHookConfigs(dir, ["claude"], HOOKS);
await writeHookConfigs(["claude"], HOOKS, projectHookResolver(dir));
const issues = await verifyHookConfigs(["claude"], HOOKS, projectHookResolver(dir));
expect(issues).toEqual([]);
});

it("reports missing config file", async () => {
const issues = await verifyHookConfigs(dir, ["claude"], HOOKS);
const issues = await verifyHookConfigs(["claude"], HOOKS, projectHookResolver(dir));
expect(issues).toHaveLength(1);
expect(issues[0]!.issue).toContain("missing");
});

it("skips unsupported agents without reporting issues", async () => {
const issues = await verifyHookConfigs(dir, ["codex"], HOOKS);
const issues = await verifyHookConfigs(["codex"], HOOKS, projectHookResolver(dir));
expect(issues).toEqual([]);
});

it("returns empty when no hooks declared", async () => {
const issues = await verifyHookConfigs(dir, ["claude"], []);
const issues = await verifyHookConfigs(["claude"], [], projectHookResolver(dir));
expect(issues).toEqual([]);
});

Expand All @@ -193,7 +193,7 @@ describe("verifyHookConfigs", () => {
"utf-8",
);

const issues = await verifyHookConfigs(dir, ["claude"], HOOKS);
const issues = await verifyHookConfigs(["claude"], HOOKS, projectHookResolver(dir));
expect(issues).toHaveLength(1);
expect(issues[0]!.issue).toContain("hooks");
});
Expand Down
41 changes: 29 additions & 12 deletions src/agents/hook-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { getAgent } from "./registry.js";
import type { HookDeclaration, HookConfigSpec } from "./types.js";
import type { HookConfig } from "../config/schema.js";

export interface HookResolvedTarget {
filePath: string;
shared: boolean;
}

export type HookTargetResolver = (agentId: string, spec: HookConfigSpec) => HookResolvedTarget;

/**
* Convert HookConfig entries (from agents.toml) to universal HookDeclarations.
*/
Expand All @@ -16,6 +23,16 @@ export function toHookDeclarations(configs: HookConfig[]): HookDeclaration[] {
}));
}

/**
* Convenience resolver for project-scope hooks: joins spec.filePath with projectRoot.
*/
export function projectHookResolver(projectRoot: string): HookTargetResolver {
return (_id: string, spec: HookConfigSpec) => ({
filePath: join(projectRoot, spec.filePath),
shared: spec.shared,
});
}

export interface HookWriteWarning {
agent: string;
message: string;
Expand All @@ -28,9 +45,9 @@ export interface HookWriteWarning {
* - Agents that don't support hooks: collected as warnings.
*/
export async function writeHookConfigs(
projectRoot: string,
agentIds: string[],
hooks: HookDeclaration[],
resolveTarget: HookTargetResolver,
): Promise<HookWriteWarning[]> {
const warnings: HookWriteWarning[] = [];
if (hooks.length === 0) return warnings;
Expand All @@ -48,13 +65,13 @@ export async function writeHookConfigs(

const serialized = agent.serializeHooks(hooks);
const spec = agent.hooks;
if (seen.has(spec.filePath)) continue;
seen.add(spec.filePath);
const { filePath, shared } = resolveTarget(id, spec);
if (seen.has(filePath)) continue;
seen.add(filePath);

const filePath = join(projectRoot, spec.filePath);
await mkdir(dirname(filePath), { recursive: true });

if (spec.shared) {
if (shared) {
await mergeWrite(filePath, spec, serialized);
} else {
await freshWrite(filePath, spec, serialized);
Expand All @@ -69,9 +86,9 @@ export async function writeHookConfigs(
* Returns a list of issues found.
*/
export async function verifyHookConfigs(
projectRoot: string,
agentIds: string[],
hooks: HookDeclaration[],
resolveTarget: HookTargetResolver,
): Promise<{ agent: string; issue: string }[]> {
if (hooks.length === 0) return [];

Expand All @@ -86,23 +103,23 @@ export async function verifyHookConfigs(
if (!agent.hooks) continue;

const spec = agent.hooks;
if (seen.has(spec.filePath)) continue;
seen.add(spec.filePath);
const { filePath } = resolveTarget(id, spec);
if (seen.has(filePath)) continue;
seen.add(filePath);

const filePath = join(projectRoot, spec.filePath);
if (!existsSync(filePath)) {
issues.push({ agent: id, issue: `Hook config missing: ${spec.filePath}` });
issues.push({ agent: id, issue: `Hook config missing: ${filePath}` });
continue;
}

try {
const existing = await readExisting(filePath);
const hooksSection = existing[spec.rootKey];
if (!hooksSection || typeof hooksSection !== "object") {
issues.push({ agent: id, issue: `Hook config missing "${spec.rootKey}" key in ${spec.filePath}` });
issues.push({ agent: id, issue: `Hook config missing "${spec.rootKey}" key in ${filePath}` });
}
} catch {
issues.push({ agent: id, issue: `Failed to read hook config: ${spec.filePath}` });
issues.push({ agent: id, issue: `Failed to read hook config: ${filePath}` });
}
}

Expand Down
8 changes: 6 additions & 2 deletions src/agents/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
export { getAgent, allAgentIds } from "./registry.js";
export { writeMcpConfigs, verifyMcpConfigs, toMcpDeclarations } from "./mcp-writer.js";
export { writeHookConfigs, verifyHookConfigs, toHookDeclarations } from "./hook-writer.js";
export { writeMcpConfigs, verifyMcpConfigs, toMcpDeclarations, projectMcpResolver } from "./mcp-writer.js";
export type { McpTargetResolver, McpResolvedTarget } from "./mcp-writer.js";
export { writeHookConfigs, verifyHookConfigs, toHookDeclarations, projectHookResolver } from "./hook-writer.js";
export type { HookTargetResolver, HookResolvedTarget } from "./hook-writer.js";
export { UnsupportedFeature } from "./errors.js";
export { getUserMcpTarget, userMcpResolver } from "./paths.js";
export type { UserMcpTarget } from "./paths.js";
export type {
AgentDefinition,
McpDeclaration,
Expand Down
Loading