fix: 支持读取 ~/.claude/skills/ 目录下的已安装技能 (#36)

fix: 支持读取 ~/.claude/skills/ 目录下的已安装技能
This commit is contained in:
歸藏 authored and GitHub committed 2026-02-09 16:07:58 +08:00
commit 336897cded
6 files changed
+413 -54

No files matched your search

+249 -16
View File
@@ -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 });
}
+82 -6
View File
@@ -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<string, InstalledSkill[]>();
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[] = [];
+22 -7
View File
@@ -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 || '';
+13 -5
View File
@@ -29,7 +29,7 @@ type ViewMode = "edit" | "preview" | "split";
interface SkillEditorProps {
skill: SkillItem;
onSave: (name: string, content: string) => Promise<void>;
onSave: (skill: SkillItem, content: string) => Promise<void>;
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<HTMLTextAreaElement>) => {
@@ -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" ? (
<HugeiconsIcon icon={GlobeIcon} className="h-2.5 w-2.5 mr-0.5" />
) : skill.source === "installed" ? (
<HugeiconsIcon icon={FolderOpenIcon} className="h-2.5 w-2.5 mr-0.5" />
) : (
<HugeiconsIcon icon={FolderOpenIcon} className="h-2.5 w-2.5 mr-0.5" />
)}
{skill.source}
{skill.source === "installed" && skill.installedSource
? `installed:${skill.installedSource}`
: skill.source}
</Badge>
</div>
+4 -1
View File
@@ -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({
) : (
<HugeiconsIcon icon={FolderOpenIcon} className="h-2.5 w-2.5 mr-0.5" />
)}
{skill.source}
{skill.source === "installed" && skill.installedSource
? `installed:${skill.installedSource}`
: skill.source}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
+43 -19
View File
@@ -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() {
</span>
{projectSkills.map((skill) => (
<SkillListItem
key={`${skill.source}:${skill.name}`}
key={`${skill.source}:${skill.installedSource ?? "default"}:${skill.name}`}
skill={skill}
selected={
selected?.name === skill.name &&
selected?.source === skill.source
selected?.source === skill.source &&
selected?.installedSource === skill.installedSource
}
onSelect={() => setSelected(skill)}
onDelete={handleDelete}
@@ -172,11 +193,12 @@ export function SkillsManager() {
</span>
{globalSkills.map((skill) => (
<SkillListItem
key={`${skill.source}:${skill.name}`}
key={`${skill.source}:${skill.installedSource ?? "default"}:${skill.name}`}
skill={skill}
selected={
selected?.name === skill.name &&
selected?.source === skill.source
selected?.source === skill.source &&
selected?.installedSource === skill.installedSource
}
onSelect={() => setSelected(skill)}
onDelete={handleDelete}
@@ -191,11 +213,12 @@ export function SkillsManager() {
</span>
{installedSkills.map((skill) => (
<SkillListItem
key={`${skill.source}:${skill.name}`}
key={`${skill.source}:${skill.installedSource ?? "default"}:${skill.name}`}
skill={skill}
selected={
selected?.name === skill.name &&
selected?.source === skill.source
selected?.source === skill.source &&
selected?.installedSource === skill.installedSource
}
onSelect={() => setSelected(skill)}
onDelete={handleDelete}
@@ -210,11 +233,12 @@ export function SkillsManager() {
</span>
{pluginSkills.map((skill) => (
<SkillListItem
key={skill.filePath || `${skill.source}:${skill.name}`}
key={skill.filePath || `${skill.source}:${skill.installedSource ?? "default"}:${skill.name}`}
skill={skill}
selected={
selected?.name === skill.name &&
selected?.source === skill.source
selected?.source === skill.source &&
selected?.installedSource === skill.installedSource
}
onSelect={() => setSelected(skill)}
onDelete={handleDelete}