/** Tests bundle manifest parsing for Agent, Codex, Claude, Cursor, and OpenClaw formats. */ import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, detectBundleManifestFormat, loadBundleManifest, } from "./bundle-manifest.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir, mkdirSafeDir, } from "./test-helpers/fs-fixtures.js"; const AGENT_BUNDLE_MANIFEST_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; type BundlePluginManifest = Extract< ReturnType, { ok: true } >["manifest"]; type ReadonlyBundleManifestExpectation = Omit< BundlePluginManifest, "capabilities" | "hooks" | "settingsFiles" | "skills" > & { capabilities: readonly string[]; hooks: readonly string[]; settingsFiles?: readonly string[]; skills: readonly string[]; }; const tempDirs: string[] = []; function makeTempDir() { return makeTrackedTempDir("openclaw-bundle-manifest", tempDirs); } const mkdirSafe = mkdirSafeDir; function expectLoadedManifest( rootDir: string, bundleFormat: "agent" | "codex" | "claude" | "cursor", ) { const result = loadBundleManifest({ rootDir, bundleFormat }); expect(result.ok).toBe(true); if (!result.ok) { throw new Error("expected bundle manifest to load"); } return result.manifest; } function writeBundleManifest( rootDir: string, relativePath: string, manifest: Record, ) { writeBundleFixtureFile(rootDir, relativePath, manifest); } function writeBundleFixtureFile(rootDir: string, relativePath: string, value: unknown) { mkdirSafe(path.dirname(path.join(rootDir, relativePath))); fs.writeFileSync( path.join(rootDir, relativePath), typeof value === "string" ? value : JSON.stringify(value), "utf-8", ); } function writeBundleFixtureFiles(rootDir: string, files: Readonly>) { Object.entries(files).forEach(([relativePath, value]) => { writeBundleFixtureFile(rootDir, relativePath, value); }); } function setupBundleFixture(params: { rootDir: string; dirs?: readonly string[]; jsonFiles?: Readonly>; textFiles?: Readonly>; manifestRelativePath?: string; manifest?: Record; }) { for (const relativeDir of params.dirs ?? []) { mkdirSafe(path.join(params.rootDir, relativeDir)); } writeBundleFixtureFiles(params.rootDir, params.jsonFiles ?? {}); writeBundleFixtureFiles(params.rootDir, params.textFiles ?? {}); if (params.manifestRelativePath && params.manifest) { writeBundleManifest(params.rootDir, params.manifestRelativePath, params.manifest); } } function setupClaudeHookFixture( rootDir: string, kind: "default-hooks" | "custom-hooks" | "no-hooks", ) { if (kind === "default-hooks") { setupBundleFixture({ rootDir, dirs: [".claude-plugin", "hooks"], jsonFiles: { "hooks/hooks.json": { hooks: [] } }, manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, manifest: { name: "Hook Plugin", description: "Claude hooks fixture", }, }); return; } if (kind === "custom-hooks") { setupBundleFixture({ rootDir, dirs: [".claude-plugin", "custom-hooks"], manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, manifest: { name: "Custom Hook Plugin", hooks: "custom-hooks", }, }); return; } setupBundleFixture({ rootDir, dirs: [".claude-plugin", "skills"], manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, manifest: { name: "No Hooks" }, }); } function expectBundleManifest(params: { rootDir: string; bundleFormat: "agent" | "codex" | "claude" | "cursor"; expected: ReadonlyBundleManifestExpectation; }) { expect(detectBundleManifestFormat(params.rootDir)).toBe(params.bundleFormat); expect(expectLoadedManifest(params.rootDir, params.bundleFormat)).toEqual(params.expected); } function expectClaudeHookResolution(params: { rootDir: string; expectedHooks: readonly string[]; hasHooksCapability: boolean; }) { const manifest = expectLoadedManifest(params.rootDir, "claude"); expect(manifest.hooks).toEqual(params.expectedHooks); expect(manifest.capabilities.includes("hooks")).toBe(params.hasHooksCapability); } afterEach(() => { cleanupTrackedTempDirs(tempDirs); }); describe("bundle manifest parsing", () => { it("does not treat openclaw.bundle.json as a bundle manifest", () => { const rootDir = makeTempDir(); writeBundleManifest(rootDir, "openclaw.bundle.json", { name: "Not Real", skills: ["skills"], }); expect(detectBundleManifestFormat(rootDir)).toBeNull(); }); it.each([ { name: "detects and loads Agent Plugins bundles from the portable layout", bundleFormat: "agent" as const, setup: (rootDir: string) => { setupBundleFixture({ rootDir, dirs: ["skills/summarize"], textFiles: { "skills/summarize/SKILL.md": "---\nname: summarize\ndescription: Summarize\n---\n", "mcp.json": "{", }, manifestRelativePath: AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, manifest: { $schema: AGENT_BUNDLE_MANIFEST_SCHEMA, name: "Portable.Bundle", description: "Agent Plugins fixture", version: "1.2.3", unknown: true, }, }); }, expected: { id: "portable-bundle", name: "Portable.Bundle", description: "Agent Plugins fixture", version: "1.2.3", bundleFormat: "agent", skills: ["skills"], settingsFiles: [], hooks: [], capabilities: ["skills", "mcpServers"], }, }, { name: "detects and loads Codex bundle manifests", bundleFormat: "codex" as const, setup: (rootDir: string) => { setupBundleFixture({ rootDir, dirs: [".codex-plugin", "skills", "hooks"], manifestRelativePath: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, manifest: { name: "Sample Bundle", description: "Codex fixture", skills: "skills", hooks: "hooks", mcpServers: { sample: { command: "node", args: ["server.js"], }, }, apps: { sample: { title: "Sample App", }, }, }, }); }, expected: { id: "sample-bundle", name: "Sample Bundle", description: "Codex fixture", version: undefined, bundleFormat: "codex", skills: ["skills"], settingsFiles: [], hooks: ["hooks"], capabilities: ["skills", "hooks", "mcpServers", "apps"], }, }, { name: "detects and loads Claude bundle manifests from the component layout", bundleFormat: "claude" as const, setup: (rootDir: string) => { setupBundleFixture({ rootDir, dirs: [ ".claude-plugin", "skill-packs/starter", "commands-pack", "agents-pack", "hooks-pack", "mcp", "lsp", "styles", "hooks", ], textFiles: { "hooks/hooks.json": '{"hooks":[]}', "settings.json": '{"hideThinkingBlock":true}', }, manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, manifest: { name: "Claude Sample", description: "Claude fixture", skills: ["skill-packs/starter"], commands: "commands-pack", agents: "agents-pack", hooks: "hooks-pack", mcpServers: "mcp", lspServers: "lsp", outputStyles: "styles", }, }); }, expected: { id: "claude-sample", name: "Claude Sample", description: "Claude fixture", version: undefined, bundleFormat: "claude" as const, skills: ["skill-packs/starter", "commands-pack", "agents-pack", "styles"], settingsFiles: ["settings.json"], hooks: ["hooks/hooks.json", "hooks-pack"], capabilities: [ "skills", "commands", "agents", "hooks", "mcpServers", "lspServers", "outputStyles", "settings", ], }, }, { name: "detects and loads Cursor bundle manifests", bundleFormat: "cursor" as const, setup: (rootDir: string) => { setupBundleFixture({ rootDir, dirs: [".cursor-plugin", "skills", ".cursor/commands", ".cursor/rules", ".cursor/agents"], textFiles: { ".cursor/hooks.json": '{"hooks":[]}', ".mcp.json": '{"servers":{}}', }, manifestRelativePath: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, manifest: { name: "Cursor Sample", description: "Cursor fixture", mcpServers: "./.mcp.json", }, }); }, expected: { id: "cursor-sample", name: "Cursor Sample", description: "Cursor fixture", version: undefined, bundleFormat: "cursor", skills: ["skills", ".cursor/commands"], settingsFiles: [], hooks: [], capabilities: ["skills", "commands", "agents", "hooks", "rules", "mcpServers"], }, }, { name: "detects manifestless Claude bundles from the default layout", bundleFormat: "claude" as const, setup: (rootDir: string) => { setupBundleFixture({ rootDir, dirs: ["commands", "skills"], textFiles: { "settings.json": '{"hideThinkingBlock":true}', }, }); }, expected: (rootDir: string) => ({ id: path.basename(rootDir).toLowerCase(), name: undefined, description: undefined, version: undefined, bundleFormat: "claude" as const, skills: ["skills", "commands"], settingsFiles: ["settings.json"], hooks: [], capabilities: ["skills", "commands", "settings"], }), }, ] as const)("$name", ({ bundleFormat, setup, expected }) => { const rootDir = makeTempDir(); setup(rootDir); expectBundleManifest({ rootDir, bundleFormat, expected: typeof expected === "function" ? expected(rootDir) : expected, }); }); it("detects Link-style Codex bundles with skills and MCP servers", () => { const rootDir = makeTempDir(); setupBundleFixture({ rootDir, dirs: [".codex-plugin", "skills/create-payment-credential"], textFiles: { ".mcp.json": JSON.stringify({ mcpServers: { link: { command: "pnpx", args: ["@stripe/link-cli", "--mcp"], }, }, }), }, manifestRelativePath: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, manifest: { name: "link", version: "0.2.1", description: "Secure, one-time-use payment credentials from Link", homepage: "https://link.com/agents", repository: "https://github.com/stripe/link-cli", skills: "./skills/", mcpServers: "./.mcp.json", interface: { displayName: "Link", category: "Finance", }, }, }); expectBundleManifest({ rootDir, bundleFormat: "codex", expected: { id: "link", name: "link", version: "0.2.1", description: "Secure, one-time-use payment credentials from Link", bundleFormat: "codex", skills: ["./skills/"], settingsFiles: [], hooks: [], capabilities: expect.arrayContaining(["skills", "mcpServers"]), }, }); }); it("keeps client-specific and native formats ahead of portable Agent Plugins", () => { const claudeRoot = makeTempDir(); setupBundleFixture({ rootDir: claudeRoot, dirs: [".claude-plugin"], jsonFiles: { [CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH]: { name: "Claude" }, [AGENT_BUNDLE_MANIFEST_RELATIVE_PATH]: { $schema: AGENT_BUNDLE_MANIFEST_SCHEMA, name: "Agent", }, }, }); expect(detectBundleManifestFormat(claudeRoot)).toBe("claude"); const nativeRoot = makeTempDir(); writeBundleFixtureFiles(nativeRoot, { "openclaw.plugin.json": { id: "native", configSchema: { type: "object" } }, [AGENT_BUNDLE_MANIFEST_RELATIVE_PATH]: { $schema: AGENT_BUNDLE_MANIFEST_SCHEMA, name: "Agent", }, }); expect(detectBundleManifestFormat(nativeRoot)).toBeNull(); const entryRoot = makeTempDir(); writeBundleFixtureFiles(entryRoot, { "index.ts": "export default {}", [AGENT_BUNDLE_MANIFEST_RELATIVE_PATH]: { $schema: AGENT_BUNDLE_MANIFEST_SCHEMA, name: "Agent", }, }); expect(detectBundleManifestFormat(entryRoot)).toBe("agent"); }); it.each([ { name: "wrong schema falls through to native entry detection", manifest: { $schema: "https://wrong.example/plugin.schema.json", name: "not-agent" }, files: { "index.ts": "export default {}" }, expected: null, }, { name: "missing schema falls through to manifestless Claude markers", manifest: { name: "not-agent" }, files: { "skills/example/SKILL.md": "---\ndescription: Example\n---\n" }, expected: "claude", }, { name: "wrong schema without fallback markers is not a bundle", manifest: { $schema: "https://wrong.example/plugin.schema.json", name: "not-agent" }, files: {}, expected: null, }, ])("$name", ({ manifest, files, expected }) => { const rootDir = makeTempDir(); writeBundleFixtureFiles(rootDir, { [AGENT_BUNDLE_MANIFEST_RELATIVE_PATH]: manifest, ...files, }); expect(detectBundleManifestFormat(rootDir)).toBe(expected); }); it.each([ { name: "missing schema", manifest: { name: "portable" } }, { name: "wrong schema", manifest: { $schema: "https://wrong.example/plugin.schema.json", name: "portable" }, }, ])("rejects Agent Plugins manifests with $name", ({ manifest }) => { const rootDir = makeTempDir(); writeBundleManifest(rootDir, AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, manifest); const result = loadBundleManifest({ rootDir, bundleFormat: "agent" }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain(`expected $schema ${AGENT_BUNDLE_MANIFEST_SCHEMA}`); } }); it("rejects Agent Plugins manifests with missing or empty names", () => { for (const name of [undefined, " "]) { const rootDir = makeTempDir(); writeBundleManifest(rootDir, AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, { $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", ...(name === undefined ? {} : { name }), }); const result = loadBundleManifest({ rootDir, bundleFormat: "agent" }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain("name must be a non-empty string"); } } }); it("requires strict JSON only for Agent Plugins manifests", () => { const rootDir = makeTempDir(); writeBundleFixtureFile( rootDir, AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, '{ name: "not strict JSON", }', ); const result = loadBundleManifest({ rootDir, bundleFormat: "agent" }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain("failed to parse plugin manifest"); } }); it("does not expose Agent Plugins skills when skills is not a directory", () => { const rootDir = makeTempDir(); writeBundleFixtureFiles(rootDir, { [AGENT_BUNDLE_MANIFEST_RELATIVE_PATH]: { $schema: AGENT_BUNDLE_MANIFEST_SCHEMA, name: "portable", }, skills: "not a directory", }); expect(expectLoadedManifest(rootDir, "agent").skills).toStrictEqual([]); }); it("loads ai.openclaw activation like an equivalent Claude bundle", () => { const activation = { onStartup: true, onCommands: [" summarize ", ""], onCapabilities: ["tool", "unknown"], }; const agentRoot = makeTempDir(); writeBundleManifest(agentRoot, AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, { $schema: AGENT_BUNDLE_MANIFEST_SCHEMA, name: "portable", extensions: { "ai.openclaw": { activation }, }, }); const claudeRoot = makeTempDir(); writeBundleManifest(claudeRoot, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, { name: "claude", activation, }); const agentActivation = expectLoadedManifest(agentRoot, "agent").activation; expect(agentActivation).toEqual({ onStartup: true, onCommands: ["summarize"], onCapabilities: ["tool"], }); expect(agentActivation).toEqual(expectLoadedManifest(claudeRoot, "claude").activation); }); it.each([ { name: "non-object extensions", extensions: "invalid" }, { name: "non-object ai.openclaw extension", extensions: { "ai.openclaw": "invalid" }, }, { name: "unknown extension namespace", extensions: { "com.example": { activation: { onStartup: true } } }, }, ])("ignores $name", ({ extensions }) => { const rootDir = makeTempDir(); writeBundleManifest(rootDir, AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, { $schema: AGENT_BUNDLE_MANIFEST_SCHEMA, name: "portable", extensions, }); expect(expectLoadedManifest(rootDir, "agent").activation).toBeUndefined(); }); it("ignores unknown fields inside the ai.openclaw extension", () => { const rootDir = makeTempDir(); writeBundleManifest(rootDir, AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, { $schema: AGENT_BUNDLE_MANIFEST_SCHEMA, name: "portable", extensions: { "ai.openclaw": { activation: { onStartup: true }, futureField: { enabled: true }, }, }, }); expect(expectLoadedManifest(rootDir, "agent").activation).toEqual({ onStartup: true }); }); it.each([ { name: "accepts JSON5 Codex bundle manifests", bundleFormat: "codex" as const, manifestRelativePath: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, json5Manifest: `{ // Bundle name can include comments and trailing commas. name: "Codex JSON5 Bundle", skills: "skills", hooks: "hooks", }`, dirs: ["skills", "hooks"], expected: { id: "codex-json5-bundle", name: "Codex JSON5 Bundle", description: undefined, version: undefined, bundleFormat: "codex", skills: ["skills"], settingsFiles: [], hooks: ["hooks"], capabilities: ["skills", "hooks"], }, }, { name: "accepts JSON5 Claude bundle manifests", bundleFormat: "claude" as const, manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, json5Manifest: `{ name: "Claude JSON5 Bundle", commands: "commands-pack", hooks: "hooks-pack", outputStyles: "styles", }`, dirs: [".claude-plugin", "commands-pack", "hooks-pack", "styles"], expected: { id: "claude-json5-bundle", name: "Claude JSON5 Bundle", description: undefined, version: undefined, bundleFormat: "claude", skills: ["commands-pack", "styles"], settingsFiles: [], hooks: ["hooks-pack"], capabilities: ["skills", "commands", "hooks", "outputStyles"], }, }, { name: "accepts JSON5 Cursor bundle manifests", bundleFormat: "cursor" as const, manifestRelativePath: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, json5Manifest: `{ name: "Cursor JSON5 Bundle", commands: ".cursor/commands", mcpServers: "./.mcp.json", }`, dirs: [".cursor-plugin", "skills", ".cursor/commands"], textFiles: { ".mcp.json": "{ servers: {}, }", }, expected: { id: "cursor-json5-bundle", name: "Cursor JSON5 Bundle", description: undefined, version: undefined, bundleFormat: "cursor", skills: ["skills", ".cursor/commands"], settingsFiles: [], hooks: [], capabilities: ["skills", "commands", "mcpServers"], }, }, ] as const)( "$name", ({ bundleFormat, manifestRelativePath, json5Manifest, dirs, textFiles, expected }) => { const rootDir = makeTempDir(); setupBundleFixture({ rootDir, dirs: [path.dirname(manifestRelativePath), ...dirs], textFiles: { [manifestRelativePath]: json5Manifest, ...textFiles, }, }); expectBundleManifest({ rootDir, bundleFormat, expected, }); }, ); it.each([ { name: "rejects Agent Plugins manifests that parse to non-objects", bundleFormat: "agent" as const, manifestRelativePath: AGENT_BUNDLE_MANIFEST_RELATIVE_PATH, }, { name: "rejects JSON5 Codex bundle manifests that parse to non-objects", bundleFormat: "codex" as const, manifestRelativePath: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, }, { name: "rejects JSON5 Claude bundle manifests that parse to non-objects", bundleFormat: "claude" as const, manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, }, { name: "rejects JSON5 Cursor bundle manifests that parse to non-objects", bundleFormat: "cursor" as const, manifestRelativePath: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, }, ] as const)("$name", ({ bundleFormat, manifestRelativePath }) => { const rootDir = makeTempDir(); setupBundleFixture({ rootDir, dirs: [path.dirname(manifestRelativePath)], textFiles: { [manifestRelativePath]: bundleFormat === "agent" ? '"still not an object"' : "'still not an object'", }, }); const result = loadBundleManifest({ rootDir, bundleFormat }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain("plugin manifest must be an object"); } }); it.each([ { name: "resolves Claude bundle hooks from default and declared paths", setupKind: "default-hooks", expectedHooks: ["hooks/hooks.json"], hasHooksCapability: true, }, { name: "resolves Claude bundle hooks from manifest-declared paths only", setupKind: "custom-hooks", expectedHooks: ["custom-hooks"], hasHooksCapability: true, }, { name: "returns empty hooks for Claude bundles with no hooks directory", setupKind: "no-hooks", expectedHooks: [], hasHooksCapability: false, }, ] as const)("$name", ({ setupKind, expectedHooks, hasHooksCapability }) => { const rootDir = makeTempDir(); setupClaudeHookFixture(rootDir, setupKind); expectClaudeHookResolution({ rootDir, expectedHooks, hasHooksCapability, }); }); it("does not misclassify native index plugins as manifestless Claude bundles", () => { const rootDir = makeTempDir(); setupBundleFixture({ rootDir, dirs: ["commands"], textFiles: { "index.ts": "export default {}" }, }); expect(detectBundleManifestFormat(rootDir)).toBeNull(); }); });