diff --git a/package-lock.json b/package-lock.json index c5f233f..715f35b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.22.0", + "version": "0.22.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.22.0", + "version": "0.22.1", "dependencies": { "@ai-sdk/anthropic": "^3.0.47", "@ai-sdk/google": "^3.0.31", diff --git a/package.json b/package.json index 47ac3d9..2d475a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.22.0", + "version": "0.22.1", "private": true, "author": { "name": "op7418", diff --git a/src/app/api/skills/[name]/route.ts b/src/app/api/skills/[name]/route.ts index 2e1faa8..2f3702d 100644 --- a/src/app/api/skills/[name]/route.ts +++ b/src/app/api/skills/[name]/route.ts @@ -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 }, diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 21c9b4e..a1e4011 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -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) {