fix(adapters): restore Paperclip skill for legacy runners (#12225)
<!-- Write all pull request text in Simplified Technical English (ASD-STE100): short sentences, one instruction per sentence, simple approved vocabulary, and the active voice. --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Legacy local adapters run agents that use the Paperclip skill for the control-plane workflow. > - PR #7029 removed the required-skill fallback and made runtime skill selection depend only on stored preferences. > - No migration or runtime fallback replaced that behavior for existing agents or non-CEO agents. > - PR #12138 added core skills to new CEOs, and PR #12147 added Claude skill discovery. These changes did not mount the operational skill for all legacy agents. > - This pull request makes the operational skill a legacy adapter runtime invariant. It keeps all other skills configurable. > - The native runner stays unchanged because its protocol supplies the control-plane contract. > - The benefit is that new and existing legacy agents can always operate through Paperclip. ## Linked Issues or Issue Description Refs #7029 Refs #12138 Refs #12147 **What happened?** A skill-capable legacy local agent could start without `paperclipai/paperclip/paperclip`. This happened when the agent had no stored skill preference. An explicit empty preference also removed the skill. The agent then reported that the Paperclip skill was not available. **Expected behavior** Every skill-capable legacy local adapter must mount the Paperclip operational skill when the runtime inventory contains it. Optional skills must remain configurable. The native runner must keep its current protocol-based behavior. **Steps to reproduce** 1. Create a non-CEO `codex_local` agent without `paperclipSkillSync` preferences. 2. Start a legacy heartbeat. 3. Inspect the managed `CODEX_HOME/skills` directory. 4. Observe that the Paperclip skill is absent before this change. **Paperclip version or commit** The problem reproduces on `master` before this pull request. PR #7029 introduced the configured-only selection behavior. **Deployment mode** Local development and self-hosted legacy local adapters. ## What Changed - Added a shared legacy skill resolver that always selects the canonical Paperclip operational skill when it is available. - Applied the resolver to direct adapter execution, ACPX execution, skill snapshots, and persistent skill sync. - Added Hermes skill materialization at sync and run boundaries. - Aligned Cursor, Gemini, and OpenCode execution-time injection with the configured child `HOME`. - Made Hermes stop execution when another installation blocks the required operational skill. - Kept optional skills controlled by `paperclipSkillSync.desiredSkills`. - Kept `paperclip_runner` on the configurable-only resolver. - Added regression coverage for missing preferences, empty preferences, each skill-capable legacy adapter, ACPX, Hermes, and native runner isolation. - Documented the legacy runtime invariant. ## Verification - `pnpm -r typecheck` passed on the pushed commit. - `pnpm build` passed on the pushed commit. - The adapter utility regression suites passed: 236 tests. - The changed server adapter suites passed: 48 tests across 12 files. - The OpenCode adapter suite passed: 8 tests. - The Hermes adapter suite passed: 7 tests. - `git diff --check` passed. - `pnpm test:run` is not clean on this macOS host. The command reported failures in unchanged workspace and filesystem suites. An isolated rerun of `company-skills.test.ts` and `company-skills-service.test.ts` reproduced 11 failures because macOS resolved `/var/...` paths as `/private/var/...`. The changed adapter suites pass independently. ## Risks - This change deliberately makes the operational skill non-removable for skill-capable legacy local adapters. - Existing agents receive the skill on their next list, sync, or run boundary. No database migration is required. - The resolver does not create a skill when the runtime inventory does not contain the canonical entry. - Hermes aborts a run if another installation occupies the required operational skill target. - Hermes removes only an undesired Paperclip-owned symlink that still points to the known Paperclip source. - The native runner does not receive the legacy default. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex based on GPT-5. The exact serving model ID and context window were not exposed. The agent used reasoning, tool use, and code execution. ## 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) - [x] 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 - [x] All Paperclip CI gates are green - [x] 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:
parent
6524d2b67f
commit
a9d0927fe8
|
|
@ -756,6 +756,15 @@ Server and CLI import the generated manifest; they do not crawl repository
|
|||
paths at request time. Root `skills/` remains reserved for Paperclip runtime
|
||||
skills and is not part of the catalog.
|
||||
|
||||
Skill-capable legacy local adapters always select the bundled
|
||||
`paperclipai/paperclip/paperclip` operational skill when it is present in the
|
||||
runtime inventory. This applies to existing agents without a stored skill
|
||||
preference and to explicit empty optional-skill selections. The operational
|
||||
skill supplies the control-plane workflow that those adapters need for
|
||||
heartbeats. Other runtime skills remain controlled by
|
||||
`paperclipSkillSync.desiredSkills`. The native `paperclip_runner` does not use
|
||||
this legacy default because its protocol supplies the control-plane contract.
|
||||
|
||||
Validate the catalog without writing the manifest:
|
||||
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -1182,6 +1182,26 @@ describe("shared ACPX engine runtime behavior", () => {
|
|||
expect(await pathExists(path.join(codexHome, "skills", remove.runtimeName))).toBe(false);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("keeps the operational skill in an ACPX Codex home after an empty replacement", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const skillRoot = path.join(root, "skills");
|
||||
const codexHome = path.join(root, "codex-home");
|
||||
const operational = {
|
||||
...await createSkill(skillRoot, "paperclip"),
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
};
|
||||
|
||||
await runExecutor({
|
||||
agent: "codex",
|
||||
stateDir: path.join(root, "state"),
|
||||
env: { CODEX_HOME: codexHome },
|
||||
paperclipRuntimeSkills: [operational],
|
||||
paperclipSkillSync: { desiredSkills: [] },
|
||||
});
|
||||
|
||||
expect(await pathExists(path.join(codexHome, "skills", operational.runtimeName, "SKILL.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")("removes legacy ACPX Codex skill symlinks when a skill is no longer desired", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const skillRoot = path.join(root, "skills");
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ import {
|
|||
renderTemplate,
|
||||
resolvePaperclipInstanceRootForAdapter,
|
||||
selectPaperclipTaskMarkdown,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
rewriteWorkspaceCwdEnvVarsForExecution,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
|
|
@ -910,7 +910,7 @@ async function resolveSelectedRuntimeSkills(
|
|||
moduleDir: string,
|
||||
): Promise<{ allSkills: PaperclipSkillEntry[]; selectedSkills: PaperclipSkillEntry[]; desiredSkillNames: string[] }> {
|
||||
const allSkills = await readPaperclipRuntimeSkillEntries(config, moduleDir);
|
||||
const desiredSkillNames = resolvePaperclipDesiredSkillNames(config, allSkills);
|
||||
const desiredSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, allSkills);
|
||||
const desiredSet = new Set(desiredSkillNames);
|
||||
return {
|
||||
allSkills,
|
||||
|
|
@ -1852,7 +1852,7 @@ async function buildRuntime(input: {
|
|||
skillsIdentity = preparedSkills.identity;
|
||||
skillCommandNotes.push(...preparedSkills.commandNotes);
|
||||
} else {
|
||||
const desired = resolvePaperclipDesiredSkillNames(
|
||||
const desired = resolveLegacyPaperclipDesiredSkillNames(
|
||||
config,
|
||||
await readPaperclipRuntimeSkillEntries(config, input.engine.moduleDir),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@ import {
|
|||
buildPaperclipEnv,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
materializePaperclipSkillCopy,
|
||||
PAPERCLIP_OPERATIONAL_SKILL_KEY,
|
||||
refreshPaperclipWorkspaceEnvForExecution,
|
||||
renderPaperclipWakePrompt,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
selectPaperclipTaskMarkdown,
|
||||
runningProcesses,
|
||||
runChildProcess,
|
||||
|
|
@ -28,6 +31,45 @@ import {
|
|||
WATCHDOG_DEFAULT_MANDATE,
|
||||
} from "./server-utils.js";
|
||||
|
||||
describe("legacy adapter skill selection", () => {
|
||||
const operationalEntry = {
|
||||
key: PAPERCLIP_OPERATIONAL_SKILL_KEY,
|
||||
runtimeName: "paperclip",
|
||||
};
|
||||
const optionalEntry = {
|
||||
key: "company/example/reviewer",
|
||||
runtimeName: "reviewer",
|
||||
};
|
||||
|
||||
it("keeps the operational skill selected without a stored preference", () => {
|
||||
expect(resolveLegacyPaperclipDesiredSkillNames({}, [operationalEntry, optionalEntry])).toEqual([
|
||||
PAPERCLIP_OPERATIONAL_SKILL_KEY,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the operational skill selected after an explicit empty replacement", () => {
|
||||
expect(resolveLegacyPaperclipDesiredSkillNames(
|
||||
{ paperclipSkillSync: { desiredSkills: [] } },
|
||||
[operationalEntry, optionalEntry],
|
||||
)).toEqual([PAPERCLIP_OPERATIONAL_SKILL_KEY]);
|
||||
});
|
||||
|
||||
it("does not force optional skills or synthesize a missing operational entry", () => {
|
||||
const config = { paperclipSkillSync: { desiredSkills: [optionalEntry.key] } };
|
||||
expect(resolveLegacyPaperclipDesiredSkillNames(config, [operationalEntry, optionalEntry])).toEqual([
|
||||
PAPERCLIP_OPERATIONAL_SKILL_KEY,
|
||||
optionalEntry.key,
|
||||
]);
|
||||
expect(resolveLegacyPaperclipDesiredSkillNames(config, [optionalEntry])).toEqual([
|
||||
optionalEntry.key,
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the configurable resolver available for native runners", () => {
|
||||
expect(resolvePaperclipDesiredSkillNames({}, [operationalEntry])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function isPidAlive(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
|
|
|
|||
|
|
@ -2989,6 +2989,33 @@ export function resolvePaperclipDesiredSkillNames(
|
|||
return Array.from(new Set(desiredSkills));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy adapters call the Paperclip API through the operational skill. Keep
|
||||
* that skill mounted even when an agent predates skill preferences or carries
|
||||
* an explicit empty desired set. Native runners provide the same authority
|
||||
* through their protocol and must continue to use the configurable-only
|
||||
* resolver above.
|
||||
*/
|
||||
export const PAPERCLIP_OPERATIONAL_SKILL_KEY = "paperclipai/paperclip/paperclip";
|
||||
|
||||
export function resolveLegacyPaperclipDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; runtimeName?: string | null }>,
|
||||
): string[] {
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const operationalEntry = availableEntries.find(
|
||||
(entry) => entry.key.trim().toLowerCase() === PAPERCLIP_OPERATIONAL_SKILL_KEY,
|
||||
);
|
||||
if (!operationalEntry) return desiredSkills;
|
||||
|
||||
return [
|
||||
operationalEntry.key,
|
||||
...desiredSkills.filter(
|
||||
(key) => key.trim().toLowerCase() !== PAPERCLIP_OPERATIONAL_SKILL_KEY,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function writePaperclipSkillSyncPreference(
|
||||
config: Record<string, unknown>,
|
||||
desiredSkills: Array<string | PaperclipDesiredSkillEntry>,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
buildRuntimeMountedSkillSnapshot,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readInstalledSkillTargets,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -30,7 +30,7 @@ function resolveClaudeSkillsHome(config: Record<string, unknown>) {
|
|||
|
||||
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
const skillsHome = resolveClaudeSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
return buildRuntimeMountedSkillSnapshot({
|
||||
|
|
@ -60,5 +60,5 @@ export function resolveClaudeDesiredSkillNames(
|
|||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ import {
|
|||
isPaperclipSkillSourceMissing,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
import {
|
||||
buildRuntimeMountedSkillSnapshot,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
|
|
@ -14,11 +15,14 @@ const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|||
|
||||
async function buildCodexSkillSnapshot(
|
||||
config: Record<string, unknown>,
|
||||
adapterType: string,
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSkills = adapterType === "paperclip_runner"
|
||||
? resolvePaperclipDesiredSkillNames(config, availableEntries)
|
||||
: resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
return buildRuntimeMountedSkillSnapshot({
|
||||
adapterType: "codex_local",
|
||||
adapterType,
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
configuredDetail: "Will be linked into the effective CODEX_HOME/skills/ directory on the next run.",
|
||||
|
|
@ -26,19 +30,19 @@ async function buildCodexSkillSnapshot(
|
|||
}
|
||||
|
||||
export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildCodexSkillSnapshot(ctx.config);
|
||||
return buildCodexSkillSnapshot(ctx.config, ctx.adapterType);
|
||||
}
|
||||
|
||||
export async function syncCodexSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
_desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
return buildCodexSkillSnapshot(ctx.config);
|
||||
return buildCodexSkillSnapshot(ctx.config, ctx.adapterType);
|
||||
}
|
||||
|
||||
export function resolveCodexDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import {
|
|||
isPaperclipSkillSourceMissing,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
|
|
@ -53,6 +53,7 @@ import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
|||
import { prepareCursorSandboxCommand } from "./remote-command.js";
|
||||
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
||||
import { hasCursorTrustBypassArg } from "../shared/trust.js";
|
||||
import { resolveCursorSkillsHome } from "./skills.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -117,16 +118,12 @@ function renderPaperclipEnvNote(env: Record<string, string>): string {
|
|||
].join("\n");
|
||||
}
|
||||
|
||||
function cursorSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".cursor", "skills");
|
||||
}
|
||||
|
||||
async function buildCursorSkillsDir(config: Record<string, unknown>): Promise<string> {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-skills-"));
|
||||
const target = path.join(tmp, "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries));
|
||||
const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries));
|
||||
for (const entry of availableEntries) {
|
||||
if (!desiredNames.has(entry.key)) continue;
|
||||
if (isPaperclipSkillSourceMissing(entry)) continue;
|
||||
|
|
@ -158,7 +155,7 @@ export async function ensureCursorSkillsInjected(
|
|||
: await readPaperclipRuntimeSkillEntries({}, __moduleDir));
|
||||
if (skillsEntries.length === 0) return;
|
||||
|
||||
const skillsHome = options.skillsHome ?? cursorSkillsHome();
|
||||
const skillsHome = options.skillsHome ?? resolveCursorSkillsHome({});
|
||||
try {
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
} catch (err) {
|
||||
|
|
@ -233,12 +230,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
|
||||
const desiredCursorSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, cursorSkillEntries);
|
||||
if (!executionTargetIsRemote) {
|
||||
await ensureCursorSkillsInjected(onLog, {
|
||||
skillsEntries: cursorSkillEntries.filter(
|
||||
(entry) => desiredCursorSkillNames.includes(entry.key) && !isPaperclipSkillSourceMissing(entry),
|
||||
),
|
||||
skillsHome: resolveCursorSkillsHome(config),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
ensurePaperclipSkillSymlink,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readInstalledSkillTargets,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -20,7 +20,7 @@ function asString(value: unknown): string | null {
|
|||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveCursorSkillsHome(config: Record<string, unknown>) {
|
||||
export function resolveCursorSkillsHome(config: Record<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
|
|
@ -32,7 +32,7 @@ function resolveCursorSkillsHome(config: Record<string, unknown>) {
|
|||
|
||||
async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
const skillsHome = resolveCursorSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
return buildPersistentSkillSnapshot({
|
||||
|
|
@ -57,7 +57,10 @@ export async function syncCursorSkills(
|
|||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const desiredSet = new Set([
|
||||
...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries),
|
||||
...desiredSkills,
|
||||
]);
|
||||
const skillsHome = resolveCursorSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
|
|
@ -84,5 +87,5 @@ export function resolveCursorDesiredSkillNames(
|
|||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import {
|
|||
isPaperclipSkillSourceMissing,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
parseObject,
|
||||
renderTemplate,
|
||||
|
|
@ -66,6 +66,7 @@ import {
|
|||
formatGeminiAcpFallbackMessage,
|
||||
resolveGeminiExecutionEngineForRun,
|
||||
} from "./acp.js";
|
||||
import { resolveGeminiSkillsHome } from "./skills.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const executeGeminiAcp = createGeminiAcpExecutor();
|
||||
|
|
@ -132,25 +133,21 @@ function renderApiAccessNote(env: Record<string, string>): string {
|
|||
].join("\n");
|
||||
}
|
||||
|
||||
function geminiSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".gemini", "skills");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject Paperclip skills directly into `~/.gemini/skills/` via symlinks.
|
||||
* Inject Paperclip skills directly into the effective Gemini skills home.
|
||||
* This avoids needing GEMINI_CLI_HOME overrides, so the CLI naturally finds
|
||||
* both its auth credentials and the injected skills in the real home directory.
|
||||
* both its auth credentials and the injected skills under the child HOME.
|
||||
*/
|
||||
async function ensureGeminiSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
|
||||
desiredSkillNames?: string[],
|
||||
skillsHome = resolveGeminiSkillsHome({}),
|
||||
): Promise<void> {
|
||||
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
|
||||
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
|
||||
if (selectedEntries.length === 0) return;
|
||||
|
||||
const skillsHome = geminiSkillsHome();
|
||||
try {
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
} catch (err) {
|
||||
|
|
@ -197,7 +194,7 @@ async function buildGeminiSkillsDir(
|
|||
const target = path.join(tmp, "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries));
|
||||
const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries));
|
||||
for (const entry of availableEntries) {
|
||||
if (!desiredNames.has(entry.key)) continue;
|
||||
if (isPaperclipSkillSourceMissing(entry)) continue;
|
||||
|
|
@ -258,12 +255,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries);
|
||||
const desiredGeminiSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, geminiSkillEntries);
|
||||
if (!executionTargetIsRemote) {
|
||||
await ensureGeminiSkillsInjected(
|
||||
onLog,
|
||||
geminiSkillEntries.filter((entry) => !isPaperclipSkillSourceMissing(entry)),
|
||||
desiredGeminiSkillNames,
|
||||
resolveGeminiSkillsHome(config),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
ensurePaperclipSkillSymlink,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readInstalledSkillTargets,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -20,7 +20,7 @@ function asString(value: unknown): string | null {
|
|||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveGeminiSkillsHome(config: Record<string, unknown>) {
|
||||
export function resolveGeminiSkillsHome(config: Record<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
|
|
@ -32,7 +32,7 @@ function resolveGeminiSkillsHome(config: Record<string, unknown>) {
|
|||
|
||||
async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
const skillsHome = resolveGeminiSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
return buildPersistentSkillSnapshot({
|
||||
|
|
@ -57,7 +57,10 @@ export async function syncGeminiSkills(
|
|||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const desiredSet = new Set([
|
||||
...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries),
|
||||
...desiredSkills,
|
||||
]);
|
||||
const skillsHome = resolveGeminiSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
|
|
@ -84,5 +87,5 @@ export function resolveGeminiDesiredSkillNames(
|
|||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
stringifyPaperclipWakePayload,
|
||||
refreshPaperclipWorkspaceEnvForExecution,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
|
|
@ -233,7 +233,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
const grokSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredGrokSkillNames = resolvePaperclipDesiredSkillNames(config, grokSkillEntries);
|
||||
const desiredGrokSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, grokSkillEntries);
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const stagedAssets = await stageGrokProjectAssets({
|
||||
cwd,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
import {
|
||||
buildRuntimeMountedSkillSnapshot,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -16,7 +16,7 @@ async function buildGrokSkillSnapshot(
|
|||
config: Record<string, unknown>,
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
return buildRuntimeMountedSkillSnapshot({
|
||||
adapterType: "grok_local",
|
||||
availableEntries,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
|
|
@ -61,3 +64,93 @@ test("Hermes adapter exposes bundled Paperclip task bridge skill", async () => {
|
|||
|
||||
expect(snapshot?.entries.some((entry) => entry.runtimeName === "paperclip-task-bridge")).toBe(true);
|
||||
});
|
||||
|
||||
test("Hermes keeps the operational Paperclip skill linked after an empty replacement", async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-core-skill-"));
|
||||
try {
|
||||
const source = path.join(home, "runtime-skills", "paperclip");
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.writeFile(path.join(source, "SKILL.md"), "# Paperclip\n", "utf8");
|
||||
const adapter = createServerAdapter();
|
||||
const snapshot = await adapter.syncSkills?.({
|
||||
adapterType: "hermes_local",
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
config: {
|
||||
env: { HOME: home },
|
||||
paperclipRuntimeSkills: [{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source,
|
||||
}],
|
||||
paperclipSkillSync: { desiredSkills: [] },
|
||||
},
|
||||
}, []);
|
||||
|
||||
expect(snapshot?.desiredSkills).toContain("paperclipai/paperclip/paperclip");
|
||||
expect((await fs.lstat(path.join(home, ".hermes", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
} finally {
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Hermes rejects a conflicting operational skill target", async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-core-conflict-"));
|
||||
try {
|
||||
const source = path.join(home, "runtime-skills", "paperclip");
|
||||
const target = path.join(home, ".hermes", "skills", "paperclip");
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.writeFile(path.join(source, "SKILL.md"), "# Paperclip\n", "utf8");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
await fs.writeFile(path.join(target, "SKILL.md"), "# Conflicting skill\n", "utf8");
|
||||
const adapter = createServerAdapter();
|
||||
|
||||
await expect(adapter.syncSkills?.({
|
||||
adapterType: "hermes_local",
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
config: {
|
||||
env: { HOME: home },
|
||||
paperclipRuntimeSkills: [{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source,
|
||||
}],
|
||||
},
|
||||
}, [])).rejects.toThrow("occupied by another installation");
|
||||
} finally {
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Hermes rejects a live symlink owned by another operational skill", async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-hermes-core-link-conflict-"));
|
||||
try {
|
||||
const source = path.join(home, "runtime-skills", "paperclip");
|
||||
const conflictingSource = path.join(home, "external-skills", "paperclip");
|
||||
const target = path.join(home, ".hermes", "skills", "paperclip");
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.writeFile(path.join(source, "SKILL.md"), "# Paperclip\n", "utf8");
|
||||
await fs.mkdir(conflictingSource, { recursive: true });
|
||||
await fs.writeFile(path.join(conflictingSource, "SKILL.md"), "# External skill\n", "utf8");
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.symlink(conflictingSource, target);
|
||||
const adapter = createServerAdapter();
|
||||
|
||||
await expect(adapter.syncSkills?.({
|
||||
adapterType: "hermes_local",
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
config: {
|
||||
env: { HOME: home },
|
||||
paperclipRuntimeSkills: [{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source,
|
||||
}],
|
||||
},
|
||||
}, [])).rejects.toThrow("occupied by another installation");
|
||||
} finally {
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import {
|
|||
detectModel,
|
||||
resolveProvider,
|
||||
} from "./detect-model.js";
|
||||
import { reconcileHermesPaperclipSkills } from "./skills.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config helpers
|
||||
|
|
@ -350,6 +351,25 @@ export async function execute(
|
|||
(ctx.runtime?.sessionParams as Record<string, unknown> | null)?.sessionId,
|
||||
);
|
||||
|
||||
// The server adds this runtime inventory at the run boundary. Requiring the
|
||||
// marker avoids touching a developer's real Hermes home in direct unit or
|
||||
// library calls that did not opt into Paperclip runtime skills.
|
||||
if (Object.prototype.hasOwnProperty.call(config, "paperclipRuntimeSkills")) {
|
||||
try {
|
||||
const selectedSkills = await reconcileHermesPaperclipSkills(config);
|
||||
if (selectedSkills.length > 0) {
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[hermes] Reconciled ${selectedSkills.length} Paperclip-managed skill(s) into the Hermes skills home.\n`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await ctx.onLog("stderr", `[hermes] Cannot start without the required Paperclip-managed skills: ${reason}\n`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolve provider (defense in depth) ────────────────────────────────
|
||||
// Priority chain:
|
||||
// 1. Explicit provider in adapterConfig (user override)
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ import type {
|
|||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
ensurePaperclipSkillSymlink,
|
||||
isPaperclipSkillSourceMissing,
|
||||
readInstalledSkillTargets,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
|
|
@ -132,7 +135,7 @@ async function buildHermesSkillSnapshot(config: Record<string, unknown>): Promis
|
|||
|
||||
// 1. Scan Paperclip-managed skills (bundled with the adapter)
|
||||
const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, paperclipEntries);
|
||||
const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, paperclipEntries);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const availableByKey = new Map(paperclipEntries.map((e) => [e.key, e]));
|
||||
|
||||
|
|
@ -209,12 +212,53 @@ export async function listHermesSkills(
|
|||
return buildHermesSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function reconcileHermesPaperclipSkills(
|
||||
config: Record<string, unknown>,
|
||||
requestedDesiredSkills?: string[],
|
||||
): Promise<string[]> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = requestedDesiredSkills
|
||||
? Array.from(new Set([
|
||||
...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries),
|
||||
...requestedDesiredSkills,
|
||||
]))
|
||||
: resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = path.join(resolveHermesHome(config), ".hermes", "skills");
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry]));
|
||||
|
||||
for (const entry of availableEntries) {
|
||||
if (!desiredSet.has(entry.key) || isPaperclipSkillSourceMissing(entry)) continue;
|
||||
const target = path.join(skillsHome, entry.runtimeName);
|
||||
await ensurePaperclipSkillSymlink(entry.source, target);
|
||||
const linkedSource = await fs.readlink(target).catch(() => null);
|
||||
const resolvedSource = linkedSource
|
||||
? path.resolve(path.dirname(target), linkedSource)
|
||||
: null;
|
||||
if (resolvedSource !== path.resolve(entry.source)) {
|
||||
throw new Error(
|
||||
`Cannot reconcile Hermes skill "${entry.key}" because ${target} is occupied by another installation.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
const available = availableByRuntimeName.get(name);
|
||||
if (!available || desiredSet.has(available.key)) continue;
|
||||
if (installedEntry.targetPath !== available.source) continue;
|
||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||
}
|
||||
|
||||
return desiredSkills;
|
||||
}
|
||||
|
||||
export async function syncHermesSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
_desiredSkills: string[],
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
// Hermes manages its own skill loading — sync is a no-op.
|
||||
// Return the current snapshot so the UI stays in sync.
|
||||
await reconcileHermesPaperclipSkills(ctx.config, desiredSkills);
|
||||
return buildHermesSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
|
|
@ -222,5 +266,5 @@ export function resolveHermesDesiredSkillNames(
|
|||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; runtimeName?: string | null }>,
|
||||
): string[] {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
isPaperclipSkillSourceMissing,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
parseObject,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
|
|
@ -174,7 +174,7 @@ async function buildKimiSkillsDir(
|
|||
const target = path.join(tmp, "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries));
|
||||
const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries));
|
||||
for (const entry of availableEntries) {
|
||||
if (!desiredNames.has(entry.key)) continue;
|
||||
if (isPaperclipSkillSourceMissing(entry)) continue;
|
||||
|
|
@ -232,7 +232,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const kimiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredKimiSkillNames = resolvePaperclipDesiredSkillNames(config, kimiSkillEntries);
|
||||
const desiredKimiSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, kimiSkillEntries);
|
||||
const envConfig = parseObject(config.env);
|
||||
|
||||
const hasExplicitApiKey =
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
ensurePaperclipSkillSymlink,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readInstalledSkillTargets,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -38,7 +38,7 @@ function resolveKimiSkillsHome(config: Record<string, unknown>) {
|
|||
|
||||
async function buildKimiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
const skillsHome = resolveKimiSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
return buildPersistentSkillSnapshot({
|
||||
|
|
@ -63,7 +63,10 @@ export async function syncKimiSkills(
|
|||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const desiredSet = new Set([
|
||||
...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries),
|
||||
...desiredSkills,
|
||||
]);
|
||||
const skillsHome = resolveKimiSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, runAdapterExecutionTargetProcess: vi.fn() };
|
||||
});
|
||||
|
||||
import { ensureRemoteOpenCodeModelConfiguredAndAvailable } from "./execute.js";
|
||||
import { ensureRemoteOpenCodeModelConfiguredAndAvailable, execute } from "./execute.js";
|
||||
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
const runProcessMock = vi.mocked(runAdapterExecutionTargetProcess);
|
||||
|
||||
async function createSkillDir(root: string, name: string): Promise<string> {
|
||||
const skillDir = path.join(root, name);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), `# ${name}\n`, "utf8");
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
function probeResult(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
exitCode: 0,
|
||||
|
|
@ -23,6 +33,78 @@ function probeResult(overrides: Record<string, unknown>) {
|
|||
} as never;
|
||||
}
|
||||
|
||||
describe("OpenCode local skill injection", () => {
|
||||
it("injects runtime skills into the configured child HOME", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-configured-home-"));
|
||||
const processHome = path.join(root, "process-home");
|
||||
const configuredHome = path.join(root, "configured-home");
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "opencode");
|
||||
const skillSource = await createSkillDir(path.join(root, "runtime-skills"), "paperclip");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(commandPath, "#!/bin/sh\nexit 0\n", "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = processHome;
|
||||
runProcessMock.mockReset();
|
||||
runProcessMock.mockResolvedValueOnce(probeResult({
|
||||
stdout: JSON.stringify({
|
||||
type: "text",
|
||||
sessionID: "session-configured-home",
|
||||
part: { text: "done" },
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-configured-home",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "OpenCode Coder",
|
||||
adapterType: "opencode_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
model: "openai/gpt-5",
|
||||
env: {
|
||||
HOME: configuredHome,
|
||||
OPENCODE_ALLOW_ALL_MODELS: "1",
|
||||
},
|
||||
paperclipRuntimeSkills: [{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source: skillSource,
|
||||
}],
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const installedSkill = path.join(configuredHome, ".claude", "skills", "paperclip");
|
||||
expect((await fs.lstat(installedSkill)).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.realpath(installedSkill)).toBe(await fs.realpath(skillSource));
|
||||
await expect(fs.lstat(path.join(processHome, ".claude", "skills", "paperclip"))).rejects.toThrow();
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureRemoteOpenCodeModelConfiguredAndAvailable", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.OPENCODE_ALLOW_ALL_MODELS;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import {
|
|||
isPaperclipSkillSourceMissing,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
||||
import {
|
||||
|
|
@ -58,6 +58,7 @@ import {
|
|||
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
import { resolveOpenCodeSkillsHome } from "./skills.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -165,16 +166,12 @@ export async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: {
|
|||
}
|
||||
}
|
||||
|
||||
function claudeSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".claude", "skills");
|
||||
}
|
||||
|
||||
async function ensureOpenCodeSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
|
||||
desiredSkillNames?: string[],
|
||||
skillsHome = resolveOpenCodeSkillsHome({}),
|
||||
) {
|
||||
const skillsHome = claudeSkillsHome();
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
|
||||
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
|
||||
|
|
@ -212,7 +209,7 @@ async function buildOpenCodeSkillsDir(config: Record<string, unknown>): Promise<
|
|||
const target = path.join(tmp, "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries));
|
||||
const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries));
|
||||
for (const entry of availableEntries) {
|
||||
if (!desiredNames.has(entry.key)) continue;
|
||||
if (isPaperclipSkillSourceMissing(entry)) continue;
|
||||
|
|
@ -256,12 +253,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries);
|
||||
const desiredOpenCodeSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, openCodeSkillEntries);
|
||||
if (!executionTargetIsRemote) {
|
||||
await ensureOpenCodeSkillsInjected(
|
||||
onLog,
|
||||
openCodeSkillEntries,
|
||||
desiredOpenCodeSkillNames,
|
||||
resolveOpenCodeSkillsHome(config),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
ensurePaperclipSkillSymlink,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readInstalledSkillTargets,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -20,7 +20,7 @@ function asString(value: unknown): string | null {
|
|||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveOpenCodeSkillsHome(config: Record<string, unknown>) {
|
||||
export function resolveOpenCodeSkillsHome(config: Record<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
|
|
@ -32,7 +32,7 @@ function resolveOpenCodeSkillsHome(config: Record<string, unknown>) {
|
|||
|
||||
async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
const skillsHome = resolveOpenCodeSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
return buildPersistentSkillSnapshot({
|
||||
|
|
@ -61,7 +61,10 @@ export async function syncOpenCodeSkills(
|
|||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const desiredSet = new Set([
|
||||
...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries),
|
||||
...desiredSkills,
|
||||
]);
|
||||
const skillsHome = resolveOpenCodeSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
|
|
@ -88,5 +91,5 @@ export function resolveOpenCodeDesiredSkillNames(
|
|||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import {
|
|||
isPaperclipSkillSourceMissing,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
|
|
@ -127,7 +127,7 @@ async function buildPiSkillsDir(config: Record<string, unknown>): Promise<string
|
|||
const target = path.join(tmp, "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries));
|
||||
const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries));
|
||||
for (const entry of availableEntries) {
|
||||
if (!desiredNames.has(entry.key)) continue;
|
||||
if (isPaperclipSkillSourceMissing(entry)) continue;
|
||||
|
|
@ -261,7 +261,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
}
|
||||
|
||||
const piSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredPiSkillNames = resolvePaperclipDesiredSkillNames(config, piSkillEntries);
|
||||
const desiredPiSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, piSkillEntries);
|
||||
if (!executionTargetIsRemote) {
|
||||
await ensurePiSkillsInjected(onLog, piSkillEntries, desiredPiSkillNames);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
ensurePaperclipSkillSymlink,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readInstalledSkillTargets,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -32,7 +32,7 @@ function resolvePiSkillsHome(config: Record<string, unknown>) {
|
|||
|
||||
async function buildPiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
const skillsHome = resolvePiSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
return buildPersistentSkillSnapshot({
|
||||
|
|
@ -57,7 +57,10 @@ export async function syncPiSkills(
|
|||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const desiredSet = new Set([
|
||||
...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries),
|
||||
...desiredSkills,
|
||||
]);
|
||||
const skillsHome = resolvePiSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
|
|
@ -84,5 +87,5 @@ export function resolvePiDesiredSkillNames(
|
|||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ describe("claude local skill sync", () => {
|
|||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports built-in Paperclip skills as available when no explicit selection exists", async () => {
|
||||
it("keeps the operational Paperclip skill configured when no explicit selection exists", async () => {
|
||||
const snapshot = await listClaudeSkills({
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
|
|
@ -38,8 +38,9 @@ describe("claude local skill sync", () => {
|
|||
|
||||
expect(snapshot.mode).toBe("ephemeral");
|
||||
expect(snapshot.supported).toBe(true);
|
||||
expect(snapshot.desiredSkills).toEqual([]);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("available");
|
||||
expect(snapshot.desiredSkills).toEqual([paperclipKey]);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
|
||||
expect(snapshot.entries.find((entry) => entry.key === createAgentKey)?.state).toBe("available");
|
||||
});
|
||||
|
||||
it("respects an explicit desired skill list without mutating a persistent home", async () => {
|
||||
|
|
|
|||
|
|
@ -1322,7 +1322,7 @@ process.exit(1);
|
|||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => {
|
||||
it("uses a worktree-isolated CODEX_HOME and mounts the operational skill by default", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
|
|
@ -1380,9 +1380,6 @@ process.exit(1);
|
|||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: ["paperclip"],
|
||||
},
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ describe("codex local skill sync", () => {
|
|||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills for workspace injection on the next run", async () => {
|
||||
it("defaults the operational Paperclip skill for workspace injection on the next run", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-skill-sync-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
||||
|
|
@ -32,9 +32,6 @@ describe("codex local skill sync", () => {
|
|||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
@ -45,6 +42,19 @@ describe("codex local skill sync", () => {
|
|||
expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("CODEX_HOME/skills/");
|
||||
});
|
||||
|
||||
it("does not apply the legacy operational skill default to the native runner", async () => {
|
||||
const snapshot = await listCodexSkills({
|
||||
agentId: "agent-native",
|
||||
companyId: "company-1",
|
||||
adapterType: "paperclip_runner",
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(snapshot.adapterType).toBe("paperclip_runner");
|
||||
expect(snapshot.desiredSkills).toEqual([]);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("available");
|
||||
});
|
||||
|
||||
it("does not persist Paperclip skills into CODEX_HOME during sync", async () => {
|
||||
const codexHome = await makeTempDir("paperclip-codex-skill-prune-");
|
||||
cleanupDirs.add(codexHome);
|
||||
|
|
|
|||
|
|
@ -252,6 +252,8 @@ describe("cursor execute", () => {
|
|||
|
||||
it("injects company-library runtime skills into the Cursor skills home before execution", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-execute-runtime-skill-"));
|
||||
const processHome = path.join(root, "process-home");
|
||||
const configuredHome = path.join(root, "configured-home");
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "agent");
|
||||
const runtimeSkillsRoot = path.join(root, "runtime-skills");
|
||||
|
|
@ -262,7 +264,7 @@ describe("cursor execute", () => {
|
|||
const asciiHeartDir = await createSkillDir(runtimeSkillsRoot, "ascii-heart");
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
process.env.HOME = processHome;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
|
|
@ -284,6 +286,7 @@ describe("cursor execute", () => {
|
|||
command: commandPath,
|
||||
cwd: workspace,
|
||||
model: "auto",
|
||||
env: { HOME: configuredHome },
|
||||
paperclipRuntimeSkills: [
|
||||
{
|
||||
name: "paperclip",
|
||||
|
|
@ -307,10 +310,12 @@ describe("cursor execute", () => {
|
|||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect((await fs.lstat(path.join(root, ".cursor", "skills", "ascii-heart"))).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.realpath(path.join(root, ".cursor", "skills", "ascii-heart"))).toBe(
|
||||
const installedSkill = path.join(configuredHome, ".cursor", "skills", "ascii-heart");
|
||||
expect((await fs.lstat(installedSkill)).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.realpath(installedSkill)).toBe(
|
||||
await fs.realpath(asciiHeartDir),
|
||||
);
|
||||
await expect(fs.lstat(path.join(processHome, ".cursor", "skills", "ascii-heart"))).rejects.toThrow();
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ describe("cursor local skill sync", () => {
|
|||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the Cursor skills home", async () => {
|
||||
it("defaults and installs the operational Paperclip skill in the Cursor skills home", async () => {
|
||||
const home = await makeTempDir("paperclip-cursor-skill-sync-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
|
|
@ -39,9 +39,6 @@ describe("cursor local skill sync", () => {
|
|||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
@ -55,6 +52,25 @@ describe("cursor local skill sync", () => {
|
|||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the operational skill installed after an explicit empty replacement", async () => {
|
||||
const home = await makeTempDir("paperclip-cursor-required-skill-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
const snapshot = await syncCursorSkills({
|
||||
agentId: "agent-required",
|
||||
companyId: "company-1",
|
||||
adapterType: "cursor",
|
||||
config: {
|
||||
env: { HOME: home },
|
||||
paperclipSkillSync: { desiredSkills: [] },
|
||||
},
|
||||
}, []);
|
||||
|
||||
expect(snapshot.desiredSkills).toEqual([paperclipKey]);
|
||||
expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("installed");
|
||||
expect((await fs.lstat(path.join(home, ".cursor", "skills", "paperclip"))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes company-library runtime skills supplied outside the bundled Paperclip directory", async () => {
|
||||
const home = await makeTempDir("paperclip-cursor-runtime-skills-home-");
|
||||
const runtimeSkills = await makeTempDir("paperclip-cursor-runtime-skills-src-");
|
||||
|
|
|
|||
|
|
@ -73,7 +73,72 @@ type CapturePayload = {
|
|||
paperclipEnvKeys: string[];
|
||||
};
|
||||
|
||||
async function createSkillDir(root: string, name: string): Promise<string> {
|
||||
const skillDir = path.join(root, name);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, "SKILL.md"), `# ${name}\n`, "utf8");
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
describe("gemini execute", () => {
|
||||
it("injects runtime skills into the configured child HOME", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-configured-home-"));
|
||||
const processHome = path.join(root, "process-home");
|
||||
const configuredHome = path.join(root, "configured-home");
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "gemini");
|
||||
const skillSource = await createSkillDir(path.join(root, "runtime-skills"), "paperclip");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeGeminiCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = processHome;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-configured-home",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Gemini Coder",
|
||||
adapterType: "gemini_local",
|
||||
adapterConfig: { engine: "cli" },
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
engine: "cli",
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: { HOME: configuredHome },
|
||||
paperclipRuntimeSkills: [{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
runtimeName: "paperclip",
|
||||
source: skillSource,
|
||||
}],
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const installedSkill = path.join(configuredHome, ".gemini", "skills", "paperclip");
|
||||
expect((await fs.lstat(installedSkill)).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.realpath(installedSkill)).toBe(await fs.realpath(skillSource));
|
||||
await expect(fs.lstat(path.join(processHome, ".gemini", "skills", "paperclip"))).rejects.toThrow();
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("passes prompt via --prompt and injects paperclip env vars", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ describe("gemini local skill sync", () => {
|
|||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the Gemini skills home", async () => {
|
||||
it("defaults and installs the operational Paperclip skill in the Gemini skills home", async () => {
|
||||
const home = await makeTempDir("paperclip-gemini-skill-sync-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
|
|
@ -32,9 +32,6 @@ describe("gemini local skill sync", () => {
|
|||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,16 +7,12 @@ import {
|
|||
describe("grok local skill sync", () => {
|
||||
const paperclipKey = "paperclipai/paperclip/paperclip";
|
||||
|
||||
it("reports Grok skills as ephemeral workspace-mounted state", async () => {
|
||||
it("defaults the operational Paperclip skill as ephemeral workspace-mounted state", async () => {
|
||||
const snapshot = await listGrokSkills({
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
adapterType: "grok_local",
|
||||
config: {
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(snapshot.adapterType).toBe("grok_local");
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ describe("kimi local skill sync", () => {
|
|||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the Kimi skills home", async () => {
|
||||
it("defaults and installs the operational Paperclip skill in the Kimi skills home", async () => {
|
||||
const kimiCodeHome = await makeTempDir("paperclip-kimi-skill-sync-");
|
||||
cleanupDirs.add(kimiCodeHome);
|
||||
|
||||
|
|
@ -32,9 +32,6 @@ describe("kimi local skill sync", () => {
|
|||
env: {
|
||||
KIMI_CODE_HOME: kimiCodeHome,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ describe("opencode local skill sync", () => {
|
|||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the shared Claude/OpenCode skills home", async () => {
|
||||
it("defaults and installs the operational Paperclip skill in the shared Claude/OpenCode skills home", async () => {
|
||||
const home = await makeTempDir("paperclip-opencode-skill-sync-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
|
|
@ -32,9 +32,6 @@ describe("opencode local skill sync", () => {
|
|||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -188,8 +188,8 @@ describe("pi_local execute", () => {
|
|||
cwd: workspace,
|
||||
model: "google/gemini-3-flash-preview",
|
||||
promptTemplate: "Keep working.",
|
||||
// No explicit paperclipSkillSync preference →
|
||||
// resolvePaperclipDesiredSkillNames returns [] → skill is not injected.
|
||||
// The implicit legacy default applies only to the canonical Paperclip
|
||||
// operational skill, so this unrelated skill remains unselected.
|
||||
paperclipRuntimeSkills: [
|
||||
{ key: "not-injected", runtimeName: "not-injected", source: nonInjectedSkillDir },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ describe("pi local skill sync", () => {
|
|||
cleanupDirs.clear();
|
||||
});
|
||||
|
||||
it("reports configured Paperclip skills and installs them into the Pi skills home", async () => {
|
||||
it("defaults and installs the operational Paperclip skill in the Pi skills home", async () => {
|
||||
const home = await makeTempDir("paperclip-pi-skill-sync-");
|
||||
cleanupDirs.add(home);
|
||||
|
||||
|
|
@ -32,9 +32,6 @@ describe("pi local skill sync", () => {
|
|||
env: {
|
||||
HOME: home,
|
||||
},
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: [paperclipKey],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -2227,8 +2227,9 @@ export function agentRoutes(
|
|||
// The default CEO instructions assume the core paperclip skills (board
|
||||
// coordination, planning, hiring, memory). Union them into every
|
||||
// skills-capable CEO hire/create so a fresh CEO never starts with an empty
|
||||
// desired-skill set that contradicts its own instructions. Callers can still
|
||||
// remove any of them afterwards via the per-agent skills sync.
|
||||
// desired-skill set that contradicts its own instructions. Optional role
|
||||
// skills remain removable afterwards. Legacy adapters separately guarantee
|
||||
// the Paperclip operational skill as a runtime invariant.
|
||||
function defaultRoleSkillSelections(
|
||||
role: string | null | undefined,
|
||||
adapterType: string,
|
||||
|
|
|
|||
Loading…
Reference in New Issue