Prune stale Codex skill symlinks
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -80,11 +80,17 @@ async function isLikelyPaperclipRepoRoot(candidate: string): Promise<boolean> {
|
|||||||
return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir;
|
return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: string): Promise<boolean> {
|
async function isLikelyPaperclipRuntimeSkillPath(
|
||||||
|
candidate: string,
|
||||||
|
skillName: string,
|
||||||
|
options: { requireSkillMarkdown?: boolean } = {},
|
||||||
|
): Promise<boolean> {
|
||||||
if (path.basename(candidate) !== skillName) return false;
|
if (path.basename(candidate) !== skillName) return false;
|
||||||
const skillsRoot = path.dirname(candidate);
|
const skillsRoot = path.dirname(candidate);
|
||||||
if (path.basename(skillsRoot) !== "skills") return false;
|
if (path.basename(skillsRoot) !== "skills") return false;
|
||||||
if (!(await pathExists(path.join(candidate, "SKILL.md")))) return false;
|
if (options.requireSkillMarkdown !== false && !(await pathExists(path.join(candidate, "SKILL.md")))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
let cursor = path.dirname(skillsRoot);
|
let cursor = path.dirname(skillsRoot);
|
||||||
for (let depth = 0; depth < 6; depth += 1) {
|
for (let depth = 0; depth < 6; depth += 1) {
|
||||||
@@ -97,6 +103,39 @@ async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName:
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pruneBrokenUnavailablePaperclipSkillSymlinks(
|
||||||
|
skillsHome: string,
|
||||||
|
allowedSkillNames: Iterable<string>,
|
||||||
|
onLog: AdapterExecutionContext["onLog"],
|
||||||
|
) {
|
||||||
|
const allowed = new Set(Array.from(allowedSkillNames));
|
||||||
|
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (allowed.has(entry.name) || !entry.isSymbolicLink()) continue;
|
||||||
|
|
||||||
|
const target = path.join(skillsHome, entry.name);
|
||||||
|
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||||
|
if (!linkedPath) continue;
|
||||||
|
|
||||||
|
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
|
||||||
|
if (await pathExists(resolvedLinkedPath)) continue;
|
||||||
|
if (
|
||||||
|
!(await isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.name, {
|
||||||
|
requireSkillMarkdown: false,
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.unlink(target).catch(() => {});
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Removed stale Codex skill "${entry.name}" from ${skillsHome}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type EnsureCodexSkillsInjectedOptions = {
|
type EnsureCodexSkillsInjectedOptions = {
|
||||||
skillsHome?: string;
|
skillsHome?: string;
|
||||||
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
|
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
|
||||||
@@ -141,7 +180,7 @@ export async function ensureCodexSkillsInjected(
|
|||||||
if (
|
if (
|
||||||
resolvedLinkedPath &&
|
resolvedLinkedPath &&
|
||||||
resolvedLinkedPath !== entry.source &&
|
resolvedLinkedPath !== entry.source &&
|
||||||
(await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.runtimeName))
|
(await isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.runtimeName))
|
||||||
) {
|
) {
|
||||||
await fs.unlink(target);
|
await fs.unlink(target);
|
||||||
if (linkSkill) {
|
if (linkSkill) {
|
||||||
@@ -171,6 +210,12 @@ export async function ensureCodexSkillsInjected(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await pruneBrokenUnavailablePaperclipSkillSymlinks(
|
||||||
|
skillsHome,
|
||||||
|
skillsEntries.map((entry) => entry.runtimeName),
|
||||||
|
onLog,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
|
|||||||
@@ -102,4 +102,44 @@ describe("codex local adapter skill injection", () => {
|
|||||||
await fs.realpath(path.join(customRoot, "custom", "paperclip")),
|
await fs.realpath(path.join(customRoot, "custom", "paperclip")),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prunes broken symlinks for unavailable Paperclip repo skills before Codex starts", async () => {
|
||||||
|
const currentRepo = await makeTempDir("paperclip-codex-current-");
|
||||||
|
const oldRepo = await makeTempDir("paperclip-codex-old-");
|
||||||
|
const skillsHome = await makeTempDir("paperclip-codex-home-");
|
||||||
|
cleanupDirs.add(currentRepo);
|
||||||
|
cleanupDirs.add(oldRepo);
|
||||||
|
cleanupDirs.add(skillsHome);
|
||||||
|
|
||||||
|
await createPaperclipRepoSkill(currentRepo, "paperclip");
|
||||||
|
await createPaperclipRepoSkill(oldRepo, "agent-browser");
|
||||||
|
const staleTarget = path.join(oldRepo, "skills", "agent-browser");
|
||||||
|
await fs.symlink(staleTarget, path.join(skillsHome, "agent-browser"));
|
||||||
|
await fs.rm(staleTarget, { recursive: true, force: true });
|
||||||
|
|
||||||
|
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
|
||||||
|
await ensureCodexSkillsInjected(
|
||||||
|
async (stream, chunk) => {
|
||||||
|
logs.push({ stream, chunk });
|
||||||
|
},
|
||||||
|
{
|
||||||
|
skillsHome,
|
||||||
|
skillsEntries: [{
|
||||||
|
key: paperclipKey,
|
||||||
|
runtimeName: "paperclip",
|
||||||
|
source: path.join(currentRepo, "skills", "paperclip"),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fs.lstat(path.join(skillsHome, "agent-browser"))).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
expect(logs).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
stream: "stdout",
|
||||||
|
chunk: expect.stringContaining('Removed stale Codex skill "agent-browser"'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user