mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-15 08:03:42 +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
539 lines
18 KiB
TypeScript
539 lines
18 KiB
TypeScript
import * as fs from "node:fs";
|
||
import * as os from "node:os";
|
||
import * as path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||
import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js";
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
|
||
describe("extensions discovery", () => {
|
||
let tempDir: string;
|
||
let extensionsDir: string;
|
||
|
||
beforeEach(() => {
|
||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "clanker-ext-test-"));
|
||
extensionsDir = path.join(tempDir, "extensions");
|
||
fs.mkdirSync(extensionsDir);
|
||
});
|
||
|
||
afterEach(() => {
|
||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
const extensionCode = `
|
||
export default function(clanker) {
|
||
clanker.registerCommand("test", { handler: async () => {} });
|
||
}
|
||
`;
|
||
|
||
const extensionCodeWithTool = (toolName: string) => `
|
||
import { Type } from "@sinclair/typebox";
|
||
export default function(clanker) {
|
||
clanker.registerTool({
|
||
name: "${toolName}",
|
||
label: "${toolName}",
|
||
description: "Test tool",
|
||
parameters: Type.Object({}),
|
||
execute: async () => ({ content: [{ type: "text", text: "ok" }] }),
|
||
});
|
||
}
|
||
`;
|
||
|
||
it("discovers direct .ts files in extensions/", async () => {
|
||
fs.writeFileSync(path.join(extensionsDir, "foo.ts"), extensionCode);
|
||
fs.writeFileSync(path.join(extensionsDir, "bar.ts"), extensionCode);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(2);
|
||
expect(result.extensions.map((e) => path.basename(e.path)).sort()).toEqual([
|
||
"bar.ts",
|
||
"foo.ts",
|
||
]);
|
||
});
|
||
|
||
it("discovers direct .js files in extensions/", async () => {
|
||
fs.writeFileSync(path.join(extensionsDir, "foo.js"), extensionCode);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(path.basename(result.extensions[0].path)).toBe("foo.js");
|
||
});
|
||
|
||
it("discovers subdirectory with index.ts", async () => {
|
||
const subdir = path.join(extensionsDir, "my-extension");
|
||
fs.mkdirSync(subdir);
|
||
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].path).toContain("my-extension");
|
||
expect(result.extensions[0].path).toContain("index.ts");
|
||
});
|
||
|
||
it("discovers subdirectory with index.js", async () => {
|
||
const subdir = path.join(extensionsDir, "my-extension");
|
||
fs.mkdirSync(subdir);
|
||
fs.writeFileSync(path.join(subdir, "index.js"), extensionCode);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].path).toContain("index.js");
|
||
});
|
||
|
||
it("prefers index.ts over index.js", async () => {
|
||
const subdir = path.join(extensionsDir, "my-extension");
|
||
fs.mkdirSync(subdir);
|
||
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode);
|
||
fs.writeFileSync(path.join(subdir, "index.js"), extensionCode);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].path).toContain("index.ts");
|
||
});
|
||
|
||
it("discovers subdirectory with package.json clanker field", async () => {
|
||
const subdir = path.join(extensionsDir, "my-package");
|
||
const srcDir = path.join(subdir, "src");
|
||
fs.mkdirSync(subdir);
|
||
fs.mkdirSync(srcDir);
|
||
fs.writeFileSync(path.join(srcDir, "main.ts"), extensionCode);
|
||
fs.writeFileSync(
|
||
path.join(subdir, "package.json"),
|
||
JSON.stringify({
|
||
name: "my-package",
|
||
clanker: {
|
||
extensions: ["./src/main.ts"],
|
||
},
|
||
}),
|
||
);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].path).toContain("src");
|
||
expect(result.extensions[0].path).toContain("main.ts");
|
||
});
|
||
|
||
it("package.json can declare multiple extensions", async () => {
|
||
const subdir = path.join(extensionsDir, "my-package");
|
||
fs.mkdirSync(subdir);
|
||
fs.writeFileSync(path.join(subdir, "ext1.ts"), extensionCode);
|
||
fs.writeFileSync(path.join(subdir, "ext2.ts"), extensionCode);
|
||
fs.writeFileSync(
|
||
path.join(subdir, "package.json"),
|
||
JSON.stringify({
|
||
name: "my-package",
|
||
clanker: {
|
||
extensions: ["./ext1.ts", "./ext2.ts"],
|
||
},
|
||
}),
|
||
);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(2);
|
||
});
|
||
|
||
it("package.json with clanker field takes precedence over index.ts", async () => {
|
||
const subdir = path.join(extensionsDir, "my-package");
|
||
fs.mkdirSync(subdir);
|
||
fs.writeFileSync(
|
||
path.join(subdir, "index.ts"),
|
||
extensionCodeWithTool("from-index"),
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(subdir, "custom.ts"),
|
||
extensionCodeWithTool("from-custom"),
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(subdir, "package.json"),
|
||
JSON.stringify({
|
||
name: "my-package",
|
||
clanker: {
|
||
extensions: ["./custom.ts"],
|
||
},
|
||
}),
|
||
);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].path).toContain("custom.ts");
|
||
// Verify the right tool was registered
|
||
expect(result.extensions[0].tools.has("from-custom")).toBe(true);
|
||
expect(result.extensions[0].tools.has("from-index")).toBe(false);
|
||
});
|
||
|
||
it("ignores package.json without clanker field, falls back to index.ts", async () => {
|
||
const subdir = path.join(extensionsDir, "my-package");
|
||
fs.mkdirSync(subdir);
|
||
fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode);
|
||
fs.writeFileSync(
|
||
path.join(subdir, "package.json"),
|
||
JSON.stringify({
|
||
name: "my-package",
|
||
version: "1.0.0",
|
||
}),
|
||
);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].path).toContain("index.ts");
|
||
});
|
||
|
||
it("ignores subdirectory without index or package.json", async () => {
|
||
const subdir = path.join(extensionsDir, "not-an-extension");
|
||
fs.mkdirSync(subdir);
|
||
fs.writeFileSync(path.join(subdir, "helper.ts"), extensionCode);
|
||
fs.writeFileSync(path.join(subdir, "utils.ts"), extensionCode);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(0);
|
||
});
|
||
|
||
it("does not recurse beyond one level", async () => {
|
||
const subdir = path.join(extensionsDir, "container");
|
||
const nested = path.join(subdir, "nested");
|
||
fs.mkdirSync(subdir);
|
||
fs.mkdirSync(nested);
|
||
fs.writeFileSync(path.join(nested, "index.ts"), extensionCode);
|
||
// No index.ts or package.json in container/
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(0);
|
||
});
|
||
|
||
it("handles mixed direct files and subdirectories", async () => {
|
||
// Direct file
|
||
fs.writeFileSync(path.join(extensionsDir, "direct.ts"), extensionCode);
|
||
|
||
// Subdirectory with index
|
||
const subdir1 = path.join(extensionsDir, "with-index");
|
||
fs.mkdirSync(subdir1);
|
||
fs.writeFileSync(path.join(subdir1, "index.ts"), extensionCode);
|
||
|
||
// Subdirectory with package.json
|
||
const subdir2 = path.join(extensionsDir, "with-manifest");
|
||
fs.mkdirSync(subdir2);
|
||
fs.writeFileSync(path.join(subdir2, "entry.ts"), extensionCode);
|
||
fs.writeFileSync(
|
||
path.join(subdir2, "package.json"),
|
||
JSON.stringify({ clanker: { extensions: ["./entry.ts"] } }),
|
||
);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(3);
|
||
});
|
||
|
||
it("skips non-existent paths declared in package.json", async () => {
|
||
const subdir = path.join(extensionsDir, "my-package");
|
||
fs.mkdirSync(subdir);
|
||
fs.writeFileSync(path.join(subdir, "exists.ts"), extensionCode);
|
||
fs.writeFileSync(
|
||
path.join(subdir, "package.json"),
|
||
JSON.stringify({
|
||
clanker: {
|
||
extensions: ["./exists.ts", "./missing.ts"],
|
||
},
|
||
}),
|
||
);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].path).toContain("exists.ts");
|
||
});
|
||
|
||
it("loads extensions and registers commands", async () => {
|
||
fs.writeFileSync(
|
||
path.join(extensionsDir, "with-command.ts"),
|
||
extensionCode,
|
||
);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].commands.has("test")).toBe(true);
|
||
});
|
||
|
||
it("loads extensions and registers tools", async () => {
|
||
fs.writeFileSync(
|
||
path.join(extensionsDir, "with-tool.ts"),
|
||
extensionCodeWithTool("my-tool"),
|
||
);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].tools.has("my-tool")).toBe(true);
|
||
});
|
||
|
||
it("reports errors for invalid extension code", async () => {
|
||
fs.writeFileSync(
|
||
path.join(extensionsDir, "invalid.ts"),
|
||
"this is not valid typescript export",
|
||
);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(1);
|
||
expect(result.errors[0].path).toContain("invalid.ts");
|
||
expect(result.extensions).toHaveLength(0);
|
||
});
|
||
|
||
it("handles explicitly configured paths", async () => {
|
||
const customPath = path.join(tempDir, "custom-location", "my-ext.ts");
|
||
fs.mkdirSync(path.dirname(customPath), { recursive: true });
|
||
fs.writeFileSync(customPath, extensionCode);
|
||
|
||
const result = await discoverAndLoadExtensions(
|
||
[customPath],
|
||
tempDir,
|
||
tempDir,
|
||
);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].path).toContain("my-ext.ts");
|
||
});
|
||
|
||
it("resolves dependencies from extension's own node_modules", async () => {
|
||
const extPath = path.join(tempDir, "custom-location", "with-deps");
|
||
const nodeModulesDir = path.join(extPath, "node_modules", "ms");
|
||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(extPath, "index.ts"),
|
||
`
|
||
import { Type } from "@sinclair/typebox";
|
||
import ms from "ms";
|
||
export default function(clanker) {
|
||
clanker.registerTool({
|
||
name: "parse_duration",
|
||
label: "parse_duration",
|
||
description: "Parse a duration string",
|
||
parameters: Type.Object({ value: Type.String() }),
|
||
execute: async (_toolCallId, params) => ({
|
||
content: [{ type: "text", text: String(ms(params.value)) }],
|
||
}),
|
||
});
|
||
}
|
||
`,
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(extPath, "package.json"),
|
||
JSON.stringify({
|
||
name: "with-deps",
|
||
type: "module",
|
||
}),
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(nodeModulesDir, "package.json"),
|
||
JSON.stringify({
|
||
name: "ms",
|
||
type: "module",
|
||
exports: "./index.js",
|
||
}),
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(nodeModulesDir, "index.js"),
|
||
`export default function ms(value) { return value === "1m" ? 60000 : 0; }`,
|
||
);
|
||
|
||
const result = await discoverAndLoadExtensions([extPath], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].path).toContain("with-deps");
|
||
// The extension registers a 'parse_duration' tool
|
||
expect(result.extensions[0].tools.has("parse_duration")).toBe(true);
|
||
});
|
||
|
||
it("registers message renderers", async () => {
|
||
const extCode = `
|
||
export default function(clanker) {
|
||
clanker.registerMessageRenderer("my-custom-type", (message, options, theme) => {
|
||
return null; // Use default rendering
|
||
});
|
||
}
|
||
`;
|
||
fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].messageRenderers.has("my-custom-type")).toBe(
|
||
true,
|
||
);
|
||
});
|
||
|
||
it("reports error when extension throws during initialization", async () => {
|
||
const extCode = `
|
||
export default function(clanker) {
|
||
throw new Error("Initialization failed!");
|
||
}
|
||
`;
|
||
fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(1);
|
||
expect(result.errors[0].error).toContain("Initialization failed!");
|
||
expect(result.extensions).toHaveLength(0);
|
||
});
|
||
|
||
it("reports error when extension has no default export", async () => {
|
||
const extCode = `
|
||
export function notDefault(clanker) {
|
||
clanker.registerCommand("test", { handler: async () => {} });
|
||
}
|
||
`;
|
||
fs.writeFileSync(path.join(extensionsDir, "no-default.ts"), extCode);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(1);
|
||
expect(result.errors[0].error).toContain(
|
||
"does not export a valid factory function",
|
||
);
|
||
expect(result.extensions).toHaveLength(0);
|
||
});
|
||
|
||
it("allows multiple extensions to register different tools", async () => {
|
||
fs.writeFileSync(
|
||
path.join(extensionsDir, "tool-a.ts"),
|
||
extensionCodeWithTool("tool-a"),
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(extensionsDir, "tool-b.ts"),
|
||
extensionCodeWithTool("tool-b"),
|
||
);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(2);
|
||
|
||
const allTools = new Set<string>();
|
||
for (const ext of result.extensions) {
|
||
for (const name of ext.tools.keys()) {
|
||
allTools.add(name);
|
||
}
|
||
}
|
||
expect(allTools.has("tool-a")).toBe(true);
|
||
expect(allTools.has("tool-b")).toBe(true);
|
||
});
|
||
|
||
it("loads extension with event handlers", async () => {
|
||
const extCode = `
|
||
export default function(clanker) {
|
||
clanker.on("agent_start", async () => {});
|
||
clanker.on("tool_call", async (event) => undefined);
|
||
clanker.on("agent_end", async () => {});
|
||
}
|
||
`;
|
||
fs.writeFileSync(path.join(extensionsDir, "with-handlers.ts"), extCode);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].handlers.has("agent_start")).toBe(true);
|
||
expect(result.extensions[0].handlers.has("tool_call")).toBe(true);
|
||
expect(result.extensions[0].handlers.has("agent_end")).toBe(true);
|
||
});
|
||
|
||
it("loads extension with shortcuts", async () => {
|
||
const extCode = `
|
||
export default function(clanker) {
|
||
clanker.registerShortcut("ctrl+t", {
|
||
description: "Test shortcut",
|
||
handler: async (ctx) => {},
|
||
});
|
||
}
|
||
`;
|
||
fs.writeFileSync(path.join(extensionsDir, "with-shortcut.ts"), extCode);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].shortcuts.has("ctrl+t")).toBe(true);
|
||
});
|
||
|
||
it("loads extension with flags", async () => {
|
||
const extCode = `
|
||
export default function(clanker) {
|
||
clanker.registerFlag("my-flag", {
|
||
description: "My custom flag",
|
||
handler: async (value) => {},
|
||
});
|
||
}
|
||
`;
|
||
fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode);
|
||
|
||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].flags.has("my-flag")).toBe(true);
|
||
});
|
||
|
||
it("loadExtensions only loads explicit paths without discovery", async () => {
|
||
// Create discoverable extensions (would be found by discoverAndLoadExtensions)
|
||
fs.writeFileSync(
|
||
path.join(extensionsDir, "discovered.ts"),
|
||
extensionCodeWithTool("discovered"),
|
||
);
|
||
|
||
// Create explicit extension outside discovery path
|
||
const explicitPath = path.join(tempDir, "explicit.ts");
|
||
fs.writeFileSync(explicitPath, extensionCodeWithTool("explicit"));
|
||
|
||
// Use loadExtensions directly to skip discovery
|
||
const { loadExtensions } = await import("../src/core/extensions/loader.js");
|
||
const result = await loadExtensions([explicitPath], tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(1);
|
||
expect(result.extensions[0].tools.has("explicit")).toBe(true);
|
||
expect(result.extensions[0].tools.has("discovered")).toBe(false);
|
||
});
|
||
|
||
it("loadExtensions with no paths loads nothing", async () => {
|
||
// Create discoverable extensions (would be found by discoverAndLoadExtensions)
|
||
fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCode);
|
||
|
||
// Use loadExtensions directly with empty paths
|
||
const { loadExtensions } = await import("../src/core/extensions/loader.js");
|
||
const result = await loadExtensions([], tempDir);
|
||
|
||
expect(result.errors).toHaveLength(0);
|
||
expect(result.extensions).toHaveLength(0);
|
||
});
|
||
});
|