mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 21:00:30 +00:00
Implement extension discovery with package.json manifest support
Discovery rules: 1. extensions/*.ts or *.js - direct files 2. extensions/*/index.ts or index.js - subdirectory with index 3. extensions/*/package.json with pi field - load declared paths No recursion beyond one level. Complex packages use package.json manifest. Added PiManifest type for future theme/skill bundling support. 17 tests covering all discovery scenarios. refs #454
This commit is contained in:
parent
2846c7d190
commit
9794868b38
2 changed files with 371 additions and 3 deletions
|
|
@ -407,19 +407,91 @@ export async function loadExtensions(paths: string[], cwd: string, eventBus?: Ev
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PiManifest {
|
||||||
|
extensions?: string[];
|
||||||
|
themes?: string[];
|
||||||
|
skills?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPiManifest(packageJsonPath: string): PiManifest | null {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(packageJsonPath, "utf-8");
|
||||||
|
const pkg = JSON.parse(content);
|
||||||
|
if (pkg.pi && typeof pkg.pi === "object") {
|
||||||
|
return pkg.pi as PiManifest;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExtensionFile(name: string): boolean {
|
||||||
|
return name.endsWith(".ts") || name.endsWith(".js");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover extensions in a directory.
|
||||||
|
*
|
||||||
|
* Discovery rules:
|
||||||
|
* 1. Direct files: `extensions/*.ts` or `*.js` → load
|
||||||
|
* 2. Subdirectory with index: `extensions/* /index.ts` or `index.js` → load
|
||||||
|
* 3. Subdirectory with package.json: `extensions/* /package.json` with "pi" field → load what it declares
|
||||||
|
*
|
||||||
|
* No recursion beyond one level. Complex packages must use package.json manifest.
|
||||||
|
*/
|
||||||
function discoverExtensionsInDir(dir: string): string[] {
|
function discoverExtensionsInDir(dir: string): string[] {
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const discovered: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
return entries
|
|
||||||
.filter((e) => (e.isFile() || e.isSymbolicLink()) && e.name.endsWith(".ts"))
|
for (const entry of entries) {
|
||||||
.map((e) => path.join(dir, e.name));
|
const entryPath = path.join(dir, entry.name);
|
||||||
|
|
||||||
|
// 1. Direct files: *.ts or *.js
|
||||||
|
if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {
|
||||||
|
discovered.push(entryPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2 & 3. Subdirectories
|
||||||
|
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||||
|
// Check for package.json with "pi" field first
|
||||||
|
const packageJsonPath = path.join(entryPath, "package.json");
|
||||||
|
if (fs.existsSync(packageJsonPath)) {
|
||||||
|
const manifest = readPiManifest(packageJsonPath);
|
||||||
|
if (manifest?.extensions) {
|
||||||
|
// Load paths declared in manifest (relative to package.json dir)
|
||||||
|
for (const extPath of manifest.extensions) {
|
||||||
|
const resolvedExtPath = path.resolve(entryPath, extPath);
|
||||||
|
if (fs.existsSync(resolvedExtPath)) {
|
||||||
|
discovered.push(resolvedExtPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue; // package.json found, don't check for index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for index.ts or index.js
|
||||||
|
const indexTs = path.join(entryPath, "index.ts");
|
||||||
|
const indexJs = path.join(entryPath, "index.js");
|
||||||
|
if (fs.existsSync(indexTs)) {
|
||||||
|
discovered.push(indexTs);
|
||||||
|
} else if (fs.existsSync(indexJs)) {
|
||||||
|
discovered.push(indexJs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return discovered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
296
packages/coding-agent/test/extensions-discovery.test.ts
Normal file
296
packages/coding-agent/test/extensions-discovery.test.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { discoverAndLoadExtensions } from "../src/core/extensions/loader.js";
|
||||||
|
|
||||||
|
describe("extensions discovery", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let extensionsDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ext-test-"));
|
||||||
|
extensionsDir = path.join(tempDir, "extensions");
|
||||||
|
fs.mkdirSync(extensionsDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const extensionCode = `
|
||||||
|
export default function(pi) {
|
||||||
|
pi.registerCommand("test", { handler: async () => {} });
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const extensionCodeWithTool = (toolName: string) => `
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
export default function(pi) {
|
||||||
|
pi.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 pi 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",
|
||||||
|
pi: {
|
||||||
|
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",
|
||||||
|
pi: {
|
||||||
|
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 pi 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",
|
||||||
|
pi: {
|
||||||
|
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 pi 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({ pi: { 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({
|
||||||
|
pi: {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue