Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"@opencode-ai/sdk": "^1.3.15",
"@pierre/diffs": "catalog:",
"effect": "catalog:",
"node-pty": "^1.1.0"
"node-pty": "^1.1.0",
"yaml": "catalog:"
},
"devDependencies": {
"@effect/vitest": "catalog:",
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { cwd } = yield* ServerConfig;
const httpClient = yield* HttpClient.HttpClient;
Expand Down Expand Up @@ -165,9 +166,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
effectiveConfig,
() => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
processEnv,
cwd,
).pipe(
Effect.map(stampIdentity),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
);

Expand Down
177 changes: 177 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeSkills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
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 { discoverClaudeSkills } from "./ClaudeSkills.ts";

const writeSkill = Effect.fn(function* (
skillsDir: string,
directoryName: string,
contents: string,
) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const skillDir = path.join(skillsDir, directoryName);
yield* fs.makeDirectory(skillDir, { recursive: true });
yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents);
});

it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
it.effect("discovers user and project skills with frontmatter metadata", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
const configDir = path.join(tempDir, "claude-home");
const workspace = path.join(tempDir, "workspace");

yield* writeSkill(
path.join(configDir, "skills"),
"codex-review",
[
"---",
"name: codex-review",
"description: Ask Codex for a review.",
"---",
"",
"# Body",
].join("\n"),
);
yield* writeSkill(
path.join(workspace, ".claude", "skills"),
"deploy",
["---", "name: deploy", "description: Deploy the app.", "---", "", "# Deploy"].join("\n"),
);

const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);

assert.deepEqual(skills, [
{
name: "codex-review",
path: path.join(configDir, "skills", "codex-review", "SKILL.md"),
enabled: true,
scope: "user",
description: "Ask Codex for a review.",
},
{
name: "deploy",
path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"),
enabled: true,
scope: "project",
description: "Deploy the app.",
},
]);
}),
);

it.effect("prefers project skills over user skills on name collisions", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
const configDir = path.join(tempDir, "claude-home");
const workspace = path.join(tempDir, "workspace");

yield* writeSkill(
path.join(configDir, "skills"),
"deploy",
["---", "name: deploy", "description: User deploy.", "---"].join("\n"),
);
yield* writeSkill(
path.join(workspace, ".claude", "skills"),
"deploy",
["---", "name: deploy", "description: Project deploy.", "---"].join("\n"),
);

const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);

assert.equal(skills.length, 1);
assert.equal(skills[0]?.scope, "project");
assert.equal(skills[0]?.description, "Project deploy.");
}),
);

it.effect("falls back to the directory name and skips malformed frontmatter", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
const configDir = path.join(tempDir, "claude-home");
const skillsDir = path.join(configDir, "skills");

yield* writeSkill(skillsDir, "no-frontmatter", "# Just a heading\n");
yield* writeSkill(skillsDir, "broken-yaml", "---\nname: [unclosed\n---\n");
// A stray file (not a directory with SKILL.md) must be skipped.
yield* fs.makeDirectory(skillsDir, { recursive: true });
yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill");

const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined);

// A skill with no frontmatter falls back to its directory name; a skill
// whose frontmatter fails to parse is skipped entirely (Claude Code
// won't load it either).
assert.deepEqual(
skills.map((skill) => skill.name),
["no-frontmatter"],
);
assert.equal(skills[0]?.description, undefined);
}),
);

it.effect("honors CLAUDE_CONFIG_DIR from the environment when homePath is unset", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
const environmentConfigDir = path.join(tempDir, "env-config");

yield* writeSkill(
path.join(environmentConfigDir, "skills"),
"env-skill",
["---", "name: env-skill", "description: From env config dir.", "---"].join("\n"),
);

const skills = yield* discoverClaudeSkills({ homePath: "" }, undefined, {
CLAUDE_CONFIG_DIR: environmentConfigDir,
});

assert.deepEqual(
skills.map((skill) => skill.name),
["env-skill"],
);

// An explicit homePath wins over the environment variable, matching
// makeClaudeEnvironment which overwrites CLAUDE_CONFIG_DIR for the CLI.
const explicitHome = path.join(tempDir, "explicit-home");
yield* writeSkill(
path.join(explicitHome, "skills"),
"explicit-skill",
["---", "name: explicit-skill", "---"].join("\n"),
);
const explicitSkills = yield* discoverClaudeSkills({ homePath: explicitHome }, undefined, {
CLAUDE_CONFIG_DIR: environmentConfigDir,
});
assert.deepEqual(
explicitSkills.map((skill) => skill.name),
["explicit-skill"],
);
}),
);

it.effect("returns an empty list when no skill roots exist", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });

const skills = yield* discoverClaudeSkills(
{ homePath: path.join(tempDir, "missing-home") },
path.join(tempDir, "missing-workspace"),
);

assert.deepEqual(skills, []);
}),
);
});
142 changes: 142 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeSkills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker.
*
* Claude Code loads skills from `<config dir>/skills` (user scope) and
* `<cwd>/.claude/skills` (project scope), one directory per skill with a
* `SKILL.md` carrying YAML frontmatter. The Agent SDK init handshake surfaces
* skills only as slash commands without their filesystem paths, so the
* provider snapshot scans the same locations directly, mirroring how the
* Codex app-server reports its skills.
*
* @module provider/Drivers/ClaudeSkills
*/
import * as NodeOS from "node:os";

import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import { parse as parseYamlDocument } from "yaml";

import { expandHomePath } from "../../pathExpansion.ts";

type ClaudeSkillScope = "user" | "project";

const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;

type SkillFrontmatter =
| { readonly kind: "missing" }
| { readonly kind: "malformed" }
| { readonly kind: "parsed"; readonly name?: string; readonly description?: string };

function parseSkillFrontmatter(contents: string): SkillFrontmatter {
const match = FRONTMATTER_PATTERN.exec(contents);
if (!match) {
return { kind: "missing" };
}

let parsed: unknown;
try {
parsed = parseYamlDocument(match[1] ?? "");
} catch {
return { kind: "malformed" };
}
if (typeof parsed !== "object" || parsed === null) {
return { kind: "malformed" };
}

const record = parsed as Record<string, unknown>;
const name = typeof record.name === "string" ? record.name.trim() : "";
const description = typeof record.description === "string" ? record.description.trim() : "";
return {
kind: "parsed",
...(name ? { name } : {}),
...(description ? { description } : {}),
};
}

/**
* Resolve the Claude config directory the CLI would use, matching the
* precedence the spawned CLI sees: the instance's `homePath` (exported as
* `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR`
* already present in the process environment, then `~/.claude`.
*/
const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* (
config: Pick<ClaudeSettings, "homePath">,
environment: NodeJS.ProcessEnv,
): Effect.fn.Return<string, never, Path.Path> {
const path = yield* Path.Path;
const homePath = config.homePath.trim();
if (homePath.length > 0) {
return path.resolve(expandHomePath(homePath));
}
const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? "";
if (environmentConfigDir.length > 0) {
return path.resolve(expandHomePath(environmentConfigDir));
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
return path.join(NodeOS.homedir(), ".claude");
});
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Enumerate Claude Code skills from the user config dir and the workspace.
* Discovery is best-effort: unreadable roots and malformed skill entries are
* skipped so a broken skill never degrades the provider snapshot. On name
* collisions the project-scoped skill wins, matching Claude Code's
* most-specific-wins resolution.
*/
export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (
config: Pick<ClaudeSettings, "homePath">,
cwd?: string,
environment?: NodeJS.ProcessEnv,
): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env);

const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [
{ directory: path.join(configDirPath, "skills"), scope: "user" },
...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []),
];

const skillsByName = new Map<string, ServerProviderSkill>();
for (const root of roots) {
const entries = yield* fileSystem
.readDirectory(root.directory)
.pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));

for (const entry of [...entries].sort()) {
const skillPath = path.join(root.directory, entry, "SKILL.md");
const contents = yield* fileSystem
.readFileString(skillPath)
.pipe(Effect.orElseSucceed(() => undefined));
if (contents === undefined) {
continue;
}

const frontmatter = parseSkillFrontmatter(contents);
// Malformed frontmatter means the skill won't load in Claude Code
// either — skip it rather than surfacing a broken entry under its
// directory name.
if (frontmatter.kind === "malformed") {
continue;
}

const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim();
if (!name) {
continue;
}

skillsByName.set(name, {
name,
path: skillPath,
enabled: true,
scope: root.scope,
...(frontmatter.kind === "parsed" && frontmatter.description
? { description: frontmatter.description }
: {}),
});
}
}

return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
});
Loading
Loading