Merge pull request #3491 from Tibsfox/fix/claude-code-skill-directory-support

fix(gsd): add Claude Code official skill directories to skill resolution
This commit is contained in:
Jeremy McSpadden 2026-04-04 18:24:51 -05:00 committed by GitHub
commit dbaf37ae78
6 changed files with 98 additions and 15 deletions

View file

@ -24,13 +24,18 @@ export type { GSDSkillRule, SkillDiscoveryMode, SkillResolution, SkillResolution
/**
* Known skill directories, in priority order.
* Global skills (~/.agents/skills/) take precedence over project skills.
* Searches both the skills.sh ecosystem directory (~/.agents/skills/) and
* Claude Code's official directory (~/.claude/skills/). Project-level
* directories for both conventions are included as well.
* Legacy ~/.gsd/agent/skills/ is included as a fallback for pre-migration installs.
*/
export function getSkillSearchDirs(cwd: string): Array<{ dir: string; method: SkillResolution["method"] }> {
const dirs: Array<{ dir: string; method: SkillResolution["method"] }> = [
{ dir: join(homedir(), ".agents", "skills"), method: "user-skill" },
{ dir: join(cwd, ".agents", "skills"), method: "project-skill" },
// Claude Code official skill directories
{ dir: join(homedir(), ".claude", "skills"), method: "user-skill" },
{ dir: join(cwd, ".claude", "skills"), method: "project-skill" },
];
// Legacy fallback — read skills from old GSD directory only if migration hasn't completed
const legacyDir = join(homedir(), ".gsd", "agent", "skills");

View file

@ -935,13 +935,16 @@ export async function installPacksBatched(
/**
* Check if any skills from a pack are already installed.
* Searches both the skills.sh ecosystem directory and Claude Code's official directory.
*/
export function isPackInstalled(pack: SkillPack): boolean {
const skillsDir = join(homedir(), ".agents", "skills");
if (!existsSync(skillsDir)) return false;
const skillsDirs = [
join(homedir(), ".agents", "skills"),
join(homedir(), ".claude", "skills"),
];
return pack.skills.every((name) =>
existsSync(join(skillsDir, name, "SKILL.md")),
skillsDirs.some((dir) => existsSync(join(dir, name, "SKILL.md"))),
);
}

View file

@ -12,8 +12,9 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
/** Industry-standard skills.sh global skills directory */
/** Skills directories — skills.sh ecosystem + Claude Code official */
const SKILLS_DIR = join(homedir(), ".agents", "skills");
const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
export interface DiscoveredSkill {
name: string;
@ -58,8 +59,9 @@ export function detectNewSkills(): DiscoveredSkill[] {
for (const dir of current) {
if (baselineSkills.has(dir)) continue;
const skillMdPath = join(SKILLS_DIR, dir, "SKILL.md");
if (!existsSync(skillMdPath)) continue;
// Check both skill directories for the SKILL.md file
const skillMdPath = resolveSkillMdPath(dir);
if (!skillMdPath) continue;
const meta = parseSkillFrontmatter(skillMdPath);
if (meta) {
@ -97,10 +99,10 @@ ${entries}
// ─── Internals ────────────────────────────────────────────────────────────────
function listSkillDirs(): string[] {
if (!existsSync(SKILLS_DIR)) return [];
function listSkillDirsFrom(dir: string): string[] {
if (!existsSync(dir)) return [];
try {
return readdirSync(SKILLS_DIR, { withFileTypes: true })
return readdirSync(dir, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name);
} catch {
@ -108,6 +110,13 @@ function listSkillDirs(): string[] {
}
}
function listSkillDirs(): string[] {
const names = new Set<string>();
for (const name of listSkillDirsFrom(SKILLS_DIR)) names.add(name);
for (const name of listSkillDirsFrom(CLAUDE_SKILLS_DIR)) names.add(name);
return [...names];
}
function parseSkillFrontmatter(path: string): { name?: string; description?: string } | null {
try {
const content = readFileSync(path, "utf-8");
@ -131,6 +140,14 @@ function parseSkillFrontmatter(path: string): { name?: string; description?: str
}
}
function resolveSkillMdPath(skillName: string): string | null {
for (const dir of [SKILLS_DIR, CLAUDE_SKILLS_DIR]) {
const candidate = join(dir, skillName, "SKILL.md");
if (existsSync(candidate)) return candidate;
}
return null;
}
function escapeXml(text: string): string {
return text
.replace(/&/g, "&amp;")

View file

@ -207,9 +207,13 @@ export function formatSkillDetail(basePath: string, skillName: string): string {
lines.push(` ${date} ${u.id.padEnd(20)} ${formatTokenCount(u.tokens.total).padStart(8)} tokens ${formatCost(u.cost)}`);
}
// Check for SKILL.md existence
const skillPath = join(homedir(), ".agents", "skills", skillName, "SKILL.md");
if (existsSync(skillPath)) {
// Check for SKILL.md existence — search both ecosystem and Claude Code directories
const candidatePaths = [
join(homedir(), ".agents", "skills", skillName, "SKILL.md"),
join(homedir(), ".claude", "skills", skillName, "SKILL.md"),
];
const skillPath = candidatePaths.find(p => existsSync(p));
if (skillPath) {
const stat = statSync(skillPath);
lines.push("");
lines.push(`SKILL.md: ${skillPath}`);

View file

@ -31,12 +31,14 @@ const activelyLoadedSkills = new Set<string>();
*/
export function captureAvailableSkills(): void {
const skillsDir = join(homedir(), ".agents", "skills");
const claudeSkillsDir = join(homedir(), ".claude", "skills");
const legacyDir = join(homedir(), ".gsd", "agent", "skills");
const names = listSkillNames(skillsDir);
const claudeNames = listSkillNames(claudeSkillsDir);
// Include skills still in the legacy directory only if migration hasn't completed
const legacyMigrated = existsSync(join(legacyDir, ".migrated-to-agents"));
const legacyNames = legacyMigrated ? [] : listSkillNames(legacyDir);
const all = new Set([...names, ...legacyNames]);
const all = new Set([...names, ...claudeNames, ...legacyNames]);
availableSkills = [...all];
activelyLoadedSkills.clear();
}
@ -106,10 +108,11 @@ export function detectStaleSkills(
// Check all installed skills, not just those with usage data
const skillsDir = join(homedir(), ".agents", "skills");
const claudeSkillsDir = join(homedir(), ".claude", "skills");
const legacyDir = join(homedir(), ".gsd", "agent", "skills");
const legacyMigrated = existsSync(join(legacyDir, ".migrated-to-agents"));
const legacyNames = legacyMigrated ? [] : listSkillNames(legacyDir);
const installedSet = new Set([...listSkillNames(skillsDir), ...legacyNames]);
const installedSet = new Set([...listSkillNames(skillsDir), ...listSkillNames(claudeSkillsDir), ...legacyNames]);
const installed = [...installedSet];
for (const skill of installed) {

View file

@ -0,0 +1,51 @@
/**
* Tests for Claude Code skill directory support in getSkillSearchDirs().
*
* Verifies that ~/.claude/skills/ and .claude/skills/ are included in
* the skill search path alongside ~/.agents/skills/ and .agents/skills/.
*/
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { homedir } from "node:os";
import { getSkillSearchDirs } from "../preferences-skills.ts";
describe("getSkillSearchDirs — Claude Code directory support", () => {
const cwd = "/tmp/test-project";
test("includes ~/.agents/skills/ as user-skill", () => {
const dirs = getSkillSearchDirs(cwd);
const agents = dirs.find((d) => d.dir === join(homedir(), ".agents", "skills"));
assert.ok(agents, "should include ~/.agents/skills/");
assert.equal(agents!.method, "user-skill");
});
test("includes .agents/skills/ as project-skill", () => {
const dirs = getSkillSearchDirs(cwd);
const projectAgents = dirs.find((d) => d.dir === join(cwd, ".agents", "skills"));
assert.ok(projectAgents, "should include .agents/skills/");
assert.equal(projectAgents!.method, "project-skill");
});
test("includes ~/.claude/skills/ as user-skill", () => {
const dirs = getSkillSearchDirs(cwd);
const claude = dirs.find((d) => d.dir === join(homedir(), ".claude", "skills"));
assert.ok(claude, "should include ~/.claude/skills/");
assert.equal(claude!.method, "user-skill");
});
test("includes .claude/skills/ as project-skill", () => {
const dirs = getSkillSearchDirs(cwd);
const projectClaude = dirs.find((d) => d.dir === join(cwd, ".claude", "skills"));
assert.ok(projectClaude, "should include .claude/skills/");
assert.equal(projectClaude!.method, "project-skill");
});
test("~/.agents/skills/ appears before ~/.claude/skills/ (priority order)", () => {
const dirs = getSkillSearchDirs(cwd);
const agentsIdx = dirs.findIndex((d) => d.dir === join(homedir(), ".agents", "skills"));
const claudeIdx = dirs.findIndex((d) => d.dir === join(homedir(), ".claude", "skills"));
assert.ok(agentsIdx < claudeIdx, "~/.agents/skills/ should have higher priority than ~/.claude/skills/");
});
});