Skip to content
41 changes: 39 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,34 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
description: "User deploy.",
},
]);

const programFiles = path.join(tempDir, "Program Files");
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,
});

assert.deepEqual(managedSkills, [
{
name: "deploy",
path: path.join(programFiles, "ClaudeCode", ".claude", "skills", "deploy", "SKILL.md"),
enabled: true,
scope: "user",
description: "Managed deploy.",
},
]);
}),
);

Expand Down
24 changes: 16 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,19 @@ 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 = environment.ProgramFiles?.trim();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return programFiles ? path.join(programFiles, "ClaudeCode") : undefined;
Comment thread
maria-rcks marked this conversation as resolved.
Resolved
}
return "/etc/claude-code/managed-settings.json";
return "/etc/claude-code";
}

function skillOverrideSettingsPaths(
Expand All @@ -127,7 +127,7 @@ function skillOverrideSettingsPaths(
environment: NodeJS.ProcessEnv,
repositoryRoot?: string,
): ReadonlyArray<string> {
const managedPath = claudeManagedSettingsPath(path, platform, environment);
const managedDirectory = claudeManagedDirectory(path, platform, environment);
const root = repositoryRoot !== undefined && repositoryRoot !== cwd ? repositoryRoot : undefined;
return [
path.join(configDirPath, "settings.json"),
Expand All @@ -138,7 +138,7 @@ function skillOverrideSettingsPaths(
]
: []),
...(root ? [path.join(root, ".claude", "settings.local.json")] : []),
...(managedPath ? [managedPath] : []),
...(managedDirectory ? [path.join(managedDirectory, "managed-settings.json")] : []),
];
}

Expand Down Expand Up @@ -447,7 +447,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 @@ -461,8 +461,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