Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
134 changes: 134 additions & 0 deletions apps/desktop/src/App.import-candidates.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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({
Expand Down
54 changes: 30 additions & 24 deletions apps/desktop/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ import {
normalizeStaleDeploymentRepairResult
} from './doctor.js';
import {
normalizeImportCandidateGroups,
normalizeImportCandidate,
visibleImportCandidates
selectedImportCandidates,
selectImportCandidateVariant,
toggleImportCandidateGroup,
toggleImportCandidateGroupSelection,
updateImportCandidateGroupType
} from './importCandidates.js';
import {
appUpdateNotice,
Expand All @@ -60,8 +65,7 @@ import {
isHttpUrl,
isImportableCandidate,
remoteImportCandidate,
shouldConfirmLocalImport,
toggleImportCandidateSelection
shouldConfirmLocalImport
} from './importFlow.js';
import {
clearLegacyDashboardMetadata,
Expand All @@ -78,6 +82,7 @@ import {
previewCandidatesForWorkspace,
previewHistory,
previewImportCandidates,
previewImportCandidateGroups,
previewPaths,
previewSkills,
previewUsageRankings,
Expand Down Expand Up @@ -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.',
Expand All @@ -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({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -4188,17 +4187,24 @@ export default function App() {

{importReview.open ? (
<ImportReview
candidates={importReview.candidates}
groups={importReview.candidates}
errors={importReview.errors}
onClose={closeImportReview}
onImport={importSelectedCandidates}
onToggleAll={toggleAllImportCandidates}
onToggleSelected={(candidate) =>
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}
Expand Down
36 changes: 18 additions & 18 deletions apps/desktop/src/cardLayout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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*\{(?<body>[\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*\{(?<body>[\s\S]*?)\n\}/
)?.groups.body || '';
const candidatePathMetaRule = css.match(/\.candidatePath,\s*\.candidateSymlinkSource\s*\{(?<body>[^}]*)\}/s)
?.groups.body || '';
const candidatePathRule = css.match(/\.candidatePath\s*\{(?<body>[^}]*)\}/s)?.groups.body || '';
const candidatePathCodeRule = css.match(/\.candidatePath code,\s*\.candidateSymlinkSource code\s*\{(?<body>[^}]*)\}/s)
?.groups.body || '';
const disclosureRule = css.match(/\.candidateLocationsDisclosure\s*\{(?<body>[^}]*)\}/s)?.groups.body || '';
const locationRule = css.match(/\.candidateLocation\s*\{(?<body>[^}]*)\}/s)?.groups.body || '';

assert.match(candidateRowSource, /className="candidatePath"/);
assert.match(candidateRowSource, /<span>Path<\/span>/);
assert.match(candidateRowSource, /<code>\{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', () => {
Expand All @@ -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%;/);
});

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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*<strong>\{workspace\.usageCount\}<\/strong>/);
assert.match(appSource, /<strong>No calls in this range<\/strong>/);
assert.match(appSource, /<caption className="srOnly">Skills ranked by calls<\/caption>/);
Expand Down
Loading