From 61e3720dedc9a0a5847544e402e28f9a41e36f98 Mon Sep 17 00:00:00 2001 From: gy212 <2124065319@qq.com> Date: Mon, 9 Feb 2026 11:10:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=E8=AF=BB=E5=8F=96=20~?= =?UTF-8?q?/.claude/skills/=20=E7=9B=AE=E5=BD=95=E4=B8=8B=E7=9A=84?= =?UTF-8?q?=E5=B7=B2=E5=AE=89=E8=A3=85=E6=8A=80=E8=83=BD=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:CodePilot 只扫描 ~/.agents/skills/,缺失了 ~/.claude/skills/ 路径, 导致用户通过 CLI 安装到该目录的技能无法在 CodePilot 中显示和使用。 修改内容: 后端: - 列表接口 (GET /api/skills) 同时扫描 ~/.agents/skills/ 和 ~/.claude/skills/ - 通过 SHA1 内容哈希去重:同名同内容只保留一条,同名不同内容保留两条 - 详情接口 (GET/PUT/DELETE /api/skills/:name) 新增 ?source= 参数精确定位来源 - 详情接口使用 YAML front matter 的 name 字段匹配,修复列表能看到但点开 404 的问题 - 同名不同内容时返回 409,要求前端传 source 消歧 前端: - SkillItem 类型新增 installedSource 字段 - Badge 显示 installed:claude 或 installed:agents 明确来源 - 保存/删除时携带 ?source= 防止误操作同名技能 - 聊天补全项描述追加来源提示 - 技能展开时精准请求对应来源 Co-Authored-By: Claude Opus 4.6 --- src/app/api/skills/[name]/route.ts | 265 ++++++++++++++++++++++-- src/app/api/skills/route.ts | 88 +++++++- src/components/chat/MessageInput.tsx | 29 ++- src/components/skills/SkillEditor.tsx | 18 +- src/components/skills/SkillListItem.tsx | 5 +- src/components/skills/SkillsManager.tsx | 62 ++++-- 6 files changed, 413 insertions(+), 54 deletions(-) diff --git a/src/app/api/skills/[name]/route.ts b/src/app/api/skills/[name]/route.ts index 738e663e5..aec503d8e 100644 --- a/src/app/api/skills/[name]/route.ts +++ b/src/app/api/skills/[name]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; import os from "os"; +import crypto from "crypto"; function getGlobalCommandsDir(): string { return path.join(os.homedir(), ".claude", "commands"); @@ -15,23 +16,190 @@ function getInstalledSkillsDir(): string { return path.join(os.homedir(), ".agents", "skills"); } +function getClaudeSkillsDir(): string { + return path.join(os.homedir(), ".claude", "skills"); +} + +type InstalledSource = "agents" | "claude"; +type SkillSource = "global" | "project" | "installed"; +type SkillMatch = { + filePath: string; + source: SkillSource; + installedSource?: InstalledSource; +}; + +function computeContentHash(content: string): string { + return crypto.createHash("sha1").update(content, "utf8").digest("hex"); +} + +/** + * Parse YAML front matter from SKILL.md content. + * Extracts `name` and `description` fields from the --- delimited block. + */ +function parseSkillFrontMatter(content: string): { name?: string; description?: string } { + const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/); + if (!fmMatch) return {}; + + const frontMatter = fmMatch[1]; + const lines = frontMatter.split(/\r?\n/); + const result: { name?: string; description?: string } = {}; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + const nameMatch = line.match(/^name:\s*(.+)/); + if (nameMatch) { + result.name = nameMatch[1].trim(); + continue; + } + + if (/^description:\s*\|/.test(line)) { + const descLines: string[] = []; + for (let j = i + 1; j < lines.length; j++) { + if (/^\s+/.test(lines[j])) { + descLines.push(lines[j].trim()); + } else { + break; + } + } + if (descLines.length > 0) { + result.description = descLines.filter(Boolean).join(" "); + } + continue; + } + + const descMatch = line.match(/^description:\s+(.+)/); + if (descMatch) { + result.description = descMatch[1].trim(); + } + } + return result; +} + +function countInstalledSkills(dir: string): number { + if (!fs.existsSync(dir)) return 0; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + let count = 0; + 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)) count += 1; + } + return count; + } catch { + return 0; + } +} + +function getPreferredInstalledSource(): InstalledSource { + const agentsCount = countInstalledSkills(getInstalledSkillsDir()); + const claudeCount = countInstalledSkills(getClaudeSkillsDir()); + return agentsCount === claudeCount + ? "claude" + : agentsCount > claudeCount + ? "agents" + : "claude"; +} + +type InstalledMatch = { + filePath: string; + installedSource: InstalledSource; + contentHash: string; +}; + +function findInstalledSkillMatches( + name: string, + installedSource?: InstalledSource +): InstalledMatch[] { + const matches: InstalledMatch[] = []; + const dirs: Array<{ dir: string; source: InstalledSource }> = []; + if (!installedSource || installedSource === "agents") { + dirs.push({ dir: getInstalledSkillsDir(), source: "agents" }); + } + if (!installedSource || installedSource === "claude") { + dirs.push({ dir: getClaudeSkillsDir(), source: "claude" }); + } + + for (const { dir, source } of dirs) { + if (!fs.existsSync(dir)) continue; + 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 skillName = meta.name || entry.name; + if (skillName !== name) continue; + matches.push({ + filePath: skillMdPath, + installedSource: source, + contentHash: computeContentHash(content), + }); + } + } catch { + // ignore read errors + } + } + + return matches; +} + function findSkillFile( - name: string -): { filePath: string; source: "global" | "project" | "installed" } | null { - // Check project first, then global, then installed (~/.agents/skills/) - const projectPath = path.join(getProjectCommandsDir(), `${name}.md`); - if (fs.existsSync(projectPath)) { - return { filePath: projectPath, source: "project" }; + name: string, + options?: { installedSource?: InstalledSource; installedOnly?: boolean } +): + | SkillMatch + | { conflict: true; sources: InstalledSource[] } + | null { + const installedSource = options?.installedSource; + + if (!options?.installedOnly) { + // Check project first, then global, then installed (~/.agents/skills/ and ~/.claude/skills/) + const projectPath = path.join(getProjectCommandsDir(), `${name}.md`); + if (fs.existsSync(projectPath)) { + return { filePath: projectPath, source: "project" }; + } + const globalPath = path.join(getGlobalCommandsDir(), `${name}.md`); + if (fs.existsSync(globalPath)) { + return { filePath: globalPath, source: "global" }; + } } - const globalPath = path.join(getGlobalCommandsDir(), `${name}.md`); - if (fs.existsSync(globalPath)) { - return { filePath: globalPath, source: "global" }; + + const installedMatches = findInstalledSkillMatches(name, installedSource); + if (installedMatches.length === 1) { + const match = installedMatches[0]; + return { + filePath: match.filePath, + source: "installed", + installedSource: match.installedSource, + }; } - // Installed skills: ~/.agents/skills/{name}/SKILL.md - const installedPath = path.join(getInstalledSkillsDir(), name, "SKILL.md"); - if (fs.existsSync(installedPath)) { - return { filePath: installedPath, source: "installed" }; + + if (installedMatches.length > 1) { + const uniqueHashes = new Set(installedMatches.map((m) => m.contentHash)); + if (uniqueHashes.size === 1) { + const preferred = getPreferredInstalledSource(); + const preferredMatch = + installedMatches.find((m) => m.installedSource === preferred) || + installedMatches[0]; + return { + filePath: preferredMatch.filePath, + source: "installed", + installedSource: preferredMatch.installedSource, + }; + } + + return { + conflict: true, + sources: Array.from( + new Set(installedMatches.map((m) => m.installedSource)) + ), + }; } + return null; } @@ -41,7 +209,28 @@ export async function GET( ) { try { const { name } = await params; - const found = findSkillFile(name); + const url = new URL(_request.url); + const sourceParam = url.searchParams.get("source"); + const installedSource = + sourceParam === "agents" || sourceParam === "claude" + ? (sourceParam as InstalledSource) + : undefined; + if (sourceParam && !installedSource) { + return NextResponse.json( + { error: "Invalid source; expected 'agents' or 'claude'" }, + { status: 400 } + ); + } + + const found = installedSource + ? findSkillFile(name, { installedSource, installedOnly: true }) + : findSkillFile(name); + if (found && "conflict" in found) { + return NextResponse.json( + { error: "Multiple skills with different content", sources: found.sources }, + { status: 409 } + ); + } if (!found) { return NextResponse.json({ error: "Skill not found" }, { status: 404 }); } @@ -58,6 +247,7 @@ export async function GET( description, content, source: found.source, + installedSource: found.installedSource, filePath: found.filePath, }, }); @@ -78,7 +268,28 @@ export async function PUT( const body = await request.json(); const { content } = body as { content: string }; - const found = findSkillFile(name); + const url = new URL(request.url); + const sourceParam = url.searchParams.get("source"); + const installedSource = + sourceParam === "agents" || sourceParam === "claude" + ? (sourceParam as InstalledSource) + : undefined; + if (sourceParam && !installedSource) { + return NextResponse.json( + { error: "Invalid source; expected 'agents' or 'claude'" }, + { status: 400 } + ); + } + + const found = installedSource + ? findSkillFile(name, { installedSource, installedOnly: true }) + : findSkillFile(name); + if (found && "conflict" in found) { + return NextResponse.json( + { error: "Multiple skills with different content", sources: found.sources }, + { status: 409 } + ); + } if (!found) { return NextResponse.json({ error: "Skill not found" }, { status: 404 }); } @@ -96,6 +307,7 @@ export async function PUT( description, content: content ?? "", source: found.source, + installedSource: found.installedSource, filePath: found.filePath, }, }); @@ -113,7 +325,28 @@ export async function DELETE( ) { try { const { name } = await params; - const found = findSkillFile(name); + const url = new URL(_request.url); + const sourceParam = url.searchParams.get("source"); + const installedSource = + sourceParam === "agents" || sourceParam === "claude" + ? (sourceParam as InstalledSource) + : undefined; + if (sourceParam && !installedSource) { + return NextResponse.json( + { error: "Invalid source; expected 'agents' or 'claude'" }, + { status: 400 } + ); + } + + const found = installedSource + ? findSkillFile(name, { installedSource, installedOnly: true }) + : findSkillFile(name); + if (found && "conflict" in found) { + return NextResponse.json( + { error: "Multiple skills with different content", sources: found.sources }, + { status: 409 } + ); + } if (!found) { return NextResponse.json({ error: "Skill not found" }, { status: 404 }); } diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index dd9f5591c..8641f1ecf 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -2,15 +2,20 @@ import { NextRequest, NextResponse } from "next/server"; import fs from "fs"; import path from "path"; import os from "os"; +import crypto from "crypto"; interface SkillFile { name: string; description: string; content: string; source: "global" | "project" | "plugin" | "installed"; + installedSource?: "agents" | "claude"; filePath: string; } +type InstalledSource = "agents" | "claude"; +type InstalledSkill = SkillFile & { installedSource: InstalledSource; contentHash: string }; + function getGlobalCommandsDir(): string { return path.join(os.homedir(), ".claude", "commands"); } @@ -48,6 +53,14 @@ function getInstalledSkillsDir(): string { return path.join(os.homedir(), ".agents", "skills"); } +function getClaudeSkillsDir(): string { + return path.join(os.homedir(), ".claude", "skills"); +} + +function computeContentHash(content: string): string { + return crypto.createHash("sha1").update(content, "utf8").digest("hex"); +} + /** * Parse YAML front matter from SKILL.md content. * Extracts `name` and `description` fields from the --- delimited block. @@ -97,12 +110,15 @@ function parseSkillFrontMatter(content: string): { name?: string; description?: } /** - * Scan ~/.agents/skills/ for installed skills (npx skills add). - * Each skill is a directory containing a SKILL.md with YAML front matter. + * Scan a directory for installed skills. + * Each skill is a subdirectory containing a SKILL.md with YAML front matter. + * Used for both ~/.agents/skills/ and ~/.claude/skills/. */ -function scanInstalledSkills(): SkillFile[] { - const skills: SkillFile[] = []; - const dir = getInstalledSkillsDir(); +function scanInstalledSkills( + dir: string, + installedSource: InstalledSource +): InstalledSkill[] { + const skills: InstalledSkill[] = []; if (!fs.existsSync(dir)) return skills; try { @@ -116,12 +132,15 @@ function scanInstalledSkills(): SkillFile[] { const meta = parseSkillFrontMatter(content); const name = meta.name || entry.name; const description = meta.description || `Installed skill: /${name}`; + const contentHash = computeContentHash(content); skills.push({ name, description, content, source: "installed", + installedSource, + contentHash, filePath: skillMdPath, }); } @@ -131,6 +150,43 @@ function scanInstalledSkills(): SkillFile[] { return skills; } +function resolveInstalledSkills( + agentsSkills: InstalledSkill[], + claudeSkills: InstalledSkill[], + preferredSource: InstalledSource +): SkillFile[] { + const all = [...agentsSkills, ...claudeSkills]; + const byName = new Map(); + for (const skill of all) { + const existing = byName.get(skill.name); + if (existing) { + existing.push(skill); + } else { + byName.set(skill.name, [skill]); + } + } + + const resolved: InstalledSkill[] = []; + for (const group of byName.values()) { + if (group.length === 1) { + resolved.push(group[0]); + continue; + } + + const uniqueHashes = new Set(group.map((s) => s.contentHash)); + if (uniqueHashes.size === 1) { + const preferred = + group.find((s) => s.installedSource === preferredSource) || group[0]; + resolved.push(preferred); + continue; + } + + resolved.push(...group); + } + + return resolved.map(({ contentHash: _contentHash, ...rest }) => rest); +} + function scanDirectory( dir: string, source: "global" | "project" | "plugin", @@ -183,7 +239,27 @@ export async function GET(request: NextRequest) { const globalSkills = scanDirectory(globalDir, "global"); const projectSkills = scanDirectory(projectDir, "project"); - const installedSkills = scanInstalledSkills(); + + const agentsSkillsDir = getInstalledSkillsDir(); + const claudeSkillsDir = getClaudeSkillsDir(); + console.log(`[skills] Scanning installed: ${agentsSkillsDir} (exists: ${fs.existsSync(agentsSkillsDir)})`); + console.log(`[skills] Scanning installed: ${claudeSkillsDir} (exists: ${fs.existsSync(claudeSkillsDir)})`); + const agentsSkills = scanInstalledSkills(agentsSkillsDir, "agents"); + const claudeSkills = scanInstalledSkills(claudeSkillsDir, "claude"); + const preferredInstalledSource: InstalledSource = + agentsSkills.length === claudeSkills.length + ? "claude" + : agentsSkills.length > claudeSkills.length + ? "agents" + : "claude"; + console.log( + `[skills] Installed counts: agents=${agentsSkills.length}, claude=${claudeSkills.length}, preferred=${preferredInstalledSource}` + ); + const installedSkills = resolveInstalledSkills( + agentsSkills, + claudeSkills, + preferredInstalledSource + ); // Scan installed plugin skills const pluginSkills: SkillFile[] = []; diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index b81abc2a4..0de92bf68 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -63,6 +63,7 @@ interface PopoverItem { description?: string; builtIn?: boolean; immediate?: boolean; + installedSource?: "agents" | "claude"; } interface CommandBadge { @@ -70,6 +71,7 @@ interface CommandBadge { label: string; description: string; isSkill: boolean; + installedSource?: "agents" | "claude"; } type PopoverMode = 'file' | 'skill' | null; @@ -369,12 +371,19 @@ export function MessageInput({ const skills = data.skills || []; apiSkills = skills .filter((s: { name: string }) => s.name.toLowerCase().includes(filter.toLowerCase())) - .map((s: { name: string; description: string }) => ({ - label: s.name, - value: `/${s.name}`, - description: s.description, - builtIn: false, - })); + .map((s: { name: string; description: string; source?: string; installedSource?: "agents" | "claude" }) => { + const sourceHint = + s.source === "installed" && s.installedSource + ? ` (${s.installedSource})` + : ""; + return { + label: s.name, + value: `/${s.name}`, + description: `${s.description || ""}${sourceHint}`, + builtIn: false, + installedSource: s.installedSource, + }; + }); } } catch { // API not available - just use built-in commands @@ -417,6 +426,7 @@ export function MessageInput({ label: item.label, description: item.description || '', isSkill: !item.builtIn, + installedSource: item.installedSource, }); setInputValue(''); closePopover(); @@ -518,7 +528,12 @@ export function MessageInput({ if (badge.isSkill) { // Fetch skill content from API try { - const res = await fetch(`/api/skills/${encodeURIComponent(badge.label)}`); + const sourceParam = badge.installedSource + ? `?source=${badge.installedSource}` + : ""; + const res = await fetch( + `/api/skills/${encodeURIComponent(badge.label)}${sourceParam}` + ); if (res.ok) { const data = await res.json(); expandedPrompt = data.skill?.content || ''; diff --git a/src/components/skills/SkillEditor.tsx b/src/components/skills/SkillEditor.tsx index 14f790e30..3df977507 100644 --- a/src/components/skills/SkillEditor.tsx +++ b/src/components/skills/SkillEditor.tsx @@ -29,7 +29,7 @@ type ViewMode = "edit" | "preview" | "split"; interface SkillEditorProps { skill: SkillItem; - onSave: (name: string, content: string) => Promise; + onSave: (skill: SkillItem, content: string) => Promise; onDelete: (skill: SkillItem) => void; } @@ -53,13 +53,13 @@ export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) { const handleSave = useCallback(async () => { setSaving(true); try { - await onSave(skill.name, content); + await onSave(skill, content); setSaved(true); setTimeout(() => setSaved(false), 2000); } finally { setSaving(false); } - }, [skill.name, content, onSave]); + }, [skill, content, onSave]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -121,15 +121,23 @@ export function SkillEditor({ skill, onSave, onDelete }: SkillEditorProps) { "text-[10px] px-1.5 py-0 shrink-0", skill.source === "global" ? "border-green-500/40 text-green-600 dark:text-green-400" - : "border-blue-500/40 text-blue-600 dark:text-blue-400" + : skill.source === "installed" + ? "border-orange-500/40 text-orange-600 dark:text-orange-400" + : skill.source === "plugin" + ? "border-purple-500/40 text-purple-600 dark:text-purple-400" + : "border-blue-500/40 text-blue-600 dark:text-blue-400" )} > {skill.source === "global" ? ( + ) : skill.source === "installed" ? ( + ) : ( )} - {skill.source} + {skill.source === "installed" && skill.installedSource + ? `installed:${skill.installedSource}` + : skill.source} diff --git a/src/components/skills/SkillListItem.tsx b/src/components/skills/SkillListItem.tsx index a75c7f7ab..06e32ee34 100644 --- a/src/components/skills/SkillListItem.tsx +++ b/src/components/skills/SkillListItem.tsx @@ -17,6 +17,7 @@ export interface SkillItem { description: string; content: string; source: "global" | "project" | "plugin" | "installed"; + installedSource?: "agents" | "claude"; filePath: string; } @@ -89,7 +90,9 @@ export function SkillListItem({ ) : ( )} - {skill.source} + {skill.source === "installed" && skill.installedSource + ? `installed:${skill.installedSource}` + : skill.source}

diff --git a/src/components/skills/SkillsManager.tsx b/src/components/skills/SkillsManager.tsx index a17fcb47a..becb0b29b 100644 --- a/src/components/skills/SkillsManager.tsx +++ b/src/components/skills/SkillsManager.tsx @@ -54,9 +54,17 @@ export function SkillsManager() { [] ); + const buildSkillUrl = useCallback((skill: SkillItem) => { + const base = `/api/skills/${encodeURIComponent(skill.name)}`; + if (skill.source === "installed" && skill.installedSource) { + return `${base}?source=${skill.installedSource}`; + } + return base; + }, []); + const handleSave = useCallback( - async (name: string, content: string) => { - const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { + async (skill: SkillItem, content: string) => { + const res = await fetch(buildSkillUrl(skill), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), @@ -68,32 +76,44 @@ export function SkillsManager() { const data = await res.json(); // Update in list setSkills((prev) => - prev.map((s) => (s.name === name && s.source === data.skill.source ? data.skill : s)) + prev.map((s) => + s.name === skill.name && + s.source === data.skill.source && + s.installedSource === data.skill.installedSource + ? data.skill + : s + ) ); // Update selected setSelected(data.skill); }, - [] + [buildSkillUrl] ); const handleDelete = useCallback( async (skill: SkillItem) => { - const res = await fetch( - `/api/skills/${encodeURIComponent(skill.name)}`, - { method: "DELETE" } - ); + const res = await fetch(buildSkillUrl(skill), { method: "DELETE" }); if (res.ok) { setSkills((prev) => prev.filter( - (s) => !(s.name === skill.name && s.source === skill.source) + (s) => + !( + s.name === skill.name && + s.source === skill.source && + s.installedSource === skill.installedSource + ) ) ); - if (selected?.name === skill.name && selected?.source === skill.source) { + if ( + selected?.name === skill.name && + selected?.source === skill.source && + selected?.installedSource === skill.installedSource + ) { setSelected(null); } } }, - [selected] + [buildSkillUrl, selected] ); const filtered = skills.filter( @@ -153,11 +173,12 @@ export function SkillsManager() { {projectSkills.map((skill) => ( setSelected(skill)} onDelete={handleDelete} @@ -172,11 +193,12 @@ export function SkillsManager() { {globalSkills.map((skill) => ( setSelected(skill)} onDelete={handleDelete} @@ -191,11 +213,12 @@ export function SkillsManager() { {installedSkills.map((skill) => ( setSelected(skill)} onDelete={handleDelete} @@ -210,11 +233,12 @@ export function SkillsManager() { {pluginSkills.map((skill) => ( setSelected(skill)} onDelete={handleDelete}