fix: Skills API 增加项目级 .claude/skills 扫描
列表接口 (route.ts): - 新增 getProjectSkillsDir + scanProjectSkills 扫描 .claude/skills/*/SKILL.md - 解析 YAML front matter 取 name/description,source 为 "project" - 与 project commands 去重(commands 优先) 按名查找接口 ([name]/route.ts): - findSkillFile 查找链路扩展:project commands → project skills → global → installed - 支持目录名直接匹配和 front matter name 匹配 - GET 对 SKILL.md 从 front matter 取 description - PUT 增加 cwd 参数支持 修复与 Agent SDK 的目录约定不一致问题:SDK 会加载项目级 .claude/skills,但本项目 API 之前只扫 commands 目录和用户级 skills。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
5dda2ea1e3
commit
ddb2c00a8c
4 files changed
+101
-11
No files matched your search
@@ -12,6 +12,10 @@ function getProjectCommandsDir(cwd?: string): string {
|
||||
return path.join(cwd || process.cwd(), ".claude", "commands");
|
||||
}
|
||||
|
||||
function getProjectSkillsDir(cwd?: string): string {
|
||||
return path.join(cwd || process.cwd(), ".claude", "skills");
|
||||
}
|
||||
|
||||
function getInstalledSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".agents", "skills");
|
||||
}
|
||||
@@ -157,11 +161,39 @@ function findSkillFile(
|
||||
const installedSource = options?.installedSource;
|
||||
|
||||
if (!options?.installedOnly) {
|
||||
// Check project first, then global, then installed (~/.agents/skills/ and ~/.claude/skills/)
|
||||
// Check project commands → project skills → global commands → installed
|
||||
const projectPath = path.join(getProjectCommandsDir(options?.cwd), `${name}.md`);
|
||||
if (fs.existsSync(projectPath)) {
|
||||
return { filePath: projectPath, source: "project" };
|
||||
}
|
||||
|
||||
// Check project-level .claude/skills/{name}/SKILL.md
|
||||
const projectSkillPath = path.join(getProjectSkillsDir(options?.cwd), name, "SKILL.md");
|
||||
if (fs.existsSync(projectSkillPath)) {
|
||||
return { filePath: projectSkillPath, source: "project" };
|
||||
}
|
||||
|
||||
// Check project-level skills by front matter name (scan all subdirs)
|
||||
const projectSkillsDir = getProjectSkillsDir(options?.cwd);
|
||||
if (fs.existsSync(projectSkillsDir)) {
|
||||
try {
|
||||
const entries = fs.readdirSync(projectSkillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
||||
if (entry.name === name) continue; // already checked above
|
||||
const skillMdPath = path.join(projectSkillsDir, entry.name, "SKILL.md");
|
||||
if (!fs.existsSync(skillMdPath)) continue;
|
||||
const skillContent = fs.readFileSync(skillMdPath, "utf-8");
|
||||
const meta = parseSkillFrontMatter(skillContent);
|
||||
if (meta.name === name) {
|
||||
return { filePath: skillMdPath, source: "project" };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
const globalPath = path.join(getGlobalCommandsDir(), `${name}.md`);
|
||||
if (fs.existsSync(globalPath)) {
|
||||
return { filePath: globalPath, source: "global" };
|
||||
@@ -238,9 +270,16 @@ export async function GET(
|
||||
|
||||
const content = fs.readFileSync(found.filePath, "utf-8");
|
||||
const firstLine = content.split("\n")[0]?.trim() || "";
|
||||
const description = firstLine.startsWith("#")
|
||||
? firstLine.replace(/^#+\s*/, "")
|
||||
: firstLine || `Skill: /${name}`;
|
||||
let description: string;
|
||||
|
||||
if (found.filePath.endsWith("SKILL.md")) {
|
||||
const meta = parseSkillFrontMatter(content);
|
||||
description = meta.description || (firstLine.startsWith("#") ? firstLine.replace(/^#+\s*/, "") : firstLine || `Skill: /${name}`);
|
||||
} else {
|
||||
description = firstLine.startsWith("#")
|
||||
? firstLine.replace(/^#+\s*/, "")
|
||||
: firstLine || `Skill: /${name}`;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
skill: {
|
||||
@@ -271,6 +310,7 @@ export async function PUT(
|
||||
|
||||
const url = new URL(request.url);
|
||||
const sourceParam = url.searchParams.get("source");
|
||||
const cwdParam = url.searchParams.get("cwd") || undefined;
|
||||
const installedSource =
|
||||
sourceParam === "agents" || sourceParam === "claude"
|
||||
? (sourceParam as InstalledSource)
|
||||
@@ -283,8 +323,8 @@ export async function PUT(
|
||||
}
|
||||
|
||||
const found = installedSource
|
||||
? findSkillFile(name, { installedSource, installedOnly: true })
|
||||
: findSkillFile(name);
|
||||
? findSkillFile(name, { installedSource, installedOnly: true, cwd: cwdParam })
|
||||
: findSkillFile(name, { cwd: cwdParam });
|
||||
if (found && "conflict" in found) {
|
||||
return NextResponse.json(
|
||||
{ error: "Multiple skills with different content", sources: found.sources },
|
||||
|
||||
@@ -24,6 +24,10 @@ function getProjectCommandsDir(cwd?: string): string {
|
||||
return path.join(cwd || process.cwd(), ".claude", "commands");
|
||||
}
|
||||
|
||||
function getProjectSkillsDir(cwd?: string): string {
|
||||
return path.join(cwd || process.cwd(), ".claude", "skills");
|
||||
}
|
||||
|
||||
function getPluginCommandsDirs(): string[] {
|
||||
const dirs: string[] = [];
|
||||
const marketplacesDir = path.join(os.homedir(), ".claude", "plugins", "marketplaces");
|
||||
@@ -57,6 +61,40 @@ function getClaudeSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".claude", "skills");
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan project-level skills from .claude/skills/{name}/SKILL.md.
|
||||
* Each subdirectory may contain a SKILL.md with optional YAML front matter.
|
||||
*/
|
||||
function scanProjectSkills(dir: string): SkillFile[] {
|
||||
const skills: SkillFile[] = [];
|
||||
if (!fs.existsSync(dir)) return skills;
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
||||
const skillMdPath = path.join(dir, entry.name, "SKILL.md");
|
||||
if (!fs.existsSync(skillMdPath)) continue;
|
||||
|
||||
const content = fs.readFileSync(skillMdPath, "utf-8");
|
||||
const meta = parseSkillFrontMatter(content);
|
||||
const name = meta.name || entry.name;
|
||||
const description = meta.description || `Skill: /${name}`;
|
||||
|
||||
skills.push({
|
||||
name,
|
||||
description,
|
||||
content,
|
||||
source: "project",
|
||||
filePath: skillMdPath,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
function computeContentHash(content: string): string {
|
||||
return crypto.createHash("sha1").update(content, "utf8").digest("hex");
|
||||
}
|
||||
@@ -240,6 +278,18 @@ export async function GET(request: NextRequest) {
|
||||
const globalSkills = scanDirectory(globalDir, "global");
|
||||
const projectSkills = scanDirectory(projectDir, "project");
|
||||
|
||||
// Scan project-level skills (.claude/skills/*/SKILL.md)
|
||||
const projectSkillsDir = getProjectSkillsDir(cwd);
|
||||
console.log(`[skills] Scanning project skills: ${projectSkillsDir} (exists: ${fs.existsSync(projectSkillsDir)})`);
|
||||
const projectLevelSkills = scanProjectSkills(projectSkillsDir);
|
||||
console.log(`[skills] Found ${projectLevelSkills.length} project-level skills`);
|
||||
|
||||
// Deduplicate: project commands take priority over project skills with the same name
|
||||
const projectCommandNames = new Set(projectSkills.map((s) => s.name));
|
||||
const dedupedProjectSkills = projectLevelSkills.filter(
|
||||
(s) => !projectCommandNames.has(s.name)
|
||||
);
|
||||
|
||||
const agentsSkillsDir = getInstalledSkillsDir();
|
||||
const claudeSkillsDir = getClaudeSkillsDir();
|
||||
console.log(`[skills] Scanning installed: ${agentsSkillsDir} (exists: ${fs.existsSync(agentsSkillsDir)})`);
|
||||
@@ -267,8 +317,8 @@ export async function GET(request: NextRequest) {
|
||||
pluginSkills.push(...scanDirectory(dir, "plugin"));
|
||||
}
|
||||
|
||||
const all = [...globalSkills, ...projectSkills, ...installedSkills, ...pluginSkills];
|
||||
console.log(`[skills] Found: global=${globalSkills.length}, project=${projectSkills.length}, installed=${installedSkills.length}, plugin=${pluginSkills.length}`);
|
||||
const all = [...globalSkills, ...projectSkills, ...dedupedProjectSkills, ...installedSkills, ...pluginSkills];
|
||||
console.log(`[skills] Found: global=${globalSkills.length}, project=${projectSkills.length}, projectSkills=${dedupedProjectSkills.length}, installed=${installedSkills.length}, plugin=${pluginSkills.length}`);
|
||||
|
||||
return NextResponse.json({ skills: all });
|
||||
} catch (error) {
|
||||
|
||||
Reference in new issue
Block a user