feat(09-03): implement skillRegistryService with install, uninstall, rollback, list
- install() copies cached files to agent .claude/skills/<slug>/ dir - install() returns pending_plugin_install for skills with file kind=plugin - uninstall() soft-deletes via removed_at timestamp - rollback() restores prior version from cache and updates active_version_id - list() filters soft-deleted by default; includeRemoved=true returns all - fetchAll() delegates to fetchAllSources for multi-source refresh
This commit is contained in:
parent
136155ad27
commit
22ab1da17b
1 changed files with 145 additions and 0 deletions
145
server/src/services/skill-registry.ts
Normal file
145
server/src/services/skill-registry.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { eq, isNull, and, desc } from "drizzle-orm";
|
||||
import { cp, mkdir, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { getSkillRegistryDb } from "./skill-registry-db.js";
|
||||
import { skills, skillVersions, skillFiles } 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;
|
||||
type SkillListItem = SkillRow;
|
||||
|
||||
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();
|
||||
if (opts?.includeRemoved) {
|
||||
return db.select().from(skills);
|
||||
}
|
||||
return db.select().from(skills).where(isNull(skills.removedAt));
|
||||
},
|
||||
|
||||
async getById(skillId: string, opts?: { includeRemoved?: boolean }): Promise<SkillRow | 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().from(skills).where(and(...conditions));
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
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): Promise<void> {
|
||||
const db = await getSkillRegistryDb();
|
||||
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));
|
||||
},
|
||||
|
||||
async fetchAll(
|
||||
sources?: SkillSourceConfig[],
|
||||
): Promise<{ fetched: number; errors: string[] }> {
|
||||
return fetchAllSources(sources);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue