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:
@@ -502,7 +502,7 @@ function registerAgentSkillCommands(skills: Command): void {
|
||||
addCommonClientOptions(
|
||||
agent
|
||||
.command("sync")
|
||||
.description("Replace an agent's non-required desired company skills and sync runtime state")
|
||||
.description("Replace an agent's desired company skills and sync runtime state")
|
||||
.argument("<agentRef>", "Agent ID or shortname/url-key")
|
||||
.option("--skill <skillRef>", "Desired company skill ID, key, or slug; may be repeated", collectOptionValue, [] as string[])
|
||||
.action(async (agentRef: string, opts: AgentSkillSyncOptions) => {
|
||||
@@ -535,7 +535,7 @@ function registerAgentSkillCommands(skills: Command): void {
|
||||
addCommonClientOptions(
|
||||
agent
|
||||
.command("clear")
|
||||
.description("Clear an agent's non-required desired company skills and sync runtime state")
|
||||
.description("Clear an agent's desired company skills and sync runtime state")
|
||||
.argument("<agentRef>", "Agent ID or shortname/url-key")
|
||||
.option("--yes", "Confirm clear without prompting", false)
|
||||
.action(async (agentRef: string, opts: ConfirmedSkillOptions) => {
|
||||
@@ -544,7 +544,7 @@ function registerAgentSkillCommands(skills: Command): void {
|
||||
const agentRow = await resolveAgent(ctx, agentRef);
|
||||
await confirmDangerousAction(
|
||||
opts.yes,
|
||||
`Clear non-required desired company skills for "${agentRow.name}" (${agentRow.id})?`,
|
||||
`Clear desired company skills for "${agentRow.name}" (${agentRow.id})?`,
|
||||
);
|
||||
const snapshot = await ctx.api.post<AgentSkillSnapshot>(
|
||||
`/api/agents/${encodeURIComponent(agentRow.id)}/skills/sync`,
|
||||
@@ -555,7 +555,7 @@ function registerAgentSkillCommands(skills: Command): void {
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Desired company skills cleared for ${agentRow.name} (${agentRow.id}); required Paperclip skills remain server-enforced.`,
|
||||
`Desired company skills cleared for ${agentRow.name} (${agentRow.id}).`,
|
||||
);
|
||||
printAgentSkillSnapshot(snapshot, agentRow);
|
||||
} catch (err) {
|
||||
@@ -911,7 +911,6 @@ function printAgentSkillSnapshot(snapshot: AgentSkillSnapshot | null, agent: Age
|
||||
runtimeName: entry.runtimeName,
|
||||
desired: entry.desired,
|
||||
managed: entry.managed,
|
||||
required: entry.required ?? false,
|
||||
state: entry.state,
|
||||
origin: entry.origin,
|
||||
detail: entry.detail,
|
||||
|
||||
@@ -213,8 +213,6 @@ describe("adapter skill snapshots", () => {
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source: "/runtime/paperclip",
|
||||
required: true,
|
||||
requiredReason: "Required for Paperclip heartbeats.",
|
||||
};
|
||||
const optionalEntry = {
|
||||
key: "company/ascii-heart",
|
||||
@@ -245,8 +243,7 @@ describe("adapter skill snapshots", () => {
|
||||
expect.objectContaining({
|
||||
key: requiredEntry.key,
|
||||
state: "configured",
|
||||
origin: "paperclip_required",
|
||||
required: true,
|
||||
origin: "company_managed",
|
||||
detail: "Mounted on next run.",
|
||||
}),
|
||||
]);
|
||||
@@ -345,7 +342,7 @@ describe("adapter skill snapshots", () => {
|
||||
key: requiredEntry.key,
|
||||
state: "installed",
|
||||
managed: true,
|
||||
origin: "paperclip_required",
|
||||
origin: "company_managed",
|
||||
}));
|
||||
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
||||
key: optionalEntry.key,
|
||||
|
||||
@@ -195,8 +195,6 @@ export interface PaperclipSkillEntry {
|
||||
currentVersionId?: string | null;
|
||||
sourceStatus?: "available" | "missing";
|
||||
missingDetail?: string | null;
|
||||
required?: boolean;
|
||||
requiredReason?: string | null;
|
||||
}
|
||||
|
||||
export interface PaperclipDesiredSkillEntry {
|
||||
@@ -258,17 +256,10 @@ function skillLocationLabel(value: string | null | undefined): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function buildManagedSkillOrigin(entry: { required?: boolean }): Pick<
|
||||
function buildManagedSkillOrigin(): Pick<
|
||||
AdapterSkillEntry,
|
||||
"origin" | "originLabel" | "readOnly"
|
||||
> {
|
||||
if (entry.required) {
|
||||
return {
|
||||
origin: "paperclip_required",
|
||||
originLabel: "Required by Paperclip",
|
||||
readOnly: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
@@ -1594,20 +1585,6 @@ export async function resolvePaperclipSkillsDir(
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readSkillRequired(skillDir: string): Promise<boolean> {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(skillDir, "SKILL.md"), "utf8");
|
||||
const normalized = content.replace(/\r\n/g, "\n");
|
||||
if (!normalized.startsWith("---\n")) return true;
|
||||
const closing = normalized.indexOf("\n---\n", 4);
|
||||
if (closing < 0) return true;
|
||||
const frontmatter = normalized.slice(4, closing);
|
||||
return !/^\s*required\s*:\s*false\s*$/m.test(frontmatter);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPaperclipSkillEntries(
|
||||
moduleDir: string,
|
||||
additionalCandidates: string[] = [],
|
||||
@@ -1618,18 +1595,10 @@ export async function listPaperclipSkillEntries(
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
const dirs = entries.filter((entry) => entry.isDirectory());
|
||||
return Promise.all(dirs.map(async (entry) => {
|
||||
const skillDir = path.join(root, entry.name);
|
||||
const required = await readSkillRequired(skillDir);
|
||||
return {
|
||||
return dirs.map((entry) => ({
|
||||
key: `paperclipai/paperclip/${entry.name}`,
|
||||
runtimeName: entry.name,
|
||||
source: skillDir,
|
||||
required,
|
||||
requiredReason: required
|
||||
? "Bundled Paperclip skills are always available for local adapters."
|
||||
: null,
|
||||
};
|
||||
source: path.join(root, entry.name),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
@@ -1682,9 +1651,7 @@ export function buildRuntimeMountedSkillSnapshot(
|
||||
sourcePath: null,
|
||||
targetPath: null,
|
||||
detail: resolvePaperclipSkillMissingDetail(available, missingDetail),
|
||||
required: Boolean(available.required),
|
||||
requiredReason: available.requiredReason ?? null,
|
||||
...buildManagedSkillOrigin(available),
|
||||
...buildManagedSkillOrigin(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -1709,9 +1676,7 @@ export function buildRuntimeMountedSkillSnapshot(
|
||||
available,
|
||||
)
|
||||
: null,
|
||||
required: Boolean(available.required),
|
||||
requiredReason: available.requiredReason ?? null,
|
||||
...buildManagedSkillOrigin(available),
|
||||
...buildManagedSkillOrigin(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1807,9 +1772,7 @@ export function buildPersistentSkillSnapshot(
|
||||
available,
|
||||
missingDetail,
|
||||
),
|
||||
required: Boolean(available.required),
|
||||
requiredReason: available.requiredReason ?? null,
|
||||
...buildManagedSkillOrigin(available),
|
||||
...buildManagedSkillOrigin(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -1841,9 +1804,7 @@ export function buildPersistentSkillSnapshot(
|
||||
sourcePath: available.source,
|
||||
targetPath: path.join(skillsHome, available.runtimeName),
|
||||
detail,
|
||||
required: Boolean(available.required),
|
||||
requiredReason: available.requiredReason ?? null,
|
||||
...buildManagedSkillOrigin(available),
|
||||
...buildManagedSkillOrigin(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1925,11 +1886,6 @@ function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSki
|
||||
typeof entry.missingDetail === "string" && entry.missingDetail.trim().length > 0
|
||||
? entry.missingDetail.trim()
|
||||
: null,
|
||||
required: asBoolean(entry.required, false),
|
||||
requiredReason:
|
||||
typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0
|
||||
? entry.requiredReason.trim()
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -2029,19 +1985,14 @@ function canonicalizeDesiredPaperclipSkillReference(
|
||||
|
||||
export function resolvePaperclipDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; runtimeName?: string | null; required?: boolean }>,
|
||||
availableEntries: Array<{ key: string; runtimeName?: string | null }>,
|
||||
): string[] {
|
||||
const preference = readPaperclipSkillSyncPreference(config);
|
||||
const requiredSkills = availableEntries
|
||||
.filter((entry) => entry.required)
|
||||
.map((entry) => entry.key);
|
||||
if (!preference.explicit) {
|
||||
return Array.from(new Set(requiredSkills));
|
||||
}
|
||||
if (!preference.explicit) return [];
|
||||
const desiredSkills = preference.desiredSkills
|
||||
.map((reference) => canonicalizeDesiredPaperclipSkillReference(reference, availableEntries))
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set([...requiredSkills, ...desiredSkills]));
|
||||
return Array.from(new Set(desiredSkills));
|
||||
}
|
||||
|
||||
export function writePaperclipSkillSyncPreference(
|
||||
|
||||
@@ -186,7 +186,6 @@ export type AdapterSkillState =
|
||||
|
||||
export type AdapterSkillOrigin =
|
||||
| "company_managed"
|
||||
| "paperclip_required"
|
||||
| "user_installed"
|
||||
| "external_unknown";
|
||||
|
||||
@@ -197,8 +196,6 @@ export interface AdapterSkillEntry {
|
||||
currentVersionId?: string | null;
|
||||
desired: boolean;
|
||||
managed: boolean;
|
||||
required?: boolean;
|
||||
requiredReason?: string | null;
|
||||
state: AdapterSkillState;
|
||||
origin?: AdapterSkillOrigin;
|
||||
originLabel?: string | null;
|
||||
|
||||
@@ -57,10 +57,7 @@ export async function syncCursorSkills(
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||
const desiredSet = new Set([
|
||||
...desiredSkills,
|
||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
||||
]);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveCursorSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
@@ -85,7 +82,7 @@ export async function syncCursorSkills(
|
||||
|
||||
export function resolveCursorDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||
availableEntries: Array<{ key: string }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
|
||||
@@ -57,10 +57,7 @@ export async function syncGeminiSkills(
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||
const desiredSet = new Set([
|
||||
...desiredSkills,
|
||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
||||
]);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveGeminiSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
@@ -85,7 +82,7 @@ export async function syncGeminiSkills(
|
||||
|
||||
export function resolveGeminiDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||
availableEntries: Array<{ key: string }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
|
||||
@@ -61,10 +61,7 @@ export async function syncOpenCodeSkills(
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||
const desiredSet = new Set([
|
||||
...desiredSkills,
|
||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
||||
]);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveOpenCodeSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
@@ -89,7 +86,7 @@ export async function syncOpenCodeSkills(
|
||||
|
||||
export function resolveOpenCodeDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||
availableEntries: Array<{ key: string }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
|
||||
@@ -57,10 +57,7 @@ export async function syncPiSkills(
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||
const desiredSet = new Set([
|
||||
...desiredSkills,
|
||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
||||
]);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolvePiSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
@@ -85,7 +82,7 @@ export async function syncPiSkills(
|
||||
|
||||
export function resolvePiDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||
availableEntries: Array<{ key: string }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ export type AgentSkillState =
|
||||
|
||||
export type AgentSkillOrigin =
|
||||
| "company_managed"
|
||||
| "paperclip_required"
|
||||
| "user_installed"
|
||||
| "external_unknown";
|
||||
|
||||
@@ -26,8 +25,6 @@ export interface AgentSkillEntry {
|
||||
currentVersionId?: string | null;
|
||||
desired: boolean;
|
||||
managed: boolean;
|
||||
required?: boolean;
|
||||
requiredReason?: string | null;
|
||||
state: AgentSkillState;
|
||||
origin?: AgentSkillOrigin;
|
||||
originLabel?: string | null;
|
||||
|
||||
@@ -11,7 +11,6 @@ export const agentSkillStateSchema = z.enum([
|
||||
|
||||
export const agentSkillOriginSchema = z.enum([
|
||||
"company_managed",
|
||||
"paperclip_required",
|
||||
"user_installed",
|
||||
"external_unknown",
|
||||
]);
|
||||
@@ -39,8 +38,6 @@ export const agentSkillEntrySchema = z.object({
|
||||
currentVersionId: z.string().uuid().nullable().optional(),
|
||||
desired: z.boolean(),
|
||||
managed: z.boolean(),
|
||||
required: z.boolean().optional(),
|
||||
requiredReason: z.string().nullable().optional(),
|
||||
state: agentSkillStateSchema,
|
||||
origin: agentSkillOriginSchema.optional(),
|
||||
originLabel: z.string().nullable().optional(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
---
|
||||
name: paperclip-dev
|
||||
required: false
|
||||
description: >
|
||||
Develop and operate a local Paperclip instance — start and stop servers,
|
||||
pull updates from master, run builds and tests, manage worktrees, back up
|
||||
databases, and diagnose problems. Use whenever you need to work on the
|
||||
Paperclip codebase itself or keep a running instance healthy.
|
||||
---
|
||||
|
||||
# Paperclip Dev
|
||||
|
||||
This skill covers the day-to-day workflows for developing and operating a local Paperclip instance. It assumes you are working inside the Paperclip repo checkout with `origin` pointing to `git@github.com:paperclipai/paperclip.git`.
|
||||
|
||||
> **OPEN SOURCE HYGIENE:** This repository is public-facing. Treat anything you push to `origin` as publishable. Never commit or push secrets, API keys, tokens, private logs, PII, customer data, or machine-local configuration that should stay private. Keep git history tidy as well: avoid pushing throwaway branches, noisy checkpoint commits, or speculative work that does not need to be shared upstream.
|
||||
|
||||
> **MANDATORY:** Before running any CLI command, building, testing, or managing worktrees, you MUST read `doc/DEVELOPING.md` in the Paperclip repo. It is the canonical reference for all `paperclipai` CLI commands, their options, build/test workflows, database operations, worktree management, and diagnostics. Do NOT guess at flags or options — read the doc first.
|
||||
|
||||
## Quick Command Reference
|
||||
|
||||
These are the most common commands. For full option tables and details, see `doc/DEVELOPING.md`.
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Start server (first time or normal) | `npx paperclipai run` |
|
||||
| Dev mode with hot reload | `pnpm dev` |
|
||||
| Stop dev server | `pnpm dev:stop` |
|
||||
| Build | `pnpm build` |
|
||||
| Type-check | `pnpm typecheck` |
|
||||
| Run tests | `pnpm test` |
|
||||
| Run migrations | `pnpm db:migrate` |
|
||||
| Regenerate Drizzle client | `pnpm db:generate` |
|
||||
| Back up database | `npx paperclipai db:backup` |
|
||||
| Health check | `npx paperclipai doctor --repair` |
|
||||
| Print env vars | `npx paperclipai env` |
|
||||
| Trigger agent heartbeat | `npx paperclipai heartbeat run --agent-id <id>` |
|
||||
| Install agent skills locally | `npx paperclipai agent local-cli <agent> --company-id <id>` |
|
||||
|
||||
## Pulling from Master
|
||||
|
||||
```bash
|
||||
git fetch origin && git pull origin master
|
||||
pnpm install && pnpm build
|
||||
```
|
||||
|
||||
If schema changes landed, also run `pnpm db:generate && pnpm db:migrate`.
|
||||
|
||||
## Worktrees
|
||||
|
||||
Paperclip worktrees combine git worktrees with isolated Paperclip instances — each gets its own database, server port, and environment seeded from the primary instance.
|
||||
|
||||
> **MANDATORY:** Before creating or managing worktrees, you MUST read the "Worktree-local Instances" and "Worktree CLI Reference" sections in `doc/DEVELOPING.md`. That is the canonical reference for all worktree commands, their options, seed modes, and environment variables.
|
||||
|
||||
### When to Use Worktrees
|
||||
|
||||
- Starting a feature branch that needs its own Paperclip environment
|
||||
- Running parallel agent work without cross-contaminating the primary instance
|
||||
- Testing Paperclip changes in isolation before merging
|
||||
|
||||
### Command Overview
|
||||
|
||||
The CLI has two tiers (see `doc/DEVELOPING.md` for full option tables):
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `worktree:make <name>` | Create worktree + isolated instance in one step |
|
||||
| `worktree:list` | List worktrees and their Paperclip status |
|
||||
| `worktree:merge-history` | Preview/import issue history between worktrees |
|
||||
| `worktree:cleanup <name>` | Remove worktree, branch, and instance data |
|
||||
| `worktree init` | Bootstrap instance inside existing worktree |
|
||||
| `worktree env` | Print shell exports for worktree instance |
|
||||
| `worktree reseed` | Refresh worktree DB from another instance |
|
||||
| `worktree repair` | Fix broken/missing worktree instance metadata |
|
||||
|
||||
### Typical Workflow
|
||||
|
||||
```bash
|
||||
# 1. Create a worktree for a feature
|
||||
npx paperclipai worktree:make my-feature --start-point origin/main
|
||||
|
||||
# 2. Move into the worktree (path printed by worktree:make) and source the environment
|
||||
cd <worktree-path>
|
||||
eval "$(npx paperclipai worktree env)"
|
||||
|
||||
# 3. Start the isolated Paperclip server
|
||||
npx paperclipai run
|
||||
|
||||
# 4. Do your work
|
||||
|
||||
# 5. When done, merge history back if needed
|
||||
npx paperclipai worktree:merge-history --from paperclip-my-feature --to current --apply
|
||||
|
||||
# 6. Clean up
|
||||
npx paperclipai worktree:cleanup my-feature
|
||||
```
|
||||
|
||||
## Forks — Prefer Pushing to a User Fork
|
||||
|
||||
If the user has a personal fork of `paperclipai/paperclip` configured as a git remote, push your feature branches to **that fork** instead of creating branches on the main repo. This keeps the upstream branch list clean and matches the standard open-source contribution flow.
|
||||
|
||||
### Detect a fork remote
|
||||
|
||||
Before pushing or creating a PR, list remotes and check for one that points at a non-`paperclipai` GitHub fork:
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
Treat any remote whose URL points to `github.com:<user>/paperclip` (or `github.com/<user>/paperclip.git`) as the user's fork. Common names are `fork`, `<username>`, or `myfork`. The remote named `origin` or `upstream` that points at `paperclipai/paperclip` is the canonical upstream — do not push feature branches there if a fork exists.
|
||||
|
||||
### Pushing to the fork
|
||||
|
||||
```bash
|
||||
# Push the current branch to the user's fork and set upstream
|
||||
git push -u <fork-remote> HEAD
|
||||
```
|
||||
|
||||
Then create the PR from the fork branch:
|
||||
|
||||
```bash
|
||||
gh pr create --repo paperclipai/paperclip --head <fork-owner>:<branch-name> ...
|
||||
```
|
||||
|
||||
`gh pr create` usually figures out the head ref automatically when run from a branch tracking the fork; the explicit `--head <owner>:<branch>` form is the reliable fallback when it does not.
|
||||
|
||||
### When no fork exists
|
||||
|
||||
If `git remote -v` shows only `paperclipai/paperclip` remotes (no user fork), fall back to pushing branches to `origin` as before. Do NOT create a fork on the user's behalf — ask first.
|
||||
|
||||
### Keeping the fork up to date
|
||||
|
||||
The canonical remote that points at `paperclipai/paperclip` may be named `origin` **or** `upstream` depending on how the user set up the repo. Detect it the same way as in the "Detect a fork remote" step, then fetch and push from/with that remote so the sync works under either convention:
|
||||
|
||||
```bash
|
||||
UPSTREAM_REMOTE=$(git remote -v | awk '/paperclipai\/paperclip.*\(fetch\)/{print $1; exit}')
|
||||
git fetch "$UPSTREAM_REMOTE"
|
||||
git push <fork-remote> "${UPSTREAM_REMOTE}/master:master"
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
> **MANDATORY PRE-FLIGHT:** Before creating ANY pull request, you MUST read the canonical source files listed below. Do NOT run `gh pr create` until you have read these files and verified your PR body matches every required section.
|
||||
|
||||
### Step 1 — Read the canonical files
|
||||
|
||||
You MUST read all three of these files before creating a PR:
|
||||
|
||||
1. **`.github/PULL_REQUEST_TEMPLATE.md`** — the required PR body structure
|
||||
2. **`CONTRIBUTING.md`** — contribution conventions, PR requirements, and thinking-path examples
|
||||
3. **`.github/workflows/pr.yml`** — CI checks that gate merge
|
||||
|
||||
### Step 2 — Validate your PR body against this checklist
|
||||
|
||||
After reading the template, verify your `--body` includes every one of these sections (names must match exactly):
|
||||
|
||||
- [ ] `## Thinking Path` — blockquote style, 5-8 reasoning steps
|
||||
- [ ] `## What Changed` — bullet list of concrete changes
|
||||
- [ ] `## Verification` — how a reviewer confirms this works
|
||||
- [ ] `## Risks` — what could go wrong
|
||||
- [ ] `## Model Used` — provider, model ID, version, capabilities
|
||||
- [ ] `## Checklist` — copied from the template, items checked off
|
||||
|
||||
If any section is missing or empty, do NOT submit the PR. Go back and fill it in.
|
||||
|
||||
### Step 3 — Create the PR
|
||||
|
||||
Only after completing Steps 1 and 2, run `gh pr create`. Use the template contents as the structure for `--body` — do not write a freeform summary.
|
||||
|
||||
## Hard Rules — Do NOT Bypass
|
||||
|
||||
These rules exist because agents have caused real damage by improvising around CLI failures. Follow them exactly.
|
||||
|
||||
1. **CLI is the only interface to worktrees and databases.** All worktree and database operations MUST go through `npx paperclipai` / `pnpm paperclipai` commands. You MUST NOT:
|
||||
- Run `pg_dump`, `pg_restore`, `psql`, `createdb`, `dropdb`, or any raw postgres commands
|
||||
- Manually set `DATABASE_URL` to point a worktree server at another instance's database
|
||||
- Run `rm -rf` on any `.paperclip/`, `.paperclip-worktrees/`, or `db/` directory
|
||||
- Directly manipulate embedded postgres data directories
|
||||
- Kill postgres processes by PID
|
||||
|
||||
2. **If a CLI command fails, stop and report.** Do NOT attempt workarounds. If `worktree:make`, `worktree reseed`, `worktree init`, `worktree:cleanup`, or any other `paperclipai` command fails:
|
||||
- Report the exact error message in your task comment
|
||||
- Set the task to `blocked`
|
||||
- Suggest running `npx paperclipai doctor --repair` or recreating the worktree from scratch
|
||||
- Do NOT try to manually replicate what the CLI does
|
||||
|
||||
3. **Never share databases between instances.** Each worktree instance gets its own isolated database. Never override `DATABASE_URL` to point one instance at another's database. This destroys isolation and can corrupt production data.
|
||||
|
||||
4. **Starting a dev server in a worktree requires setup first.** The correct sequence is:
|
||||
```bash
|
||||
# If the worktree already exists but has no running instance:
|
||||
cd <worktree-path>
|
||||
eval "$(npx paperclipai worktree env)"
|
||||
pnpm install && pnpm build
|
||||
npx paperclipai run # or pnpm dev
|
||||
|
||||
# If the worktree needs a fresh database:
|
||||
npx paperclipai worktree reseed --seed-mode full
|
||||
|
||||
# If the worktree is broken beyond repair:
|
||||
npx paperclipai worktree:cleanup <name>
|
||||
npx paperclipai worktree:make <name> --seed-mode full
|
||||
```
|
||||
If any step fails, follow rule 2 — stop and report.
|
||||
|
||||
5. **Seeding is a CLI operation.** When asked to seed a worktree database from the main instance, use `worktree reseed` or recreate with `worktree:make --seed-mode full`. Read `doc/DEVELOPING.md` for the full option tables. Never attempt manual database copying.
|
||||
|
||||
## Persistent Dev Servers (for Manual Testing)
|
||||
|
||||
When an agent needs to start a dev server that outlives the current heartbeat — for example, so a human or QA agent can manually test against it — the server process **must** be launched in a detached session. A process started directly from a heartbeat shell is killed when the heartbeat exits.
|
||||
|
||||
### Use `tmux` for persistent servers
|
||||
|
||||
```bash
|
||||
# 1. cd into the worktree (or main repo) and source the environment
|
||||
cd <worktree-path>
|
||||
eval "$(npx paperclipai worktree env)" # skip if using the primary instance
|
||||
|
||||
# 2. Start the dev server in a named, detached tmux session
|
||||
tmux new-session -d -s <session-name> 'pnpm dev'
|
||||
|
||||
# Example with a descriptive name:
|
||||
tmux new-session -d -s auth-fix-3102 'pnpm dev'
|
||||
```
|
||||
|
||||
### Managing the session
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Check if the session is alive | `tmux has-session -t <session-name> 2>/dev/null && echo running` |
|
||||
| View server output | `tmux capture-pane -t <session-name> -p` |
|
||||
| Kill the session | `tmux kill-session -t <session-name>` |
|
||||
| List all tmux sessions | `tmux list-sessions` |
|
||||
|
||||
### Verifying the server is reachable
|
||||
|
||||
After launching, confirm the port is listening before reporting success:
|
||||
|
||||
```bash
|
||||
# Wait briefly for startup, then verify
|
||||
sleep 3
|
||||
curl -sf http://127.0.0.1:<port>/api/health && echo "Server is up"
|
||||
lsof -nP -iTCP:<port> -sTCP:LISTEN
|
||||
```
|
||||
|
||||
### Key rules
|
||||
|
||||
1. **Always use `tmux` (or equivalent)** when a dev server needs to stay running after the heartbeat ends. A server started directly from the agent shell will die when the heartbeat exits, even if it appeared healthy moments before.
|
||||
2. **Name the session descriptively** — include the worktree name and port (e.g., `auth-fix-3102`).
|
||||
3. **Verify the server is listening** before reporting the URL to anyone.
|
||||
4. **Do not use `nohup` or `&` alone** — these are unreliable for agent shells that may have their entire process group killed.
|
||||
5. **Clean up when done** — kill the tmux session when the testing is complete.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
|---------|-----|
|
||||
| Server won't start | Run `npx paperclipai doctor --repair` to diagnose and auto-fix |
|
||||
| Forgetting to source worktree env | Run `eval "$(npx paperclipai worktree env)"` after cd-ing into the worktree |
|
||||
| Stale dependencies after pull | Run `pnpm install && pnpm build` after pulling |
|
||||
| Schema out of date after pull | Run `pnpm db:generate && pnpm db:migrate` |
|
||||
| Reseeding while target DB is running | Stop the target server first, or use `--allow-live-target` |
|
||||
| Cleaning up with unmerged commits | Merge or push first, or use `--force` if intentionally discarding |
|
||||
| Running agents against wrong instance | Verify `PAPERCLIP_API_URL` points to the correct port |
|
||||
| CLI command fails | Do NOT work around it — report the error and block (see Hard Rules above) |
|
||||
| Agent tries manual postgres operations | NEVER do this — all DB ops go through the CLI (see Hard Rules above) |
|
||||
| Dev server dies between heartbeats | Launch in a detached `tmux` session — see "Persistent Dev Servers" above |
|
||||
| Pushed feature branch to `paperclipai/paperclip` when a fork exists | Push to the user's fork remote instead — see "Forks" above |
|
||||
@@ -2643,9 +2643,7 @@ export function AgentSkillsTab({
|
||||
);
|
||||
const optionalSkillRows = useMemo<SkillRow[]>(
|
||||
() =>
|
||||
(companySkills ?? [])
|
||||
.filter((skill) => !adapterEntryByKey.get(skill.key)?.required)
|
||||
.map((skill) => ({
|
||||
(companySkills ?? []).map((skill) => ({
|
||||
id: skill.id,
|
||||
key: skill.key,
|
||||
name: skill.name,
|
||||
@@ -2659,27 +2657,6 @@ export function AgentSkillsTab({
|
||||
})),
|
||||
[adapterEntryByKey, companySkills],
|
||||
);
|
||||
const requiredSkillRows = useMemo<SkillRow[]>(
|
||||
() =>
|
||||
(skillSnapshot?.entries ?? [])
|
||||
.filter((entry) => entry.required)
|
||||
.map((entry) => {
|
||||
const companySkill = companySkillByKey.get(entry.key);
|
||||
return {
|
||||
id: companySkill?.id ?? `required:${entry.key}`,
|
||||
key: entry.key,
|
||||
name: companySkill?.name ?? entry.key,
|
||||
description: companySkill?.description ?? null,
|
||||
detail: entry.detail ?? null,
|
||||
locationLabel: entry.locationLabel ?? null,
|
||||
originLabel: entry.originLabel ?? null,
|
||||
linkTo: companySkill ? `/skills/${companySkill.id}` : null,
|
||||
readOnly: false,
|
||||
adapterEntry: entry,
|
||||
};
|
||||
}),
|
||||
[companySkillByKey, skillSnapshot],
|
||||
);
|
||||
const unmanagedSkillRows = useMemo<SkillRow[]>(
|
||||
() =>
|
||||
(skillSnapshot?.entries ?? [])
|
||||
@@ -2780,8 +2757,6 @@ export function AgentSkillsTab({
|
||||
<>
|
||||
{(() => {
|
||||
const renderSkillRow = (skill: SkillRow) => {
|
||||
const adapterEntry = skill.adapterEntry ?? adapterEntryByKey.get(skill.key);
|
||||
const required = Boolean(adapterEntry?.required);
|
||||
const summaryText = resolveSkillSummaryText(skill, { fallbackKey: true });
|
||||
const rowClassName = cn(
|
||||
"flex items-start gap-3 border-b border-border px-3 py-3 text-sm last:border-b-0",
|
||||
@@ -2828,8 +2803,8 @@ export function AgentSkillsTab({
|
||||
);
|
||||
}
|
||||
|
||||
const checked = required || skillDraft.includes(skill.key);
|
||||
const disabled = required || skillSnapshot?.mode === "unsupported";
|
||||
const checked = skillDraft.includes(skill.key);
|
||||
const disabled = skillSnapshot?.mode === "unsupported";
|
||||
const checkbox = (
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -2847,14 +2822,7 @@ export function AgentSkillsTab({
|
||||
|
||||
return (
|
||||
<label key={skill.id} className={rowClassName}>
|
||||
{required && adapterEntry?.requiredReason ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{checkbox}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{adapterEntry.requiredReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : skillSnapshot?.mode === "unsupported" ? (
|
||||
{skillSnapshot?.mode === "unsupported" ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{checkbox}</span>
|
||||
@@ -2895,7 +2863,7 @@ export function AgentSkillsTab({
|
||||
);
|
||||
};
|
||||
|
||||
if (optionalSkillRows.length === 0 && requiredSkillRows.length === 0 && unmanagedSkillRows.length === 0) {
|
||||
if (optionalSkillRows.length === 0 && unmanagedSkillRows.length === 0) {
|
||||
return (
|
||||
<section className="border-y border-border">
|
||||
<div className="px-3 py-6 text-sm text-muted-foreground">
|
||||
@@ -2917,7 +2885,6 @@ export function AgentSkillsTab({
|
||||
|
||||
{renderSkillSection("Other skills", otherSkillRows)}
|
||||
|
||||
{renderSkillSection("Required by Paperclip", requiredSkillRows)}
|
||||
|
||||
{unmanagedSkillRows.length > 0 && (
|
||||
<section className="border-y border-border">
|
||||
|
||||
@@ -583,11 +583,9 @@ function buildAcpxClaudeSnapshot(): AgentSkillSnapshot {
|
||||
runtimeName: "paperclip",
|
||||
desired: true,
|
||||
managed: true,
|
||||
required: true,
|
||||
requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.",
|
||||
state: "configured",
|
||||
origin: "paperclip_required",
|
||||
originLabel: "Required by Paperclip",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: "skills/paperclip",
|
||||
targetPath: null,
|
||||
@@ -598,7 +596,6 @@ function buildAcpxClaudeSnapshot(): AgentSkillSnapshot {
|
||||
runtimeName: "design-guide",
|
||||
desired: true,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "configured",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
@@ -612,7 +609,6 @@ function buildAcpxClaudeSnapshot(): AgentSkillSnapshot {
|
||||
runtimeName: "mobile-app-qa",
|
||||
desired: false,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "available",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
@@ -638,11 +634,9 @@ function buildAcpxCodexSnapshot(): AgentSkillSnapshot {
|
||||
runtimeName: "paperclip",
|
||||
desired: true,
|
||||
managed: true,
|
||||
required: true,
|
||||
requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.",
|
||||
state: "configured",
|
||||
origin: "paperclip_required",
|
||||
originLabel: "Required by Paperclip",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: "skills/paperclip",
|
||||
targetPath: null,
|
||||
@@ -653,7 +647,6 @@ function buildAcpxCodexSnapshot(): AgentSkillSnapshot {
|
||||
runtimeName: "design-guide",
|
||||
desired: false,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "available",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
@@ -667,7 +660,6 @@ function buildAcpxCodexSnapshot(): AgentSkillSnapshot {
|
||||
runtimeName: "mobile-app-qa",
|
||||
desired: false,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "available",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
@@ -695,11 +687,9 @@ function buildAcpxCustomSnapshot(): AgentSkillSnapshot {
|
||||
runtimeName: "paperclip",
|
||||
desired: false,
|
||||
managed: true,
|
||||
required: true,
|
||||
requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.",
|
||||
state: "available",
|
||||
origin: "paperclip_required",
|
||||
originLabel: "Required by Paperclip",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: "skills/paperclip",
|
||||
targetPath: null,
|
||||
@@ -710,7 +700,6 @@ function buildAcpxCustomSnapshot(): AgentSkillSnapshot {
|
||||
runtimeName: "design-guide",
|
||||
desired: true,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "configured",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
@@ -725,7 +714,6 @@ function buildAcpxCustomSnapshot(): AgentSkillSnapshot {
|
||||
runtimeName: "mobile-app-qa",
|
||||
desired: false,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "available",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
|
||||
Reference in New Issue
Block a user