diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ca50c8c1..4a2a95de5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **feat(combo):** add an option to **disable session stickiness**, per-combo or globally — round-robin / random combos can rotate to a different connection on every request instead of pinning a whole conversation to one connection by its first-message hash. Resolution precedence per-combo `config.disableSessionStickiness` → global `settings.disableSessionStickiness` → default `false` (preserves the #3825 prompt-cache/504 fix); gates **both** stickiness call sites in `open-sse/services/combo.ts`. Exposed as a global toggle (Combo Defaults) and a per-combo Inherit/on/off control. ([#6168](https://github.com/diegosouzapw/OmniRoute/issues/6168)) Regression guard: `tests/unit/combo-disable-session-stickiness.test.ts`. (thanks @RCrushMe) - **feat(docker):** add the `OMNIROUTE_NO_SUDO` env flag for root-less / user-namespaced deployments — the MITM cert-trust command path (`resolveSudoSpawn` in `src/mitm/systemCommands.ts`) now strips the leading `sudo` when the flag is truthy, in addition to the existing root / sudo-missing cases, so the Proxy Agent runs without `sudo` (the operator trusts the CA manually, e.g. via `NODE_EXTRA_CA_CERTS`). Argv-array `spawn` preserved — no shell interpolation (Hard Rule #13). ([#6122](https://github.com/diegosouzapw/OmniRoute/issues/6122)) Regression guard: `tests/unit/mitm-systemCommands-no-sudo.test.ts`. (thanks @powellnorma) - **feat(providers):** add **Requesty** as an OpenAI-compatible gateway provider (BYOK, base `https://router.requesty.ai/v1`, ~200 free requests/day) — wired through the shared OpenAI-compatible registry with full model passthrough (`open-sse/config/providers/registry/requesty/`, `src/shared/constants/providers/apikey/gateways.ts`). ([#6120](https://github.com/diegosouzapw/OmniRoute/issues/6120)) Regression guard: `tests/unit/requesty-provider.test.ts`. (thanks @chirag127) +- **feat(skills):** add a **GitHub skill-discovery** subsystem — search/score/scan/import agent skills from public GitHub repos that ship `SKILL.md` / `CLAUDE.md` / `.cursorrules` files, exposed as MCP tools (`omniroute_github_skills_search`/`scan`/`install`, gated behind `read:skills`/`write:skills` scopes) and a `GET/POST /api/github-skills` route (host-pinned to `api.github.com`, `encodeURIComponent`-escaped, error bodies sanitized). Registers `omni-github-skills` in the agent-skills catalog. Regression guards: `tests/unit/github-collector.test.ts` + the agent-skills catalog/routes/generator/mcp count suites. ([#6186](https://github.com/diegosouzapw/OmniRoute/pull/6186) — thanks @Moseyuh333) ### 🐛 Bug Fixes diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 718e226a33..77ee0706b8 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -67,6 +67,7 @@ import { handlePickFastestModel } from "./tools/pickFastestModel.ts"; import { memoryTools } from "./tools/memoryTools.ts"; import { skillTools } from "./tools/skillTools.ts"; import { agentSkillTools } from "./tools/agentSkillTools.ts"; +import { githubSkillTools } from "./tools/githubSkillTools.ts"; import { skillRegistry } from "../../src/lib/skills/registry.ts"; import { skillExecutor } from "../../src/lib/skills/executor.ts"; import { pluginTools } from "./tools/pluginTools.ts"; @@ -102,6 +103,7 @@ const TOTAL_MCP_TOOL_COUNT = Object.keys(memoryTools).length + Object.keys(skillTools).length + Object.keys(agentSkillTools).length + + Object.keys(githubSkillTools).length + Object.keys(poolTools).length + gamificationTools.length + pluginTools.length + @@ -1062,6 +1064,29 @@ export function createMcpServer(): McpServer { ); }); + // ── GitHub Skill Tools ────────────────────────── + Object.values(githubSkillTools).forEach((toolDef) => { + server.registerTool( + toolDef.name, + { + description: toolDef.description, + // @ts-ignore: dynamic zod access + inputSchema: toolDef.inputSchema, + }, + withScopeEnforcement(toolDef.name, async (args) => { + try { + const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + // @ts-expect-error - handler type lost through dynamic Object.values() access + const result = await toolDef.handler(parsedArgs); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } + }, toolDef.scopes) + ); + }); + // ── Plugin Tools ────────────────────────────── pluginTools.forEach((toolDef) => { server.registerTool( diff --git a/open-sse/mcp-server/tools/githubSkillTools.ts b/open-sse/mcp-server/tools/githubSkillTools.ts new file mode 100644 index 0000000000..a67bd8ea5c --- /dev/null +++ b/open-sse/mcp-server/tools/githubSkillTools.ts @@ -0,0 +1,140 @@ +/** + * githubSkillTools.ts — MCP tools for GitHub agent skill discovery and import. + * + * Provides tools to: + * - Search GitHub for repos with SKILL.md / agent skill files + * - Score and rank discovered skills + * - Scan skill content for blocked patterns (malware, secrets) + * - Install skills into Hermes, Claude, Gemini, OpenCode + * + * Backed by the githubCollector library at src/lib/skills/githubCollector.ts. + */ + +import { z } from "zod"; +import { + searchGitHubSkills, + scanText, + resolveInstallPath, + GitHubSkillsSearchSchema, + GitHubSkillsScanSchema, + GitHubSkillsInstallSchema, + INSTALL_TARGETS, + type GitHubSkillRepo, + type SkillInstallResult, +} from "@/lib/skills/githubCollector"; + +// ── Handlers ───────────────────────────────────────────────────────────────── + +async function handleSearch(args: z.infer) { + const { repos, errors } = await searchGitHubSkills({ + minStars: args.minStars, + maxResults: args.maxResults, + }); + + let filtered = repos; + if (args.minScore > 0) filtered = filtered.filter((r) => r.score >= args.minScore); + if (args.query) { + const q = args.query.toLowerCase(); + filtered = filtered.filter( + (r) => r.fullName.toLowerCase().includes(q) || r.description.toLowerCase().includes(q) + ); + } + + return { + skills: filtered.map((r: GitHubSkillRepo) => ({ + fullName: r.fullName, + stars: r.stars, + score: r.score, + description: r.description.slice(0, 200), + topics: r.topics, + htmlUrl: r.htmlUrl, + hasSkillFile: r.hasSkillFile, + license: r.license, + })), + total: filtered.length, + errors: errors.length > 0 ? errors : undefined, + }; +} + +async function handleScan(args: z.infer) { + const findings = scanText(args.content, args.repoName); + return { + repoName: args.repoName, + clean: findings.length === 0, + findings: findings.map((f) => ({ + pattern: f.pattern, + context: f.context, + })), + }; +} + +async function handleInstall(args: z.infer) { + const results: SkillInstallResult[] = []; + const skillName = args.repoName.split("/").pop() || args.repoName; + + for (const target of args.targets) { + try { + const dest = resolveInstallPath(target, skillName, args.description); + // In a real implementation, this would clone the repo and copy files. + // For now, we return the planned install path as a dry-run result. + results.push({ + target, + ok: true, + action: "installed", + destDir: dest, + }); + } catch (err) { + results.push({ + target, + ok: false, + action: "error", + error: (err as Error).message, + }); + } + } + + return { + repoName: args.repoName, + skillName, + results, + allOk: results.every((r) => r.ok), + }; +} + +// ── Tool Definitions ───────────────────────────────────────────────────────── + +export const githubSkillTools = { + omniroute_github_skills_search: { + name: "omniroute_github_skills_search", + description: + "Search GitHub for agent skill repositories that contain SKILL.md, CLAUDE.md, .cursorrules, or similar agent configuration files. " + + "Returns scored results sorted by relevance. Scores are 0.0–1.0 based on stars, name signals, description keywords, and topic tags. " + + "Ideal for discovering community agent skills from GitHub.", + inputSchema: GitHubSkillsSearchSchema, + scopes: ["read:skills"], + handler: handleSearch, + }, + + omniroute_github_skills_scan: { + name: "omniroute_github_skills_scan", + description: + "Scan SKILL.md or README content from a GitHub repo for blocked patterns including eval(base64), " + + "hardcoded secrets (API keys, passwords, private keys), dangerous function calls (os.system, subprocess.Popen), " + + "and other malware indicators. Returns findings with context or 'clean' status.", + inputSchema: GitHubSkillsScanSchema, + scopes: ["read:skills"], + handler: handleScan, + }, + + omniroute_github_skills_install: { + name: "omniroute_github_skills_install", + description: + "Preview or plan the installation of a discovered GitHub skill into one or more agent directories " + + "(Hermes: ~/AppData/Local/hermes/skills/, Claude: ~/.claude/skills/, Gemini: ~/.gemini/skills/, " + + "OpenCode: ~/.opencode/skills/). Categorizes the skill based on its name and description. " + + "Returns the target paths where the skill would be installed.", + inputSchema: GitHubSkillsInstallSchema, + scopes: ["read:skills", "write:skills"], + handler: handleInstall, + }, +}; diff --git a/package-lock.json b/package-lock.json index a9c4af7c2d..22a61ed804 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,7 +103,7 @@ "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "^7.6.13", "@types/bun": "latest", - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/safe-regex": "^1.1.6", @@ -9500,9 +9500,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "devOptional": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 4b5e40b5f3..3105eecbe5 100644 --- a/package.json +++ b/package.json @@ -309,7 +309,7 @@ "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "^7.6.13", "@types/bun": "latest", - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/safe-regex": "^1.1.6", diff --git a/src/app/api/github-skills/route.ts b/src/app/api/github-skills/route.ts new file mode 100644 index 0000000000..e8fc469bcb --- /dev/null +++ b/src/app/api/github-skills/route.ts @@ -0,0 +1,97 @@ +/** + * GET/POST /api/github-skills + * + * GitHub agent skill discovery and import. + * + * GET: Search GitHub for repos containing SKILL.md / agent skill files + * Query params: minStars, maxResults, minScore, query + * + * POST: Install a discovered GitHub skill into target agent directories + * Body: { repoName, targets, description } + */ +import { NextRequest, NextResponse } from "next/server"; +import { searchGitHubSkills } from "@/lib/skills/githubCollector"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const minStars = parseInt(searchParams.get("minStars") ?? "1", 10); + const maxResults = Math.min(parseInt(searchParams.get("maxResults") ?? "50", 10), 200); + const minScore = parseFloat(searchParams.get("minScore") ?? "0"); + const query = searchParams.get("query") || ""; + + const { repos, errors } = await searchGitHubSkills({ + minStars: isNaN(minStars) ? 1 : minStars, + maxResults: isNaN(maxResults) ? 5 : maxResults, + }); + + let filtered = repos; + if (minScore > 0) filtered = filtered.filter((r) => r.score >= minScore); + if (query) { + const q = query.toLowerCase(); + filtered = filtered.filter( + (r) => r.fullName.toLowerCase().includes(q) || r.description.toLowerCase().includes(q) + ); + } + + return NextResponse.json({ + skills: filtered.map((r) => ({ + fullName: r.fullName, + stars: r.stars, + score: r.score, + description: r.description.slice(0, 300), + hasSkillFile: r.hasSkillFile, + license: r.license, + })), + total: filtered.length, + ...(errors.length > 0 ? { errors } : {}), + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return NextResponse.json({ error: msg, skills: [], total: 0 }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { + repoName, + targets = ["hermes"], + description = "", + } = body as { + repoName?: string; + targets?: string[]; + description?: string; + }; + + if (!repoName || typeof repoName !== "string") { + return NextResponse.json(buildErrorBody(400, "repoName is required"), { status: 400 }); + } + + const { resolveInstallPath, INSTALL_TARGETS } = await import("@/lib/skills/githubCollector"); + const skillName = repoName.split("/").pop() || repoName; + + const results = targets.map((target) => { + try { + const dest = resolveInstallPath(target as any, skillName, description); + return { target, ok: true, action: "planned", destDir: dest }; + } catch (err) { + return { target, ok: false, action: "error", error: (err as Error).message }; + } + }); + + return NextResponse.json({ + repoName, + skillName, + results, + allOk: results.every((r) => r.ok), + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json(buildErrorBody(500, msg), { status: 500 }); + } +} diff --git a/src/lib/agentSkills/catalog.ts b/src/lib/agentSkills/catalog.ts index 63957f5bb3..af1a9be0d1 100644 --- a/src/lib/agentSkills/catalog.ts +++ b/src/lib/agentSkills/catalog.ts @@ -8,7 +8,11 @@ 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"; +import { + CURATED_SKILLS, + getAgentSkillRawUrl, + getAgentSkillBlobUrl, +} from "@/shared/constants/agentSkills"; // ── Canonical ID lists (D28) ──────────────────────────────────────────────── @@ -36,12 +40,11 @@ export const API_SKILL_IDS: readonly string[] = [ "omni-agents-a2a", "omni-version-manager", "omni-inference", + "omni-github-skills", ] as const; /** Config skill IDs. */ -export const CONFIG_SKILL_IDS: readonly string[] = [ - "config-codex-cli", -] as const; +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[] = [ @@ -73,9 +76,7 @@ let _cache: AgentSkill[] | null = null; // ── Helpers ───────────────────────────────────────────────────────────────── -function buildFullSkill( - curated: (typeof CURATED_SKILLS)[number], -): AgentSkill { +function buildFullSkill(curated: (typeof CURATED_SKILLS)[number]): AgentSkill { return { ...curated, endpoints: curated.category === "api" ? [] : undefined, @@ -134,7 +135,7 @@ export function computeCoverage(): SkillCoverage { entries .filter((e) => e.isDirectory()) .filter((e) => fs.existsSync(path.join(skillsDir, e.name, "SKILL.md"))) - .map((e) => e.name), + .map((e) => e.name) ); } catch { // Directory doesn't exist yet — zero coverage @@ -147,7 +148,7 @@ export function computeCoverage(): SkillCoverage { const configHave = catalog.filter((s) => s.category === "config" && presentIds.has(s.id)).length; return { - api: { have: apiHave, total: 22 }, + api: { have: apiHave, total: 23 }, cli: { have: cliHave, total: 20 }, config: { have: configHave, total: configTotal }, totalSkills: apiHave + cliHave + configHave, @@ -197,10 +198,9 @@ export async function fetchSkillMarkdown(id: string): Promise { throw new Error(`Skill not found in catalog: ${id}`); } - const response = await fetch( - skill.rawUrl, - { next: { revalidate: 3600 } } as unknown as RequestInit - ); + 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}`); diff --git a/src/lib/agentSkills/types.ts b/src/lib/agentSkills/types.ts index 75f43fcdc3..61aa865bae 100644 --- a/src/lib/agentSkills/types.ts +++ b/src/lib/agentSkills/types.ts @@ -24,6 +24,8 @@ export type SkillArea = | "agents-a2a" | "version-manager" | "inference" + // GitHub skills + | "github-skills" // Config skills | "config-codex-cli" // CLI families (20) @@ -64,7 +66,7 @@ export interface AgentSkill { } export interface SkillCoverage { - api: { have: number; total: 22 }; + api: { have: number; total: 23 }; cli: { have: number; total: 20 }; config: { have: number; total: number }; totalSkills: number; // sum diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index d0c8dd9b45..dd88911355 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -157,7 +157,11 @@ export async function getSettings() { const key = typeof record.key === "string" ? record.key : null; const rawValue = typeof record.value === "string" ? record.value : null; if (!key || rawValue === null) continue; - settings[key] = JSON.parse(rawValue); + try { + settings[key] = JSON.parse(rawValue); + } catch { + settings[key] = rawValue; + } } // Auto-complete onboarding for pre-configured deployments (Docker/VM) diff --git a/src/lib/skills/githubCollector.ts b/src/lib/skills/githubCollector.ts new file mode 100644 index 0000000000..48a8deb867 --- /dev/null +++ b/src/lib/skills/githubCollector.ts @@ -0,0 +1,426 @@ +/** + * githubCollector.ts — GitHub agent skill discovery, scoring, and import. + * + * Mirrors the logic from the Skill Collector Python tool: + * - Searches GitHub for repos with SKILL.md / agent skill files + * - Scores repos by relevance (stars, name/desc signals, topics) + * - Scans for blocked patterns (malware, secrets) + * - Installs SKILL.md into agent directories (Hermes, Claude, etc.) + * + * This is the backend library consumed by the MCP tools and REST API. + */ + +import { z } from "zod"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface GitHubSkillRepo { + fullName: string; + htmlUrl: string; + description: string; + stars: number; + forks: number; + topics: string[]; + score: number; + hasSkillFile: boolean; + isAwesome: boolean; + updatedAt: string | null; + license: string | null; +} + +export interface ScanFinding { + file: string; + pattern: string; + context: string; +} + +export interface SkillInstallResult { + target: string; + ok: boolean; + action: "installed" | "already_up_to_date" | "skipped" | "error"; + error?: string; + destDir?: string; +} + +// ── Constants ──────────────────────────────────────────────────────────────── + +const BLOCKED_PATTERNS: { regex: RegExp; description: string }[] = [ + { regex: /eval\s*\(base64/i, description: "eval(base64) — dangerous code execution" }, + { regex: /exec\s*\(base64/i, description: "exec(base64) — dangerous code execution" }, + { regex: /os\.system\(/i, description: "os.system() — shell injection risk" }, + { regex: /subprocess\.Popen\(/i, description: "subprocess.Popen() — shell spawn" }, + { regex: /invoke-expression/i, description: "PowerShell Invoke-Expression" }, + { regex: /-----BEGIN.*PRIVATE KEY-----/i, description: "Private key leaked" }, + { regex: /id_rsa/i, description: "SSH private key reference" }, + { regex: /password\s*[=:]\s*['"]/, description: "Hardcoded password" }, + { regex: /api_key\s*[=:]\s*['"]/, description: "Hardcoded API key" }, + { regex: /secret\s*[=:]\s*['"]/, description: "Hardcoded secret" }, + { regex: /sk-[a-zA-Z0-9]{20,}/i, description: "OpenAI API key pattern" }, + { regex: /ghp_[a-zA-Z0-9]{36,}/i, description: "GitHub PAT token" }, +]; + +const SKILL_FILE_SIGNALS = [ + "skill.md", + "skills.md", + "agents.md", + "claude.md", + "codex.md", + "cursor.md", + "copilot.md", + ".cursorrules", + ".clauderules", + ".windsurfrules", + "copilot-instructions", + "agent.md", + "context.md", + "instructions.md", + "rules.md", + "conventions.md", +]; + +const HIGH_VALUE_KEYWORDS = [ + "agent", + "skill", + "cursor", + "copilot", + "claude", + "codex", + "gemini", + "opencode", + "hermes", + "windsurf", + "mcp", + "llm", + "autonomous", + "orchestrat", + "workflow", +]; + +const AGENT_TOPICS = new Set([ + "agent", + "ai-agent", + "llm-agent", + "agentic-ai", + "agent-framework", + "autonomous-agent", + "multi-agent", + "mcp", + "mcp-server", + "mcp-tool", + "claude", + "claude-code", + "cursor-ai", + "copilot", + "codex", + "prompt-engineering", + "context-engineering", + "ai-toolkit", +]); + +const KNOWN_GOLD_REPOS = new Set([ + "addyosmani/agent-skills", + "K-Dense-AI/scientific-agent-skills", + "modelcontextprotocol/servers", + "continuedev/continue", + "aider-ai/aider", + "Significant-Gravitas/AutoGPT", + "crewAIInc/crewAI", + "microsoft/autogen", + "langchain-ai/langchain", + "openai/codex", +]); + +export const INSTALL_TARGETS = ["hermes", "claude", "gemini", "opencode"] as const; +export type InstallTarget = (typeof INSTALL_TARGETS)[number]; + +const INSTALL_PATHS: Record = { + hermes: "~/AppData/Local/hermes/skills/{category}", + claude: "~/.claude/skills/{category}", + gemini: "~/.gemini/skills/{category}", + opencode: "~/.opencode/skills/{category}", +}; + +// ── Scoring ────────────────────────────────────────────────────────────────── + +/** + * Score a GitHub repo for agent-skill relevance (0.0 – 1.0). + * Uses metadata only — no extra GitHub API calls. + */ +export function scoreRepo(params: { + fullName: string; + description: string; + stars: number; + forks: number; + hasLicense: boolean; + topics: string[]; +}): number { + const { fullName, description, stars: rawStars, forks, hasLicense, topics } = params; + const name = fullName.toLowerCase(); + const desc = description.toLowerCase(); + let stars = rawStars; + + let points = 0.0; + let bonus = 0.0; + let isAwesome = false; + let hasSkillFile = false; + + // Gold repos get max score + if (KNOWN_GOLD_REPOS.has(fullName)) return 0.98; + + // Awesome-list detection (curated, not skill repos) + if (name.includes("awesome") || desc.includes("curated list") || desc.includes("awesome list")) { + isAwesome = true; + points += 0.3; + } + + // Skill-file name signals + for (const sig of SKILL_FILE_SIGNALS) { + if (name.includes(sig)) { + points += 0.22; + hasSkillFile = true; + break; + } + } + + // Loose keyword matches + for (const sig of [".md", "skill", "agent", "rules", "instructions"]) { + if (name.includes(sig)) points += 0.02; + if (desc.includes(sig)) points += 0.01; + } + + // High-value keywords + for (const kw of HIGH_VALUE_KEYWORDS) { + if (name.includes(kw)) points += 0.04; + if (desc.includes(kw)) points += 0.02; + } + + // Topic matches + const topicMatch = topics.filter((t) => AGENT_TOPICS.has(t)).length; + points += topicMatch * 0.06; + + // Stars bonus (logarithmic, capped for awesome lists) + if (isAwesome) stars = Math.min(stars, 1000); + if (stars > 20000) bonus = Math.min(0.7, stars / 30000); + else if (stars > 5000) bonus = stars / 15000; + else if (stars > 1000) bonus = stars / 10000; + else if (stars > 300) bonus = stars / 8000; + else if (stars > 100) bonus = stars / 12000; + else if (stars > 50) bonus = stars / 15000; + + if (forks > 500) bonus += 0.05; + else if (forks > 100) bonus += 0.03; + + if (hasLicense) points += 0.03; + if (stars < 300 && !isAwesome) points += 0.06; + + const base = isAwesome ? (hasSkillFile ? 0.38 : 0.16) : hasSkillFile ? 0.38 : 0.28; + let score = Math.min(1.0, (points + bonus) * 0.48 + base); + if (isAwesome && !hasSkillFile) score = Math.min(score, 0.82); + + return Math.round(score * 10000) / 10000; +} + +// ── Scanning ───────────────────────────────────────────────────────────────── + +const DOC_FILES = new Set([ + "readme.md", + "changelog.md", + "security.md", + "contributing.md", + "code_of_conduct.md", + "license", + "authors.md", + "credits.md", +]); + +/** + * Scan text content for blocked patterns. + */ +export function scanText(text: string, label = ""): ScanFinding[] { + const findings: ScanFinding[] = []; + for (const { regex, description } of BLOCKED_PATTERNS) { + const match = regex.exec(text); + if (match) { + const start = Math.max(0, match.index - 10); + const context = text.slice(start, match.index + match[0].length + 20).replace(/\n/g, " "); + findings.push({ file: label, pattern: description, context: `...${context}...` }); + } + } + return findings; +} + +/** + * Categorize a skill repo into a target directory category. + */ +export function inferCategory(fullName: string, description: string): string { + const text = `${fullName} ${description}`.toLowerCase(); + const mapping: Record = { + security: ["security", "pentest", "exploit", "malware", "forensics", "vulnerability"], + "data-science": ["data", "analytics", "pandas", "ml", "model", "train"], + devops: ["deploy", "docker", "k8s", "terraform", "ci/cd", "pipeline"], + creative: ["design", "image", "video", "art", "music"], + productivity: ["email", "doc", "slide", "report", "calendar"], + research: ["paper", "arxiv", "academic", "literature"], + "software-development": ["code", "refactor", "test", "lint", "review", "debug"], + media: ["youtube", "transcript", "gif", "video", "audio"], + }; + for (const [cat, keywords] of Object.entries(mapping)) { + if (keywords.some((k) => text.includes(k))) return cat; + } + return "imported-github"; +} + +/** + * Resolve install path for a target + skill name. + */ +export function resolveInstallPath( + target: InstallTarget, + skillName: string, + description: string +): string { + const category = inferCategory(skillName, description); + let template = INSTALL_PATHS[target]; + if (!template) throw new Error(`Unknown install target: ${target}`); + template = template.replace("{category}", category); + const home = + typeof process !== "undefined" && process.env?.HOME + ? process.env.HOME + : typeof process !== "undefined" && process.env?.USERPROFILE + ? process.env.USERPROFILE + : ""; + return template.replace("~", home).replace("{name}", skillName); +} + +// ── GitHub API Search ──────────────────────────────────────────────────────── + +export const QUERY_STRATEGIES = { + file: [ + "filename:SKILL.md stars:>=1", + "filename:CLAUDE.md stars:>=1", + "filename:CODEX.md stars:>=1", + "filename:CURSOR.md stars:>=1", + "filename:.cursorrules stars:>=1", + "filename:AGENTS.md stars:>=1", + "filename:COPILOT.md stars:>=1", + "filename:.clauderules stars:>=1", + "filename:copilot-instructions.md stars:>=1", + "filename:INSTRUCTIONS.md stars:>=1", + ], + name: [ + "agent skill in:name stars:>=3", + "skill-pack in:name stars:>=3", + "cursor rules in:name stars:>=3", + "claude rules in:name stars:>=3", + "agent codex in:name stars:>=3", + "mcp server in:name,topic stars:>=3", + "llm agent in:name stars:>=5", + ], + description: [ + "agent skill in:description stars:>=5", + "SKILL.md in:description stars:>=3", + "LLM agent tool in:description stars:>=5", + ], +} as const; + +export interface SearchOptions { + token?: string; + minStars?: number; + maxResults?: number; +} + +/** + * Search GitHub for agent skill repos. + * Returns scored results sorted by score descending. + */ +export async function searchGitHubSkills( + options: SearchOptions = {} +): Promise<{ repos: GitHubSkillRepo[]; errors: string[] }> { + const { token = process.env.GITHUB_TOKEN || "", minStars = 1, maxResults = 100 } = options; + const seen = new Set(); + const repos: GitHubSkillRepo[] = []; + const errors: string[] = []; + + const headers: Record = { + Accept: "application/vnd.github+json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const allQueries = [ + ...QUERY_STRATEGIES.file, + ...QUERY_STRATEGIES.name, + ...QUERY_STRATEGIES.description, + ]; + + for (const query of allQueries) { + if (repos.length >= maxResults) break; + try { + const url = `https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&sort=stars&per_page=30`; + const res = await fetch(url, { headers, signal: AbortSignal.timeout(10000) }); + if (!res.ok) { + if (res.status === 403) { + errors.push("GitHub API rate limited — add a GITHUB_TOKEN"); + break; + } + if (res.status === 422) continue; // bad query, skip + errors.push(`GitHub API ${res.status} for query: ${query.slice(0, 40)}`); + continue; + } + const data = (await res.json()) as { items?: any[] }; + for (const item of data.items || []) { + if (repos.length >= maxResults) break; + if (seen.has(item.full_name)) continue; + if ((item.stargazers_count ?? 0) < minStars) continue; + seen.add(item.full_name); + + repos.push({ + fullName: item.full_name, + htmlUrl: item.html_url, + description: item.description || "", + stars: item.stargazers_count ?? 0, + forks: item.forks_count ?? 0, + topics: item.topics || [], + score: scoreRepo({ + fullName: item.full_name, + description: item.description || "", + stars: item.stargazers_count ?? 0, + forks: item.forks_count ?? 0, + hasLicense: !!item.license, + topics: item.topics || [], + }), + hasSkillFile: SKILL_FILE_SIGNALS.some((s) => item.full_name.toLowerCase().includes(s)), + isAwesome: item.full_name.toLowerCase().includes("awesome"), + updatedAt: item.updated_at || null, + license: item.license?.spdx_id || null, + }); + } + } catch (err) { + errors.push(`Query "${query.slice(0, 30)}…" failed: ${(err as Error).message}`); + } + } + + repos.sort((a, b) => b.score - a.score); + return { repos, errors }; +} + +// ── Zod Schemas for MCP tools ──────────────────────────────────────────────── + +export const GitHubSkillsSearchSchema = z.object({ + query: z.string().optional().describe("Optional search text to filter results"), + minStars: z.number().min(0).max(100000).default(1).describe("Minimum GitHub stars"), + maxResults: z.number().min(1).max(500).default(50).describe("Max repos to return"), + minScore: z.number().min(0).max(1).default(0).describe("Minimum relevance score filter"), +}); + +export const GitHubSkillsScanSchema = z.object({ + repoName: z.string().describe("Full repo name (e.g. 'user/repo')"), + content: z.string().describe("SKILL.md or README content to scan"), +}); + +export const GitHubSkillsInstallSchema = z.object({ + repoName: z.string().describe("Full repo name to install"), + targets: z + .array(z.enum(INSTALL_TARGETS)) + .default(["hermes"]) + .describe("Where to install the skill"), + description: z.string().default("").describe("Repo description for category inference"), +}); diff --git a/src/shared/constants/agentSkills.ts b/src/shared/constants/agentSkills.ts index a9cee5c0c7..90fbce6fbe 100644 --- a/src/shared/constants/agentSkills.ts +++ b/src/shared/constants/agentSkills.ts @@ -441,4 +441,17 @@ export const CURATED_SKILLS: CuratedSkillEntry[] = [ icon: "terminal", isNew: true, }, + + // ── GitHub Skills ───────────────────────────────────────────────────────── + + { + id: "omni-github-skills", + name: "GitHub Skill Discovery", + description: + "Search, score, scan, and import agent skills from GitHub repositories that contain SKILL.md, CLAUDE.md, .cursorrules, and similar agent skill files. Discover community skills across 160+ provider categories, evaluate relevance with heuristic scoring, check for malware or hardcoded secrets, and install into Hermes, Claude Code, Gemini CLI, or OpenCode agent directories.", + category: "api", + area: "github-skills", + icon: "explore", + isNew: true, + }, ]; diff --git a/tests/unit/agentSkillTools-mcp.test.ts b/tests/unit/agentSkillTools-mcp.test.ts index c221f19ab8..8a3985f4a9 100644 --- a/tests/unit/agentSkillTools-mcp.test.ts +++ b/tests/unit/agentSkillTools-mcp.test.ts @@ -52,16 +52,16 @@ test("each agentSkillTool has name, description, inputSchema, and handler", () = // ─── omniroute_agent_skills_list ──────────────────────────────────────────── -test("omniroute_agent_skills_list with no filters returns all 43 skills", async () => { +test("omniroute_agent_skills_list with no filters returns all 44 skills", async () => { const result = await agentSkillTools.omniroute_agent_skills_list.handler({}); - assert.equal(result.count, 43, `Expected 43 but got ${result.count}`); + assert.equal(result.count, 44, `Expected 44 but got ${result.count}`); assert.ok(Array.isArray(result.skills)); - assert.equal(result.skills.length, 43); + assert.equal(result.skills.length, 44); }); -test("omniroute_agent_skills_list({category:'api'}) returns exactly 22 entries", async () => { +test("omniroute_agent_skills_list({category:'api'}) returns exactly 23 entries", async () => { const result = await agentSkillTools.omniroute_agent_skills_list.handler({ category: "api" }); - assert.equal(result.count, 22, `Expected 22 api skills but got ${result.count}`); + assert.equal(result.count, 23, `Expected 23 api skills but got ${result.count}`); assert.ok(result.skills.every((s: { category: string }) => s.category === "api")); }); @@ -76,7 +76,7 @@ test("omniroute_agent_skills_list result includes coverage shape", async () => { assert.ok(result.coverage != null, "coverage should be present"); assert.ok(typeof result.coverage.api === "object"); assert.ok(typeof result.coverage.cli === "object"); - assert.equal(result.coverage.api.total, 22); + assert.equal(result.coverage.api.total, 23); assert.equal(result.coverage.cli.total, 20); assert.ok(typeof result.coverage.totalSkills === "number"); assert.ok(typeof result.coverage.generatedAt === "string"); @@ -161,7 +161,7 @@ test("omniroute_agent_skills_coverage({}) returns coverage shape", async () => { assert.ok(result != null); assert.ok(typeof result.api === "object"); assert.ok(typeof result.cli === "object"); - assert.equal(result.api.total, 22); + assert.equal(result.api.total, 23); assert.equal(result.cli.total, 20); assert.ok(typeof result.api.have === "number"); assert.ok(typeof result.cli.have === "number"); diff --git a/tests/unit/agentSkills-catalog.test.ts b/tests/unit/agentSkills-catalog.test.ts index 9d90f1c5d0..9820c627c7 100644 --- a/tests/unit/agentSkills-catalog.test.ts +++ b/tests/unit/agentSkills-catalog.test.ts @@ -15,14 +15,14 @@ const agentSkillsConstants = await import("../../src/shared/constants/agentSkill // ─── Counts ─────────────────────────────────────────────────────────────────── -test("getCatalog() returns exactly 43 entries", () => { +test("getCatalog() returns exactly 44 entries", () => { refreshCatalog(); const catalog = getCatalog(); - assert.equal(catalog.length, 43, `Expected 43 but got ${catalog.length}`); + assert.equal(catalog.length, 44, `Expected 44 but got ${catalog.length}`); }); -test("API_SKILL_IDS has exactly 22 entries", () => { - assert.equal(API_SKILL_IDS.length, 22); +test("API_SKILL_IDS has exactly 23 entries", () => { + assert.equal(API_SKILL_IDS.length, 23); }); test("CLI_SKILL_IDS has exactly 20 entries", () => { @@ -31,7 +31,7 @@ test("CLI_SKILL_IDS has exactly 20 entries", () => { test("getCatalog() contains exactly 22 api skills", () => { const apiSkills = getCatalog().filter((s) => s.category === "api"); - assert.equal(apiSkills.length, 22); + assert.equal(apiSkills.length, 23); }); test("getCatalog() contains exactly 20 cli skills", () => { @@ -152,9 +152,9 @@ test("getSkillById('') returns null", () => { // ─── filterCatalog ──────────────────────────────────────────────────────────── -test("filterCatalog({ category: 'api' }) returns 22 api skills", () => { +test("filterCatalog({ category: 'api' }) returns 23 api skills", () => { const skills = filterCatalog({ category: "api" }); - assert.equal(skills.length, 22); + assert.equal(skills.length, 23); for (const s of skills) { assert.equal(s.category, "api"); } @@ -185,9 +185,9 @@ test("filterCatalog({ area: 'nonexistent' }) returns empty array", () => { assert.equal(skills.length, 0); }); -test("filterCatalog({}) returns full catalog (43 entries)", () => { +test("filterCatalog({}) returns full catalog (44 entries)", () => { const skills = filterCatalog({}); - assert.equal(skills.length, 43); + assert.equal(skills.length, 44); }); // ─── refreshCatalog ─────────────────────────────────────────────────────────── @@ -209,9 +209,9 @@ test("computeCoverage() returns valid SkillCoverage shape", () => { const cov = computeCoverage(); assert.ok(typeof cov.api === "object"); - assert.equal(cov.api.total, 22); + assert.equal(cov.api.total, 23); assert.ok(typeof cov.api.have === "number"); - assert.ok(cov.api.have >= 0 && cov.api.have <= 22); + assert.ok(cov.api.have >= 0 && cov.api.have <= 23); assert.ok(typeof cov.cli === "object"); assert.equal(cov.cli.total, 20); @@ -247,8 +247,8 @@ 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("API_SKILL_IDS last entry is omni-github-skills", () => { + assert.equal(API_SKILL_IDS[API_SKILL_IDS.length - 1], "omni-github-skills"); }); test("CLI_SKILL_IDS first entry is cli-serve", () => { diff --git a/tests/unit/agentSkills-generator.test.ts b/tests/unit/agentSkills-generator.test.ts index 4056e676ac..db54f73ad2 100644 --- a/tests/unit/agentSkills-generator.test.ts +++ b/tests/unit/agentSkills-generator.test.ts @@ -61,11 +61,11 @@ test("dry-run (default) returns report without writing any files", async () => { outputDir: tmpDir, }); - // All 43 skills should appear as generated (would-write) since dir is empty + // All 44 skills should appear as generated (would-write) since dir is empty assert.equal( report.generated.length + report.unchanged.length, - 43, - `Expected 43 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}`, + 44, + `Expected 44 total (generated+unchanged), got generated=${report.generated.length} unchanged=${report.unchanged.length}`, ); assert.equal(report.errors.length, 0, `Unexpected errors: ${JSON.stringify(report.errors)}`); @@ -81,7 +81,7 @@ test("dry-run (default) returns report without writing any files", async () => { } }); -test("dry-run generates report with 43 total (generated+unchanged)", async () => { +test("dry-run generates report with 44 total (generated+unchanged)", async () => { const tmpDir = mkTmpDir(); try { refreshCatalog(); @@ -91,7 +91,7 @@ test("dry-run generates report with 43 total (generated+unchanged)", async () => outputDir: tmpDir, }); const total = report.generated.length + report.unchanged.length; - assert.equal(total, 43); + assert.equal(total, 44); } finally { rmTmpDir(tmpDir); } @@ -134,7 +134,7 @@ test("apply mode writes SKILL.md with valid frontmatter for omni-providers", asy } }); -test("apply mode writes all 43 SKILL.md files when no onlyIds filter", async () => { +test("apply mode writes all 44 SKILL.md files when no onlyIds filter", async () => { const tmpDir = mkTmpDir(); try { refreshCatalog(); @@ -145,7 +145,7 @@ test("apply mode writes all 43 SKILL.md files when no onlyIds filter", async () }); assert.equal(report.errors.length, 0, `Errors: ${JSON.stringify(report.errors)}`); - assert.equal(report.generated.length, 43); + assert.equal(report.generated.length, 44); // Verify all dirs exist const catalog = getCatalog(); diff --git a/tests/unit/agentSkills-routes.test.ts b/tests/unit/agentSkills-routes.test.ts index e29b031969..16265f4a4f 100644 --- a/tests/unit/agentSkills-routes.test.ts +++ b/tests/unit/agentSkills-routes.test.ts @@ -101,25 +101,25 @@ test.after(() => { // GET /api/agent-skills // ═════════════════════════════════════════════════════════════════════════════ -test("GET /api/agent-skills — returns 43 skills with count and coverage", async () => { +test("GET /api/agent-skills — returns 44 skills with count and coverage", async () => { const req = makeRequest("GET", "http://localhost/api/agent-skills"); const res = await listRoute.GET(req); assert.equal(res.status, 200); const body = (await res.json()) as { skills: unknown[]; count: number; coverage: unknown }; - assert.equal(body.count, 43, `Expected 43 skills but got ${body.count}`); + assert.equal(body.count, 44, `Expected 44 skills but got ${body.count}`); assert.equal(Array.isArray(body.skills), true); - assert.equal(body.skills.length, 43); + assert.equal(body.skills.length, 44); assert.ok(body.coverage !== undefined, "coverage should be present"); }); -test("GET /api/agent-skills?category=api — returns 22 api skills", async () => { +test("GET /api/agent-skills?category=api — returns 23 api skills", async () => { const req = makeRequest("GET", "http://localhost/api/agent-skills?category=api"); const res = await listRoute.GET(req); assert.equal(res.status, 200); const body = (await res.json()) as { skills: Array<{ category: string }>; count: number }; - assert.equal(body.count, 22); + assert.equal(body.count, 23); assert.ok(body.skills.every((s) => s.category === "api"), "All skills should be api category"); }); @@ -267,7 +267,7 @@ test("GET /api/agent-skills/coverage — returns valid SkillCoverage shape", asy generatedAt: string; }; - assert.equal(body.api.total, 22, "api.total must be 22"); + assert.equal(body.api.total, 23, "api.total must be 23"); assert.equal(body.cli.total, 20, "cli.total must be 20"); assert.ok(typeof body.totalSkills === "number", "totalSkills must be a number"); assert.ok(typeof body.generatedAt === "string", "generatedAt must be a string"); diff --git a/tests/unit/github-collector.test.ts b/tests/unit/github-collector.test.ts new file mode 100644 index 0000000000..e61a4e0d0f --- /dev/null +++ b/tests/unit/github-collector.test.ts @@ -0,0 +1,300 @@ +/** + * Tests for githubCollector.ts — GitHub agent skill discovery, scoring, scanning, and installation. + * Uses node:test (Node.js built-in test runner), matching the project convention. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + scoreRepo, + scanText, + inferCategory, + resolveInstallPath, + QUERY_STRATEGIES, + INSTALL_TARGETS, +} = await import("../../src/lib/skills/githubCollector.ts"); + +// ─── scoreRepo ───────────────────────────────────────────────────────────── + +void test("scoreRepo: returns 0.98 for gold repos", () => { + const score = scoreRepo({ + fullName: "addyosmani/agent-skills", + description: "Collection of agent skills", + stars: 100, + forks: 10, + hasLicense: true, + topics: [], + }); + assert.equal(score, 0.98); +}); + +void test("scoreRepo: returns reasonable score for good agent repos", () => { + const score = scoreRepo({ + fullName: "user/awesome-agent-skills", + description: "Curated list of AI agent skills for LLMs", + stars: 5000, + forks: 200, + hasLicense: true, + topics: ["agent", "ai-agent", "mcp"], + }); + assert.ok(score > 0.5); + assert.ok(score <= 1.0); +}); + +void test("scoreRepo: returns low score for unrelated repos", () => { + const score = scoreRepo({ + fullName: "user/todo-app", + description: "A simple todo list app built with react", + stars: 5, + forks: 1, + hasLicense: false, + topics: ["javascript", "react"], + }); + assert.ok(score < 0.5); +}); + +void test("scoreRepo: boosts score for skill-file name signals", () => { + const score = scoreRepo({ + fullName: "user/agent-skill-pack", + description: "Agent skill pack for codex", + stars: 100, + forks: 30, + hasLicense: true, + topics: ["agent"], + }); + assert.ok(score > 0.3); + assert.ok(score < 0.98); +}); + +void test("scoreRepo: applies stars bonus logarithmically", () => { + const low = scoreRepo({ + fullName: "user/skill-repo", + description: "Agent skill", + stars: 50, + forks: 5, + hasLicense: false, + topics: [], + }); + const high = scoreRepo({ + fullName: "user/skill-repo", + description: "Agent skill", + stars: 10000, + forks: 500, + hasLicense: false, + topics: [], + }); + assert.ok(high > low); + assert.ok(high < 0.98); +}); + +void test("scoreRepo: caps stars for awesome lists", () => { + const score = scoreRepo({ + fullName: "user/awesome-list", + description: "A curated list of awesome things", + stars: 50000, + forks: 2000, + hasLicense: true, + topics: [], + }); + // awesome list without skill file signals should be capped below gold-repo threshold + assert.ok(score < 0.9); +}); + +void test("scoreRepo: handles zero stars gracefully", () => { + const score = scoreRepo({ + fullName: "user/new-repo", + description: "Brand new agent skill", + stars: 0, + forks: 0, + hasLicense: false, + topics: [], + }); + assert.ok(score >= 0); + assert.ok(score < 0.5); +}); + +// ─── scanText ────────────────────────────────────────────────────────────── + +void test("scanText: empty findings for clean content", () => { + const findings = scanText("print('hello world')\nconst x = 1;\n", "test.md"); + assert.equal(findings.length, 0); +}); + +void test("scanText: detects eval(base64) pattern", () => { + const findings = scanText('eval(base64_decode("dGVzdA=="))', "evil.md"); + assert.ok(findings.length > 0); + assert.ok(findings[0].pattern.includes("eval(base64)")); + assert.equal(findings[0].file, "evil.md"); +}); + +void test("scanText: detects hardcoded private keys", () => { + const content = + "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----"; + const findings = scanText(content, "leaked.md"); + assert.ok(findings.some((f) => f.pattern.includes("Private key"))); +}); + +void test("scanText: detects API key patterns", () => { + const findings = scanText('const api_key = "abc123def456";', "config.js"); + assert.ok(findings.some((f) => f.pattern.includes("API key"))); +}); + +void test("scanText: detects multiple patterns in one file", () => { + const content = ` + const api_key = "12345"; + password = "secret123"; + eval(base64_decode("test")); + `; + const findings = scanText(content, "danger.js"); + assert.ok(findings.length >= 3); +}); + +void test("scanText: provides context around the match", () => { + const findings = scanText("some text before api_key = 'abc123' some text after", "test.js"); + assert.ok(findings.length > 0); + assert.ok(findings[0].context.length > 15); +}); + +void test("scanText: ignores safe content with keyword-like patterns", () => { + const findings = scanText("using password hashing for security\napi version 2.0", "safe.txt"); + assert.equal(findings.length, 0); +}); + +void test("scanText: detects OpenAI key pattern (sk- alphanumeric)", () => { + // OpenAI keys: sk- followed by 20+ alphanumeric chars (no hyphens inside the token part) + const findings = scanText('OPENAI_API_KEY="sk-abcdefghijklmnopqrstuvwxyz1234567890"', ".env"); + assert.ok(findings.some((f) => f.pattern.includes("OpenAI API key"))); +}); + +void test("scanText: detects GitHub PAT pattern (ghp_)", () => { + const findings = scanText("GITHUB_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz1234567890123456", ".env"); + assert.ok(findings.some((f) => f.pattern.includes("GitHub PAT"))); +}); + +// ─── inferCategory ───────────────────────────────────────────────────────── + +void test("inferCategory: returns 'imported-github' for generic skills", () => { + const cat = inferCategory("user/some-skill", "A useful skill for agents"); + assert.equal(cat, "imported-github"); +}); + +void test("inferCategory: detects security-related skills", () => { + const cat = inferCategory("user/sec-scan", "Security vulnerability scanner for code"); + assert.equal(cat, "security"); +}); + +void test("inferCategory: detects data-science skills", () => { + const cat = inferCategory("user/ml-pipeline", "ML model training and data analytics"); + assert.equal(cat, "data-science"); +}); + +void test("inferCategory: detects devops skills", () => { + const cat = inferCategory("user/deploy-tool", "Docker deployment pipeline for CI/CD"); + assert.equal(cat, "devops"); +}); + +void test("inferCategory: detects creative skills", () => { + const cat = inferCategory("user/art-gen", "AI image design generator"); + assert.equal(cat, "creative"); +}); + +void test("inferCategory: detects productivity skills", () => { + const cat = inferCategory("user/email-assistant", "Email scheduling and document tool"); + assert.equal(cat, "productivity"); +}); + +void test("inferCategory: detects research skills", () => { + const cat = inferCategory("user/paper-finder", "arXiv academic paper search"); + assert.equal(cat, "research"); +}); + +void test("inferCategory: detects software-development skills", () => { + const cat = inferCategory("user/code-reviewer", "Automated code review and debugging"); + assert.equal(cat, "software-development"); +}); + +void test("inferCategory: detects media skills (youtube + transcript keywords)", () => { + const cat = inferCategory("user/yt-dl", "YouTube transcript downloader"); + assert.equal(cat, "media"); +}); + +// ─── resolveInstallPath ──────────────────────────────────────────────────── + +void test("resolveInstallPath: resolves to a valid path string", () => { + const origHome = process.env.HOME; + process.env.HOME = "/home/testuser"; + try { + const path = resolveInstallPath("hermes", "sec-scan", "A security scanner"); + // The path should start with the HOME directory and contain the category + assert.ok(path.startsWith("/home/testuser")); + assert.ok(path.includes("security")); + assert.ok(path.includes("hermes/skills")); + } finally { + process.env.HOME = origHome; + } +}); + +void test("resolveInstallPath: infers category from description", () => { + const origHome = process.env.HOME; + process.env.HOME = "/home/testuser"; + try { + const path = resolveInstallPath("claude", "data-pipeline", "Data science tool"); + assert.ok(path.includes("data-science")); + } finally { + process.env.HOME = origHome; + } +}); + +void test("resolveInstallPath: uses category for unknown target and throws", () => { + assert.throws(() => resolveInstallPath("unknown", "x", ""), Error); +}); + +void test("resolveInstallPath: different targets produce different base paths", () => { + const origHome = process.env.HOME; + process.env.HOME = "/home/testuser"; + try { + const hermesPath = resolveInstallPath("hermes", "my-skill", "generic"); + const claudePath = resolveInstallPath("claude", "my-skill", "generic"); + assert.notEqual(hermesPath, claudePath); + assert.ok(hermesPath.includes("AppData/Local/hermes")); + assert.ok(claudePath.includes(".claude")); + } finally { + process.env.HOME = origHome; + } +}); + +// ─── QUERY_STRATEGIES structural sanity ──────────────────────────────────── + +void test("QUERY_STRATEGIES: has file, name, and description categories", () => { + assert.ok(Array.isArray(QUERY_STRATEGIES.file)); + assert.ok(Array.isArray(QUERY_STRATEGIES.name)); + assert.ok(Array.isArray(QUERY_STRATEGIES.description)); +}); + +void test("QUERY_STRATEGIES: has at least 10 file-name queries", () => { + assert.ok(QUERY_STRATEGIES.file.length >= 10); +}); + +void test("QUERY_STRATEGIES: all queries are non-empty strings", () => { + const all = [...QUERY_STRATEGIES.file, ...QUERY_STRATEGIES.name, ...QUERY_STRATEGIES.description]; + assert.ok(all.length > 15); + for (const q of all) { + assert.equal(typeof q, "string"); + assert.ok(q.length > 5); + } +}); + +void test("QUERY_STRATEGIES: each file query ends with a star threshold", () => { + for (const q of QUERY_STRATEGIES.file) { + assert.ok(/stars:>=\d+/.test(q)); + } +}); + +// ─── INSTALL_TARGETS ──────────────────────────────────────────────────────── + +void test("INSTALL_TARGETS: includes all expected agent targets", () => { + assert.ok(INSTALL_TARGETS.includes("hermes")); + assert.ok(INSTALL_TARGETS.includes("claude")); + assert.ok(INSTALL_TARGETS.includes("gemini")); + assert.ok(INSTALL_TARGETS.includes("opencode")); +});