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:
7418andClaude Opus 4.6 committed 2026-03-02 17:30:57 +08:00
1 parent 5dda2ea1e3
commit ddb2c00a8c
4 files changed
+101 -11

No files matched your search

+46 -6
View File
@@ -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 },
+52 -2
View File
@@ -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) {