mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents working on the Paperclip codebase itself need guidance on dev workflows: server lifecycle, worktrees, builds, database ops, diagnostics > - There was no bundled skill covering these workflows — agents had to figure it out from scratch each time > - Additionally, not every skill should be force-installed on every agent — a dev-focused skill should be opt-in > - This PR adds a `paperclip-dev` skill with `required: false` frontmatter so it ships with Paperclip but isn't auto-installed > - The skill's PR section references canonical files (`.github/PULL_REQUEST_TEMPLATE.md`, `CONTRIBUTING.md`) instead of duplicating their content, with gated instructions that force agents to read those files before creating any PR > - The benefit is that developers (human or agent) can opt in to structured dev guidance without polluting the default agent skill set or creating drift between duplicated docs ## What Changed - Added `skills/paperclip-dev/SKILL.md` covering server management, worktree lifecycle, builds, database ops, diagnostics, agent operations, and common mistakes - The Pull Requests section uses gated, reference-based instructions — agents MUST read `.github/PULL_REQUEST_TEMPLATE.md` and `CONTRIBUTING.md` before running `gh pr create`, with a brief checklist of required section names (no content duplication) - Updated `packages/adapter-utils/src/server-utils.ts` to respect `required: false` frontmatter — optional skills are bundled but not auto-installed on agents - Added test in `server/src/__tests__/paperclip-skill-utils.test.ts` verifying that optional skills are excluded from the default install set ## Verification ```bash # Run tests pnpm test # Manual verification: create a fresh worktree without seeding npx paperclipai worktree:make test-optional-skill --no-seed cd ~/paperclip-test-optional-skill eval "$(npx paperclipai worktree env)" npx paperclipai run # Verify paperclip-dev appears in company skill library but is NOT auto-assigned # Call listPaperclipSkillEntries() — paperclip-dev should show required: false # Call resolvePaperclipDesiredSkillNames() — paperclip-dev should NOT be in the default set # Cleanup npx paperclipai worktree:cleanup test-optional-skill ``` ## Risks - Low risk. The `required` field defaults to `true` when absent, so all existing skills behave identically. Only the new `paperclip-dev` skill sets `required: false`. ## Model Used Claude Opus 4.6 (`claude-opus-4-6`) via Claude Code, with tool use and extended context. ## 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 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 - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
98 lines
4.2 KiB
TypeScript
98 lines
4.2 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
listPaperclipSkillEntries,
|
|
removeMaintainerOnlySkillSymlinks,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
|
|
async function makeTempDir(prefix: string): Promise<string> {
|
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
}
|
|
|
|
describe("paperclip skill utils", () => {
|
|
const cleanupDirs = new Set<string>();
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
cleanupDirs.clear();
|
|
});
|
|
|
|
it("lists bundled runtime skills from ./skills without pulling in .agents/skills", async () => {
|
|
const root = await makeTempDir("paperclip-skill-roots-");
|
|
cleanupDirs.add(root);
|
|
|
|
const moduleDir = path.join(root, "a", "b", "c", "d", "e");
|
|
await fs.mkdir(moduleDir, { recursive: true });
|
|
await fs.mkdir(path.join(root, "skills", "paperclip"), { recursive: true });
|
|
await fs.mkdir(path.join(root, "skills", "paperclip-create-agent"), { recursive: true });
|
|
await fs.mkdir(path.join(root, ".agents", "skills", "release"), { recursive: true });
|
|
|
|
const entries = await listPaperclipSkillEntries(moduleDir);
|
|
|
|
expect(entries.map((entry) => entry.key)).toEqual([
|
|
"paperclipai/paperclip/paperclip",
|
|
"paperclipai/paperclip/paperclip-create-agent",
|
|
]);
|
|
expect(entries.map((entry) => entry.runtimeName)).toEqual([
|
|
"paperclip",
|
|
"paperclip-create-agent",
|
|
]);
|
|
expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip"));
|
|
expect(entries[1]?.source).toBe(path.join(root, "skills", "paperclip-create-agent"));
|
|
});
|
|
|
|
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);
|
|
|
|
const skillsHome = path.join(root, "skills-home");
|
|
const runtimeSkill = path.join(root, "skills", "paperclip");
|
|
const customSkill = path.join(root, "custom", "release-notes");
|
|
const staleMaintainerSkill = path.join(root, ".agents", "skills", "release");
|
|
|
|
await fs.mkdir(skillsHome, { recursive: true });
|
|
await fs.mkdir(runtimeSkill, { recursive: true });
|
|
await fs.mkdir(customSkill, { recursive: true });
|
|
|
|
await fs.symlink(runtimeSkill, path.join(skillsHome, "paperclip"));
|
|
await fs.symlink(customSkill, path.join(skillsHome, "release-notes"));
|
|
await fs.symlink(staleMaintainerSkill, path.join(skillsHome, "release"));
|
|
|
|
const removed = await removeMaintainerOnlySkillSymlinks(skillsHome, ["paperclip"]);
|
|
|
|
expect(removed).toEqual(["release"]);
|
|
await expect(fs.lstat(path.join(skillsHome, "release"))).rejects.toThrow();
|
|
expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true);
|
|
expect((await fs.lstat(path.join(skillsHome, "release-notes"))).isSymbolicLink()).toBe(true);
|
|
});
|
|
});
|