Skip to content

Commit 4e98c01

Browse files
fix(cli): preserve user-managed symlinks when installing to pi
1 parent 1756c0b commit 4e98c01

2 files changed

Lines changed: 77 additions & 0 deletions

File tree

src/targets/pi.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,20 +88,32 @@ export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promi
8888
for (const skill of bundle.skillDirs) {
8989
const skillName = sanitizePathName(skill.name)
9090
const targetDir = path.join(paths.skillsDir, skillName)
91+
if (await isUserManagedSymlink(targetDir)) {
92+
console.log(`Preserving user-managed symlink at ${targetDir}; skipping install of skill "${skillName}"`)
93+
continue
94+
}
9195
await cleanupCurrentManagedSkillDir(targetDir, manifest, skillName)
9296
await copySkillDir(skill.sourceDir, targetDir, transformContentForPi)
9397
}
9498

9599
for (const skill of bundle.generatedSkills) {
96100
const skillName = sanitizePathName(skill.name)
97101
const targetDir = path.join(paths.skillsDir, skillName)
102+
if (await isUserManagedSymlink(targetDir)) {
103+
console.log(`Preserving user-managed symlink at ${targetDir}; skipping install of skill "${skillName}"`)
104+
continue
105+
}
98106
await cleanupCurrentManagedSkillDir(targetDir, manifest, skillName)
99107
await writeText(path.join(targetDir, "SKILL.md"), skill.content + "\n")
100108
}
101109

102110
for (const agent of bundle.agents) {
103111
const agentFileName = `${sanitizePathName(agent.name)}.md`
104112
const targetPath = path.join(paths.agentsDir, agentFileName)
113+
if (await isUserManagedSymlink(targetPath)) {
114+
console.log(`Preserving user-managed symlink at ${targetPath}; skipping install of agent "${agent.name}"`)
115+
continue
116+
}
105117
await cleanupCurrentManagedAgentFile(targetPath, manifest, agentFileName)
106118
await writeText(targetPath, agent.content + "\n")
107119
}
@@ -394,6 +406,19 @@ async function cleanupRemovedAgents(
394406
}
395407
}
396408

409+
// A user-created symlink at a managed path is an explicit override (a fork,
410+
// worktree, or local patch — see issue #1048). Removing the link would
411+
// deactivate the override, and writing "through" a preserved link would
412+
// clobber the override's target, so installs skip these paths entirely.
413+
// Reverting to upstream content requires the user to remove the symlink first.
414+
async function isUserManagedSymlink(targetPath: string): Promise<boolean> {
415+
try {
416+
return (await fs.lstat(targetPath)).isSymbolicLink()
417+
} catch {
418+
return false
419+
}
420+
}
421+
397422
async function cleanupCurrentManagedSkillDir(
398423
targetDir: string,
399424
manifest: PiInstallManifest | null,

tests/pi-writer.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,58 @@ Run these research agents:
278278
expect(await exists(path.join(outputRoot, "extensions", "compound-engineering-compat.ts"))).toBe(false)
279279
})
280280

281+
test("preserves user-managed symlinks at managed skill and agent paths on reinstall", async () => {
282+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-symlink-preserve-"))
283+
const outputRoot = path.join(tempRoot, ".pi")
284+
const bundle: PiBundle = {
285+
pluginName: "compound-engineering",
286+
prompts: [],
287+
skillDirs: [
288+
{
289+
name: "skill-one",
290+
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
291+
},
292+
],
293+
generatedSkills: [{ name: "generated-one", content: "---\nname: generated-one\n---\n\nUpstream generated" }],
294+
agents: [{ name: "agent-one", content: "---\nname: agent-one\n---\n\nUpstream agent" }],
295+
extensions: [],
296+
}
297+
await writePiBundle(outputRoot, bundle)
298+
299+
// The user replaces the managed skill dirs and agent file with symlinks
300+
// pointing at a local fork — the override pattern from issue #1048.
301+
const forkRoot = path.join(tempRoot, "fork")
302+
const forkSkillDir = path.join(forkRoot, "skill-one")
303+
await fs.mkdir(forkSkillDir, { recursive: true })
304+
await fs.writeFile(path.join(forkSkillDir, "SKILL.md"), "user fork skill\n")
305+
const forkGeneratedDir = path.join(forkRoot, "generated-one")
306+
await fs.mkdir(forkGeneratedDir, { recursive: true })
307+
await fs.writeFile(path.join(forkGeneratedDir, "SKILL.md"), "user fork generated\n")
308+
const forkAgentFile = path.join(forkRoot, "agent-one.md")
309+
await fs.writeFile(forkAgentFile, "user fork agent\n")
310+
311+
const skillLink = path.join(outputRoot, "skills", "skill-one")
312+
await fs.rm(skillLink, { recursive: true, force: true })
313+
await fs.symlink(forkSkillDir, skillLink, "dir")
314+
const generatedLink = path.join(outputRoot, "skills", "generated-one")
315+
await fs.rm(generatedLink, { recursive: true, force: true })
316+
await fs.symlink(forkGeneratedDir, generatedLink, "dir")
317+
const agentLink = path.join(outputRoot, "agents", "agent-one.md")
318+
await fs.rm(agentLink, { force: true })
319+
await fs.symlink(forkAgentFile, agentLink, "file")
320+
321+
await writePiBundle(outputRoot, bundle)
322+
323+
// Symlinks survive the reinstall...
324+
expect((await fs.lstat(skillLink)).isSymbolicLink()).toBe(true)
325+
expect((await fs.lstat(generatedLink)).isSymbolicLink()).toBe(true)
326+
expect((await fs.lstat(agentLink)).isSymbolicLink()).toBe(true)
327+
// ...and the install did not write through them into the fork.
328+
expect(await fs.readFile(path.join(forkSkillDir, "SKILL.md"), "utf8")).toBe("user fork skill\n")
329+
expect(await fs.readFile(path.join(forkGeneratedDir, "SKILL.md"), "utf8")).toBe("user fork generated\n")
330+
expect(await fs.readFile(forkAgentFile, "utf8")).toBe("user fork agent\n")
331+
})
332+
281333
test("namespaces managed install manifests per plugin so installs do not collide", async () => {
282334
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-multi-plugin-"))
283335
const outputRoot = path.join(tempRoot, ".pi")

0 commit comments

Comments
 (0)