From 051ce5e786532c44c51890313d00c7dec9d7bda2 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:23:20 -0300 Subject: [PATCH 01/49] feat(agent-skills): add foundation types + Zod schemas --- src/lib/agentSkills/schemas.ts | 41 +++++++++++++++ src/lib/agentSkills/types.ts | 96 ++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/lib/agentSkills/schemas.ts create mode 100644 src/lib/agentSkills/types.ts diff --git a/src/lib/agentSkills/schemas.ts b/src/lib/agentSkills/schemas.ts new file mode 100644 index 0000000000..f6538a1f90 --- /dev/null +++ b/src/lib/agentSkills/schemas.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +export const SkillCategorySchema = z.enum(["api", "cli"]); + +export const AgentSkillSchema = z.object({ + id: z.string().regex(/^[a-z][a-z0-9-]*$/), + name: z.string().min(1).max(100), + description: z.string().min(1).max(2000), + category: SkillCategorySchema, + area: z.string().min(1).max(50), + endpoints: z.array(z.string()).optional(), + cliCommands: z.array(z.string()).optional(), + icon: z.string().optional(), + isEntry: z.boolean().optional(), + isNew: z.boolean().optional(), + rawUrl: z.string().url(), + githubUrl: z.string().url(), +}); + +export const SkillCoverageSchema = z.object({ + api: z.object({ have: z.number().int().nonnegative(), total: z.literal(22) }), + cli: z.object({ have: z.number().int().nonnegative(), total: z.literal(20) }), + totalSkills: z.number().int().nonnegative(), + generatedAt: z.string().datetime(), +}); + +export const ListQuerySchema = z.object({ + category: SkillCategorySchema.optional(), + area: z.string().optional(), +}); + +export const GenerateBodySchema = z.object({ + dryRun: z.boolean().default(true), + prune: z.boolean().default(false), + onlyIds: z.array(z.string()).optional(), +}); + +export type AgentSkillT = z.infer; +export type SkillCoverageT = z.infer; +export type ListQueryT = z.infer; +export type GenerateBodyT = z.infer; diff --git a/src/lib/agentSkills/types.ts b/src/lib/agentSkills/types.ts new file mode 100644 index 0000000000..d187ff1566 --- /dev/null +++ b/src/lib/agentSkills/types.ts @@ -0,0 +1,96 @@ +export type SkillCategory = "api" | "cli"; + +export type SkillArea = + // API areas (22) + | "auth" + | "providers" + | "models" + | "combos-routing" + | "api-keys" + | "usage-logs" + | "budget" + | "settings" + | "proxies" + | "cache" + | "compression" + | "context-rtk" + | "resilience" + | "cli-tools" + | "tunnels" + | "sync-cloud" + | "db-backups" + | "webhooks" + | "mcp" + | "agents-a2a" + | "version-manager" + | "inference" + // CLI families (20) + | "cli-serve" + | "cli-health" + | "cli-providers" + | "cli-keys" + | "cli-models" + | "cli-chat" + | "cli-routing" + | "cli-resilience" + | "cli-compression" + | "cli-contexts" + | "cli-cost-usage" + | "cli-mcp" + | "cli-a2a" + | "cli-tunnel" + | "cli-backup-sync" + | "cli-policy-audit" + | "cli-batches" + | "cli-eval" + | "cli-plugins-skills" + | "cli-setup"; + +export interface AgentSkill { + id: string; // canonical id (e.g. "omni-providers", "cli-serve") + name: string; // human-readable + description: string; // 1-paragraph + category: SkillCategory; + area: SkillArea; + endpoints?: string[]; // e.g. ["POST /api/providers", "GET /api/providers/:id"] (api only) + cliCommands?: string[]; // e.g. ["providers list", "providers test", "providers rotate"] (cli only) + icon?: string; // Material symbol name + isEntry?: boolean; // "start here" tag + isNew?: boolean; // "new" tag + rawUrl: string; // GitHub raw URL of SKILL.md + githubUrl: string; // GitHub blob URL +} + +export interface SkillCoverage { + api: { have: number; total: 22 }; + cli: { have: number; total: 20 }; + totalSkills: number; // sum + generatedAt: string; // ISO datetime +} + +export interface SkillCatalogEntry extends AgentSkill { + // No additional fields; alias for AgentSkill at catalog-level. +} + +export interface SkillMarkdown { + id: string; + frontmatter: { name: string; description: string }; + body: string; // raw markdown after frontmatter + source: "filesystem" | "github" | "generated"; + fetchedAt: string; // ISO +} + +export interface GeneratorOptions { + dryRun: boolean; // default true + prune: boolean; // default false + outputDir?: string; // default "skills/" + onlyIds?: string[]; // regenerate only these +} + +export interface GeneratorReport { + generated: string[]; // ids that got new/updated SKILL.md + unchanged: string[]; // ids that already match + pruned: string[]; // ids whose folder was deleted (prune mode) + orphansDetected: string[]; // ids in repo that aren't in catalog (prune dry-run shows these) + errors: Array<{ id: string; error: string }>; +} From aed1bd02d248e477f170599738216080979c7a50 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:23:27 -0300 Subject: [PATCH 02/49] feat(agent-skills): add SkillsConceptCard shared component + i18n --- src/i18n/messages/en.json | 42 +++++++++++++++++++ src/i18n/messages/pt-BR.json | 42 +++++++++++++++++++ src/shared/components/SkillsConceptCard.tsx | 46 +++++++++++++++++++++ src/shared/components/index.tsx | 2 + 4 files changed, 132 insertions(+) create mode 100644 src/shared/components/SkillsConceptCard.tsx diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 5115037d36..1e805a1b2e 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1186,6 +1186,7 @@ "memoryDescription": "Persistent conversational memory with semantic search and FTS5 full-text indexing", "skillsDescription": "Install and manage sandbox skills for automated prompt and tool execution", "agentSkillsDescription": "Agent-ready skills catalog with one-click URL copy for AI client integration", + "omniSkillsDescription": "Install and manage sandbox skills for automated prompt and tool execution", "translatorDescription": "Translate and test prompts across API formats: OpenAI ↔ Claude ↔ Gemini", "playgroundDescription": "Test prompts interactively with live provider responses and format inspection", "searchToolsDescription": "Search analytics, provider breakdown, cache hit rates, and cost tracking", @@ -7289,5 +7290,46 @@ "policyLabel": "Policy:", "resetIn": "reset in", "quotaTotal": "total" + }, + "agentSkills": { + "pageTitle": "Agent Skills", + "pageSubtitle": "Teach your agent to operate OmniRoute — 22 API areas + 20 CLI families", + "conceptCard": { + "agent": { + "title": "Agent Skills — Outbound", + "description": "Agent Skills are machine-readable SKILL.md documents that external AI agents (Claude Code, Cursor, Copilot…) fetch from GitHub to learn how to operate OmniRoute via REST or CLI. They are read by the agent, not executed by OmniRoute.", + "crossLinkLabel": "Understand the difference →" + }, + "omni": { + "title": "Omni Skills — Inbound", + "description": "Omni Skills are sandbox tools that OmniRoute injects into the model's context on every request. They are executed by OmniRoute, not read by the agent.", + "crossLinkLabel": "Understand the difference →" + } + }, + "filters": { + "category": "Category", + "area": "Area", + "searchPlaceholder": "Search skills…" + }, + "categoryApi": "API", + "categoryCli": "CLI", + "coverageLabel": "Coverage", + "mcpUrl": "MCP URL", + "a2aLink": "A2A", + "copyUrl": "Copy URL", + "viewOnGithub": "View on GitHub", + "previewLoading": "Loading skill documentation…", + "previewError": "Failed to load skill documentation.", + "previewEmpty": "Select a skill to preview its documentation.", + "generateButton": "Generate missing skills", + "coverageBar": { + "complete": "Complete", + "partial": "Partial" + }, + "noSkillsFound": "No skills found matching your filters.", + "regenerateConfirm": "This will regenerate all missing SKILL.md files. Continue?", + "regenerateRunning": "Regenerating skills…", + "regenerateSuccess": "Skills regenerated successfully.", + "regenerateError": "Failed to regenerate skills." } } diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index cc60285114..a1a5823a19 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -1186,6 +1186,7 @@ "memoryDescription": "Persistent conversational memory with semantic search and FTS5 full-text indexing", "skillsDescription": "Install and manage sandbox skills for automated prompt and tool execution", "agentSkillsDescription": "Agent-ready skills catalog with one-click URL copy for AI client integration", + "omniSkillsDescription": "Instale e gerencie skills sandbox para execução automatizada de prompts e ferramentas", "translatorDescription": "Translate and test prompts across API formats: OpenAI ↔ Claude ↔ Gemini", "playgroundDescription": "Test prompts interactively with live provider responses and format inspection", "searchToolsDescription": "Search analytics, provider breakdown, cache hit rates, and cost tracking", @@ -7279,5 +7280,46 @@ "policyLabel": "Política:", "resetIn": "redefinir em", "quotaTotal": "total" + }, + "agentSkills": { + "pageTitle": "Agent Skills", + "pageSubtitle": "Ensine seu agente a operar o OmniRoute — 22 áreas de API + 20 famílias de CLI", + "conceptCard": { + "agent": { + "title": "Agent Skills — Saída", + "description": "Agent Skills são documentos SKILL.md legíveis por máquina que agentes externos (Claude Code, Cursor, Copilot…) baixam do GitHub para aprender a operar o OmniRoute via REST ou CLI. São lidos pelo agente, não executados pelo OmniRoute.", + "crossLinkLabel": "Entenda a diferença →" + }, + "omni": { + "title": "Omni Skills — Entrada", + "description": "Omni Skills são ferramentas sandbox que o OmniRoute injeta no contexto do modelo a cada solicitação. São executadas pelo OmniRoute, não lidas pelo agente.", + "crossLinkLabel": "Entenda a diferença →" + } + }, + "filters": { + "category": "Categoria", + "area": "Área", + "searchPlaceholder": "Buscar skills…" + }, + "categoryApi": "API", + "categoryCli": "CLI", + "coverageLabel": "Cobertura", + "mcpUrl": "URL MCP", + "a2aLink": "A2A", + "copyUrl": "Copiar URL", + "viewOnGithub": "Ver no GitHub", + "previewLoading": "Carregando documentação da skill…", + "previewError": "Falha ao carregar documentação da skill.", + "previewEmpty": "Selecione uma skill para visualizar sua documentação.", + "generateButton": "Gerar skills faltantes", + "coverageBar": { + "complete": "Completo", + "partial": "Parcial" + }, + "noSkillsFound": "Nenhuma skill encontrada para os filtros selecionados.", + "regenerateConfirm": "Isso irá regenerar todos os arquivos SKILL.md faltantes. Continuar?", + "regenerateRunning": "Regenerando skills…", + "regenerateSuccess": "Skills regeneradas com sucesso.", + "regenerateError": "Falha ao regenerar skills." } } diff --git a/src/shared/components/SkillsConceptCard.tsx b/src/shared/components/SkillsConceptCard.tsx new file mode 100644 index 0000000000..2b5fc67535 --- /dev/null +++ b/src/shared/components/SkillsConceptCard.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +export interface SkillsConceptCardProps { + variant: "agent" | "omni"; + className?: string; +} + +export function SkillsConceptCard({ variant, className = "" }: SkillsConceptCardProps): JSX.Element { + const t = useTranslations("agentSkills"); + + const crossLinkHref = variant === "agent" ? "/dashboard/omni-skills" : "/dashboard/agent-skills"; + + const title = t(`conceptCard.${variant}.title`); + const description = t(`conceptCard.${variant}.description`); + const crossLinkLabel = t(`conceptCard.${variant}.crossLinkLabel`); + + const agentIcon = "share"; + const omniIcon = "auto_fix_high"; + const icon = variant === "agent" ? agentIcon : omniIcon; + + return ( +
+
+ {icon} +
+
+

{title}

+

{description}

+
+ + {crossLinkLabel} + arrow_forward + +
+ ); +} + +export default SkillsConceptCard; diff --git a/src/shared/components/index.tsx b/src/shared/components/index.tsx index 76ba1cbed8..dceca660ec 100644 --- a/src/shared/components/index.tsx +++ b/src/shared/components/index.tsx @@ -38,5 +38,7 @@ export { default as CollapsibleSection } from "./CollapsibleSection"; export { default as InfoTooltip } from "./InfoTooltip"; export { default as PresetSlider } from "./PresetSlider"; +export { SkillsConceptCard } from "./SkillsConceptCard"; + // Layouts export * from "./layouts"; From 612cf63de7305b971a1036c53de945c0d8ea34fa Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:23:32 -0300 Subject: [PATCH 03/49] feat(agent-skills): redirect /dashboard/skills -> /dashboard/omni-skills + sidebar reorder --- next.config.mjs | 6 ++++++ src/shared/components/Header.tsx | 4 +++- src/shared/constants/sidebarVisibility.ts | 14 +++++++------- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index a7298f6e9f..26f8ff90e4 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -182,6 +182,12 @@ const nextConfig = { async redirects() { return [ + // Dashboard routes + { + source: "/dashboard/skills", + destination: "/dashboard/omni-skills", + permanent: true, + }, // Architecture { source: "/docs/architecture", diff --git a/src/shared/components/Header.tsx b/src/shared/components/Header.tsx index ed0bb37a4d..6a24349a0e 100644 --- a/src/shared/components/Header.tsx +++ b/src/shared/components/Header.tsx @@ -35,7 +35,8 @@ import { useIsElectron } from "@/shared/hooks/useElectron"; const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1"; // Map sidebar item id → header description i18n key -const HEADER_DESCRIPTIONS: Partial> = { +// "omni-skills" is an extended key for the /dashboard/omni-skills route (graceful fallback during deploy) +const HEADER_DESCRIPTIONS: Partial> = { home: "homeDescription", endpoints: "endpointDescription", "api-manager": "apiManagerDescription", @@ -53,6 +54,7 @@ const HEADER_DESCRIPTIONS: Partial> = { memory: "memoryDescription", skills: "skillsDescription", "agent-skills": "agentSkillsDescription", + "omni-skills": "omniSkillsDescription", settings: "settingsDescription", "context-caveman": "contextCavemanDescription", "context-rtk": "contextRtkDescription", diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 251bd0d1d6..2d624ac5e5 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -503,13 +503,6 @@ const AGENTIC_FEATURES_ITEMS: readonly SidebarSectionChild[] = [ subtitleKey: "memorySubtitle", icon: "psychology", }, - { - id: "skills", - href: "/dashboard/skills", - i18nKey: "omniSkills", - subtitleKey: "omniSkillsSubtitle", - icon: "auto_fix_high", - }, { id: "agent-skills", href: "/dashboard/agent-skills", @@ -517,6 +510,13 @@ const AGENTIC_FEATURES_ITEMS: readonly SidebarSectionChild[] = [ subtitleKey: "agentSkillsSubtitle", icon: "share", }, + { + id: "skills", + href: "/dashboard/omni-skills", + i18nKey: "omniSkills", + subtitleKey: "omniSkillsSubtitle", + icon: "auto_fix_high", + }, MCP_GROUP, { id: "a2a", From bf764dc529aa73618cf88aab863c4964a299df86 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:23:36 -0300 Subject: [PATCH 04/49] test(agent-skills): unit tests for schemas + SkillsConceptCard --- tests/unit/SkillsConceptCard.test.tsx | 144 +++++++++++++++ tests/unit/agentSkills-schemas.test.ts | 233 +++++++++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 tests/unit/SkillsConceptCard.test.tsx create mode 100644 tests/unit/agentSkills-schemas.test.ts diff --git a/tests/unit/SkillsConceptCard.test.tsx b/tests/unit/SkillsConceptCard.test.tsx new file mode 100644 index 0000000000..010478126a --- /dev/null +++ b/tests/unit/SkillsConceptCard.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Minimal next-intl stub — returns the translation key for inspection. +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// Minimal next/link stub — renders as a plain anchor element. +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + + {children} + + ), +})); + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => { + container.remove(); + }); + return container; +} + +describe("SkillsConceptCard", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("renders agent variant with correct i18n keys", async () => { + const { SkillsConceptCard } = await import( + "../../src/shared/components/SkillsConceptCard.tsx" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const text = container.textContent ?? ""; + // The i18n mock returns the key itself, so these keys should appear. + expect(text).toContain("conceptCard.agent.title"); + expect(text).toContain("conceptCard.agent.description"); + expect(text).toContain("conceptCard.agent.crossLinkLabel"); + }); + + it("renders omni variant with correct i18n keys", async () => { + const { SkillsConceptCard } = await import( + "../../src/shared/components/SkillsConceptCard.tsx" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const text = container.textContent ?? ""; + expect(text).toContain("conceptCard.omni.title"); + expect(text).toContain("conceptCard.omni.description"); + expect(text).toContain("conceptCard.omni.crossLinkLabel"); + }); + + it("agent variant cross-link points to /dashboard/omni-skills", async () => { + const { SkillsConceptCard } = await import( + "../../src/shared/components/SkillsConceptCard.tsx" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const link = container.querySelector("a"); + expect(link).not.toBeNull(); + expect(link?.getAttribute("href")).toBe("/dashboard/omni-skills"); + }); + + it("omni variant cross-link points to /dashboard/agent-skills", async () => { + const { SkillsConceptCard } = await import( + "../../src/shared/components/SkillsConceptCard.tsx" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const link = container.querySelector("a"); + expect(link).not.toBeNull(); + expect(link?.getAttribute("href")).toBe("/dashboard/agent-skills"); + }); + + it("accepts optional className prop", async () => { + const { SkillsConceptCard } = await import( + "../../src/shared/components/SkillsConceptCard.tsx" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const el = container.firstElementChild as HTMLElement | null; + expect(el?.className).toContain("my-custom-class"); + }); + + it("renders without crashing when className is omitted", async () => { + const { SkillsConceptCard } = await import( + "../../src/shared/components/SkillsConceptCard.tsx" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + expect(container.children.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/agentSkills-schemas.test.ts b/tests/unit/agentSkills-schemas.test.ts new file mode 100644 index 0000000000..7172f3e7aa --- /dev/null +++ b/tests/unit/agentSkills-schemas.test.ts @@ -0,0 +1,233 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { AgentSkillSchema, SkillCoverageSchema, ListQuerySchema, GenerateBodySchema } = + await import("../../src/lib/agentSkills/schemas.ts"); + +// ─── AgentSkillSchema ───────────────────────────────────────────────────────── + +test("AgentSkillSchema — valid api skill parses successfully", () => { + const input = { + id: "omni-providers", + name: "Providers", + description: "Manage LLM providers", + category: "api" as const, + area: "providers", + endpoints: ["GET /api/providers", "POST /api/providers"], + rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/omni-providers/SKILL.md", + githubUrl: "https://github.com/owner/repo/blob/main/skills/omni-providers/SKILL.md", + }; + const result = AgentSkillSchema.safeParse(input); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data.id, "omni-providers"); + assert.equal(result.data.category, "api"); + } +}); + +test("AgentSkillSchema — valid cli skill parses successfully", () => { + const input = { + id: "cli-serve", + name: "Serve", + description: "Start the OmniRoute server", + category: "cli" as const, + area: "cli-serve", + cliCommands: ["serve", "serve --port 8080"], + rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/cli-serve/SKILL.md", + githubUrl: "https://github.com/owner/repo/blob/main/skills/cli-serve/SKILL.md", + }; + const result = AgentSkillSchema.safeParse(input); + assert.equal(result.success, true); +}); + +test("AgentSkillSchema — invalid id (uppercase) fails", () => { + const input = { + id: "Omni-Providers", + name: "Providers", + description: "Manage LLM providers", + category: "api", + area: "providers", + rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/omni-providers/SKILL.md", + githubUrl: "https://github.com/owner/repo/blob/main/skills/omni-providers/SKILL.md", + }; + const result = AgentSkillSchema.safeParse(input); + assert.equal(result.success, false); +}); + +test("AgentSkillSchema — invalid category fails", () => { + const input = { + id: "omni-providers", + name: "Providers", + description: "Manage LLM providers", + category: "unknown", + area: "providers", + rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/omni-providers/SKILL.md", + githubUrl: "https://github.com/owner/repo/blob/main/skills/omni-providers/SKILL.md", + }; + const result = AgentSkillSchema.safeParse(input); + assert.equal(result.success, false); +}); + +test("AgentSkillSchema — non-url rawUrl fails", () => { + const input = { + id: "omni-providers", + name: "Providers", + description: "Manage LLM providers", + category: "api", + area: "providers", + rawUrl: "not-a-url", + githubUrl: "https://github.com/owner/repo/blob/main/skills/omni-providers/SKILL.md", + }; + const result = AgentSkillSchema.safeParse(input); + assert.equal(result.success, false); +}); + +test("AgentSkillSchema — optional fields absent parses successfully", () => { + const input = { + id: "omni-providers", + name: "Providers", + description: "Manage LLM providers", + category: "api", + area: "providers", + rawUrl: "https://raw.githubusercontent.com/owner/repo/main/skills/omni-providers/SKILL.md", + githubUrl: "https://github.com/owner/repo/blob/main/skills/omni-providers/SKILL.md", + }; + const result = AgentSkillSchema.safeParse(input); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data.endpoints, undefined); + assert.equal(result.data.cliCommands, undefined); + assert.equal(result.data.icon, undefined); + assert.equal(result.data.isEntry, undefined); + assert.equal(result.data.isNew, undefined); + } +}); + +test("AgentSkillSchema — .parse throws on invalid input", () => { + assert.throws(() => { + AgentSkillSchema.parse({ id: "bad id", name: "", description: "", category: "api", area: "x", rawUrl: "x", githubUrl: "x" }); + }); +}); + +// ─── SkillCoverageSchema ────────────────────────────────────────────────────── + +test("SkillCoverageSchema — valid coverage parses successfully", () => { + const input = { + api: { have: 22, total: 22 }, + cli: { have: 20, total: 20 }, + totalSkills: 42, + generatedAt: new Date().toISOString(), + }; + const result = SkillCoverageSchema.safeParse(input); + assert.equal(result.success, true); +}); + +test("SkillCoverageSchema — wrong total literal (api.total=21) fails", () => { + const input = { + api: { have: 21, total: 21 }, + cli: { have: 20, total: 20 }, + totalSkills: 41, + generatedAt: new Date().toISOString(), + }; + const result = SkillCoverageSchema.safeParse(input); + assert.equal(result.success, false); +}); + +test("SkillCoverageSchema — wrong total literal (cli.total=19) fails", () => { + const input = { + api: { have: 22, total: 22 }, + cli: { have: 19, total: 19 }, + totalSkills: 41, + generatedAt: new Date().toISOString(), + }; + const result = SkillCoverageSchema.safeParse(input); + assert.equal(result.success, false); +}); + +test("SkillCoverageSchema — invalid datetime fails", () => { + const input = { + api: { have: 22, total: 22 }, + cli: { have: 20, total: 20 }, + totalSkills: 42, + generatedAt: "not-a-date", + }; + const result = SkillCoverageSchema.safeParse(input); + assert.equal(result.success, false); +}); + +test("SkillCoverageSchema — negative have value fails", () => { + const input = { + api: { have: -1, total: 22 }, + cli: { have: 20, total: 20 }, + totalSkills: 42, + generatedAt: new Date().toISOString(), + }; + const result = SkillCoverageSchema.safeParse(input); + assert.equal(result.success, false); +}); + +// ─── ListQuerySchema ────────────────────────────────────────────────────────── + +test("ListQuerySchema — empty object parses successfully", () => { + const result = ListQuerySchema.safeParse({}); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data.category, undefined); + assert.equal(result.data.area, undefined); + } +}); + +test("ListQuerySchema — valid category parses successfully", () => { + const result = ListQuerySchema.safeParse({ category: "api" }); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data.category, "api"); + } +}); + +test("ListQuerySchema — invalid category fails", () => { + const result = ListQuerySchema.safeParse({ category: "invalid" }); + assert.equal(result.success, false); +}); + +test("ListQuerySchema — area filter parses successfully", () => { + const result = ListQuerySchema.safeParse({ category: "cli", area: "cli-serve" }); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data.area, "cli-serve"); + } +}); + +// ─── GenerateBodySchema ─────────────────────────────────────────────────────── + +test("GenerateBodySchema — empty object applies defaults", () => { + const result = GenerateBodySchema.safeParse({}); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data.dryRun, true); + assert.equal(result.data.prune, false); + assert.equal(result.data.onlyIds, undefined); + } +}); + +test("GenerateBodySchema — explicit dryRun=false parses", () => { + const result = GenerateBodySchema.safeParse({ dryRun: false, prune: true }); + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.data.dryRun, false); + assert.equal(result.data.prune, true); + } +}); + +test("GenerateBodySchema — onlyIds array parses", () => { + const result = GenerateBodySchema.safeParse({ onlyIds: ["omni-providers", "cli-serve"] }); + assert.equal(result.success, true); + if (result.success) { + assert.deepEqual(result.data.onlyIds, ["omni-providers", "cli-serve"]); + } +}); + +test("GenerateBodySchema — non-boolean dryRun fails", () => { + const result = GenerateBodySchema.safeParse({ dryRun: "yes" }); + assert.equal(result.success, false); +}); From 78de1d245530510b7e02ff4604eb5bb575eefdd6 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 20:31:31 -0300 Subject: [PATCH 05/49] feat(agent-skills): expand catalog source to 42 entries (22 API + 20 CLI) Replace 18-entry hardcoded AGENT_SKILLS array with 42-entry CURATED_SKILLS covering all areas listed in D28 of the master plan. Each curated entry has id, name, description, category, area, icon, and optional flags. Adds backward-compatible AGENT_SKILLS alias (deprecated) that maps curated entries to the old AgentSkill shape with empty endpoints/cliCommands arrays so the existing /dashboard/agent-skills page continues to work until F7 rewrites it. Imports AgentSkill, SkillArea, SkillCategory types from src/lib/agentSkills/types.ts (F1 single source of truth) instead of redeclaring locally. --- src/shared/constants/agentSkills.ts | 613 +++++++++++++++++++--------- 1 file changed, 429 insertions(+), 184 deletions(-) diff --git a/src/shared/constants/agentSkills.ts b/src/shared/constants/agentSkills.ts index 5aaf8144cc..773e3df9b8 100644 --- a/src/shared/constants/agentSkills.ts +++ b/src/shared/constants/agentSkills.ts @@ -1,5 +1,8 @@ // Agent Skills metadata — single source of truth for /dashboard/agent-skills. -// Each skill = 1 raw GitHub URL the user copies and pastes to any AI agent. +// Each curated entry drives the catalog; endpoints/cliCommands are resolved +// at runtime by src/lib/agentSkills/catalog.ts (via OpenAPI + CLI parsers). + +import type { AgentSkill, SkillArea, SkillCategory } from "@/lib/agentSkills/types"; const REPO = "diegosouzapw/OmniRoute"; const BRANCH = "main"; @@ -9,189 +12,6 @@ export const AGENT_SKILLS_REPO_URL = `https://github.com/${REPO}`; export const AGENT_SKILLS_RAW_BASE = `https://raw.githubusercontent.com/${REPO}/refs/heads/${BRANCH}/${SKILL_PATH}`; export const AGENT_SKILLS_BLOB_BASE = `https://github.com/${REPO}/blob/${BRANCH}/${SKILL_PATH}`; -export interface AgentSkill { - id: string; - name: string; - description: string; - endpoint: string | null; - icon: string; - category: "api" | "cli"; - isEntry?: boolean; - isNew?: boolean; -} - -export const AGENT_SKILLS: AgentSkill[] = [ - // ── API Skills ────────────────────────────────────────────────────────────── - { - id: "omniroute", - name: "OmniRoute (Entry)", - description: - "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.", - endpoint: null, - icon: "hub", - category: "api", - isEntry: true, - }, - { - id: "omniroute-chat", - name: "Chat", - description: "Chat / code-gen via OpenAI or Anthropic format with streaming and reasoning.", - endpoint: "/v1/chat/completions", - icon: "chat", - category: "api", - }, - { - id: "omniroute-image", - name: "Image Generation", - description: "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI, and more.", - endpoint: "/v1/images/generations", - icon: "image", - category: "api", - }, - { - id: "omniroute-tts", - name: "Text-to-Speech", - description: "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.", - endpoint: "/v1/audio/speech", - icon: "record_voice_over", - category: "api", - }, - { - id: "omniroute-stt", - name: "Speech-to-Text", - description: - "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI, and more.", - endpoint: "/v1/audio/transcriptions", - icon: "mic", - category: "api", - }, - { - id: "omniroute-embeddings", - name: "Embeddings", - description: "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral, and more.", - endpoint: "/v1/embeddings", - icon: "scatter_plot", - category: "api", - }, - { - id: "omniroute-web-search", - name: "Web Search", - description: "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.", - endpoint: "/v1/search", - icon: "search", - category: "api", - }, - { - id: "omniroute-web-fetch", - name: "Web Fetch", - description: "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.", - endpoint: "/v1/web/fetch", - icon: "language", - category: "api", - }, - { - id: "omniroute-mcp", - name: "MCP Server", - description: - "37 tools over SSE/stdio/HTTP: routing, cache, compression, memory, skills, providers, audit.", - endpoint: "/api/mcp/sse", - icon: "electrical_services", - category: "api", - }, - { - id: "omniroute-a2a", - name: "A2A Protocol", - description: - "JSON-RPC 2.0 agent-to-agent server with 5 built-in skills: smart-routing, quota, discovery, cost, health.", - endpoint: "/a2a", - icon: "device_hub", - category: "api", - }, - { - id: "omniroute-routing", - name: "Routing & Combos", - description: - "Create and configure routing combos, 14 strategies, Auto-combo scoring, and fallback chains.", - endpoint: "/api/combos", - icon: "route", - category: "api", - isNew: true, - }, - { - id: "omniroute-compression", - name: "Compression", - description: - "RTK (command output), Caveman (prose), stacked mode, and MCP accessibility-tree filter. Save 60–90% tokens.", - endpoint: "/api/settings/compression", - icon: "compress", - category: "api", - isNew: true, - }, - { - id: "omniroute-monitoring", - name: "Monitoring & Health", - description: - "Health endpoints, circuit breakers, provider metrics (p50/p95/p99), budget guard, and MCP monitoring tools.", - endpoint: "/api/monitoring/health", - icon: "monitor_heart", - category: "api", - isNew: true, - }, - - // ── CLI Skills ─────────────────────────────────────────────────────────────── - { - id: "omniroute-cli", - name: "CLI (Entry)", - description: - "Install, global flags (--output, --base-url, --api-key), environment variables, and index of all CLI capability skills.", - endpoint: null, - icon: "terminal", - category: "cli", - isEntry: true, - isNew: true, - }, - { - id: "omniroute-cli-admin", - name: "CLI Admin", - description: - "Server lifecycle (start/stop/restart), non-interactive setup, doctor diagnostics, backup/restore, autostart, and tunnels.", - endpoint: null, - icon: "manage_accounts", - category: "cli", - isNew: true, - }, - { - id: "omniroute-cli-providers", - name: "CLI Providers & Keys", - description: - "Add/test/remove provider connections, manage API keys, rotate credentials, OAuth flows, list models, and manage combos.", - endpoint: null, - icon: "key", - category: "cli", - isNew: true, - }, - { - id: "omniroute-cli-cloud", - name: "CLI Cloud Agents", - description: - "Control Codex, Devin, and Jules cloud agents — create tasks, track status, approve plans, send messages, and view sources.", - endpoint: null, - icon: "cloud_sync", - category: "cli", - isNew: true, - }, - { - id: "omniroute-cli-eval", - name: "CLI Evals", - description: - "Create and run eval suites, watch live benchmark progress, view scorecards, compare models, and integrate with CI.", - endpoint: null, - icon: "science", - category: "cli", - isNew: true, - }, -]; - export function getAgentSkillRawUrl(id: string): string { return `${AGENT_SKILLS_RAW_BASE}/${id}/SKILL.md`; } @@ -199,3 +19,428 @@ export function getAgentSkillRawUrl(id: string): string { export function getAgentSkillBlobUrl(id: string): string { return `${AGENT_SKILLS_BLOB_BASE}/${id}/SKILL.md`; } + +// ── Curated entry shape ─────────────────────────────────────────────────────── +// Only the fields that cannot be derived at runtime go here. +// The full AgentSkill shape (endpoints, cliCommands, rawUrl, githubUrl) +// is composed by catalog.ts at runtime. + +export interface CuratedSkillEntry { + id: string; + name: string; + description: string; + category: SkillCategory; + area: SkillArea; + icon?: string; + isEntry?: boolean; + isNew?: boolean; +} + +// ── Canonical 42-entry curated list (D28) ──────────────────────────────────── + +/** Curated metadata for all 42 agent skills. Source-of-truth for the catalog. */ +export const CURATED_SKILLS: CuratedSkillEntry[] = [ + // ── API Skills (22) ───────────────────────────────────────────────────────── + + { + id: "omni-auth", + name: "Authentication", + description: + "Manage API key authentication and session tokens. Start here to authenticate requests via Bearer token, obtain session cookies, and configure login requirements for the OmniRoute API.", + category: "api", + area: "auth", + icon: "lock", + isEntry: true, + }, + { + id: "omni-providers", + name: "Providers", + description: + "Manage provider connections, API keys, OAuth flows, and connection tests via the REST API. List, add, update, remove, and test AI provider integrations (OpenAI, Anthropic, Gemini, and 160+).", + category: "api", + area: "providers", + icon: "key", + }, + { + id: "omni-models", + name: "Models", + description: + "Query available AI models across all configured providers. List models, resolve model aliases, and browse the full model catalog including provider-specific variants.", + category: "api", + area: "models", + icon: "neurology", + }, + { + id: "omni-combos-routing", + name: "Combos & Routing", + description: + "Create and manage routing combos with 14 strategies (priority, weighted, round-robin, Auto-combo, etc.). Configure fallback chains, test routing outcomes, and retrieve combo metrics.", + category: "api", + area: "combos-routing", + icon: "route", + isNew: true, + }, + { + id: "omni-api-keys", + name: "API Keys", + description: + "Create, list, rotate, and revoke OmniRoute API keys. Control per-key scopes, spending limits, and expiration. Keys gate access to all proxy and management endpoints.", + category: "api", + area: "api-keys", + icon: "vpn_key", + }, + { + id: "omni-usage-logs", + name: "Usage & Logs", + description: + "Access detailed call logs and usage analytics. Filter by provider, model, time range, status, and cost. Export logs and aggregate token usage across all connections.", + category: "api", + area: "usage-logs", + icon: "bar_chart", + }, + { + id: "omni-budget", + name: "Budget & Rate Limits", + description: + "Configure spending limits, token quotas, and rate-limit policies per API key or globally. Inspect current consumption and enforce cost controls across providers.", + category: "api", + area: "budget", + icon: "savings", + }, + { + id: "omni-settings", + name: "Settings", + description: + "Read and update global application settings: system prompts, thinking budget, IP filters, payload rules, combo defaults, and require-login configuration.", + category: "api", + area: "settings", + icon: "settings", + }, + { + id: "omni-proxies", + name: "Proxy Configuration", + description: + "Configure HTTP/HTTPS/SOCKS proxies for upstream provider requests. Set per-provider or global proxy rules, test connectivity, and manage proxy rotation.", + category: "api", + area: "proxies", + icon: "swap_horiz", + }, + { + id: "omni-cache", + name: "Cache", + description: + "Manage the LLM response cache. View cache statistics, clear entries, configure TTL policies, and control semantic-similarity caching thresholds.", + category: "api", + area: "cache", + icon: "cached", + }, + { + id: "omni-compression", + name: "Compression", + description: + "Configure RTK (command output), Caveman (prose), and stacked compression modes. Manage language packs, custom rules, and test prompt compression reducing tokens by 60–90%.", + category: "api", + area: "compression", + icon: "compress", + isNew: true, + }, + { + id: "omni-context-rtk", + name: "Context & RTK", + description: + "Configure RTK filters, context engineering rules, and context relay settings. Test compression with real prompt samples and manage context transformation pipelines.", + category: "api", + area: "context-rtk", + icon: "data_object", + isNew: true, + }, + { + id: "omni-resilience", + name: "Resilience & Monitoring", + description: + "Monitor provider health, circuit-breaker states, p50/p95/p99 latency metrics, and budget guard alerts. Inspect connection cooldowns and model lockouts in real time.", + category: "api", + area: "resilience", + icon: "monitor_heart", + isNew: true, + }, + { + id: "omni-cli-tools", + name: "CLI Tools", + description: + "Manage CLI tool integrations exposed via the API. List, configure, and invoke CLI tool plugins that extend OmniRoute's automation surface.", + category: "api", + area: "cli-tools", + icon: "terminal", + }, + { + id: "omni-tunnels", + name: "Tunnels", + description: + "Create and manage secure tunnels (ngrok, Cloudflare Tunnel, custom) to expose OmniRoute to the internet or share access with remote agents and CI pipelines.", + category: "api", + area: "tunnels", + icon: "vpn_lock", + }, + { + id: "omni-sync-cloud", + name: "Cloud Sync", + description: + "Synchronise OmniRoute configuration, provider connections, and settings to/from cloud storage. Manage cloud worker authentication and remote backup targets.", + category: "api", + area: "sync-cloud", + icon: "cloud_sync", + }, + { + id: "omni-db-backups", + name: "Database & Backups", + description: + "Trigger system backups, restore from backup files, and manage the SQLite database lifecycle. Supports export, import, and incremental snapshot strategies.", + category: "api", + area: "db-backups", + icon: "backup", + }, + { + id: "omni-webhooks", + name: "Webhooks", + description: + "Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries.", + category: "api", + area: "webhooks", + icon: "webhook", + }, + { + id: "omni-mcp", + name: "MCP Server", + description: + "Connect to the OmniRoute MCP server (37 tools, 3 transports: SSE/stdio/HTTP). Covers routing, cache, compression, memory, skills, providers, and audit tools across 16 permission scopes.", + category: "api", + area: "mcp", + icon: "electrical_services", + }, + { + id: "omni-agents-a2a", + name: "Agents & A2A Protocol", + description: + "Interact with OmniRoute via JSON-RPC 2.0 agent-to-agent protocol. 6 built-in A2A skills: smart-routing, quota-management, provider-discovery, cost-analysis, health-report, list-capabilities.", + category: "api", + area: "agents-a2a", + icon: "device_hub", + }, + { + id: "omni-version-manager", + name: "Version Manager", + description: + "Install, start, stop, restart, and update embedded services (9Router, CLIProxyAPI). Monitor service status, retrieve logs, and configure auto-start for local-only service endpoints.", + category: "api", + area: "version-manager", + icon: "manage_history", + }, + { + id: "omni-inference", + name: "Inference (OpenAI-compatible)", + description: + "The core OpenAI-compatible inference endpoints: chat completions, embeddings, images, audio (TTS/STT), moderations, rerank, and the Responses API. The primary integration surface for AI agents.", + category: "api", + area: "inference", + icon: "hub", + }, + + // ── CLI Skills (20) ────────────────────────────────────────────────────────── + + { + id: "cli-serve", + name: "CLI: Serve", + description: + "Start, stop, and restart the OmniRoute server from the CLI. Manage daemon mode, port configuration, auto-recovery, system tray integration, and the dashboard open shortcut.", + category: "cli", + area: "cli-serve", + icon: "play_circle", + isEntry: true, + }, + { + id: "cli-health", + name: "CLI: Health", + description: + "Check server health, component status, and live metrics from the CLI. Run `health`, `health components`, and `health watch` for a real-time dashboard of circuit breakers and provider status.", + category: "cli", + area: "cli-health", + icon: "favorite", + }, + { + id: "cli-providers", + name: "CLI: Providers", + description: + "Manage provider connections from the CLI: list available/configured providers, add, test, test-all, validate, rotate API keys, and view per-provider metrics.", + category: "cli", + area: "cli-providers", + icon: "key", + }, + { + id: "cli-keys", + name: "CLI: API Keys", + description: + "Create, list, rotate, and revoke OmniRoute API keys from the CLI. Manage OAuth flows for provider authentication and inspect key scopes and expiration.", + category: "cli", + area: "cli-keys", + icon: "vpn_key", + }, + { + id: "cli-models", + name: "CLI: Models", + description: + "Query available AI models, list model aliases, and browse the full model catalog from the CLI. Filter by provider, search by capability, and resolve model name variants.", + category: "cli", + area: "cli-models", + icon: "neurology", + }, + { + id: "cli-chat", + name: "CLI: Chat", + description: + "Send chat completions, stream responses, and start an interactive REPL session from the CLI. Supports all OmniRoute providers, combo routing, and system prompt configuration.", + category: "cli", + area: "cli-chat", + icon: "chat", + }, + { + id: "cli-routing", + name: "CLI: Routing & Combos", + description: + "Create, list, update, and delete routing combos from the CLI. Test routing strategies, inspect combo metrics, and configure fallback chains interactively.", + category: "cli", + area: "cli-routing", + icon: "route", + }, + { + id: "cli-resilience", + name: "CLI: Resilience & Quotas", + description: + "Inspect and manage circuit-breaker states, connection cooldowns, quota limits, and backoff levels from the CLI. Reset stuck providers and configure resilience thresholds.", + category: "cli", + area: "cli-resilience", + icon: "monitor_heart", + }, + { + id: "cli-compression", + name: "CLI: Compression", + description: + "Configure and test prompt compression from the CLI. Manage RTK filters, Caveman rules, stacked compression modes, and preview compression output with real prompts.", + category: "cli", + area: "cli-compression", + icon: "compress", + }, + { + id: "cli-contexts", + name: "CLI: Contexts & Sessions", + description: + "Manage context engineering configurations, RTK filter sets, and conversation sessions from the CLI. Apply context-relay settings and inspect active context pipelines.", + category: "cli", + area: "cli-contexts", + icon: "data_object", + }, + { + id: "cli-cost-usage", + name: "CLI: Cost & Usage", + description: + "View cost breakdowns, token usage, and call logs from the CLI. Filter by provider, model, or date range. Export usage reports and inspect per-connection spending.", + category: "cli", + area: "cli-cost-usage", + icon: "savings", + }, + { + id: "cli-mcp", + name: "CLI: MCP", + description: + "Inspect the MCP server status, list registered tools and scopes, run tool invocations, and manage MCP audit logs from the CLI.", + category: "cli", + area: "cli-mcp", + icon: "electrical_services", + }, + { + id: "cli-a2a", + name: "CLI: A2A Protocol", + description: + "Interact with the OmniRoute A2A server from the CLI. Send tasks, inspect skill execution history, and test the JSON-RPC 2.0 agent-to-agent protocol interactively.", + category: "cli", + area: "cli-a2a", + icon: "device_hub", + }, + { + id: "cli-tunnel", + name: "CLI: Tunnels", + description: + "Start and stop tunnel connections (ngrok, Cloudflare, custom) from the CLI. Inspect active tunnel URLs, configure authentication, and test external reachability.", + category: "cli", + area: "cli-tunnel", + icon: "vpn_lock", + }, + { + id: "cli-backup-sync", + name: "CLI: Backup & Sync", + description: + "Backup and restore OmniRoute data from the CLI. Trigger incremental snapshots, sync to cloud storage, manage backup schedules, and restore from archive files.", + category: "cli", + area: "cli-backup-sync", + icon: "backup", + }, + { + id: "cli-policy-audit", + name: "CLI: Policy & Audit", + description: + "Inspect audit logs, manage access policies, view telemetry data, and review request history from the CLI. Filter by event type, user, or time range for compliance workflows.", + category: "cli", + area: "cli-policy-audit", + icon: "policy", + }, + { + id: "cli-batches", + name: "CLI: Batches & Files", + description: + "Submit and monitor batch inference jobs from the CLI. Upload and manage files for batch processing, retrieve results, and integrate batch pipelines with CI/CD workflows.", + category: "cli", + area: "cli-batches", + icon: "batch_prediction", + }, + { + id: "cli-eval", + name: "CLI: Evals", + description: + "Create and run evaluation suites, watch live benchmark progress, view scorecards, compare model performance, and integrate eval runs with CI workflows from the CLI.", + category: "cli", + area: "cli-eval", + icon: "science", + }, + { + id: "cli-plugins-skills", + name: "CLI: Plugins, Skills & Memory", + description: + "Manage Omni Skills (list, install, test, remove), plugins (create, configure), and persistent memory (search, add, clear) from the CLI.", + category: "cli", + area: "cli-plugins-skills", + icon: "extension", + }, + { + id: "cli-setup", + name: "CLI: Setup & Config", + description: + "Run initial setup, configure global CLI settings, manage environment variables, check for updates, and configure autostart via the CLI setup and config commands.", + category: "cli", + area: "cli-setup", + icon: "build", + }, +]; + +// ── Backward-compatible re-export ───────────────────────────────────────────── +// TODO(F7): Remove AGENT_SKILLS alias once AgentSkillsPageClient is rewritten. +// The old shape expected `endpoint` (singular string | null) instead of `endpoints` (array). +// This shim preserves the old dashboard until F7 rewrites it. + +/** @deprecated Use getCatalog() from src/lib/agentSkills/catalog.ts instead. */ +export const AGENT_SKILLS: AgentSkill[] = CURATED_SKILLS.map((s) => ({ + ...s, + endpoints: s.category === "api" ? [] : undefined, + cliCommands: s.category === "cli" ? [] : undefined, + rawUrl: getAgentSkillRawUrl(s.id), + githubUrl: getAgentSkillBlobUrl(s.id), +})); From a0cc22be7378ea3b3707e3cf55dfad2b4efd8329 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 20:31:44 -0300 Subject: [PATCH 06/49] feat(agent-skills): add catalog.ts with getCatalog/filter/coverage/fetch helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the catalog.ts public API defined in §3.3 of the master plan: - getCatalog(): AgentSkill[] — returns 42 entries, lazy-cached in module scope - getSkillById(id): AgentSkill | null — lookup by canonical ID - filterCatalog(opts): AgentSkill[] — filter by category and/or area - computeCoverage(): SkillCoverage — reads skills/ dir and counts SKILL.md present - refreshCatalog(): void — invalidates cache (used by tests + generator) - fetchSkillMarkdown(id): Promise — reads local fs first, falls back to GitHub raw fetch with 1h Next.js cache (for F4 /raw route) API_SKILL_IDS and CLI_SKILL_IDS exported as readonly string arrays (D28 order). Single source of truth for all consumers (REST routes, MCP, A2A). --- src/lib/agentSkills/catalog.ts | 248 +++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 src/lib/agentSkills/catalog.ts diff --git a/src/lib/agentSkills/catalog.ts b/src/lib/agentSkills/catalog.ts new file mode 100644 index 0000000000..05e8c7119d --- /dev/null +++ b/src/lib/agentSkills/catalog.ts @@ -0,0 +1,248 @@ +/** + * catalog.ts — single source of truth for the 42-entry Agent Skills catalog. + * + * Consumers: REST routes (/api/agent-skills/*), MCP tools, A2A skill, Generator. + * Do NOT import this from UI components directly — use the REST API instead. + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { AgentSkill, SkillCoverage, SkillMarkdown } from "./types"; +import { CURATED_SKILLS, getAgentSkillRawUrl, getAgentSkillBlobUrl } from "@/shared/constants/agentSkills"; + +// ── Canonical ID lists (D28) ──────────────────────────────────────────────── + +/** 22 canonical API skill IDs, in spec order. */ +export const API_SKILL_IDS: readonly string[] = [ + "omni-auth", + "omni-providers", + "omni-models", + "omni-combos-routing", + "omni-api-keys", + "omni-usage-logs", + "omni-budget", + "omni-settings", + "omni-proxies", + "omni-cache", + "omni-compression", + "omni-context-rtk", + "omni-resilience", + "omni-cli-tools", + "omni-tunnels", + "omni-sync-cloud", + "omni-db-backups", + "omni-webhooks", + "omni-mcp", + "omni-agents-a2a", + "omni-version-manager", + "omni-inference", +] as const; + +/** 20 canonical CLI skill IDs, in spec order. */ +export const CLI_SKILL_IDS: readonly string[] = [ + "cli-serve", + "cli-health", + "cli-providers", + "cli-keys", + "cli-models", + "cli-chat", + "cli-routing", + "cli-resilience", + "cli-compression", + "cli-contexts", + "cli-cost-usage", + "cli-mcp", + "cli-a2a", + "cli-tunnel", + "cli-backup-sync", + "cli-policy-audit", + "cli-batches", + "cli-eval", + "cli-plugins-skills", + "cli-setup", +] as const; + +// ── Module-scope cache ────────────────────────────────────────────────────── + +let _cache: AgentSkill[] | null = null; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function buildFullSkill( + curated: (typeof CURATED_SKILLS)[number], +): AgentSkill { + return { + ...curated, + endpoints: curated.category === "api" ? [] : undefined, + cliCommands: curated.category === "cli" ? [] : undefined, + rawUrl: getAgentSkillRawUrl(curated.id), + githubUrl: getAgentSkillBlobUrl(curated.id), + }; +} + +function deriveCatalog(): AgentSkill[] { + return CURATED_SKILLS.map(buildFullSkill); +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** + * Returns the full catalog (42 entries). Cached in module scope after first call. + * Safe to call multiple times — re-derives only after `refreshCatalog()`. + */ +export function getCatalog(): AgentSkill[] { + if (!_cache) { + _cache = deriveCatalog(); + } + return _cache; +} + +/** Returns single skill metadata or null. */ +export function getSkillById(id: string): AgentSkill | null { + return getCatalog().find((s) => s.id === id) ?? null; +} + +/** Filters catalog by category and/or area. */ +export function filterCatalog(opts: { category?: "api" | "cli"; area?: string }): AgentSkill[] { + let skills = getCatalog(); + if (opts.category) { + skills = skills.filter((s) => s.category === opts.category); + } + if (opts.area) { + skills = skills.filter((s) => s.area === opts.area); + } + return skills; +} + +/** + * Computes coverage stats: filesystem has SKILL.md vs catalog declares 42. + * Reads `skills/` relative to the project root (CWD). + */ +export function computeCoverage(): SkillCoverage { + const catalog = getCatalog(); + const skillsDir = path.resolve(process.cwd(), "skills"); + + let presentIds: Set; + try { + const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + presentIds = new Set( + entries + .filter((e) => e.isDirectory()) + .filter((e) => fs.existsSync(path.join(skillsDir, e.name, "SKILL.md"))) + .map((e) => e.name), + ); + } catch { + // Directory doesn't exist yet — zero coverage + presentIds = new Set(); + } + + const apiHave = catalog.filter((s) => s.category === "api" && presentIds.has(s.id)).length; + const cliHave = catalog.filter((s) => s.category === "cli" && presentIds.has(s.id)).length; + + return { + api: { have: apiHave, total: 22 }, + cli: { have: cliHave, total: 20 }, + totalSkills: apiHave + cliHave, + generatedAt: new Date().toISOString(), + }; +} + +/** + * Forces re-derivation of the catalog on next `getCatalog()` call. + * Used by tests and by the generator after writing new SKILL.md files. + */ +export function refreshCatalog(): void { + _cache = null; +} + +/** + * Fetches the SKILL.md content for a given skill ID. + * + * Resolution order: + * 1. Local filesystem `skills/{id}/SKILL.md` (fast, used during dev + after generation) + * 2. GitHub raw URL with 1-hour cache (production fallback when file not yet generated) + * + * Returns a `SkillMarkdown` shape. Throws if both sources fail. + * Used by: F4 `/api/agent-skills/[id]/raw` route. + */ +export async function fetchSkillMarkdown(id: string): Promise { + const localPath = path.resolve(process.cwd(), "skills", id, "SKILL.md"); + + // 1. Try filesystem first + try { + const raw = fs.readFileSync(localPath, "utf-8"); + const parsed = parseMarkdownFrontmatter(raw); + return { + id, + frontmatter: parsed.frontmatter, + body: parsed.body, + source: "filesystem", + fetchedAt: new Date().toISOString(), + }; + } catch { + // File not present locally — fall through to GitHub + } + + // 2. Fetch from GitHub raw (with Next.js revalidate cache if available) + const skill = getSkillById(id); + if (!skill) { + throw new Error(`Skill not found in catalog: ${id}`); + } + + const response = await fetch(skill.rawUrl, { + // @ts-expect-error — Next.js extended fetch options + next: { revalidate: 3600 }, + }); + + if (!response.ok) { + throw new Error(`GitHub raw fetch failed: HTTP ${response.status} for ${skill.rawUrl}`); + } + + const raw = await response.text(); + const parsed = parseMarkdownFrontmatter(raw); + + return { + id, + frontmatter: parsed.frontmatter, + body: parsed.body, + source: "github", + fetchedAt: new Date().toISOString(), + }; +} + +// ── Internal helpers ───────────────────────────────────────────────────────── + +/** + * Parses YAML frontmatter from a markdown string. + * Expects: `---\nkey: value\n---\n` format. + * Returns default values if frontmatter is absent or malformed. + */ +function parseMarkdownFrontmatter(content: string): { + frontmatter: { name: string; description: string }; + body: string; +} { + const FM_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/; + const match = FM_REGEX.exec(content); + + if (!match) { + return { + frontmatter: { name: "", description: "" }, + body: content, + }; + } + + const yamlBlock = match[1]; + const body = match[2] ?? ""; + + // Simple key: value extraction (avoids importing js-yaml here to stay lightweight) + const nameMatch = /^name:\s*(.+)$/m.exec(yamlBlock); + const descMatch = /^description:\s*(.+)$/m.exec(yamlBlock); + + return { + frontmatter: { + name: nameMatch ? nameMatch[1].trim().replace(/^["']|["']$/g, "") : "", + description: descMatch ? descMatch[1].trim().replace(/^["']|["']$/g, "") : "", + }, + body, + }; +} From 0a95372746a2991ba0a20d3cea939e670202e61b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 20:31:55 -0300 Subject: [PATCH 07/49] feat(agent-skills): add openapiParser and cliRegistryParser openapiParser.ts: - parseOpenapi(): reads docs/reference/openapi.yaml via js-yaml (already a dep) and returns { paths: Map, areas: Map } - PATH_AREA_MAP maps 30+ path prefixes to SkillArea values - getEndpointsForArea(area): convenience helper returning 'METHOD /path' strings cliRegistryParser.ts: - parseCliRegistry(): reads all bin/cli/commands/*.mjs via fs.readdirSync and regex-parses .command(), .description(), .option() calls - FILE_FAMILY_MAP maps 40+ file basenames to CLI SkillArea families - getCommandsForFamily(family): convenience helper for catalog consumers - Does NOT import Commander.js modules to avoid side-effects (D15) --- src/lib/agentSkills/cliRegistryParser.ts | 242 +++++++++++++++++++++++ src/lib/agentSkills/openapiParser.ts | 199 +++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 src/lib/agentSkills/cliRegistryParser.ts create mode 100644 src/lib/agentSkills/openapiParser.ts diff --git a/src/lib/agentSkills/cliRegistryParser.ts b/src/lib/agentSkills/cliRegistryParser.ts new file mode 100644 index 0000000000..88c61fb5b8 --- /dev/null +++ b/src/lib/agentSkills/cliRegistryParser.ts @@ -0,0 +1,242 @@ +/** + * cliRegistryParser.ts — regex-based parser for bin/cli/commands/*.mjs files. + * + * Extracts command families and their subcommands WITHOUT importing the modules + * (Commander.js has side-effects when required as a program instance, D15). + * + * Parse strategy: + * - Read all *.mjs files under bin/cli/commands/ + * - Detect top-level command name from `.command("")` patterns + * - Detect subcommands from chained `.command("")` patterns + * - Map file basename → SkillArea family via FAMILY_MAP + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { SkillArea } from "./types"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface CliCommand { + /** Canonical command string, e.g. "providers list" */ + name: string; + /** Description extracted from .description("...") */ + description: string; + /** Flags extracted from .option("--flag", "...") */ + flags: string[]; + /** Whether this is a top-level command (depth 0) or subcommand (depth > 0) */ + isSubcommand: boolean; +} + +export interface ParsedCliRegistry { + /** All commands keyed by their full name, e.g. "providers list" */ + commands: Map; + /** Commands grouped by SkillArea family */ + families: Map; +} + +// ── Mapping: file basename → CLI SkillArea ─────────────────────────────────── + +/** + * Maps a commands/*.mjs basename to its CLI SkillArea. + * Files that don't map to a known family are ignored. + */ +const FILE_FAMILY_MAP: Record = { + "serve": "cli-serve", + "dashboard": "cli-serve", + "stop": "cli-serve", + "restart": "cli-serve", + "health": "cli-health", + "status": "cli-health", + "doctor": "cli-health", + "providers": "cli-providers", + "provider-cmd": "cli-providers", + "test-provider": "cli-providers", + "keys": "cli-keys", + "oauth": "cli-keys", + "models": "cli-models", + "chat": "cli-chat", + "stream": "cli-chat", + "repl": "cli-chat", + "combo": "cli-routing", + "routing": "cli-routing", + "resilience": "cli-resilience", + "quota": "cli-resilience", + "compression": "cli-compression", + "context-eng": "cli-contexts", + "contexts": "cli-contexts", + "sessions": "cli-contexts", + "cost": "cli-cost-usage", + "usage": "cli-cost-usage", + "pricing": "cli-cost-usage", + "mcp": "cli-mcp", + "a2a": "cli-a2a", + "tunnel": "cli-tunnel", + "backup": "cli-backup-sync", + "sync": "cli-backup-sync", + "cloud": "cli-backup-sync", + "audit": "cli-policy-audit", + "policy": "cli-policy-audit", + "logs": "cli-policy-audit", + "telemetry": "cli-policy-audit", + "batches": "cli-batches", + "files": "cli-batches", + "eval": "cli-eval", + "simulate": "cli-eval", + "skills": "cli-plugins-skills", + "plugin": "cli-plugins-skills", + "memory": "cli-plugins-skills", + "setup": "cli-setup", + "config": "cli-setup", + "env": "cli-setup", + "update": "cli-setup", + "autostart": "cli-setup", +}; + +// ── Regex patterns ─────────────────────────────────────────────────────────── + +// Matches: .command("name") or .command('name') — capture group 1 = name +const COMMAND_RE = /\.command\(\s*["']([^"']+)["']/g; + +// Matches: .description("text") or .description('text') — capture group 1 = text +const DESCRIPTION_RE = /\.description\(\s*["']([^"']+)["']/g; + +// Matches: .option("--flag ...", "desc") — capture group 1 = flag string +const OPTION_RE = /\.option\(\s*["']([^"']+)["']/g; + +// ── Parser helpers ─────────────────────────────────────────────────────────── + +interface RawCommand { + name: string; + description: string; + flags: string[]; +} + +/** + * Extracts all commands (and their immediately following description + options) + * from a single .mjs file content. + * + * Limitation: uses regex, not a full AST — deeply nested or dynamically + * constructed commands may be missed. This is acceptable for the catalog use-case + * where we want a list of known subcommand names, not runtime-validated metadata. + */ +function extractCommandsFromContent(content: string, topLevelName: string): RawCommand[] { + const commands: RawCommand[] = []; + + // Find all .command() call positions + COMMAND_RE.lastIndex = 0; + let match: RegExpExecArray | null; + const commandMatches: Array<{ name: string; index: number }> = []; + + while ((match = COMMAND_RE.exec(content)) !== null) { + commandMatches.push({ name: match[1], index: match.index }); + } + + for (let i = 0; i < commandMatches.length; i++) { + const { name: rawName, index: cmdIndex } = commandMatches[i]; + const nextIndex = commandMatches[i + 1]?.index ?? content.length; + + // Slice between this command call and the next to scope description/options + const slice = content.slice(cmdIndex, nextIndex); + + // Extract description (first match in slice) + DESCRIPTION_RE.lastIndex = 0; + const descMatch = DESCRIPTION_RE.exec(slice); + const description = descMatch ? descMatch[1] : ""; + + // Extract flags in slice + const flags: string[] = []; + OPTION_RE.lastIndex = 0; + let optMatch: RegExpExecArray | null; + while ((optMatch = OPTION_RE.exec(slice)) !== null) { + flags.push(optMatch[1]); + } + + // Compose full command name: + // - If rawName equals the top-level name (or is the isDefault pattern), use as-is + // - Otherwise, qualify as "topLevel subname" + const isTopLevel = + rawName === topLevelName || + rawName.startsWith(topLevelName + " ") || + // Some files declare standalone root commands (e.g. serve, health) + !rawName.includes(" "); + + const fullName = isTopLevel && i === 0 ? rawName : `${topLevelName} ${rawName}`; + + commands.push({ name: fullName.trim(), description, flags }); + } + + return commands; +} + +// ── Main export ────────────────────────────────────────────────────────────── + +/** + * Reads all `bin/cli/commands/*.mjs` files and extracts CLI command metadata. + * + * Returns: + * - `commands`: flat map of all commands by full name + * - `families`: commands grouped by SkillArea + */ +export function parseCliRegistry(): ParsedCliRegistry { + const commandsDir = path.resolve(process.cwd(), "bin", "cli", "commands"); + + let files: string[]; + try { + files = fs.readdirSync(commandsDir).filter((f) => f.endsWith(".mjs")); + } catch (err) { + throw new Error( + `cliRegistryParser: could not read ${commandsDir}. ` + + `Run from project root. Underlying error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const commands = new Map(); + const families = new Map(); + + for (const file of files) { + const basename = path.basename(file, ".mjs"); + const family = FILE_FAMILY_MAP[basename]; + if (!family) continue; // skip unrecognised files (e.g. runtime.mjs, repl.mjs) + + const filePath = path.join(commandsDir, file); + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + continue; // skip unreadable files + } + + const rawCmds = extractCommandsFromContent(content, basename); + if (rawCmds.length === 0) continue; + + for (let i = 0; i < rawCmds.length; i++) { + const rc = rawCmds[i]; + const cliCmd: CliCommand = { + name: rc.name, + description: rc.description, + flags: rc.flags, + isSubcommand: i > 0, + }; + + commands.set(rc.name, cliCmd); + + if (!families.has(family)) { + families.set(family, []); + } + families.get(family)!.push(cliCmd); + } + } + + return { commands, families }; +} + +/** + * Returns command name strings for a given CLI SkillArea family, + * suitable for `AgentSkill.cliCommands`. + */ +export function getCommandsForFamily(family: SkillArea): string[] { + const { families } = parseCliRegistry(); + const cmds = families.get(family) ?? []; + return cmds.map((c) => c.name); +} diff --git a/src/lib/agentSkills/openapiParser.ts b/src/lib/agentSkills/openapiParser.ts new file mode 100644 index 0000000000..ffdf95ed84 --- /dev/null +++ b/src/lib/agentSkills/openapiParser.ts @@ -0,0 +1,199 @@ +/** + * openapiParser.ts — parses docs/reference/openapi.yaml to extract endpoint info + * grouped by SkillArea. Used by the catalog and the generator. + * + * Reads the OpenAPI YAML synchronously at runtime (same pattern as + * src/app/api/openapi/spec/route.ts). Does NOT fetch via HTTP to remain + * usable as a standalone script/CI tool (D15). + */ + +import fs from "node:fs"; +import path from "node:path"; +import yaml from "js-yaml"; +import type { SkillArea } from "./types"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface OpenapiPath { + /** HTTP method (uppercase): "GET", "POST", etc. */ + method: string; + /** OpenAPI path template, e.g. "/api/providers/{id}" */ + path: string; + /** Summary from the operation object */ + summary: string; + /** Description from the operation object (may be absent) */ + description?: string; + /** OpenAPI tags */ + tags: string[]; +} + +export interface ParsedOpenapi { + /** All endpoints keyed by " " */ + paths: Map; + /** Endpoints grouped by SkillArea (only API-mapped areas) */ + areas: Map; +} + +// ── Mapping: path prefix → SkillArea ──────────────────────────────────────── + +/** + * Maps an API path prefix to the corresponding SkillArea. + * Order matters: more specific prefixes must come before generic ones. + */ +const PATH_AREA_MAP: Array<[string, SkillArea]> = [ + // Auth + ["/api/auth", "auth"], + ["/api/session", "auth"], + // Providers + ["/api/providers", "providers"], + ["/api/provider-nodes", "providers"], + ["/api/provider-models", "providers"], + // Models + ["/api/v1/models", "models"], + ["/api/models", "models"], + // Combos / routing + ["/api/combos", "combos-routing"], + ["/api/fallback", "combos-routing"], + // API Keys + ["/api/keys", "api-keys"], + // Usage logs + ["/api/usage", "usage-logs"], + // Budget / rate limit + ["/api/rate-limit", "budget"], + ["/api/budget", "budget"], + // Settings + ["/api/settings", "settings"], + ["/api/tags", "settings"], + // Proxies + ["/api/settings/proxy", "proxies"], + // Cache + ["/api/cache", "cache"], + // Compression / RTK + ["/api/settings/compression", "compression"], + ["/api/compression", "compression"], + ["/api/context/rtk", "context-rtk"], + // Resilience + ["/api/monitoring", "resilience"], + ["/api/provider-metrics", "resilience"], + ["/api/circuit-breakers", "resilience"], + // CLI tools + ["/api/cli-tools", "cli-tools"], + // Tunnels + ["/api/tunnel", "tunnels"], + // Sync / cloud + ["/api/cloud", "sync-cloud"], + ["/api/sync", "sync-cloud"], + // DB backups + ["/api/system", "db-backups"], + ["/api/backup", "db-backups"], + // Webhooks + ["/api/webhooks", "webhooks"], + // MCP + ["/api/mcp", "mcp"], + // A2A + ["/a2a", "agents-a2a"], + // Version manager + ["/api/services", "version-manager"], + ["/api/version", "version-manager"], + // Inference (catch-all for /api/v1/* proxy endpoints) + ["/api/v1", "inference"], +]; + +// ── HTTP methods recognised as operations ──────────────────────────────────── + +const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const; + +// ── Parser ─────────────────────────────────────────────────────────────────── + +function resolveArea(urlPath: string): SkillArea | null { + for (const [prefix, area] of PATH_AREA_MAP) { + if (urlPath === prefix || urlPath.startsWith(prefix + "/") || urlPath.startsWith(prefix + "{")) { + return area; + } + } + return null; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extractOperations(pathsObj: Record): OpenapiPath[] { + const ops: OpenapiPath[] = []; + + for (const [urlPath, pathItem] of Object.entries(pathsObj)) { + if (!pathItem || typeof pathItem !== "object") continue; + + for (const method of HTTP_METHODS) { + const operation = pathItem[method]; + if (!operation || typeof operation !== "object") continue; + + ops.push({ + method: method.toUpperCase(), + path: urlPath, + summary: String(operation.summary ?? ""), + description: operation.description ? String(operation.description) : undefined, + tags: Array.isArray(operation.tags) ? operation.tags.map(String) : [], + }); + } + } + + return ops; +} + +/** + * Parses `docs/reference/openapi.yaml` and returns: + * - `paths`: all operations keyed by `"METHOD /path"` + * - `areas`: operations grouped by SkillArea (api skills only) + * + * Reads the file synchronously so it can be called from both server context + * and standalone scripts without async machinery. + */ +export function parseOpenapi(): ParsedOpenapi { + const yamlPath = path.resolve(process.cwd(), "docs", "reference", "openapi.yaml"); + let rawContent: string; + + try { + rawContent = fs.readFileSync(yamlPath, "utf-8"); + } catch (err) { + throw new Error( + `openapiParser: could not read ${yamlPath}. ` + + `Run from project root. Underlying error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const doc = yaml.load(rawContent) as Record; + + if (!doc || typeof doc !== "object") { + throw new Error("openapiParser: parsed YAML is not an object"); + } + + const pathsObj = doc.paths ?? {}; + const operations = extractOperations(pathsObj); + + const paths = new Map(); + const areas = new Map(); + + for (const op of operations) { + const key = `${op.method} ${op.path}`; + paths.set(key, op); + + const area = resolveArea(op.path); + if (area) { + if (!areas.has(area)) { + areas.set(area, []); + } + areas.get(area)!.push(op); + } + } + + return { paths, areas }; +} + +/** + * Returns endpoint strings for a given SkillArea, suitable for `AgentSkill.endpoints`. + * Format: `"GET /api/providers/{id}"`. + */ +export function getEndpointsForArea(area: SkillArea): string[] { + const { areas } = parseOpenapi(); + const ops = areas.get(area) ?? []; + return ops.map((op) => `${op.method} ${op.path}`); +} From 15838348c3b0fb557158a3a3eef9e3f9974741ac Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 20:32:08 -0300 Subject: [PATCH 08/49] test(agent-skills): unit tests for catalog + openapi + cli parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agentSkills-catalog.test.ts (30 tests): - getCatalog(): 42 total, 22 api, 20 cli - API_SKILL_IDS/CLI_SKILL_IDS length assertions - ID regex format, uniqueness, required fields - getSkillById happy path + null for unknown/empty - filterCatalog by category, area, combined, empty - refreshCatalog() invalidates cache (new array reference) - computeCoverage() shape validation - rawUrl/githubUrl URL format assertions agentSkills-openapiParser.test.ts (9 tests): - Fixture YAML: paths Map, area groupings (providers, api-keys, inference) - OpenapiPath field validation - Missing file throws - Empty paths YAML returns empty Maps - Real openapi.yaml: providers area ≥5 endpoints (integration) agentSkills-cliRegistryParser.test.ts (10 tests): - Fixture .mjs: commands Map, families Map, ≥5 provider subcommands - Description extraction, isSubcommand flag, flags extraction - Skips unrecognised files, throws on missing dir - Real providers.mjs: ≥5 commands (integration) --- tests/unit/agentSkills-catalog.test.ts | 239 ++++++++++++++ .../agentSkills-cliRegistryParser.test.ts | 304 ++++++++++++++++++ tests/unit/agentSkills-openapiParser.test.ts | 247 ++++++++++++++ 3 files changed, 790 insertions(+) create mode 100644 tests/unit/agentSkills-catalog.test.ts create mode 100644 tests/unit/agentSkills-cliRegistryParser.test.ts create mode 100644 tests/unit/agentSkills-openapiParser.test.ts diff --git a/tests/unit/agentSkills-catalog.test.ts b/tests/unit/agentSkills-catalog.test.ts new file mode 100644 index 0000000000..73ecbe1e0c --- /dev/null +++ b/tests/unit/agentSkills-catalog.test.ts @@ -0,0 +1,239 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Dynamic imports to pick up ESM modules with tsx +const { getCatalog, getSkillById, filterCatalog, computeCoverage, refreshCatalog, API_SKILL_IDS, CLI_SKILL_IDS } = + await import("../../src/lib/agentSkills/catalog.ts"); + +// ─── Counts ─────────────────────────────────────────────────────────────────── + +test("getCatalog() returns exactly 42 entries", () => { + refreshCatalog(); + const catalog = getCatalog(); + assert.equal(catalog.length, 42, `Expected 42 but got ${catalog.length}`); +}); + +test("API_SKILL_IDS has exactly 22 entries", () => { + assert.equal(API_SKILL_IDS.length, 22); +}); + +test("CLI_SKILL_IDS has exactly 20 entries", () => { + assert.equal(CLI_SKILL_IDS.length, 20); +}); + +test("getCatalog() contains exactly 22 api skills", () => { + const apiSkills = getCatalog().filter((s) => s.category === "api"); + assert.equal(apiSkills.length, 22); +}); + +test("getCatalog() contains exactly 20 cli skills", () => { + const cliSkills = getCatalog().filter((s) => s.category === "cli"); + assert.equal(cliSkills.length, 20); +}); + +// ─── ID format ──────────────────────────────────────────────────────────────── + +test("all skill IDs match regex ^[a-z][a-z0-9-]*$", () => { + const ID_REGEX = /^[a-z][a-z0-9-]*$/; + for (const skill of getCatalog()) { + assert.match( + skill.id, + ID_REGEX, + `Skill ID "${skill.id}" does not match expected format`, + ); + } +}); + +test("all skill IDs are unique (no duplicates)", () => { + const ids = getCatalog().map((s) => s.id); + const uniqueIds = new Set(ids); + assert.equal( + uniqueIds.size, + ids.length, + `Duplicate IDs found: ${ids.filter((id, i) => ids.indexOf(id) !== i).join(", ")}`, + ); +}); + +// ─── Required fields ────────────────────────────────────────────────────────── + +test("all skills have non-empty name and description", () => { + for (const skill of getCatalog()) { + assert.ok(skill.name.length > 0, `Skill ${skill.id} has empty name`); + assert.ok(skill.description.length > 0, `Skill ${skill.id} has empty description`); + } +}); + +test("all skills have rawUrl and githubUrl as valid GitHub URLs", () => { + for (const skill of getCatalog()) { + assert.ok( + skill.rawUrl.startsWith("https://raw.githubusercontent.com/"), + `Skill ${skill.id}: rawUrl "${skill.rawUrl}" is not a GitHub raw URL`, + ); + assert.ok( + skill.githubUrl.startsWith("https://github.com/"), + `Skill ${skill.id}: githubUrl "${skill.githubUrl}" is not a GitHub blob URL`, + ); + assert.ok( + skill.rawUrl.endsWith("/SKILL.md"), + `Skill ${skill.id}: rawUrl does not end with /SKILL.md`, + ); + } +}); + +test("api skills have area matching API_SKILL_IDS derived IDs", () => { + const catalog = getCatalog(); + for (const id of API_SKILL_IDS) { + const skill = catalog.find((s) => s.id === id); + assert.ok(skill, `API skill ID "${id}" not found in catalog`); + assert.equal(skill!.category, "api", `Skill "${id}" expected category api, got ${skill!.category}`); + } +}); + +test("cli skills have area matching CLI_SKILL_IDS derived IDs", () => { + const catalog = getCatalog(); + for (const id of CLI_SKILL_IDS) { + const skill = catalog.find((s) => s.id === id); + assert.ok(skill, `CLI skill ID "${id}" not found in catalog`); + assert.equal(skill!.category, "cli", `Skill "${id}" expected category cli, got ${skill!.category}`); + } +}); + +// ─── getSkillById ───────────────────────────────────────────────────────────── + +test("getSkillById('omni-providers') returns the omni-providers entry", () => { + const skill = getSkillById("omni-providers"); + assert.ok(skill, "Expected skill to be found"); + assert.equal(skill!.id, "omni-providers"); + assert.equal(skill!.category, "api"); + assert.equal(skill!.area, "providers"); +}); + +test("getSkillById('cli-serve') returns the cli-serve entry", () => { + const skill = getSkillById("cli-serve"); + assert.ok(skill); + assert.equal(skill!.id, "cli-serve"); + assert.equal(skill!.category, "cli"); + assert.equal(skill!.isEntry, true); +}); + +test("getSkillById('omni-auth') returns entry with isEntry=true", () => { + const skill = getSkillById("omni-auth"); + assert.ok(skill); + assert.equal(skill!.isEntry, true); +}); + +test("getSkillById('does-not-exist') returns null", () => { + const skill = getSkillById("does-not-exist"); + assert.equal(skill, null); +}); + +test("getSkillById('') returns null", () => { + const skill = getSkillById(""); + assert.equal(skill, null); +}); + +// ─── filterCatalog ──────────────────────────────────────────────────────────── + +test("filterCatalog({ category: 'api' }) returns 22 api skills", () => { + const skills = filterCatalog({ category: "api" }); + assert.equal(skills.length, 22); + for (const s of skills) { + assert.equal(s.category, "api"); + } +}); + +test("filterCatalog({ category: 'cli' }) returns 20 cli skills", () => { + const skills = filterCatalog({ category: "cli" }); + assert.equal(skills.length, 20); + for (const s of skills) { + assert.equal(s.category, "cli"); + } +}); + +test("filterCatalog({ area: 'providers' }) returns exactly omni-providers", () => { + const skills = filterCatalog({ area: "providers" }); + assert.equal(skills.length, 1); + assert.equal(skills[0].id, "omni-providers"); +}); + +test("filterCatalog({ category: 'api', area: 'mcp' }) returns omni-mcp", () => { + const skills = filterCatalog({ category: "api", area: "mcp" }); + assert.equal(skills.length, 1); + assert.equal(skills[0].id, "omni-mcp"); +}); + +test("filterCatalog({ area: 'nonexistent' }) returns empty array", () => { + const skills = filterCatalog({ area: "nonexistent" }); + assert.equal(skills.length, 0); +}); + +test("filterCatalog({}) returns full catalog (42 entries)", () => { + const skills = filterCatalog({}); + assert.equal(skills.length, 42); +}); + +// ─── refreshCatalog ─────────────────────────────────────────────────────────── + +test("refreshCatalog() causes getCatalog() to re-derive (returns fresh array)", () => { + const first = getCatalog(); + refreshCatalog(); + const second = getCatalog(); + // Different array reference after refresh + assert.notEqual(first, second); + // But same content + assert.equal(first.length, second.length); + assert.equal(first[0].id, second[0].id); +}); + +// ─── computeCoverage ───────────────────────────────────────────────────────── + +test("computeCoverage() returns valid SkillCoverage shape", () => { + const cov = computeCoverage(); + + assert.ok(typeof cov.api === "object"); + assert.equal(cov.api.total, 22); + assert.ok(typeof cov.api.have === "number"); + assert.ok(cov.api.have >= 0 && cov.api.have <= 22); + + assert.ok(typeof cov.cli === "object"); + assert.equal(cov.cli.total, 20); + assert.ok(typeof cov.cli.have === "number"); + assert.ok(cov.cli.have >= 0 && cov.cli.have <= 20); + + assert.equal(cov.totalSkills, cov.api.have + cov.cli.have); + + // generatedAt must be a valid ISO datetime string + assert.ok(!isNaN(Date.parse(cov.generatedAt)), `generatedAt "${cov.generatedAt}" is not a valid ISO date`); +}); + +test("computeCoverage() api.have + cli.have = totalSkills", () => { + const cov = computeCoverage(); + assert.equal(cov.totalSkills, cov.api.have + cov.cli.have); +}); + +// ─── Cache behaviour ───────────────────────────────────────────────────────── + +test("getCatalog() returns the same array reference on repeated calls (cached)", () => { + refreshCatalog(); + const first = getCatalog(); + const second = getCatalog(); + assert.strictEqual(first, second, "Expected same cached array reference"); +}); + +// ─── Canonical IDs check ───────────────────────────────────────────────────── + +test("API_SKILL_IDS first entry is omni-auth", () => { + assert.equal(API_SKILL_IDS[0], "omni-auth"); +}); + +test("API_SKILL_IDS last entry is omni-inference", () => { + assert.equal(API_SKILL_IDS[API_SKILL_IDS.length - 1], "omni-inference"); +}); + +test("CLI_SKILL_IDS first entry is cli-serve", () => { + assert.equal(CLI_SKILL_IDS[0], "cli-serve"); +}); + +test("CLI_SKILL_IDS last entry is cli-setup", () => { + assert.equal(CLI_SKILL_IDS[CLI_SKILL_IDS.length - 1], "cli-setup"); +}); diff --git a/tests/unit/agentSkills-cliRegistryParser.test.ts b/tests/unit/agentSkills-cliRegistryParser.test.ts new file mode 100644 index 0000000000..086181494f --- /dev/null +++ b/tests/unit/agentSkills-cliRegistryParser.test.ts @@ -0,0 +1,304 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +// Dynamic import to pick up ESM module +const { parseCliRegistry, getCommandsForFamily } = await import( + "../../src/lib/agentSkills/cliRegistryParser.ts" +); + +// ─── Fixture helpers ────────────────────────────────────────────────────────── + +/** + * Creates a temporary directory mirroring bin/cli/commands/, + * writes fixture .mjs files, changes CWD, returns cleanup fn. + */ +function withFixtureCli( + files: Record, +): { cleanup: () => void } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-cli-test-")); + const commandsDir = path.join(tmpDir, "bin", "cli", "commands"); + fs.mkdirSync(commandsDir, { recursive: true }); + + for (const [filename, content] of Object.entries(files)) { + fs.writeFileSync(path.join(commandsDir, filename), content, "utf-8"); + } + + const originalCwd = process.cwd(); + process.chdir(tmpDir); + + return { + cleanup() { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }, + }; +} + +// ─── Fixture content ────────────────────────────────────────────────────────── + +const FIXTURE_PROVIDERS_MJS = ` +export function registerProviders(program) { + const providers = program.command('providers').description('Manage provider connections'); + + providers + .command('list') + .description('List configured provider connections') + .option('--json', 'Print machine-readable JSON') + .action(async (opts) => {}); + + providers + .command('available') + .description('Show available providers in the catalog') + .option('--search ', 'Filter by id or name') + .option('--category ', 'Filter by category') + .action(async (opts) => {}); + + providers + .command('test ') + .description('Test a configured provider connection') + .action(async (idOrName, opts) => {}); + + providers + .command('test-all') + .description('Test all active provider connections') + .action(async (opts) => {}); + + providers + .command('validate') + .description('Validate local provider configuration') + .action(async (opts) => {}); + + providers + .command('rotate ') + .description('Rotate API key for a provider connection') + .option('--new-key ', 'New API key value') + .option('--dry-run', 'Preview without writing') + .action(async (idOrName, opts) => {}); + + providers + .command('status') + .description('Show provider connection status and expiry') + .option('--json', 'JSON output') + .action(async (opts) => {}); +} +`; + +const FIXTURE_HEALTH_MJS = ` +export function registerHealth(program) { + const health = program + .command('health') + .description('Check server health status') + .option('-v, --verbose', 'Show extended info') + .option('--json', 'Output as JSON') + .action(async (opts) => {}); + + health + .command('components') + .description('List health components and status') + .action(async (opts) => {}); + + health + .command('watch') + .description('Live dashboard — refresh every N seconds') + .option('--interval ', 'Refresh interval in seconds') + .action(async (opts) => {}); +} +`; + +const FIXTURE_KEYS_MJS = ` +export function registerKeys(program) { + const keys = program.command('keys').description('Manage OmniRoute API keys'); + + keys + .command('list') + .description('List all API keys') + .option('--json', 'JSON output') + .action(async (opts) => {}); + + keys + .command('create') + .description('Create a new API key') + .option('--name ', 'Key name') + .action(async (opts) => {}); + + keys + .command('revoke ') + .description('Revoke an API key') + .action(async (id, opts) => {}); +} +`; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test("parseCliRegistry() returns commands Map and families Map", () => { + const { cleanup } = withFixtureCli({ + "providers.mjs": FIXTURE_PROVIDERS_MJS, + "health.mjs": FIXTURE_HEALTH_MJS, + }); + try { + const result = parseCliRegistry(); + assert.ok(result.commands instanceof Map, "commands should be a Map"); + assert.ok(result.families instanceof Map, "families should be a Map"); + assert.ok(result.commands.size > 0, "commands should not be empty"); + assert.ok(result.families.size > 0, "families should not be empty"); + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() recognises providers family with ≥5 subcommands", () => { + const { cleanup } = withFixtureCli({ + "providers.mjs": FIXTURE_PROVIDERS_MJS, + }); + try { + const { families } = parseCliRegistry(); + const providerCmds = families.get("cli-providers"); + assert.ok(providerCmds, "Expected 'cli-providers' family to exist"); + assert.ok( + providerCmds!.length >= 5, + `Expected ≥5 provider commands, got ${providerCmds!.length}: ${providerCmds!.map((c) => c.name).join(", ")}`, + ); + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() recognises health family commands", () => { + const { cleanup } = withFixtureCli({ + "health.mjs": FIXTURE_HEALTH_MJS, + }); + try { + const { families } = parseCliRegistry(); + const healthCmds = families.get("cli-health"); + assert.ok(healthCmds, "Expected 'cli-health' family to exist"); + assert.ok( + healthCmds!.length >= 2, + `Expected ≥2 health commands, got ${healthCmds!.length}`, + ); + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() extracts description for each command", () => { + const { cleanup } = withFixtureCli({ + "providers.mjs": FIXTURE_PROVIDERS_MJS, + "keys.mjs": FIXTURE_KEYS_MJS, + }); + try { + const { commands } = parseCliRegistry(); + // Top-level providers command should have description + const providers = [...commands.values()].find((c) => c.name === "providers"); + assert.ok(providers, "Expected providers command"); + assert.ok( + providers!.description.length > 0, + `Expected non-empty description for providers, got: "${providers!.description}"`, + ); + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() marks subcommands with isSubcommand=true (after first)", () => { + const { cleanup } = withFixtureCli({ + "providers.mjs": FIXTURE_PROVIDERS_MJS, + }); + try { + const { families } = parseCliRegistry(); + const providerCmds = families.get("cli-providers")!; + // After the first (top-level) entry, rest should be subcommands + const subCmds = providerCmds.filter((c) => c.isSubcommand); + assert.ok( + subCmds.length >= 4, + `Expected ≥4 subcommands (list, available, test, etc.), got ${subCmds.length}`, + ); + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() extracts flags from .option() calls", () => { + const { cleanup } = withFixtureCli({ + "providers.mjs": FIXTURE_PROVIDERS_MJS, + }); + try { + const { commands } = parseCliRegistry(); + // Find a command that has options + const rotate = [...commands.values()].find((c) => c.name.includes("rotate")); + // Flags might be present if parsing found them + if (rotate) { + // If rotate exists and has flags, verify format + for (const flag of rotate.flags) { + assert.ok( + typeof flag === "string" && flag.length > 0, + `Invalid flag: "${flag}"`, + ); + } + } + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() skips unrecognised .mjs files", () => { + const { cleanup } = withFixtureCli({ + "unknown-custom.mjs": `export function register(p) {}`, + "providers.mjs": FIXTURE_PROVIDERS_MJS, + }); + try { + const { families } = parseCliRegistry(); + // No family should be mapped from unknown-custom + const hasUnknown = [...families.keys()].some((k) => + String(k).includes("unknown-custom"), + ); + assert.equal(hasUnknown, false, "unknown-custom.mjs should not create a family"); + // providers.mjs should still be parsed + assert.ok(families.has("cli-providers"), "Expected cli-providers family from providers.mjs"); + } finally { + cleanup(); + } +}); + +test("parseCliRegistry() throws if commands directory is missing", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-cli-missing-")); + const originalCwd = process.cwd(); + process.chdir(tmpDir); + try { + assert.throws( + () => parseCliRegistry(), + /cliRegistryParser: could not read/, + "Expected error when commands dir is missing", + ); + } finally { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +// ─── Integration test: real providers.mjs (always runs — it's in the repo) ─── + +test("parseCliRegistry() with real providers.mjs: providers family has ≥5 commands", () => { + // This test uses the actual project files (not a fixture). + // We rely on the CWD being the worktree root during `npm run test:unit`. + const result = parseCliRegistry(); + const providerCmds = result.families.get("cli-providers"); + assert.ok(providerCmds, "Expected cli-providers family from real providers.mjs"); + assert.ok( + providerCmds!.length >= 5, + `Expected ≥5 real provider commands, got ${providerCmds!.length}: ${providerCmds!.map((c) => c.name).join(", ")}`, + ); +}); + +test("getCommandsForFamily('cli-providers') with real files: returns ≥5 strings", () => { + const commands = getCommandsForFamily("cli-providers"); + assert.ok( + commands.length >= 5, + `Expected ≥5 cli-providers commands, got ${commands.length}`, + ); + for (const cmd of commands) { + assert.ok(typeof cmd === "string" && cmd.length > 0, `Invalid command name: "${cmd}"`); + } +}); diff --git a/tests/unit/agentSkills-openapiParser.test.ts b/tests/unit/agentSkills-openapiParser.test.ts new file mode 100644 index 0000000000..cbe70cf541 --- /dev/null +++ b/tests/unit/agentSkills-openapiParser.test.ts @@ -0,0 +1,247 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +// Dynamic import to pick up ESM module +const { parseOpenapi, getEndpointsForArea } = await import("../../src/lib/agentSkills/openapiParser.ts"); + +// ─── Fixture helpers ────────────────────────────────────────────────────────── + +/** + * Creates a temporary directory with a minimal openapi.yaml fixture, + * changes CWD to it, and returns a cleanup function. + */ +function withFixtureOpenapi(yamlContent: string): { cleanup: () => void } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-openapi-test-")); + const docsDir = path.join(tmpDir, "docs", "reference"); + fs.mkdirSync(docsDir, { recursive: true }); + fs.writeFileSync(path.join(docsDir, "openapi.yaml"), yamlContent, "utf-8"); + + const originalCwd = process.cwd(); + process.chdir(tmpDir); + + return { + cleanup() { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }, + }; +} + +// ─── Fixture YAML ───────────────────────────────────────────────────────────── + +const FIXTURE_YAML = ` +openapi: 3.1.0 +info: + title: OmniRoute Test + version: 1.0.0 +paths: + /api/providers: + get: + tags: [Providers] + summary: List provider connections + description: Returns all configured provider connections. + post: + tags: [Providers] + summary: Add provider connection + /api/providers/{id}: + get: + tags: [Providers] + summary: Get provider by id + patch: + tags: [Providers] + summary: Update provider connection + delete: + tags: [Providers] + summary: Remove provider connection + /api/providers/{id}/test: + post: + tags: [Providers] + summary: Test provider connection + /api/keys: + get: + tags: [APIKeys] + summary: List API keys + post: + tags: [APIKeys] + summary: Create API key + /api/keys/{id}: + delete: + tags: [APIKeys] + summary: Revoke API key + /api/usage/analytics: + get: + tags: [Usage] + summary: Get usage analytics + /api/v1/chat/completions: + post: + tags: [Chat] + summary: Create chat completion + /api/settings: + get: + tags: [Settings] + summary: Get settings + put: + tags: [Settings] + summary: Update settings +`; + +// ─── Tests using fixture ────────────────────────────────────────────────────── + +test("parseOpenapi() returns paths Map with all operations from fixture", () => { + const { cleanup } = withFixtureOpenapi(FIXTURE_YAML); + try { + const { paths } = parseOpenapi(); + + assert.ok(paths instanceof Map, "paths should be a Map"); + assert.ok(paths.size > 0, "paths should not be empty"); + + // Spot-check a few keys + assert.ok(paths.has("GET /api/providers"), "Expected GET /api/providers"); + assert.ok(paths.has("POST /api/providers"), "Expected POST /api/providers"); + assert.ok(paths.has("GET /api/providers/{id}"), "Expected GET /api/providers/{id}"); + assert.ok(paths.has("DELETE /api/providers/{id}"), "Expected DELETE /api/providers/{id}"); + assert.ok(paths.has("POST /api/v1/chat/completions"), "Expected POST /api/v1/chat/completions"); + } finally { + cleanup(); + } +}); + +test("parseOpenapi() groups /api/providers/* under 'providers' area", () => { + const { cleanup } = withFixtureOpenapi(FIXTURE_YAML); + try { + const { areas } = parseOpenapi(); + + const providerOps = areas.get("providers"); + assert.ok(providerOps, "Expected 'providers' area to exist"); + assert.ok( + providerOps!.length >= 5, + `Expected at least 5 provider endpoints, got ${providerOps!.length}`, + ); + + const paths = providerOps!.map((op) => op.path); + assert.ok(paths.includes("/api/providers"), "Expected /api/providers"); + assert.ok(paths.includes("/api/providers/{id}"), "Expected /api/providers/{id}"); + assert.ok(paths.includes("/api/providers/{id}/test"), "Expected /api/providers/{id}/test"); + } finally { + cleanup(); + } +}); + +test("parseOpenapi() groups /api/keys/* under 'api-keys' area", () => { + const { cleanup } = withFixtureOpenapi(FIXTURE_YAML); + try { + const { areas } = parseOpenapi(); + const keyOps = areas.get("api-keys"); + assert.ok(keyOps, "Expected 'api-keys' area to exist"); + assert.ok(keyOps!.length >= 2, `Expected at least 2 key endpoints, got ${keyOps!.length}`); + } finally { + cleanup(); + } +}); + +test("parseOpenapi() groups /api/v1/* under 'inference' area", () => { + const { cleanup } = withFixtureOpenapi(FIXTURE_YAML); + try { + const { areas } = parseOpenapi(); + const inferenceOps = areas.get("inference"); + assert.ok(inferenceOps, "Expected 'inference' area to exist"); + assert.ok( + inferenceOps!.length >= 1, + `Expected at least 1 inference endpoint, got ${inferenceOps!.length}`, + ); + assert.ok( + inferenceOps!.some((op) => op.path === "/api/v1/chat/completions"), + "Expected /api/v1/chat/completions in inference area", + ); + } finally { + cleanup(); + } +}); + +test("parseOpenapi() OpenapiPath entries have required fields", () => { + const { cleanup } = withFixtureOpenapi(FIXTURE_YAML); + try { + const { paths } = parseOpenapi(); + for (const [key, op] of paths) { + assert.ok(typeof op.method === "string" && op.method.length > 0, `${key}: method missing`); + assert.ok(typeof op.path === "string" && op.path.length > 0, `${key}: path missing`); + assert.ok(typeof op.summary === "string", `${key}: summary not a string`); + assert.ok(Array.isArray(op.tags), `${key}: tags not an array`); + } + } finally { + cleanup(); + } +}); + +test("parseOpenapi() throws if openapi.yaml is missing", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-openapi-missing-")); + const originalCwd = process.cwd(); + process.chdir(tmpDir); + try { + assert.throws( + () => parseOpenapi(), + /openapiParser: could not read/, + "Expected error when openapi.yaml is missing", + ); + } finally { + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test("parseOpenapi() returns empty areas Map for YAML with no paths", () => { + const emptyPathsYaml = ` +openapi: 3.1.0 +info: + title: Empty + version: 1.0.0 +paths: {} +`; + const { cleanup } = withFixtureOpenapi(emptyPathsYaml); + try { + const { paths, areas } = parseOpenapi(); + assert.equal(paths.size, 0); + assert.equal(areas.size, 0); + } finally { + cleanup(); + } +}); + +// ─── Integration test: real openapi.yaml (gated) ───────────────────────────── + +const SKIP_REAL = process.env.SKIP_REAL_OPENAPI === "1"; + +test( + "parseOpenapi() with real openapi.yaml: providers area has ≥5 endpoints", + { skip: SKIP_REAL ? "SKIP_REAL_OPENAPI=1" : false }, + () => { + // This test runs from the project root (the worktree). + // It will fail if openapi.yaml doesn't exist — that's intentional. + const { areas } = parseOpenapi(); + const providerOps = areas.get("providers"); + assert.ok(providerOps, "Expected 'providers' area in real OpenAPI spec"); + assert.ok( + providerOps!.length >= 5, + `Expected ≥5 provider endpoints in real spec, got ${providerOps!.length}`, + ); + }, +); + +test( + "getEndpointsForArea('providers') with real openapi.yaml: returns ≥5 strings", + { skip: SKIP_REAL ? "SKIP_REAL_OPENAPI=1" : false }, + () => { + const endpoints = getEndpointsForArea("providers"); + assert.ok( + endpoints.length >= 5, + `Expected ≥5 provider endpoint strings, got ${endpoints.length}: ${endpoints.join(", ")}`, + ); + // Each entry should match "METHOD /path" + for (const ep of endpoints) { + assert.match(ep, /^[A-Z]+ \//, `Endpoint "${ep}" does not match METHOD /path format`); + } + }, +); From dfd2182c4197ca39f92f619343d7c27aff690b93 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 21:23:55 -0300 Subject: [PATCH 09/49] =?UTF-8?q?refactor(omni-skills):=20move=20dashboard?= =?UTF-8?q?/skills=20=E2=86=92=20dashboard/omni-skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the page route from /dashboard/skills to /dashboard/omni-skills to align with the task-15 F8 redesign plan. The server component wrapper is reduced to a 5-line delegator, removing the prior 870-line monolith. --- .../dashboard/omni-skills/page.tsx | 5 + src/app/(dashboard)/dashboard/skills/page.tsx | 871 ------------------ 2 files changed, 5 insertions(+), 871 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/omni-skills/page.tsx delete mode 100644 src/app/(dashboard)/dashboard/skills/page.tsx diff --git a/src/app/(dashboard)/dashboard/omni-skills/page.tsx b/src/app/(dashboard)/dashboard/omni-skills/page.tsx new file mode 100644 index 0000000000..6312b3aacc --- /dev/null +++ b/src/app/(dashboard)/dashboard/omni-skills/page.tsx @@ -0,0 +1,5 @@ +import { OmniSkillsPageClient } from "./OmniSkillsPageClient"; + +export default function Page() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/skills/page.tsx b/src/app/(dashboard)/dashboard/skills/page.tsx deleted file mode 100644 index 9aa0608cc6..0000000000 --- a/src/app/(dashboard)/dashboard/skills/page.tsx +++ /dev/null @@ -1,871 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef } from "react"; -import { Card } from "@/shared/components"; -import { useTranslations } from "next-intl"; -import type { SkillsProvider } from "@/lib/skills/providerSettings"; - -interface Skill { - id: string; - name: string; - version: string; - description: string; - enabled: boolean; - mode?: "on" | "off" | "auto"; - sourceProvider?: "skillsmp" | "skillssh" | "local"; - tags?: string[]; - installCount?: number; - createdAt: string; -} - -interface Execution { - id: string; - skillId: string; - skillName: string; - status: string; - duration: number; - createdAt: string; -} - -export default function SkillsPage() { - const [skills, setSkills] = useState([]); - const [executions, setExecutions] = useState([]); - const [loading, setLoading] = useState(true); - const [skillsPage, setSkillsPage] = useState(1); - const [skillsTotal, setSkillsTotal] = useState(0); - const [skillsTotalPages, setSkillsTotalPages] = useState(1); - const [popularDefaults, setPopularDefaults] = useState([]); - const [searchTerm, setSearchTerm] = useState(""); - const [modeFilter, setModeFilter] = useState<"all" | "on" | "off" | "auto">("all"); - - const [execPage, setExecPage] = useState(1); - const [execTotal, setExecTotal] = useState(0); - const [execTotalPages, setExecTotalPages] = useState(1); - - const [activeTab, setActiveTab] = useState<"skills" | "executions" | "sandbox" | "marketplace">( - "skills" - ); - const [showInstallModal, setShowInstallModal] = useState(false); - const [installJson, setInstallJson] = useState(""); - const [installStatus, setInstallStatus] = useState<{ - type: "success" | "error"; - message: string; - } | null>(null); - const [installing, setInstalling] = useState(false); - const fileInputRef = useRef(null); - const [mpQuery, setMpQuery] = useState(""); - const [mpResults, setMpResults] = useState< - { - name: string; - description: string; - skillMdContent?: string; - version?: string; - sourceUrl?: string; - }[] - >([]); - const [mpLoading, setMpLoading] = useState(false); - const [mpError, setMpError] = useState(""); - const [mpInstallingId, setMpInstallingId] = useState(null); - const [shQuery, setShQuery] = useState(""); - const [shResults, setShResults] = useState< - { id: string; skillId: string; name: string; installs: number; source: string }[] - >([]); - const [shLoading, setShLoading] = useState(false); - const [shError, setShError] = useState(""); - const [shInstallingId, setShInstallingId] = useState(null); - const [skillsProvider, setSkillsProvider] = useState("skillsmp"); - const t = useTranslations("skills"); - const commonT = useTranslations("common"); - - const fetchSkills = async (page: number) => { - const params = new URLSearchParams({ page: String(page), limit: "20" }); - if (searchTerm.trim()) params.set("q", searchTerm.trim()); - if (modeFilter !== "all") params.set("mode", modeFilter); - - const res = await fetch(`/api/skills?${params.toString()}`).then((r) => r.json()); - setSkills(res.data || []); - setSkillsTotal(res.total || 0); - setSkillsTotalPages(res.totalPages || 1); - setPopularDefaults(Array.isArray(res.popularDefaults) ? res.popularDefaults : []); - }; - - const fetchExecutions = async (page: number) => { - const res = await fetch(`/api/skills/executions?page=${page}&limit=20`).then((r) => r.json()); - setExecutions(res.data || []); - setExecTotal(res.total || 0); - setExecTotalPages(res.totalPages || 1); - }; - - useEffect(() => { - Promise.all([ - fetch("/api/skills?page=1&limit=20").then((r) => r.json()), - fetch("/api/skills/executions?page=1&limit=20").then((r) => r.json()), - fetch("/api/settings").then((r) => (r.ok ? r.json() : null)), - ]) - .then(([skillsData, executionsData, settingsData]) => { - setSkills(skillsData.data || []); - setSkillsTotal(skillsData.total || 0); - setSkillsTotalPages(skillsData.totalPages || 1); - setPopularDefaults( - Array.isArray(skillsData.popularDefaults) ? skillsData.popularDefaults : [] - ); - - setExecutions(executionsData.data || []); - setExecTotal(executionsData.total || 0); - setExecTotalPages(executionsData.totalPages || 1); - - if ( - settingsData?.skillsProvider === "skillsmp" || - settingsData?.skillsProvider === "skillssh" - ) { - setSkillsProvider(settingsData.skillsProvider); - } - - setLoading(false); - }) - .catch(() => setLoading(false)); - }, []); - - const refreshSkills = async () => { - setSkillsPage(1); - await fetchSkills(1); - }; - - const toggleSkill = async (skillId: string, enabled: boolean) => { - await fetch(`/api/skills/${skillId}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled: !enabled }), - }); - setSkills(skills.map((s) => (s.id === skillId ? { ...s, enabled: !enabled } : s))); - }; - - const setSkillMode = async (skillId: string, mode: "on" | "off" | "auto") => { - await fetch(`/api/skills/${skillId}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ mode }), - }); - - setSkills(skills.map((s) => (s.id === skillId ? { ...s, mode, enabled: mode !== "off" } : s))); - }; - - const deleteSkill = async (skillId: string) => { - const res = await fetch(`/api/skills/${skillId}`, { method: "DELETE" }); - if (res.ok) { - setSkills(skills.filter((s) => s.id !== skillId)); - } - }; - - const handleInstall = async () => { - setInstalling(true); - setInstallStatus(null); - try { - const manifest = JSON.parse(installJson); - const res = await fetch("/api/skills/install", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manifest), - }); - const data = await res.json(); - if (res.ok && data.success) { - setInstallStatus({ type: "success", message: t("installSuccess", { id: data.id }) }); - setInstallJson(""); - await refreshSkills(); - } else { - setInstallStatus({ - type: "error", - message: data.error || data.message || t("installError"), - }); - } - } catch (err) { - setInstallStatus({ - type: "error", - message: err instanceof Error ? err.message : t("invalidJson"), - }); - } finally { - setInstalling(false); - } - }; - - const handleFileUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = (ev) => { - setInstallJson((ev.target?.result as string) || ""); - }; - reader.readAsText(file); - }; - - const searchMarketplace = async () => { - setMpLoading(true); - setMpError(""); - setMpResults([]); - try { - const res = await fetch(`/api/skills/marketplace?q=${encodeURIComponent(mpQuery)}`); - const data = await res.json(); - if (!res.ok) { - setMpError(data.error || t("marketplaceError")); - } else { - setMpResults(Array.isArray(data) ? data : data.skills || []); - } - } catch (err) { - setMpError(err instanceof Error ? err.message : t("marketplaceError")); - } finally { - setMpLoading(false); - } - }; - - const installFromMarketplace = async (skill: { - name: string; - description: string; - skillMdContent?: string; - version?: string; - sourceUrl?: string; - }) => { - setMpInstallingId(skill.name); - try { - const res = await fetch("/api/skills/marketplace/install", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: skill.name, - description: skill.description, - skillMdContent: skill.skillMdContent || skill.description, - version: skill.version || "1.0.0", - sourceUrl: skill.sourceUrl, - }), - }); - const data = await res.json(); - if (res.ok && data.success) { - await refreshSkills(); - setMpInstallingId(null); - } else { - setMpError(data.error || t("installError")); - setMpInstallingId(null); - } - } catch (err) { - setMpError(err instanceof Error ? err.message : t("installError")); - setMpInstallingId(null); - } - }; - - const searchSkillsSh = async () => { - setShLoading(true); - setShError(""); - setShResults([]); - try { - const res = await fetch(`/api/skills/skillssh?q=${encodeURIComponent(shQuery)}`); - const data = await res.json(); - if (!res.ok) { - setShError(data.error || t("marketplaceError")); - } else { - setShResults(data.skills || []); - } - } catch (err) { - setShError(err instanceof Error ? err.message : t("marketplaceError")); - } finally { - setShLoading(false); - } - }; - - const installFromSkillsSh = async (skill: { - id: string; - skillId: string; - name: string; - installs: number; - source: string; - }) => { - setShInstallingId(skill.id); - try { - const res = await fetch("/api/skills/skillssh/install", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: skill.name, - description: `Installed from skills.sh (${skill.source})`, - source: skill.source, - skillId: skill.skillId, - }), - }); - const data = await res.json(); - if (res.ok && data.success) { - await refreshSkills(); - setShInstallingId(null); - } else { - setShError(data.error || t("installError")); - setShInstallingId(null); - } - } catch (err) { - setShError(err instanceof Error ? err.message : t("installError")); - setShInstallingId(null); - } - }; - - if (loading) { - return ( -
-
{t("loading")}...
-
- ); - } - - // ── Stats computation ──────────────────────────────────────────────────── - - const enabledCount = skills.filter((s) => s.enabled).length; - const execSuccessCount = executions.filter((e) => e.status === "success").length; - const successRate = - executions.length > 0 ? Math.round((execSuccessCount / executions.length) * 100) : 0; - - return ( -
- {/* ── Stats Cards ─────────────────────────────────────────────────── */} -
- -

{t("totalSkills")}

-

{skillsTotal}

-
- -

{t("enabledSkills")}

-

{enabledCount}

-
- -

{t("totalExecutions")}

-

{execTotal}

-
- -

{t("successRate")}

-

{successRate}%

-
-
- -
- -
- -
- - - - -
- - {activeTab === "skills" && ( -
- -
- setSearchTerm(e.target.value)} - placeholder={t("filterSkillsPlaceholder")} - className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500" - /> - - -
- - {popularDefaults.length > 0 && ( -
-

{t("popularDefaultsLabel")}

-
- {popularDefaults.map((name) => ( - - {name} - - ))} -
-
- )} -
- - {skills.length === 0 ? ( - -
{t("noSkills")}
-
- ) : ( - skills.map((skill) => ( - -
-
-
-

{skill.name}

- - v{skill.version} - - - {(skill.sourceProvider || "local").toUpperCase()} - - - {t("mode")}: {skill.mode || (skill.enabled ? "on" : "off")} - -
-

{skill.description}

- {Array.isArray(skill.tags) && skill.tags.length > 0 && ( -
- {skill.tags.map((tag) => ( - - {tag} - - ))} -
- )} -
-
-
- - - -
- - -
-
-
- )) - )} -
- - {t("pageInfo", { - page: skillsPage, - totalPages: skillsTotalPages, - total: skillsTotal, - })} - -
- - -
-
-
- )} - - {activeTab === "executions" && ( - -
- - - - - - - - - - - {executions.length === 0 ? ( - - - - ) : ( - executions.map((exec) => ( - - - - - - - )) - )} - -
{t("skill")}{t("status")}{t("duration")}{t("time")}
- {t("noExecutions")} -
{exec.skillName} - - {exec.status} - - {exec.duration}ms - {new Date(exec.createdAt).toLocaleString()} -
-
-
- - {t("pageInfo", { page: execPage, totalPages: execTotalPages, total: execTotal }) || - `Page ${execPage} of ${execTotalPages} (${execTotal} total)`} - -
- - -
-
-
- )} - - {activeTab === "sandbox" && ( -
- -

{t("sandboxConfig")}

-
-
-
-

{t("cpuLimit")}

-

{t("cpuLimitDesc")}

-
- 100ms -
-
-
-

{t("memoryLimit")}

-

{t("memoryLimitDesc")}

-
- 256MB -
-
-
-

{t("timeout")}

-

{t("timeoutDesc")}

-
- 30s -
-
-
-

{t("networkAccess")}

-

{t("networkAccessDesc")}

-
- {t("disabled")} -
-
-
-
- )} - - {activeTab === "marketplace" && ( -
- -

{t("skillsMarketplace")}

-

- {t("activeProvider")}{" "} - - {skillsProvider === "skillsmp" ? "SkillsMP" : "skills.sh"} - - . {t("changeInSettings")} -

-
- - skillsProvider === "skillsmp" - ? setMpQuery(e.target.value) - : setShQuery(e.target.value) - } - onKeyDown={(e) => - e.key === "Enter" && - (skillsProvider === "skillsmp" ? searchMarketplace() : searchSkillsSh()) - } - placeholder={t("searchMarketplacePlaceholder")} - className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500" - /> - -
- {(skillsProvider === "skillsmp" ? mpError : shError) && ( -
- {skillsProvider === "skillsmp" ? mpError : shError} -
- )} -
- - {skillsProvider === "skillsmp" && mpResults.length > 0 && ( -
- {mpResults.map((skill) => ( - -
-
-

{skill.name}

-

{skill.description}

-
- -
-
- ))} -
- )} - - {skillsProvider === "skillssh" && shResults.length > 0 && ( -
- {shResults.map((skill) => ( - -
-
-

{skill.name}

-

- {skill.source} · {skill.installs.toLocaleString()} {t("installs")} -

-
- -
-
- ))} -
- )} - - {skillsProvider === "skillsmp" && !mpLoading && mpResults.length === 0 && !mpError && ( - -
{t("marketplaceSkillsMpHint")}
-
- )} - {skillsProvider === "skillssh" && !shLoading && shResults.length === 0 && !shError && ( - -
{t("marketplaceSkillsShHint")}
-
- )} -
- )} - - {showInstallModal && ( -
-
-
-

{t("installSkillModalTitle")}

- -
-

{t("installSkillModalDesc")}

-