Validate final selected skill roots and references before preview, approval, and client/Hopper changes, with a repeated installation-boundary check. Preserve interactive unselected-client choices and registration-only setup. Share typed directory/read observations across setup, doctor, and update; report unmanaged paths and unknown reads with actionable paths/reasons. Retain uninstall child outcomes and ownership manifests when cleanup remains. Shared single-pass inspection retains all unreadable paths after stale or missing files and keeps observed metadata, without duplicate manifest reads. Absent installed files do not depend on generated bundled bytes. Validation: 217 related tests, 41 final CLI/MCP and filesystem checks, and all 7,446 source tests in 508 files pass (eight platform/condition skips). Static/docs checks pass. Normal four-call install/rerun/repair/uninstall tasks preserve results and output token counts. No native Windows follow-up or arbitrary concurrent swap containment claim. Fixes #3057. Co-authored-by: geekwolf <3125615+geekwolf@users.noreply.github.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: akushonkamen <74654766+akushonkamen@users.noreply.github.com>
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
import {
|
|
access,
|
|
cp,
|
|
lstat,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { dirname, join, relative, resolve } from "node:path";
|
|
|
|
import { expect } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { cliTest } from "../../support/cli/cliFixture.js";
|
|
|
|
cliTest.for(["root", "references"] as const)(
|
|
"rejects a linked skill %s during planning before configuring a client",
|
|
async (kind, { cli }) => {
|
|
const directory = await createTestTempDirectory("rea-skill-preflight-");
|
|
const selected = join(
|
|
directory,
|
|
".claude/skills/reverse-engineer-anything",
|
|
);
|
|
const managed = join(directory, "user-managed");
|
|
const source = resolve("skills/reverse-engineer-anything");
|
|
await mkdir(kind === "root" ? dirname(selected) : selected, {
|
|
recursive: true,
|
|
});
|
|
await cp(kind === "root" ? source : join(source, "references"), managed, {
|
|
recursive: true,
|
|
});
|
|
if (kind === "references")
|
|
await cp(join(source, "SKILL.md"), join(selected, "SKILL.md"));
|
|
const link = kind === "root" ? selected : join(selected, "references");
|
|
await symlink(
|
|
managed,
|
|
link,
|
|
process.platform === "win32" ? "junction" : "dir",
|
|
);
|
|
const run = (arguments_: readonly string[]) =>
|
|
cli.run({
|
|
arguments: [...arguments_, "--json"],
|
|
cwd: directory,
|
|
environment: { USERPROFILE: directory },
|
|
});
|
|
const preview = await run([
|
|
"setup",
|
|
"--client",
|
|
"claude_code",
|
|
"--dry-run",
|
|
]);
|
|
const applied = await run(["setup", "--client", "claude_code", "--yes"]);
|
|
await expect(access(join(directory, ".claude.json"))).rejects.toMatchObject(
|
|
{
|
|
code: "ENOENT",
|
|
},
|
|
);
|
|
for (const result of [preview, applied]) {
|
|
expect(result.exitCode, result.stdout).toBe(1);
|
|
expect(result.json).toMatchObject({
|
|
status: "needs_human",
|
|
code: "skill_install_failed",
|
|
appliedActions: [],
|
|
});
|
|
expect(result.stdout).toContain(link);
|
|
expect(result.stdout).toContain("--skill=false");
|
|
}
|
|
const doctor = await run(["doctor", "--client", "claude_code", "--skill"]);
|
|
expect(doctor.exitCode, doctor.stdout).toBe(1);
|
|
expect(doctor.json).toMatchObject({
|
|
healthy: false,
|
|
identity: { skill: { state: "unmanaged" } },
|
|
});
|
|
expect(doctor.stdout).toContain(link);
|
|
expect((await lstat(link)).isSymbolicLink()).toBe(true);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"setup refuses a linked skill while allowing registration-only setup",
|
|
async ({ cli }) => {
|
|
const directory = await createTestTempDirectory("rea-linked-skill-cli-");
|
|
const managed = join(directory, "managed");
|
|
const selected = join(
|
|
directory,
|
|
".claude/skills/reverse-engineer-anything",
|
|
);
|
|
await mkdir(managed);
|
|
await mkdir(join(directory, ".claude/skills"), { recursive: true });
|
|
await cp(resolve("skills/reverse-engineer-anything"), managed, {
|
|
recursive: true,
|
|
});
|
|
const original = await readFile(join(managed, "SKILL.md"), "utf8");
|
|
await symlink(
|
|
managed,
|
|
selected,
|
|
process.platform === "win32" ? "junction" : "dir",
|
|
);
|
|
const run = (arguments_: readonly string[]) =>
|
|
cli.run({
|
|
arguments: [...arguments_, "--json"],
|
|
cwd: directory,
|
|
environment: { USERPROFILE: directory },
|
|
});
|
|
|
|
const refused = await run([
|
|
"setup",
|
|
"--skill-client",
|
|
"claude_code",
|
|
"--yes",
|
|
]);
|
|
expect(refused.exitCode, refused.stdout).toBe(1);
|
|
expect(refused.json).toMatchObject({
|
|
status: "needs_human",
|
|
code: "skill_install_failed",
|
|
});
|
|
expect(refused.stdout).toContain("symbolic link");
|
|
const doctor = await run(["doctor", "--client", "claude_code", "--skill"]);
|
|
expect(doctor.exitCode, doctor.stdout).toBe(1);
|
|
expect(doctor.json).toMatchObject({
|
|
healthy: false,
|
|
identity: { skill: { state: "unmanaged" } },
|
|
});
|
|
const registration = await run([
|
|
"setup",
|
|
"--client",
|
|
"claude_code",
|
|
"--skill=false",
|
|
"--yes",
|
|
]);
|
|
expect(registration.exitCode, registration.stdout).toBe(0);
|
|
expect(registration.json).toMatchObject({ status: "ready" });
|
|
await access(join(directory, ".claude.json"));
|
|
expect((await lstat(selected)).isSymbolicLink()).toBe(true);
|
|
expect((await readdir(managed)).sort()).toEqual(["SKILL.md", "references"]);
|
|
expect(await readFile(join(managed, "SKILL.md"), "utf8")).toBe(original);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"uninstall reports retained references and preserves their ownership manifest",
|
|
async ({ cli }) => {
|
|
const directory = await createTestTempDirectory("rea-skill-retained-cli-");
|
|
const selected = join(
|
|
directory,
|
|
".claude/skills/reverse-engineer-anything",
|
|
);
|
|
const managed = join(directory, "user-managed-references");
|
|
const source = resolve("skills/reverse-engineer-anything");
|
|
await mkdir(selected, { recursive: true });
|
|
await cp(join(source, "SKILL.md"), join(selected, "SKILL.md"));
|
|
await cp(join(source, "references"), managed, { recursive: true });
|
|
await symlink(
|
|
managed,
|
|
join(selected, "references"),
|
|
process.platform === "win32" ? "junction" : "dir",
|
|
);
|
|
const before = await readFile(join(managed, "native-and-artifacts.md"));
|
|
const result = await cli.run({
|
|
arguments: ["uninstall", "--json"],
|
|
cwd: directory,
|
|
environment: { USERPROFILE: directory },
|
|
});
|
|
expect(result.exitCode, result.stdout).toBe(0);
|
|
expect(result.json).toMatchObject({
|
|
items: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: "skill",
|
|
status: "retained",
|
|
detail: expect.stringContaining("retained"),
|
|
}),
|
|
]),
|
|
});
|
|
expect(await readFile(join(selected, "SKILL.md"))).toEqual(
|
|
await readFile(join(source, "SKILL.md")),
|
|
);
|
|
expect(await readFile(join(managed, "native-and-artifacts.md"))).toEqual(
|
|
before,
|
|
);
|
|
expect((await lstat(join(selected, "references"))).isSymbolicLink()).toBe(
|
|
true,
|
|
);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"skill-only setup verifies its selected client copy on install, rerun and repair",
|
|
async ({ cli }) => {
|
|
const directory = await createTestTempDirectory("rea-selected-skill-");
|
|
const selected = join(
|
|
directory,
|
|
".claude/skills/reverse-engineer-anything",
|
|
);
|
|
const shared = join(directory, ".agents/skills/reverse-engineer-anything");
|
|
const run = (args: readonly string[]) =>
|
|
cli.run({
|
|
arguments: [...args, "--json"],
|
|
cwd: directory,
|
|
environment: { USERPROFILE: directory },
|
|
});
|
|
const preview = await run([
|
|
"setup",
|
|
"--skill-client",
|
|
"claude_code",
|
|
"--dry-run",
|
|
]);
|
|
expect(preview.exitCode, preview.stdout).toBe(0);
|
|
expect(preview.json).toMatchObject({
|
|
status: "planned",
|
|
doctor: {
|
|
scope: {
|
|
clients: [],
|
|
skill: true,
|
|
skill_clients: ["claude_code"],
|
|
},
|
|
},
|
|
});
|
|
const registered = await run([
|
|
"setup",
|
|
"--client",
|
|
"claude_code",
|
|
"--skill=false",
|
|
"--yes",
|
|
]);
|
|
expect(registered.exitCode, registered.stderr).toBe(0);
|
|
const configuration = join(directory, ".claude.json");
|
|
const before = await readFile(configuration, "utf8");
|
|
const args = ["setup", "--skill-client", "claude_code", "--yes"];
|
|
const installed = await run(args);
|
|
expect(installed.exitCode, installed.stdout).toBe(0);
|
|
expect(installed.json).toMatchObject({
|
|
status: "ready",
|
|
appliedActions: ["installed_skill"],
|
|
doctor: {
|
|
healthy: true,
|
|
scope: { clients: [], skill: true, skill_clients: ["claude_code"] },
|
|
identity: { skill: { state: "aligned" } },
|
|
},
|
|
});
|
|
const bundle = resolve("skills/reverse-engineer-anything");
|
|
const assertAligned = async () => {
|
|
for (const entry of await readdir(bundle, {
|
|
recursive: true,
|
|
withFileTypes: true,
|
|
})) {
|
|
if (!entry.isFile()) continue;
|
|
const path = relative(bundle, join(entry.parentPath, entry.name));
|
|
expect(await readFile(join(selected, path))).toEqual(
|
|
await readFile(join(bundle, path)),
|
|
);
|
|
}
|
|
};
|
|
await assertAligned();
|
|
const repeated = await run(args);
|
|
expect(repeated.exitCode, repeated.stdout).toBe(0);
|
|
expect(repeated.json).toMatchObject({
|
|
status: "ready",
|
|
appliedActions: [],
|
|
});
|
|
|
|
await writeFile(
|
|
join(selected, "references/native-and-artifacts.md"),
|
|
"damaged selected copy\n",
|
|
);
|
|
const damaged = await run(["doctor", "--client", "claude_code", "--skill"]);
|
|
expect(damaged.json).toMatchObject({
|
|
healthy: false,
|
|
identity: { skill: { state: "stale" } },
|
|
});
|
|
const repaired = await run(args);
|
|
expect(repaired.exitCode, repaired.stdout).toBe(0);
|
|
expect(repaired.json).toMatchObject({
|
|
status: "ready",
|
|
appliedActions: ["installed_skill"],
|
|
});
|
|
await assertAligned();
|
|
await expect(access(shared)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await readFile(configuration, "utf8")).toBe(before);
|
|
|
|
const sharedControl = await run([
|
|
"setup",
|
|
"--skill-client",
|
|
"shared",
|
|
"--yes",
|
|
]);
|
|
expect(sharedControl.exitCode, sharedControl.stdout).toBe(0);
|
|
expect(sharedControl.json).toMatchObject({ status: "ready" });
|
|
await access(join(shared, "SKILL.md"));
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"setup keeps registration and skill readiness selections independent",
|
|
async ({ cli }) => {
|
|
const directory = await createTestTempDirectory("rea-independent-skill-");
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"setup",
|
|
"--client",
|
|
"codex",
|
|
"--skill-client",
|
|
"claude_code",
|
|
"--yes",
|
|
"--json",
|
|
],
|
|
cwd: directory,
|
|
environment: { USERPROFILE: directory },
|
|
});
|
|
expect(result.exitCode, result.stdout).toBe(0);
|
|
expect(result.json).toMatchObject({
|
|
status: "ready",
|
|
doctor: {
|
|
healthy: true,
|
|
scope: {
|
|
clients: ["codex"],
|
|
skill_clients: ["claude_code"],
|
|
skill: true,
|
|
},
|
|
identity: { skill: { state: "aligned" } },
|
|
},
|
|
});
|
|
await access(join(directory, ".codex/config.toml"));
|
|
await access(
|
|
join(directory, ".claude/skills/reverse-engineer-anything/SKILL.md"),
|
|
);
|
|
await expect(access(join(directory, ".claude.json"))).rejects.toMatchObject(
|
|
{ code: "ENOENT" },
|
|
);
|
|
await expect(
|
|
access(join(directory, ".agents/skills/reverse-engineer-anything")),
|
|
).rejects.toMatchObject({ code: "ENOENT" });
|
|
},
|
|
);
|