Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/safe-skill-reinstall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-install": patch
---

Fix skill re-install wiping/not updating files when the discovered source path is the same as (or overlaps) the install destination (e.g. authoring skills directly in `.agents/skills`). The copy now stages overlapping sources and is a no-op when source and destination resolve to the same path, instead of deleting the source before copying.
53 changes: 45 additions & 8 deletions packages/agent-install/src/skill/installer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { existsSync } from "node:fs";
import { cp, lstat, mkdir, readdir, readlink, realpath, rm, symlink } from "node:fs/promises";
import { homedir, platform } from "node:os";
import {
cp,
lstat,
mkdir,
mkdtemp,
readdir,
readlink,
realpath,
rm,
symlink,
} from "node:fs/promises";
import { homedir, platform, tmpdir } from "node:os";
import { basename, dirname, join, relative, resolve, sep } from "node:path";

import { isPathSafe } from "../utils/is-path-safe.ts";
Expand Down Expand Up @@ -101,6 +111,36 @@ const copyDirectory = async (src: string, dest: string, rootSrc = src): Promise<
);
};

const resolveExistingPath = async (path: string): Promise<string> =>
realpath(path).catch(() => resolve(path));

const directoriesOverlap = (source: string, dest: string): boolean =>
source === dest || isPathInsideBase(dest, source) || isPathInsideBase(source, dest);

const syncDirectory = async (source: string, dest: string): Promise<void> => {
const [realSource, realDest] = await Promise.all([
resolveExistingPath(source),
resolveExistingPath(dest),
]);

if (realSource === realDest) return;

if (directoriesOverlap(realSource, realDest)) {
const staging = await mkdtemp(join(tmpdir(), "agent-install-stage-"));
try {
await copyDirectory(realSource, staging);
await cleanAndCreateDirectory(dest);
await copyDirectory(staging, dest);
} finally {
await rm(staging, { recursive: true, force: true }).catch(() => undefined);
}
return;
}

await cleanAndCreateDirectory(dest);
await copyDirectory(source, dest);
};

const isLoopError = (error: unknown): boolean =>
Boolean(
error &&
Expand Down Expand Up @@ -154,8 +194,7 @@ const createSymlink = async (target: string, linkPath: string): Promise<boolean>
};

const installByCopy = async (skill: Skill, destination: string): Promise<InstallResultForAgent> => {
await cleanAndCreateDirectory(destination);
await copyDirectory(skill.path, destination);
await syncDirectory(skill.path, destination);
return { success: true, path: destination, mode: "copy" };
};

Expand All @@ -166,8 +205,7 @@ const installBySymlink = async (
agentDir: string,
isGlobal: boolean,
): Promise<InstallResultForAgent> => {
await cleanAndCreateDirectory(canonicalDir);
await copyDirectory(skill.path, canonicalDir);
await syncDirectory(skill.path, canonicalDir);

if (isGlobal && isUniversalSkillAgent(agentType)) {
return { success: true, path: canonicalDir, canonicalPath: canonicalDir, mode: "symlink" };
Expand All @@ -177,8 +215,7 @@ const installBySymlink = async (
return { success: true, path: agentDir, canonicalPath: canonicalDir, mode: "symlink" };
}

await cleanAndCreateDirectory(agentDir);
await copyDirectory(skill.path, agentDir);
await syncDirectory(skill.path, agentDir);
return {
success: true,
path: agentDir,
Expand Down
33 changes: 33 additions & 0 deletions packages/agent-install/test/installer-symlink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,39 @@ describe("installer symlink regressions", () => {
}
});

it("preserves content when the source path is the canonical destination", async () => {
const root = await mkdtemp(join(tmpdir(), "agent-install-symlink-"));
const projectDir = join(root, "project");
const skillName = "in-place-skill";
const canonicalDir = join(projectDir, ".agents", "skills", skillName);
await mkdir(canonicalDir, { recursive: true });
await writeFile(
join(canonicalDir, "SKILL.md"),
`---\nname: ${skillName}\ndescription: in place\n---\nORIGINAL BODY\n`,
"utf-8",
);

try {
const result = await installSkillForAgent(
{ name: skillName, description: "test", path: canonicalDir, rawContent: "" },
"claude-code",
{ cwd: projectDir, mode: "symlink", global: false },
);
expect(result.success).toBe(true);

const canonicalContents = await readFile(join(canonicalDir, "SKILL.md"), "utf-8");
expect(canonicalContents).toContain("ORIGINAL BODY");

const claudeContents = await readFile(
join(projectDir, ".claude", "skills", skillName, "SKILL.md"),
"utf-8",
);
expect(claudeContents).toContain("ORIGINAL BODY");
} finally {
await rm(root, { recursive: true, force: true });
}
});

it("rejects unsafe skill names (path traversal)", async () => {
const root = await mkdtemp(join(tmpdir(), "agent-install-symlink-"));
const projectDir = join(root, "project");
Expand Down
Loading