From e1b24c1d5c6f92c9600d56954f322b72b147faf5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 08:35:59 -0600 Subject: [PATCH] feat(cursor): export skill injection helper and document auto-behaviors Export ensureCursorSkillsInjected from the server entrypoint and add a test for skill directory injection. Document the auto-inject and auto-trust behaviors in the adapter notes. Co-Authored-By: Claude Opus 4.6 --- packages/adapters/cursor-local/src/index.ts | 2 + .../adapters/cursor-local/src/server/index.ts | 2 +- .../cursor-local-skill-injection.test.ts | 103 ++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 server/src/__tests__/cursor-local-skill-injection.test.ts diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index 88142e5e..5c72a958 100644 --- a/packages/adapters/cursor-local/src/index.ts +++ b/packages/adapters/cursor-local/src/index.ts @@ -78,4 +78,6 @@ Notes: - Runs are executed with: agent -p --output-format stream-json ... - Prompts are passed as a final positional argument. - Sessions are resumed with --resume when stored session cwd matches current cwd. +- Paperclip auto-injects local skills into "~/.cursor/skills" when missing, so Cursor can discover "$paperclip" and related skills on local runs. +- Paperclip auto-adds --trust unless one of --trust/--yolo/-f is already present in extraArgs. `; diff --git a/packages/adapters/cursor-local/src/server/index.ts b/packages/adapters/cursor-local/src/server/index.ts index 18c0f95a..4e585b17 100644 --- a/packages/adapters/cursor-local/src/server/index.ts +++ b/packages/adapters/cursor-local/src/server/index.ts @@ -1,4 +1,4 @@ -export { execute } from "./execute.js"; +export { execute, ensureCursorSkillsInjected } from "./execute.js"; export { testEnvironment } from "./test.js"; export { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; diff --git a/server/src/__tests__/cursor-local-skill-injection.test.ts b/server/src/__tests__/cursor-local-skill-injection.test.ts new file mode 100644 index 00000000..136f02f2 --- /dev/null +++ b/server/src/__tests__/cursor-local-skill-injection.test.ts @@ -0,0 +1,103 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ensureCursorSkillsInjected } from "@paperclipai/adapter-cursor-local/server"; + +async function makeTempDir(prefix: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +async function createSkillDir(root: string, name: string) { + await fs.mkdir(path.join(root, name), { recursive: true }); +} + +describe("cursor local adapter skill injection", () => { + const cleanupDirs = new Set(); + + afterEach(async () => { + await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + it("links missing Paperclip skills into Cursor skills home", async () => { + const skillsDir = await makeTempDir("paperclip-cursor-skills-src-"); + const skillsHome = await makeTempDir("paperclip-cursor-skills-home-"); + cleanupDirs.add(skillsDir); + cleanupDirs.add(skillsHome); + + await createSkillDir(skillsDir, "paperclip"); + await createSkillDir(skillsDir, "paperclip-create-agent"); + await fs.writeFile(path.join(skillsDir, "README.txt"), "ignore", "utf8"); + + const logs: string[] = []; + await ensureCursorSkillsInjected( + async (_stream, chunk) => { + logs.push(chunk); + }, + { skillsDir, skillsHome }, + ); + + const injectedA = path.join(skillsHome, "paperclip"); + const injectedB = path.join(skillsHome, "paperclip-create-agent"); + expect((await fs.lstat(injectedA)).isSymbolicLink()).toBe(true); + expect((await fs.lstat(injectedB)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(injectedA)).toBe(await fs.realpath(path.join(skillsDir, "paperclip"))); + expect(await fs.realpath(injectedB)).toBe( + await fs.realpath(path.join(skillsDir, "paperclip-create-agent")), + ); + expect(logs.some((line) => line.includes('Injected Cursor skill "paperclip"'))).toBe(true); + expect(logs.some((line) => line.includes('Injected Cursor skill "paperclip-create-agent"'))).toBe(true); + }); + + it("preserves existing targets and only links missing skills", async () => { + const skillsDir = await makeTempDir("paperclip-cursor-preserve-src-"); + const skillsHome = await makeTempDir("paperclip-cursor-preserve-home-"); + cleanupDirs.add(skillsDir); + cleanupDirs.add(skillsHome); + + await createSkillDir(skillsDir, "paperclip"); + await createSkillDir(skillsDir, "paperclip-create-agent"); + + const existingTarget = path.join(skillsHome, "paperclip"); + await fs.mkdir(existingTarget, { recursive: true }); + await fs.writeFile(path.join(existingTarget, "keep.txt"), "keep", "utf8"); + + await ensureCursorSkillsInjected(async () => {}, { skillsDir, skillsHome }); + + expect((await fs.lstat(existingTarget)).isDirectory()).toBe(true); + expect(await fs.readFile(path.join(existingTarget, "keep.txt"), "utf8")).toBe("keep"); + expect((await fs.lstat(path.join(skillsHome, "paperclip-create-agent"))).isSymbolicLink()).toBe(true); + }); + + it("logs per-skill link failures and continues without throwing", async () => { + const skillsDir = await makeTempDir("paperclip-cursor-fail-src-"); + const skillsHome = await makeTempDir("paperclip-cursor-fail-home-"); + cleanupDirs.add(skillsDir); + cleanupDirs.add(skillsHome); + + await createSkillDir(skillsDir, "ok-skill"); + await createSkillDir(skillsDir, "fail-skill"); + + const logs: string[] = []; + await ensureCursorSkillsInjected( + async (_stream, chunk) => { + logs.push(chunk); + }, + { + skillsDir, + skillsHome, + linkSkill: async (source, target) => { + if (target.endsWith(`${path.sep}fail-skill`)) { + throw new Error("simulated link failure"); + } + await fs.symlink(source, target); + }, + }, + ); + + expect((await fs.lstat(path.join(skillsHome, "ok-skill"))).isSymbolicLink()).toBe(true); + await expect(fs.lstat(path.join(skillsHome, "fail-skill"))).rejects.toThrow(); + expect(logs.some((line) => line.includes('Failed to inject Cursor skill "fail-skill"'))).toBe(true); + }); +});