feat(skills): add restore and delete for skill backups

Introduce list/restore/delete commands for skill backups created during
uninstall. Restore copies files back to SSOT, saves the DB record, and
syncs to the current app with rollback on failure. Delete removes the
backup directory after a confirmation dialog. ConfirmDialog gains a
configurable zIndex prop to support nested dialog stacking.
This commit is contained in:
Jason
2026-03-16 00:00:31 +08:00
parent 9336001746
commit 333c9f277b
12 changed files with 678 additions and 3 deletions

View File

@@ -7,7 +7,8 @@
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
use crate::error::format_skill_error;
use crate::services::skill::{
DiscoverableSkill, ImportSkillSelection, Skill, SkillRepo, SkillService, SkillUninstallResult,
DiscoverableSkill, ImportSkillSelection, Skill, SkillBackupEntry, SkillRepo, SkillService,
SkillUninstallResult,
};
use crate::store::AppState;
use std::sync::Arc;
@@ -35,6 +36,17 @@ pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<Instal
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_skill_backups() -> Result<Vec<SkillBackupEntry>, String> {
SkillService::list_backups().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_skill_backup(backup_id: String) -> Result<bool, String> {
SkillService::delete_backup(&backup_id).map_err(|e| e.to_string())?;
Ok(true)
}
/// 安装 Skill新版统一安装
///
/// 参数:
@@ -65,6 +77,17 @@ pub fn uninstall_skill_unified(
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn restore_skill_backup(
backup_id: String,
current_app: String,
app_state: State<'_, AppState>,
) -> Result<InstalledSkill, String> {
let app_type = parse_app_type(&current_app)?;
SkillService::restore_from_backup(&app_state.db, &backup_id, &app_type)
.map_err(|e| e.to_string())
}
/// 切换 Skill 的应用启用状态
#[tauri::command]
pub fn toggle_skill_app(

View File

@@ -944,8 +944,11 @@ pub fn run() {
commands::restore_env_backup,
// Skill management (v3.10.0+ unified)
commands::get_installed_skills,
commands::get_skill_backups,
commands::delete_skill_backup,
commands::install_skill_unified,
commands::uninstall_skill_unified,
commands::restore_skill_backup,
commands::toggle_skill_app,
commands::scan_unmanaged_skills,
commands::import_skills_from_apps,

View File

@@ -160,6 +160,15 @@ pub struct SkillUninstallResult {
pub backup_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillBackupEntry {
pub backup_id: String,
pub backup_path: String,
pub created_at: i64,
pub skill: InstalledSkill,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SkillBackupMetadata {
@@ -697,6 +706,125 @@ impl SkillService {
Ok(SkillUninstallResult { backup_path })
}
pub fn list_backups() -> Result<Vec<SkillBackupEntry>> {
let backup_dir = Self::get_backup_dir()?;
let mut entries = Vec::new();
for entry in fs::read_dir(&backup_dir)? {
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
log::warn!("读取 Skill 备份目录项失败: {err}");
continue;
}
};
let path = entry.path();
if !path.is_dir() {
continue;
}
match Self::read_backup_metadata(&path) {
Ok(metadata) => entries.push(SkillBackupEntry {
backup_id: entry.file_name().to_string_lossy().to_string(),
backup_path: path.to_string_lossy().to_string(),
created_at: metadata.backup_created_at,
skill: metadata.skill,
}),
Err(err) => {
log::warn!("解析 Skill 备份失败 {}: {err:#}", path.display());
}
}
}
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(entries)
}
pub fn delete_backup(backup_id: &str) -> Result<()> {
let backup_path = Self::backup_path_for_id(backup_id)?;
let metadata = fs::symlink_metadata(&backup_path)
.with_context(|| format!("failed to access {}", backup_path.display()))?;
if !metadata.is_dir() {
return Err(anyhow!(
"Skill backup is not a directory: {}",
backup_path.display()
));
}
fs::remove_dir_all(&backup_path)
.with_context(|| format!("failed to delete {}", backup_path.display()))?;
log::info!("Skill 备份已删除: {}", backup_path.display());
Ok(())
}
pub fn restore_from_backup(
db: &Arc<Database>,
backup_id: &str,
current_app: &AppType,
) -> Result<InstalledSkill> {
let backup_path = Self::backup_path_for_id(backup_id)?;
let metadata = Self::read_backup_metadata(&backup_path)?;
let backup_skill_dir = backup_path.join("skill");
if !backup_skill_dir.join("SKILL.md").exists() {
return Err(anyhow!(
"Skill backup is invalid or missing SKILL.md: {}",
backup_path.display()
));
}
let existing_skills = db.get_all_installed_skills()?;
if existing_skills.contains_key(&metadata.skill.id)
|| existing_skills.values().any(|skill| {
skill
.directory
.eq_ignore_ascii_case(&metadata.skill.directory)
})
{
return Err(anyhow!(
"Skill already exists, please uninstall the current one first: {}",
metadata.skill.directory
));
}
let ssot_dir = Self::get_ssot_dir()?;
let restore_path = ssot_dir.join(&metadata.skill.directory);
if restore_path.exists() || Self::is_symlink(&restore_path) {
return Err(anyhow!(
"Restore target already exists: {}",
restore_path.display()
));
}
let mut restored_skill = metadata.skill;
restored_skill.installed_at = Utc::now().timestamp();
restored_skill.apps = SkillApps::only(current_app);
Self::copy_dir_recursive(&backup_skill_dir, &restore_path)?;
if let Err(err) = db.save_skill(&restored_skill) {
let _ = fs::remove_dir_all(&restore_path);
return Err(err.into());
}
if !restored_skill.apps.is_empty() {
if let Err(err) = Self::sync_to_app_dir(&restored_skill.directory, current_app) {
let _ = db.delete_skill(&restored_skill.id);
let _ = fs::remove_dir_all(&restore_path);
return Err(err);
}
}
log::info!(
"Skill {} 已从备份恢复到 {}",
restored_skill.name,
restore_path.display()
);
Ok(restored_skill)
}
/// 切换应用启用状态
///
/// 启用:复制到应用目录
@@ -1599,6 +1727,26 @@ impl SkillService {
Ok(())
}
fn backup_path_for_id(backup_id: &str) -> Result<PathBuf> {
if backup_id.contains("..")
|| backup_id.contains('/')
|| backup_id.contains('\\')
|| backup_id.trim().is_empty()
{
return Err(anyhow!("Invalid backup id: {backup_id}"));
}
Ok(Self::get_backup_dir()?.join(backup_id))
}
fn read_backup_metadata(backup_path: &Path) -> Result<SkillBackupMetadata> {
let metadata_path = backup_path.join("meta.json");
let content = fs::read_to_string(&metadata_path)
.with_context(|| format!("failed to read {}", metadata_path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", metadata_path.display()))
}
fn create_uninstall_backup(skill: &InstalledSkill) -> Result<Option<PathBuf>> {
let Some(source_path) = Self::resolve_uninstall_backup_source(skill)? else {
log::warn!(

View File

@@ -193,6 +193,150 @@ fn uninstall_skill_creates_backup_before_removing_ssot() {
);
}
#[test]
fn restore_skill_backup_restores_files_to_ssot_and_current_app() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("restore-skill");
write_skill(&ssot_skill_dir, "Restore Skill");
fs::write(ssot_skill_dir.join("prompt.md"), "restore me").expect("write prompt.md");
let state = create_test_state().expect("create test state");
state
.db
.save_skill(&InstalledSkill {
id: "local:restore-skill".to_string(),
name: "Restore Skill".to_string(),
description: Some("Bring the files back".to_string()),
directory: "restore-skill".to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps {
claude: true,
codex: false,
gemini: false,
opencode: false,
},
installed_at: 456,
})
.expect("save skill");
let uninstall =
SkillService::uninstall(&state.db, "local:restore-skill").expect("uninstall skill");
let backup_id = std::path::Path::new(
&uninstall
.backup_path
.expect("backup path should be returned on uninstall"),
)
.file_name()
.expect("backup dir name")
.to_string_lossy()
.to_string();
let restored = SkillService::restore_from_backup(&state.db, &backup_id, &AppType::Claude)
.expect("restore from backup");
assert_eq!(restored.directory, "restore-skill");
assert!(restored.apps.claude, "restored skill should enable Claude");
assert!(
!restored.apps.codex && !restored.apps.gemini && !restored.apps.opencode,
"restore should only enable the selected app"
);
assert!(
home.join(".cc-switch")
.join("skills")
.join("restore-skill")
.join("prompt.md")
.exists(),
"restored skill should exist in SSOT"
);
assert!(
home.join(".claude")
.join("skills")
.join("restore-skill")
.join("prompt.md")
.exists(),
"restored skill should sync to the selected app"
);
assert!(
state
.db
.get_installed_skill("local:restore-skill")
.expect("query restored skill")
.is_some(),
"restored skill should be written back to the database"
);
}
#[test]
fn delete_skill_backup_removes_backup_directory() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let ssot_skill_dir = home
.join(".cc-switch")
.join("skills")
.join("delete-backup-skill");
write_skill(&ssot_skill_dir, "Delete Backup Skill");
let state = create_test_state().expect("create test state");
state
.db
.save_skill(&InstalledSkill {
id: "local:delete-backup-skill".to_string(),
name: "Delete Backup Skill".to_string(),
description: Some("Remove my backup".to_string()),
directory: "delete-backup-skill".to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps {
claude: true,
codex: false,
gemini: false,
opencode: false,
},
installed_at: 789,
})
.expect("save skill");
let uninstall =
SkillService::uninstall(&state.db, "local:delete-backup-skill").expect("uninstall skill");
let backup_path = uninstall
.backup_path
.expect("backup path should be returned on uninstall");
let backup_id = std::path::Path::new(&backup_path)
.file_name()
.expect("backup dir name")
.to_string_lossy()
.to_string();
assert!(
std::path::Path::new(&backup_path).exists(),
"backup directory should exist before deletion"
);
SkillService::delete_backup(&backup_id).expect("delete backup");
assert!(
!std::path::Path::new(&backup_path).exists(),
"backup directory should be removed"
);
assert!(
SkillService::list_backups()
.expect("list backups")
.into_iter()
.all(|entry| entry.backup_id != backup_id),
"deleted backup should no longer appear in backup list"
);
}
#[test]
fn migration_snapshot_overrides_multi_source_directory_inference() {
let _guard = test_mutex().lock().expect("acquire test mutex");

View File

@@ -1034,6 +1034,17 @@ function App() {
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() =>
unifiedSkillsPanelRef.current?.openRestoreFromBackup()
}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<History className="w-4 h-4 mr-2" />
{t("skills.restoreFromBackup.button")}
</Button>
<Button
variant="ghost"
size="sm"

View File

@@ -17,6 +17,7 @@ interface ConfirmDialogProps {
confirmText?: string;
cancelText?: string;
variant?: "destructive" | "info";
zIndex?: "base" | "nested" | "alert" | "top";
onConfirm: () => void;
onCancel: () => void;
}
@@ -28,6 +29,7 @@ export function ConfirmDialog({
confirmText,
cancelText,
variant = "destructive",
zIndex = "alert",
onConfirm,
onCancel,
}: ConfirmDialogProps) {
@@ -46,7 +48,7 @@ export function ConfirmDialog({
}
}}
>
<DialogContent className="max-w-sm" zIndex="alert">
<DialogContent className="max-w-sm" zIndex={zIndex}>
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<IconComponent className={iconClass} />

View File

@@ -5,7 +5,11 @@ import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
type ImportSkillSelection,
type SkillBackupEntry,
useDeleteSkillBackup,
useInstalledSkills,
useSkillBackups,
useRestoreSkillBackup,
useToggleSkillApp,
useUninstallSkill,
useScanUnmanagedSkills,
@@ -21,6 +25,14 @@ import { MCP_SKILLS_APP_IDS } from "@/config/appConfig";
import { AppCountBar } from "@/components/common/AppCountBar";
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
import { ListItemRow } from "@/components/common/ListItemRow";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface UnifiedSkillsPanelProps {
onOpenDiscovery: () => void;
@@ -31,6 +43,14 @@ export interface UnifiedSkillsPanelHandle {
openDiscovery: () => void;
openImport: () => void;
openInstallFromZip: () => void;
openRestoreFromBackup: () => void;
}
function formatSkillBackupDate(unixSeconds: number): string {
const date = new Date(unixSeconds * 1000);
return Number.isNaN(date.getTime())
? String(unixSeconds)
: date.toLocaleString();
}
const UnifiedSkillsPanel = React.forwardRef<
@@ -42,13 +62,23 @@ const UnifiedSkillsPanel = React.forwardRef<
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
variant?: "destructive" | "info";
onConfirm: () => void;
} | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
const { data: skills, isLoading } = useInstalledSkills();
const {
data: skillBackups = [],
refetch: refetchSkillBackups,
isFetching: isFetchingSkillBackups,
} = useSkillBackups();
const deleteBackupMutation = useDeleteSkillBackup();
const toggleAppMutation = useToggleSkillApp();
const uninstallMutation = useUninstallSkill();
const restoreBackupMutation = useRestoreSkillBackup();
const { data: unmanagedSkills, refetch: scanUnmanaged } =
useScanUnmanagedSkills();
const importMutation = useImportSkillsFromApps();
@@ -152,10 +182,71 @@ const UnifiedSkillsPanel = React.forwardRef<
}
};
const handleOpenRestoreFromBackup = async () => {
setRestoreDialogOpen(true);
try {
await refetchSkillBackups();
} catch (error) {
toast.error(t("common.error"), { description: String(error) });
}
};
const handleRestoreFromBackup = async (backupId: string) => {
try {
const restored = await restoreBackupMutation.mutateAsync({
backupId,
currentApp,
});
setRestoreDialogOpen(false);
toast.success(
t("skills.restoreFromBackup.success", { name: restored.name }),
{
closeButton: true,
},
);
} catch (error) {
toast.error(t("skills.restoreFromBackup.failed"), {
description: String(error),
});
}
};
const handleDeleteBackup = (backup: SkillBackupEntry) => {
setConfirmDialog({
isOpen: true,
title: t("skills.restoreFromBackup.deleteConfirmTitle"),
message: t("skills.restoreFromBackup.deleteConfirmMessage", {
name: backup.skill.name,
}),
confirmText: t("skills.restoreFromBackup.delete"),
variant: "destructive",
onConfirm: async () => {
try {
await deleteBackupMutation.mutateAsync(backup.backupId);
await refetchSkillBackups();
setConfirmDialog(null);
toast.success(
t("skills.restoreFromBackup.deleteSuccess", {
name: backup.skill.name,
}),
{
closeButton: true,
},
);
} catch (error) {
toast.error(t("skills.restoreFromBackup.deleteFailed"), {
description: String(error),
});
}
},
});
};
React.useImperativeHandle(ref, () => ({
openDiscovery: onOpenDiscovery,
openImport: handleOpenImport,
openInstallFromZip: handleInstallFromZip,
openRestoreFromBackup: handleOpenRestoreFromBackup,
}));
return (
@@ -205,6 +296,9 @@ const UnifiedSkillsPanel = React.forwardRef<
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
confirmText={confirmDialog.confirmText}
variant={confirmDialog.variant}
zIndex="top"
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
@@ -217,6 +311,17 @@ const UnifiedSkillsPanel = React.forwardRef<
onClose={() => setImportDialogOpen(false)}
/>
)}
<RestoreSkillsDialog
backups={skillBackups}
isDeleting={deleteBackupMutation.isPending}
isLoading={isFetchingSkillBackups}
onDelete={handleDeleteBackup}
isRestoring={restoreBackupMutation.isPending}
onRestore={handleRestoreFromBackup}
onClose={() => setRestoreDialogOpen(false)}
open={restoreDialogOpen}
/>
</div>
);
});
@@ -318,6 +423,124 @@ interface ImportSkillsDialogProps {
onClose: () => void;
}
interface RestoreSkillsDialogProps {
backups: SkillBackupEntry[];
isDeleting: boolean;
isLoading: boolean;
isRestoring: boolean;
onDelete: (backup: SkillBackupEntry) => void;
onRestore: (backupId: string) => void;
onClose: () => void;
open: boolean;
}
const RestoreSkillsDialog: React.FC<RestoreSkillsDialogProps> = ({
backups,
isDeleting,
isLoading,
isRestoring,
onDelete,
onRestore,
onClose,
open,
}) => {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
<DialogContent
className="max-w-2xl max-h-[85vh] flex flex-col"
zIndex="alert"
>
<DialogHeader>
<DialogTitle>{t("skills.restoreFromBackup.title")}</DialogTitle>
<DialogDescription>
{t("skills.restoreFromBackup.description")}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4">
{isLoading ? (
<div className="py-10 text-center text-sm text-muted-foreground">
{t("common.loading")}
</div>
) : backups.length === 0 ? (
<div className="py-10 text-center text-sm text-muted-foreground">
{t("skills.restoreFromBackup.empty")}
</div>
) : (
<div className="space-y-3">
{backups.map((backup) => (
<div
key={backup.backupId}
className="rounded-xl border border-border-default bg-background/70 p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="font-medium text-sm text-foreground">
{backup.skill.name}
</div>
<div className="rounded-md bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{backup.skill.directory}
</div>
</div>
{backup.skill.description && (
<div className="mt-2 text-sm text-muted-foreground">
{backup.skill.description}
</div>
)}
<div className="mt-3 space-y-1.5 text-xs text-muted-foreground">
<div>
{t("skills.restoreFromBackup.createdAt")}:{" "}
{formatSkillBackupDate(backup.createdAt)}
</div>
<div className="break-all" title={backup.backupPath}>
{t("skills.restoreFromBackup.path")}:{" "}
{backup.backupPath}
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:min-w-28">
<Button
type="button"
variant="outline"
onClick={() => onRestore(backup.backupId)}
disabled={isRestoring || isDeleting}
>
{isRestoring
? t("skills.restoreFromBackup.restoring")
: t("skills.restoreFromBackup.restore")}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => onDelete(backup)}
disabled={isRestoring || isDeleting}
>
{isDeleting
? t("skills.restoreFromBackup.deleting")
: t("skills.restoreFromBackup.delete")}
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
skills,
onImport,

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
skillsApi,
type SkillBackupEntry,
type DiscoverableSkill,
type ImportSkillSelection,
type InstalledSkill,
@@ -17,6 +18,24 @@ export function useInstalledSkills() {
});
}
export function useSkillBackups() {
return useQuery({
queryKey: ["skills", "backups"],
queryFn: () => skillsApi.getBackups(),
enabled: false,
});
}
export function useDeleteSkillBackup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (backupId: string) => skillsApi.deleteBackup(backupId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "backups"] });
},
});
}
/**
* 发现可安装的 Skills从仓库获取
*/
@@ -62,6 +81,23 @@ export function useUninstallSkill() {
});
}
export function useRestoreSkillBackup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
backupId,
currentApp,
}: {
backupId: string;
currentApp: AppId;
}) => skillsApi.restoreBackup(backupId, currentApp),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "backups"] });
},
});
}
/**
* 切换 Skill 在特定应用的启用状态
*/
@@ -170,4 +206,10 @@ export function useInstallSkillsFromZip() {
// ========== 辅助类型 ==========
export type { InstalledSkill, DiscoverableSkill, ImportSkillSelection, AppId };
export type {
InstalledSkill,
DiscoverableSkill,
ImportSkillSelection,
SkillBackupEntry,
AppId,
};

View File

@@ -1570,6 +1570,24 @@
"backup": {
"location": "Backup location: {{path}}"
},
"restoreFromBackup": {
"button": "Restore Backup",
"title": "Restore From Backup",
"description": "Choose a Skills backup to restore its files locally and add it back to the current list.",
"empty": "No Skills backups available to restore",
"createdAt": "Backed up at",
"path": "Backup path",
"restore": "Restore",
"restoring": "Restoring...",
"delete": "Delete",
"deleting": "Deleting...",
"deleteSuccess": "Deleted backup for {{name}}",
"deleteFailed": "Failed to delete skill backup",
"deleteConfirmTitle": "Delete Backup",
"deleteConfirmMessage": "Are you sure you want to delete the backup for \"{{name}}\"? This action cannot be undone.",
"success": "Skill {{name}} restored from backup",
"failed": "Failed to restore from backup"
},
"apps": {
"claude": "Claude",
"codex": "Codex",

View File

@@ -1570,6 +1570,24 @@
"backup": {
"location": "バックアップ場所: {{path}}"
},
"restoreFromBackup": {
"button": "バックアップから復元",
"title": "バックアップから復元",
"description": "Skills バックアップを選択して、ローカルにファイルを復元し、現在の一覧へ戻します。",
"empty": "復元できる Skills バックアップはありません",
"createdAt": "バックアップ日時",
"path": "バックアップパス",
"restore": "復元",
"restoring": "復元中...",
"delete": "削除",
"deleting": "削除中...",
"deleteSuccess": "スキルバックアップ {{name}} を削除しました",
"deleteFailed": "スキルバックアップの削除に失敗しました",
"deleteConfirmTitle": "バックアップを削除",
"deleteConfirmMessage": "スキルバックアップ「{{name}}」を削除しますか?この操作は元に戻せません。",
"success": "スキル {{name}} をバックアップから復元しました",
"failed": "バックアップからの復元に失敗しました"
},
"apps": {
"claude": "Claude",
"codex": "Codex",

View File

@@ -1570,6 +1570,24 @@
"backup": {
"location": "备份位置: {{path}}"
},
"restoreFromBackup": {
"button": "从备份中恢复",
"title": "从备份中恢复",
"description": "选择一个 Skills 备份,将文件恢复到本地并重新加入当前列表。",
"empty": "暂无可恢复的 Skills 备份",
"createdAt": "备份时间",
"path": "备份路径",
"restore": "恢复",
"restoring": "恢复中...",
"delete": "删除",
"deleting": "删除中...",
"deleteSuccess": "技能备份 {{name}} 已删除",
"deleteFailed": "删除技能备份失败",
"deleteConfirmTitle": "确认删除备份",
"deleteConfirmMessage": "确定要删除技能备份 \"{{name}}\" 吗?此操作无法撤销。",
"success": "技能 {{name}} 已从备份恢复",
"failed": "从备份恢复失败"
},
"apps": {
"claude": "Claude",
"codex": "Codex",

View File

@@ -31,6 +31,13 @@ export interface SkillUninstallResult {
backupPath?: string;
}
export interface SkillBackupEntry {
backupId: string;
backupPath: string;
createdAt: number;
skill: InstalledSkill;
}
/** 可发现的 Skill来自仓库 */
export interface DiscoverableSkill {
key: string;
@@ -89,6 +96,16 @@ export const skillsApi = {
return await invoke("get_installed_skills");
},
/** 获取可恢复的 Skill 备份列表 */
async getBackups(): Promise<SkillBackupEntry[]> {
return await invoke("get_skill_backups");
},
/** 删除 Skill 备份 */
async deleteBackup(backupId: string): Promise<boolean> {
return await invoke("delete_skill_backup", { backupId });
},
/** 安装 Skill统一安装 */
async installUnified(
skill: DiscoverableSkill,
@@ -102,6 +119,14 @@ export const skillsApi = {
return await invoke("uninstall_skill_unified", { id });
},
/** 从备份恢复 Skill */
async restoreBackup(
backupId: string,
currentApp: AppId,
): Promise<InstalledSkill> {
return await invoke("restore_skill_backup", { backupId, currentApp });
},
/** 切换 Skill 的应用启用状态 */
async toggleApp(id: string, app: AppId, enabled: boolean): Promise<boolean> {
return await invoke("toggle_skill_app", { id, app, enabled });