From 275c2ae2a455342f43395d21a090bd3bbd22b279 Mon Sep 17 00:00:00 2001 From: Santos Date: Fri, 31 Jul 2026 23:48:00 +0800 Subject: [PATCH] feat(import): group review candidates by skill --- CHANGELOG.md | 2 + README.md | 2 +- README.zh-CN.md | 2 +- .../desktop/src/App.import-candidates.test.js | 134 ++++++++++++++ apps/desktop/src/App.jsx | 54 +++--- apps/desktop/src/cardLayout.test.js | 36 ++-- apps/desktop/src/components/importReview.jsx | 175 +++++++++++------- apps/desktop/src/importCandidates.js | 168 +++++++++++++++++ apps/desktop/src/previewData.js | 69 +++++++ apps/desktop/src/styles.css | 110 +++++++++++ crates/skillbox-core/src/import.rs | 170 +++++++++++++++++ crates/skillbox-core/src/tests.rs | 163 +++++++++++++--- crates/skillbox-core/src/types.rs | 28 +++ docs/architecture.md | 6 +- docs/implementation-status.md | 2 +- docs/workflows.md | 4 +- 16 files changed, 983 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6ffcda..0ca0674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ version tags such as `v0.3.0`. ## Unreleased +- Group Import Review candidates by normalized skill name, keep equivalent copies as locations of one Rust-qualified variant, require explicit selection when same-name variants differ, and submit at most one source per skill. + - Add explicit inbound user-skills Git updates through Check remote, reviewed repository-wide diffs, and preview-confirmed fast-forward apply. - Report worktree cleanliness separately from synced/ahead/behind/diverged, diff --git a/README.md b/README.md index df1f2d9..37c1cf1 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Longer-term support for native Claude, OpenClaw, Cursor, Claude Code, Copilot, a ## Features - Scan and register supported global or project-local `SKILL.md` workspaces. In the packaged macOS app, use the native single-directory picker or enter a path manually to choose a project or existing skills folder; SkillBox immediately runs a read-only preview, and can explicitly create exactly one selected `.agents/skills`, `.codex/skills`, `.claude/skills`, or `.cursor/skills` root before registration. Cancelling the picker changes nothing, and SkillBox never creates all runtime roots automatically. -- Review user, remote, and system import candidates before copying anything; group import-equivalent multi-root copies without losing their source locations, and conservatively revert eligible deploy-back imports. +- Review user, remote, and system import candidates before copying anything; see one card per skill, inspect every location and differing variant, and import exactly one reviewed source without changing equivalent copies. - Install GitHub-backed skills through a preview/apply flow and bind discovered remote source candidates without replacing the active version. - Check remote sources, preview all-file diffs, apply updates, and roll back to immutable versions. - Preview runtime-profile and frontmatter compatibility before deploying to an individual workspace. Blocked targets cannot be selected, warnings require confirmation, and apply revalidates stale skill/target/profile state before creating an ownership-checked symlink. diff --git a/README.zh-CN.md b/README.zh-CN.md index 262e53a..6031153 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -97,7 +97,7 @@ Runtime 目录只是部署目标: ## 功能 - 扫描并登记受支持的全局或项目局部 `SKILL.md` workspaces。在打包后的 macOS app 中,可以通过原生单目录选择器或手动输入路径选择 project / 现有 skills folder;SkillBox 会立即执行只读 preview,并可在登记前显式创建一个选中的 `.agents/skills`、`.codex/skills`、`.claude/skills` 或 `.cursor/skills` root。取消选择不会改变当前状态,也不会一次创建所有 runtime roots。 -- 在复制前 review user、remote 和 system import candidates;合并导入内容一致的多 root 副本但不丢失来源位置,并对符合条件的 deploy-back import 执行保守回退。 +- 在复制前 review user、remote 和 system import candidates;同名 skill 只显示一张卡片,保留所有位置和差异 variant,并且每个 skill 只导入一个明确选择的来源,不改动等价副本。 - 通过 preview/apply 安装 GitHub-backed skill,并在不替换当前版本的情况下绑定识别到的 remote source candidate。 - 检查 remote source、预览全文件 diff、应用更新,并回滚到不可变版本。 - 部署前 preview runtime profile 与 frontmatter compatibility;blocked target 不可选择,warning 需要确认,apply 会重新校验 skill/target/profile 是否 stale,再创建 ownership-checked symlink。 diff --git a/apps/desktop/src/App.import-candidates.test.js b/apps/desktop/src/App.import-candidates.test.js index 8525c35..9fe7818 100644 --- a/apps/desktop/src/App.import-candidates.test.js +++ b/apps/desktop/src/App.import-candidates.test.js @@ -1,9 +1,18 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + filterImportCandidateGroups, + filterImportCandidateGroupsByQuery, filterImportCandidatesByQuery, filterWorkspaceSkillCandidates, + importCandidateGroupLocationCount, + importCandidateGroupTabs, + normalizeImportCandidateGroup, + normalizeImportCandidateGroups, normalizeImportCandidate, + selectedImportCandidates, + selectImportCandidateVariant, + toggleImportCandidateGroupSelection, visibleImportCandidates, workspaceSkillTabs } from './importCandidates.js'; @@ -141,6 +150,131 @@ test('normalizes grouped import candidate source paths', () => { ]); }); +test('normalizes Rust-owned import groups without inferring variant equivalence', () => { + const group = normalizeImportCandidateGroup({ + id: 'skill-hyperframes', + name: 'hyperframes', + description: 'Create product videos.', + usage_count: 8, + requires_review: true, + selected_variant_id: null, + variants: [ + { + id: 'variant-user', + candidate: { + name: 'hyperframes', + source_path: '/Users/example/.agents/skills/hyperframes', + suggested_type: 'user', + import_status: 'importable' + }, + locations: [ + { + source_path: '/Users/example/.agents/skills/hyperframes', + real_path: '/Users/example/src/hyperframes', + is_symlink: true, + symlink_target_path: '/Users/example/src/hyperframes' + }, + { + source_path: '/Users/example/.cursor/skills/hyperframes', + real_path: '/Users/example/src/hyperframes', + is_symlink: true, + symlink_target_path: '/Users/example/src/hyperframes' + } + ] + }, + { + id: 'variant-remote', + candidate: { + name: 'hyperframes', + source_path: '/Users/example/.codex/skills/hyperframes', + suggested_type: 'remote', + import_status: 'importable' + }, + locations: [ + { + source_path: '/Users/example/.codex/skills/hyperframes', + real_path: '/Users/example/src/hyperframes', + is_symlink: true, + symlink_target_path: '/Users/example/src/hyperframes' + } + ] + } + ] + }); + + assert.equal(group.variants.length, 2); + assert.equal(importCandidateGroupLocationCount(group), 3); + assert.equal(group.requiresReview, true); + assert.equal(group.selectedVariantId, null); + assert.equal(group.isSelected, false); + assert.deepEqual(selectedImportCandidates([group]), []); +}); + +test('variant review requires an explicit Rust variant choice and submits one primary', () => { + const [group] = normalizeImportCandidateGroups([ + { + id: 'skill-demo', + name: 'demo', + requires_review: true, + variants: [ + { + id: 'variant-a', + candidate: { name: 'demo', source_path: '/first/demo', import_status: 'importable' }, + locations: [{ source_path: '/first/demo' }, { source_path: '/copy/demo' }] + }, + { + id: 'variant-b', + candidate: { name: 'demo', source_path: '/second/demo', import_status: 'importable' }, + locations: [{ source_path: '/second/demo' }] + } + ] + } + ]); + + assert.deepEqual(selectedImportCandidates([group]), []); + const selected = selectImportCandidateVariant([group], group.id, 'variant-b'); + assert.deepEqual(importRequestItems(selectedImportCandidates(selected)), [ + { source_path: '/second/demo', skill_type: 'user', deploy_back_to_source: true } + ]); +}); + +test('group search tabs and select-all count one skill while matching every location', () => { + const groups = normalizeImportCandidateGroups([ + { + id: 'skill-demo', + name: 'demo', + selected_variant_id: 'variant-demo', + variants: [{ + id: 'variant-demo', + candidate: { name: 'demo', source_path: '/one/demo', import_status: 'importable', is_selected: false }, + locations: [ + { source_path: '/one/demo' }, + { source_path: '/project/.cursor/skills/demo', is_symlink: true, symlink_target_path: '/one/demo' } + ] + }] + }, + { + id: 'skill-system', + name: 'system-skill', + variants: [{ + id: 'variant-system', + candidate: { name: 'system-skill', source_path: '/system', import_status: 'system' }, + locations: [{ source_path: '/system' }] + }] + } + ]); + + assert.deepEqual(importCandidateGroupTabs(groups), [ + { id: 'all', label: 'All', count: 2 }, + { id: 'unimported', label: 'Unimported', count: 1 }, + { id: 'imported', label: 'Imported', count: 0 }, + { id: 'system', label: 'System', count: 1 } + ]); + assert.deepEqual(filterImportCandidateGroups(groups, 'system').map((group) => group.name), ['system-skill']); + assert.deepEqual(filterImportCandidateGroupsByQuery(groups, 'cursor skills').map((group) => group.name), ['demo']); + assert.deepEqual(toggleImportCandidateGroupSelection(groups).map((group) => group.isSelected), [true, false]); +}); + test('builds workspace skill tabs and separates unimported, imported, and system skills', () => { const candidates = [ normalizeImportCandidate({ diff --git a/apps/desktop/src/App.jsx b/apps/desktop/src/App.jsx index 6ddd631..7a44782 100644 --- a/apps/desktop/src/App.jsx +++ b/apps/desktop/src/App.jsx @@ -43,8 +43,13 @@ import { normalizeStaleDeploymentRepairResult } from './doctor.js'; import { + normalizeImportCandidateGroups, normalizeImportCandidate, - visibleImportCandidates + selectedImportCandidates, + selectImportCandidateVariant, + toggleImportCandidateGroup, + toggleImportCandidateGroupSelection, + updateImportCandidateGroupType } from './importCandidates.js'; import { appUpdateNotice, @@ -60,8 +65,7 @@ import { isHttpUrl, isImportableCandidate, remoteImportCandidate, - shouldConfirmLocalImport, - toggleImportCandidateSelection + shouldConfirmLocalImport } from './importFlow.js'; import { clearLegacyDashboardMetadata, @@ -78,6 +82,7 @@ import { previewCandidatesForWorkspace, previewHistory, previewImportCandidates, + previewImportCandidateGroups, previewPaths, previewSkills, previewUsageRankings, @@ -1001,10 +1006,7 @@ export default function App() { setWorkspaces(normalizeWorkspaces(previewWorkspaces)); setImportReview({ open: true, - candidates: applyPreviewImportStatuses( - previewImportCandidates.map(normalizeImportCandidate), - skills - ), + candidates: normalizeImportCandidateGroups(previewImportCandidateGroups), errors: [], title: 'Import Review', subtitle: 'Confirm each skill type before SkillBox copies it into the managed store.', @@ -1017,7 +1019,7 @@ export default function App() { const scan = await invoke('scan_import_candidates'); const workspaceRows = await invoke('list_workspaces').catch(() => []); - const candidates = (scan.candidates || []).map(normalizeImportCandidate); + const candidates = normalizeImportCandidateGroups(scan.groups || [], scan.candidates || []); setWorkspaces(normalizeWorkspaces(workspaceRows)); setImportReview({ @@ -1201,26 +1203,22 @@ export default function App() { setImportReview((current) => ({ ...current, open: false })); } - function updateImportCandidate(sourcePath, patch) { + function updateImportCandidateGroup(groupId, updater) { setImportReview((current) => ({ ...current, - candidates: current.candidates.map((candidate) => - candidate.sourcePath === sourcePath ? { ...candidate, ...patch } : candidate - ) + candidates: updater(current.candidates, groupId) })); } function toggleAllImportCandidates() { setImportReview((current) => ({ ...current, - candidates: toggleImportCandidateSelection(current.candidates, visibleImportCandidates(current.candidates)) + candidates: toggleImportCandidateGroupSelection(current.candidates) })); } async function importSelectedCandidates() { - const selected = visibleImportCandidates(importReview.candidates).filter( - (candidate) => candidate.isSelected && isImportableCandidate(candidate) - ); + const selected = selectedImportCandidates(importReview.candidates); if (selected.length === 0) { setNotice('Select at least one candidate without conflicts to import.'); return; @@ -3662,10 +3660,11 @@ export default function App() { setNotice(''); if (!window.__TAURI_INTERNALS__) { - const candidates = applyPreviewImportStatuses( + const previewCandidates = applyPreviewImportStatuses( previewCandidatesForWorkspace(workspace).map(normalizeImportCandidate), skills ); + const candidates = normalizeImportCandidateGroups([], previewCandidates); setImportReview({ open: true, @@ -3681,7 +3680,7 @@ export default function App() { try { const scan = await invoke('scan_workspace_import_candidates', { path: workspace.path }); const workspaceRows = await invoke('list_workspaces').catch(() => []); - const candidates = (scan.candidates || []).map(normalizeImportCandidate); + const candidates = normalizeImportCandidateGroups(scan.groups || [], scan.candidates || []); setWorkspaces(normalizeWorkspaces(workspaceRows)); setImportReview({ @@ -4188,17 +4187,24 @@ export default function App() { {importReview.open ? ( - isImportableCandidate(candidate) - ? updateImportCandidate(candidate.sourcePath, { isSelected: !candidate.isSelected }) - : null + onSelectVariant={(group, variant) => + updateImportCandidateGroup(group.id, (groups) => + selectImportCandidateVariant(groups, group.id, variant.id) + ) + } + onToggleSelected={(group) => + updateImportCandidateGroup(group.id, (groups) => toggleImportCandidateGroup(groups, group.id)) + } + onTypeChange={(group, skillType) => + updateImportCandidateGroup(group.id, (groups) => + updateImportCandidateGroupType(groups, group.id, skillType) + ) } - onTypeChange={(candidate, skillType) => updateImportCandidate(candidate.sourcePath, { skillType })} status={status} subtitle={importReview.subtitle} title={importReview.title} diff --git a/apps/desktop/src/cardLayout.test.js b/apps/desktop/src/cardLayout.test.js index 9748781..63d7377 100644 --- a/apps/desktop/src/cardLayout.test.js +++ b/apps/desktop/src/cardLayout.test.js @@ -431,22 +431,22 @@ test('workspace skill tabs stay visible instead of collapsing into a scrollbar', assert.doesNotMatch(workspaceSkillTabsRule, /overflow-x:\s*auto;/); }); -test('import candidate path uses a labeled metadata row', () => { - const candidateRowSource = appSource.match( - /function CandidateRow\(\{ candidate, onToggleSelected, onTypeChange \}\)\s*\{(?[\s\S]*?)\n\}/ +test('import candidate groups disclose locations and use radio variant selection', () => { + const groupSource = appSource.match( + /function CandidateGroupCard\(\{ group, onSelectVariant, onToggleSelected, onTypeChange \}\)\s*\{(?[\s\S]*?)\n\}/ )?.groups.body || ''; - const candidatePathMetaRule = css.match(/\.candidatePath,\s*\.candidateSymlinkSource\s*\{(?[^}]*)\}/s) - ?.groups.body || ''; - const candidatePathRule = css.match(/\.candidatePath\s*\{(?[^}]*)\}/s)?.groups.body || ''; - const candidatePathCodeRule = css.match(/\.candidatePath code,\s*\.candidateSymlinkSource code\s*\{(?[^}]*)\}/s) - ?.groups.body || ''; + const disclosureRule = css.match(/\.candidateLocationsDisclosure\s*\{(?[^}]*)\}/s)?.groups.body || ''; + const locationRule = css.match(/\.candidateLocation\s*\{(?[^}]*)\}/s)?.groups.body || ''; - assert.match(candidateRowSource, /className="candidatePath"/); - assert.match(candidateRowSource, /Path<\/span>/); - assert.match(candidateRowSource, /\{compactPath\(candidate\.sourcePath\)\}<\/code>/); - assert.match(candidatePathMetaRule, /white-space:\s*nowrap;/); - assert.match(candidatePathRule, /margin-top:\s*7px;/); - assert.match(candidatePathCodeRule, /display:\s*inline;/); + assert.match(groupSource, /aria-controls=\{disclosureId\}/); + assert.match(groupSource, /aria-expanded=\{expanded\}/); + assert.match(groupSource, /Found in \{locationCount\}/); + assert.match(groupSource, /type="radio"/); + assert.match(groupSource, /name=\{`\$\{group\.id\}-variant`\}/); + assert.match(groupSource, /onSelectVariant\(group, variant\)/); + assert.match(groupSource, /Source: \{compactPath\(location\.symlinkTargetPath \|\| location\.realPath\)\}/); + assert.match(disclosureRule, /display:\s*inline-flex;/); + assert.match(locationRule, /grid-template-columns:\s*96px minmax\(0,\s*1fr\);/); }); test('import review uses the shared searchable candidate list template', () => { @@ -469,7 +469,7 @@ test('import review uses the shared searchable candidate list template', () => { assert.match(candidateReviewListSource, /role="searchbox"/); assert.match(candidateReviewListSource, /type="text"/); assert.doesNotMatch(candidateReviewListSource, /type="search"/); - assert.match(candidateReviewListSource, /workspaceSkillTabs\(searchedCandidates\)/); + assert.match(candidateReviewListSource, /importCandidateGroupTabs\(searchedGroups\)/); assert.match(searchRule, /width:\s*100%;/); }); @@ -503,12 +503,12 @@ test('remote source binding dialog keeps long candidate lists inside the viewpor assert.match(candidateListRule, /overflow-y:\s*auto;/); }); -test('import review uses all candidates by default in the shared review list', () => { +test('import review uses all candidate groups by default in the shared review list', () => { assert.match( appSource, /const \[activeTab,\s*setActiveTab\]\s*=\s*useState\('all'\);/ ); - assert.match(appSource, /const filteredCandidates = filterWorkspaceSkillCandidates\(searchedCandidates,\s*activeTab\);/); + assert.match(appSource, /const filteredGroups = filterImportCandidateGroups\(searchedGroups,\s*activeTab\);/); }); test('workspace cards show the shared workspace icon beside the workspace name', () => { @@ -939,7 +939,7 @@ test('compact call labels stay short while usage explanations retain local scope assert.match(historyPageSource, /Try another history filter or sync local histories\./); assert.match(skillCardSource, /\{skill\.usageCount\} calls/); assert.doesNotMatch(skillCardSource, /locally observed calls/i); - assert.match(appSource, /className="candidateUsage">[\s\S]*Calls \{candidate\.usageCount \|\| 0\}/); + assert.match(appSource, /className="candidateUsage">[\s\S]*Calls \{group\.usageCount \|\| 0\}/); assert.match(appSource, /Calls:\s*\{workspace\.usageCount\}<\/strong>/); assert.match(appSource, /No calls in this range<\/strong>/); assert.match(appSource, /Skills ranked by calls<\/caption>/); diff --git a/apps/desktop/src/components/importReview.jsx b/apps/desktop/src/components/importReview.jsx index 53f0159..b547937 100644 --- a/apps/desktop/src/components/importReview.jsx +++ b/apps/desktop/src/components/importReview.jsx @@ -1,12 +1,15 @@ import React, { useState } from 'react'; -import { Search } from 'lucide-react'; +import { ChevronDown, MapPin, Search } from 'lucide-react'; import codexAppIcon from '../assets/codex-app-icon.png'; import codexCliIcon from '../assets/codex-cli-icon.png'; import { - filterImportCandidatesByQuery, - filterWorkspaceSkillCandidates, - visibleImportCandidates, - workspaceSkillTabs + filterImportCandidateGroups, + filterImportCandidateGroupsByQuery, + importCandidateGroupLocationCount, + importCandidateGroupStatus, + importCandidateGroupTabs, + isSelectableImportCandidateGroup, + selectedImportCandidate } from '../importCandidates.js'; import { candidateImportSourcePaths, @@ -186,21 +189,21 @@ export function LocalImportConfirmationDialog({ } export function ImportReview({ - candidates, + groups, errors = [], onClose, onImport, onToggleAll, onToggleSelected, + onSelectVariant, onTypeChange, status, subtitle = 'Confirm each skill type before SkillBox copies it into the managed store.', title = 'Import Review' }) { - const visibleCandidates = visibleImportCandidates(candidates); - const selectableCount = visibleCandidates.filter(isImportableCandidate).length; - const selectedCount = visibleCandidates.filter( - (candidate) => candidate.isSelected && isImportableCandidate(candidate) + const selectableCount = groups.filter(isSelectableImportCandidateGroup).length; + const selectedCount = groups.filter( + (group) => group.isSelected && isSelectableImportCandidateGroup(group) ).length; const isAllSelected = selectableCount > 0 && selectedCount === selectableCount; @@ -227,15 +230,16 @@ export function ImportReview({ {errors.length} scan {errors.length === 1 ? 'issue' : 'issues'} found. ) : null} - {visibleCandidates.length === 0 && errors.length === 0 ? ( + {groups.length === 0 && errors.length === 0 ? (
No skills found This workspace has no importable SKILL.md directories yet.
) : null} - {visibleCandidates.length > 0 ? ( + {groups.length > 0 ? ( @@ -273,12 +277,12 @@ export function ImportReview({ ); } -function CandidateReviewList({ candidates, onToggleSelected, onTypeChange }) { +function CandidateReviewList({ groups, onSelectVariant, onToggleSelected, onTypeChange }) { const [activeTab, setActiveTab] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); - const searchedCandidates = filterImportCandidatesByQuery(candidates, searchQuery); - const tabs = workspaceSkillTabs(searchedCandidates); - const filteredCandidates = filterWorkspaceSkillCandidates(searchedCandidates, activeTab); + const searchedGroups = filterImportCandidateGroupsByQuery(groups, searchQuery); + const tabs = importCandidateGroupTabs(searchedGroups); + const filteredGroups = filterImportCandidateGroups(searchedGroups, activeTab); return ( <> @@ -305,11 +309,12 @@ function CandidateReviewList({ candidates, onToggleSelected, onTypeChange }) { onTabChange={setActiveTab} /> - {filteredCandidates.length > 0 ? ( - filteredCandidates.map((candidate) => ( - 0 ? ( + filteredGroups.map((group) => ( + @@ -344,72 +349,112 @@ function WorkspaceSkillTabs({ activeTab, tabs, onTabChange }) { ); } -function CandidateRow({ candidate, onToggleSelected, onTypeChange }) { +function CandidateGroupCard({ group, onSelectVariant, onToggleSelected, onTypeChange }) { + const [expanded, setExpanded] = useState(false); + const candidate = selectedImportCandidate(group) || group.variants[0]?.candidate; + const status = importCandidateGroupStatus(group); + const locationCount = importCandidateGroupLocationCount(group); + const disclosureId = `${group.id}-locations`; + return ( -
+
- {candidate.name} - - - {candidate.skillType === 'user' ? 'User skill' : 'Remote skill'} - - {candidate.importStatus === 'system' ? System : null} - {candidate.importStatus === 'imported' ? Imported : null} - {candidate.conflict ? Conflict : null} + {group.name} + {candidate ? : null} + {locationCount} {locationCount === 1 ? 'location' : 'locations'} + {status.imported ? Imported : null} + {status.system ? System : null} + {status.conflict ? Conflict : null} + {group.requiresReview && !group.selectedVariantId ? Needs review : null}
- {candidate.description || 'No description in SKILL.md'} - - Path - {compactPath(candidate.sourcePath)} - - {candidate.isSymlink ? ( - - Symlink source - {compactPath(candidate.symlinkTargetPath || candidate.realPath || '')} - - ) : null} - {candidate.additionalSourcePaths?.length > 0 ? ( -
- - Also found in {candidate.additionalSourcePaths.length}{' '} - {candidate.additionalSourcePaths.length === 1 ? 'other location' : 'other locations'} (not changed) - - {candidate.additionalSourcePaths.map((sourcePath) => ( - {compactPath(sourcePath)} - ))} -
+ {group.description || 'No description in SKILL.md'} + + {expanded ? ( +
+ {group.variants.map((variant, variantIndex) => { + const importable = isImportableCandidate(variant.candidate); + const selected = group.selectedVariantId === variant.id; + return ( +
+ +
+ + {variant.candidate.skillType === 'user' ? 'User suggestion' : 'Remote suggestion'} + + + {variant.candidate.conflict || variant.candidate.importStatus} + +
+ {variant.locations.map((location) => ( +
+ {location.isSymlink ? 'Runtime symlink' : 'Skill folder'} + {compactPath(location.sourcePath)} + {location.isSymlink ? ( + Source: {compactPath(location.symlinkTargetPath || location.realPath)} + ) : null} +
+ ))} + {variant.candidate.conflict ?

{variant.candidate.conflict}

: null} +
+ ); + })} +
) : null} - Calls {candidate.usageCount || 0} + Calls {group.usageCount || 0} - {candidateStatusNote(candidate) ?

{candidateStatusNote(candidate)}

: null} + {candidate && candidateStatusNote(candidate) ?

{candidateStatusNote(candidate)}

: null}
-
+
diff --git a/apps/desktop/src/importCandidates.js b/apps/desktop/src/importCandidates.js index e60a9ac..5c16dad 100644 --- a/apps/desktop/src/importCandidates.js +++ b/apps/desktop/src/importCandidates.js @@ -34,6 +34,174 @@ export function normalizeImportCandidate(candidate) { }; } +export function normalizeImportCandidateLocation(location = {}) { + return { + sourcePath: location.sourcePath || location.source_path || '', + sourceRoot: location.sourceRoot || location.source_root || '', + realPath: location.realPath || location.real_path || '', + isSymlink: Boolean(location.isSymlink ?? location.is_symlink), + symlinkTargetPath: location.symlinkTargetPath || location.symlink_target_path || '' + }; +} + +export function normalizeImportCandidateGroup(group) { + const variants = (group.variants || []).map((variant, index) => { + const candidate = normalizeImportCandidate(variant.candidate || variant); + const locations = (variant.locations || []).map(normalizeImportCandidateLocation); + const locationCopies = locations + .map((location) => location.sourcePath) + .filter((path) => path && path !== candidate.sourcePath); + return { + id: variant.id || `variant-${index}-${candidate.sourcePath}`, + candidate: { + ...candidate, + additionalSourcePaths: [...new Set([...candidate.additionalSourcePaths, ...locationCopies])] + }, + locations: locations.length > 0 + ? locations + : [normalizeImportCandidateLocation(candidate)] + }; + }); + const selectedVariantId = group.selectedVariantId || group.selected_variant_id || null; + const selectedVariant = variants.find((variant) => variant.id === selectedVariantId); + + return { + id: group.id, + name: group.name || variants[0]?.candidate.name || '', + description: group.description || variants[0]?.candidate.description || '', + usageCount: Number(group.usageCount ?? group.usage_count) || 0, + requiresReview: Boolean(group.requiresReview ?? group.requires_review), + selectedVariantId, + variants, + isSelected: Boolean(selectedVariant?.candidate.isSelected) + }; +} + +export function normalizeImportCandidateGroups(groups = [], candidates = []) { + if (groups.length > 0) { + return groups.map(normalizeImportCandidateGroup); + } + + return candidates.map((candidate, index) => { + const normalized = normalizeImportCandidate(candidate); + const variantId = `legacy-variant-${index}`; + return normalizeImportCandidateGroup({ + id: `legacy-group-${index}`, + name: normalized.name, + description: normalized.description, + usageCount: normalized.usageCount, + selectedVariantId: normalized.isSelected ? variantId : null, + variants: [{ id: variantId, candidate: normalized }] + }); + }); +} + +export function selectedImportCandidate(group) { + return group.variants.find((variant) => variant.id === group.selectedVariantId)?.candidate || null; +} + +export function isSelectableImportCandidateGroup(group) { + const candidate = selectedImportCandidate(group); + return Boolean(candidate && candidate.importStatus === 'importable' && !candidate.conflict); +} + +export function toggleImportCandidateGroup(groups, groupId) { + return groups.map((group) => group.id === groupId && isSelectableImportCandidateGroup(group) + ? { ...group, isSelected: !group.isSelected } + : group); +} + +export function selectImportCandidateVariant(groups, groupId, variantId) { + return groups.map((group) => { + if (group.id !== groupId) return group; + const variant = group.variants.find((candidateVariant) => candidateVariant.id === variantId); + if (!variant || variant.candidate.importStatus !== 'importable' || variant.candidate.conflict) { + return group; + } + return { ...group, selectedVariantId: variantId, isSelected: true }; + }); +} + +export function updateImportCandidateGroupType(groups, groupId, skillType) { + return groups.map((group) => group.id === groupId + ? { + ...group, + variants: group.variants.map((variant) => variant.id === group.selectedVariantId + ? { ...variant, candidate: { ...variant.candidate, skillType } } + : variant) + } + : group); +} + +export function toggleImportCandidateGroupSelection(groups, targetGroups = groups) { + const targetIds = new Set(targetGroups.map((group) => group.id)); + const selectable = targetGroups.filter(isSelectableImportCandidateGroup); + const shouldSelectAll = selectable.some((group) => !group.isSelected); + return groups.map((group) => targetIds.has(group.id) && isSelectableImportCandidateGroup(group) + ? { ...group, isSelected: shouldSelectAll } + : group); +} + +export function selectedImportCandidates(groups = []) { + return groups + .filter((group) => group.isSelected && isSelectableImportCandidateGroup(group)) + .map(selectedImportCandidate); +} + +export function importCandidateGroupLocationCount(group) { + return group.variants.reduce((count, variant) => count + variant.locations.length, 0); +} + +export function importCandidateGroupStatus(group) { + const statuses = new Set(group.variants.map((variant) => variant.candidate.importStatus)); + return { + importable: statuses.has('importable'), + imported: statuses.has('imported'), + system: statuses.has('system'), + conflict: group.variants.some((variant) => Boolean(variant.candidate.conflict)) + }; +} + +export function importCandidateGroupTabs(groups = []) { + return [ + { id: 'all', label: 'All', count: groups.length }, + { id: 'unimported', label: 'Unimported', count: groups.filter((group) => importCandidateGroupStatus(group).importable).length }, + { id: 'imported', label: 'Imported', count: groups.filter((group) => importCandidateGroupStatus(group).imported).length }, + { id: 'system', label: 'System', count: groups.filter((group) => importCandidateGroupStatus(group).system).length } + ]; +} + +export function filterImportCandidateGroups(groups = [], activeTab = 'all') { + if (activeTab === 'all') return groups; + const key = activeTab === 'unimported' ? 'importable' : activeTab; + return groups.filter((group) => importCandidateGroupStatus(group)[key]); +} + +export function filterImportCandidateGroupsByQuery(groups = [], query = '') { + const tokens = String(query).trim().toLowerCase().split(/\s+/).filter(Boolean); + if (tokens.length === 0) return groups; + return groups.filter((group) => { + const searchable = [ + group.name, + group.description, + ...group.variants.flatMap((variant) => [ + variant.candidate.description, + variant.candidate.skillType, + variant.candidate.importStatus, + variant.candidate.conflict, + variant.candidate.suggestionReason, + ...variant.locations.flatMap((location) => [ + location.sourcePath, + location.sourceRoot, + location.realPath, + location.symlinkTargetPath + ]) + ]) + ].filter(Boolean).join(' ').toLowerCase(); + return tokens.every((token) => searchable.includes(token)); + }); +} + export function workspaceSkillTabs(candidates = []) { const visibleCandidates = visibleImportCandidates(candidates); const unimportedCandidates = visibleCandidates.filter(isUnimportedCandidate); diff --git a/apps/desktop/src/previewData.js b/apps/desktop/src/previewData.js index b49256f..10bb254 100644 --- a/apps/desktop/src/previewData.js +++ b/apps/desktop/src/previewData.js @@ -54,6 +54,75 @@ export const previewImportCandidates = [ } ]; +export const previewImportCandidateGroups = [ + { + id: 'skill-release-helper', + name: 'release-helper', + description: 'Prepare release notes and verify project release assets.', + usageCount: 6, + requiresReview: true, + selectedVariantId: null, + variants: [ + { + id: 'variant-release-user', + candidate: previewImportCandidates[0], + locations: [ + { + sourcePath: '~/.agents/skills/release-helper', + sourceRoot: '~/.agents/skills', + realPath: '~/Library/Application Support/SkillFixtures/release-helper', + isSymlink: true, + symlinkTargetPath: '~/Library/Application Support/SkillFixtures/release-helper' + }, + { + sourcePath: '~/Projects/demo-app/.cursor/skills/release-helper', + sourceRoot: '~/Projects/demo-app/.cursor/skills', + realPath: '~/Library/Application Support/SkillFixtures/release-helper', + isSymlink: true, + symlinkTargetPath: '~/Library/Application Support/SkillFixtures/release-helper' + } + ] + }, + { + id: 'variant-release-remote', + candidate: { + ...previewImportCandidates[0], + sourcePath: '~/.codex/skills/release-helper', + sourceRoot: '~/.codex/skills', + suggestedType: 'remote', + skillType: 'remote', + suggestionReason: 'inside ~/.codex/skills' + }, + locations: [ + { + sourcePath: '~/.codex/skills/release-helper', + sourceRoot: '~/.codex/skills', + realPath: '~/.codex/skills/release-helper', + isSymlink: false + } + ] + } + ] + }, + ...previewImportCandidates.slice(1).map((candidate, index) => ({ + id: `skill-preview-${index}`, + name: candidate.name, + description: candidate.description, + usageCount: candidate.usageCount || 0, + selectedVariantId: candidate.isSelected ? `variant-preview-${index}` : null, + variants: [{ + id: `variant-preview-${index}`, + candidate, + locations: [{ + sourcePath: candidate.sourcePath, + sourceRoot: candidate.sourceRoot, + realPath: candidate.sourcePath, + isSymlink: false + }] + }] + })) +]; + export const previewWorkspaces = [ { canonical_path: '/Users/example/.codex/skills', diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 42ef6b4..ab87849 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -6426,6 +6426,108 @@ dd { margin: 5px 0 0 14px; } +.candidateLocationsDisclosure { + display: inline-flex; + max-width: 100%; + min-height: 30px; + margin-top: 8px; + align-items: center; + gap: 6px; + border: 0; + background: transparent; + color: var(--skillbox-text-secondary); + cursor: pointer; + font-size: 12px; + font-weight: 650; + padding: 0; + text-align: left; +} + +.candidateLocationsDisclosure svg { + width: 14px; + height: 14px; + flex: 0 0 14px; +} + +.candidateLocationsDisclosure svg:last-child { + transition: transform 140ms ease; +} + +.candidateLocationsDisclosure svg:last-child.expanded { + transform: rotate(180deg); +} + +.candidateVariants { + display: grid; + margin-top: 8px; + gap: 8px; +} + +.candidateVariant { + min-width: 0; + border-top: 1px solid var(--skillbox-border-soft); + padding-top: 8px; +} + +.candidateVariant.selected { + border-top-color: var(--skillbox-blue-border); +} + +.candidateVariantChoice { + display: flex; + align-items: center; + gap: 8px; + color: var(--skillbox-text); + cursor: pointer; + font-size: 12px; + font-weight: 650; +} + +.candidateVariantChoice input { + width: 16px; + height: 16px; + flex: 0 0 16px; + accent-color: var(--skillbox-blue); +} + +.candidateVariantChoice input:disabled + span { + color: var(--skillbox-text-muted); +} + +.candidateVariantMeta { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 7px 0; +} + +.candidateLocation { + display: grid; + min-width: 0; + grid-template-columns: 96px minmax(0, 1fr); + gap: 3px 8px; + align-items: baseline; + padding: 5px 0; +} + +.candidateLocation > span { + color: var(--skillbox-text-secondary); + font-size: 11px; + font-weight: 650; +} + +.candidateLocation code { + margin: 0; +} + +.candidateLocation small { + grid-column: 2; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .candidatePath span, .candidateSymlinkSource span { color: var(--skillbox-text-secondary); @@ -6833,4 +6935,12 @@ dd { grid-column: 2; width: max-content; } + + .candidateLocation { + grid-template-columns: minmax(0, 1fr); + } + + .candidateLocation small { + grid-column: 1; + } } diff --git a/crates/skillbox-core/src/import.rs b/crates/skillbox-core/src/import.rs index 24b654f..faacc99 100644 --- a/crates/skillbox-core/src/import.rs +++ b/crates/skillbox-core/src/import.rs @@ -95,6 +95,7 @@ pub fn scan_import_candidates( .then_with(|| left.source_path.cmp(&right.source_path)) }) }); + let groups = group_import_candidates(&candidates, &usage_by_skill); dedupe_import_candidates(&mut candidates); candidates.sort_by(|left, right| { left.name @@ -104,10 +105,160 @@ pub fn scan_import_candidates( Ok(ImportCandidateScan { roots: scan.roots, candidates, + groups, errors: scan.errors, }) } +pub(crate) fn group_import_candidates( + candidates: &[ImportCandidate], + usage_by_skill: &HashMap, +) -> Vec { + let mut grouped = BTreeMap::>::new(); + for candidate in candidates { + grouped + .entry(candidate.name.to_ascii_lowercase()) + .or_default() + .push(candidate.clone()); + } + + grouped + .into_iter() + .map(|(normalized_name, candidates)| { + build_import_candidate_group(&normalized_name, candidates, usage_by_skill) + }) + .collect() +} + +fn build_import_candidate_group( + normalized_name: &str, + candidates: Vec, + usage_by_skill: &HashMap, +) -> ImportCandidateGroup { + let mut variants: Vec<(String, ImportCandidateVariant)> = Vec::new(); + + for candidate in candidates { + let signature = import_candidate_variant_signature(&candidate); + let location = import_candidate_location(&candidate); + if let Some((_, variant)) = variants + .iter_mut() + .find(|(existing_signature, _)| existing_signature == &signature) + { + if !variant.locations.contains(&location) { + variant.locations.push(location); + } + continue; + } + + let variant_id = format!("variant-{}", &sha256(&signature)[..16]); + let mut variant_candidate = candidate; + variant_candidate.additional_source_paths.clear(); + variants.push(( + signature, + ImportCandidateVariant { + id: variant_id, + candidate: variant_candidate, + locations: vec![location], + }, + )); + } + + let mut variants = variants + .into_iter() + .map(|(_, mut variant)| { + variant.candidate.source_path = variant.locations[0].source_path.clone(); + variant.candidate.source_root = variant.locations[0].source_root.clone(); + variant.candidate.real_path = variant.locations[0].real_path.clone(); + variant.candidate.is_symlink = variant.locations[0].is_symlink; + variant.candidate.symlink_target_path = + variant.locations[0].symlink_target_path.clone(); + variant.candidate.additional_source_paths = variant + .locations + .iter() + .skip(1) + .map(|location| location.source_path.clone()) + .collect(); + variant + }) + .collect::>(); + + let importable_variant_ids = variants + .iter() + .filter(|variant| { + variant.candidate.import_status == ImportCandidateStatus::Importable + && variant.candidate.conflict.is_none() + }) + .map(|variant| variant.id.clone()) + .collect::>(); + let selected_variant_id = + (importable_variant_ids.len() == 1).then(|| importable_variant_ids[0].clone()); + for variant in &mut variants { + variant.candidate.is_selected = selected_variant_id.as_deref() == Some(&variant.id); + } + + let name = variants + .first() + .map(|variant| variant.candidate.name.clone()) + .unwrap_or_else(|| normalized_name.to_string()); + let description = variants + .iter() + .find_map(|variant| { + (!variant.candidate.description.trim().is_empty()) + .then(|| variant.candidate.description.clone()) + }) + .unwrap_or_default(); + let usage_count = usage_by_skill + .get(&name) + .map(|usage| usage.usage_count) + .unwrap_or_else(|| { + variants + .iter() + .map(|variant| variant.candidate.usage_count) + .max() + .unwrap_or_default() + }); + + ImportCandidateGroup { + id: format!("skill-{}", &sha256(normalized_name)[..16]), + name, + description, + usage_count, + requires_review: importable_variant_ids.len() > 1, + selected_variant_id, + variants, + } +} + +fn import_candidate_variant_signature(candidate: &ImportCandidate) -> String { + let source_identity = if candidate.import_status == ImportCandidateStatus::Imported { + format!("managed:{}", candidate.real_path.display()) + } else { + match skill_directory_snapshot_hash(&candidate.real_path) { + Ok(snapshot) => format!("snapshot:{snapshot}"), + Err(_) => format!("unavailable:{}", candidate.real_path.display()), + } + }; + format!( + "{}\n{}\n{:?}\n{:?}\n{}\n{}", + candidate.name.to_ascii_lowercase(), + candidate.content_hash, + candidate.suggested_type, + candidate.import_status, + candidate.conflict.as_deref().unwrap_or_default(), + source_identity, + ) +} + +fn import_candidate_location(candidate: &ImportCandidate) -> ImportCandidateLocation { + ImportCandidateLocation { + source_path: candidate.source_path.clone(), + source_root: candidate.source_root.clone(), + real_path: candidate.real_path.clone(), + is_symlink: candidate.is_symlink, + symlink_target_path: candidate.symlink_target_path.clone(), + } +} + pub(crate) fn dedupe_import_candidates(candidates: &mut Vec) { let mut deduped: Vec = Vec::new(); let mut package_hashes: Vec> = Vec::new(); @@ -209,6 +360,7 @@ pub fn import_candidates( managed_root: impl AsRef, ) -> Result { let mutation_lock = acquire_user_skills_mutation_lock(managed_root.as_ref())?; + validate_unique_import_request_names(&items)?; let paths = ensure_managed_layout(mutation_lock.truth_root().to_path_buf())?; let mut imported = Vec::new(); let mut errors = Vec::new(); @@ -224,6 +376,24 @@ pub fn import_candidates( Ok(ImportBatchResult { imported, errors }) } +fn validate_unique_import_request_names(items: &[ImportRequestItem]) -> Result<()> { + let mut names = HashSet::new(); + for item in items { + let source_path = expand_home(item.source_path.clone()); + let Ok(skill) = read_skill(&source_path) else { + continue; + }; + let normalized_name = skill.name.to_ascii_lowercase(); + if !names.insert(normalized_name) { + return Err(format!( + "Import review may select only one source variant for skill {}.", + skill.name + )); + } + } + Ok(()) +} + pub(crate) fn import_one_candidate( paths: &ManagedPaths, item: ImportRequestItem, diff --git a/crates/skillbox-core/src/tests.rs b/crates/skillbox-core/src/tests.rs index 88a7556..f0582e6 100644 --- a/crates/skillbox-core/src/tests.rs +++ b/crates/skillbox-core/src/tests.rs @@ -9912,6 +9912,16 @@ fn scan_import_candidates_groups_identical_copied_skills_across_roots() { assert_eq!(demo.additional_source_paths, vec![project_source.clone()]); assert_eq!(demo.import_status, ImportCandidateStatus::Importable); assert!(demo.is_selected); + assert_eq!(candidates.groups.len(), 1); + let group = &candidates.groups[0]; + assert_eq!(group.name, "demo"); + assert_eq!(group.variants.len(), 1); + assert_eq!(group.variants[0].locations.len(), 2); + assert_eq!( + group.selected_variant_id.as_deref(), + Some(group.variants[0].id.as_str()) + ); + assert!(!group.requires_review); let reversed = scan_import_candidates(&[project_root, global_root], &managed_root).unwrap(); let reversed_demo = candidate(&reversed.candidates, "demo"); @@ -9939,6 +9949,56 @@ fn scan_import_candidates_keeps_same_skill_md_with_different_assets_separate() { .candidates .iter() .all(|candidate| candidate.additional_source_paths.is_empty())); + assert_eq!(candidates.groups.len(), 1); + assert_eq!(candidates.groups[0].variants.len(), 2); + assert!(candidates.groups[0].requires_review); + assert!(candidates.groups[0].selected_variant_id.is_none()); + assert!(candidates.groups[0] + .variants + .iter() + .all(|variant| !variant.candidate.is_selected)); +} + +#[test] +fn scan_import_candidates_groups_runtime_aliases_by_skill_with_explicit_variants() { + let root = temp_dir("candidate-runtime-alias-group"); + let agents_root = root.join("home/.agents/skills"); + let codex_root = root.join("home/.codex/skills"); + let cursor_root = root.join("home/.cursor/skills"); + let shared_source = agents_root.join("hyperframes"); + let managed_root = root.join("SkillBox"); + make_skill(&shared_source, "hyperframes", "Create product videos"); + fs::create_dir_all(&codex_root).unwrap(); + fs::create_dir_all(&cursor_root).unwrap(); + symlink_dir(&shared_source, &codex_root.join("hyperframes")).unwrap(); + symlink_dir(&shared_source, &cursor_root.join("hyperframes")).unwrap(); + + let scan = + scan_import_candidates(&[agents_root, codex_root, cursor_root], &managed_root).unwrap(); + + assert_eq!(scan.groups.len(), 1); + let group = &scan.groups[0]; + assert_eq!(group.name, "hyperframes"); + assert_eq!(group.variants.len(), 2); + assert_eq!( + group + .variants + .iter() + .map(|variant| variant.locations.len()) + .sum::(), + 3 + ); + assert!(group.requires_review); + assert!(group.selected_variant_id.is_none()); + assert_eq!( + group + .variants + .iter() + .flat_map(|variant| &variant.locations) + .filter(|location| location.is_symlink) + .count(), + 2 + ); } #[test] @@ -9998,25 +10058,29 @@ fn import_candidates_reuses_identical_user_target_without_deployments() { make_skill(&first_source, "demo", "Demo skill"); make_skill(&second_source, "demo", "Demo skill"); - let result = import_candidates( - vec![ - ImportRequestItem { - source_path: first_source.clone(), - skill_type: SkillKind::User, - deploy_back_to_source: false, - }, - ImportRequestItem { - source_path: second_source.clone(), - skill_type: SkillKind::User, - deploy_back_to_source: false, - }, - ], + let first_result = import_candidates( + vec![ImportRequestItem { + source_path: first_source.clone(), + skill_type: SkillKind::User, + deploy_back_to_source: false, + }], + &managed_root, + ) + .unwrap(); + let second_result = import_candidates( + vec![ImportRequestItem { + source_path: second_source.clone(), + skill_type: SkillKind::User, + deploy_back_to_source: false, + }], &managed_root, ) .unwrap(); - assert!(result.errors.is_empty()); - assert_eq!(result.imported.len(), 2); + assert!(first_result.errors.is_empty()); + assert!(second_result.errors.is_empty()); + assert_eq!(first_result.imported.len(), 1); + assert_eq!(second_result.imported.len(), 1); assert!(first_source.is_dir()); assert!(second_source.is_dir()); let records = list_import_records( @@ -10029,6 +10093,41 @@ fn import_candidates_reuses_identical_user_target_without_deployments() { assert!(records.records.is_empty()); } +#[test] +fn import_candidates_rejects_multiple_sources_for_one_skill_before_writing() { + let root = temp_dir("candidate-one-variant-only"); + let first_source = root.join("first/demo"); + let second_source = root.join("second/demo"); + let managed_root = root.join("SkillBox"); + make_skill(&first_source, "demo", "First demo"); + make_skill(&second_source, "demo", "Second demo"); + fs::write(first_source.join("prompt.md"), "first\n").unwrap(); + fs::write(second_source.join("prompt.md"), "second\n").unwrap(); + + let error = import_candidates( + vec![ + ImportRequestItem { + source_path: first_source.clone(), + skill_type: SkillKind::User, + deploy_back_to_source: true, + }, + ImportRequestItem { + source_path: second_source.clone(), + skill_type: SkillKind::Remote, + deploy_back_to_source: true, + }, + ], + &managed_root, + ) + .unwrap_err(); + + assert!(error.contains("only one source variant for skill demo")); + assert!(first_source.is_dir()); + assert!(second_source.is_dir()); + assert!(!managed_root.join("user-skills/demo").exists()); + assert!(!managed_root.join("remote-skills/demo").exists()); +} + #[test] fn import_candidates_copies_user_skill_backs_up_original_and_symlinks_source() { let root = temp_dir("candidate-import-user"); @@ -10781,6 +10880,9 @@ fn scan_import_candidates_keeps_distinct_imported_targets_separate() { candidates.candidates[0].real_path, candidates.candidates[1].real_path ); + assert_eq!(candidates.groups.len(), 1); + assert_eq!(candidates.groups[0].variants.len(), 2); + assert!(candidates.groups[0].selected_variant_id.is_none()); } #[test] @@ -10839,6 +10941,7 @@ fn scan_import_candidates_uses_total_usage_for_imported_skills() { let demo = candidate(&candidates.candidates, "demo"); assert_eq!(demo.import_status, ImportCandidateStatus::Imported); assert_eq!(demo.usage_count, 2); + assert_eq!(candidates.groups[0].usage_count, 2); } #[test] @@ -10905,24 +11008,28 @@ fn remote_import_rejects_same_skill_md_with_different_assets() { fs::write(first_source.join("prompt.md"), "first\n").unwrap(); fs::write(second_source.join("prompt.md"), "second\n").unwrap(); + let first_result = import_candidates( + vec![ImportRequestItem { + source_path: first_source.clone(), + skill_type: SkillKind::Remote, + deploy_back_to_source: true, + }], + &managed_root, + ) + .unwrap(); let result = import_candidates( - vec![ - ImportRequestItem { - source_path: first_source.clone(), - skill_type: SkillKind::Remote, - deploy_back_to_source: true, - }, - ImportRequestItem { - source_path: second_source.clone(), - skill_type: SkillKind::Remote, - deploy_back_to_source: true, - }, - ], + vec![ImportRequestItem { + source_path: second_source.clone(), + skill_type: SkillKind::Remote, + deploy_back_to_source: true, + }], &managed_root, ) .unwrap(); - assert_eq!(result.imported.len(), 1); + assert_eq!(first_result.imported.len(), 1); + assert!(first_result.errors.is_empty()); + assert!(result.imported.is_empty()); assert_eq!(result.errors.len(), 1); assert!(fs::symlink_metadata(&first_source) .unwrap() diff --git a/crates/skillbox-core/src/types.rs b/crates/skillbox-core/src/types.rs index 4468fc0..74c5018 100644 --- a/crates/skillbox-core/src/types.rs +++ b/crates/skillbox-core/src/types.rs @@ -1060,6 +1060,33 @@ pub struct ImportCandidate { pub usage_count: usize, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ImportCandidateLocation { + pub source_path: PathBuf, + pub source_root: Option, + pub real_path: PathBuf, + pub is_symlink: bool, + pub symlink_target_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ImportCandidateVariant { + pub id: String, + pub candidate: ImportCandidate, + pub locations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ImportCandidateGroup { + pub id: String, + pub name: String, + pub description: String, + pub usage_count: usize, + pub requires_review: bool, + pub selected_variant_id: Option, + pub variants: Vec, +} + #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ImportCandidateStatus { @@ -1072,6 +1099,7 @@ pub enum ImportCandidateStatus { pub struct ImportCandidateScan { pub roots: Vec, pub candidates: Vec, + pub groups: Vec, pub errors: Vec, } diff --git a/docs/architecture.md b/docs/architecture.md index c4998e8..1d393ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,7 +103,7 @@ cargo run -p skillbox-cli --offline -- - `skills.rs` `SKILL.md` 解析、扫描、导入、symlink 部署 - `runtime_profiles.rs` versioned runtime profile registry、root precedence 和 capability policy - `compatibility.rs` read-only frontmatter/target compatibility preview 与 stale-preview apply -- `import.rs` import candidates 扫描、类型推断、整目录重复候选分组、冲突与备份 +- `import.rs` import candidates 扫描、类型推断、Rust-owned skill group / variant / location 分组、冲突与备份 - `state.rs` managed state 聚合与用户偏好 - `workspaces.rs` workspace registry 发现、注册与扫描 - `remote.rs` GitHub install preview/apply、remote source 绑定、update check、diff 预览、版本切换 @@ -132,7 +132,7 @@ cargo run -p skillbox-cli --offline -- - workspace registry 的发现、手动添加、扫描统计和 forget 操作。 - runtime profile registry、structured frontmatter preservation 和部署 compatibility 判定。 - user/remote skill 导入。 -- import candidates 扫描、类型推断、整目录快照去重和冲突检测。分组后的候选保留 primary `source_path` 与 `additional_source_paths`;React 展示全部来源,但只把 primary 作为结构化 import item 提交,避免一次 review 隐式创建多个不可单独 revert 的 active imports。 +- import candidates 扫描、类型推断、整目录快照去重和冲突检测。Rust 按规范化 skill name 生成稳定 group/variant ids;严格等价副本成为同一 variant 的 locations,同名但快照、类型、状态或冲突不同的来源仍是同卡片内的独立 variants。React 只展示 Rust 结果并提交明确选中的 variant primary,core 同时拒绝一次请求为同名 skill 提交多个来源。 - preview-confirmed symlink 部署和部署索引。 - import backup 与 source 替换为 symlink。 - GitHub install preview/apply, GitHub-only remote source search, manual binding, update check, version listing, diff preview, update/rollback apply, and operation logging. @@ -272,7 +272,7 @@ Preview 会在 working-tree write 前检查 incoming add/rename/type-change 与 GitHub remote source 可以是仓库中的 skill 子目录,也可以是根目录包含 `SKILL.md` 的 standalone repository。后者在 metadata 中显式记录为 `root: true`,preview、install、update 和 deploy 共用同一份清理后的 repository worktree snapshot;Git checkout 的 `.git` metadata 不进入 managed store,逃逸 source root 的 symlink 在 copy 边界被拒绝。 -重复候选只在名称、`SKILL.md` hash、推断类型、状态、冲突结果和完整导入快照均一致时合并;快照忽略顶层 `.git`,并覆盖其它路径、文件内容、Unix mode 与 symlink target。已 imported 的多个 runtime symlink 仅在解析到同一 managed `real_path` 时作为 alias 合并。primary 来源沿扫描 root 顺序选择;其它实体来源保留在 `additional_source_paths`,仅用于 review/search,不会在本次操作中被修改。导入只备份 primary、替换其 managed symlink 并写入 import record。仅 `SKILL.md` 相同但脚本、权限或资源不同的目录不能合并或复用;User 和 Remote 的已有 managed target 都必须通过完整快照校验。 +重复候选只在名称、`SKILL.md` hash、推断类型、状态、冲突结果和完整导入快照均一致时合并;快照忽略顶层 `.git`,并覆盖其它路径、文件内容、Unix mode 与 symlink target。相同 canonical source 的 runtime symlink 和不同真实路径的相同完整快照可成为一个 variant 的 locations;已 imported aliases 仍必须解析到同一 managed `real_path`。primary 来源沿扫描 root 顺序选择。Calls 使用 Rust 的按 skill 去重 aggregate,不把 locations 或 variants 直接相加。其它位置仅用于 review/search,不会在本次操作中被修改。多个 material variants 不预选,必须显式单选;导入只备份所选 primary、替换其 managed symlink 并写入 import record。 ## 当前状态与目标状态 diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 87dd4f3..7599ff5 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -29,7 +29,7 @@ - Added shared desktop diff rendering and remote skill workflow normalization helpers for source binding and version change previews. - Implemented SQLite-backed workspace registry for global and project-local skills roots, including `.codex/skills`, `.agents/skills`, `.claude/skills`, scan-time auto registration, imported skill counts, preview-confirmed single-root project initialization, manual add/forget, Rust CLI compatibility commands, Tauri commands, and a searchable desktop Workspaces page with type filters and per-workspace skill review/import. - Implemented import records and import revert in Rust core, Rust CLI, Tauri commands, and Skill Detail UI, including backup restoration, conservative legacy reconciliation, multi-workspace blocking, and warning/danger confirmation states. -- Implemented whole-directory duplicate grouping for Import Review, including deterministic primary source selection, retained additional source paths, grouped desktop review/search, explicit untouched-copy messaging, and primary-only backup/symlink deployment that preserves existing revert guarantees. +- Implemented Rust-owned Import Review groups with stable skill/variant identities, full-snapshot-equivalent locations, explicit selection for materially different same-name variants, group-level Calls/search/tab counts, and one-primary-only import enforcement that preserves existing revert guarantees. - Added signed macOS app update checks and user-confirmed install/restart through the Tauri updater plugin, plus release workflow assets for updater archives, signatures, and `latest.json`. - Added daily macOS updater metadata checks with a SQLite-backed successful-result cache, a sidebar Update reminder, one-click metadata recheck plus signed install/restart, and retry-safe pending updates without automatic downloads. - Added ordered, transactional Rust SQLite migrations, consistent pre-migration backups for existing databases, schema version tracking, and integrity validation. diff --git a/docs/workflows.md b/docs/workflows.md index 6da404f..4d8b382 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -51,7 +51,9 @@ Claude、OpenClaw、Cursor、Claude Code、Copilot 等需要通过 agent adapter - 根据路径和内容推断类型:当前 `.agents/skills` 倾向 user,`.codex/skills` 倾向 remote,`.system` 默认不选中,包含 GitHub 来源信息的未知目录倾向 remote。 - 对名称、`SKILL.md` hash、推断类型、状态、冲突结果以及完整导入快照都一致的实体副本进行分组;快照忽略顶层 `.git`,但覆盖其它文件、Unix mode、目录和 symlink。仅 `SKILL.md` 相同而脚本、权限或资源不同的候选保持分离。 - 已 imported 的多个 runtime symlink 只有解析到同一个 managed `real_path` 时才作为 alias 合并。 -- 分组候选按扫描 root 顺序保留一个 primary `source_path`,并通过 `additional_source_paths` 保留其它实体来源。Import Review 显示来源数量和可展开路径,搜索会匹配任一来源,并明确其它副本在本次操作中不会改变。 +- Rust 先按规范化 skill name 输出一组,再按完整目录快照、推断类型、状态和冲突划分 variants。等价副本是一个 variant 的 locations;同名但有实质差异的来源仍在同一张卡片内,并标记 `Needs review`。 +- Import Review 默认折叠 locations;展开后显示每个路径、symlink source、类型建议、状态和冲突。搜索匹配任一 variant/location,tab、Select all 和结果摘要按 group 计数。 +- 只有唯一安全 importable variant 时才预选。多个 material variants 必须由用户用 radio 明确选择一个,User/Remote 只作用于该 variant。Desktop 只提交所选 primary `source_path`,core 拒绝同一批次为同名 skill 提交多个来源;其它 locations 不会被修改。 - agent adapter 引入后,候选项还应携带 `agent_id`、原生格式和 target scope。 - 检查 managed target 是否冲突。 - user skill 复制到 `~/.skillbox/user-skills/`。