mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-15 14:03:49 +00:00
- Rename all package names from companion-* to clanker-* - Update npm scopes from @mariozechner to @harivansh-afk - Rename config directories .companion -> .clanker - Rename environment variables COMPANION_* -> CLANKER_* - Update all documentation, README files, and install scripts - Rename package directories (companion-channels, companion-grind, companion-teams) - Update GitHub URLs to harivansh-afk/clanker-agent - Preserve full git history from companion-cloud monorepo
570 lines
18 KiB
TypeScript
570 lines
18 KiB
TypeScript
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { AuthStorage } from "../src/core/auth-storage.js";
|
|
import { ExtensionRunner } from "../src/core/extensions/runner.js";
|
|
import { ModelRegistry } from "../src/core/model-registry.js";
|
|
import { DefaultResourceLoader } from "../src/core/resource-loader.js";
|
|
import { SessionManager } from "../src/core/session-manager.js";
|
|
import { SettingsManager } from "../src/core/settings-manager.js";
|
|
import type { Skill } from "../src/core/skills.js";
|
|
|
|
describe("DefaultResourceLoader", () => {
|
|
let tempDir: string;
|
|
let agentDir: string;
|
|
let cwd: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = join(
|
|
tmpdir(),
|
|
`rl-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
);
|
|
agentDir = join(tempDir, "agent");
|
|
cwd = join(tempDir, "project");
|
|
mkdirSync(agentDir, { recursive: true });
|
|
mkdirSync(cwd, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("reload", () => {
|
|
it("should initialize with empty results before reload", () => {
|
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
|
|
|
expect(loader.getExtensions().extensions).toEqual([]);
|
|
expect(loader.getSkills().skills).toEqual([]);
|
|
expect(loader.getPrompts().prompts).toEqual([]);
|
|
expect(loader.getThemes().themes).toEqual([]);
|
|
});
|
|
|
|
it("should discover skills from agentDir", async () => {
|
|
const skillsDir = join(agentDir, "skills");
|
|
mkdirSync(skillsDir, { recursive: true });
|
|
writeFileSync(
|
|
join(skillsDir, "test-skill.md"),
|
|
`---
|
|
name: test-skill
|
|
description: A test skill
|
|
---
|
|
Skill content here.`,
|
|
);
|
|
|
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
|
await loader.reload();
|
|
|
|
const { skills } = loader.getSkills();
|
|
expect(skills.some((s) => s.name === "test-skill")).toBe(true);
|
|
});
|
|
|
|
it("should ignore extra markdown files in auto-discovered skill dirs", async () => {
|
|
const skillDir = join(agentDir, "skills", "clanker-skills", "browser-tools");
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(
|
|
join(skillDir, "SKILL.md"),
|
|
`---
|
|
name: browser-tools
|
|
description: Browser tools
|
|
---
|
|
Skill content here.`,
|
|
);
|
|
writeFileSync(join(skillDir, "EFFICIENCY.md"), "No frontmatter here");
|
|
|
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
|
await loader.reload();
|
|
|
|
const { skills, diagnostics } = loader.getSkills();
|
|
expect(skills.some((s) => s.name === "browser-tools")).toBe(true);
|
|
expect(diagnostics.some((d) => d.path?.endsWith("EFFICIENCY.md"))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("should discover prompts from agentDir", async () => {
|
|
const promptsDir = join(agentDir, "prompts");
|
|
mkdirSync(promptsDir, { recursive: true });
|
|
writeFileSync(
|
|
join(promptsDir, "test-prompt.md"),
|
|
`---
|
|
description: A test prompt
|
|
---
|
|
Prompt content.`,
|
|
);
|
|
|
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
|
await loader.reload();
|
|
|
|
const { prompts } = loader.getPrompts();
|
|
expect(prompts.some((p) => p.name === "test-prompt")).toBe(true);
|
|
});
|
|
|
|
it("should prefer project resources over user on name collisions", async () => {
|
|
const userPromptsDir = join(agentDir, "prompts");
|
|
const projectPromptsDir = join(cwd, ".clanker", "prompts");
|
|
mkdirSync(userPromptsDir, { recursive: true });
|
|
mkdirSync(projectPromptsDir, { recursive: true });
|
|
const userPromptPath = join(userPromptsDir, "commit.md");
|
|
const projectPromptPath = join(projectPromptsDir, "commit.md");
|
|
writeFileSync(userPromptPath, "User prompt");
|
|
writeFileSync(projectPromptPath, "Project prompt");
|
|
|
|
const userSkillDir = join(agentDir, "skills", "collision-skill");
|
|
const projectSkillDir = join(cwd, ".clanker", "skills", "collision-skill");
|
|
mkdirSync(userSkillDir, { recursive: true });
|
|
mkdirSync(projectSkillDir, { recursive: true });
|
|
const userSkillPath = join(userSkillDir, "SKILL.md");
|
|
const projectSkillPath = join(projectSkillDir, "SKILL.md");
|
|
writeFileSync(
|
|
userSkillPath,
|
|
`---
|
|
name: collision-skill
|
|
description: user
|
|
---
|
|
User skill`,
|
|
);
|
|
writeFileSync(
|
|
projectSkillPath,
|
|
`---
|
|
name: collision-skill
|
|
description: project
|
|
---
|
|
Project skill`,
|
|
);
|
|
|
|
const baseTheme = JSON.parse(
|
|
readFileSync(
|
|
join(
|
|
process.cwd(),
|
|
"src",
|
|
"modes",
|
|
"interactive",
|
|
"theme",
|
|
"dark.json",
|
|
),
|
|
"utf-8",
|
|
),
|
|
) as { name: string; vars?: Record<string, string> };
|
|
baseTheme.name = "collision-theme";
|
|
const userThemePath = join(agentDir, "themes", "collision.json");
|
|
const projectThemePath = join(cwd, ".clanker", "themes", "collision.json");
|
|
mkdirSync(join(agentDir, "themes"), { recursive: true });
|
|
mkdirSync(join(cwd, ".clanker", "themes"), { recursive: true });
|
|
writeFileSync(userThemePath, JSON.stringify(baseTheme, null, 2));
|
|
if (baseTheme.vars) {
|
|
baseTheme.vars.accent = "#ff00ff";
|
|
}
|
|
writeFileSync(projectThemePath, JSON.stringify(baseTheme, null, 2));
|
|
|
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
|
await loader.reload();
|
|
|
|
const prompt = loader
|
|
.getPrompts()
|
|
.prompts.find((p) => p.name === "commit");
|
|
expect(prompt?.filePath).toBe(projectPromptPath);
|
|
|
|
const skill = loader
|
|
.getSkills()
|
|
.skills.find((s) => s.name === "collision-skill");
|
|
expect(skill?.filePath).toBe(projectSkillPath);
|
|
|
|
const theme = loader
|
|
.getThemes()
|
|
.themes.find((t) => t.name === "collision-theme");
|
|
expect(theme?.sourcePath).toBe(projectThemePath);
|
|
});
|
|
|
|
it("should keep both extensions loaded when command names collide", async () => {
|
|
const userExtDir = join(agentDir, "extensions");
|
|
const projectExtDir = join(cwd, ".clanker", "extensions");
|
|
mkdirSync(userExtDir, { recursive: true });
|
|
mkdirSync(projectExtDir, { recursive: true });
|
|
|
|
writeFileSync(
|
|
join(projectExtDir, "project.ts"),
|
|
`export default function(clanker) {
|
|
clanker.registerCommand("deploy", {
|
|
description: "project deploy",
|
|
handler: async () => {},
|
|
});
|
|
clanker.registerCommand("project-only", {
|
|
description: "project only",
|
|
handler: async () => {},
|
|
});
|
|
}`,
|
|
);
|
|
|
|
writeFileSync(
|
|
join(userExtDir, "user.ts"),
|
|
`export default function(clanker) {
|
|
clanker.registerCommand("deploy", {
|
|
description: "user deploy",
|
|
handler: async () => {},
|
|
});
|
|
clanker.registerCommand("user-only", {
|
|
description: "user only",
|
|
handler: async () => {},
|
|
});
|
|
}`,
|
|
);
|
|
|
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
|
await loader.reload();
|
|
|
|
const extensionsResult = loader.getExtensions();
|
|
expect(extensionsResult.extensions).toHaveLength(2);
|
|
expect(
|
|
extensionsResult.errors.some((e) =>
|
|
e.error.includes('Command "/deploy" conflicts'),
|
|
),
|
|
).toBe(true);
|
|
|
|
const sessionManager = SessionManager.inMemory();
|
|
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
|
const modelRegistry = new ModelRegistry(authStorage);
|
|
const runner = new ExtensionRunner(
|
|
extensionsResult.extensions,
|
|
extensionsResult.runtime,
|
|
cwd,
|
|
sessionManager,
|
|
modelRegistry,
|
|
);
|
|
|
|
expect(runner.getCommand("deploy")?.description).toBe("project deploy");
|
|
expect(runner.getCommand("project-only")?.description).toBe(
|
|
"project only",
|
|
);
|
|
expect(runner.getCommand("user-only")?.description).toBe("user only");
|
|
|
|
const commandNames = runner.getRegisteredCommands().map((c) => c.name);
|
|
expect(commandNames.filter((name) => name === "deploy")).toHaveLength(1);
|
|
});
|
|
|
|
it("should honor overrides for auto-discovered resources", async () => {
|
|
const settingsManager = SettingsManager.inMemory();
|
|
settingsManager.setExtensionPaths(["-extensions/disabled.ts"]);
|
|
settingsManager.setSkillPaths(["-skills/skip-skill"]);
|
|
settingsManager.setPromptTemplatePaths(["-prompts/skip.md"]);
|
|
settingsManager.setThemePaths(["-themes/skip.json"]);
|
|
|
|
const extensionsDir = join(agentDir, "extensions");
|
|
mkdirSync(extensionsDir, { recursive: true });
|
|
writeFileSync(
|
|
join(extensionsDir, "disabled.ts"),
|
|
"export default function() {}",
|
|
);
|
|
|
|
const skillDir = join(agentDir, "skills", "skip-skill");
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(
|
|
join(skillDir, "SKILL.md"),
|
|
`---
|
|
name: skip-skill
|
|
description: Skip me
|
|
---
|
|
Content`,
|
|
);
|
|
|
|
const promptsDir = join(agentDir, "prompts");
|
|
mkdirSync(promptsDir, { recursive: true });
|
|
writeFileSync(join(promptsDir, "skip.md"), "Skip prompt");
|
|
|
|
const themesDir = join(agentDir, "themes");
|
|
mkdirSync(themesDir, { recursive: true });
|
|
writeFileSync(join(themesDir, "skip.json"), "{}");
|
|
|
|
const loader = new DefaultResourceLoader({
|
|
cwd,
|
|
agentDir,
|
|
settingsManager,
|
|
});
|
|
await loader.reload();
|
|
|
|
const { extensions } = loader.getExtensions();
|
|
const { skills } = loader.getSkills();
|
|
const { prompts } = loader.getPrompts();
|
|
const { themes } = loader.getThemes();
|
|
|
|
expect(extensions.some((e) => e.path.endsWith("disabled.ts"))).toBe(
|
|
false,
|
|
);
|
|
expect(skills.some((s) => s.name === "skip-skill")).toBe(false);
|
|
expect(prompts.some((p) => p.name === "skip")).toBe(false);
|
|
expect(themes.some((t) => t.sourcePath?.endsWith("skip.json"))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("should discover AGENTS.md context files", async () => {
|
|
writeFileSync(
|
|
join(cwd, "AGENTS.md"),
|
|
"# Project Guidelines\n\nBe helpful.",
|
|
);
|
|
|
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
|
await loader.reload();
|
|
|
|
const { agentsFiles } = loader.getAgentsFiles();
|
|
expect(agentsFiles.some((f) => f.path.includes("AGENTS.md"))).toBe(true);
|
|
});
|
|
|
|
it("should discover SOUL.md from the project root", async () => {
|
|
writeFileSync(join(cwd, "SOUL.md"), "# Soul\n\nBe less corporate.");
|
|
|
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
|
await loader.reload();
|
|
|
|
const { agentsFiles } = loader.getAgentsFiles();
|
|
expect(agentsFiles.some((f) => f.path.endsWith("SOUL.md"))).toBe(true);
|
|
});
|
|
|
|
it("should discover clanker context files from the default workspace", async () => {
|
|
const workspaceDir = join(tempDir, "workspace");
|
|
const appDir = join(tempDir, "apps", "todo-app");
|
|
mkdirSync(workspaceDir, { recursive: true });
|
|
mkdirSync(appDir, { recursive: true });
|
|
writeFileSync(join(workspaceDir, "IDENTITY.md"), "# Identity\n\nPi");
|
|
writeFileSync(join(workspaceDir, "TOOLS.md"), "# Tools\n\nUse ~/.clanker");
|
|
|
|
const loader = new DefaultResourceLoader({ cwd: appDir, agentDir });
|
|
await loader.reload();
|
|
|
|
const { agentsFiles } = loader.getAgentsFiles();
|
|
expect(agentsFiles.some((f) => f.path.endsWith("IDENTITY.md"))).toBe(
|
|
true,
|
|
);
|
|
expect(agentsFiles.some((f) => f.path.endsWith("TOOLS.md"))).toBe(true);
|
|
});
|
|
|
|
it("should discover SYSTEM.md from cwd/.clanker", async () => {
|
|
const piDir = join(cwd, ".clanker");
|
|
mkdirSync(piDir, { recursive: true });
|
|
writeFileSync(join(piDir, "SYSTEM.md"), "You are a helpful assistant.");
|
|
|
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
|
await loader.reload();
|
|
|
|
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
|
|
});
|
|
|
|
it("should discover APPEND_SYSTEM.md", async () => {
|
|
const piDir = join(cwd, ".clanker");
|
|
mkdirSync(piDir, { recursive: true });
|
|
writeFileSync(
|
|
join(piDir, "APPEND_SYSTEM.md"),
|
|
"Additional instructions.",
|
|
);
|
|
|
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
|
await loader.reload();
|
|
|
|
expect(loader.getAppendSystemPrompt()).toContain(
|
|
"Additional instructions.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("extendResources", () => {
|
|
it("should load skills and prompts with extension metadata", async () => {
|
|
const extraSkillDir = join(tempDir, "extra-skills", "extra-skill");
|
|
mkdirSync(extraSkillDir, { recursive: true });
|
|
const skillPath = join(extraSkillDir, "SKILL.md");
|
|
writeFileSync(
|
|
skillPath,
|
|
`---
|
|
name: extra-skill
|
|
description: Extra skill
|
|
---
|
|
Extra content`,
|
|
);
|
|
|
|
const extraPromptDir = join(tempDir, "extra-prompts");
|
|
mkdirSync(extraPromptDir, { recursive: true });
|
|
const promptPath = join(extraPromptDir, "extra.md");
|
|
writeFileSync(
|
|
promptPath,
|
|
`---
|
|
description: Extra prompt
|
|
---
|
|
Extra prompt content`,
|
|
);
|
|
|
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
|
await loader.reload();
|
|
|
|
loader.extendResources({
|
|
skillPaths: [
|
|
{
|
|
path: extraSkillDir,
|
|
metadata: {
|
|
source: "extension:extra",
|
|
scope: "temporary",
|
|
origin: "top-level",
|
|
baseDir: extraSkillDir,
|
|
},
|
|
},
|
|
],
|
|
promptPaths: [
|
|
{
|
|
path: promptPath,
|
|
metadata: {
|
|
source: "extension:extra",
|
|
scope: "temporary",
|
|
origin: "top-level",
|
|
baseDir: extraPromptDir,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const { skills } = loader.getSkills();
|
|
expect(skills.some((skill) => skill.name === "extra-skill")).toBe(true);
|
|
|
|
const { prompts } = loader.getPrompts();
|
|
expect(prompts.some((prompt) => prompt.name === "extra")).toBe(true);
|
|
|
|
const metadata = loader.getPathMetadata();
|
|
expect(metadata.get(skillPath)?.source).toBe("extension:extra");
|
|
expect(metadata.get(promptPath)?.source).toBe("extension:extra");
|
|
});
|
|
});
|
|
|
|
describe("noSkills option", () => {
|
|
it("should skip skill discovery when noSkills is true", async () => {
|
|
const skillsDir = join(agentDir, "skills");
|
|
mkdirSync(skillsDir, { recursive: true });
|
|
writeFileSync(
|
|
join(skillsDir, "test-skill.md"),
|
|
`---
|
|
name: test-skill
|
|
description: A test skill
|
|
---
|
|
Content`,
|
|
);
|
|
|
|
const loader = new DefaultResourceLoader({
|
|
cwd,
|
|
agentDir,
|
|
noSkills: true,
|
|
});
|
|
await loader.reload();
|
|
|
|
const { skills } = loader.getSkills();
|
|
expect(skills).toEqual([]);
|
|
});
|
|
|
|
it("should still load additional skill paths when noSkills is true", async () => {
|
|
const customSkillDir = join(tempDir, "custom-skills");
|
|
mkdirSync(customSkillDir, { recursive: true });
|
|
writeFileSync(
|
|
join(customSkillDir, "custom.md"),
|
|
`---
|
|
name: custom
|
|
description: Custom skill
|
|
---
|
|
Content`,
|
|
);
|
|
|
|
const loader = new DefaultResourceLoader({
|
|
cwd,
|
|
agentDir,
|
|
noSkills: true,
|
|
additionalSkillPaths: [customSkillDir],
|
|
});
|
|
await loader.reload();
|
|
|
|
const { skills } = loader.getSkills();
|
|
expect(skills.some((s) => s.name === "custom")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("override functions", () => {
|
|
it("should apply skillsOverride", async () => {
|
|
const injectedSkill: Skill = {
|
|
name: "injected",
|
|
description: "Injected skill",
|
|
filePath: "/fake/path",
|
|
baseDir: "/fake",
|
|
source: "custom",
|
|
disableModelInvocation: false,
|
|
};
|
|
const loader = new DefaultResourceLoader({
|
|
cwd,
|
|
agentDir,
|
|
skillsOverride: () => ({
|
|
skills: [injectedSkill],
|
|
diagnostics: [],
|
|
}),
|
|
});
|
|
await loader.reload();
|
|
|
|
const { skills } = loader.getSkills();
|
|
expect(skills).toHaveLength(1);
|
|
expect(skills[0].name).toBe("injected");
|
|
});
|
|
|
|
it("should apply systemPromptOverride", async () => {
|
|
const loader = new DefaultResourceLoader({
|
|
cwd,
|
|
agentDir,
|
|
systemPromptOverride: () => "Custom system prompt",
|
|
});
|
|
await loader.reload();
|
|
|
|
expect(loader.getSystemPrompt()).toBe("Custom system prompt");
|
|
});
|
|
});
|
|
|
|
describe("extension conflict detection", () => {
|
|
it("should detect tool conflicts between extensions", async () => {
|
|
// Create two extensions that register the same tool
|
|
const ext1Dir = join(agentDir, "extensions", "ext1");
|
|
const ext2Dir = join(agentDir, "extensions", "ext2");
|
|
mkdirSync(ext1Dir, { recursive: true });
|
|
mkdirSync(ext2Dir, { recursive: true });
|
|
|
|
writeFileSync(
|
|
join(ext1Dir, "index.ts"),
|
|
`
|
|
import type { ExtensionAPI } from "@mariozechner/clanker-coding-agent";
|
|
import { Type } from "@sinclair/typebox";
|
|
export default function(clanker: ExtensionAPI) {
|
|
clanker.registerTool({
|
|
name: "duplicate-tool",
|
|
description: "First",
|
|
parameters: Type.Object({}),
|
|
execute: async () => ({ result: "1" }),
|
|
});
|
|
}`,
|
|
);
|
|
|
|
writeFileSync(
|
|
join(ext2Dir, "index.ts"),
|
|
`
|
|
import type { ExtensionAPI } from "@mariozechner/clanker-coding-agent";
|
|
import { Type } from "@sinclair/typebox";
|
|
export default function(clanker: ExtensionAPI) {
|
|
clanker.registerTool({
|
|
name: "duplicate-tool",
|
|
description: "Second",
|
|
parameters: Type.Object({}),
|
|
execute: async () => ({ result: "2" }),
|
|
});
|
|
}`,
|
|
);
|
|
|
|
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
|
await loader.reload();
|
|
|
|
const { errors } = loader.getExtensions();
|
|
expect(
|
|
errors.some(
|
|
(e) =>
|
|
e.error.includes("duplicate-tool") && e.error.includes("conflicts"),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
});
|