From 666d91da8baab73b558fd861ca8e7447debda646 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Wed, 1 Apr 2026 11:15:27 +0200 Subject: [PATCH] =?UTF-8?q?[nexus]=20feat(19-01):=20adapter-aware=20skill?= =?UTF-8?q?=20service=20layer=20=E2=80=94=20source=20column,=20uninstall?= =?UTF-8?q?=20file=20removal,=20syncHermesNativeSkills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- server/src/routes/skill-registry.ts | 6 ++- server/src/services/skill-registry-db.ts | 1 + server/src/services/skill-registry-groups.ts | 14 +++-- server/src/services/skill-registry-schema.ts | 1 + server/src/services/skill-registry.ts | 56 +++++++++++++++++++- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/server/src/routes/skill-registry.ts b/server/src/routes/skill-registry.ts index f0ccb948..d92f9871 100644 --- a/server/src/routes/skill-registry.ts +++ b/server/src/routes/skill-registry.ts @@ -52,11 +52,13 @@ export function skillRegistryRoutes(): Router { res.json({ ok: true }); }); - // Soft-delete a skill + // Soft-delete a skill (also removes files from disk) + // agentSkillsDir is required as a query param until Plan 02 replaces it with agentId-based resolution router.delete("/skill-registry/skills/:sourceId/:slug", async (req, res) => { assertBoard(req); const skillId = `${req.params.sourceId}/${req.params.slug}`; - await svc.uninstall(skillId); + const agentSkillsDir = (req.query.agentSkillsDir as string | undefined) ?? ""; + await svc.uninstall(skillId, agentSkillsDir); res.json({ ok: true }); }); diff --git a/server/src/services/skill-registry-db.ts b/server/src/services/skill-registry-db.ts index a5468d31..6cefb498 100644 --- a/server/src/services/skill-registry-db.ts +++ b/server/src/services/skill-registry-db.ts @@ -166,6 +166,7 @@ export async function getSkillRegistryDb(): Promise { `ALTER TABLE agent_skills ADD COLUMN task_count INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE agent_skills ADD COLUMN avg_cost_usd REAL`, `ALTER TABLE agent_skills ADD COLUMN last_used_at INTEGER`, + `ALTER TABLE agent_skills ADD COLUMN source TEXT NOT NULL DEFAULT 'managed'`, ]; for (const sql of agentSkillsAlters) { try { diff --git a/server/src/services/skill-registry-groups.ts b/server/src/services/skill-registry-groups.ts index e6c0e2af..ba453e58 100644 --- a/server/src/services/skill-registry-groups.ts +++ b/server/src/services/skill-registry-groups.ts @@ -419,13 +419,21 @@ export function skillGroupService() { .where(inArray(skillGroups.id, groupIds)); }, - async listAgentSkills(agentId: string): Promise { + async listAgentSkills(agentId: string): Promise> { const db = await getSkillRegistryDb(); const rows = await db - .select() + .select({ + skillId: agentSkills.skillId, + source: agentSkills.source, + installedAt: agentSkills.installedAt, + }) .from(agentSkills) .where(eq(agentSkills.agentId, agentId)); - return rows.map((r) => r.skillId); + return rows.map((r) => ({ + skillId: r.skillId, + source: (r.source ?? "managed") as "managed" | "native", + installedAt: r.installedAt, + })); }, async getAgentEffectiveSkills(agentId: string): Promise { diff --git a/server/src/services/skill-registry-schema.ts b/server/src/services/skill-registry-schema.ts index 52f30d6b..4901a8ca 100644 --- a/server/src/services/skill-registry-schema.ts +++ b/server/src/services/skill-registry-schema.ts @@ -73,6 +73,7 @@ export const agentSkills = sqliteTable("agent_skills", { agentId: text("agent_id").notNull(), skillId: text("skill_id").notNull(), installedAt: integer("installed_at").notNull(), + source: text("source").notNull().default("managed"), // 'managed' | 'native' taskCount: integer("task_count").notNull().default(0), avgCostUsd: real("avg_cost_usd"), lastUsedAt: integer("last_used_at"), diff --git a/server/src/services/skill-registry.ts b/server/src/services/skill-registry.ts index b419d60e..9050de10 100644 --- a/server/src/services/skill-registry.ts +++ b/server/src/services/skill-registry.ts @@ -1,6 +1,7 @@ import { eq, isNull, and, desc, sql } from "drizzle-orm"; -import { cp, mkdir, rm } from "node:fs/promises"; +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"; @@ -160,8 +161,14 @@ export function skillRegistryService() { }; }, - async uninstall(skillId: string): Promise { + async uninstall(skillId: string, agentSkillsDir: string): Promise { 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() }) @@ -193,6 +200,51 @@ export function skillRegistryService() { .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 { + 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[] }> {