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:
commit
dbaf37ae78
6 changed files with 98 additions and 15 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"))),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, "&")
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
51
src/resources/extensions/gsd/tests/claude-skill-dirs.test.ts
Normal file
51
src/resources/extensions/gsd/tests/claude-skill-dirs.test.ts
Normal 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/");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue