2026-03-08 13:50:32 -04:00
|
|
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
|
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
|
|
|
import { join } from "path";
|
|
|
|
|
import { tmpdir } from "os";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
loadUserAgents,
|
|
|
|
|
loadProjectAgents,
|
|
|
|
|
loadOpencodeGlobalAgents,
|
|
|
|
|
loadOpencodeProjectAgents,
|
|
|
|
|
} from "./loader";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates a temporary directory tree for testing agent loading.
|
|
|
|
|
* Returns the root dir with `.claude/agents/` and `.opencode/agents/` subdirs
|
|
|
|
|
* pre-created, containing the specified agent files.
|
|
|
|
|
*/
|
|
|
|
|
function createProjectWithAgents(
|
|
|
|
|
agents: {
|
|
|
|
|
claudeAgents?: Array<{ filename: string; content: string }>;
|
|
|
|
|
opencodeAgents?: Array<{ filename: string; content: string }>;
|
|
|
|
|
} = {},
|
|
|
|
|
): string {
|
|
|
|
|
const root = mkdtempSync(join(tmpdir(), "agent-loader-test-"));
|
|
|
|
|
if (agents.claudeAgents) {
|
|
|
|
|
const dir = join(root, ".claude", "agents");
|
|
|
|
|
mkdirSync(dir, { recursive: true });
|
|
|
|
|
for (const { filename, content } of agents.claudeAgents) {
|
|
|
|
|
writeFileSync(join(dir, filename), content, "utf-8");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (agents.opencodeAgents) {
|
|
|
|
|
const dir = join(root, ".opencode", "agents");
|
|
|
|
|
mkdirSync(dir, { recursive: true });
|
|
|
|
|
for (const { filename, content } of agents.opencodeAgents) {
|
|
|
|
|
writeFileSync(join(dir, filename), content, "utf-8");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return root;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BASIC_AGENT = `---
|
|
|
|
|
name: test-agent
|
|
|
|
|
description: A test agent
|
|
|
|
|
tools: Bash,Read
|
|
|
|
|
---
|
|
|
|
|
You are a test agent.`;
|
|
|
|
|
|
|
|
|
|
const MINIMAL_AGENT = `---
|
|
|
|
|
description: Minimal agent
|
|
|
|
|
---
|
|
|
|
|
Do minimal things.`;
|
|
|
|
|
|
|
|
|
|
const NO_FRONTMATTER_AGENT = `Just a prompt with no frontmatter.`;
|
|
|
|
|
|
|
|
|
|
describe("claude-code-agent-loader", () => {
|
|
|
|
|
const dirs: string[] = [];
|
2026-05-09 00:26:45 +08:00
|
|
|
const originalEnv = {
|
|
|
|
|
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
|
|
|
|
|
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
|
|
|
|
}
|
2026-03-08 13:50:32 -04:00
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
for (const dir of dirs) {
|
|
|
|
|
rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
dirs.length = 0;
|
2026-05-09 00:26:45 +08:00
|
|
|
if (originalEnv.OPENCODE_CONFIG_DIR === undefined) {
|
|
|
|
|
delete process.env.OPENCODE_CONFIG_DIR
|
|
|
|
|
} else {
|
|
|
|
|
process.env.OPENCODE_CONFIG_DIR = originalEnv.OPENCODE_CONFIG_DIR
|
|
|
|
|
}
|
|
|
|
|
if (originalEnv.XDG_CONFIG_HOME === undefined) {
|
|
|
|
|
delete process.env.XDG_CONFIG_HOME
|
|
|
|
|
} else {
|
|
|
|
|
process.env.XDG_CONFIG_HOME = originalEnv.XDG_CONFIG_HOME
|
|
|
|
|
}
|
2026-03-08 13:50:32 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function trackDir(dir: string): string {
|
|
|
|
|
dirs.push(dir);
|
|
|
|
|
return dir;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("loadProjectAgents", () => {
|
|
|
|
|
test("loads agents from <directory>/.claude/agents", () => {
|
|
|
|
|
const root = trackDir(
|
|
|
|
|
createProjectWithAgents({
|
|
|
|
|
claudeAgents: [{ filename: "my-agent.md", content: BASIC_AGENT }],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = loadProjectAgents(root);
|
|
|
|
|
|
|
|
|
|
expect(Object.keys(result)).toEqual(["test-agent"]);
|
|
|
|
|
expect(result["test-agent"].description).toBe("(project) A test agent");
|
|
|
|
|
expect(result["test-agent"].mode).toBe("subagent");
|
|
|
|
|
expect(result["test-agent"].prompt).toBe("You are a test agent.");
|
|
|
|
|
expect(result["test-agent"].tools).toEqual({ bash: true, read: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("uses filename as agent name when frontmatter name is absent", () => {
|
|
|
|
|
const root = trackDir(
|
|
|
|
|
createProjectWithAgents({
|
|
|
|
|
claudeAgents: [
|
|
|
|
|
{ filename: "fallback-name.md", content: MINIMAL_AGENT },
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = loadProjectAgents(root);
|
|
|
|
|
|
|
|
|
|
expect(Object.keys(result)).toEqual(["fallback-name"]);
|
|
|
|
|
expect(result["fallback-name"].description).toBe(
|
|
|
|
|
"(project) Minimal agent",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("handles agent with no frontmatter", () => {
|
|
|
|
|
const root = trackDir(
|
|
|
|
|
createProjectWithAgents({
|
|
|
|
|
claudeAgents: [{ filename: "raw.md", content: NO_FRONTMATTER_AGENT }],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = loadProjectAgents(root);
|
|
|
|
|
|
|
|
|
|
expect(Object.keys(result)).toEqual(["raw"]);
|
|
|
|
|
expect(result["raw"].prompt).toBe("Just a prompt with no frontmatter.");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("returns empty object when project has no .claude/agents directory", () => {
|
|
|
|
|
const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-test-")));
|
|
|
|
|
|
|
|
|
|
const result = loadProjectAgents(root);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("ignores non-markdown files", () => {
|
|
|
|
|
const root = trackDir(
|
|
|
|
|
createProjectWithAgents({
|
|
|
|
|
claudeAgents: [
|
|
|
|
|
{ filename: "good.md", content: BASIC_AGENT },
|
|
|
|
|
{ filename: "bad.txt", content: "not a markdown file" },
|
|
|
|
|
{ filename: "also-bad.json", content: "{}" },
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = loadProjectAgents(root);
|
|
|
|
|
|
|
|
|
|
expect(Object.keys(result)).toEqual(["test-agent"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("loads multiple agents", () => {
|
|
|
|
|
const root = trackDir(
|
|
|
|
|
createProjectWithAgents({
|
|
|
|
|
claudeAgents: [
|
|
|
|
|
{ filename: "agent-a.md", content: BASIC_AGENT },
|
|
|
|
|
{
|
|
|
|
|
filename: "agent-b.md",
|
|
|
|
|
content: `---\nname: second-agent\ndescription: Another agent\n---\nDo other things.`,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = loadProjectAgents(root);
|
|
|
|
|
|
|
|
|
|
expect(Object.keys(result).sort()).toEqual([
|
|
|
|
|
"second-agent",
|
|
|
|
|
"test-agent",
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("loadOpencodeProjectAgents", () => {
|
|
|
|
|
test("loads agents from <directory>/.opencode/agents", () => {
|
|
|
|
|
const root = trackDir(
|
|
|
|
|
createProjectWithAgents({
|
|
|
|
|
opencodeAgents: [{ filename: "oc-agent.md", content: BASIC_AGENT }],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = loadOpencodeProjectAgents(root);
|
|
|
|
|
|
|
|
|
|
expect(Object.keys(result)).toEqual(["test-agent"]);
|
|
|
|
|
expect(result["test-agent"].description).toBe(
|
|
|
|
|
"(opencode-project) A test agent",
|
|
|
|
|
);
|
|
|
|
|
expect(result["test-agent"].mode).toBe("subagent");
|
|
|
|
|
expect(result["test-agent"].prompt).toBe("You are a test agent.");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("returns empty object when project has no .opencode/agents directory", () => {
|
|
|
|
|
const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-test-")));
|
|
|
|
|
|
|
|
|
|
const result = loadOpencodeProjectAgents(root);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("loadUserAgents", () => {
|
|
|
|
|
test("returns empty object when pointed at dir without agents/", () => {
|
|
|
|
|
const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-test-")))
|
2026-05-04 20:11:50 +09:00
|
|
|
process.env.CLAUDE_CONFIG_DIR = root
|
|
|
|
|
const result = loadUserAgents()
|
|
|
|
|
expect(result).toEqual({})
|
2026-03-08 13:50:32 -04:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("loadOpencodeGlobalAgents", () => {
|
|
|
|
|
test("returns empty object when pointed at dir without agents/", () => {
|
|
|
|
|
const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-test-")))
|
2026-05-04 20:11:50 +09:00
|
|
|
process.env.OPENCODE_CONFIG_DIR = root
|
|
|
|
|
const result = loadOpencodeGlobalAgents()
|
|
|
|
|
expect(result).toEqual({})
|
2026-03-08 13:50:32 -04:00
|
|
|
})
|
2026-05-09 00:26:45 +08:00
|
|
|
|
|
|
|
|
test("loads agents from both the custom and default opencode config directories", () => {
|
|
|
|
|
const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-opencode-global-")))
|
|
|
|
|
const defaultAgentsDir = join(root, "xdg", "opencode", "agents")
|
|
|
|
|
const customAgentsDir = join(root, "custom-opencode", "agents")
|
|
|
|
|
|
|
|
|
|
mkdirSync(defaultAgentsDir, { recursive: true })
|
|
|
|
|
mkdirSync(customAgentsDir, { recursive: true })
|
|
|
|
|
|
|
|
|
|
writeFileSync(join(defaultAgentsDir, "default-agent.md"), BASIC_AGENT, "utf-8")
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(customAgentsDir, "custom-agent.md"),
|
|
|
|
|
`---\nname: custom-agent\ndescription: Custom agent\n---\nFrom custom config.`,
|
|
|
|
|
"utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
process.env.XDG_CONFIG_HOME = join(root, "xdg")
|
|
|
|
|
process.env.OPENCODE_CONFIG_DIR = join(root, "custom-opencode")
|
|
|
|
|
|
|
|
|
|
const result = loadOpencodeGlobalAgents()
|
|
|
|
|
|
|
|
|
|
expect(Object.keys(result).sort()).toEqual(["custom-agent", "test-agent"])
|
|
|
|
|
})
|
2026-03-08 13:50:32 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("tools parsing", () => {
|
|
|
|
|
test("parses comma-separated tools into boolean record", () => {
|
|
|
|
|
const agentWithTools = `---\nname: tooled\ndescription: Has tools\ntools: Bash,Read,Edit\n---\nDo things.`;
|
|
|
|
|
const root = trackDir(
|
|
|
|
|
createProjectWithAgents({
|
|
|
|
|
claudeAgents: [{ filename: "tooled.md", content: agentWithTools }],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = loadProjectAgents(root);
|
|
|
|
|
|
|
|
|
|
expect(result["tooled"].tools).toEqual({
|
|
|
|
|
bash: true,
|
|
|
|
|
read: true,
|
|
|
|
|
edit: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("omits tools when frontmatter tools field is absent", () => {
|
|
|
|
|
const agentNoTools = `---\nname: no-tools\ndescription: No tools\n---\nDo things.`;
|
|
|
|
|
const root = trackDir(
|
|
|
|
|
createProjectWithAgents({
|
|
|
|
|
claudeAgents: [{ filename: "no-tools.md", content: agentNoTools }],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = loadProjectAgents(root);
|
|
|
|
|
|
|
|
|
|
expect(result["no-tools"].tools).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("scope labeling", () => {
|
|
|
|
|
test("project and opencode-project loaders apply correct scope prefixes", () => {
|
|
|
|
|
const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-scope-")))
|
|
|
|
|
const content = `---\nname: scoped\ndescription: Scoped agent\n---\nPrompt.`
|
|
|
|
|
|
|
|
|
|
const claudeProjectDir = join(root, "project", ".claude", "agents")
|
|
|
|
|
const ocProjectDir = join(root, "project", ".opencode", "agents")
|
|
|
|
|
|
|
|
|
|
mkdirSync(claudeProjectDir, { recursive: true })
|
|
|
|
|
mkdirSync(ocProjectDir, { recursive: true })
|
|
|
|
|
|
|
|
|
|
writeFileSync(join(claudeProjectDir, "a.md"), content, "utf-8")
|
|
|
|
|
writeFileSync(join(ocProjectDir, "a.md"), content, "utf-8")
|
|
|
|
|
|
|
|
|
|
const project = loadProjectAgents(join(root, "project"))
|
|
|
|
|
const ocProject = loadOpencodeProjectAgents(join(root, "project"))
|
|
|
|
|
|
|
|
|
|
expect(project["scoped"].description).toBe("(project) Scoped agent")
|
|
|
|
|
expect(ocProject["scoped"].description).toBe("(opencode-project) Scoped agent")
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
});
|