Implement skills store API foundation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-11 16:18:33 +00:00
1 parent 69a368ed51
commit db599edadf
23 files changed
+2145 -53

No files matched your search

@@ -12,6 +12,19 @@ const mockAccessService = vi.hoisted(() => ({
}));
const mockCompanySkillService = vi.hoisted(() => ({
list: vi.fn(),
categoryCounts: vi.fn(),
detail: vi.fn(),
listVersions: vi.fn(),
getVersion: vi.fn(),
createVersion: vi.fn(),
starSkill: vi.fn(),
unstarSkill: vi.fn(),
forkSkill: vi.fn(),
listComments: vi.fn(),
createComment: vi.fn(),
updateComment: vi.fn(),
deleteComment: vi.fn(),
importFromSource: vi.fn(),
installFromCatalog: vi.fn(),
deleteSkill: vi.fn(),
@@ -102,6 +115,101 @@ describe("company skill mutation permissions", () => {
imported: [],
warnings: [],
});
mockCompanySkillService.list.mockResolvedValue([]);
mockCompanySkillService.categoryCounts.mockResolvedValue([]);
mockCompanySkillService.detail.mockResolvedValue(null);
mockCompanySkillService.listVersions.mockResolvedValue([]);
mockCompanySkillService.getVersion.mockResolvedValue(null);
mockCompanySkillService.createVersion.mockResolvedValue({
id: "version-1",
companyId: "company-1",
companySkillId: "skill-1",
revisionNumber: 1,
label: "v1",
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# Skill" }],
authorAgentId: null,
authorUserId: "board",
createdAt: new Date("2026-05-26T00:00:00.000Z"),
});
mockCompanySkillService.starSkill.mockResolvedValue({
skillId: "skill-1",
starred: true,
starCount: 1,
});
mockCompanySkillService.unstarSkill.mockResolvedValue({
skillId: "skill-1",
starred: false,
starCount: 0,
});
mockCompanySkillService.forkSkill.mockResolvedValue({
id: "skill-fork",
companyId: "company-1",
key: "company/company-1/review-fork",
slug: "review-fork",
name: "Review Fork",
description: null,
markdown: "# Review",
sourceType: "local_path",
sourceLocator: "/tmp/review-fork",
sourceRef: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
iconUrl: null,
color: null,
tagline: null,
authorName: null,
homepageUrl: null,
categories: [],
sharingScope: "company",
publicShareToken: null,
forkedFromSkillId: "skill-1",
forkedFromCompanyId: "company-1",
starCount: 0,
installCount: 1,
forkCount: 0,
currentVersionId: null,
metadata: null,
createdAt: new Date("2026-05-26T00:00:00.000Z"),
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
});
mockCompanySkillService.listComments.mockResolvedValue([]);
mockCompanySkillService.createComment.mockResolvedValue({
id: "comment-1",
companyId: "company-1",
companySkillId: "skill-1",
parentCommentId: null,
authorAgentId: null,
authorUserId: "board",
body: "Looks good",
deletedAt: null,
createdAt: new Date("2026-05-26T00:00:00.000Z"),
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
});
mockCompanySkillService.updateComment.mockResolvedValue({
id: "comment-1",
companyId: "company-1",
companySkillId: "skill-1",
parentCommentId: null,
authorAgentId: null,
authorUserId: "board",
body: "Updated",
deletedAt: null,
createdAt: new Date("2026-05-26T00:00:00.000Z"),
updatedAt: new Date("2026-05-26T00:00:00.000Z"),
});
mockCompanySkillService.deleteComment.mockResolvedValue({
id: "comment-1",
companyId: "company-1",
companySkillId: "skill-1",
parentCommentId: null,
authorAgentId: null,
authorUserId: "board",
body: "Updated",
deletedAt: new Date("2026-05-26T00:01:00.000Z"),
createdAt: new Date("2026-05-26T00:00:00.000Z"),
updatedAt: new Date("2026-05-26T00:01:00.000Z"),
});
mockCompanySkillService.installFromCatalog.mockResolvedValue({
action: "created",
skill: {
@@ -484,6 +592,77 @@ describe("company skill mutation permissions", () => {
expect(mockCompanySkillService.installFromCatalog).not.toHaveBeenCalled();
});
it("passes store list filters and category count requests to the service", async () => {
const app = await createApp({ type: "board", source: "local_implicit" });
await request(app)
.get("/api/companies/company-1/skills?sort=stars&categories[]=memory&category=git&scope=company&q=review")
.expect(200);
expect(mockCompanySkillService.list).toHaveBeenCalledWith("company-1", {
q: "review",
sort: "stars",
categories: ["git", "memory"],
scope: "company",
});
await request(app).get("/api/companies/company-1/skills/categories").expect(200);
expect(mockCompanySkillService.categoryCounts).toHaveBeenCalledWith("company-1");
});
it("creates skill versions and logs the mutation", async () => {
const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" });
await request(app)
.post("/api/companies/company-1/skills/skill-1/versions")
.send({ label: "v1" })
.expect(201);
expect(mockCompanySkillService.createVersion).toHaveBeenCalledWith("company-1", "skill-1", { label: "v1" }, {
type: "user",
userId: "user-1",
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_version_created",
entityType: "company_skill_version",
entityId: "version-1",
}));
});
it("stars, forks, and comments on skills through company-scoped endpoints", async () => {
const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" });
await request(app).post("/api/companies/company-1/skills/skill-1/star").send({}).expect(200);
expect(mockCompanySkillService.starSkill).toHaveBeenCalledWith("company-1", "skill-1", {
type: "user",
userId: "user-1",
});
await request(app).post("/api/companies/company-1/skills/skill-1/fork").send({ slug: "review-fork" }).expect(201);
expect(mockCompanySkillService.forkSkill).toHaveBeenCalledWith("company-1", "skill-1", { slug: "review-fork" }, {
type: "user",
userId: "user-1",
});
await request(app).post("/api/companies/company-1/skills/skill-1/comments").send({ body: "Looks good" }).expect(201);
expect(mockCompanySkillService.createComment).toHaveBeenCalledWith("company-1", "skill-1", { body: "Looks good" }, {
type: "user",
userId: "user-1",
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_starred",
entityId: "skill-1",
}));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_forked",
entityId: "skill-fork",
}));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "company.skill_comment_created",
entityId: "comment-1",
}));
});
it("allows agents with canCreateAgents to mutate company skills", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
@@ -143,6 +143,169 @@ describeEmbeddedPostgres("companySkillService.list", () => {
});
});
it("filters store list results by category and creates version snapshots", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-versioned-skill-"));
cleanupDirs.add(skillDir);
await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: Versioned Skill\ncategories:\n - Memory\n---\n\n# Versioned Skill\n", "utf8");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(companySkills).values({
id: skillId,
companyId,
key: `company/${companyId}/versioned-skill`,
slug: "versioned-skill",
name: "Versioned Skill",
description: "Tracks revisions.",
markdown: "# Versioned Skill",
sourceType: "local_path",
sourceLocator: skillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
categories: ["memory"],
tagline: "Tracks revisions",
});
const filtered = await svc.list(companyId, { categories: ["memory"], sort: "recent" });
expect(filtered.some((skill) => skill.id === skillId)).toBe(true);
expect(filtered.find((skill) => skill.id === skillId)).toMatchObject({
categories: ["memory"],
tagline: "Tracks revisions",
});
const version = await svc.createVersion(companyId, skillId, { label: "v1" }, { type: "user", userId: "board" });
expect(version).toMatchObject({
companySkillId: skillId,
revisionNumber: 1,
label: "v1",
authorUserId: "board",
});
expect(version.fileInventory).toEqual([
expect.objectContaining({
path: "SKILL.md",
kind: "skill",
content: expect.stringContaining("# Versioned Skill"),
}),
]);
await expect(svc.getVersion(companyId, skillId, version.id)).resolves.toMatchObject({ id: version.id });
});
it("tracks stars and skill comments with actor ownership", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(companySkills).values({
id: skillId,
companyId,
key: `company/${companyId}/discussion-skill`,
slug: "discussion-skill",
name: "Discussion Skill",
description: null,
markdown: "# Discussion Skill",
sourceType: "local_path",
sourceLocator: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
});
await expect(svc.starSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
starred: true,
starCount: 1,
});
await expect(svc.starSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
starred: true,
starCount: 1,
});
const comment = await svc.createComment(
companyId,
skillId,
{ body: "Looks useful." },
{ type: "user", userId: "board" },
);
expect(comment).toMatchObject({ body: "Looks useful.", authorUserId: "board" });
await expect(svc.updateComment(
companyId,
skillId,
comment.id,
{ body: "Looks very useful." },
{ type: "agent", agentId: randomUUID() },
)).rejects.toMatchObject({ status: 422 });
await expect(svc.deleteComment(companyId, skillId, comment.id, { type: "user", userId: "board" }))
.resolves.toMatchObject({ id: comment.id, deletedAt: expect.any(Date) });
await expect(svc.unstarSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
starred: false,
starCount: 0,
});
});
it("validates version-aware desired skill selections", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
const otherSkillId = randomUUID();
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pinned-skill-"));
cleanupDirs.add(skillDir);
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Pinned Skill\n", "utf8");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(companySkills).values([
{
id: skillId,
companyId,
key: `company/${companyId}/pinned-skill`,
slug: "pinned-skill",
name: "Pinned Skill",
description: null,
markdown: "# Pinned Skill",
sourceType: "local_path",
sourceLocator: skillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
},
{
id: otherSkillId,
companyId,
key: `company/${companyId}/other-skill`,
slug: "other-skill",
name: "Other Skill",
description: null,
markdown: "# Other Skill",
sourceType: "local_path",
sourceLocator: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
},
]);
const version = await svc.createVersion(companyId, skillId, {}, { type: "user", userId: "board" });
await expect(svc.resolveRequestedSkillEntries(companyId, [
{ key: "pinned-skill", versionId: version.id },
])).resolves.toEqual([
{ key: `company/${companyId}/pinned-skill`, versionId: version.id },
]);
await expect(svc.resolveRequestedSkillEntries(companyId, [
{ key: "other-skill", versionId: version.id },
])).rejects.toMatchObject({ status: 422 });
});
it("preserves missing local-path skills that active agents still desire", async () => {
const companyId = randomUUID();
const skillId = randomUUID();