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(
|
addCommonClientOptions(
|
||||||
agent
|
agent
|
||||||
.command("sync")
|
.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")
|
.argument("<agentRef>", "Agent ID or shortname/url-key")
|
||||||
.option("--skill <skillRef>", "Desired company skill ID, key, or slug; may be repeated", collectOptionValue, [] as string[])
|
.option("--skill <skillRef>", "Desired company skill ID, key, or slug; may be repeated", collectOptionValue, [] as string[])
|
||||||
.action(async (agentRef: string, opts: AgentSkillSyncOptions) => {
|
.action(async (agentRef: string, opts: AgentSkillSyncOptions) => {
|
||||||
@@ -535,7 +535,7 @@ function registerAgentSkillCommands(skills: Command): void {
|
|||||||
addCommonClientOptions(
|
addCommonClientOptions(
|
||||||
agent
|
agent
|
||||||
.command("clear")
|
.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")
|
.argument("<agentRef>", "Agent ID or shortname/url-key")
|
||||||
.option("--yes", "Confirm clear without prompting", false)
|
.option("--yes", "Confirm clear without prompting", false)
|
||||||
.action(async (agentRef: string, opts: ConfirmedSkillOptions) => {
|
.action(async (agentRef: string, opts: ConfirmedSkillOptions) => {
|
||||||
@@ -544,7 +544,7 @@ function registerAgentSkillCommands(skills: Command): void {
|
|||||||
const agentRow = await resolveAgent(ctx, agentRef);
|
const agentRow = await resolveAgent(ctx, agentRef);
|
||||||
await confirmDangerousAction(
|
await confirmDangerousAction(
|
||||||
opts.yes,
|
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>(
|
const snapshot = await ctx.api.post<AgentSkillSnapshot>(
|
||||||
`/api/agents/${encodeURIComponent(agentRow.id)}/skills/sync`,
|
`/api/agents/${encodeURIComponent(agentRow.id)}/skills/sync`,
|
||||||
@@ -555,7 +555,7 @@ function registerAgentSkillCommands(skills: Command): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(
|
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);
|
printAgentSkillSnapshot(snapshot, agentRow);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -911,7 +911,6 @@ function printAgentSkillSnapshot(snapshot: AgentSkillSnapshot | null, agent: Age
|
|||||||
runtimeName: entry.runtimeName,
|
runtimeName: entry.runtimeName,
|
||||||
desired: entry.desired,
|
desired: entry.desired,
|
||||||
managed: entry.managed,
|
managed: entry.managed,
|
||||||
required: entry.required ?? false,
|
|
||||||
state: entry.state,
|
state: entry.state,
|
||||||
origin: entry.origin,
|
origin: entry.origin,
|
||||||
detail: entry.detail,
|
detail: entry.detail,
|
||||||
|
|||||||
@@ -213,8 +213,6 @@ describe("adapter skill snapshots", () => {
|
|||||||
key: "paperclipai/paperclip/paperclip",
|
key: "paperclipai/paperclip/paperclip",
|
||||||
runtimeName: "paperclip",
|
runtimeName: "paperclip",
|
||||||
source: "/runtime/paperclip",
|
source: "/runtime/paperclip",
|
||||||
required: true,
|
|
||||||
requiredReason: "Required for Paperclip heartbeats.",
|
|
||||||
};
|
};
|
||||||
const optionalEntry = {
|
const optionalEntry = {
|
||||||
key: "company/ascii-heart",
|
key: "company/ascii-heart",
|
||||||
@@ -245,8 +243,7 @@ describe("adapter skill snapshots", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
key: requiredEntry.key,
|
key: requiredEntry.key,
|
||||||
state: "configured",
|
state: "configured",
|
||||||
origin: "paperclip_required",
|
origin: "company_managed",
|
||||||
required: true,
|
|
||||||
detail: "Mounted on next run.",
|
detail: "Mounted on next run.",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -345,7 +342,7 @@ describe("adapter skill snapshots", () => {
|
|||||||
key: requiredEntry.key,
|
key: requiredEntry.key,
|
||||||
state: "installed",
|
state: "installed",
|
||||||
managed: true,
|
managed: true,
|
||||||
origin: "paperclip_required",
|
origin: "company_managed",
|
||||||
}));
|
}));
|
||||||
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
expect(snapshot.entries).toContainEqual(expect.objectContaining({
|
||||||
key: optionalEntry.key,
|
key: optionalEntry.key,
|
||||||
|
|||||||
@@ -195,8 +195,6 @@ export interface PaperclipSkillEntry {
|
|||||||
currentVersionId?: string | null;
|
currentVersionId?: string | null;
|
||||||
sourceStatus?: "available" | "missing";
|
sourceStatus?: "available" | "missing";
|
||||||
missingDetail?: string | null;
|
missingDetail?: string | null;
|
||||||
required?: boolean;
|
|
||||||
requiredReason?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaperclipDesiredSkillEntry {
|
export interface PaperclipDesiredSkillEntry {
|
||||||
@@ -258,17 +256,10 @@ function skillLocationLabel(value: string | null | undefined): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildManagedSkillOrigin(entry: { required?: boolean }): Pick<
|
function buildManagedSkillOrigin(): Pick<
|
||||||
AdapterSkillEntry,
|
AdapterSkillEntry,
|
||||||
"origin" | "originLabel" | "readOnly"
|
"origin" | "originLabel" | "readOnly"
|
||||||
> {
|
> {
|
||||||
if (entry.required) {
|
|
||||||
return {
|
|
||||||
origin: "paperclip_required",
|
|
||||||
originLabel: "Required by Paperclip",
|
|
||||||
readOnly: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
origin: "company_managed",
|
origin: "company_managed",
|
||||||
originLabel: "Managed by Paperclip",
|
originLabel: "Managed by Paperclip",
|
||||||
@@ -1594,20 +1585,6 @@ export async function resolvePaperclipSkillsDir(
|
|||||||
return null;
|
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(
|
export async function listPaperclipSkillEntries(
|
||||||
moduleDir: string,
|
moduleDir: string,
|
||||||
additionalCandidates: string[] = [],
|
additionalCandidates: string[] = [],
|
||||||
@@ -1618,18 +1595,10 @@ export async function listPaperclipSkillEntries(
|
|||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||||
const dirs = entries.filter((entry) => entry.isDirectory());
|
const dirs = entries.filter((entry) => entry.isDirectory());
|
||||||
return Promise.all(dirs.map(async (entry) => {
|
return dirs.map((entry) => ({
|
||||||
const skillDir = path.join(root, entry.name);
|
key: `paperclipai/paperclip/${entry.name}`,
|
||||||
const required = await readSkillRequired(skillDir);
|
runtimeName: entry.name,
|
||||||
return {
|
source: path.join(root, entry.name),
|
||||||
key: `paperclipai/paperclip/${entry.name}`,
|
|
||||||
runtimeName: entry.name,
|
|
||||||
source: skillDir,
|
|
||||||
required,
|
|
||||||
requiredReason: required
|
|
||||||
? "Bundled Paperclip skills are always available for local adapters."
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
@@ -1682,9 +1651,7 @@ export function buildRuntimeMountedSkillSnapshot(
|
|||||||
sourcePath: null,
|
sourcePath: null,
|
||||||
targetPath: null,
|
targetPath: null,
|
||||||
detail: resolvePaperclipSkillMissingDetail(available, missingDetail),
|
detail: resolvePaperclipSkillMissingDetail(available, missingDetail),
|
||||||
required: Boolean(available.required),
|
...buildManagedSkillOrigin(),
|
||||||
requiredReason: available.requiredReason ?? null,
|
|
||||||
...buildManagedSkillOrigin(available),
|
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1709,9 +1676,7 @@ export function buildRuntimeMountedSkillSnapshot(
|
|||||||
available,
|
available,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
required: Boolean(available.required),
|
...buildManagedSkillOrigin(),
|
||||||
requiredReason: available.requiredReason ?? null,
|
|
||||||
...buildManagedSkillOrigin(available),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1807,9 +1772,7 @@ export function buildPersistentSkillSnapshot(
|
|||||||
available,
|
available,
|
||||||
missingDetail,
|
missingDetail,
|
||||||
),
|
),
|
||||||
required: Boolean(available.required),
|
...buildManagedSkillOrigin(),
|
||||||
requiredReason: available.requiredReason ?? null,
|
|
||||||
...buildManagedSkillOrigin(available),
|
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1841,9 +1804,7 @@ export function buildPersistentSkillSnapshot(
|
|||||||
sourcePath: available.source,
|
sourcePath: available.source,
|
||||||
targetPath: path.join(skillsHome, available.runtimeName),
|
targetPath: path.join(skillsHome, available.runtimeName),
|
||||||
detail,
|
detail,
|
||||||
required: Boolean(available.required),
|
...buildManagedSkillOrigin(),
|
||||||
requiredReason: available.requiredReason ?? null,
|
|
||||||
...buildManagedSkillOrigin(available),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1925,11 +1886,6 @@ function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSki
|
|||||||
typeof entry.missingDetail === "string" && entry.missingDetail.trim().length > 0
|
typeof entry.missingDetail === "string" && entry.missingDetail.trim().length > 0
|
||||||
? entry.missingDetail.trim()
|
? entry.missingDetail.trim()
|
||||||
: null,
|
: null,
|
||||||
required: asBoolean(entry.required, false),
|
|
||||||
requiredReason:
|
|
||||||
typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0
|
|
||||||
? entry.requiredReason.trim()
|
|
||||||
: null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -2029,19 +1985,14 @@ function canonicalizeDesiredPaperclipSkillReference(
|
|||||||
|
|
||||||
export function resolvePaperclipDesiredSkillNames(
|
export function resolvePaperclipDesiredSkillNames(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
availableEntries: Array<{ key: string; runtimeName?: string | null; required?: boolean }>,
|
availableEntries: Array<{ key: string; runtimeName?: string | null }>,
|
||||||
): string[] {
|
): string[] {
|
||||||
const preference = readPaperclipSkillSyncPreference(config);
|
const preference = readPaperclipSkillSyncPreference(config);
|
||||||
const requiredSkills = availableEntries
|
if (!preference.explicit) return [];
|
||||||
.filter((entry) => entry.required)
|
|
||||||
.map((entry) => entry.key);
|
|
||||||
if (!preference.explicit) {
|
|
||||||
return Array.from(new Set(requiredSkills));
|
|
||||||
}
|
|
||||||
const desiredSkills = preference.desiredSkills
|
const desiredSkills = preference.desiredSkills
|
||||||
.map((reference) => canonicalizeDesiredPaperclipSkillReference(reference, availableEntries))
|
.map((reference) => canonicalizeDesiredPaperclipSkillReference(reference, availableEntries))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return Array.from(new Set([...requiredSkills, ...desiredSkills]));
|
return Array.from(new Set(desiredSkills));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writePaperclipSkillSyncPreference(
|
export function writePaperclipSkillSyncPreference(
|
||||||
|
|||||||
@@ -186,7 +186,6 @@ export type AdapterSkillState =
|
|||||||
|
|
||||||
export type AdapterSkillOrigin =
|
export type AdapterSkillOrigin =
|
||||||
| "company_managed"
|
| "company_managed"
|
||||||
| "paperclip_required"
|
|
||||||
| "user_installed"
|
| "user_installed"
|
||||||
| "external_unknown";
|
| "external_unknown";
|
||||||
|
|
||||||
@@ -197,8 +196,6 @@ export interface AdapterSkillEntry {
|
|||||||
currentVersionId?: string | null;
|
currentVersionId?: string | null;
|
||||||
desired: boolean;
|
desired: boolean;
|
||||||
managed: boolean;
|
managed: boolean;
|
||||||
required?: boolean;
|
|
||||||
requiredReason?: string | null;
|
|
||||||
state: AdapterSkillState;
|
state: AdapterSkillState;
|
||||||
origin?: AdapterSkillOrigin;
|
origin?: AdapterSkillOrigin;
|
||||||
originLabel?: string | null;
|
originLabel?: string | null;
|
||||||
|
|||||||
@@ -57,10 +57,7 @@ export async function syncCursorSkills(
|
|||||||
desiredSkills: string[],
|
desiredSkills: string[],
|
||||||
): Promise<AdapterSkillSnapshot> {
|
): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||||
const desiredSet = new Set([
|
const desiredSet = new Set(desiredSkills);
|
||||||
...desiredSkills,
|
|
||||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
|
||||||
]);
|
|
||||||
const skillsHome = resolveCursorSkillsHome(ctx.config);
|
const skillsHome = resolveCursorSkillsHome(ctx.config);
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
const installed = await readInstalledSkillTargets(skillsHome);
|
||||||
@@ -85,7 +82,7 @@ export async function syncCursorSkills(
|
|||||||
|
|
||||||
export function resolveCursorDesiredSkillNames(
|
export function resolveCursorDesiredSkillNames(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
availableEntries: Array<{ key: string }>,
|
||||||
) {
|
) {
|
||||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,10 +57,7 @@ export async function syncGeminiSkills(
|
|||||||
desiredSkills: string[],
|
desiredSkills: string[],
|
||||||
): Promise<AdapterSkillSnapshot> {
|
): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||||
const desiredSet = new Set([
|
const desiredSet = new Set(desiredSkills);
|
||||||
...desiredSkills,
|
|
||||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
|
||||||
]);
|
|
||||||
const skillsHome = resolveGeminiSkillsHome(ctx.config);
|
const skillsHome = resolveGeminiSkillsHome(ctx.config);
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
const installed = await readInstalledSkillTargets(skillsHome);
|
||||||
@@ -85,7 +82,7 @@ export async function syncGeminiSkills(
|
|||||||
|
|
||||||
export function resolveGeminiDesiredSkillNames(
|
export function resolveGeminiDesiredSkillNames(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
availableEntries: Array<{ key: string }>,
|
||||||
) {
|
) {
|
||||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,10 +61,7 @@ export async function syncOpenCodeSkills(
|
|||||||
desiredSkills: string[],
|
desiredSkills: string[],
|
||||||
): Promise<AdapterSkillSnapshot> {
|
): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||||
const desiredSet = new Set([
|
const desiredSet = new Set(desiredSkills);
|
||||||
...desiredSkills,
|
|
||||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
|
||||||
]);
|
|
||||||
const skillsHome = resolveOpenCodeSkillsHome(ctx.config);
|
const skillsHome = resolveOpenCodeSkillsHome(ctx.config);
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
const installed = await readInstalledSkillTargets(skillsHome);
|
||||||
@@ -89,7 +86,7 @@ export async function syncOpenCodeSkills(
|
|||||||
|
|
||||||
export function resolveOpenCodeDesiredSkillNames(
|
export function resolveOpenCodeDesiredSkillNames(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
availableEntries: Array<{ key: string }>,
|
||||||
) {
|
) {
|
||||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,10 +57,7 @@ export async function syncPiSkills(
|
|||||||
desiredSkills: string[],
|
desiredSkills: string[],
|
||||||
): Promise<AdapterSkillSnapshot> {
|
): Promise<AdapterSkillSnapshot> {
|
||||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||||
const desiredSet = new Set([
|
const desiredSet = new Set(desiredSkills);
|
||||||
...desiredSkills,
|
|
||||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
|
||||||
]);
|
|
||||||
const skillsHome = resolvePiSkillsHome(ctx.config);
|
const skillsHome = resolvePiSkillsHome(ctx.config);
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
const installed = await readInstalledSkillTargets(skillsHome);
|
||||||
@@ -85,7 +82,7 @@ export async function syncPiSkills(
|
|||||||
|
|
||||||
export function resolvePiDesiredSkillNames(
|
export function resolvePiDesiredSkillNames(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
availableEntries: Array<{ key: string }>,
|
||||||
) {
|
) {
|
||||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export type AgentSkillState =
|
|||||||
|
|
||||||
export type AgentSkillOrigin =
|
export type AgentSkillOrigin =
|
||||||
| "company_managed"
|
| "company_managed"
|
||||||
| "paperclip_required"
|
|
||||||
| "user_installed"
|
| "user_installed"
|
||||||
| "external_unknown";
|
| "external_unknown";
|
||||||
|
|
||||||
@@ -26,8 +25,6 @@ export interface AgentSkillEntry {
|
|||||||
currentVersionId?: string | null;
|
currentVersionId?: string | null;
|
||||||
desired: boolean;
|
desired: boolean;
|
||||||
managed: boolean;
|
managed: boolean;
|
||||||
required?: boolean;
|
|
||||||
requiredReason?: string | null;
|
|
||||||
state: AgentSkillState;
|
state: AgentSkillState;
|
||||||
origin?: AgentSkillOrigin;
|
origin?: AgentSkillOrigin;
|
||||||
originLabel?: string | null;
|
originLabel?: string | null;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export const agentSkillStateSchema = z.enum([
|
|||||||
|
|
||||||
export const agentSkillOriginSchema = z.enum([
|
export const agentSkillOriginSchema = z.enum([
|
||||||
"company_managed",
|
"company_managed",
|
||||||
"paperclip_required",
|
|
||||||
"user_installed",
|
"user_installed",
|
||||||
"external_unknown",
|
"external_unknown",
|
||||||
]);
|
]);
|
||||||
@@ -39,8 +38,6 @@ export const agentSkillEntrySchema = z.object({
|
|||||||
currentVersionId: z.string().uuid().nullable().optional(),
|
currentVersionId: z.string().uuid().nullable().optional(),
|
||||||
desired: z.boolean(),
|
desired: z.boolean(),
|
||||||
managed: z.boolean(),
|
managed: z.boolean(),
|
||||||
required: z.boolean().optional(),
|
|
||||||
requiredReason: z.string().nullable().optional(),
|
|
||||||
state: agentSkillStateSchema,
|
state: agentSkillStateSchema,
|
||||||
origin: agentSkillOriginSchema.optional(),
|
origin: agentSkillOriginSchema.optional(),
|
||||||
originLabel: z.string().nullable().optional(),
|
originLabel: z.string().nullable().optional(),
|
||||||
|
|||||||
@@ -128,12 +128,11 @@ async function createRuntimeSkill(root: string, input: {
|
|||||||
const key = input.key ?? `company/${runtimeName}`;
|
const key = input.key ?? `company/${runtimeName}`;
|
||||||
const source = path.join(root, "skills", runtimeName);
|
const source = path.join(root, "skills", runtimeName);
|
||||||
await fs.mkdir(source, { recursive: true });
|
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 {
|
return {
|
||||||
key,
|
key,
|
||||||
runtimeName,
|
runtimeName,
|
||||||
source,
|
source,
|
||||||
required: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,7 +633,7 @@ describe("acpx_local execute", () => {
|
|||||||
it("includes skill content in the ACPX Claude session fingerprint", async () => {
|
it("includes skill content in the ACPX Claude session fingerprint", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-claude-fingerprint-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-claude-fingerprint-"));
|
||||||
try {
|
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 runtimes: FakeRuntime[] = [];
|
||||||
const execute = createAcpxLocalExecutor({
|
const execute = createAcpxLocalExecutor({
|
||||||
createRuntime: (options) => {
|
createRuntime: (options) => {
|
||||||
@@ -657,7 +656,7 @@ describe("acpx_local execute", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const first = await execute(context);
|
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({
|
const second = await execute({
|
||||||
...context,
|
...context,
|
||||||
runtime: {
|
runtime: {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
|
|
||||||
describe("acpx local skill sync", () => {
|
describe("acpx local skill sync", () => {
|
||||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
const createAgentKey = "paperclipai/paperclip/paperclip-create-agent";
|
|
||||||
|
|
||||||
it("reports ACPX Claude skills as supported runtime-mounted state", async () => {
|
it("reports ACPX Claude skills as supported runtime-mounted state", async () => {
|
||||||
const snapshot = await listAcpxSkills({
|
const snapshot = await listAcpxSkills({
|
||||||
@@ -25,7 +24,6 @@ describe("acpx local skill sync", () => {
|
|||||||
expect(snapshot.supported).toBe(true);
|
expect(snapshot.supported).toBe(true);
|
||||||
expect(snapshot.mode).toBe("ephemeral");
|
expect(snapshot.mode).toBe("ephemeral");
|
||||||
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
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)?.state).toBe("configured");
|
||||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("ACPX Claude session");
|
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("ACPX Claude session");
|
||||||
expect(snapshot.warnings).toEqual([]);
|
expect(snapshot.warnings).toEqual([]);
|
||||||
|
|||||||
@@ -264,8 +264,6 @@ describe.sequential("agent skill routes", () => {
|
|||||||
key: "paperclipai/paperclip/paperclip",
|
key: "paperclipai/paperclip/paperclip",
|
||||||
runtimeName: "paperclip",
|
runtimeName: "paperclip",
|
||||||
source: "/tmp/paperclip",
|
source: "/tmp/paperclip",
|
||||||
required: true,
|
|
||||||
requiredReason: "required",
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(
|
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(
|
||||||
|
|||||||
@@ -1046,6 +1046,9 @@ describe("claude execute", () => {
|
|||||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
|
||||||
},
|
},
|
||||||
promptTemplate: "Follow the paperclip heartbeat.",
|
promptTemplate: "Follow the paperclip heartbeat.",
|
||||||
|
paperclipSkillSync: {
|
||||||
|
desiredSkills: ["paperclip"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
context: {},
|
context: {},
|
||||||
authToken: "run-jwt-token",
|
authToken: "run-jwt-token",
|
||||||
@@ -1083,6 +1086,9 @@ describe("claude execute", () => {
|
|||||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath2,
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath2,
|
||||||
},
|
},
|
||||||
promptTemplate: "Follow the paperclip heartbeat.",
|
promptTemplate: "Follow the paperclip heartbeat.",
|
||||||
|
paperclipSkillSync: {
|
||||||
|
desiredSkills: ["paperclip"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
context: {
|
context: {
|
||||||
issueId: "issue-1",
|
issueId: "issue-1",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe("claude local skill sync", () => {
|
|||||||
cleanupDirs.clear();
|
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({
|
const snapshot = await listClaudeSkills({
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
@@ -38,9 +38,8 @@ describe("claude local skill sync", () => {
|
|||||||
|
|
||||||
expect(snapshot.mode).toBe("ephemeral");
|
expect(snapshot.mode).toBe("ephemeral");
|
||||||
expect(snapshot.supported).toBe(true);
|
expect(snapshot.supported).toBe(true);
|
||||||
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
expect(snapshot.desiredSkills).toEqual([]);
|
||||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
|
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("available");
|
||||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects an explicit desired skill list without mutating a persistent home", async () => {
|
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.desiredSkills).toContain(paperclipKey);
|
||||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
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 () => {
|
it("normalizes legacy flat Paperclip skill refs to canonical keys", async () => {
|
||||||
|
|||||||
@@ -1114,6 +1114,9 @@ describe("codex execute", () => {
|
|||||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||||
},
|
},
|
||||||
promptTemplate: "Follow the paperclip heartbeat.",
|
promptTemplate: "Follow the paperclip heartbeat.",
|
||||||
|
paperclipSkillSync: {
|
||||||
|
desiredSkills: ["paperclip"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
context: {},
|
context: {},
|
||||||
authToken: "run-jwt-token",
|
authToken: "run-jwt-token",
|
||||||
@@ -1222,6 +1225,9 @@ describe("codex execute", () => {
|
|||||||
CODEX_HOME: explicitCodexHome,
|
CODEX_HOME: explicitCodexHome,
|
||||||
},
|
},
|
||||||
promptTemplate: "Follow the paperclip heartbeat.",
|
promptTemplate: "Follow the paperclip heartbeat.",
|
||||||
|
paperclipSkillSync: {
|
||||||
|
desiredSkills: ["paperclip"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
context: {},
|
context: {},
|
||||||
authToken: "run-jwt-token",
|
authToken: "run-jwt-token",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ async function makeTempDir(prefix: string): Promise<string> {
|
|||||||
|
|
||||||
describe("codex local skill sync", () => {
|
describe("codex local skill sync", () => {
|
||||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
const createAgentKey = "paperclipai/paperclip/paperclip-create-agent";
|
|
||||||
const cleanupDirs = new Set<string>();
|
const cleanupDirs = new Set<string>();
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -42,11 +41,7 @@ describe("codex local skill sync", () => {
|
|||||||
const before = await listCodexSkills(ctx);
|
const before = await listCodexSkills(ctx);
|
||||||
expect(before.mode).toBe("ephemeral");
|
expect(before.mode).toBe("ephemeral");
|
||||||
expect(before.desiredSkills).toContain(paperclipKey);
|
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 === 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/");
|
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 () => {
|
it("normalizes legacy flat Paperclip skill refs before reporting configured state", async () => {
|
||||||
const codexHome = await makeTempDir("paperclip-codex-legacy-skill-sync-");
|
const codexHome = await makeTempDir("paperclip-codex-legacy-skill-sync-");
|
||||||
cleanupDirs.add(codexHome);
|
cleanupDirs.add(codexHome);
|
||||||
|
|||||||
@@ -288,8 +288,6 @@ describe("cursor execute", () => {
|
|||||||
{
|
{
|
||||||
name: "paperclip",
|
name: "paperclip",
|
||||||
source: paperclipDir,
|
source: paperclipDir,
|
||||||
required: true,
|
|
||||||
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ascii-heart",
|
name: "ascii-heart",
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ describe("cursor local skill sync", () => {
|
|||||||
const before = await listCursorSkills(ctx);
|
const before = await listCursorSkills(ctx);
|
||||||
expect(before.mode).toBe("persistent");
|
expect(before.mode).toBe("persistent");
|
||||||
expect(before.desiredSkills).toContain(paperclipKey);
|
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");
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||||
|
|
||||||
const after = await syncCursorSkills(ctx, [paperclipKey]);
|
const after = await syncCursorSkills(ctx, [paperclipKey]);
|
||||||
@@ -78,8 +77,6 @@ describe("cursor local skill sync", () => {
|
|||||||
key: "paperclip",
|
key: "paperclip",
|
||||||
runtimeName: "paperclip",
|
runtimeName: "paperclip",
|
||||||
source: paperclipDir,
|
source: paperclipDir,
|
||||||
required: true,
|
|
||||||
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "ascii-heart",
|
key: "ascii-heart",
|
||||||
@@ -95,7 +92,7 @@ describe("cursor local skill sync", () => {
|
|||||||
|
|
||||||
const before = await listCursorSkills(ctx);
|
const before = await listCursorSkills(ctx);
|
||||||
expect(before.warnings).toEqual([]);
|
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");
|
expect(before.entries.find((entry) => entry.key === "ascii-heart")?.state).toBe("missing");
|
||||||
|
|
||||||
const after = await syncCursorSkills(ctx, ["ascii-heart"]);
|
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);
|
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);
|
const before = await listGeminiSkills(ctx);
|
||||||
expect(before.mode).toBe("persistent");
|
expect(before.mode).toBe("persistent");
|
||||||
expect(before.desiredSkills).toContain(paperclipKey);
|
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");
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||||
|
|
||||||
const after = await syncGeminiSkills(ctx, [paperclipKey]);
|
const after = await syncGeminiSkills(ctx, [paperclipKey]);
|
||||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(home, ".gemini", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
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", () => {
|
describe("grok local skill sync", () => {
|
||||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||||
const createAgentKey = "paperclipai/paperclip/paperclip-create-agent";
|
|
||||||
|
|
||||||
it("reports Grok skills as ephemeral workspace-mounted state", async () => {
|
it("reports Grok skills as ephemeral workspace-mounted state", async () => {
|
||||||
const snapshot = await listGrokSkills({
|
const snapshot = await listGrokSkills({
|
||||||
@@ -24,9 +23,7 @@ describe("grok local skill sync", () => {
|
|||||||
expect(snapshot.supported).toBe(true);
|
expect(snapshot.supported).toBe(true);
|
||||||
expect(snapshot.mode).toBe("ephemeral");
|
expect(snapshot.mode).toBe("ephemeral");
|
||||||
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
expect(snapshot.desiredSkills).toContain(paperclipKey);
|
||||||
expect(snapshot.desiredSkills).toContain(createAgentKey);
|
|
||||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)).toMatchObject({
|
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)).toMatchObject({
|
||||||
required: true,
|
|
||||||
state: "configured",
|
state: "configured",
|
||||||
detail: "Will be copied into `.claude/skills` in the execution workspace on the next run.",
|
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.mode).toBe("persistent");
|
||||||
expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills).");
|
expect(before.warnings).toContain("OpenCode currently uses the shared Claude skills home (~/.claude/skills).");
|
||||||
expect(before.desiredSkills).toContain(paperclipKey);
|
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");
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||||
|
|
||||||
const after = await syncOpenCodeSkills(ctx, [paperclipKey]);
|
const after = await syncOpenCodeSkills(ctx, [paperclipKey]);
|
||||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||||
expect((await fs.lstat(path.join(home, ".claude", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
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();
|
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 () => {
|
it("removes stale maintainer-only symlinks from a shared skills home", async () => {
|
||||||
const root = await makeTempDir("paperclip-skill-cleanup-");
|
const root = await makeTempDir("paperclip-skill-cleanup-");
|
||||||
cleanupDirs.add(root);
|
cleanupDirs.add(root);
|
||||||
|
|||||||
@@ -130,8 +130,11 @@ describe("pi_local execute", () => {
|
|||||||
model: "google/gemini-3-flash-preview",
|
model: "google/gemini-3-flash-preview",
|
||||||
promptTemplate: "Keep working.",
|
promptTemplate: "Keep working.",
|
||||||
paperclipRuntimeSkills: [
|
paperclipRuntimeSkills: [
|
||||||
{ key: "demo-skill", runtimeName: "demo-skill", source: skillDir, required: true },
|
{ key: "demo-skill", runtimeName: "demo-skill", source: skillDir },
|
||||||
],
|
],
|
||||||
|
paperclipSkillSync: {
|
||||||
|
desiredSkills: ["demo-skill"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
context: {},
|
context: {},
|
||||||
authToken: "run-jwt-token",
|
authToken: "run-jwt-token",
|
||||||
@@ -185,10 +188,10 @@ describe("pi_local execute", () => {
|
|||||||
cwd: workspace,
|
cwd: workspace,
|
||||||
model: "google/gemini-3-flash-preview",
|
model: "google/gemini-3-flash-preview",
|
||||||
promptTemplate: "Keep working.",
|
promptTemplate: "Keep working.",
|
||||||
// required:false with no explicit paperclipSkillSync preference →
|
// No explicit paperclipSkillSync preference →
|
||||||
// resolvePaperclipDesiredSkillNames returns [] → skill is not injected.
|
// resolvePaperclipDesiredSkillNames returns [] → skill is not injected.
|
||||||
paperclipRuntimeSkills: [
|
paperclipRuntimeSkills: [
|
||||||
{ key: "not-injected", runtimeName: "not-injected", source: nonInjectedSkillDir, required: false },
|
{ key: "not-injected", runtimeName: "not-injected", source: nonInjectedSkillDir },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
context: {},
|
context: {},
|
||||||
|
|||||||
@@ -41,49 +41,10 @@ describe("pi local skill sync", () => {
|
|||||||
const before = await listPiSkills(ctx);
|
const before = await listPiSkills(ctx);
|
||||||
expect(before.mode).toBe("persistent");
|
expect(before.mode).toBe("persistent");
|
||||||
expect(before.desiredSkills).toContain(paperclipKey);
|
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");
|
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("missing");
|
||||||
|
|
||||||
const after = await syncPiSkills(ctx, [paperclipKey]);
|
const after = await syncPiSkills(ctx, [paperclipKey]);
|
||||||
expect(after.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
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);
|
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
|
// hermes-paperclip-adapter v0.2.0 predates the authToken field; cast is
|
||||||
// intentional until hermes ships a matching AdapterExecutionContext type.
|
// intentional until hermes ships a matching AdapterExecutionContext type.
|
||||||
const executeHermesLocal = hermesExecute as unknown as ServerAdapterModule["execute"];
|
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 = {
|
const hermesLocalAdapter: ServerAdapterModule = {
|
||||||
type: "hermes_local",
|
type: "hermes_local",
|
||||||
@@ -510,8 +514,8 @@ const hermesLocalAdapter: ServerAdapterModule = {
|
|||||||
},
|
},
|
||||||
testEnvironment: (ctx) => hermesTestEnvironment(normalizeHermesConfig(ctx) as never),
|
testEnvironment: (ctx) => hermesTestEnvironment(normalizeHermesConfig(ctx) as never),
|
||||||
sessionCodec: hermesSessionCodec,
|
sessionCodec: hermesSessionCodec,
|
||||||
listSkills: hermesListSkills,
|
listSkills: listHermesSkills,
|
||||||
syncSkills: hermesSyncSkills,
|
syncSkills: syncHermesSkills,
|
||||||
models: hermesModels,
|
models: hermesModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
supportsInstructionsBundle: false,
|
supportsInstructionsBundle: false,
|
||||||
|
|||||||
@@ -1480,18 +1480,13 @@ export function agentRoutes(
|
|||||||
companyId,
|
companyId,
|
||||||
requestedDesiredSkills,
|
requestedDesiredSkills,
|
||||||
);
|
);
|
||||||
const resolvedRequestedSkills = resolvedRequestedSkillEntries.map((entry) => entry.key);
|
|
||||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
|
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, {
|
||||||
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType),
|
materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType),
|
||||||
versionSelections: skillVersionSelectionMap(resolvedRequestedSkillEntries),
|
versionSelections: skillVersionSelectionMap(resolvedRequestedSkillEntries),
|
||||||
});
|
});
|
||||||
const requiredSkills = runtimeSkillEntries
|
const desiredSkillEntries = resolvedRequestedSkillEntries.filter(
|
||||||
.filter((entry) => entry.required)
|
(entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index,
|
||||||
.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 desiredSkills = desiredSkillEntries.map((entry) => entry.key);
|
const desiredSkills = desiredSkillEntries.map((entry) => entry.key);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1712,15 +1707,9 @@ export function agentRoutes(
|
|||||||
const preference = readPaperclipSkillSyncPreference(
|
const preference = readPaperclipSkillSyncPreference(
|
||||||
agent.adapterConfig as Record<string, unknown>,
|
agent.adapterConfig as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId, {
|
const desiredSkillEntries = preference.desiredSkillEntries.filter(
|
||||||
materializeMissing: false,
|
(entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index,
|
||||||
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);
|
|
||||||
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, desiredSkillEntries));
|
res.json(buildUnsupportedSkillSnapshot(agent.adapterType, desiredSkillEntries));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4117,11 +4117,9 @@ export function companySkillService(db: Db) {
|
|||||||
|
|
||||||
const out: PaperclipSkillEntry[] = [];
|
const out: PaperclipSkillEntry[] = [];
|
||||||
for (const skill of skills) {
|
for (const skill of skills) {
|
||||||
const sourceKind = asString(getSkillMeta(skill).sourceKind);
|
|
||||||
const sourceResolution = await resolveRuntimeSkillSource(companyId, skill, options);
|
const sourceResolution = await resolveRuntimeSkillSource(companyId, skill, options);
|
||||||
if (!sourceResolution) continue;
|
if (!sourceResolution) continue;
|
||||||
|
|
||||||
const required = sourceKind === "paperclip_bundled";
|
|
||||||
out.push({
|
out.push({
|
||||||
key: skill.key,
|
key: skill.key,
|
||||||
runtimeName: buildSkillRuntimeName(skill.key, skill.slug),
|
runtimeName: buildSkillRuntimeName(skill.key, skill.slug),
|
||||||
@@ -4130,10 +4128,6 @@ export function companySkillService(db: Db) {
|
|||||||
currentVersionId: skill.currentVersionId,
|
currentVersionId: skill.currentVersionId,
|
||||||
sourceStatus: sourceResolution.status,
|
sourceStatus: sourceResolution.status,
|
||||||
missingDetail: sourceResolution.status === "missing" ? sourceResolution.detail : null,
|
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,43 +2643,20 @@ export function AgentSkillsTab({
|
|||||||
);
|
);
|
||||||
const optionalSkillRows = useMemo<SkillRow[]>(
|
const optionalSkillRows = useMemo<SkillRow[]>(
|
||||||
() =>
|
() =>
|
||||||
(companySkills ?? [])
|
(companySkills ?? []).map((skill) => ({
|
||||||
.filter((skill) => !adapterEntryByKey.get(skill.key)?.required)
|
id: skill.id,
|
||||||
.map((skill) => ({
|
key: skill.key,
|
||||||
id: skill.id,
|
name: skill.name,
|
||||||
key: skill.key,
|
description: skill.description,
|
||||||
name: skill.name,
|
detail: adapterEntryByKey.get(skill.key)?.detail ?? null,
|
||||||
description: skill.description,
|
locationLabel: adapterEntryByKey.get(skill.key)?.locationLabel ?? null,
|
||||||
detail: adapterEntryByKey.get(skill.key)?.detail ?? null,
|
originLabel: adapterEntryByKey.get(skill.key)?.originLabel ?? null,
|
||||||
locationLabel: adapterEntryByKey.get(skill.key)?.locationLabel ?? null,
|
linkTo: `/skills/${skill.id}`,
|
||||||
originLabel: adapterEntryByKey.get(skill.key)?.originLabel ?? null,
|
readOnly: false,
|
||||||
linkTo: `/skills/${skill.id}`,
|
adapterEntry: adapterEntryByKey.get(skill.key) ?? null,
|
||||||
readOnly: false,
|
})),
|
||||||
adapterEntry: adapterEntryByKey.get(skill.key) ?? null,
|
|
||||||
})),
|
|
||||||
[adapterEntryByKey, companySkills],
|
[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[]>(
|
const unmanagedSkillRows = useMemo<SkillRow[]>(
|
||||||
() =>
|
() =>
|
||||||
(skillSnapshot?.entries ?? [])
|
(skillSnapshot?.entries ?? [])
|
||||||
@@ -2780,8 +2757,6 @@ export function AgentSkillsTab({
|
|||||||
<>
|
<>
|
||||||
{(() => {
|
{(() => {
|
||||||
const renderSkillRow = (skill: SkillRow) => {
|
const renderSkillRow = (skill: SkillRow) => {
|
||||||
const adapterEntry = skill.adapterEntry ?? adapterEntryByKey.get(skill.key);
|
|
||||||
const required = Boolean(adapterEntry?.required);
|
|
||||||
const summaryText = resolveSkillSummaryText(skill, { fallbackKey: true });
|
const summaryText = resolveSkillSummaryText(skill, { fallbackKey: true });
|
||||||
const rowClassName = cn(
|
const rowClassName = cn(
|
||||||
"flex items-start gap-3 border-b border-border px-3 py-3 text-sm last:border-b-0",
|
"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 checked = skillDraft.includes(skill.key);
|
||||||
const disabled = required || skillSnapshot?.mode === "unsupported";
|
const disabled = skillSnapshot?.mode === "unsupported";
|
||||||
const checkbox = (
|
const checkbox = (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -2847,14 +2822,7 @@ export function AgentSkillsTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<label key={skill.id} className={rowClassName}>
|
<label key={skill.id} className={rowClassName}>
|
||||||
{required && adapterEntry?.requiredReason ? (
|
{skillSnapshot?.mode === "unsupported" ? (
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span>{checkbox}</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">{adapterEntry.requiredReason}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : skillSnapshot?.mode === "unsupported" ? (
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span>{checkbox}</span>
|
<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 (
|
return (
|
||||||
<section className="border-y border-border">
|
<section className="border-y border-border">
|
||||||
<div className="px-3 py-6 text-sm text-muted-foreground">
|
<div className="px-3 py-6 text-sm text-muted-foreground">
|
||||||
@@ -2917,7 +2885,6 @@ export function AgentSkillsTab({
|
|||||||
|
|
||||||
{renderSkillSection("Other skills", otherSkillRows)}
|
{renderSkillSection("Other skills", otherSkillRows)}
|
||||||
|
|
||||||
{renderSkillSection("Required by Paperclip", requiredSkillRows)}
|
|
||||||
|
|
||||||
{unmanagedSkillRows.length > 0 && (
|
{unmanagedSkillRows.length > 0 && (
|
||||||
<section className="border-y border-border">
|
<section className="border-y border-border">
|
||||||
|
|||||||
@@ -583,11 +583,9 @@ function buildAcpxClaudeSnapshot(): AgentSkillSnapshot {
|
|||||||
runtimeName: "paperclip",
|
runtimeName: "paperclip",
|
||||||
desired: true,
|
desired: true,
|
||||||
managed: true,
|
managed: true,
|
||||||
required: true,
|
|
||||||
requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.",
|
|
||||||
state: "configured",
|
state: "configured",
|
||||||
origin: "paperclip_required",
|
origin: "company_managed",
|
||||||
originLabel: "Required by Paperclip",
|
originLabel: "Managed by Paperclip",
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
sourcePath: "skills/paperclip",
|
sourcePath: "skills/paperclip",
|
||||||
targetPath: null,
|
targetPath: null,
|
||||||
@@ -598,7 +596,6 @@ function buildAcpxClaudeSnapshot(): AgentSkillSnapshot {
|
|||||||
runtimeName: "design-guide",
|
runtimeName: "design-guide",
|
||||||
desired: true,
|
desired: true,
|
||||||
managed: true,
|
managed: true,
|
||||||
required: false,
|
|
||||||
state: "configured",
|
state: "configured",
|
||||||
origin: "company_managed",
|
origin: "company_managed",
|
||||||
originLabel: "Managed by Paperclip",
|
originLabel: "Managed by Paperclip",
|
||||||
@@ -612,7 +609,6 @@ function buildAcpxClaudeSnapshot(): AgentSkillSnapshot {
|
|||||||
runtimeName: "mobile-app-qa",
|
runtimeName: "mobile-app-qa",
|
||||||
desired: false,
|
desired: false,
|
||||||
managed: true,
|
managed: true,
|
||||||
required: false,
|
|
||||||
state: "available",
|
state: "available",
|
||||||
origin: "company_managed",
|
origin: "company_managed",
|
||||||
originLabel: "Managed by Paperclip",
|
originLabel: "Managed by Paperclip",
|
||||||
@@ -638,11 +634,9 @@ function buildAcpxCodexSnapshot(): AgentSkillSnapshot {
|
|||||||
runtimeName: "paperclip",
|
runtimeName: "paperclip",
|
||||||
desired: true,
|
desired: true,
|
||||||
managed: true,
|
managed: true,
|
||||||
required: true,
|
|
||||||
requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.",
|
|
||||||
state: "configured",
|
state: "configured",
|
||||||
origin: "paperclip_required",
|
origin: "company_managed",
|
||||||
originLabel: "Required by Paperclip",
|
originLabel: "Managed by Paperclip",
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
sourcePath: "skills/paperclip",
|
sourcePath: "skills/paperclip",
|
||||||
targetPath: null,
|
targetPath: null,
|
||||||
@@ -653,7 +647,6 @@ function buildAcpxCodexSnapshot(): AgentSkillSnapshot {
|
|||||||
runtimeName: "design-guide",
|
runtimeName: "design-guide",
|
||||||
desired: false,
|
desired: false,
|
||||||
managed: true,
|
managed: true,
|
||||||
required: false,
|
|
||||||
state: "available",
|
state: "available",
|
||||||
origin: "company_managed",
|
origin: "company_managed",
|
||||||
originLabel: "Managed by Paperclip",
|
originLabel: "Managed by Paperclip",
|
||||||
@@ -667,7 +660,6 @@ function buildAcpxCodexSnapshot(): AgentSkillSnapshot {
|
|||||||
runtimeName: "mobile-app-qa",
|
runtimeName: "mobile-app-qa",
|
||||||
desired: false,
|
desired: false,
|
||||||
managed: true,
|
managed: true,
|
||||||
required: false,
|
|
||||||
state: "available",
|
state: "available",
|
||||||
origin: "company_managed",
|
origin: "company_managed",
|
||||||
originLabel: "Managed by Paperclip",
|
originLabel: "Managed by Paperclip",
|
||||||
@@ -695,11 +687,9 @@ function buildAcpxCustomSnapshot(): AgentSkillSnapshot {
|
|||||||
runtimeName: "paperclip",
|
runtimeName: "paperclip",
|
||||||
desired: false,
|
desired: false,
|
||||||
managed: true,
|
managed: true,
|
||||||
required: true,
|
|
||||||
requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.",
|
|
||||||
state: "available",
|
state: "available",
|
||||||
origin: "paperclip_required",
|
origin: "company_managed",
|
||||||
originLabel: "Required by Paperclip",
|
originLabel: "Managed by Paperclip",
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
sourcePath: "skills/paperclip",
|
sourcePath: "skills/paperclip",
|
||||||
targetPath: null,
|
targetPath: null,
|
||||||
@@ -710,7 +700,6 @@ function buildAcpxCustomSnapshot(): AgentSkillSnapshot {
|
|||||||
runtimeName: "design-guide",
|
runtimeName: "design-guide",
|
||||||
desired: true,
|
desired: true,
|
||||||
managed: true,
|
managed: true,
|
||||||
required: false,
|
|
||||||
state: "configured",
|
state: "configured",
|
||||||
origin: "company_managed",
|
origin: "company_managed",
|
||||||
originLabel: "Managed by Paperclip",
|
originLabel: "Managed by Paperclip",
|
||||||
@@ -725,7 +714,6 @@ function buildAcpxCustomSnapshot(): AgentSkillSnapshot {
|
|||||||
runtimeName: "mobile-app-qa",
|
runtimeName: "mobile-app-qa",
|
||||||
desired: false,
|
desired: false,
|
||||||
managed: true,
|
managed: true,
|
||||||
required: false,
|
|
||||||
state: "available",
|
state: "available",
|
||||||
origin: "company_managed",
|
origin: "company_managed",
|
||||||
originLabel: "Managed by Paperclip",
|
originLabel: "Managed by Paperclip",
|
||||||
|
|||||||
Reference in New Issue
Block a user