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 <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dotta
2026-03-19 12:05:27 -05:00
parent 8edff22c0b
commit ce69ebd2ec
3 changed files with 102 additions and 1 deletions

View File

@@ -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",

View File

@@ -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;

View File

@@ -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<CompanySkill | null> {
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<string, unknown>;
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,