import { createHash } from 'node:crypto' import { access, mkdir, mkdtemp, readFile, readdir, rename, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, test } from 'bun:test' import { zipSync } from 'fflate' import { SkillHubClient, type SuiteInstallPlan } from '../../../src/clients/skillhub-client' import { InventoryStore } from '../../../src/stores/inventory-store' import { checkSuite, installSuite, planSuiteUpgrade, removeSuite, upgradeSuite } from '../../../src/services/suite-service' const registry = 'http://registry.test' function archive(content: string): { bytes: Uint8Array; fingerprint: string } { const bytes = zipSync({ 'SKILL.md': new TextEncoder().encode(content) }) const aggregate = createHash('sha256') const fileHash = createHash('sha256').update(content).digest('hex') aggregate.update(`SKILL.md:${fileHash}\n`, 'utf8') return { bytes, fingerprint: `sha256:${aggregate.digest('hex')}` } } function clientFor( plan: SuiteInstallPlan, downloads: Record, capabilities: string[] = ['skill-suite-v1'], available = true ): SkillHubClient { const fetchImpl = (async (input: URL | RequestInfo) => { const url = new URL(String(input)) if (url.pathname === '/.well-known/clawhub.json') { return Response.json({ apiBase: '/api/v1', capabilities }) } if (url.pathname.endsWith('/install-plan')) return Response.json({ code: 0, data: plan }) if (url.pathname === `/api/v1/suites/${plan.namespace}/${plan.slug}`) { return Response.json({ code: 0, data: { id: 1, versionId: 2, namespace: plan.namespace, slug: plan.slug, displayName: plan.slug, version: plan.version, status: 'PUBLISHED', visibility: 'PUBLIC', available, members: plan.members.map(member => ({ ...member, blockingReason: available ? null : 'member_unavailable' })) } }) } const bytes = downloads[url.pathname] return bytes ? new Response(bytes.slice().buffer as ArrayBuffer, { status: 200 }) : Response.json({ code: 404 }, { status: 404 }) }) as unknown as typeof fetch return new SkillHubClient(registry, undefined, fetchImpl) } function clientWithPlanFailure(status: number, message: string): SkillHubClient { const fetchImpl = (async (input: URL | RequestInfo) => { const url = new URL(String(input)) if (url.pathname === '/.well-known/clawhub.json') { return Response.json({ apiBase: '/api/v1', capabilities: ['skill-suite-v1'] }) } if (url.pathname.endsWith('/install-plan')) { return Response.json({ code: status, msg: message }, { status }) } return Response.json({ code: 404 }, { status: 404 }) }) as unknown as typeof fetch return new SkillHubClient(registry, undefined, fetchImpl) } function makePlan(): { plan: SuiteInstallPlan; downloads: Record } { const alpha = archive('# Alpha') const beta = archive('# Beta') return { plan: { operationId: 'operation-1', namespace: 'global', slug: 'starter-pack', version: '1.0.0', fingerprint: 'sha256:suite', members: [ { skillId: 10, skillVersionId: 11, namespace: 'global', slug: 'alpha', version: '1.0.0', fingerprint: alpha.fingerprint, downloadUrl: '/downloads/alpha', position: 0, entry: true }, { skillId: 20, skillVersionId: 21, namespace: 'global', slug: 'beta', version: '2.0.0', fingerprint: beta.fingerprint, downloadUrl: '/downloads/beta', position: 1, entry: false } ] }, downloads: { '/downloads/alpha': alpha.bytes, '/downloads/beta': beta.bytes } } } async function exists(path: string): Promise { try { await access(path) return true } catch { return false } } describe('Suite local lifecycle', () => { test('installs every exact member and writes one Suite inventory snapshot', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() const result = await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) }) expect(result.installed).toHaveLength(2) expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') expect(await readFile(join(rootDir, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta') const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) expect(inventory.suites).toEqual([expect.objectContaining({ slug: 'starter-pack', version: '1.0.0' })]) expect(inventory.items).toHaveLength(2) expect(inventory.items[0].installedBy).toEqual(['suite:@global/starter-pack@1.0.0']) const checked = await checkSuite({ registry, namespace: 'global', slug: 'starter-pack', home, client: clientFor(plan, downloads) }) expect(checked.current).toBe(true) expect(checked.members.every(member => member.status === 'ok')).toBe(true) }) test('serializes the same Suite across different Agent targets', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const firstRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-first-root-')) const secondRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-second-root-')) const { plan, downloads } = makePlan() let signalFirstLocked: (() => void) | undefined let releaseFirst: (() => void) | undefined const firstLocked = new Promise((resolvePromise) => { signalFirstLocked = resolvePromise }) const holdFirst = new Promise((resolvePromise) => { releaseFirst = resolvePromise }) const firstInstall = installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads), afterTargetLocksAcquired: async () => { signalFirstLocked?.() await holdFirst } }) await firstLocked try { await expect(installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'claude', rootDir: secondRoot, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) })).rejects.toThrow('Suite operation is busy') } finally { releaseFirst?.() await firstInstall } const inventory = await new InventoryStore(home).read() expect(inventory.suites).toHaveLength(1) expect(inventory.suites?.[0]?.members.every(member => member.installDirs.every(dir => dir.startsWith(firstRoot)))).toBe(true) expect(await exists(join(secondRoot, 'alpha'))).toBe(false) }) test('does not change live directories or inventory when a member fingerprint fails', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() plan.members[1]!.fingerprint = 'sha256:wrong' await expect(installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) })).rejects.toThrow('fingerprint does not match') expect(await exists(join(rootDir, 'alpha'))).toBe(false) expect(await exists(join(rootDir, 'beta'))).toBe(false) expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) }) test('sends one idempotency key with the install-plan request', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() let idempotencyKey: string | null = null const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => { const url = new URL(String(input)) if (url.pathname === '/.well-known/clawhub.json') { return Response.json({ capabilities: ['skill-suite-v1'] }) } if (url.pathname.endsWith('/install-plan')) { idempotencyKey = new Headers(init?.headers).get('Idempotency-Key') return Response.json({ code: 0, data: plan }) } const bytes = downloads[url.pathname] return bytes ? new Response(bytes.slice().buffer as ArrayBuffer) : Response.json({ code: 404 }, { status: 404 }) }) as unknown as typeof fetch await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: new SkillHubClient(registry, undefined, fetchImpl) }) expect(idempotencyKey).toMatch(/^[0-9a-f-]{36}$/) }) test('retries a transient install-plan failure with the same idempotency key', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() const keys: Array = [] let planAttempts = 0 const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => { const url = new URL(String(input)) if (url.pathname === '/.well-known/clawhub.json') { return Response.json({ capabilities: ['skill-suite-v1'] }) } if (url.pathname.endsWith('/install-plan')) { keys.push(new Headers(init?.headers).get('Idempotency-Key')) planAttempts += 1 if (planAttempts === 1) return Response.json({ msg: 'temporary outage' }, { status: 503 }) return Response.json({ code: 0, data: plan }) } const bytes = downloads[url.pathname] return bytes ? new Response(bytes.slice().buffer as ArrayBuffer) : Response.json({ code: 404 }, { status: 404 }) }) as unknown as typeof fetch await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: new SkillHubClient(registry, undefined, fetchImpl) }) expect(keys).toHaveLength(2) expect(keys[0]).toMatch(/^[0-9a-f-]{36}$/) expect(keys[1]).toBe(keys[0]) }) test('reports an installed exact version as stale when a member becomes unavailable', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) }) const checked = await checkSuite({ registry, namespace: 'global', slug: 'starter-pack', home, client: clientFor(plan, downloads, ['skill-suite-v1'], false) }) expect(checked.current).toBe(false) expect(checked.installedVersionAvailable).toBe(false) expect(checked.blockingReasons).toContain('member_unavailable') }) test('checks the installed version when the suite has no latest published version', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) }) const fetchImpl = (async (input: URL | RequestInfo) => { const url = new URL(String(input)) if (url.pathname === '/.well-known/clawhub.json') { return Response.json({ capabilities: ['skill-suite-v1'] }) } if (url.pathname === `/api/v1/suites/${plan.namespace}/${plan.slug}`) { if (!url.searchParams.has('version')) { return Response.json({ code: 404, msg: 'no latest version' }, { status: 404 }) } return Response.json({ code: 0, data: { id: 1, versionId: 2, namespace: plan.namespace, slug: plan.slug, displayName: plan.slug, version: plan.version, status: 'YANKED', visibility: 'PUBLIC', available: false, members: plan.members.map(member => ({ ...member, blockingReason: 'VERSION_UNAVAILABLE' })) } }) } return Response.json({ code: 404 }, { status: 404 }) }) as unknown as typeof fetch const checked = await checkSuite({ registry, namespace: 'global', slug: 'starter-pack', home, client: new SkillHubClient(registry, undefined, fetchImpl) }) expect(checked.remoteVersion).toBeUndefined() expect(checked.current).toBe(false) expect(checked.installedVersionAvailable).toBe(false) expect(checked.blockingReasons).toContain('VERSION_UNAVAILABLE') }) test('rejects an old Server before resolving or writing Suite members', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() await expect(installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads, []) })).rejects.toThrow('registry does not support Skill Suites') expect(await exists(join(rootDir, 'alpha'))).toBe(false) expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) }) test('treats a non-JSON metadata response as an unsupported old Server', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const fetchImpl = (async () => new Response('legacy registry', { status: 200, headers: { 'Content-Type': 'text/html' } })) as unknown as typeof fetch await expect(installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: new SkillHubClient(registry, undefined, fetchImpl) })).rejects.toThrow('registry does not support Skill Suites') expect(await exists(join(rootDir, 'starter-pack'))).toBe(false) expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) }) test('keeps every target untouched when the Server denies a member plan', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) await expect(installSuite({ registry, namespace: 'private-team', slug: 'restricted-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientWithPlanFailure(403, 'access denied') })).rejects.toThrow('access denied') expect(await readdir(rootDir)).toEqual([]) expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) }) test('blocks an upgrade when the exact remote snapshot has an unavailable member', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) }) await expect(planSuiteUpgrade({ registry, namespace: 'global', slug: 'starter-pack', home, client: clientFor(plan, downloads, ['skill-suite-v1'], false) })).rejects.toThrow('is unavailable') expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') }) test('rolls back all live members when a filesystem commit fails midway', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() const renameOperation: typeof rename = async (source, target): Promise => { if (String(source).includes('.skillhub-suite-stage-') && String(target) === join(rootDir, 'beta')) { throw new Error('injected rename failure') } await rename(source, target) } await expect(installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads), renameOperation })).rejects.toThrow('injected rename failure') expect(await exists(join(rootDir, 'alpha'))).toBe(false) expect(await exists(join(rootDir, 'beta'))).toBe(false) expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) }) test('rolls back the first Agent target when the second target commit fails', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const codexRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-codex-')) const claudeRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-claude-')) const { plan, downloads } = makePlan() const renameOperation: typeof rename = async (source, target): Promise => { if (String(source).includes('.skillhub-suite-stage-') && String(target) === join(claudeRoot, 'alpha')) { throw new Error('injected second target failure') } await rename(source, target) } await expect(installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [ { agent: 'codex', rootDir: codexRoot, scope: 'project', source: 'explicit' }, { agent: 'claude', rootDir: claudeRoot, scope: 'project', source: 'explicit' } ], force: false, home, client: clientFor(plan, downloads), renameOperation })).rejects.toThrow('injected second target failure') expect(await readdir(codexRoot)).toEqual([]) expect(await readdir(claudeRoot)).toEqual([]) expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) }) test('reports retained backup paths when rollback cannot restore a replaced member', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() const old = archive('# Old Alpha') const alphaDir = join(rootDir, 'alpha') await mkdir(alphaDir, { recursive: true }) await writeFile(join(alphaDir, 'SKILL.md'), '# Old Alpha') await mkdir(join(home, '.skillhub'), { recursive: true }) await writeFile(join(home, '.skillhub', 'inventory.json'), JSON.stringify({ items: [{ registry, namespace: 'global', slug: 'alpha', version: '0.9.0', fingerprint: old.fingerprint, installedBy: ['direct'], targets: [{ agent: 'codex', rootDir, installDir: alphaDir, installedAt: '2026-09-01T00:00:00Z' }] }], suites: [] })) const renameOperation: typeof rename = async (source, target): Promise => { const from = String(source) const to = String(target) if (from.includes('.skillhub-suite-stage-') && to === join(rootDir, 'beta')) { throw new Error('injected commit failure') } if (from.includes('.skillhub-suite-backup-') && to === alphaDir) { throw new Error('injected restore failure') } await rename(source, target) } await expect(installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: true, home, client: clientFor(plan, downloads), renameOperation })).rejects.toThrow('rollback was incomplete') const retainedBackup = (await readdir(rootDir)).find(name => name.startsWith('alpha.skillhub-suite-backup-')) expect(retainedBackup).toBeDefined() expect(await readFile(join(rootDir, retainedBackup!, 'SKILL.md'), 'utf8')).toBe('# Old Alpha') const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) expect(inventory.items[0]).toMatchObject({ slug: 'alpha', version: '0.9.0', installedBy: ['direct'] }) }) test('installs one exact snapshot across multiple Agent targets', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const codexRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-codex-')) const claudeRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-claude-')) const { plan, downloads } = makePlan() const result = await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [ { agent: 'codex', rootDir: codexRoot, scope: 'project', source: 'explicit' }, { agent: 'claude', rootDir: claudeRoot, scope: 'project', source: 'explicit' } ], force: false, home, client: clientFor(plan, downloads) }) expect(result.installed).toHaveLength(4) expect(await readFile(join(codexRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') expect(await readFile(join(claudeRoot, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta') const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) expect(inventory.suites[0].members[0].installDirs).toHaveLength(2) }) test('rejects member coordinates that collide in the local directory layout', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() plan.members[1] = { ...plan.members[1]!, namespace: 'team', slug: 'alpha' } await expect(installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) })).rejects.toThrow('use the same local directory') expect(await exists(join(rootDir, 'alpha'))).toBe(false) }) test('does not create a Suite directory when a Skill and Suite share one coordinate', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() plan.slug = 'marketing' plan.members = [plan.members[1]!] const directSkillDir = join(rootDir, 'marketing') await mkdir(directSkillDir, { recursive: true }) await writeFile(join(directSkillDir, 'SKILL.md'), '# Direct marketing Skill') await installSuite({ registry, namespace: 'global', slug: 'marketing', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) }) expect(await readFile(join(directSkillDir, 'SKILL.md'), 'utf8')).toBe('# Direct marketing Skill') expect(await readFile(join(rootDir, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta') }) test('preserves a member shared by two installed Suites', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const first = makePlan() first.plan.members = [first.plan.members[0]!] await installSuite({ registry, namespace: 'global', slug: first.plan.slug, targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(first.plan, first.downloads) }) const secondPlan = { ...first.plan, slug: 'editor-pack', fingerprint: 'sha256:editor-pack' } await installSuite({ registry, namespace: 'global', slug: secondPlan.slug, targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(secondPlan, first.downloads) }) const removed = await removeSuite({ registry, namespace: 'global', slug: first.plan.slug, home }) expect(removed.preserved).toEqual([{ dir: join(rootDir, 'alpha'), reason: 'shared' }]) expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) expect(inventory.items[0].installedBy).toEqual(['suite:@global/editor-pack@1.0.0']) }) test('removing a Suite does not rewrite the same coordinate from another registry', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const firstRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-registry-a-')) const secondRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-registry-b-')) const secondRegistry = 'http://registry-b.test' const { plan, downloads } = makePlan() plan.members = [plan.members[0]!] await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) }) await installSuite({ registry: secondRegistry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'claude', rootDir: secondRoot, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) }) await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) const afterFirstRemoval = await new InventoryStore(home).read() expect(afterFirstRemoval.suites).toEqual([ expect.objectContaining({ registry: secondRegistry, slug: 'starter-pack' }) ]) expect(afterFirstRemoval.items).toEqual([ expect.objectContaining({ registry: secondRegistry, slug: 'alpha', installedBy: ['suite:@global/starter-pack@1.0.0'] }) ]) expect(await readFile(join(secondRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') const secondRemoval = await removeSuite({ registry: secondRegistry, namespace: 'global', slug: 'starter-pack', home }) expect(secondRemoval.removed).toEqual([join(secondRoot, 'alpha')]) expect(await exists(join(secondRoot, 'alpha'))).toBe(false) }) test('reuses a matching legacy direct install and preserves it when Suite is removed', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() plan.members = [plan.members[0]!] const skillDir = join(rootDir, 'alpha') await mkdir(skillDir, { recursive: true }) await writeFile(join(skillDir, 'SKILL.md'), '# Alpha') await mkdir(join(home, '.skillhub'), { recursive: true }) await writeFile(join(home, '.skillhub', 'inventory.json'), JSON.stringify({ items: [{ registry, namespace: 'global', slug: 'alpha', version: '1.0.0', fingerprint: plan.members[0]!.fingerprint, targets: [{ agent: 'codex', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' }] }] })) const installed = await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) }) expect(installed.reused).toHaveLength(1) const removed = await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) expect(removed.preserved).toEqual([{ dir: skillDir, reason: 'shared' }]) expect(await exists(skillDir)).toBe(true) const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) expect(inventory.suites).toEqual([]) expect(inventory.items[0].installedBy).toEqual(['direct']) }) test('requires force before replacing a locally modified same-version member', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() plan.members = [plan.members[0]!] const member = plan.members[0]! const skillDir = join(rootDir, member.slug) await mkdir(skillDir, { recursive: true }) await writeFile(join(skillDir, 'SKILL.md'), '# Alpha') const store = new InventoryStore(home) await store.upsertTarget(registry, member.namespace, member.slug, member.version, { agent: 'codex', rootDir, installDir: skillDir, installedAt: new Date().toISOString() }, member.fingerprint) await writeFile(join(skillDir, 'SKILL.md'), '# Locally modified Alpha') await expect(installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) })).rejects.toThrow('local changes') expect(await readFile(join(skillDir, 'SKILL.md'), 'utf8')).toBe('# Locally modified Alpha') expect((await store.read()).suites ?? []).toEqual([]) }) test('tracks direct and Suite ownership independently for each Agent target', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const directRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-direct-root-')) const suiteOnlyRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-only-root-')) const { plan, downloads } = makePlan() const alpha = plan.members[0]! await mkdir(join(directRoot, alpha.slug), { recursive: true }) await writeFile(join(directRoot, alpha.slug, 'SKILL.md'), '# Alpha') const store = new InventoryStore(home) await store.upsertTarget(registry, alpha.namespace, alpha.slug, alpha.version, { agent: 'codex', rootDir: directRoot, installDir: join(directRoot, alpha.slug), installedAt: new Date().toISOString() }, alpha.fingerprint) await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [ { agent: 'codex', rootDir: directRoot, scope: 'project', source: 'explicit' }, { agent: 'claude', rootDir: suiteOnlyRoot, scope: 'project', source: 'explicit' } ], force: false, home, client: clientFor(plan, downloads) }) await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) expect(await access(join(directRoot, 'alpha')).then(() => true)).toBe(true) await expect(access(join(suiteOnlyRoot, 'alpha'))).rejects.toThrow() const inventory = await store.read() const alphaItem = inventory.items.find(item => item.slug === 'alpha')! expect(alphaItem.targets).toHaveLength(1) expect(alphaItem.targets[0]!.installedBy).toEqual(['direct']) }) test('removes unmodified members that are owned only by the Suite', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) }) const removed = await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) expect(removed.removed.sort()).toEqual([join(rootDir, 'alpha'), join(rootDir, 'beta')].sort()) expect(await exists(join(rootDir, 'alpha'))).toBe(false) expect(await exists(join(rootDir, 'beta'))).toBe(false) const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) expect(inventory).toMatchObject({ items: [], suites: [] }) }) test('upgrades the exact snapshot and retires members removed by the new Suite version', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const first = makePlan() await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(first.plan, first.downloads) }) const alphaV2 = archive('# Alpha v2') const gamma = archive('# Gamma') const second: SuiteInstallPlan = { ...first.plan, operationId: 'operation-2', version: '2.0.0', fingerprint: 'sha256:suite-v2', members: [ { ...first.plan.members[0]!, skillVersionId: 12, version: '2.0.0', fingerprint: alphaV2.fingerprint }, { skillId: 30, skillVersionId: 31, namespace: 'global', slug: 'gamma', version: '1.0.0', fingerprint: gamma.fingerprint, downloadUrl: '/downloads/gamma', position: 1, entry: false } ] } const client = clientFor(second, { '/downloads/alpha': alphaV2.bytes, '/downloads/gamma': gamma.bytes }) const upgraded = await upgradeSuite({ registry, namespace: 'global', slug: 'starter-pack', home, client }) expect(upgraded.upgrade.changes.map(change => change.action).sort()).toEqual(['add', 'change', 'remove']) expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha v2') expect(await exists(join(rootDir, 'beta'))).toBe(false) expect(await readFile(join(rootDir, 'gamma', 'SKILL.md'), 'utf8')).toBe('# Gamma') const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) expect(inventory.suites[0]).toMatchObject({ version: '2.0.0', fingerprint: 'sha256:suite-v2' }) expect(inventory.items.map((item: { slug: string }) => item.slug).sort()).toEqual(['alpha', 'gamma']) }) test('does not overwrite a locally modified member during Suite upgrade by default', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const first = makePlan() await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(first.plan, first.downloads) }) await writeFile(join(rootDir, 'alpha', 'SKILL.md'), '# Locally modified Alpha') const alphaV2 = archive('# Alpha v2') const second: SuiteInstallPlan = { ...first.plan, operationId: 'operation-2', version: '2.0.0', fingerprint: 'sha256:suite-v2', members: [ { ...first.plan.members[0]!, skillVersionId: 12, version: '2.0.0', fingerprint: alphaV2.fingerprint }, first.plan.members[1]! ] } await expect(upgradeSuite({ registry, namespace: 'global', slug: 'starter-pack', home, client: clientFor(second, { ...first.downloads, '/downloads/alpha': alphaV2.bytes }) })).rejects.toThrow('local changes') expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Locally modified Alpha') const unchangedInventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) expect(unchangedInventory.suites[0]).toMatchObject({ version: '1.0.0', fingerprint: 'sha256:suite' }) await upgradeSuite({ registry, namespace: 'global', slug: 'starter-pack', force: true, home, client: clientFor(second, { ...first.downloads, '/downloads/alpha': alphaV2.bytes }) }) expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha v2') const upgradedInventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) expect(upgradedInventory.suites[0]).toMatchObject({ version: '2.0.0', fingerprint: 'sha256:suite-v2' }) }) test('does not reinstall a Suite removed after upgrade planning', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const first = makePlan() await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(first.plan, first.downloads) }) const nextPlan = { ...first.plan, operationId: 'operation-2', version: '2.0.0' } const client = clientFor(nextPlan, first.downloads) const fetchDetail = client.suiteDetail.bind(client) let signalPlanRead: (() => void) | undefined let releasePlan: (() => void) | undefined const planRead = new Promise((resolvePromise) => { signalPlanRead = resolvePromise }) const holdPlan = new Promise((resolvePromise) => { releasePlan = resolvePromise }) client.suiteDetail = async (...args) => { signalPlanRead?.() await holdPlan return fetchDetail(...args) } const upgrading = upgradeSuite({ registry, namespace: 'global', slug: 'starter-pack', home, client }) await planRead await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) releasePlan?.() await expect(upgrading).rejects.toThrow('installed Suite changed while waiting for target locks') expect(await readdir(rootDir)).toEqual([]) const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) expect(inventory).toMatchObject({ items: [], suites: [] }) }) test('rejects stale upgrade targets after the same Suite is reinstalled elsewhere', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const originalRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-original-')) const replacementRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-replacement-')) const first = makePlan() await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir: originalRoot, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(first.plan, first.downloads) }) const nextPlan = { ...first.plan, operationId: 'operation-2', version: '2.0.0' } const upgradeClient = clientFor(nextPlan, first.downloads) const fetchDetail = upgradeClient.suiteDetail.bind(upgradeClient) let signalPlanRead: (() => void) | undefined let releasePlan: (() => void) | undefined const planRead = new Promise((resolvePromise) => { signalPlanRead = resolvePromise }) const holdPlan = new Promise((resolvePromise) => { releasePlan = resolvePromise }) upgradeClient.suiteDetail = async (...args) => { signalPlanRead?.() await holdPlan return fetchDetail(...args) } const upgrading = upgradeSuite({ registry, namespace: 'global', slug: 'starter-pack', home, client: upgradeClient }) await planRead try { await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home }) await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'claude', rootDir: replacementRoot, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(first.plan, first.downloads) }) } finally { releasePlan?.() } await expect(upgrading).rejects.toThrow('installed Suite changed while waiting for target locks') expect(await readdir(originalRoot)).toEqual([]) expect(await readFile(join(replacementRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha') const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8')) expect(inventory.suites[0]).toMatchObject({ version: '1.0.0', fingerprint: 'sha256:suite' }) expect(inventory.suites[0].members.every((member: { installDirs: string[] }) => member.installDirs.every(dir => dir.startsWith(replacementRoot)))).toBe(true) }) test('preserves a member modified after removal starts but before locked validation', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const { plan, downloads } = makePlan() await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(plan, downloads) }) const removed = await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home, afterTargetLocksAcquired: async () => { await writeFile(join(rootDir, 'beta', 'SKILL.md'), '# Locally modified Beta') } }) expect(removed.removed).toEqual([join(rootDir, 'alpha')]) expect(removed.preserved).toEqual([{ dir: join(rootDir, 'beta'), reason: 'modified' }]) expect(await readFile(join(rootDir, 'beta', 'SKILL.md'), 'utf8')).toBe('# Locally modified Beta') const inventory = await new InventoryStore(home).read() expect(inventory.items).toEqual([expect.objectContaining({ slug: 'beta', installedBy: ['direct'] })]) }) test('preserves a retired member that gains direct ownership before locked upgrade validation', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) const first = makePlan() await installSuite({ registry, namespace: 'global', slug: 'starter-pack', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: false, home, client: clientFor(first.plan, first.downloads) }) const second: SuiteInstallPlan = { ...first.plan, operationId: 'operation-2', version: '2.0.0', fingerprint: 'sha256:suite-v2', members: [first.plan.members[0]!] } const beta = first.plan.members[1]! const betaDir = join(rootDir, beta.slug) await installSuite({ registry, namespace: 'global', slug: 'starter-pack', version: '2.0.0', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: true, home, client: clientFor(second, first.downloads), afterTargetLocksAcquired: async () => { await new InventoryStore(home).upsertTarget( registry, beta.namespace, beta.slug, beta.version, { agent: 'codex', rootDir, installDir: betaDir, installedAt: new Date().toISOString() }, beta.fingerprint) } }) expect(await readFile(join(betaDir, 'SKILL.md'), 'utf8')).toBe('# Beta') const inventory = await new InventoryStore(home).read() expect(inventory.items.find(item => item.slug === 'beta')).toMatchObject({ installedBy: ['direct'] }) }) })