[nexus] feat(19-01): adapter-aware skill service layer — source column, uninstall file removal, syncHermesNativeSkills
- 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
This commit is contained in:
parent
034fa35d5e
commit
666d91da8b
5 changed files with 71 additions and 7 deletions
|
|
@ -52,11 +52,13 @@ export function skillRegistryRoutes(): Router {
|
||||||
res.json({ ok: true });
|
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) => {
|
router.delete("/skill-registry/skills/:sourceId/:slug", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const skillId = `${req.params.sourceId}/${req.params.slug}`;
|
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 });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ export async function getSkillRegistryDb(): Promise<SkillRegistryDb> {
|
||||||
`ALTER TABLE agent_skills ADD COLUMN task_count INTEGER NOT NULL DEFAULT 0`,
|
`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 avg_cost_usd REAL`,
|
||||||
`ALTER TABLE agent_skills ADD COLUMN last_used_at INTEGER`,
|
`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) {
|
for (const sql of agentSkillsAlters) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -419,13 +419,21 @@ export function skillGroupService() {
|
||||||
.where(inArray(skillGroups.id, groupIds));
|
.where(inArray(skillGroups.id, groupIds));
|
||||||
},
|
},
|
||||||
|
|
||||||
async listAgentSkills(agentId: string): Promise<string[]> {
|
async listAgentSkills(agentId: string): Promise<Array<{ skillId: string; source: "managed" | "native"; installedAt: number }>> {
|
||||||
const db = await getSkillRegistryDb();
|
const db = await getSkillRegistryDb();
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select({
|
||||||
|
skillId: agentSkills.skillId,
|
||||||
|
source: agentSkills.source,
|
||||||
|
installedAt: agentSkills.installedAt,
|
||||||
|
})
|
||||||
.from(agentSkills)
|
.from(agentSkills)
|
||||||
.where(eq(agentSkills.agentId, agentId));
|
.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<string[]> {
|
async getAgentEffectiveSkills(agentId: string): Promise<string[]> {
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export const agentSkills = sqliteTable("agent_skills", {
|
||||||
agentId: text("agent_id").notNull(),
|
agentId: text("agent_id").notNull(),
|
||||||
skillId: text("skill_id").notNull(),
|
skillId: text("skill_id").notNull(),
|
||||||
installedAt: integer("installed_at").notNull(),
|
installedAt: integer("installed_at").notNull(),
|
||||||
|
source: text("source").notNull().default("managed"), // 'managed' | 'native'
|
||||||
taskCount: integer("task_count").notNull().default(0),
|
taskCount: integer("task_count").notNull().default(0),
|
||||||
avgCostUsd: real("avg_cost_usd"),
|
avgCostUsd: real("avg_cost_usd"),
|
||||||
lastUsedAt: integer("last_used_at"),
|
lastUsedAt: integer("last_used_at"),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { eq, isNull, and, desc, sql } from "drizzle-orm";
|
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 path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
import { getSkillRegistryDb } from "./skill-registry-db.js";
|
import { getSkillRegistryDb } from "./skill-registry-db.js";
|
||||||
import { skills, skillVersions, skillFiles, communityRatings, agentSkills } from "./skill-registry-schema.js";
|
import { skills, skillVersions, skillFiles, communityRatings, agentSkills } from "./skill-registry-schema.js";
|
||||||
import { fetchAllSources, type SkillSourceConfig } from "./skill-registry-fetcher.js";
|
import { fetchAllSources, type SkillSourceConfig } from "./skill-registry-fetcher.js";
|
||||||
|
|
@ -160,8 +161,14 @@ export function skillRegistryService() {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async uninstall(skillId: string): Promise<void> {
|
async uninstall(skillId: string, agentSkillsDir: string): Promise<void> {
|
||||||
const db = await getSkillRegistryDb();
|
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
|
await db
|
||||||
.update(skills)
|
.update(skills)
|
||||||
.set({ removedAt: Date.now(), updatedAt: Date.now() })
|
.set({ removedAt: Date.now(), updatedAt: Date.now() })
|
||||||
|
|
@ -193,6 +200,51 @@ export function skillRegistryService() {
|
||||||
.where(eq(skills.id, skillId));
|
.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(
|
async fetchAll(
|
||||||
sources?: SkillSourceConfig[],
|
sources?: SkillSourceConfig[],
|
||||||
): Promise<{ fetched: number; errors: string[] }> {
|
): Promise<{ fetched: number; errors: string[] }> {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue