From 9fcd72e32754b618f6086e496a6b89122e797d4a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 12 Aug 2026 08:16:54 -0400 Subject: [PATCH] feat(skills): publish managed bundle versions --- ...-skill-sharing-implementation-checklist.md | 5 +- .../skills/SkillShareDialog.test.tsx | 37 +++++++++++- .../components/skills/SkillShareDialog.tsx | 21 ++++--- .../skills/SkillShareReviewContent.tsx | 30 ++++++++-- .../skill-share-package-selection.test.ts | 58 +++++++++++++++++++ .../skills/skill-share-package-selection.ts | 58 ++++++++++++++++--- src/renderer/src/i18n/locales/en.json | 8 ++- 7 files changed, 187 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/components/skills/skill-share-package-selection.test.ts diff --git a/docs/agent-skill-sharing-implementation-checklist.md b/docs/agent-skill-sharing-implementation-checklist.md index 3dc8f1ec50d..6a1c4a236fa 100644 --- a/docs/agent-skill-sharing-implementation-checklist.md +++ b/docs/agent-skill-sharing-implementation-checklist.md @@ -879,8 +879,9 @@ does not mean the surrounding phase is complete. - [ ] Add installed-bundle update, rollback, install-on-another-machine, inspect, and remove actions. Grouped inspection, selective update/rollback, and one-surface safe removal are implemented; install-on-another-machine remains. -- [ ] Add shared-bundle copy-link, access, revoke, publish-version, and Cloud-delete actions. Access, - revoke, version/package deletion are bundle-compatible; copy-link and publish-version remain. +- [x] Add shared-bundle copy-link, access, revoke, publish-version, and Cloud-delete actions. Exact + full-bundle version publishing reuses its package; ambiguous or partial managed-bundle + selections create a new package rather than mutating the wrong history. - [x] Protect locally modified skills during bundle update and removal, including when only part of a bundle remains managed. Keep-local is the default and destructive replacement is explicit. diff --git a/src/renderer/src/components/skills/SkillShareDialog.test.tsx b/src/renderer/src/components/skills/SkillShareDialog.test.tsx index 57b3f74e26f..805002f2dfa 100644 --- a/src/renderer/src/components/skills/SkillShareDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillShareDialog.test.tsx @@ -41,6 +41,15 @@ const preview: SkillSharePreview = { expiresAt: '2026-08-11T01:00:00.000Z' } +const secondSkill: DiscoveredSkill = { + ...skill, + id: 'home:second-skill', + name: 'second-skill', + description: 'Second skill', + directoryPath: '/home/skills/second-skill', + skillFilePath: '/home/skills/second-skill/SKILL.md' +} + function managedInstall(destinationIdentity: string): ManagedSkillInstall { return { name: 'private-skill', @@ -55,7 +64,11 @@ function managedInstall(destinationIdentity: string): ManagedSkillInstall { } } -function setup(installs: ManagedSkillInstall[], organization = false) { +function setup( + installs: ManagedSkillInstall[], + organization = false, + selectedSkills: DiscoveredSkill[] = [skill] +) { let progressListener: ((progress: SkillShareProgress) => void) | null = null const skills = { listManagedInstalls: vi.fn().mockResolvedValue({ status: 'ok', value: installs }), @@ -86,7 +99,7 @@ function setup(installs: ManagedSkillInstall[], organization = false) { } } }) - render( undefined} />) + render( undefined} />) return { skills, emitProgress: (progress: SkillShareProgress) => progressListener?.(progress) @@ -127,6 +140,26 @@ describe('SkillShareDialog', () => { ) }) + it('publishes a new immutable version for one exact managed bundle', async () => { + const installs = [ + { ...managedInstall('global:local'), bundleDigest: 'd'.repeat(64) }, + { + ...managedInstall('global:local'), + name: secondSkill.name, + bundleDigest: 'd'.repeat(64) + } + ] + const { skills } = setup(installs, false, [skill, secondSkill]) + + await screen.findByRole('heading', { name: 'Publish new skill bundle version' }) + expect(screen.getByRole('button', { name: 'Publish new version' })).toBeTruthy() + expect(skills.prepareShare).toHaveBeenCalledWith({ + skillIds: [skill.id, secondSkill.id], + bundleName: 'shared-skills', + packageId: 'pkg_1' + }) + }) + it('shows bounded upload progress and supports cancellation', async () => { let rejectPublish: (error: Error) => void = () => undefined const { skills, emitProgress } = setup([], true) diff --git a/src/renderer/src/components/skills/SkillShareDialog.tsx b/src/renderer/src/components/skills/SkillShareDialog.tsx index 3fae5b83016..1c94ed314e2 100644 --- a/src/renderer/src/components/skills/SkillShareDialog.tsx +++ b/src/renderer/src/components/skills/SkillShareDialog.tsx @@ -15,7 +15,7 @@ import { SkillSharePreparationReview, SkillSharePublishedLink } from './SkillShareReviewContent' -import { matchingManagedSkillInstall } from './skill-share-package-selection' +import { matchingManagedSkillShareInstall } from './skill-share-package-selection' type SkillShareDialogProps = { skills?: DiscoveredSkill[] @@ -72,10 +72,7 @@ export function SkillShareDialog({ try { const operation = await window.api.skills.listManagedInstalls() if (operation.status === 'ok') { - managedInstall = - selectedSkills.length === 1 - ? matchingManagedSkillInstall(selectedSkills[0], operation.value) - : null + managedInstall = matchingManagedSkillShareInstall(selectedSkills, operation.value) } } catch (cause) { console.warn('[skills] managed install lookup failed during share:', cause) @@ -210,6 +207,7 @@ export function SkillShareDialog({ {preparing ? ( @@ -265,10 +263,15 @@ export function SkillShareDialog({ 'auto.components.skills.SkillShareDialog.7aa4ba0dba', 'Publish new version' ) - : translate( - 'auto.components.skills.SkillShareDialog.0f07fa2a79', - 'Publish skill' - )} + : selectedSkills.length > 1 + ? translate( + 'auto.components.skills.SkillShareDialog.publishBundle', + 'Publish bundle' + ) + : translate( + 'auto.components.skills.SkillShareDialog.0f07fa2a79', + 'Publish skill' + )} ) ) : null} diff --git a/src/renderer/src/components/skills/SkillShareReviewContent.tsx b/src/renderer/src/components/skills/SkillShareReviewContent.tsx index 041e7663787..5e59f4b907c 100644 --- a/src/renderer/src/components/skills/SkillShareReviewContent.tsx +++ b/src/renderer/src/components/skills/SkillShareReviewContent.tsx @@ -12,22 +12,40 @@ import type { export function SkillShareDialogHeader({ published, - publishingNewVersion + publishingNewVersion, + skillCount }: { published: boolean publishingNewVersion: boolean + skillCount: number }): React.JSX.Element { + const bundle = skillCount > 1 return ( {published - ? translate('auto.components.skills.SkillShareDialog.ready', 'Skill link ready') - : publishingNewVersion + ? bundle ? translate( - 'auto.components.skills.SkillShareReviewContent.2dca0b720b', - 'Publish new skill version' + 'auto.components.skills.SkillShareReviewContent.bundleReady', + 'Skill bundle link ready' ) - : translate('auto.components.skills.SkillShareDialog.title', 'Share skill')} + : translate('auto.components.skills.SkillShareDialog.ready', 'Skill link ready') + : publishingNewVersion + ? bundle + ? translate( + 'auto.components.skills.SkillShareReviewContent.publishBundleVersion', + 'Publish new skill bundle version' + ) + : translate( + 'auto.components.skills.SkillShareReviewContent.2dca0b720b', + 'Publish new skill version' + ) + : bundle + ? translate( + 'auto.components.skills.SkillShareReviewContent.shareBundle', + 'Share skill bundle' + ) + : translate('auto.components.skills.SkillShareDialog.title', 'Share skill')} {published diff --git a/src/renderer/src/components/skills/skill-share-package-selection.test.ts b/src/renderer/src/components/skills/skill-share-package-selection.test.ts new file mode 100644 index 00000000000..9e0e61f4a40 --- /dev/null +++ b/src/renderer/src/components/skills/skill-share-package-selection.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import type { ManagedSkillInstall } from '../../../../shared/skill-install-contract' +import type { DiscoveredSkill } from '../../../../shared/skills' +import { matchingManagedSkillShareInstall } from './skill-share-package-selection' + +function skill(name: string): DiscoveredSkill { + return { + id: `skill:${name}`, + name, + description: null, + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Home', + rootPath: '/skills', + directoryPath: `/skills/${name}`, + skillFilePath: `/skills/${name}/SKILL.md`, + installed: true, + fileCount: 1, + updatedAt: null + } +} + +function install(name: string, destinationIdentity = 'global:local'): ManagedSkillInstall { + return { + name, + packageId: 'pkg_1', + versionId: 'ver_1', + packageDigest: 'a'.repeat(64), + bundleDigest: 'b'.repeat(64), + scope: 'global', + destinationIdentity, + destination: { scope: 'global' }, + installedAt: '2026-08-11T00:00:00.000Z', + state: 'unchanged' + } +} + +describe('matchingManagedSkillShareInstall', () => { + it('matches one complete managed bundle and rejects a partial selection', () => { + const installs = [install('alpha'), install('beta')] + + expect(matchingManagedSkillShareInstall([skill('alpha'), skill('beta')], installs)).toBe( + installs[0] + ) + expect(matchingManagedSkillShareInstall([skill('alpha')], installs)).toBeNull() + }) + + it('rejects the same bundle installed at multiple destinations', () => { + const installs = [ + install('alpha'), + install('beta'), + install('alpha', 'global:other'), + install('beta', 'global:other') + ] + + expect(matchingManagedSkillShareInstall([skill('alpha'), skill('beta')], installs)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/skills/skill-share-package-selection.ts b/src/renderer/src/components/skills/skill-share-package-selection.ts index 19632847708..f674eb2ee0f 100644 --- a/src/renderer/src/components/skills/skill-share-package-selection.ts +++ b/src/renderer/src/components/skills/skill-share-package-selection.ts @@ -1,18 +1,58 @@ import type { ManagedSkillInstall } from '../../../../shared/skill-install-contract' import type { DiscoveredSkill } from '../../../../shared/skills' -export function matchingManagedSkillInstall( - skill: DiscoveredSkill, +function skillScope(skill: DiscoveredSkill): ManagedSkillInstall['scope'] | null { + return skill.sourceKind === 'home' ? 'global' : skill.sourceKind === 'repo' ? 'workspace' : null +} + +export function matchingManagedSkillShareInstall( + skills: readonly DiscoveredSkill[], installs: ManagedSkillInstall[] ): ManagedSkillInstall | null { - const scope = - skill.sourceKind === 'home' ? 'global' : skill.sourceKind === 'repo' ? 'workspace' : null - if (!scope) { + if (skills.length === 0) { return null } - const matches = installs.filter( - (install) => - install.name === skill.name && install.scope === scope && install.state !== 'missing' + if (skills.length === 1) { + const scope = skillScope(skills[0]) + const matches = installs.filter( + (install) => + install.name === skills[0].name && + install.scope === scope && + install.state !== 'missing' && + !install.bundleDigest + ) + if (matches.length === 1) { + return matches[0] + } + if (matches.length > 1) { + return null + } + } + const scope = skillScope(skills[0]) + if (!scope || skills.some((skill) => skillScope(skill) !== scope)) { + return null + } + const selectedNames = new Set(skills.map((skill) => skill.name)) + const groups = new Map() + for (const install of installs) { + if (install.scope !== scope || install.state === 'missing' || !install.bundleDigest) { + continue + } + const key = [ + install.packageId, + install.versionId, + install.destinationIdentity, + install.bundleDigest + ].join('\0') + const group = groups.get(key) ?? [] + group.push(install) + groups.set(key, group) + } + const matches = [...groups.values()].filter( + (group) => + group.length === selectedNames.size && + group.every((install) => selectedNames.has(install.name)) && + new Set(group.map((install) => install.name)).size === group.length ) - return matches.length === 1 ? matches[0] : null + return matches.length === 1 ? (matches[0][0] ?? null) : null } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1e8b52406aa..595736a4d3f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4148,7 +4148,8 @@ "30985d4fc0": "Cancel", "3af85f6add": "Done", "readyDescriptionV2": "Anyone with this unlisted link can inspect and install the skills.", - "descriptionV2": "Review the exact files, then publish an immutable version behind an unlisted link." + "descriptionV2": "Review the exact files, then publish an immutable version behind an unlisted link.", + "publishBundle": "Publish bundle" }, "SkillCard": { "d25a1b8ae6": "Share skill", @@ -4277,7 +4278,10 @@ "01c5a17e03": "{{value0}} files ยท {{value1}}", "unlistedLinkTitle": "Unlisted link", "unlistedPublishingAs": "Publishing as {{value0}}. Anyone with the link can inspect and install these skills.", - "unlistedLinkDetails": "The link is not searchable or listed publicly. Revoke it to block future access; installed copies remain." + "unlistedLinkDetails": "The link is not searchable or listed publicly. Revoke it to block future access; installed copies remain.", + "bundleReady": "Skill bundle link ready", + "publishBundleVersion": "Publish new skill bundle version", + "shareBundle": "Share skill bundle" }, "SkillBundleInstallFlow": { "01c5a13e01": "Close",