Skip to content
69 changes: 67 additions & 2 deletions apps/server/src/provider/Drivers/ClaudeSkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@ import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as HostProcess from "@t3tools/shared/HostProcess";

import { discoverClaudeSkills } from "./ClaudeSkills.ts";
import { discoverClaudeSkills as discoverSkills } from "./ClaudeSkills.ts";
import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts";

// Windows makes the managed directory fixture-controlled without reading host policy or skills.
const discoverClaudeSkills = (
...[config, cwd, environment = {}]: Parameters<typeof discoverSkills>
) =>
discoverSkills(config, cwd, environment).pipe(
Effect.provideService(HostProcess.Platform, "win32"),
);

const writeSkill = Effect.fn(function* (
skillsDir: string,
directoryName: string,
Expand Down Expand Up @@ -399,7 +408,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
}),
);

it.effect("prefers user skills on name collisions even with a stray .agents copy", () =>
it.effect("prefers managed then user skills on collisions, even with a stray .agents copy", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down Expand Up @@ -434,6 +443,62 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
description: "User deploy.",
},
]);

const programFiles = path.join(tempDir, "Program Files");
const programData = path.join(tempDir, "ProgramData");
yield* writeSkill(
path.join(programData, "ClaudeCode", ".claude", "skills"),
"legacy-only",
"# Legacy skills must not be discovered",
);
yield* fs.writeFileString(
path.join(programData, "ClaudeCode", "managed-settings.json"),
'{"skillOverrides":{"deploy":"off"}}',
);
for (const ProgramFiles of [undefined, "", programFiles]) {
const legacySkills = yield* discoverClaudeSkills({ homePath: configDir }, workspace, {
PROGRAMDATA: programData,
...(ProgramFiles !== undefined ? { ProgramFiles } : {}),
});
assert.deepEqual(
legacySkills,
skills.map((skill) => ({ ...skill, enabled: false })),
);
}
yield* writeSkill(
path.join(programFiles, "ClaudeCode", ".claude", "skills"),
"deploy",
["---", "name: deploy", "description: Managed deploy.", "---"].join("\n"),
);
yield* fs.writeFileString(
path.join(configDir, "settings.json"),
'{"skillOverrides":{"deploy":"off"}}',
);
yield* fs.writeFileString(
path.join(programFiles, "ClaudeCode", "managed-settings.json"),
'{"skillOverrides":{"deploy":"on"}}',
);
const managedSkills = yield* discoverClaudeSkills({ homePath: configDir }, workspace, {
ProgramFiles: programFiles,
PROGRAMDATA: programData,
});

assert.deepEqual(managedSkills, [
{
name: "deploy",
path: path.join(programFiles, "ClaudeCode", ".claude", "skills", "deploy", "SKILL.md"),
enabled: true,
scope: "user",
description: "Managed deploy.",
},
]);
assert.deepEqual(
yield* discoverClaudeSkills({ homePath: configDir }, workspace, {
PROGRAMFILES: ` ${programFiles} `,
PROGRAMDATA: programData,
}),
managedSkills,
);
}),
);

Expand Down
28 changes: 20 additions & 8 deletions apps/server/src/provider/Drivers/ClaudeSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,21 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter {
};
}

function claudeManagedSettingsPath(
function claudeManagedDirectory(
path: Path.Path,
platform: NodeJS.Platform,
environment: NodeJS.ProcessEnv,
): string | undefined {
if (platform === "darwin") {
return "/Library/Application Support/ClaudeCode/managed-settings.json";
return "/Library/Application Support/ClaudeCode";
}
if (platform === "win32") {
const programData = environment.PROGRAMDATA?.trim();
return programData ? path.join(programData, "ClaudeCode", "managed-settings.json") : undefined;
const programFiles = Object.entries(environment)
.find(([key]) => key.toLowerCase() === "programfiles")?.[1]
?.trim();
return programFiles ? path.join(programFiles, "ClaudeCode") : undefined;
}
return "/etc/claude-code/managed-settings.json";
return "/etc/claude-code";
}

/** Claude Code settings files, lowest precedence first. */
Expand All @@ -128,7 +130,8 @@ function claudeSettingsPaths(
environment: NodeJS.ProcessEnv,
repositoryRoot?: string,
): ReadonlyArray<string> {
const managedPath = claudeManagedSettingsPath(path, platform, environment);
const managedDirectory = claudeManagedDirectory(path, platform, environment);
const programData = platform === "win32" ? environment.PROGRAMDATA?.trim() : undefined;
const root = repositoryRoot !== undefined && repositoryRoot !== cwd ? repositoryRoot : undefined;
return [
path.join(configDirPath, "settings.json"),
Expand All @@ -139,7 +142,8 @@ function claudeSettingsPaths(
]
: []),
...(root ? [path.join(root, ".claude", "settings.local.json")] : []),
...(managedPath ? [managedPath] : []),
...(programData ? [path.join(programData, "ClaudeCode", "managed-settings.json")] : []),
...(managedDirectory ? [path.join(managedDirectory, "managed-settings.json")] : []),
];
}

Expand Down Expand Up @@ -485,7 +489,7 @@ export function claudeLaunchArgDirectories(launchArgs?: string): ReadonlyArray<s
return directories;
}

/** First name wins, matching Claude Code's precedence of user skills over project skills. */
/** First name wins: managed skills, then user skills, then project skills. */
export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (
config: Pick<ClaudeSettings, "homePath"> & Partial<Pick<ClaudeSettings, "launchArgs">>,
cwd?: string,
Expand All @@ -499,8 +503,16 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function*
cwd,
environment ?? process.env,
);
const managedDirectory = claudeManagedDirectory(
path,
yield* HostProcess.Platform,
environment ?? process.env,
);

const roots: ReadonlyArray<ClaudeSkillRoot> = [
...(managedDirectory
? [{ directory: path.join(managedDirectory, ".claude", "skills"), scope: "user" as const }]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
: []),
{ directory: path.join(configDirPath, "skills"), scope: "user" },
...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []),
...claudeLaunchArgDirectories(config.launchArgs)
Expand Down
Loading