diff --git a/desktop/src/agent-catalog.test.ts b/desktop/src/agent-catalog.test.ts new file mode 100644 index 0000000..33ae59b --- /dev/null +++ b/desktop/src/agent-catalog.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { AGENT_CATALOG, ALL_SKILL_IDS, getAgentSkills } from "./agent-catalog"; + +const EXPECTED_SKILL_IDS = [ + "1password", + "blucli", + "canvas", + "coding-agent", + "desktop-organizer", + "excel-xlsx", + "healthcheck", + "mcporter", + "nano-pdf", + "obsidian", + "officecli", + "openai-whisper", + "openhue", + "oracle", + "powerpoint-pptx", + "security-practice", + "session-logs", + "sherpa-onnx-tts", + "skill-creator", + "songsee", + "sonoscli", + "video-frames", + "word-docx", +]; + +describe("agent catalog skills binding", () => { + it("exposes exactly the 23 expected skill IDs", () => { + expect(ALL_SKILL_IDS).toEqual(EXPECTED_SKILL_IDS); + expect(ALL_SKILL_IDS).toHaveLength(23); + }); + + it("keeps ALL_SKILL_IDS sorted with no duplicates", () => { + const sorted = [...ALL_SKILL_IDS].sort((a, b) => a.localeCompare(b, "en")); + expect([...ALL_SKILL_IDS]).toEqual(sorted); + expect(new Set(ALL_SKILL_IDS).size).toBe(ALL_SKILL_IDS.length); + }); + + it("binds every agent to the full skill list", () => { + for (const agent of AGENT_CATALOG) { + expect(agent.skills).toEqual(ALL_SKILL_IDS); + } + }); + + it("gives each agent its own skills array instance", () => { + // No agent shares a reference with another agent or with ALL_SKILL_IDS. + for (const agent of AGENT_CATALOG) { + expect(agent.skills).not.toBe(ALL_SKILL_IDS); + } + const seen = new Set(); + for (const agent of AGENT_CATALOG) { + expect(seen.has(agent.skills)).toBe(false); + seen.add(agent.skills); + } + + // Mutating one agent's array does not affect others or ALL_SKILL_IDS. + const [first, second] = AGENT_CATALOG; + (first.skills as string[]).push("__mutation-probe__"); + try { + expect(second.skills).toEqual(ALL_SKILL_IDS); + expect(ALL_SKILL_IDS).toEqual(EXPECTED_SKILL_IDS); + } finally { + (first.skills as string[]).pop(); + } + }); + + it("resolves skills for a known agent and returns [] for an unknown one", () => { + expect(getAgentSkills("coder")).toEqual(ALL_SKILL_IDS); + expect(getAgentSkills("does-not-exist")).toEqual([]); + }); +}); diff --git a/desktop/src/agent-catalog.ts b/desktop/src/agent-catalog.ts index a47fe72..49e994a 100644 --- a/desktop/src/agent-catalog.ts +++ b/desktop/src/agent-catalog.ts @@ -1,3 +1,34 @@ +// The complete universe of skill IDs shipped by MicroClaw: the union of the +// certified/bundled skills (SKILL_CATALOG) and the managed skills +// (MANAGED_SKILL_CATALOG), alphabetically sorted. This MUST be kept in sync with +// `deployer/skill_catalog.py` (SKILL_CATALOG + MANAGED_SKILL_CATALOG) — adding or +// removing a skill there requires the same change here. +export const ALL_SKILL_IDS: readonly string[] = [ + "1password", + "blucli", + "canvas", + "coding-agent", + "desktop-organizer", + "excel-xlsx", + "healthcheck", + "mcporter", + "nano-pdf", + "obsidian", + "officecli", + "openai-whisper", + "openhue", + "oracle", + "powerpoint-pptx", + "security-practice", + "session-logs", + "sherpa-onnx-tts", + "skill-creator", + "songsee", + "sonoscli", + "video-frames", + "word-docx", +]; + export interface AgentCatalogEntry { id: string; name: string; @@ -8,6 +39,7 @@ export interface AgentCatalogEntry { tagKeys: string[]; taskKeys: Array<{ titleKey: string; descKey: string }>; installedByDefault: boolean; + skills: readonly string[]; workspaceDirName?: string; personaProfile?: "master-archive"; } @@ -27,6 +59,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ { titleKey: "agent.main.task.3.title", descKey: "agent.main.task.3.desc" }, ], installedByDefault: true, + skills: [...ALL_SKILL_IDS], }, { id: "master-archive", @@ -55,6 +88,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], workspaceDirName: "workspace-master-archive", personaProfile: "master-archive", }, @@ -72,6 +106,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ { titleKey: "agent.coder.task.3.title", descKey: "agent.coder.task.3.desc" }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], }, { id: "painter", @@ -87,6 +122,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ { titleKey: "agent.painter.task.3.title", descKey: "agent.painter.task.3.desc" }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], }, { id: "master", @@ -102,6 +138,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ { titleKey: "agent.master.task.3.title", descKey: "agent.master.task.3.desc" }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], }, { id: "growth-hacker", @@ -130,6 +167,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], }, { id: "leopard", @@ -145,6 +183,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ { titleKey: "agent.leopard.task.3.title", descKey: "agent.leopard.task.3.desc" }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], }, { id: "singer", @@ -160,9 +199,19 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ { titleKey: "agent.singer.task.3.title", descKey: "agent.singer.task.3.desc" }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], }, ]; export const DEFAULT_AGENT_IDS = AGENT_CATALOG.filter( (agent) => agent.installedByDefault, ).map((agent) => agent.id); + +/** + * Returns the skill IDs bound to the given agent, or an empty array if the agent + * id is not present in AGENT_CATALOG. + */ +export function getAgentSkills(agentId: string): readonly string[] { + const agent = AGENT_CATALOG.find((entry) => entry.id === agentId); + return agent ? agent.skills : []; +}