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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user