From ce69ebd2ec4eafdf81e222ef6fa2d694517d8035 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 19 Mar 2026 12:05:27 -0500 Subject: [PATCH] Add DELETE endpoint for company skills and fix skills.sh URL resolution - Add DELETE /api/companies/:companyId/skills/:skillId endpoint with same permission model as other skill mutations. Deleting a skill removes it from the DB, cleans up materialized runtime files, and automatically strips it from any agent desiredSkills that reference it. - Fix parseSkillImportSourceInput to detect skills.sh URLs (e.g. https://skills.sh/org/repo/skill) and resolve them to the underlying GitHub repo + skill slug, instead of fetching the HTML page. - Add tests for skills.sh URL resolution with and without skill slug. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- server/src/__tests__/company-skills.test.ts | 18 +++++++ server/src/routes/company-skills.ts | 29 +++++++++++ server/src/services/company-skills.ts | 56 ++++++++++++++++++++- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts index 362ae65c..73afd751 100644 --- a/server/src/__tests__/company-skills.test.ts +++ b/server/src/__tests__/company-skills.test.ts @@ -45,6 +45,24 @@ describe("company skill import source parsing", () => { expect(parsed.requestedSkillSlug).toBe("find-skills"); }); + it("resolves skills.sh URL with org/repo/skill to GitHub repo", () => { + const parsed = parseSkillImportSourceInput( + "https://skills.sh/google-labs-code/stitch-skills/design-md", + ); + + expect(parsed.resolvedSource).toBe("https://github.com/google-labs-code/stitch-skills"); + expect(parsed.requestedSkillSlug).toBe("design-md"); + }); + + it("resolves skills.sh URL with org/repo (no skill) to GitHub repo", () => { + const parsed = parseSkillImportSourceInput( + "https://skills.sh/vercel-labs/skills", + ); + + expect(parsed.resolvedSource).toBe("https://github.com/vercel-labs/skills"); + expect(parsed.requestedSkillSlug).toBeNull(); + }); + it("parses skills.sh commands whose requested skill differs from the folder name", () => { const parsed = parseSkillImportSourceInput( "npx skills add https://github.com/remotion-dev/skills --skill remotion-best-practices", diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index c8de035b..7b239832 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -221,6 +221,35 @@ export function companySkillRoutes(db: Db) { }, ); + router.delete("/companies/:companyId/skills/:skillId", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.deleteSkill(companyId, skillId); + if (!result) { + res.status(404).json({ error: "Skill not found" }); + return; + } + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_deleted", + entityType: "company_skill", + entityId: result.id, + details: { + slug: result.slug, + name: result.name, + }, + }); + + res.json(result); + }); + router.post("/companies/:companyId/skills/:skillId/install-update", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 87b7c11c..97d8bbf0 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { and, asc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companySkills } from "@paperclipai/db"; -import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; +import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils"; import type { CompanySkill, @@ -578,6 +578,17 @@ export function parseSkillImportSourceInput(rawInput: string): ParsedSkillImport }; } + // Detect skills.sh URLs and resolve to GitHub: https://skills.sh/org/repo/skill → org/repo/skill key + const skillsShMatch = normalizedSource.match(/^https?:\/\/(?:www\.)?skills\.sh\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\/([A-Za-z0-9_.-]+))?(?:[?#].*)?$/i); + if (skillsShMatch) { + const [, owner, repo, skillSlugRaw] = skillsShMatch; + return { + resolvedSource: `https://github.com/${owner}/${repo}`, + requestedSkillSlug: skillSlugRaw ? normalizeSkillSlug(skillSlugRaw) : requestedSkillSlug, + warnings, + }; + } + return { resolvedSource: normalizedSource, requestedSkillSlug, @@ -2195,6 +2206,48 @@ export function companySkillService(db: Db) { return { imported, warnings }; } + async function deleteSkill(companyId: string, skillId: string): Promise { + const row = await db + .select() + .from(companySkills) + .where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!row) return null; + + const skill = toCompanySkill(row); + + // Remove from any agent desiredSkills that reference this skill + const agentRows = await agents.list(companyId); + const allSkills = await listFull(companyId); + for (const agent of agentRows) { + const config = agent.adapterConfig as Record; + const preference = readPaperclipSkillSyncPreference(config); + const referencesSkill = preference.desiredSkills.some((ref) => { + const resolved = resolveSkillReference(allSkills, ref); + return resolved.skill?.id === skillId; + }); + if (referencesSkill) { + const filtered = preference.desiredSkills.filter((ref) => { + const resolved = resolveSkillReference(allSkills, ref); + return resolved.skill?.id !== skillId; + }); + await agents.update(agent.id, { + adapterConfig: writePaperclipSkillSyncPreference(config, filtered), + }); + } + } + + // Delete DB row + await db + .delete(companySkills) + .where(eq(companySkills.id, skillId)); + + // Clean up materialized runtime files + await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true }); + + return skill; + } + return { list, listFull, @@ -2209,6 +2262,7 @@ export function companySkillService(db: Db) { readFile, updateFile, createLocalSkill, + deleteSkill, importFromSource, scanProjectWorkspaces, importPackageFiles,