- agentSkills schema gets source TEXT NOT NULL DEFAULT 'managed' column
- Migration guard in getSkillRegistryDb() handles existing DBs via ALTER TABLE
- uninstall() now accepts agentSkillsDir and removes files before soft-deleting
- syncHermesNativeSkills() reads ~/.hermes/skills/, creates stub rows with source='native'
- listAgentSkills() returns typed objects {skillId, source, installedAt} not string[]
- Interim uninstall route fix: reads agentSkillsDir from query param until Plan 02 wires agentId
254 lines
9.4 KiB
TypeScript
254 lines
9.4 KiB
TypeScript
import { eq, isNull, and, desc, sql } from "drizzle-orm";
|
|
import { cp, mkdir, rm, readdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { getSkillRegistryDb } from "./skill-registry-db.js";
|
|
import { skills, skillVersions, skillFiles, communityRatings, agentSkills } from "./skill-registry-schema.js";
|
|
import { fetchAllSources, type SkillSourceConfig } from "./skill-registry-fetcher.js";
|
|
import { resolveSkillCacheDir } from "../home-paths.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type SkillRow = typeof skills.$inferSelect;
|
|
type VersionRow = typeof skillVersions.$inferSelect;
|
|
|
|
/** Extended skill list item with community rating and usage stats from JOINs */
|
|
type SkillListItem = SkillRow & {
|
|
averageRating: number | null;
|
|
ratingCount: number | null;
|
|
taskCount: number | null;
|
|
avgCostUsd: number | null;
|
|
lastUsedAt: number | null;
|
|
};
|
|
|
|
type InstallResult =
|
|
| { type: "installed"; skillId: string; versionId: string; targetDir: string }
|
|
| { type: "pending_plugin_install"; command: string; skillId: string; versionId: string };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Skill registry service factory.
|
|
* Manages its own libSQL database (does not accept a Postgres db param).
|
|
* Use `getSkillRegistryDb()` for all persistence.
|
|
*/
|
|
export function skillRegistryService() {
|
|
return {
|
|
async list(opts?: { includeRemoved?: boolean }): Promise<SkillListItem[]> {
|
|
const db = await getSkillRegistryDb();
|
|
|
|
const query = db
|
|
.select({
|
|
// All skills columns
|
|
id: skills.id,
|
|
sourceId: skills.sourceId,
|
|
name: skills.name,
|
|
description: skills.description,
|
|
sourceUrl: skills.sourceUrl,
|
|
activeVersionId: skills.activeVersionId,
|
|
removedAt: skills.removedAt,
|
|
createdAt: skills.createdAt,
|
|
updatedAt: skills.updatedAt,
|
|
// Community rating fields from LEFT JOIN
|
|
averageRating: communityRatings.averageRating,
|
|
ratingCount: communityRatings.ratingCount,
|
|
// Aggregated usage stats across all agents
|
|
taskCount: sql<number | null>`SUM(${agentSkills.taskCount})`,
|
|
avgCostUsd: sql<number | null>`AVG(${agentSkills.avgCostUsd})`,
|
|
lastUsedAt: sql<number | null>`MAX(${agentSkills.lastUsedAt})`,
|
|
})
|
|
.from(skills)
|
|
.leftJoin(communityRatings, eq(communityRatings.skillId, skills.id))
|
|
.leftJoin(agentSkills, eq(agentSkills.skillId, skills.id))
|
|
.groupBy(skills.id, communityRatings.id);
|
|
|
|
if (opts?.includeRemoved) {
|
|
return query as Promise<SkillListItem[]>;
|
|
}
|
|
return query.where(isNull(skills.removedAt)) as Promise<SkillListItem[]>;
|
|
},
|
|
|
|
async getById(skillId: string, opts?: { includeRemoved?: boolean }): Promise<SkillListItem | undefined> {
|
|
const db = await getSkillRegistryDb();
|
|
|
|
const conditions: Parameters<typeof and>[0][] = [eq(skills.id, skillId)];
|
|
if (!opts?.includeRemoved) conditions.push(isNull(skills.removedAt));
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: skills.id,
|
|
sourceId: skills.sourceId,
|
|
name: skills.name,
|
|
description: skills.description,
|
|
sourceUrl: skills.sourceUrl,
|
|
activeVersionId: skills.activeVersionId,
|
|
removedAt: skills.removedAt,
|
|
createdAt: skills.createdAt,
|
|
updatedAt: skills.updatedAt,
|
|
averageRating: communityRatings.averageRating,
|
|
ratingCount: communityRatings.ratingCount,
|
|
taskCount: sql<number | null>`SUM(${agentSkills.taskCount})`,
|
|
avgCostUsd: sql<number | null>`AVG(${agentSkills.avgCostUsd})`,
|
|
lastUsedAt: sql<number | null>`MAX(${agentSkills.lastUsedAt})`,
|
|
})
|
|
.from(skills)
|
|
.leftJoin(communityRatings, eq(communityRatings.skillId, skills.id))
|
|
.leftJoin(agentSkills, eq(agentSkills.skillId, skills.id))
|
|
.groupBy(skills.id, communityRatings.id)
|
|
.where(and(...conditions));
|
|
return rows[0] as SkillListItem | undefined;
|
|
},
|
|
|
|
async getVersions(skillId: string): Promise<VersionRow[]> {
|
|
const db = await getSkillRegistryDb();
|
|
return db.select().from(skillVersions).where(eq(skillVersions.skillId, skillId));
|
|
},
|
|
|
|
async install(skillId: string, agentSkillsDir: string): Promise<InstallResult> {
|
|
const db = await getSkillRegistryDb();
|
|
const skill = await this.getById(skillId);
|
|
if (!skill) throw new Error(`Skill not found: ${skillId}`);
|
|
|
|
// Get latest version (most recently fetched)
|
|
const versions = await db
|
|
.select()
|
|
.from(skillVersions)
|
|
.where(eq(skillVersions.skillId, skillId))
|
|
.orderBy(desc(skillVersions.fetchedAt));
|
|
const latest = versions[0];
|
|
if (!latest) throw new Error(`No versions found for skill: ${skillId}`);
|
|
|
|
// Check if this is a marketplace plugin — identified by any file having kind="plugin"
|
|
const files = await db
|
|
.select()
|
|
.from(skillFiles)
|
|
.where(eq(skillFiles.versionId, latest.id));
|
|
const isPlugin = files.some((f) => f.kind === "plugin");
|
|
|
|
if (isPlugin) {
|
|
// Return pending plugin install command instead of copying files
|
|
const slug = skillId.split("/").pop() ?? skillId;
|
|
return {
|
|
type: "pending_plugin_install" as const,
|
|
command: `/plugin install ${slug}@marketplace`,
|
|
skillId,
|
|
versionId: latest.id,
|
|
};
|
|
}
|
|
|
|
// Copy cached files to agent skills dir
|
|
const cacheDir = latest.cacheDir ?? resolveSkillCacheDir(skillId, latest.id);
|
|
const slug = skillId.split("/").pop() ?? skillId;
|
|
const targetDir = path.join(agentSkillsDir, slug);
|
|
await mkdir(targetDir, { recursive: true });
|
|
await cp(cacheDir, targetDir, { recursive: true });
|
|
|
|
// Update active version
|
|
await db
|
|
.update(skills)
|
|
.set({ activeVersionId: latest.id, updatedAt: Date.now() })
|
|
.where(eq(skills.id, skillId));
|
|
|
|
return {
|
|
type: "installed" as const,
|
|
skillId,
|
|
versionId: latest.id,
|
|
targetDir,
|
|
};
|
|
},
|
|
|
|
async uninstall(skillId: string, agentSkillsDir: string): Promise<void> {
|
|
const db = await getSkillRegistryDb();
|
|
|
|
// Remove skill files from disk before soft-deleting the registry row
|
|
const slug = skillId.split("/").pop() ?? skillId;
|
|
const targetDir = path.join(agentSkillsDir, slug);
|
|
await rm(targetDir, { recursive: true, force: true });
|
|
|
|
await db
|
|
.update(skills)
|
|
.set({ removedAt: Date.now(), updatedAt: Date.now() })
|
|
.where(eq(skills.id, skillId));
|
|
},
|
|
|
|
async rollback(skillId: string, versionId: string, agentSkillsDir: string): Promise<void> {
|
|
const db = await getSkillRegistryDb();
|
|
const versionRows = await db
|
|
.select()
|
|
.from(skillVersions)
|
|
.where(eq(skillVersions.id, versionId));
|
|
const version = versionRows[0];
|
|
if (!version) throw new Error(`Version not found: ${versionId}`);
|
|
|
|
const cacheDir = version.cacheDir ?? resolveSkillCacheDir(skillId, versionId);
|
|
const slug = skillId.split("/").pop() ?? skillId;
|
|
const targetDir = path.join(agentSkillsDir, slug);
|
|
|
|
// Remove current files, restore from cache
|
|
await rm(targetDir, { recursive: true, force: true });
|
|
await mkdir(targetDir, { recursive: true });
|
|
await cp(cacheDir, targetDir, { recursive: true });
|
|
|
|
// Update active version to the rolled-back version
|
|
await db
|
|
.update(skills)
|
|
.set({ activeVersionId: versionId, updatedAt: Date.now() })
|
|
.where(eq(skills.id, skillId));
|
|
},
|
|
|
|
/**
|
|
* Sync native Hermes skills from ~/.hermes/skills/ into the registry.
|
|
* Creates a minimal skills stub row and an agentSkills row with source='native'
|
|
* for each subdirectory found. Idempotent — safe to call multiple times.
|
|
* Returns silently if ~/.hermes/skills/ does not exist.
|
|
*/
|
|
async syncHermesNativeSkills(agentId: string): Promise<void> {
|
|
const hermesSkillsDir = path.join(os.homedir(), ".hermes", "skills");
|
|
let entries: string[];
|
|
try {
|
|
entries = await readdir(hermesSkillsDir);
|
|
} catch {
|
|
// Directory doesn't exist — no native skills
|
|
return;
|
|
}
|
|
|
|
const db = await getSkillRegistryDb();
|
|
const now = Date.now();
|
|
|
|
for (const entry of entries) {
|
|
const skillId = `hermes-native/${entry}`;
|
|
|
|
// Create a minimal stub in the skills table so JOINs work
|
|
await db.insert(skills).values({
|
|
id: skillId,
|
|
sourceId: "hermes-native",
|
|
name: entry,
|
|
description: null,
|
|
sourceUrl: null,
|
|
activeVersionId: null,
|
|
removedAt: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}).onConflictDoNothing();
|
|
|
|
// Record in agent_skills with source = 'native'
|
|
await db.insert(agentSkills).values({
|
|
agentId,
|
|
skillId,
|
|
installedAt: now,
|
|
source: "native",
|
|
}).onConflictDoNothing();
|
|
}
|
|
},
|
|
|
|
async fetchAll(
|
|
sources?: SkillSourceConfig[],
|
|
): Promise<{ fetched: number; errors: string[] }> {
|
|
return fetchAllSources(sources);
|
|
},
|
|
};
|
|
}
|