Files
OmniRoute/src/lib/agentSkills/catalog.ts
diegosouzapw 3ea416350e fix(tests+ci): update 42→43 skill count, fix E2E artifact path
- Unit/integration tests: update hardcoded 42→43 in 7 test files
  (agentSkillTools-mcp, agentSkills-catalog, agentSkills-generator,
  agent-skills-content, agent-skills-discovery, listCapabilities-a2a)
  to match the 43rd skill (config-codex-cli) added in the previous commit.
- Include CONFIG_SKILL_IDS in integration content test ALL_IDS so
  skills/config-codex-cli/ is no longer "unexpected".
- listCapabilities.ts: change totalSkills from literal 42 to catalog.length
  so it adapts to catalog growth automatically.
- computeCoverage assertions: include config.have in totalSkills check.
- CI: switch E2E artifact from upload-artifact path (ambiguous stripping)
  to explicit tar archive. Fixes "Could not find a production build in
  ./.build/next" — the previous approach's download path was double-nested
  (.build/next/next/...) due to upload-artifact LCA computation. tar -czf
  stores .build/next/... relative to CWD; tar -xzf restores them verbatim.
- Also exclude .build/next/cache from the tar to keep archive lean.
- feat(translator): strip client_metadata in Responses→Chat translation
  (Mistral 422 extra_forbidden fix); add regression test.
2026-06-08 10:41:00 -03:00

257 lines
7.8 KiB
TypeScript

/**
* catalog.ts — single source of truth for the 43-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;
/** Config skill IDs. */
export const CONFIG_SKILL_IDS: readonly string[] = [
"config-codex-cli",
] 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 (43 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<string>;
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;
const configTotal = CONFIG_SKILL_IDS.length;
const configHave = catalog.filter((s) => s.category === "config" && presentIds.has(s.id)).length;
return {
api: { have: apiHave, total: 22 },
cli: { have: cliHave, total: 20 },
config: { have: configHave, total: configTotal },
totalSkills: apiHave + cliHave + configHave,
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<SkillMarkdown> {
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,
{ next: { revalidate: 3600 } } as unknown as RequestInit
);
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<body>` 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,
};
}