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");
|
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", () => {
|
it("parses skills.sh commands whose requested skill differs from the folder name", () => {
|
||||||
const parsed = parseSkillImportSourceInput(
|
const parsed = parseSkillImportSourceInput(
|
||||||
"npx skills add https://github.com/remotion-dev/skills --skill remotion-best-practices",
|
"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) => {
|
router.post("/companies/:companyId/skills/:skillId/install-update", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
const skillId = req.params.skillId 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 { and, asc, eq } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { companySkills } 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 { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
|
||||||
import type {
|
import type {
|
||||||
CompanySkill,
|
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 {
|
return {
|
||||||
resolvedSource: normalizedSource,
|
resolvedSource: normalizedSource,
|
||||||
requestedSkillSlug,
|
requestedSkillSlug,
|
||||||
@@ -2195,6 +2206,48 @@ export function companySkillService(db: Db) {
|
|||||||
return { imported, warnings };
|
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 {
|
return {
|
||||||
list,
|
list,
|
||||||
listFull,
|
listFull,
|
||||||
@@ -2209,6 +2262,7 @@ export function companySkillService(db: Db) {
|
|||||||
readFile,
|
readFile,
|
||||||
updateFile,
|
updateFile,
|
||||||
createLocalSkill,
|
createLocalSkill,
|
||||||
|
deleteSkill,
|
||||||
importFromSource,
|
importFromSource,
|
||||||
scanProjectWorkspaces,
|
scanProjectWorkspaces,
|
||||||
importPackageFiles,
|
importPackageFiles,
|
||||||
|
|||||||
Reference in New Issue
Block a user