From 4ceae7644317b2ea02a1e98dd9f2898931698e89 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:46:02 +0800 Subject: [PATCH] fix(cli): harden skill upgrade lifecycle Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- cli/CHANGELOG.md | 2 + cli/README.md | 4 + cli/src/commands/upgrade.ts | 36 +++--- cli/src/services/install-service.ts | 116 +++++++++++++---- cli/src/services/installed-skill-metadata.ts | 13 ++ cli/src/services/upgrade-service.ts | 66 +++++++++- cli/test/integration/publish-command.test.ts | 1 - cli/test/integration/upgrade-command.test.ts | 117 +++++++++++++++++- .../unit/services/install-service.test.ts | 69 +++++++++++ docs/skillhub/en/guide/cli.md | 4 + 10 files changed, 376 insertions(+), 52 deletions(-) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 6c52d037..3a2b8cb9 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -14,6 +14,8 @@ All notable CLI behavior changes are documented in this file. - Prevent `--force` from overwriting an unmanaged or different-source Skill at the same target path. Source ownership is the full `registry + namespace + slug` identity. +- Reject registry downgrades and partial-target updates that the shared inventory version cannot + represent safely. - Exclude installer-owned `.skillhub/` state when publishing a local Skill directory. - Resolve `namespace/slug`, `@namespace/slug`, and `namespace--slug` diff --git a/cli/README.md b/cli/README.md index e5a915c4..c21bc1ac 100644 --- a/cli/README.md +++ b/cli/README.md @@ -266,6 +266,10 @@ The source identity is `registry + namespace + slug`. `--force` may replace loca that full identity matches the installation metadata; it never overwrites an unmanaged directory or a Skill installed from another source. +All targets in one inventory entry are upgraded together. A filter that selects only part of that +entry is rejected because the current inventory format stores one shared version for all targets. +The command also keeps the local files when the registry resolves to an older version. + ## 🔄 Namespace Workspaces Use namespace synchronization when an Agent workspace should maintain all installable skills from one team space. diff --git a/cli/src/commands/upgrade.ts b/cli/src/commands/upgrade.ts index 934d0934..dd2cfad6 100644 --- a/cli/src/commands/upgrade.ts +++ b/cli/src/commands/upgrade.ts @@ -5,14 +5,14 @@ import { EXIT } from '../shared/constants' import { executeSkillUpgradePlan, planSkillUpgrades, type UpgradePlan } from '../services/upgrade-service' export interface UpgradeCommandOptions { - namespace?: string - agent?: string[] - dir?: string - registry?: string - token?: string - check?: boolean - force?: boolean - json?: boolean + namespace?: string | undefined + agent?: string[] | undefined + dir?: string | undefined + registry?: string | undefined + token?: string | undefined + check?: boolean | undefined + force?: boolean | undefined + json?: boolean | undefined } export async function upgradeCommand(coordinates: string[], options: UpgradeCommandOptions): Promise { @@ -29,9 +29,8 @@ export async function upgradeCommand(coordinates: string[], options: UpgradeComm force: Boolean(options.force), tokenForRegistry }) - const output = renderUpgradePlan(plan, Boolean(options.check), Boolean(options.json)) - if (plan.blocked > 0) { + const output = renderUpgradePlan(plan, 'plan', Boolean(options.json)) process.stdout.write(`${output}\n`) throw new CliError('upgrade plan contains blocked skills', EXIT.validation, { blocked: plan.items.filter(item => item.action === 'blocked').map(item => ({ @@ -40,13 +39,14 @@ export async function upgradeCommand(coordinates: string[], options: UpgradeComm })) }) } - if (!options.check) { - await executeSkillUpgradePlan(plan, { force: Boolean(options.force), tokenForRegistry }) - } - return output + if (options.check) return renderUpgradePlan(plan, 'plan', Boolean(options.json)) + + await executeSkillUpgradePlan(plan, { tokenForRegistry }) + return renderUpgradePlan(plan, 'result', Boolean(options.json)) } -function renderUpgradePlan(plan: UpgradePlan, check: boolean, json: boolean): string { +function renderUpgradePlan(plan: UpgradePlan, mode: 'plan' | 'result', json: boolean): string { + const check = mode === 'plan' if (json) { return JSON.stringify({ ok: plan.blocked === 0, @@ -57,7 +57,7 @@ function renderUpgradePlan(plan: UpgradePlan, check: boolean, json: boolean): st registry: item.registry, currentVersion: item.currentVersion, remoteVersion: item.remoteVersion, - action: item.action === 'upgrade' && !check ? 'upgraded' : item.action, + action: item.action === 'upgrade' && mode === 'result' ? 'upgraded' : item.action, reason: item.reason, changedFiles: item.changedFiles, targets: item.targets @@ -65,11 +65,11 @@ function renderUpgradePlan(plan: UpgradePlan, check: boolean, json: boolean): st }) } - const heading = check ? 'Upgrade plan' : 'Upgrade result' + const heading = mode === 'plan' ? 'Upgrade plan' : 'Upgrade result' return [ `${heading}: ${plan.upgrades} upgrade, ${plan.unchanged} unchanged, ${plan.blocked} blocked`, ...plan.items.map(item => { - const action = item.action === 'upgrade' && !check ? 'upgraded' : item.action + const action = item.action === 'upgrade' && mode === 'result' ? 'upgraded' : item.action const versions = item.remoteVersion ? ` ${item.currentVersion} -> ${item.remoteVersion}` : '' const reason = item.reason ? ` (${item.reason})` : '' return `${action.padEnd(10)} ${item.coordinate}${versions}${reason}` diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index e53b0809..ca4c2c6c 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { SkillHubClient } from '../clients/skillhub-client' import { InventoryStore } from '../stores/inventory-store' @@ -58,30 +58,7 @@ async function preflightInstallTargets( }) } if (exists) { - const metadataResult = await readInstalledSkillMetadata(skillDir) - const inventoryOwners = inventory.items.filter(item => - item.targets.some(existing => existing.installDir === skillDir)) - - if (metadataResult.status !== 'valid') { - throw new CliError(`cannot verify SkillHub ownership of ${skillDir}`, EXIT.filesystem, { - path: skillDir, - reason: metadataResult.status === 'missing' ? 'installation metadata is missing' : metadataResult.reason, - next: 'move or remove the existing directory before installing' - }) - } - if (!sameInstalledSkillSource(metadataResult.metadata, identity) || - inventoryOwners.some(owner => !sameInstalledSkillSource(owner, identity))) { - throw new CliError(`source conflict at ${skillDir}`, EXIT.filesystem, { - path: skillDir, - expected: identity, - actual: { - registry: metadataResult.metadata.registry, - namespace: metadataResult.metadata.namespace, - slug: metadataResult.metadata.slug - }, - next: 'choose another target directory or remove the conflicting skill explicitly' - }) - } + await assertReplaceableInstallation(skillDir, skillDir, identity, inventory) } preparedTargets.push({ target, skillDir }) } @@ -138,10 +115,23 @@ export async function installSkill(options: InstallOptions): Promise<{ installed const backupDir = `${skillDir}.skillhub-backup-${process.pid}-${Date.now()}` let backupCreated = false + const releaseInstallLock = await acquireInstallTargetLock(target.rootDir, options.slug) try { if (await pathExists(skillDir)) { + if (!options.force) { + throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { + path: skillDir, + next: 'pass --force to overwrite' + }) + } await rename(skillDir, backupDir) backupCreated = true + await assertReplaceableInstallation( + backupDir, + skillDir, + { registry: options.registry, namespace: options.namespace, slug: options.slug }, + await store.read() + ) } await rename(tempDir, skillDir) movedIntoPlace = true @@ -167,6 +157,8 @@ export async function installSkill(options: InstallOptions): Promise<{ installed }) } throw error + } finally { + await releaseInstallLock() } } finally { if (!movedIntoPlace) { @@ -179,3 +171,77 @@ export async function installSkill(options: InstallOptions): Promise<{ installed return { installed } } + +async function assertReplaceableInstallation( + metadataDir: string, + inventoryInstallDir: string, + identity: InstalledSkillIdentity, + inventory: Inventory +): Promise { + const metadataResult = await readInstalledSkillMetadata(metadataDir) + if (metadataResult.status !== 'valid') { + throw new CliError(`cannot verify SkillHub ownership of ${inventoryInstallDir}`, EXIT.filesystem, { + path: inventoryInstallDir, + reason: metadataResult.status === 'missing' ? 'installation metadata is missing' : metadataResult.reason, + next: 'move or remove the existing directory before installing' + }) + } + + const inventoryOwners = inventory.items.filter(item => + item.targets.some(existing => existing.installDir === inventoryInstallDir)) + if (!sameInstalledSkillSource(metadataResult.metadata, identity) || + inventoryOwners.some(owner => !sameInstalledSkillSource(owner, identity))) { + throw new CliError(`source conflict at ${inventoryInstallDir}`, EXIT.filesystem, { + path: inventoryInstallDir, + expected: identity, + actual: { + registry: metadataResult.metadata.registry, + namespace: metadataResult.metadata.namespace, + slug: metadataResult.metadata.slug + }, + next: 'choose another target directory or remove the conflicting skill explicitly' + }) + } +} + +async function acquireInstallTargetLock(rootDir: string, slug: string): Promise<() => Promise> { + const lockPath = join(rootDir, `.${slug}.skillhub-install.lock`) + + const createLock = async () => { + const handle = await open(lockPath, 'wx') + await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })) + return handle + } + + let handle: Awaited> + try { + handle = await createLock() + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error + + let ownerIsRunning = true + try { + const lock = JSON.parse(await readFile(lockPath, 'utf-8')) as { pid?: unknown } + if (typeof lock.pid !== 'number') throw new Error('invalid install lock') + process.kill(lock.pid, 0) + } catch (lockError) { + if (lockError instanceof Error && 'code' in lockError && lockError.code === 'ESRCH') { + ownerIsRunning = false + } + } + + if (ownerIsRunning) { + throw new CliError(`install target is busy: ${join(rootDir, slug)}`, EXIT.filesystem, { + path: join(rootDir, slug), + next: 'wait for the other SkillHub CLI process to finish and retry' + }) + } + await rm(lockPath, { force: true }) + handle = await createLock() + } + + return async () => { + await handle.close().catch(() => {}) + await rm(lockPath, { force: true }).catch(() => {}) + } +} diff --git a/cli/src/services/installed-skill-metadata.ts b/cli/src/services/installed-skill-metadata.ts index 618e4326..36165739 100644 --- a/cli/src/services/installed-skill-metadata.ts +++ b/cli/src/services/installed-skill-metadata.ts @@ -37,6 +37,19 @@ export async function readInstalledSkillMetadata(skillDir: string): Promise Promise @@ -70,6 +71,15 @@ export async function planSkillUpgrades(options: UpgradeSelectionOptions): Promi selectedTargets: selection.targets } + if (selection.targets.length !== selection.item.targets.length) { + items.push({ + ...base, + action: 'blocked', + reason: 'partial-target upgrades are not supported because one inventory item has one shared version' + }) + continue + } + let resolved: ResolveResponse try { const token = await options.tokenForRegistry(selection.item.registry) @@ -129,9 +139,44 @@ export async function planSkillUpgrades(options: UpgradeSelectionOptions): Promi continue } - const unchanged = selection.item.version === resolved.version && + const versionOrder = compareVersions(selection.item.version, resolved.version) + if (versionOrder === 'remote-older') { + items.push({ + ...base, + remoteVersion: resolved.version, + action: 'blocked', + reason: 'remote version is older than the installed version; local files were kept', + changedFiles: inspection.changedFiles, + resolved + }) + continue + } + if (versionOrder === 'unknown') { + items.push({ + ...base, + remoteVersion: resolved.version, + action: 'blocked', + reason: 'cannot determine version order; use explicit install after verifying the release', + changedFiles: inspection.changedFiles, + resolved + }) + continue + } + + const unchanged = versionOrder === 'same' && selection.item.fingerprint === resolved.fingerprint && inspection.metadataCurrent + if (versionOrder === 'same' && !unchanged) { + items.push({ + ...base, + remoteVersion: resolved.version, + action: 'blocked', + reason: 'remote content changed without a newer version; use explicit install after verifying the release', + changedFiles: inspection.changedFiles, + resolved + }) + continue + } items.push({ ...base, remoteVersion: resolved.version, @@ -151,7 +196,7 @@ export async function planSkillUpgrades(options: UpgradeSelectionOptions): Promi export async function executeSkillUpgradePlan( plan: UpgradePlan, - options: Pick + options: Pick ): Promise { if (plan.blocked > 0) { throw new CliError('upgrade plan contains blocked skills', EXIT.validation) @@ -285,3 +330,12 @@ function isSameOrWithin(parent: string, candidate: string): boolean { function normalizeRegistry(registry: string): string { return registry.replace(/\/+$/, '') } + +function compareVersions( + installedVersion: string, + remoteVersion: string +): 'same' | 'remote-newer' | 'remote-older' | 'unknown' { + if (installedVersion === remoteVersion) return 'same' + if (!validSemver(installedVersion) || !validSemver(remoteVersion)) return 'unknown' + return compareSemver(remoteVersion, installedVersion) > 0 ? 'remote-newer' : 'remote-older' +} diff --git a/cli/test/integration/publish-command.test.ts b/cli/test/integration/publish-command.test.ts index c6f9f25a..2e2c4db2 100644 --- a/cli/test/integration/publish-command.test.ts +++ b/cli/test/integration/publish-command.test.ts @@ -261,7 +261,6 @@ describe('publish command — P1', () => { // P1 — content shape: directory layout and edge files // --------------------------------------------------------------------------- -import { mkdir } from 'node:fs/promises' import { unzipSync, strFromU8 } from 'fflate' describe('publish command — content shape', () => { diff --git a/cli/test/integration/upgrade-command.test.ts b/cli/test/integration/upgrade-command.test.ts index 9f5e9e85..87ecfe6d 100644 --- a/cli/test/integration/upgrade-command.test.ts +++ b/cli/test/integration/upgrade-command.test.ts @@ -198,14 +198,121 @@ describe('upgrade command', () => { skill.version = '1.1.0' skill.fingerprint = 'fp-v2' skill.zipBytes = makeSkillZip('# v2') + + const partial = await runCli([ + 'upgrade', '@global/shared', '--registry', registry.url, '--agent', 'codex', '--check', '--json' + ], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd }) + expect(partial.exitCode).toBe(6) + expect(JSON.parse(partial.stdout).items[0].reason).toContain('partial-target') + expect(registry.received.downloads).toBe(1) + const upgraded = await runCli(['upgrade', '@global/shared', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd }) expect(upgraded.exitCode).toBe(0) expect(registry.received.downloads).toBe(2) - expect(await readFile(join(env.cwd, '.codex', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2') - expect(await readFile(join(env.cwd, '.claude', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2') + expect(await readFile(join(env.home, '.codex', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2') + expect(await readFile(join(env.home, '.claude', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2') + }) + + test('never downgrades when the registry latest version moves backwards', async () => { + const env = await createTempHome() + const skill = { + namespace: 'global', + slug: 'stable', + version: '2.0.0', + versionId: 2, + fingerprint: 'fp-v2', + zipBytes: makeSkillZip('# v2') + } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + await runCli(['install', '@global/stable', '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + skill.version = '1.0.0' + skill.versionId = 1 + skill.fingerprint = 'fp-v1' + skill.zipBytes = makeSkillZip('# v1') + + const result = await runCli([ + 'upgrade', '@global/stable', '--registry', registry.url, '--force', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(result.exitCode).toBe(6) + expect(JSON.parse(result.stdout).items[0].reason).toContain('older') + expect(await readFile(join(rootDir, 'stable', 'SKILL.md'), 'utf-8')).toBe('# v2') + expect(registry.received.downloads).toBe(1) + }) + + test('a blocked batch reports a plan and does not claim successful writes', async () => { + const env = await createTempHome() + const first = { namespace: 'global', slug: 'first', version: '1.0.0', fingerprint: 'first-v1', zipBytes: makeSkillZip('# first v1') } + const second = { namespace: 'global', slug: 'second', version: '1.0.0', fingerprint: 'second-v1', zipBytes: makeSkillZip('# second v1') } + const registry = await startFakeRegistry({ skills: [first, second] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + for (const slug of ['first', 'second']) { + await runCli(['install', `@global/${slug}`, '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + } + + first.version = '1.1.0' + first.fingerprint = 'first-v2' + first.zipBytes = makeSkillZip('# first v2') + second.version = '1.1.0' + second.fingerprint = 'second-v2' + second.zipBytes = makeSkillZip('# second v2') + await writeFile(join(rootDir, 'second', 'SKILL.md'), '# local change') + + const result = await runCli([ + 'upgrade', '@global/first', '@global/second', '--registry', registry.url, '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(result.exitCode).toBe(6) + const output = JSON.parse(result.stdout) + expect(output.items.find((item: { coordinate: string }) => item.coordinate.endsWith('/first')).action).toBe('upgrade') + expect(output.items.find((item: { coordinate: string }) => item.coordinate.endsWith('/second')).action).toBe('blocked') + expect(await readFile(join(rootDir, 'first', 'SKILL.md'), 'utf-8')).toBe('# first v1') + }) + + test('legacy metadata without a file baseline requires explicit force migration', async () => { + const env = await createTempHome() + const skill = { namespace: 'global', slug: 'legacy', version: '1.0.0', fingerprint: 'v1', zipBytes: makeSkillZip('# v1') } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + await runCli(['install', '@global/legacy', '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + const metadataPath = join(rootDir, 'legacy', '.skillhub', 'metadata.json') + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')) + delete metadata.files + delete metadata.schemaVersion + await writeFile(metadataPath, JSON.stringify(metadata)) + skill.version = '1.1.0' + skill.fingerprint = 'v2' + skill.zipBytes = makeSkillZip('# v2') + + const blocked = await runCli([ + 'upgrade', '@global/legacy', '--registry', registry.url, '--check', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(blocked.exitCode).toBe(6) + expect(JSON.parse(blocked.stdout).items[0].reason).toContain('no file baseline') + + const migrated = await runCli([ + 'upgrade', '@global/legacy', '--registry', registry.url, '--force', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(migrated.exitCode).toBe(0) + expect(await readFile(join(rootDir, 'legacy', 'SKILL.md'), 'utf-8')).toBe('# v2') }) test('never installs a missing skill and never offers an implicit upgrade-all', async () => { @@ -220,5 +327,11 @@ describe('upgrade command', () => { const empty = await runCli(['upgrade'], { HOME: env.home, USERPROFILE: env.home }) expect(empty.exitCode).toBe(5) expect(empty.stderr).toContain('at least one') + + const tooMany = await runCli([ + 'upgrade', ...Array.from({ length: 51 }, (_, index) => `@global/skill-${index}`) + ], { HOME: env.home, USERPROFILE: env.home }) + expect(tooMany.exitCode).toBe(5) + expect(tooMany.stderr).toContain('at most 50') }) }) diff --git a/cli/test/unit/services/install-service.test.ts b/cli/test/unit/services/install-service.test.ts index afa4579d..6c579e14 100644 --- a/cli/test/unit/services/install-service.test.ts +++ b/cli/test/unit/services/install-service.test.ts @@ -206,6 +206,75 @@ describe('installSkill', () => { expect(inventory.items[0].targets[0].installDir).toBe(skillDir) }) + test('force rejects metadata explicitly owned by another installer', async () => { + globalThis.fetch = installFetch({ 'SKILL.md': '# New' }) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-')) + const skillDir = join(rootDir, 'demo') + await mkdir(skillDir, { recursive: true }) + await writeManagedMetadata(skillDir) + const metadataPath = join(skillDir, '.skillhub', 'metadata.json') + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')) + metadata.source = 'manual' + await writeFile(metadataPath, JSON.stringify(metadata)) + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true + })).rejects.toThrow('cannot verify SkillHub ownership') + }) + + test('revalidates ownership after download before replacing the target', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-')) + const skillDir = join(rootDir, 'demo') + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), '# Old') + await writeManagedMetadata(skillDir) + const archive = zipSync({ 'SKILL.md': new TextEncoder().encode('# New') }) + + globalThis.fetch = (async (input: URL | RequestInfo) => { + const path = new URL(String(input)).pathname + if (path.endsWith('/resolve')) { + return Response.json({ + code: 0, + data: { + namespace: 'global', + slug: 'demo', + version: '1.0.0', + versionId: 1, + fingerprint: 'fp', + downloadUrl: '/download' + } + }) + } + if (path.endsWith('/download')) { + await writeManagedMetadata(skillDir, { + registry: 'http://other-registry.test', + namespace: 'global', + slug: 'demo' + }) + await writeFile(join(skillDir, 'SKILL.md'), '# Replaced during download') + return new Response( + archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer, + { status: 200 } + ) + } + return Response.json({ code: 404 }, { status: 404 }) + }) as typeof fetch + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true + })).rejects.toThrow('source conflict') + + expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Replaced during download') + }) + test('force keeps old installation and inventory when replacement extraction fails', async () => { globalThis.fetch = installFetchWithDownloadResponse(new Response(new TextEncoder().encode('not a zip'), { status: 200 })) const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index 8c21476f..505a217e 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -248,6 +248,10 @@ skillhub upgrade @team/code-review --check --json `--force`; it only replaces a managed installation whose full `registry + namespace + slug` source matches. +All targets in one inventory entry are upgraded together. A filter that selects only part of that +entry is rejected because the current inventory format stores one shared version for all targets. +The command also keeps the local files when the registry resolves to an older version. + ## Local Management ### List Installed Skills