feat(skills): publish managed bundle versions

This commit is contained in:
Jinwoo-H
2026-08-12 08:16:54 -04:00
parent 4115726e19
commit 9fcd72e327
7 changed files with 187 additions and 30 deletions
@@ -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.
@@ -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(<SkillShareDialog skill={skill} open onOpenChange={() => undefined} />)
render(<SkillShareDialog skills={selectedSkills} open onOpenChange={() => 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)
@@ -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({
<SkillShareDialogHeader
published={Boolean(shareUrl)}
publishingNewVersion={publishingNewVersion}
skillCount={selectedSkills.length}
/>
{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'
)}
</Button>
)
) : null}
@@ -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 (
<DialogHeader>
<DialogTitle>
{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')}
</DialogTitle>
<DialogDescription>
{published
@@ -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()
})
})
@@ -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<string, ManagedSkillInstall[]>()
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
}
+6 -2
View File
@@ -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",