feat(skills): remove bundled paperclip-dev skill and retire required skill attribute (#7029)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Local adapters (Claude, Codex, Cursor, Gemini, Grok, OpenCode, Pi, ACPX) ship bundled "skills" — opinionated Markdown prompt bundles materialized into the agent's runtime > - One of those bundled skills, `paperclip-dev`, existed to let agents develop Paperclip itself; it has now moved to its own external repo and no longer belongs in the core tree > - The adapter skill model also carried a `required` / `requiredReason` attribute plus a `paperclip_required` `AdapterSkillOrigin` variant, all of which only existed to mark bundled skills as non-optional in the UI and adapter sync logic > - With `paperclip-dev` gone, no bundled skill is "required" anymore, and the type / runtime surface for `required` is dead weight — but it is computed at request time and never persisted, so a clean removal is safe (no compatibility shim needed) > - This pull request deletes `skills/paperclip-dev/` and removes every trace of the `required` / `requiredReason` field and the `paperclip_required` origin across shared types, validators, adapter-utils, all eight local adapters, server routes, the company-skills service, the UI, the storybook fixtures, and the test suite > - The benefit is a smaller, simpler adapter-skill surface: one origin (`company_managed`) for managed bundled skills, `resolvePaperclipDesiredSkillNames` collapses to "just the configured desired set", and the AgentDetail skills tab no longer renders a "Required by Paperclip" section that no longer applies ## Linked Issues or Issue Description <!-- No existing public GitHub issue; describing the underlying work inline (feature_request template fields). --> **Summary** Remove the bundled `paperclip-dev` skill (now maintained in its own external repo) and retire the `required` / `requiredReason` skill attribute and the `paperclip_required` skill origin, which only existed to support it. **Problem or motivation** `paperclip-dev` is the only bundled skill that was ever marked "required". Now that it lives in a separate repository, shipping it inside the core tree is wrong, and the entire `required` surface (a type field, a validator field, a synthesized `paperclip_required` origin, UI "Required by Paperclip" section, and required-skill merging in the desired-skills calculation) becomes dead weight. The `required` value is computed at request time and never persisted, so it can be removed cleanly without a migration or compatibility shim. **Proposed solution** Delete `skills/paperclip-dev/`, drop the `required` / `requiredReason` fields and `paperclip_required` origin everywhere they are produced or consumed, collapse managed-skill origin to a single `company_managed` value, and simplify `resolvePaperclipDesiredSkillNames` to return only the configured desired set. **Alternatives considered** Keeping the `required` attribute as a no-op for forward compatibility — rejected because it is request-time only (nothing persists it), so leaving it in place is pure dead surface area with no callers. **Roadmap alignment** Internal cleanup / dead-code removal that simplifies the adapter-skill surface; it does not introduce or duplicate any planned core feature in ROADMAP.md. ## What Changed - Deleted bundled `skills/paperclip-dev/` (moved to a separate repo). - Dropped `required`, `requiredReason`, and the `paperclip_required` origin from `packages/shared/src/types/adapter-skills.ts`, `packages/shared/src/validators/adapter-skills.ts`, and `packages/adapter-utils/src/types.ts`. - In `packages/adapter-utils/src/server-utils.ts`: removed `readSkillRequired()`; dropped `required`/`requiredReason` from `listPaperclipSkillEntries()`, `normalizeConfiguredPaperclipRuntimeSkills()`, `buildPersistentSkillSnapshot()`, and `PaperclipSkillEntry`; collapsed `buildManagedSkillOrigin()` to always return `company_managed`; simplified `resolvePaperclipDesiredSkillNames()` to return only the configured desired set (signature preserved so adapter call sites are untouched). - Walked all eight local adapters (`acpx-local`, `claude-local`, `codex-local`, `cursor-local`, `gemini-local`, `grok-local`, `opencode-local`, `pi-local`) and removed every remaining `requiredReason` / `paperclip_required` reference. - `server/src/services/company-skills.ts`: dropped the `required = sourceKind === "paperclip_bundled"` synthesis when listing runtime skill entries. - `server/src/routes/agents.ts`: removed required-skill merging from the desired-skills calculation in the persist-config path and the unsupported-snapshot path (keeping the current version-aware `desiredSkillEntries` structure). - `ui/src/pages/AgentDetail.tsx`: dropped required-based filters, the required tooltip, and the entire "Required by Paperclip" section from the agent skills tab; storybook fixtures in `ui/storybook/stories/acpx-local.stories.tsx` cleaned up to match. - Tests: deleted the `required: false` case in `paperclip-skill-utils.test.ts` and the "keeps required bundled skills installed" case in every `*-local-skill-sync.test.ts`; `acpx-local-execute.test.ts`, `cursor-local-execute.test.ts`, `cursor-local-skill-sync.test.ts`, `agent-skills-routes.test.ts`, and `packages/adapter-utils/src/server-utils.test.ts` were updated to drop removed fields and map `origin: "paperclip_required"` → `"company_managed"`. - `server/src/adapters/registry.ts`: two `as unknown as ServerAdapterModule["..."]` casts on `hermesListSkills` / `hermesSyncSkills` (matching the existing `executeHermesLocal` pattern). `hermes-paperclip-adapter@0.2.0` still depends on the published `@paperclipai/adapter-utils` which keeps the retired `paperclip_required` variant; the cast bridges the workspace-vs-published type mismatch at the registry seam and can drop once hermes upgrades. ## Verification Run from the workspace root: ```sh grep -rn "skills/paperclip-dev" . grep -rn "paperclip_required" --include="*.ts" --include="*.tsx" . grep -rn "requiredReason" --include="*.ts" --include="*.tsx" . pnpm -w typecheck pnpm --filter @paperclipai/server exec vitest run paperclip-skill-utils pnpm --filter @paperclipai/server exec vitest run skill-sync ``` The first three greps return only the explanatory comment in `server/src/adapters/registry.ts` (no live `paperclip_required` / `requiredReason` usage) and zero `skills/paperclip-dev` source hits. Locally: - `pnpm -w typecheck` → all packages this PR touches pass (adapter-utils, shared, server, ui, cli, and the cursor/gemini/opencode/pi adapters). - Affected vitest suites pass: `paperclip-skill-utils`, `server-utils`, all eight `*-local-skill-sync`, `agent-skills-routes`, and the `acpx`/`cursor`/`pi` execute suites. ## Risks - Behavioral shift in the agent skills UI: the "Required by Paperclip" section disappears. No bundled skill is required anymore, so this only affects environments that previously surfaced `paperclip-dev` as a forced-on row; those installs will see the skill move into the regular "company-managed" list (and be uninstalled on next sync unless explicitly listed as desired). - Existing agents may still have the string `"paperclip-dev"` in their persisted `desiredSkills`. That entry is inert (no source for it to install from); a one-time DB cleanup is out of scope. Low risk. - Hermes adapter type bridge: two casts in `registry.ts` paper over a type-only divergence between the workspace `@paperclipai/adapter-utils` and the published version still pinned by `hermes-paperclip-adapter@0.2.0`. Runtime behavior is unaffected because the retired `paperclip_required` value is no longer produced by anything in this tree. The casts can be removed once hermes upgrades its dependency. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (`claude-opus-4-7`) - Capability: agent tool use via Paperclip's `claude_local` adapter ## 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 not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] 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>
This commit is contained in:
@@ -128,12 +128,11 @@ async function createRuntimeSkill(root: string, input: {
|
||||
const key = input.key ?? `company/${runtimeName}`;
|
||||
const source = path.join(root, "skills", runtimeName);
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.writeFile(path.join(source, "SKILL.md"), input.body ?? "---\nrequired: false\n---\nUse the test skill.\n", "utf8");
|
||||
await fs.writeFile(path.join(source, "SKILL.md"), input.body ?? "---\n---\nUse the test skill.\n", "utf8");
|
||||
return {
|
||||
key,
|
||||
runtimeName,
|
||||
source,
|
||||
required: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -634,7 +633,7 @@ describe("acpx_local execute", () => {
|
||||
it("includes skill content in the ACPX Claude session fingerprint", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-claude-fingerprint-"));
|
||||
try {
|
||||
const skill = await createRuntimeSkill(root, { body: "---\nrequired: false\n---\nFirst version.\n" });
|
||||
const skill = await createRuntimeSkill(root, { body: "---\n---\nFirst version.\n" });
|
||||
const runtimes: FakeRuntime[] = [];
|
||||
const execute = createAcpxLocalExecutor({
|
||||
createRuntime: (options) => {
|
||||
@@ -657,7 +656,7 @@ describe("acpx_local execute", () => {
|
||||
});
|
||||
|
||||
const first = await execute(context);
|
||||
await fs.writeFile(path.join(skill.source, "SKILL.md"), "---\nrequired: false\n---\nSecond version.\n", "utf8");
|
||||
await fs.writeFile(path.join(skill.source, "SKILL.md"), "---\n---\nSecond version.\n", "utf8");
|
||||
const second = await execute({
|
||||
...context,
|
||||
runtime: {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
|
||||
describe("acpx local skill sync", () => {
|
||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||
const createAgentKey = "paperclipai/paperclip/paperclip-create-agent";
|
||||
|
||||
it("reports ACPX Claude skills as supported runtime-mounted state", async () => {
|
||||
const snapshot = await listAcpxSkills({
|
||||
@@ -25,7 +24,6 @@ describe("acpx local skill sync", () => {
|
||||
expect(snapshot.supported).toBe(true);
|
||||
expect(snapshot.mode).toBe("ephemeral");
|
||||
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
||||
expect(snapshot.desiredSkills).toContain(createAgentKey);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("ACPX Claude session");
|
||||
expect(snapshot.warnings).toEqual([]);
|
||||
|
||||
@@ -264,8 +264,6 @@ describe.sequential("agent skill routes", () => {
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source: "/tmp/paperclip",
|
||||
required: true,
|
||||
requiredReason: "required",
|
||||
},
|
||||
]);
|
||||
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(
|
||||
|
||||
@@ -1046,6 +1046,9 @@ describe("claude execute", () => {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
@@ -1083,6 +1086,9 @@ describe("claude execute", () => {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath2,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("claude local skill sync", () => {
|
||||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("defaults to mounting all built-in Paperclip skills when no explicit selection exists", async () => {
|
||||
it("reports built-in Paperclip skills as available when no explicit selection exists", async () => {
|
||||
const snapshot = await listClaudeSkills({
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
@@ -38,9 +38,8 @@ describe("claude local skill sync", () => {
|
||||
|
||||
expect(snapshot.mode).toBe("ephemeral");
|
||||
expect(snapshot.supported).toBe(true);
|
||||
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
expect(snapshot.desiredSkills).toEqual([]);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("available");
|
||||
});
|
||||
|
||||
it("respects an explicit desired skill list without mutating a persistent home", async () => {
|
||||
@@ -57,7 +56,7 @@ describe("claude local skill sync", () => {
|
||||
|
||||
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("configured");
|
||||
expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("available");
|
||||
});
|
||||
|
||||
it("normalizes legacy flat Paperclip skill refs to canonical keys", async () => {
|
||||
|
||||
@@ -1114,6 +1114,9 @@ describe("codex execute", () => {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
@@ -1222,6 +1225,9 @@ describe("codex execute", () => {
|
||||
CODEX_HOME: explicitCodexHome,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
|
||||
@@ -13,7 +13,6 @@ async function makeTempDir(prefix: string): Promise<string> {
|
||||
|
||||
describe("codex local skill sync", () => {
|
||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||
const createAgentKey = "paperclipai/paperclip/paperclip-create-agent";
|
||||
const cleanupDirs = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -42,11 +41,7 @@ describe("codex local skill sync", () => {
|
||||
const before = await listCodexSkills(ctx);
|
||||
expect(before.mode).toBe("ephemeral");
|
||||
expect(before.desiredSkills).toContain(paperclipKey);
|
||||
expect(before.desiredSkills).toContain(createAgentKey);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
expect(before.entries.find((entry) => entry.key === createAgentKey)?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("configured");
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("CODEX_HOME/skills/");
|
||||
});
|
||||
|
||||
@@ -76,31 +71,6 @@ describe("codex local skill sync", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps required bundled Paperclip skills configured even when the desired set is emptied", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-skill-required-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
config: {
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncCodexSkills(configuredCtx, []);
|
||||
expect(after.desiredSkills).toContain(paperclipKey);
|
||||
expect(after.desiredSkills).toContain(createAgentKey);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
expect(after.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("configured");
|
||||
});
|
||||
|
||||
it("normalizes legacy flat Paperclip skill refs before reporting configured state", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-legacy-skill-sync-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
||||
@@ -288,8 +288,6 @@ describe("cursor execute", () => {
|
||||
{
|
||||
name: "paperclip",
|
||||
source: paperclipDir,
|
||||
required: true,
|
||||
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
||||
},
|
||||
{
|
||||
name: "ascii-heart",
|
||||
|
||||
@@ -48,7 +48,6 @@ describe("cursor local skill sync", () => {
|
||||
const before = await listCursorSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toContain(paperclipKey);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||
|
||||
const after = await syncCursorSkills(ctx, [paperclipKey]);
|
||||
@@ -78,8 +77,6 @@ describe("cursor local skill sync", () => {
|
||||
key: "paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source: paperclipDir,
|
||||
required: true,
|
||||
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
||||
},
|
||||
{
|
||||
key: "ascii-heart",
|
||||
@@ -95,7 +92,7 @@ describe("cursor local skill sync", () => {
|
||||
|
||||
const before = await listCursorSkills(ctx);
|
||||
expect(before.warnings).toEqual([]);
|
||||
expect(before.desiredSkills).toEqual(["paperclip", "ascii-heart"]);
|
||||
expect(before.desiredSkills).toEqual(["ascii-heart"]);
|
||||
expect(before.entries.find((entry) => entry.key === "ascii-heart")?.state).toBe("missing");
|
||||
|
||||
const after = await syncCursorSkills(ctx, ["ascii-heart"]);
|
||||
@@ -104,41 +101,4 @@ describe("cursor local skill sync", () => {
|
||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-cursor-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncCursorSkills(configuredCtx, [paperclipKey]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncCursorSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toContain(paperclipKey);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,49 +41,10 @@ describe("gemini local skill sync", () => {
|
||||
const before = await listGeminiSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toContain(paperclipKey);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||
|
||||
const after = await syncGeminiSkills(ctx, [paperclipKey]);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-gemini-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "gemini_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncGeminiSkills(configuredCtx, [paperclipKey]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncGeminiSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toContain(paperclipKey);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
|
||||
describe("grok local skill sync", () => {
|
||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||
const createAgentKey = "paperclipai/paperclip/paperclip-create-agent";
|
||||
|
||||
it("reports Grok skills as ephemeral workspace-mounted state", async () => {
|
||||
const snapshot = await listGrokSkills({
|
||||
@@ -24,9 +23,7 @@ describe("grok local skill sync", () => {
|
||||
expect(snapshot.supported).toBe(true);
|
||||
expect(snapshot.mode).toBe("ephemeral");
|
||||
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
||||
expect(snapshot.desiredSkills).toContain(createAgentKey);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)).toMatchObject({
|
||||
required: true,
|
||||
state: "configured",
|
||||
detail: "Will be copied into `.claude/skills` in the execution workspace on the next run.",
|
||||
});
|
||||
|
||||
@@ -42,49 +42,10 @@ describe("opencode local skill sync", () => {
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills).");
|
||||
expect(before.desiredSkills).toContain(paperclipKey);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||
|
||||
const after = await syncOpenCodeSkills(ctx, [paperclipKey]);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-opencode-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "opencode_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncOpenCodeSkills(configuredCtx, [paperclipKey]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncOpenCodeSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toContain(paperclipKey);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,34 +79,6 @@ describe("paperclip skill utils", () => {
|
||||
await expect(fs.access(path.resolve("skills/create-issue-interaction-ui/SKILL.md"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("marks skills with required: false in SKILL.md frontmatter as optional", async () => {
|
||||
const root = await makeTempDir("paperclip-skill-optional-");
|
||||
cleanupDirs.add(root);
|
||||
|
||||
const moduleDir = path.join(root, "a", "b", "c", "d", "e");
|
||||
await fs.mkdir(moduleDir, { recursive: true });
|
||||
|
||||
// Required skill (no frontmatter flag)
|
||||
const requiredDir = path.join(root, "skills", "paperclip");
|
||||
await fs.mkdir(requiredDir, { recursive: true });
|
||||
await fs.writeFile(path.join(requiredDir, "SKILL.md"), "---\nname: paperclip\n---\n\n# Paperclip\n");
|
||||
|
||||
// Optional skill (required: false)
|
||||
const optionalDir = path.join(root, "skills", "paperclip-dev");
|
||||
await fs.mkdir(optionalDir, { recursive: true });
|
||||
await fs.writeFile(path.join(optionalDir, "SKILL.md"), "---\nname: paperclip-dev\nrequired: false\n---\n\n# Dev\n");
|
||||
|
||||
const entries = await listPaperclipSkillEntries(moduleDir);
|
||||
entries.sort((a, b) => a.runtimeName.localeCompare(b.runtimeName));
|
||||
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]?.runtimeName).toBe("paperclip");
|
||||
expect(entries[0]?.required).toBe(true);
|
||||
expect(entries[1]?.runtimeName).toBe("paperclip-dev");
|
||||
expect(entries[1]?.required).toBe(false);
|
||||
expect(entries[1]?.requiredReason).toBeNull();
|
||||
});
|
||||
|
||||
it("removes stale maintainer-only symlinks from a shared skills home", async () => {
|
||||
const root = await makeTempDir("paperclip-skill-cleanup-");
|
||||
cleanupDirs.add(root);
|
||||
|
||||
@@ -130,8 +130,11 @@ describe("pi_local execute", () => {
|
||||
model: "google/gemini-3-flash-preview",
|
||||
promptTemplate: "Keep working.",
|
||||
paperclipRuntimeSkills: [
|
||||
{ key: "demo-skill", runtimeName: "demo-skill", source: skillDir, required: true },
|
||||
{ key: "demo-skill", runtimeName: "demo-skill", source: skillDir },
|
||||
],
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["demo-skill"],
|
||||
},
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
@@ -185,10 +188,10 @@ describe("pi_local execute", () => {
|
||||
cwd: workspace,
|
||||
model: "google/gemini-3-flash-preview",
|
||||
promptTemplate: "Keep working.",
|
||||
// required:false with no explicit paperclipSkillSync preference →
|
||||
// No explicit paperclipSkillSync preference →
|
||||
// resolvePaperclipDesiredSkillNames returns [] → skill is not injected.
|
||||
paperclipRuntimeSkills: [
|
||||
{ key: "not-injected", runtimeName: "not-injected", source: nonInjectedSkillDir, required: false },
|
||||
{ key: "not-injected", runtimeName: "not-injected", source: nonInjectedSkillDir },
|
||||
],
|
||||
},
|
||||
context: {},
|
||||
|
||||
@@ -41,49 +41,10 @@ describe("pi local skill sync", () => {
|
||||
const before = await listPiSkills(ctx);
|
||||
expect(before.mode).toBe("persistent");
|
||||
expect(before.desiredSkills).toContain(paperclipKey);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
||||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||
|
||||
const after = await syncPiSkills(ctx, [paperclipKey]);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
|
||||
const home = await makeTempDir("paperclip-pi-skill-prune-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const configuredCtx = {
|
||||
agentId: "agent-2",
|
||||
companyId: "company-1",
|
||||
adapterType: "pi_local",
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
await syncPiSkills(configuredCtx, [paperclipKey]);
|
||||
|
||||
const clearedCtx = {
|
||||
...configuredCtx,
|
||||
config: {
|
||||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const after = await syncPiSkills(clearedCtx, []);
|
||||
expect(after.desiredSkills).toContain(paperclipKey);
|
||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".pi", "agent", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -456,6 +456,10 @@ const piLocalAdapter: ServerAdapterModule = {
|
||||
// hermes-paperclip-adapter v0.2.0 predates the authToken field; cast is
|
||||
// intentional until hermes ships a matching AdapterExecutionContext type.
|
||||
const executeHermesLocal = hermesExecute as unknown as ServerAdapterModule["execute"];
|
||||
// hermes-paperclip-adapter v0.2.0 still depends on the published @paperclipai/adapter-utils
|
||||
// that ships the "paperclip_required" origin; casts bridge until hermes upgrades.
|
||||
const listHermesSkills = hermesListSkills as unknown as ServerAdapterModule["listSkills"];
|
||||
const syncHermesSkills = hermesSyncSkills as unknown as ServerAdapterModule["syncSkills"];
|
||||
|
||||
const hermesLocalAdapter: ServerAdapterModule = {
|
||||
type: "hermes_local",
|
||||
@@ -510,8 +514,8 @@ const hermesLocalAdapter: ServerAdapterModule = {
|
||||
},
|
||||
testEnvironment: (ctx) => hermesTestEnvironment(normalizeHermesConfig(ctx) as never),
|
||||
sessionCodec: hermesSessionCodec,
|
||||
listSkills: hermesListSkills,
|
||||
syncSkills: hermesSyncSkills,
|
||||
listSkills: listHermesSkills,
|
||||
syncSkills: syncHermesSkills,
|
||||
models: hermesModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
supportsInstructionsBundle: false,
|
||||
|
||||
@@ -1480,18 +1480,13 @@ export function agentRoutes(
|
||||
companyId,
|
||||
requestedDesiredSkills,
|
||||
);
|
||||
const resolvedRequestedSkills = resolvedRequestedSkillEntries.map((entry) => entry.key);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
|
||||
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType),
|
||||
versionSelections: skillVersionSelectionMap(resolvedRequestedSkillEntries),
|
||||
});
|
||||
const requiredSkills = runtimeSkillEntries
|
||||
.filter((entry) => entry.required)
|
||||
.map((entry) => entry.key);
|
||||
const desiredSkillEntries = [
|
||||
...requiredSkills.map((key) => ({ key, versionId: null })),
|
||||
...resolvedRequestedSkillEntries,
|
||||
].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index);
|
||||
const desiredSkillEntries = resolvedRequestedSkillEntries.filter(
|
||||
(entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index,
|
||||
);
|
||||
const desiredSkills = desiredSkillEntries.map((entry) => entry.key);
|
||||
|
||||
return {
|
||||
@@ -1712,15 +1707,9 @@ export function agentRoutes(
|
||||
const preference = readPaperclipSkillSyncPreference(
|
||||
agent.adapterConfig as Record<string, unknown>,
|
||||
);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, {
|
||||
materializeMissing: false,
|
||||
versionSelections: skillVersionSelectionMap(preference.desiredSkillEntries),
|
||||
});
|
||||
const requiredSkills = runtimeSkillEntries.filter((entry) => entry.required).map((entry) => entry.key);
|
||||
const desiredSkillEntries = [
|
||||
...requiredSkills.map((key) => ({ key, versionId: null })),
|
||||
...preference.desiredSkillEntries,
|
||||
].filter((entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index);
|
||||
const desiredSkillEntries = preference.desiredSkillEntries.filter(
|
||||
(entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index,
|
||||
);
|
||||
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, desiredSkillEntries));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4117,11 +4117,9 @@ export function companySkillService(db: Db) {
|
||||
|
||||
const out: PaperclipSkillEntry[] = [];
|
||||
for (const skill of skills) {
|
||||
const sourceKind = asString(getSkillMeta(skill).sourceKind);
|
||||
const sourceResolution = await resolveRuntimeSkillSource(companyId, skill, options);
|
||||
if (!sourceResolution) continue;
|
||||
|
||||
const required = sourceKind === "paperclip_bundled";
|
||||
out.push({
|
||||
key: skill.key,
|
||||
runtimeName: buildSkillRuntimeName(skill.key, skill.slug),
|
||||
@@ -4130,10 +4128,6 @@ export function companySkillService(db: Db) {
|
||||
currentVersionId: skill.currentVersionId,
|
||||
sourceStatus: sourceResolution.status,
|
||||
missingDetail: sourceResolution.status === "missing" ? sourceResolution.detail : null,
|
||||
required,
|
||||
requiredReason: required
|
||||
? "Bundled Paperclip skills are always available for local adapters."
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user