mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
fix(github-skills): add missing import, add unit tests, fix settings JSON parse (#6186)
feat(skills): GitHub skill-discovery subsystem — search/score/scan/import agent skills from GitHub, MCP tools (scope-gated) + /api/github-skills route (host-pinned api.github.com, sanitized errors). Merged — thank you, @Moseyuh333! Pre-merge fixes: passed toolDef.scopes so tools gate correctly under scope enforcement, routed the GET error path through sanitizeErrorMessage+500 (Hard Rule #12), and realigned the agent-skills count guards (catalog/routes/generator/mcp) for the new catalog entry. Base-reds only. Integrated into release/v3.8.45.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
140
open-sse/mcp-server/tools/githubSkillTools.ts
Normal file
140
open-sse/mcp-server/tools/githubSkillTools.ts
Normal file
@@ -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<typeof GitHubSkillsSearchSchema>) {
|
||||
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<typeof GitHubSkillsScanSchema>) {
|
||||
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<typeof GitHubSkillsInstallSchema>) {
|
||||
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,
|
||||
},
|
||||
};
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
97
src/app/api/github-skills/route.ts
Normal file
97
src/app/api/github-skills/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<SkillMarkdown> {
|
||||
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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
426
src/lib/skills/githubCollector.ts
Normal file
426
src/lib/skills/githubCollector.ts
Normal file
@@ -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<InstallTarget, string> = {
|
||||
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<string, string[]> = {
|
||||
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<string>();
|
||||
const repos: GitHubSkillRepo[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
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"),
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
300
tests/unit/github-collector.test.ts
Normal file
300
tests/unit/github-collector.test.ts
Normal file
@@ -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"));
|
||||
});
|
||||
Reference in New Issue
Block a user