Skip to content

Commit ddb2c00

Browse files
7418claude
andcommitted
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>
1 parent 5dda2ea commit ddb2c00

File tree

4 files changed

+101
-11
lines changed

4 files changed

+101
-11
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codepilot",
3-
"version": "0.22.0",
3+
"version": "0.22.1",
44
"private": true,
55
"author": {
66
"name": "op7418",

src/app/api/skills/[name]/route.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ function getProjectCommandsDir(cwd?: string): string {
1212
return path.join(cwd || process.cwd(), ".claude", "commands");
1313
}
1414

15+
function getProjectSkillsDir(cwd?: string): string {
16+
return path.join(cwd || process.cwd(), ".claude", "skills");
17+
}
18+
1519
function getInstalledSkillsDir(): string {
1620
return path.join(os.homedir(), ".agents", "skills");
1721
}
@@ -157,11 +161,39 @@ function findSkillFile(
157161
const installedSource = options?.installedSource;
158162

159163
if (!options?.installedOnly) {
160-
// Check project first, then global, then installed (~/.agents/skills/ and ~/.claude/skills/)
164+
// Check project commands → project skills → global commands → installed
161165
const projectPath = path.join(getProjectCommandsDir(options?.cwd), `${name}.md`);
162166
if (fs.existsSync(projectPath)) {
163167
return { filePath: projectPath, source: "project" };
164168
}
169+
170+
// Check project-level .claude/skills/{name}/SKILL.md
171+
const projectSkillPath = path.join(getProjectSkillsDir(options?.cwd), name, "SKILL.md");
172+
if (fs.existsSync(projectSkillPath)) {
173+
return { filePath: projectSkillPath, source: "project" };
174+
}
175+
176+
// Check project-level skills by front matter name (scan all subdirs)
177+
const projectSkillsDir = getProjectSkillsDir(options?.cwd);
178+
if (fs.existsSync(projectSkillsDir)) {
179+
try {
180+
const entries = fs.readdirSync(projectSkillsDir, { withFileTypes: true });
181+
for (const entry of entries) {
182+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
183+
if (entry.name === name) continue; // already checked above
184+
const skillMdPath = path.join(projectSkillsDir, entry.name, "SKILL.md");
185+
if (!fs.existsSync(skillMdPath)) continue;
186+
const skillContent = fs.readFileSync(skillMdPath, "utf-8");
187+
const meta = parseSkillFrontMatter(skillContent);
188+
if (meta.name === name) {
189+
return { filePath: skillMdPath, source: "project" };
190+
}
191+
}
192+
} catch {
193+
// ignore read errors
194+
}
195+
}
196+
165197
const globalPath = path.join(getGlobalCommandsDir(), `${name}.md`);
166198
if (fs.existsSync(globalPath)) {
167199
return { filePath: globalPath, source: "global" };
@@ -238,9 +270,16 @@ export async function GET(
238270

239271
const content = fs.readFileSync(found.filePath, "utf-8");
240272
const firstLine = content.split("\n")[0]?.trim() || "";
241-
const description = firstLine.startsWith("#")
242-
? firstLine.replace(/^#+\s*/, "")
243-
: firstLine || `Skill: /${name}`;
273+
let description: string;
274+
275+
if (found.filePath.endsWith("SKILL.md")) {
276+
const meta = parseSkillFrontMatter(content);
277+
description = meta.description || (firstLine.startsWith("#") ? firstLine.replace(/^#+\s*/, "") : firstLine || `Skill: /${name}`);
278+
} else {
279+
description = firstLine.startsWith("#")
280+
? firstLine.replace(/^#+\s*/, "")
281+
: firstLine || `Skill: /${name}`;
282+
}
244283

245284
return NextResponse.json({
246285
skill: {
@@ -271,6 +310,7 @@ export async function PUT(
271310

272311
const url = new URL(request.url);
273312
const sourceParam = url.searchParams.get("source");
313+
const cwdParam = url.searchParams.get("cwd") || undefined;
274314
const installedSource =
275315
sourceParam === "agents" || sourceParam === "claude"
276316
? (sourceParam as InstalledSource)
@@ -283,8 +323,8 @@ export async function PUT(
283323
}
284324

285325
const found = installedSource
286-
? findSkillFile(name, { installedSource, installedOnly: true })
287-
: findSkillFile(name);
326+
? findSkillFile(name, { installedSource, installedOnly: true, cwd: cwdParam })
327+
: findSkillFile(name, { cwd: cwdParam });
288328
if (found && "conflict" in found) {
289329
return NextResponse.json(
290330
{ error: "Multiple skills with different content", sources: found.sources },

src/app/api/skills/route.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ function getProjectCommandsDir(cwd?: string): string {
2424
return path.join(cwd || process.cwd(), ".claude", "commands");
2525
}
2626

27+
function getProjectSkillsDir(cwd?: string): string {
28+
return path.join(cwd || process.cwd(), ".claude", "skills");
29+
}
30+
2731
function getPluginCommandsDirs(): string[] {
2832
const dirs: string[] = [];
2933
const marketplacesDir = path.join(os.homedir(), ".claude", "plugins", "marketplaces");
@@ -57,6 +61,40 @@ function getClaudeSkillsDir(): string {
5761
return path.join(os.homedir(), ".claude", "skills");
5862
}
5963

64+
/**
65+
* Scan project-level skills from .claude/skills/{name}/SKILL.md.
66+
* Each subdirectory may contain a SKILL.md with optional YAML front matter.
67+
*/
68+
function scanProjectSkills(dir: string): SkillFile[] {
69+
const skills: SkillFile[] = [];
70+
if (!fs.existsSync(dir)) return skills;
71+
72+
try {
73+
const entries = fs.readdirSync(dir, { withFileTypes: true });
74+
for (const entry of entries) {
75+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
76+
const skillMdPath = path.join(dir, entry.name, "SKILL.md");
77+
if (!fs.existsSync(skillMdPath)) continue;
78+
79+
const content = fs.readFileSync(skillMdPath, "utf-8");
80+
const meta = parseSkillFrontMatter(content);
81+
const name = meta.name || entry.name;
82+
const description = meta.description || `Skill: /${name}`;
83+
84+
skills.push({
85+
name,
86+
description,
87+
content,
88+
source: "project",
89+
filePath: skillMdPath,
90+
});
91+
}
92+
} catch {
93+
// ignore read errors
94+
}
95+
return skills;
96+
}
97+
6098
function computeContentHash(content: string): string {
6199
return crypto.createHash("sha1").update(content, "utf8").digest("hex");
62100
}
@@ -240,6 +278,18 @@ export async function GET(request: NextRequest) {
240278
const globalSkills = scanDirectory(globalDir, "global");
241279
const projectSkills = scanDirectory(projectDir, "project");
242280

281+
// Scan project-level skills (.claude/skills/*/SKILL.md)
282+
const projectSkillsDir = getProjectSkillsDir(cwd);
283+
console.log(`[skills] Scanning project skills: ${projectSkillsDir} (exists: ${fs.existsSync(projectSkillsDir)})`);
284+
const projectLevelSkills = scanProjectSkills(projectSkillsDir);
285+
console.log(`[skills] Found ${projectLevelSkills.length} project-level skills`);
286+
287+
// Deduplicate: project commands take priority over project skills with the same name
288+
const projectCommandNames = new Set(projectSkills.map((s) => s.name));
289+
const dedupedProjectSkills = projectLevelSkills.filter(
290+
(s) => !projectCommandNames.has(s.name)
291+
);
292+
243293
const agentsSkillsDir = getInstalledSkillsDir();
244294
const claudeSkillsDir = getClaudeSkillsDir();
245295
console.log(`[skills] Scanning installed: ${agentsSkillsDir} (exists: ${fs.existsSync(agentsSkillsDir)})`);
@@ -267,8 +317,8 @@ export async function GET(request: NextRequest) {
267317
pluginSkills.push(...scanDirectory(dir, "plugin"));
268318
}
269319

270-
const all = [...globalSkills, ...projectSkills, ...installedSkills, ...pluginSkills];
271-
console.log(`[skills] Found: global=${globalSkills.length}, project=${projectSkills.length}, installed=${installedSkills.length}, plugin=${pluginSkills.length}`);
320+
const all = [...globalSkills, ...projectSkills, ...dedupedProjectSkills, ...installedSkills, ...pluginSkills];
321+
console.log(`[skills] Found: global=${globalSkills.length}, project=${projectSkills.length}, projectSkills=${dedupedProjectSkills.length}, installed=${installedSkills.length}, plugin=${pluginSkills.length}`);
272322

273323
return NextResponse.json({ skills: all });
274324
} catch (error) {

0 commit comments

Comments
 (0)