Feat/claude skills management (#237)

* feat(skills): add Claude Skills management feature

Implement complete Skills management system with repository discovery,
installation, and lifecycle management capabilities.

Backend:
- Add SkillService with GitHub integration and installation logic
- Implement skill commands (list, install, uninstall, check updates)
- Support multiple skill repositories with caching

Frontend:
- Add Skills management page with repository browser
- Create SkillCard and RepoManager components
- Add badge, card, table UI components
- Integrate Skills API with Tauri commands

Files: 10 files changed, 1488 insertions(+)

* feat(skills): integrate Skills feature into application

Integrate Skills management feature with complete dependency updates,
configuration structure extensions, and internationalization support.

Dependencies:
- Add @radix-ui/react-visually-hidden for accessibility
- Add anyhow, zip, serde_yaml, tempfile for Skills backend
- Enable chrono serde feature for timestamp serialization

Backend Integration:
- Extend MultiAppConfig with SkillStore field
- Implement skills.json migration from legacy location
- Register SkillService and skill commands in main app
- Export skill module in commands and services

Frontend Integration:
- Add Skills page route and dialog in App
- Integrate Skills UI with main navigation

Internationalization:
- Add complete Chinese translations for Skills UI
- Add complete English translations for Skills UI

Code Quality:
- Remove redundant blank lines in gemini_mcp.rs
- Format log statements in mcp.rs

Tests:
- Update import_export_sync tests for SkillStore
- Update mcp_commands tests for new structure

Files: 16 files changed, 540 insertions(+), 39 deletions(-)

* style(skills): improve SkillsPage typography and spacing

Optimize visual hierarchy and readability of Skills page:
- Reduce title size from 2xl to lg with tighter tracking
- Improve description spacing and color contrast
- Enhance empty state with better text hierarchy
- Use explicit gray colors for better dark mode support

* feat(skills): support custom subdirectory path for skill scanning

Add optional skillsPath field to SkillRepo to enable scanning skills
from subdirectories (e.g., "skills/") instead of repository root.

Changes:
- Backend: Add skillsPath field with subdirectory scanning logic
- Frontend: Add skillsPath input field and display in repo list
- Presets: Add cexll/myclaude repo with skills/ subdirectory
- Code quality: Fix clippy warnings (dedup logic, string formatting)

Backward compatible: skillsPath is optional, defaults to root scanning.

* refactor(skills): improve repo manager dialog layout

Optimize dialog structure with fixed header and scrollable content:
- Add flexbox layout with fixed header and scrollable body
- Remove outer border wrapper for cleaner appearance
- Match SkillsPage design pattern for consistency
- Improve UX with better content hierarchy
This commit is contained in:
YoVinchen
2025-11-18 22:05:54 +08:00
committed by GitHub
parent 023726c59d
commit ec303544ca
21 changed files with 2034 additions and 7 deletions

View File

@@ -22,7 +22,15 @@ import { UpdateBadge } from "@/components/UpdateBadge";
import UsageScriptModal from "@/components/UsageScriptModal";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
function App() {
const { t } = useTranslation();
@@ -33,6 +41,7 @@ function App() {
const [isAddOpen, setIsAddOpen] = useState(false);
const [isMcpOpen, setIsMcpOpen] = useState(false);
const [isPromptOpen, setIsPromptOpen] = useState(false);
const [isSkillsOpen, setIsSkillsOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
@@ -218,6 +227,13 @@ function App() {
>
MCP
</Button>
<Button
variant="mcp"
onClick={() => setIsSkillsOpen(true)}
className="min-w-[80px]"
>
{t("skills.manage")}
</Button>
<Button onClick={() => setIsAddOpen(true)}>
<Plus className="h-4 w-4" />
{t("header.addProvider")}
@@ -303,6 +319,17 @@ function App() {
/>
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
<Dialog open={isSkillsOpen} onOpenChange={setIsSkillsOpen}>
<DialogContent className="max-w-4xl max-h-[85vh] min-h-[600px] flex flex-col p-0">
<DialogHeader className="sr-only">
<VisuallyHidden>
<DialogTitle>{t("skills.title")}</DialogTitle>
</VisuallyHidden>
</DialogHeader>
<SkillsPage onClose={() => setIsSkillsOpen(false)} />
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,218 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Trash2, ExternalLink, Plus } from "lucide-react";
import { settingsApi } from "@/lib/api";
import type { Skill, SkillRepo } from "@/lib/api/skills";
interface RepoManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
repos: SkillRepo[];
skills: Skill[];
onAdd: (repo: SkillRepo) => Promise<void>;
onRemove: (owner: string, name: string) => Promise<void>;
}
export function RepoManager({
open: isOpen,
onOpenChange,
repos,
skills,
onAdd,
onRemove,
}: RepoManagerProps) {
const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState("");
const [skillsPath, setSkillsPath] = useState("");
const [error, setError] = useState("");
const getSkillCount = (repo: SkillRepo) =>
skills.filter(
(skill) =>
skill.repoOwner === repo.owner &&
skill.repoName === repo.name &&
(skill.repoBranch || "main") === (repo.branch || "main"),
).length;
const parseRepoUrl = (
url: string,
): { owner: string; name: string } | null => {
// 支持格式:
// - https://github.com/owner/name
// - owner/name
// - https://github.com/owner/name.git
let cleaned = url.trim();
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
cleaned = cleaned.replace(/\.git$/, "");
const parts = cleaned.split("/");
if (parts.length === 2 && parts[0] && parts[1]) {
return { owner: parts[0], name: parts[1] };
}
return null;
};
const handleAdd = async () => {
setError("");
const parsed = parseRepoUrl(repoUrl);
if (!parsed) {
setError(t("skills.repo.invalidUrl"));
return;
}
try {
await onAdd({
owner: parsed.owner,
name: parsed.name,
branch: branch || "main",
enabled: true,
skillsPath: skillsPath.trim() || undefined, // 仅在有值时传递
});
setRepoUrl("");
setBranch("");
setSkillsPath("");
} catch (e) {
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
}
};
const handleOpenRepo = async (owner: string, name: string) => {
try {
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
} catch (error) {
console.error("Failed to open URL:", error);
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0">
{/* 固定头部 */}
<DialogHeader className="flex-shrink-0 border-b border-border-default px-6 py-4">
<DialogTitle>{t("skills.repo.title")}</DialogTitle>
<DialogDescription>{t("skills.repo.description")}</DialogDescription>
</DialogHeader>
{/* 可滚动内容区域 */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4">
{/* 添加仓库表单 */}
<div className="space-y-5">
<div className="space-y-2">
<Label htmlFor="repo-url">{t("skills.repo.url")}</Label>
<div className="flex flex-col gap-3">
<Input
id="repo-url"
placeholder={t("skills.repo.urlPlaceholder")}
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
className="flex-1"
/>
<div className="flex flex-col gap-3 sm:flex-row">
<Input
id="branch"
placeholder={t("skills.repo.branchPlaceholder")}
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="flex-1"
/>
<Input
id="skills-path"
placeholder={t("skills.repo.pathPlaceholder")}
value={skillsPath}
onChange={(e) => setSkillsPath(e.target.value)}
className="flex-1"
/>
<Button
onClick={handleAdd}
className="w-full sm:w-auto sm:px-4"
variant="mcp"
type="button"
>
<Plus className="h-4 w-4 mr-2" />
{t("skills.repo.add")}
</Button>
</div>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
{/* 仓库列表 */}
<div className="space-y-3">
<h4 className="text-sm font-medium">{t("skills.repo.list")}</h4>
{repos.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("skills.repo.empty")}
</p>
) : (
<div className="space-y-3">
{repos.map((repo) => (
<div
key={`${repo.owner}/${repo.name}`}
className="flex items-center justify-between rounded-xl border border-border-default bg-card px-4 py-3"
>
<div>
<div className="text-sm font-medium text-foreground">
{repo.owner}/{repo.name}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("skills.repo.branch")}: {repo.branch || "main"}
{repo.skillsPath && (
<>
<span className="mx-2"></span>
{t("skills.repo.path")}: {repo.skillsPath}
</>
)}
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
{t("skills.repo.skillCount", {
count: getSkillCount(repo),
})}
</span>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => handleOpenRepo(repo.owner, repo.name)}
title={t("common.view", { defaultValue: "查看" })}
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => onRemove(repo.owner, repo.name)}
title={t("common.delete")}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,145 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ExternalLink, Download, Trash2, Loader2 } from "lucide-react";
import { settingsApi } from "@/lib/api";
import type { Skill } from "@/lib/api/skills";
interface SkillCardProps {
skill: Skill;
onInstall: (directory: string) => Promise<void>;
onUninstall: (directory: string) => Promise<void>;
}
export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const handleInstall = async () => {
setLoading(true);
try {
await onInstall(skill.directory);
} finally {
setLoading(false);
}
};
const handleUninstall = async () => {
setLoading(true);
try {
await onUninstall(skill.directory);
} finally {
setLoading(false);
}
};
const handleOpenGithub = async () => {
if (skill.readmeUrl) {
try {
await settingsApi.openExternal(skill.readmeUrl);
} catch (error) {
console.error("Failed to open URL:", error);
}
}
};
const showDirectory =
Boolean(skill.directory) &&
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
return (
<Card className="flex flex-col h-full border-border-default bg-card transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="text-base font-semibold truncate">
{skill.name}
</CardTitle>
<div className="flex items-center gap-2 mt-1.5">
{showDirectory && (
<CardDescription className="text-xs truncate">
{skill.directory}
</CardDescription>
)}
{skill.repoOwner && skill.repoName && (
<Badge
variant="outline"
className="shrink-0 text-[10px] px-1.5 py-0 h-4 border-border-default"
>
{skill.repoOwner}/{skill.repoName}
</Badge>
)}
</div>
</div>
{skill.installed && (
<Badge
variant="default"
className="shrink-0 bg-green-600/90 hover:bg-green-600 dark:bg-green-700/90 dark:hover:bg-green-700 text-white border-0"
>
{t("skills.installed")}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="flex-1 pt-0">
<p className="text-sm text-muted-foreground/90 line-clamp-4 leading-relaxed">
{skill.description || t("skills.noDescription")}
</p>
</CardContent>
<CardFooter className="flex gap-2 pt-3 border-t border-border-default">
{skill.readmeUrl && (
<Button
variant="ghost"
size="sm"
onClick={handleOpenGithub}
disabled={loading}
className="flex-1"
>
<ExternalLink className="h-3.5 w-3.5 mr-1.5" />
{t("skills.view")}
</Button>
)}
{skill.installed ? (
<Button
variant="outline"
size="sm"
onClick={handleUninstall}
disabled={loading}
className="flex-1 border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700 dark:border-red-900/50 dark:text-red-400 dark:hover:bg-red-950/50 dark:hover:text-red-300"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
)}
{loading ? t("skills.uninstalling") : t("skills.uninstall")}
</Button>
) : (
<Button
variant="mcp"
size="sm"
onClick={handleInstall}
disabled={loading || !skill.repoOwner}
className="flex-1"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5 mr-1.5" />
)}
{loading ? t("skills.installing") : t("skills.install")}
</Button>
)}
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,190 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { RefreshCw, Settings } from "lucide-react";
import { toast } from "sonner";
import { SkillCard } from "./SkillCard";
import { RepoManager } from "./RepoManager";
import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
interface SkillsPageProps {
onClose?: () => void;
}
export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [repos, setRepos] = useState<SkillRepo[]>([]);
const [loading, setLoading] = useState(true);
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
try {
setLoading(true);
const data = await skillsApi.getAll();
setSkills(data);
if (afterLoad) {
afterLoad(data);
}
} catch (error) {
toast.error(t("skills.loadFailed"), {
description: error instanceof Error ? error.message : t("common.error"),
});
} finally {
setLoading(false);
}
};
const loadRepos = async () => {
try {
const data = await skillsApi.getRepos();
setRepos(data);
} catch (error) {
console.error("Failed to load repos:", error);
}
};
useEffect(() => {
Promise.all([loadSkills(), loadRepos()]);
}, []);
const handleInstall = async (directory: string) => {
try {
await skillsApi.install(directory);
toast.success(t("skills.installSuccess", { name: directory }));
await loadSkills();
} catch (error) {
toast.error(t("skills.installFailed"), {
description: error instanceof Error ? error.message : t("common.error"),
});
}
};
const handleUninstall = async (directory: string) => {
try {
await skillsApi.uninstall(directory);
toast.success(t("skills.uninstallSuccess", { name: directory }));
await loadSkills();
} catch (error) {
toast.error(t("skills.uninstallFailed"), {
description: error instanceof Error ? error.message : t("common.error"),
});
}
};
const handleAddRepo = async (repo: SkillRepo) => {
await skillsApi.addRepo(repo);
let repoSkillCount = 0;
await Promise.all([
loadRepos(),
loadSkills((data) => {
repoSkillCount = data.filter(
(skill) =>
skill.repoOwner === repo.owner &&
skill.repoName === repo.name &&
(skill.repoBranch || "main") === (repo.branch || "main"),
).length;
}),
]);
toast.success(
t("skills.repo.addSuccess", {
owner: repo.owner,
name: repo.name,
count: repoSkillCount,
}),
);
};
const handleRemoveRepo = async (owner: string, name: string) => {
await skillsApi.removeRepo(owner, name);
toast.success(t("skills.repo.removeSuccess", { owner, name }));
await Promise.all([loadRepos(), loadSkills()]);
};
return (
<div className="flex flex-col h-full min-h-0 bg-background">
{/* 顶部操作栏(固定区域) */}
<div className="flex-shrink-0 border-b border-border-default bg-muted/20 px-6 py-4">
<div className="flex items-center justify-between pr-8">
<h1 className="text-lg font-semibold leading-tight tracking-tight text-gray-900 dark:text-gray-100">
{t("skills.title")}
</h1>
<div className="flex gap-2">
<Button
variant="mcp"
size="sm"
onClick={() => loadSkills()}
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
/>
{loading ? t("skills.refreshing") : t("skills.refresh")}
</Button>
<Button
variant="mcp"
size="sm"
onClick={() => setRepoManagerOpen(true)}
>
<Settings className="h-4 w-4 mr-2" />
{t("skills.repoManager")}
</Button>
</div>
</div>
{/* 描述 */}
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400">
{t("skills.description")}
</p>
</div>
{/* 技能网格(可滚动详情区域) */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-6 bg-muted/10">
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
{t("skills.empty")}
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("skills.emptyDescription")}
</p>
<Button
variant="link"
onClick={() => setRepoManagerOpen(true)}
className="mt-3 text-sm font-normal"
>
{t("skills.addRepo")}
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{skills.map((skill) => (
<SkillCard
key={skill.key}
skill={skill}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
))}
</div>
)}
</div>
{/* 仓库管理对话框 */}
<RepoManager
open={repoManagerOpen}
onOpenChange={setRepoManagerOpen}
repos={repos}
skills={skills}
onAdd={handleAddRepo}
onRemove={handleRemoveRepo}
/>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,86 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

121
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,121 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn("[&_tr]:border-b [&_tr]:border-border-default", className)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b border-border-default transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -26,7 +26,8 @@
"format": "Format",
"formatSuccess": "Formatted successfully",
"formatError": "Format failed: {{error}}",
"copy": "Copy"
"copy": "Copy",
"view": "View"
},
"apiKeyInput": {
"placeholder": "Enter API Key",
@@ -606,5 +607,49 @@
"deleteTitle": "Confirm Delete",
"deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?"
}
},
"skills": {
"manage": "Skills",
"title": "Claude Skills Management",
"description": "Discover and install Claude skills from popular repositories to extend Claude Code/Codex capabilities",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"repoManager": "Repository Management",
"count": "{{count}} skills",
"empty": "No skills available",
"emptyDescription": "Add skill repositories to discover available skills",
"addRepo": "Add Skill Repository",
"loading": "Loading...",
"installed": "Installed",
"install": "Install",
"installing": "Installing...",
"uninstall": "Uninstall",
"uninstalling": "Uninstalling...",
"view": "View",
"noDescription": "No description",
"loadFailed": "Failed to load",
"installSuccess": "Skill {{name}} installed",
"installFailed": "Failed to install",
"uninstallSuccess": "Skill {{name}} uninstalled",
"uninstallFailed": "Failed to uninstall",
"repo": {
"title": "Manage Skill Repositories",
"description": "Add or remove GitHub skill repository sources",
"url": "Repository URL",
"urlPlaceholder": "owner/name or https://github.com/owner/name",
"branch": "Branch",
"branchPlaceholder": "main",
"path": "Skills Path",
"pathPlaceholder": "skills (optional, leave empty for root)",
"add": "Add Repository",
"list": "Added Repositories",
"empty": "No repositories",
"invalidUrl": "Invalid repository URL format",
"addSuccess": "Repository {{owner}}/{{name}} added, detected {{count}} skills",
"addFailed": "Failed to add",
"removeSuccess": "Repository {{owner}}/{{name}} removed",
"removeFailed": "Failed to remove",
"skillCount": "{{count}} skills detected"
}
}
}

View File

@@ -26,7 +26,8 @@
"format": "格式化",
"formatSuccess": "格式化成功",
"formatError": "格式化失败:{{error}}",
"copy": "复制"
"copy": "复制",
"view": "查看"
},
"apiKeyInput": {
"placeholder": "请输入API Key",
@@ -606,5 +607,49 @@
"deleteTitle": "确认删除",
"deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?"
}
},
"skills": {
"manage": "Skills",
"title": "Claude Skills 管理",
"description": "从流行的仓库发现并安装 Claude 技能,扩展 Claude Code/Codex 的能力",
"refresh": "刷新",
"refreshing": "刷新中...",
"repoManager": "仓库管理",
"count": "共 {{count}} 个技能",
"empty": "暂无可用技能",
"emptyDescription": "添加技能仓库以发现可用的技能",
"addRepo": "添加技能仓库",
"loading": "加载中...",
"installed": "已安装",
"install": "安装",
"installing": "安装中...",
"uninstall": "卸载",
"uninstalling": "卸载中...",
"view": "查看",
"noDescription": "暂无描述",
"loadFailed": "加载失败",
"installSuccess": "技能 {{name}} 已安装",
"installFailed": "安装失败",
"uninstallSuccess": "技能 {{name}} 已卸载",
"uninstallFailed": "卸载失败",
"repo": {
"title": "管理技能仓库",
"description": "添加或删除 GitHub 技能仓库源",
"url": "仓库 URL",
"urlPlaceholder": "owner/name 或 https://github.com/owner/name",
"branch": "分支",
"branchPlaceholder": "main",
"path": "技能路径",
"pathPlaceholder": "skills (可选,留空扫描根目录)",
"add": "添加仓库",
"list": "已添加的仓库",
"empty": "暂无仓库",
"invalidUrl": "无效的仓库 URL 格式",
"addSuccess": "仓库 {{owner}}/{{name}} 已添加,识别到 {{count}} 个技能",
"addFailed": "添加失败",
"removeSuccess": "仓库 {{owner}}/{{name}} 已删除",
"removeFailed": "删除失败",
"skillCount": "识别到 {{count}} 个技能"
}
}
}

47
src/lib/api/skills.ts Normal file
View File

@@ -0,0 +1,47 @@
import { invoke } from "@tauri-apps/api/core";
export interface Skill {
key: string;
name: string;
description: string;
directory: string;
readmeUrl?: string;
installed: boolean;
repoOwner?: string;
repoName?: string;
repoBranch?: string;
}
export interface SkillRepo {
owner: string;
name: string;
branch: string;
enabled: boolean;
skillsPath?: string; // 可选:技能所在的子目录路径,如 "skills"
}
export const skillsApi = {
async getAll(): Promise<Skill[]> {
return await invoke("get_skills");
},
async install(directory: string): Promise<boolean> {
return await invoke("install_skill", { directory });
},
async uninstall(directory: string): Promise<boolean> {
return await invoke("uninstall_skill", { directory });
},
async getRepos(): Promise<SkillRepo[]> {
return await invoke("get_skill_repos");
},
async addRepo(repo: SkillRepo): Promise<boolean> {
return await invoke("add_skill_repo", { repo });
},
async removeRepo(owner: string, name: string): Promise<boolean> {
return await invoke("remove_skill_repo", { owner, name });
},
};