fix: replace implicit app inference with explicit selection for Skills import and sync

Skills import previously inferred app enablement from filesystem presence,
causing incorrect multi-app activation when the same skill directory existed
under multiple app paths. Now the frontend submits explicit app selections
via ImportSkillSelection, and schema migration preserves a snapshot of
legacy app mappings to avoid lossy reconstruction.

Also adds reconciliation to sync_to_app (removes disabled/orphaned symlinks)
and MCP sync_all_enabled (removes disabled servers from live config).
This commit is contained in:
Jason
2026-03-14 17:20:01 +08:00
parent f1d2c6045b
commit 7097a0d710
13 changed files with 560 additions and 39 deletions
+2 -5
View File
@@ -712,17 +712,14 @@ function App() {
<UnifiedSkillsPanel
ref={unifiedSkillsPanelRef}
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
currentApp={activeApp === "openclaw" ? "claude" : activeApp}
/>
);
case "skillsDiscovery":
return (
<SkillsPage
ref={skillsPageRef}
initialApp={
activeApp === "opencode" || activeApp === "openclaw"
? "claude"
: activeApp
}
initialApp={activeApp === "openclaw" ? "claude" : activeApp}
/>
);
case "mcp":
+66 -9
View File
@@ -4,6 +4,7 @@ import { Sparkles, Trash2, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
type ImportSkillSelection,
useInstalledSkills,
useToggleSkillApp,
useUninstallSkill,
@@ -23,6 +24,7 @@ import { ListItemRow } from "@/components/common/ListItemRow";
interface UnifiedSkillsPanelProps {
onOpenDiscovery: () => void;
currentApp: AppId;
}
export interface UnifiedSkillsPanelHandle {
@@ -34,7 +36,7 @@ export interface UnifiedSkillsPanelHandle {
const UnifiedSkillsPanel = React.forwardRef<
UnifiedSkillsPanelHandle,
UnifiedSkillsPanelProps
>(({ onOpenDiscovery }, ref) => {
>(({ onOpenDiscovery, currentApp }, ref) => {
const { t } = useTranslation();
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
@@ -103,9 +105,9 @@ const UnifiedSkillsPanel = React.forwardRef<
}
};
const handleImport = async (directories: string[]) => {
const handleImport = async (imports: ImportSkillSelection[]) => {
try {
const imported = await importMutation.mutateAsync(directories);
const imported = await importMutation.mutateAsync(imports);
setImportDialogOpen(false);
toast.success(t("skills.importSuccess", { count: imported.length }), {
closeButton: true,
@@ -120,7 +122,6 @@ const UnifiedSkillsPanel = React.forwardRef<
const filePath = await skillsApi.openZipFileDialog();
if (!filePath) return;
const currentApp: AppId = "claude";
const installed = await installFromZipMutation.mutateAsync({
filePath,
currentApp,
@@ -310,7 +311,7 @@ interface ImportSkillsDialogProps {
foundIn: string[];
path: string;
}>;
onImport: (directories: string[]) => void;
onImport: (imports: ImportSkillSelection[]) => void;
onClose: () => void;
}
@@ -323,6 +324,22 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
const [selected, setSelected] = useState<Set<string>>(
new Set(skills.map((s) => s.directory)),
);
const [selectedApps, setSelectedApps] = useState<
Record<string, ImportSkillSelection["apps"]>
>(() =>
Object.fromEntries(
skills.map((skill) => [
skill.directory,
{
claude: skill.foundIn.includes("claude"),
codex: skill.foundIn.includes("codex"),
gemini: skill.foundIn.includes("gemini"),
opencode: skill.foundIn.includes("opencode"),
openclaw: false,
},
]),
),
);
const toggleSelect = (directory: string) => {
const newSelected = new Set(selected);
@@ -335,7 +352,18 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
};
const handleImport = () => {
onImport(Array.from(selected));
onImport(
Array.from(selected).map((directory) => ({
directory,
apps: selectedApps[directory] ?? {
claude: false,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
},
})),
);
};
return (
@@ -348,9 +376,9 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
<div className="flex-1 overflow-y-auto space-y-2 mb-4">
{skills.map((skill) => (
<label
<div
key={skill.directory}
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted cursor-pointer"
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted"
>
<input
type="checkbox"
@@ -365,6 +393,35 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
{skill.description}
</div>
)}
<div className="mt-2">
<AppToggleGroup
apps={
selectedApps[skill.directory] ?? {
claude: false,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
}
}
onToggle={(app, enabled) => {
setSelectedApps((prev) => ({
...prev,
[skill.directory]: {
...(prev[skill.directory] ?? {
claude: false,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
}),
[app]: enabled,
},
}));
}}
appIds={MCP_SKILLS_APP_IDS}
/>
</div>
<div
className="text-xs text-muted-foreground/50 mt-1 truncate"
title={skill.path}
@@ -372,7 +429,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
{skill.path}
</div>
</div>
</label>
</div>
))}
</div>
+4 -3
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
skillsApi,
type DiscoverableSkill,
type ImportSkillSelection,
type InstalledSkill,
} from "@/lib/api/skills";
import type { AppId } from "@/lib/api/types";
@@ -99,8 +100,8 @@ export function useScanUnmanagedSkills() {
export function useImportSkillsFromApps() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (directories: string[]) =>
skillsApi.importFromApps(directories),
mutationFn: (imports: ImportSkillSelection[]) =>
skillsApi.importFromApps(imports),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
@@ -169,4 +170,4 @@ export function useInstallSkillsFromZip() {
// ========== 辅助类型 ==========
export type { InstalledSkill, DiscoverableSkill, AppId };
export type { InstalledSkill, DiscoverableSkill, ImportSkillSelection, AppId };
+10 -2
View File
@@ -48,6 +48,12 @@ export interface UnmanagedSkill {
path: string;
}
/** 导入已有 Skill 时提交的应用启用状态 */
export interface ImportSkillSelection {
directory: string;
apps: SkillApps;
}
/** 技能对象(兼容旧 API) */
export interface Skill {
key: string;
@@ -103,8 +109,10 @@ export const skillsApi = {
},
/** 从应用目录导入 Skills */
async importFromApps(directories: string[]): Promise<InstalledSkill[]> {
return await invoke("import_skills_from_apps", { directories });
async importFromApps(
imports: ImportSkillSelection[],
): Promise<InstalledSkill[]> {
return await invoke("import_skills_from_apps", { imports });
},
/** 发现可安装的 Skills(从仓库获取) */