fix: 支持读取 ~/.claude/skills/ 目录下的已安装技能 (#36)
fix: 支持读取 ~/.claude/skills/ 目录下的已安装技能
This commit is contained in:
6 files changed
+413
-54
No files matched your search
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user