Build the Skills Store (#7990)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents increasingly depend on reusable skills, so the control plane
needs a first-class way to browse, inspect, install, version, and attach
those skills.
> - The old skills surface was mostly operational plumbing; it did not
give operators a store-like discovery flow, canonical detail URLs, rich
source/version context, or creation paths.
> - The backend also needed stronger contracts around company skill
metadata, versions, install counts, runtime materialization, and adapter
skill preferences.
> - This pull request builds the Skills Store foundation across DB,
shared contracts, server routes/services, UI, and Storybook.
> - The benefit is a more inspectable, operator-friendly skill workflow
that still preserves company-scoped control-plane boundaries and agent
runtime behavior.

## Linked Issues or Issue Description

No GitHub issue exists for this Paperclip work item. Paperclip task
refs: PAP-10846 and PAP-10921.

Feature request:
Paperclip operators need a single Skills Store experience where company
skills can be discovered, inspected, created, versioned, installed, and
attached to agents without relying on scattered operational screens or
implicit runtime state.

Related PR search:
- Searched GitHub for `Skills Store`, `company skills`, and `skill
detail`.
- Found several open skills-related PRs such as #7809 and #4409, but no
duplicate PR for this end-to-end Skills Store branch.

## What Changed

- Added the Skills Store backend foundation: company skill schema
fields, migrations, shared types/validators, and expanded server skill
routes/services.
- Added skill discovery, category navigation, canonical skill detail
routes, tabs, source attribution, version snapshots/diffs, install count
backfill, and creation flows.
- Updated agent skill preference handling so version selections survive
runtime mention injection and runtime skill materialization honors
pinned versions.
- Preserved unversioned skill assignments as live/current selections
instead of silently pinning them to the current version at assignment
time.
- Added focused regression coverage for company skill routes/services,
route helpers, UI behavior, skill version diffs, and runtime skill
version pins.
- Added Storybook coverage for Skills Store discovery/detail states and
updated the main layout navigation.
- Addressed Greptile findings around version creation races,
soft-deleted comments, fork metadata scoping, GitHub skill directory
fallback, runtime snapshot materialization, shared runtime
skill-selection helpers, and version-assignment semantics.

## Verification

- `pnpm exec vitest run
server/src/__tests__/heartbeat-project-env.test.ts
server/src/__tests__/heartbeat-runtime-skills.test.ts`
- `pnpm exec vitest run
packages/shared/src/validators/company-skill.test.ts`
- `pnpm exec vitest run server/src/__tests__/company-portability.test.ts
server/src/__tests__/company-skills-service.test.ts`
- `pnpm exec vitest run
cli/src/__tests__/company-import-export-e2e.test.ts`
- `pnpm exec vitest run
server/src/__tests__/agent-skills-routes.test.ts`
- `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts`
- `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/company-skills-routes.test.ts
server/src/__tests__/heartbeat-runtime-skills.test.ts`
- `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts`
- `pnpm exec vitest run ui/src/components/IssueProperties.test.tsx -t
"edits existing custom assignee model options from the properties pane"`
- `pnpm --filter @paperclipai/server typecheck`
- GitHub checks are green on `0823957a2`: Build, Canary Dry Run, General
tests, Typecheck + Release Registry, serialized server suites, e2e,
policy/review, Socket, Snyk, and aggregate `verify`.
- Greptile Review succeeded on `0823957a2` with `40 files reviewed, 0
comments added`; GitHub unresolved review threads: 0.

Not run in this heartbeat:
- Browser screenshot capture for the UI changes. This PR intentionally
omits screenshots per the Paperclip task direction not to add design
screenshots/images.

## Risks

- Broad feature branch touching DB, shared contracts, server, and UI;
reviewers should still scan merge conflicts carefully if `master` moves
again before landing.
- Skill version/runtime behavior is sensitive: pinned skill versions
must stay pinned while default selections should continue following the
current version.
- UI polish should get normal reviewer/browser attention before merge
because this PR includes a large Skills Store surface and screenshots
were intentionally omitted.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-based coding agent with tool use and local command
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (intentionally omitted per PAP-10921 direction)
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-06-11 14:02:09 -05:00
committed by GitHub
parent 69a368ed51
commit 1413729a06
40 changed files with 6846 additions and 492 deletions
@@ -44,6 +44,7 @@ const mockAgentInstructionsService = vi.hoisted(() => ({
const mockCompanySkillService = vi.hoisted(() => ({
listRuntimeSkillEntries: vi.fn(),
resolveRequestedSkillEntries: vi.fn(),
resolveRequestedSkillKeys: vi.fn(),
}));
@@ -258,6 +259,13 @@ describe.sequential("agent skill routes", () => {
: value,
),
);
mockCompanySkillService.resolveRequestedSkillEntries.mockImplementation(
async (_companyId: string, requested: Array<{ key: string; versionId?: string | null }>) =>
requested.map((entry) => ({
key: entry.key === "paperclip" ? "paperclipai/paperclip/paperclip" : entry.key,
versionId: entry.versionId ?? null,
})),
);
mockAdapter.listSkills.mockResolvedValue({
adapterType: "claude_local",
supported: true,
@@ -338,9 +346,10 @@ describe.sequential("agent skill routes", () => {
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", expect.objectContaining({
materializeMissing: false,
});
versionSelections: expect.any(Map),
}));
expect(mockAdapter.listSkills).toHaveBeenCalledWith(
expect.objectContaining({
adapterType: "claude_local",
@@ -369,9 +378,10 @@ describe.sequential("agent skill routes", () => {
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", expect.objectContaining({
materializeMissing: false,
});
versionSelections: expect.any(Map),
}));
});
it("passes ACPX Claude config through the agent skill listing route", async () => {
@@ -398,9 +408,10 @@ describe.sequential("agent skill routes", () => {
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", expect.objectContaining({
materializeMissing: false,
});
versionSelections: expect.any(Map),
}));
expect(mockAdapter.listSkills).toHaveBeenCalledWith(
expect.objectContaining({
adapterType: "acpx_local",
@@ -485,9 +496,10 @@ describe.sequential("agent skill routes", () => {
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", expect.objectContaining({
materializeMissing: false,
});
versionSelections: expect.any(Map),
}));
});
it("skips runtime materialization when syncing Claude skills", async () => {
@@ -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,88 @@ 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("does not synthesize a shared board user id for board actors without user ids", async () => {
const app = await createApp({ type: "board", source: "local_implicit" });
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: null,
});
});
it("allows agents with canCreateAgents to mutate company skills", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, companySkills, createDb } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -143,6 +143,339 @@ 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,
});
await expect(svc.starSkill(companyId, skillId, { type: "user", userId: null })).rejects.toMatchObject({
status: 422,
});
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.listComments(companyId, skillId)).resolves.toEqual([]);
await expect(svc.updateComment(
companyId,
skillId,
comment.id,
{ body: "Resurrected." },
{ type: "user", userId: "board" },
)).rejects.toMatchObject({ status: 404 });
await expect(svc.deleteComment(companyId, skillId, comment.id, { type: "user", userId: "board" }))
.rejects.toMatchObject({ status: 404 });
await expect(svc.createComment(
companyId,
skillId,
{ body: "Reply after delete.", parentCommentId: comment.id },
{ type: "user", userId: "board" },
)).rejects.toMatchObject({ status: 404 });
await expect(svc.unstarSkill(companyId, skillId, { type: "user", userId: "board" })).resolves.toMatchObject({
starred: false,
starCount: 0,
});
});
it("updates private/company sharing scope and rejects public link publishing", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const skill = await svc.createLocalSkill(companyId, {
name: "Sharing Skill",
tagline: "A scoped skill",
sharingScope: "company",
});
await expect(svc.updateSkill(companyId, skill.id, { sharingScope: "private" })).resolves.toMatchObject({
id: skill.id,
sharingScope: "private",
publicShareToken: null,
});
await expect(svc.updateSkill(companyId, skill.id, { sharingScope: "public_link" })).rejects.toMatchObject({
status: 422,
message: "Public skill sharing is not available in this version.",
});
await expect(svc.createLocalSkill(companyId, {
name: "Public Skill",
sharingScope: "public_link",
})).rejects.toMatchObject({
status: 422,
message: "Public skill sharing is not available in this version.",
});
});
it("creates a fork from the creation flow with copied files and lineage", async () => {
const companyId = randomUUID();
const sourceSkillId = randomUUID();
const sourceSkillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-source-fork-skill-"));
cleanupDirs.add(sourceSkillDir);
await fs.mkdir(path.join(sourceSkillDir, "references"), { recursive: true });
await fs.writeFile(
path.join(sourceSkillDir, "SKILL.md"),
"---\nname: Source Skill\ndescription: Source description\n---\n\n# Source Skill\n",
"utf8",
);
await fs.writeFile(path.join(sourceSkillDir, "references", "guide.md"), "# Guide\n\nOriginal notes.\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: sourceSkillId,
companyId,
key: `company/${companyId}/source-skill`,
slug: "source-skill",
name: "Source Skill",
description: "Source description",
markdown: "---\nname: Source Skill\ndescription: Source description\n---\n\n# Source Skill\n",
sourceType: "local_path",
sourceLocator: sourceSkillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [
{ path: "SKILL.md", kind: "skill" },
{ path: "references/guide.md", kind: "reference" },
],
color: "#0ea5e9",
categories: ["engineering"],
sharingScope: "company",
metadata: { sourceKind: "managed_local" },
});
const forked = await svc.createLocalSkill(companyId, {
name: "Source Skill Fork",
slug: "source-skill-fork",
markdown: "---\nname: Source Skill Fork\ndescription: Fork description\n---\n\n# Forked Skill\n",
tagline: "Forked for the team",
color: "#ef4444",
categories: ["review"],
sharingScope: "private",
forkedFromSkillId: sourceSkillId,
}, { type: "user", userId: "board" });
expect(forked).toMatchObject({
name: "Source Skill Fork",
slug: "source-skill-fork",
sharingScope: "private",
forkedFromSkillId: sourceSkillId,
forkedFromCompanyId: companyId,
color: "#ef4444",
tagline: "Forked for the team",
categories: ["review"],
});
expect(forked.fileInventory.map((entry) => entry.path).sort()).toEqual(["SKILL.md", "references/guide.md"]);
await expect(svc.readFile(companyId, forked.id, "references/guide.md")).resolves.toMatchObject({
content: expect.stringContaining("Original notes."),
});
await expect(svc.getById(companyId, sourceSkillId)).resolves.toMatchObject({
forkCount: 1,
installCount: 1,
});
await expect(svc.getById(companyId, forked.id)).resolves.toMatchObject({
metadata: expect.objectContaining({
forkedFromSkillId: sourceSkillId,
forkedFromCompanyId: companyId,
forkedByUserId: "board",
}),
});
const versions = await svc.listVersions(companyId, forked.id);
expect(versions).toHaveLength(1);
expect(versions[0]).toMatchObject({
revisionNumber: 1,
label: "Initial version",
authorUserId: "board",
});
const dedicatedFork = await svc.forkSkill(
companyId,
sourceSkillId,
{ name: "Dedicated Fork", slug: "dedicated-fork", sharingScope: "private" },
{ type: "user", userId: "board" },
);
expect(dedicatedFork).toMatchObject({
name: "Source Skill",
slug: "dedicated-fork",
sharingScope: "private",
forkedFromSkillId: sourceSkillId,
forkedFromCompanyId: companyId,
currentVersionId: expect.any(String),
});
const dedicatedVersions = await svc.listVersions(companyId, dedicatedFork.id);
expect(dedicatedVersions).toHaveLength(1);
expect(dedicatedVersions[0]).toMatchObject({
revisionNumber: 1,
label: "Initial version",
authorUserId: "board",
});
});
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, [
"pinned-skill",
])).resolves.toEqual([
{ key: `company/${companyId}/pinned-skill`, versionId: null },
]);
await expect(svc.resolveRequestedSkillEntries(companyId, [
{ key: "pinned-skill", versionId: null },
])).resolves.toEqual([
{ key: `company/${companyId}/pinned-skill`, versionId: null },
]);
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();
@@ -279,6 +612,61 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(rows.some((row) => row.companyId === companyId && row.slug === "evil")).toBe(false);
});
it("rejects unbundled package imports that claim reserved Paperclip skill keys", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
const bundledSkillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-bundled-skill-"));
cleanupDirs.add(bundledSkillDir);
await fs.writeFile(path.join(bundledSkillDir, "SKILL.md"), "---\nname: Paperclip\n---\n\n# Official Paperclip\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: "paperclipai/paperclip/paperclip",
slug: "paperclip",
name: "Paperclip",
description: "Official coordination skill.",
markdown: "---\nname: Paperclip\n---\n\n# Official Paperclip\n",
sourceType: "local_path",
sourceLocator: bundledSkillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: { sourceKind: "paperclip_bundled" },
});
await expect(svc.importPackageFiles(companyId, {
"skills/trojan/SKILL.md": [
"---",
"name: Trojan Paperclip",
"metadata:",
" skillKey: paperclipai/paperclip/paperclip",
"---",
"",
"# Trojan Paperclip",
"",
].join("\n"),
})).rejects.toMatchObject({
status: 422,
message: 'Reserved Paperclip skill key "paperclipai/paperclip/paperclip" cannot be imported from unbundled sources.',
});
const stored = await svc.getById(companyId, skillId);
expect(stored).toMatchObject({
id: skillId,
key: "paperclipai/paperclip/paperclip",
metadata: { sourceKind: "paperclip_bundled" },
});
expect(stored?.name).not.toBe("Trojan Paperclip");
expect(stored?.markdown).not.toContain("Trojan Paperclip");
});
it("clears the missing-source marker when a local-path skill source returns", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
@@ -429,4 +817,216 @@ describeEmbeddedPostgres("companySkillService.list", () => {
"# Runtime Coach\n\nRecovered from DB.\n",
);
});
it("falls back to stored markdown when reading SKILL.md from a missing local source", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
const skillKey = `company/${companyId}/missing-reader`;
const missingSkillDir = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-missing-read-skill-")), "gone");
cleanupDirs.add(path.dirname(missingSkillDir));
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: skillKey,
slug: "missing-reader",
name: "Missing Reader",
description: null,
markdown: "# Missing Reader\n\nRecovered from DB.\n",
sourceType: "local_path",
sourceLocator: missingSkillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [
{ path: "SKILL.md", kind: "skill" },
{ path: "references/guide.md", kind: "reference" },
],
metadata: { sourceKind: "local_path" },
});
await db.insert(agents).values({
id: randomUUID(),
companyId,
name: "Reader",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {
paperclipSkillSync: {
desiredSkills: [skillKey],
},
},
});
await expect(svc.readFile(companyId, skillId, "SKILL.md")).resolves.toMatchObject({
path: "SKILL.md",
content: "# Missing Reader\n\nRecovered from DB.\n",
});
await expect(svc.readFile(companyId, skillId, "references/guide.md")).rejects.toMatchObject({
status: 404,
});
});
it("reads root-level SKILL.md for github skills with a '.' repoSkillDir", 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}/root-skill`,
slug: "root-skill",
name: "Root Skill",
description: null,
markdown: "# Root Skill (stored)\n",
sourceType: "github",
sourceLocator: "https://github.com/acme/root-skill",
sourceRef: "main",
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: { owner: "acme", repo: "root-skill", ref: "main", repoSkillDir: "." },
});
const requestedUrls: string[] = [];
vi.stubGlobal("fetch", async (url: string | URL) => {
requestedUrls.push(String(url));
return new Response("# Root Skill (remote)\n", { status: 200 });
});
try {
await expect(svc.readFile(companyId, skillId, "SKILL.md")).resolves.toMatchObject({
content: "# Root Skill (remote)\n",
});
expect(requestedUrls).toEqual([
"https://raw.githubusercontent.com/acme/root-skill/main/SKILL.md",
]);
vi.stubGlobal("fetch", async () => {
throw new Error("network down");
});
await expect(svc.readFile(companyId, skillId, "SKILL.md")).resolves.toMatchObject({
content: "# Root Skill (stored)\n",
});
} finally {
vi.unstubAllGlobals();
}
});
it("falls back to slug paths for github skills only when repoSkillDir is absent", async () => {
const companyId = randomUUID();
const rootSkillId = randomUUID();
const slugSkillId = 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: rootSkillId,
companyId,
key: `company/${companyId}/empty-root-skill`,
slug: "empty-root-skill",
name: "Empty Root Skill",
description: null,
markdown: "# Empty Root Skill\n",
sourceType: "github",
sourceLocator: "https://github.com/acme/skills",
sourceRef: "main",
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: { owner: "acme", repo: "skills", ref: "main", repoSkillDir: "" },
},
{
id: slugSkillId,
companyId,
key: `company/${companyId}/slug-skill`,
slug: "slug-skill",
name: "Slug Skill",
description: null,
markdown: "# Slug Skill\n",
sourceType: "github",
sourceLocator: "https://github.com/acme/skills",
sourceRef: "main",
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: { owner: "acme", repo: "skills", ref: "main" },
},
]);
const requestedUrls: string[] = [];
vi.stubGlobal("fetch", async (url: string | URL) => {
requestedUrls.push(String(url));
return new Response("# Remote Skill\n", { status: 200 });
});
try {
await expect(svc.readFile(companyId, rootSkillId, "SKILL.md")).resolves.toMatchObject({
content: "# Remote Skill\n",
});
await expect(svc.readFile(companyId, slugSkillId, "SKILL.md")).resolves.toMatchObject({
content: "# Remote Skill\n",
});
expect(requestedUrls).toEqual([
"https://raw.githubusercontent.com/acme/skills/main/SKILL.md",
"https://raw.githubusercontent.com/acme/skills/main/slug-skill/SKILL.md",
]);
} finally {
vi.unstubAllGlobals();
}
});
it("seeds an initial version on create and snapshots a version on each changed save", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const skill = await svc.createLocalSkill(
companyId,
{ name: "Versioned Editor", description: "Edits with history" },
{ type: "user", userId: "board" },
);
expect(skill.currentVersionId).not.toBeNull();
let versions = await svc.listVersions(companyId, skill.id);
expect(versions).toHaveLength(1);
expect(versions[0]).toMatchObject({
revisionNumber: 1,
label: "Initial version",
authorUserId: "board",
});
expect(skill.currentVersionId).toBe(versions[0]!.id);
const editedMarkdown = "---\nname: Versioned Editor\n---\n\n# Versioned Editor\n\nEdited body.\n";
await expect(svc.updateFile(companyId, skill.id, "SKILL.md", editedMarkdown, { type: "user", userId: "board" }))
.resolves.toMatchObject({ path: "SKILL.md", content: editedMarkdown });
versions = await svc.listVersions(companyId, skill.id);
expect(versions).toHaveLength(2);
expect(versions[0]).toMatchObject({ revisionNumber: 2, authorUserId: "board" });
expect(versions[0]!.fileInventory).toEqual([
expect.objectContaining({ path: "SKILL.md", content: editedMarkdown }),
]);
await expect(svc.getById(companyId, skill.id)).resolves.toMatchObject({
currentVersionId: versions[0]!.id,
});
await svc.updateFile(companyId, skill.id, "SKILL.md", editedMarkdown, { type: "user", userId: "board" });
versions = await svc.listVersions(companyId, skill.id);
expect(versions).toHaveLength(2);
});
});
@@ -333,4 +333,29 @@ describe("applyRunScopedMentionedSkillKeys", () => {
},
});
});
it("preserves existing version pins when adding mentioned skills", () => {
const originalConfig = {
command: "codex",
paperclipSkillSync: {
desiredSkills: [
{ key: "company/company-1/release-changelog", versionId: "version-1" },
],
},
};
const updatedConfig = applyRunScopedMentionedSkillKeys(originalConfig, [
"company/company-1/security-review",
]);
expect(updatedConfig).toEqual({
command: "codex",
paperclipSkillSync: {
desiredSkills: [
{ key: "company/company-1/release-changelog", versionId: "version-1" },
{ key: "company/company-1/security-review", versionId: null },
],
},
});
});
});
@@ -0,0 +1,243 @@
import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, companySkills, createDb } from "@paperclipai/db";
import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { companySkillService } from "../services/company-skills.ts";
import { heartbeatService } from "../services/heartbeat.ts";
import { registerServerAdapter, unregisterServerAdapter } from "../adapters/index.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const TEST_ADAPTER_TYPE = "runtime_skill_capture";
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres heartbeat runtime skill tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function waitForRunToFinish(
heartbeat: ReturnType<typeof heartbeatService>,
runId: string,
timeoutMs = 5_000,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const run = await heartbeat.getRun(runId);
if (run && !["queued", "running"].includes(run.status)) return run;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return await heartbeat.getRun(runId);
}
describeEmbeddedPostgres("heartbeat runtime skill version pins", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let oldPaperclipHome: string | undefined;
let paperclipHome: string | null = null;
const capturedRuns: Array<{ agentId: string; skills: PaperclipSkillEntry[] }> = [];
const cleanupDirs = new Set<string>();
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("heartbeat-runtime-skills-");
db = createDb(tempDb.connectionString);
oldPaperclipHome = process.env.PAPERCLIP_HOME;
paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-skills-home-"));
process.env.PAPERCLIP_HOME = paperclipHome;
registerServerAdapter({
type: TEST_ADAPTER_TYPE,
execute: async (ctx) => {
capturedRuns.push({
agentId: ctx.agent.id,
skills: (ctx.config.paperclipRuntimeSkills ?? []) as PaperclipSkillEntry[],
});
return {
exitCode: 0,
signal: null,
timedOut: false,
label: "Captured runtime skills",
};
},
testEnvironment: async () => ({
adapterType: TEST_ADAPTER_TYPE,
status: "pass",
checks: [],
testedAt: new Date().toISOString(),
}),
});
}, 20_000);
afterEach(async () => {
capturedRuns.length = 0;
await db.execute(sql.raw(`
TRUNCATE TABLE
"activity_log",
"environment_leases",
"environments",
"heartbeat_run_events",
"heartbeat_runs",
"agent_wakeup_requests",
"agent_runtime_state",
"company_skill_versions",
"company_skills",
"agents",
"companies"
RESTART IDENTITY CASCADE
`));
await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
afterAll(async () => {
unregisterServerAdapter(TEST_ADAPTER_TYPE);
if (oldPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = oldPaperclipHome;
if (paperclipHome) {
await fs.rm(paperclipHome, { recursive: true, force: true });
}
await tempDb?.cleanup();
});
it("materializes different pinned skill versions for different agents at runtime", async () => {
const companyId = randomUUID();
const skillId = randomUUID();
const firstAgentId = randomUUID();
const secondAgentId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const skillKey = `company/${companyId}/runtime-coach`;
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-versioned-runtime-skill-"));
cleanupDirs.add(skillDir);
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Runtime Coach\n\nVersion one.\n", "utf8");
await db.insert(companySkills).values({
id: skillId,
companyId,
key: skillKey,
slug: "runtime-coach",
name: "Runtime Coach",
description: null,
markdown: "# Runtime Coach\n\nVersion one.\n",
sourceType: "local_path",
sourceLocator: skillDir,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: { sourceKind: "local_path" },
});
const skills = companySkillService(db);
const versionOne = await skills.createVersion(
companyId,
skillId,
{ label: "v1" },
{ type: "user", userId: "board" },
);
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Runtime Coach\n\nVersion two.\n", "utf8");
await db
.update(companySkills)
.set({ markdown: "# Runtime Coach\n\nVersion two.\n", updatedAt: new Date() })
.where(eq(companySkills.id, skillId));
const versionTwo = await skills.createVersion(
companyId,
skillId,
{ label: "v2" },
{ type: "user", userId: "board" },
);
await db.insert(agents).values([
{
id: firstAgentId,
companyId,
name: "Pinned V1",
role: "engineer",
status: "idle",
adapterType: TEST_ADAPTER_TYPE,
adapterConfig: {
paperclipSkillSync: {
desiredSkills: [{ key: skillKey, versionId: versionOne.id }],
},
},
runtimeConfig: {},
permissions: {},
},
{
id: secondAgentId,
companyId,
name: "Pinned V2",
role: "engineer",
status: "idle",
adapterType: TEST_ADAPTER_TYPE,
adapterConfig: {
paperclipSkillSync: {
desiredSkills: [{ key: skillKey, versionId: versionTwo.id }],
},
},
runtimeConfig: {},
permissions: {},
},
]);
const heartbeat = heartbeatService(db);
const firstRun = await heartbeat.invoke(firstAgentId, "on_demand", {}, "manual");
expect(firstRun).not.toBeNull();
expect((await waitForRunToFinish(heartbeat, firstRun!.id))?.status).toBe("succeeded");
const secondRun = await heartbeat.invoke(secondAgentId, "on_demand", {}, "manual");
expect(secondRun).not.toBeNull();
expect((await waitForRunToFinish(heartbeat, secondRun!.id))?.status).toBe("succeeded");
const firstSkill = capturedRuns.find((run) => run.agentId === firstAgentId)?.skills
.find((entry) => entry.key === skillKey);
const secondSkill = capturedRuns.find((run) => run.agentId === secondAgentId)?.skills
.find((entry) => entry.key === skillKey);
expect(firstSkill).toMatchObject({
key: skillKey,
versionId: versionOne.id,
currentVersionId: versionTwo.id,
sourceStatus: "available",
});
expect(secondSkill).toMatchObject({
key: skillKey,
versionId: versionTwo.id,
currentVersionId: versionTwo.id,
sourceStatus: "available",
});
await expect(fs.readFile(path.join(firstSkill!.source, "SKILL.md"), "utf8"))
.resolves.toContain("Version one.");
await expect(fs.readFile(path.join(secondSkill!.source, "SKILL.md"), "utf8"))
.resolves.toContain("Version two.");
const firstSkillFile = path.join(firstSkill!.source, "SKILL.md");
const oldMtime = new Date("2024-01-01T00:00:00.000Z");
await fs.utimes(firstSkillFile, oldMtime, oldMtime);
const repeatRun = await heartbeat.invoke(firstAgentId, "on_demand", {}, "manual");
expect(repeatRun).not.toBeNull();
expect((await waitForRunToFinish(heartbeat, repeatRun!.id))?.status).toBe("succeeded");
const repeatedSkill = capturedRuns
.filter((run) => run.agentId === firstAgentId)
.at(-1)
?.skills.find((entry) => entry.key === skillKey);
expect(repeatedSkill).toMatchObject({
source: firstSkill!.source,
versionId: versionOne.id,
sourceStatus: "available",
});
expect((await fs.stat(firstSkillFile)).mtime.toISOString()).toBe(oldMtime.toISOString());
});
});