feat(cli): add source-safe skill upgrades

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-01 15:28:41 +08:00
parent 08723fd01a
commit 77bd82c302
14 changed files with 884 additions and 23 deletions

View file

@ -4,8 +4,18 @@ All notable CLI behavior changes are documented in this file.
## Unreleased
### Added
- Add `skillhub upgrade <coordinate...>` for bounded, explicit upgrades of already-installed Skills,
including side-effect-free `--check`, structured `--json`, local-change protection, and target
filters.
### Fixed
- Prevent `--force` from overwriting an unmanaged or different-source Skill at the same target path.
Source ownership is the full `registry + namespace + slug` identity.
- Exclude installer-owned `.skillhub/` state when publishing a local Skill directory.
- Resolve `namespace/slug`, `@namespace/slug`, and `namespace--slug`
coordinates against their declared namespace instead of silently falling
back to `global`.

View file

@ -171,7 +171,7 @@ skillhub install pdf-parser --agent codex --agent claude-code
# Install to custom directory
skillhub install pdf-parser --dir ~/.claude/skills
# Force overwrite existing installation
# Reinstall a SkillHub-managed installation from the same source
skillhub install pdf-parser --force
```
@ -228,17 +228,44 @@ For a custom path or an unsupported Agent directory, use `--dir` to specify the
```json
{
"schemaVersion": 1,
"registry": "https://skill.xfyun.cn",
"namespace": "global",
"slug": "pdf-parser",
"version": "1.0.0",
"versionId": 123,
"fingerprint": "sha256:...",
"files": { "SKILL.md": "sha256..." },
"source": "skillhub",
"agent": "codex",
"installedAt": "2026-04-28T06:00:00.000Z"
}
```
The CLI creates `.skillhub/metadata.json` after extracting a downloaded package. It is not part of
the published ZIP and is excluded when a managed directory is published again.
## ⬆️ Upgrade Installed Skills
`upgrade` only operates on explicitly selected, SkillHub-managed local installations. It never
installs a missing Skill and has no implicit upgrade-all mode.
```bash
# Preview without changing files
skillhub upgrade @global/skillhub-registry --check
# Upgrade one or a bounded list of installed Skills
skillhub upgrade @global/skillhub-registry
skillhub upgrade @team/code-review @team/java-guide
# Machine-readable plan
skillhub upgrade @team/code-review --check --json
```
The source identity is `registry + namespace + slug`. `--force` may replace local changes only when
that full identity matches the installation metadata; it never overwrites an unmanaged directory or
a Skill installed from another source.
## 🔄 Namespace Workspaces
Use namespace synchronization when an Agent workspace should maintain all installable skills from one team space.
@ -402,6 +429,7 @@ Update mechanism:
| `skillhub whoami [--registry <url>] [--token <token>] [--json]` | Validate current token and display user information |
| `skillhub search <query> [--registry <url>] [--token <token>] [--limit <n>] [--json]` | Search published skills |
| `skillhub install <coordinate> [--scope <user\|project>] [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--registry <url>] [--token <token>] [--json]` | Install a skill |
| `skillhub upgrade <coordinate...> [--namespace <slug>] [--agent <profile>] [--dir <path>] [--registry <url>] [--check] [--force] [--json]` | Upgrade explicitly selected installed skills |
| `skillhub list [--agent <profile>] [--dir <path>] [--registry <url>] [--json]` | List installed skills |
| `skillhub remove <coordinate> [--agent <profile>] [--all] [--remote] [--hard] [--namespace <slug>] [--registry <url>] [--token <token>] [--json]` | Remove a skill |
| `skillhub doctor [--json]` | Scan project directory and rebuild local inventory |
@ -447,13 +475,16 @@ skillhub search test --registry https://skillhub.example.com
```bash
# Use --force to overwrite
skillhub install pdf-parser --force
skillhub install pdf-parser --force # same SkillHub source only
# Or remove first then install
skillhub remove pdf-parser
skillhub install pdf-parser
```
`--force` does not bypass source ownership. Move or explicitly remove an unmanaged or different-source
directory before installing another Skill with the same visible slug.
### Corrupted Inventory
```bash

View file

@ -43,6 +43,15 @@ export const commands = {
'skillhub install pdf-parser --scope project --agent codex'
]
},
upgrade: {
summary: 'Upgrade explicitly selected installed skills',
usage: 'skillhub upgrade <coordinate...> [--namespace <slug>] [--agent <profile>] [--dir <path>] [--registry <url>] [--check] [--force] [--json]',
examples: [
'skillhub upgrade @global/skillhub-registry',
'skillhub upgrade @team/code-review @team/java-guide --check --json',
'skillhub upgrade code-review --namespace team --agent codex'
]
},
sync: {
summary: 'Synchronize and maintain namespace workspaces',
usage: 'skillhub sync <pull|status|diff|push> [options]',

View file

@ -49,7 +49,9 @@ export async function publishCommand(path: string, options: PublishCommandOption
throw new CliError(`file must be a zip archive: ${path}`, EXIT.filesystem, { path })
}
} else if (pathStat.isDirectory()) {
archiveBlob = await createZip(path)
archiveBlob = await createZip(path, {
exclude: relativePath => relativePath === '.skillhub' || relativePath.startsWith('.skillhub/')
})
archiveName = `${basename(path)}.zip`
} else {
throw new CliError(`path must be a file or directory: ${path}`, EXIT.filesystem, { path })

View file

@ -0,0 +1,78 @@
import { CredentialsStore } from '../stores/credentials-store'
import { resolveToken } from '../services/registry-service'
import { CliError } from '../shared/errors'
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
}
export async function upgradeCommand(coordinates: string[], options: UpgradeCommandOptions): Promise<string> {
const credentials = new CredentialsStore()
const tokenForRegistry = async (registry: string): Promise<string | undefined> =>
resolveToken(options, process.env, await credentials.getToken(registry))
const plan = await planSkillUpgrades({
coordinates,
namespace: options.namespace,
registry: options.registry,
agents: options.agent,
dir: options.dir,
force: Boolean(options.force),
tokenForRegistry
})
const output = renderUpgradePlan(plan, Boolean(options.check), Boolean(options.json))
if (plan.blocked > 0) {
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 => ({
coordinate: item.coordinate,
reason: item.reason
}))
})
}
if (!options.check) {
await executeSkillUpgradePlan(plan, { force: Boolean(options.force), tokenForRegistry })
}
return output
}
function renderUpgradePlan(plan: UpgradePlan, check: boolean, json: boolean): string {
if (json) {
return JSON.stringify({
ok: plan.blocked === 0,
check,
summary: { upgrades: plan.upgrades, unchanged: plan.unchanged, blocked: plan.blocked },
items: plan.items.map(item => ({
coordinate: item.coordinate,
registry: item.registry,
currentVersion: item.currentVersion,
remoteVersion: item.remoteVersion,
action: item.action === 'upgrade' && !check ? 'upgraded' : item.action,
reason: item.reason,
changedFiles: item.changedFiles,
targets: item.targets
}))
})
}
const heading = check ? '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 versions = item.remoteVersion ? ` ${item.currentVersion} -> ${item.remoteVersion}` : ''
const reason = item.reason ? ` (${item.reason})` : ''
return `${action.padEnd(10)} ${item.coordinate}${versions}${reason}`
})
].join('\n')
}

View file

@ -11,6 +11,7 @@ import { removeCommand, type RemoveCommandOptions } from './commands/remove'
import { searchCommand } from './commands/search'
import { syncDiffCommand, syncPullCommand, syncPushCommand, syncStatusCommand, type SyncCommonOptions, type SyncPullOptions, type SyncPushOptions } from './commands/sync'
import { updateCommand } from './commands/update'
import { upgradeCommand, type UpgradeCommandOptions } from './commands/upgrade'
import { versionCommand } from './commands/version'
import { whoamiCommand } from './commands/whoami'
import { EXIT } from './shared/constants'
@ -247,6 +248,23 @@ cli
return runCommand(() => installCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json))
})
cli
.command('upgrade [...coordinates]', 'Upgrade explicitly selected installed skills')
.option('--namespace <slug>', 'Filter a bare slug by namespace')
.option('--agent <profile>', 'Filter installed targets by Agent (repeatable)')
.option('--dir <path>', 'Filter installed targets by directory')
.option('--registry <url>', 'Filter by installation source registry')
.option('--token <token>', 'API token override')
.option('--check', 'Show the exact plan without writing')
.option('--force', 'Replace local changes from the same source')
.option('--json', 'Output JSON')
.action((coordinates: string[], options: UpgradeCommandOptions & { agent?: string | string[] }) => {
return runCommand(
() => upgradeCommand(coordinates, { ...options, agent: toArray(options.agent) }),
Boolean(options.json)
)
})
cli
.command('sync <action> [path]', 'Synchronize and maintain a namespace workspace')
.option('--namespace <slug>', 'Namespace', { default: 'global' })

View file

@ -8,8 +8,14 @@ import { extractZip } from '../platform/archive'
import { readBoundedResponseBody } from '../platform/download'
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
import { snapshotSkillDirectory } from './skill-fingerprint'
import {
readInstalledSkillMetadata,
sameInstalledSkillSource,
type InstalledSkillIdentity
} from './installed-skill-metadata'
import type { AgentCandidate } from '../agents/types'
import type { ResolveResponse } from '../clients/skillhub-client'
import type { Inventory } from '../stores/inventory-store'
export interface InstallOptions {
registry: string
@ -25,15 +31,16 @@ export interface InstallOptions {
async function preflightInstallTargets(
targets: AgentCandidate[],
slug: string,
force: boolean
identity: InstalledSkillIdentity,
force: boolean,
inventory: Inventory
): Promise<Array<{ target: AgentCandidate; skillDir: string }>> {
const seenSkillDirs = new Set<string>()
const preparedTargets: Array<{ target: AgentCandidate; skillDir: string }> = []
for (const target of targets) {
const canonicalRootDir = await canonicalizeExistingPath(target.rootDir)
const canonicalSkillDir = join(canonicalRootDir, slug)
const canonicalSkillDir = join(canonicalRootDir, identity.slug)
if (seenSkillDirs.has(canonicalSkillDir)) {
throw new CliError(`multiple install targets resolve to ${canonicalSkillDir}`, EXIT.usage, {
path: canonicalSkillDir,
@ -42,13 +49,40 @@ async function preflightInstallTargets(
}
seenSkillDirs.add(canonicalSkillDir)
const skillDir = join(target.rootDir, slug)
if (await pathExists(skillDir) && !force) {
const skillDir = join(target.rootDir, identity.slug)
const exists = await pathExists(skillDir)
if (exists && !force) {
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
path: skillDir,
next: 'pass --force to overwrite'
next: 'pass --force to replace a same-source installation'
})
}
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'
})
}
}
preparedTargets.push({ target, skillDir })
}
@ -56,15 +90,19 @@ async function preflightInstallTargets(
}
export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> {
const preparedTargets = await preflightInstallTargets(options.targets, options.slug, options.force)
const store = new InventoryStore(options.home)
const inventory = await store.read()
const preparedTargets = await preflightInstallTargets(options.targets, {
registry: options.registry,
namespace: options.namespace,
slug: options.slug
}, options.force, inventory)
const client = new SkillHubClient(options.registry, options.token)
const resolved = options.resolved ?? await client.resolve(options.namespace, options.slug, options.version)
const response = await client.download(options.namespace, options.slug, resolved.version)
const buffer = await readBoundedResponseBody(response)
const installed: Array<{ agent: string; dir: string }> = []
const store = new InventoryStore(options.home)
for (const { target, skillDir } of preparedTargets) {
await mkdir(target.rootDir, { recursive: true })
const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`))
@ -78,10 +116,12 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
const metaDir = join(tempDir, '.skillhub')
await mkdir(metaDir, { recursive: true })
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
schemaVersion: 1,
registry: options.registry,
namespace: options.namespace,
slug: options.slug,
version: resolved.version,
versionId: resolved.versionId,
fingerprint: resolved.fingerprint,
files: snapshot.files,
source: 'skillhub',

View file

@ -0,0 +1,69 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { pathExists } from '../platform/paths'
export interface InstalledSkillIdentity {
registry: string
namespace: string
slug: string
}
export interface InstalledSkillMetadata extends InstalledSkillIdentity {
schemaVersion?: number
version: string
versionId?: number
fingerprint?: string
files?: Record<string, string>
source?: string
agent?: string
installedAt?: string
}
export type InstalledMetadataReadResult =
| { status: 'missing' }
| { status: 'invalid'; reason: string }
| { status: 'valid'; metadata: InstalledSkillMetadata }
export async function readInstalledSkillMetadata(skillDir: string): Promise<InstalledMetadataReadResult> {
const metadataPath = join(skillDir, '.skillhub', 'metadata.json')
if (!(await pathExists(metadataPath))) return { status: 'missing' }
try {
const value = JSON.parse(await readFile(metadataPath, 'utf-8')) as unknown
if (!isRecord(value)) return { status: 'invalid', reason: 'metadata root must be an object' }
for (const field of ['registry', 'namespace', 'slug', 'version'] as const) {
if (typeof value[field] !== 'string' || value[field].length === 0) {
return { status: 'invalid', reason: `metadata field "${field}" must be a non-empty string` }
}
}
if (value.files !== undefined && !isStringRecord(value.files)) {
return { status: 'invalid', reason: 'metadata field "files" must map paths to hashes' }
}
return { status: 'valid', metadata: value as unknown as InstalledSkillMetadata }
} catch {
return { status: 'invalid', reason: 'metadata is not valid JSON' }
}
}
export function sameInstalledSkillSource(
left: InstalledSkillIdentity,
right: InstalledSkillIdentity
): boolean {
return normalizeRegistry(left.registry) === normalizeRegistry(right.registry) &&
left.namespace === right.namespace &&
left.slug === right.slug
}
function normalizeRegistry(registry: string): string {
return registry.replace(/\/+$/, '')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every(entry => typeof entry === 'string')
}

View file

@ -0,0 +1,287 @@
import { relative, resolve } from 'node:path'
import { pathExists } from '../platform/paths'
import { SkillHubClient, type ResolveResponse } from '../clients/skillhub-client'
import { InventoryStore, type InventoryItem, type InventoryTarget } from '../stores/inventory-store'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { hasExplicitNamespace, parseSkillName, resolveSkillName } from '../shared/skill-name-parser'
import { diffSkillFiles, snapshotSkillDirectory } from './skill-fingerprint'
import { installSkill } from './install-service'
import { readInstalledSkillMetadata, sameInstalledSkillSource } from './installed-skill-metadata'
const MAX_UPGRADE_SELECTION = 50
export interface UpgradeSelectionOptions {
coordinates: string[]
namespace?: string
registry?: string
agents?: string[]
dir?: string
force: boolean
home?: string
tokenForRegistry: (registry: string) => Promise<string | undefined>
}
export type UpgradePlanAction = 'upgrade' | 'unchanged' | 'blocked'
export interface UpgradePlanItem {
coordinate: string
registry: string
currentVersion: string
remoteVersion?: string
action: UpgradePlanAction
reason?: string
changedFiles: string[]
targets: Array<{ agent: string; dir: string }>
resolved?: ResolveResponse
inventoryItem: InventoryItem
selectedTargets: InventoryTarget[]
}
export interface UpgradePlan {
items: UpgradePlanItem[]
blocked: number
upgrades: number
unchanged: number
}
export async function planSkillUpgrades(options: UpgradeSelectionOptions): Promise<UpgradePlan> {
if (options.coordinates.length === 0) {
throw new CliError('provide at least one installed skill coordinate', EXIT.usage)
}
if (options.coordinates.length > MAX_UPGRADE_SELECTION) {
throw new CliError(`upgrade accepts at most ${MAX_UPGRADE_SELECTION} coordinates`, EXIT.usage)
}
const store = new InventoryStore(options.home)
const inventory = await store.read()
const selected = selectInventoryItems(inventory.items, options)
const items: UpgradePlanItem[] = []
for (const selection of selected) {
const coordinate = `@${selection.item.namespace}/${selection.item.slug}`
const base = {
coordinate,
registry: selection.item.registry,
currentVersion: selection.item.version,
changedFiles: [] as string[],
targets: selection.targets.map(target => ({ agent: target.agent, dir: target.installDir })),
inventoryItem: selection.item,
selectedTargets: selection.targets
}
let resolved: ResolveResponse
try {
const token = await options.tokenForRegistry(selection.item.registry)
resolved = await new SkillHubClient(selection.item.registry, token)
.resolve(selection.item.namespace, selection.item.slug)
} catch (error) {
items.push({
...base,
action: 'blocked',
reason: error instanceof Error ? error.message : 'remote version unavailable'
})
continue
}
if (resolved.namespace !== selection.item.namespace || resolved.slug !== selection.item.slug) {
items.push({
...base,
remoteVersion: resolved.version,
action: 'blocked',
reason: 'registry resolved a different skill identity'
})
continue
}
const inspection = await inspectTargets(selection.item, selection.targets)
const hardConflict = inspection.hardConflicts[0]
if (hardConflict) {
items.push({
...base,
remoteVersion: resolved.version,
action: 'blocked',
reason: hardConflict,
changedFiles: inspection.changedFiles,
resolved
})
continue
}
if (inspection.changedFiles.length > 0 && !options.force) {
items.push({
...base,
remoteVersion: resolved.version,
action: 'blocked',
reason: 'local changes detected; pass --force to replace same-source files',
changedFiles: inspection.changedFiles,
resolved
})
continue
}
if (inspection.baselineMissing && !options.force) {
items.push({
...base,
remoteVersion: resolved.version,
action: 'blocked',
reason: 'installed metadata has no file baseline; pass --force to migrate this same-source installation',
resolved
})
continue
}
const unchanged = selection.item.version === resolved.version &&
selection.item.fingerprint === resolved.fingerprint &&
inspection.metadataCurrent
items.push({
...base,
remoteVersion: resolved.version,
action: unchanged ? 'unchanged' : 'upgrade',
changedFiles: inspection.changedFiles,
resolved
})
}
return {
items,
blocked: items.filter(item => item.action === 'blocked').length,
upgrades: items.filter(item => item.action === 'upgrade').length,
unchanged: items.filter(item => item.action === 'unchanged').length
}
}
export async function executeSkillUpgradePlan(
plan: UpgradePlan,
options: Pick<UpgradeSelectionOptions, 'force' | 'home' | 'tokenForRegistry'>
): Promise<void> {
if (plan.blocked > 0) {
throw new CliError('upgrade plan contains blocked skills', EXIT.validation)
}
for (const item of plan.items) {
if (item.action !== 'upgrade' || !item.resolved) continue
const token = await options.tokenForRegistry(item.registry)
await installSkill({
registry: item.registry,
token,
namespace: item.inventoryItem.namespace,
slug: item.inventoryItem.slug,
resolved: item.resolved,
targets: item.selectedTargets.map(target => ({
agent: target.agent,
rootDir: target.rootDir,
scope: 'project',
source: 'explicit'
})),
force: true,
home: options.home
})
}
}
function selectInventoryItems(
items: InventoryItem[],
options: Pick<UpgradeSelectionOptions, 'coordinates' | 'namespace' | 'registry' | 'agents' | 'dir'>
): Array<{ item: InventoryItem; targets: InventoryTarget[] }> {
const selected = new Map<string, { item: InventoryItem; targets: InventoryTarget[] }>()
for (const coordinate of options.coordinates) {
const explicitNamespace = hasExplicitNamespace(coordinate)
const parsed = explicitNamespace
? resolveSkillName(coordinate, options.namespace)
: parseSkillName(coordinate)
const namespace = explicitNamespace ? parsed.namespace : options.namespace
const matches = items.flatMap(item => {
if (item.slug !== parsed.slug) return []
if (namespace && item.namespace !== namespace) return []
if (options.registry && normalizeRegistry(item.registry) !== normalizeRegistry(options.registry)) return []
const targets = item.targets.filter(target => matchesTargetFilters(target, options.agents, options.dir))
return targets.length > 0 ? [{ item, targets }] : []
})
if (matches.length === 0) {
throw new CliError(`skill "${coordinate}" is not installed`, EXIT.usage, {
next: `use skillhub install ${coordinate}`
})
}
if (matches.length > 1) {
throw new CliError(`installed skill "${coordinate}" is ambiguous`, EXIT.usage, {
matches: matches.map(match => `${match.item.registry} @${match.item.namespace}/${match.item.slug}`),
next: 'use a full coordinate and --registry to select one installation source'
})
}
const match = matches[0]!
const key = `${normalizeRegistry(match.item.registry)}\u0000${match.item.namespace}\u0000${match.item.slug}`
selected.set(key, match)
}
if (selected.size > MAX_UPGRADE_SELECTION) {
throw new CliError(`upgrade resolves to at most ${MAX_UPGRADE_SELECTION} skills`, EXIT.usage)
}
return [...selected.values()]
}
async function inspectTargets(item: InventoryItem, targets: InventoryTarget[]): Promise<{
hardConflicts: string[]
changedFiles: string[]
baselineMissing: boolean
metadataCurrent: boolean
}> {
const hardConflicts: string[] = []
const changedFiles = new Set<string>()
let baselineMissing = false
let metadataCurrent = true
for (const target of targets) {
if (!(await pathExists(target.installDir))) {
hardConflicts.push(`installed target is missing: ${target.installDir}`)
continue
}
const result = await readInstalledSkillMetadata(target.installDir)
if (result.status !== 'valid') {
hardConflicts.push(`metadata-invalid at ${target.installDir}: ${result.status === 'missing' ? 'missing' : result.reason}`)
continue
}
if (!sameInstalledSkillSource(result.metadata, item)) {
hardConflicts.push(`source-conflict at ${target.installDir}`)
continue
}
if (!result.metadata.files) {
baselineMissing = true
} else {
const snapshot = await snapshotSkillDirectory(target.installDir)
for (const path of diffSkillFiles(result.metadata.files, snapshot.files)) {
changedFiles.add(`${target.installDir}:${path}`)
}
}
if (result.metadata.version !== item.version || result.metadata.fingerprint !== item.fingerprint) {
metadataCurrent = false
}
}
return {
hardConflicts,
changedFiles: [...changedFiles].sort(),
baselineMissing,
metadataCurrent
}
}
function matchesTargetFilters(target: InventoryTarget, agents?: string[], dir?: string): boolean {
if (agents?.length && !agents.includes(target.agent)) return false
if (!dir) return true
const filterPath = resolve(dir)
const installPath = resolve(target.installDir)
const rootPath = resolve(target.rootDir)
return isSameOrWithin(filterPath, installPath) || isSameOrWithin(filterPath, rootPath)
}
function isSameOrWithin(parent: string, candidate: string): boolean {
const rel = relative(parent, candidate)
return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/') && !rel.startsWith('\\'))
}
function normalizeRegistry(registry: string): string {
return registry.replace(/\/+$/, '')
}

View file

@ -1,3 +1,5 @@
import { unzipSync } from 'fflate'
type FakeHandler = (req: Request) => Response | Promise<Response>
export function createFakeRegistry(handlers: Record<string, FakeHandler>) {
@ -103,6 +105,7 @@ export interface CapturedPublish {
/** Visibility string from the multipart form field. */
visibility: string
rejectExistingVersion: boolean
archiveEntries: string[]
}
export interface CapturedValidate {
@ -203,7 +206,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
delete: CapturedDelete | null
validate: CapturedValidate | null
review: CapturedReview | null
} = { publish: null, resolve: null, delete: null, validate: null, review: null }
downloads: number
} = { publish: null, resolve: null, delete: null, validate: null, review: null, downloads: 0 }
// If any endpoint is configured with 'network' failure mode, we need a real
// TCP-level failure. Start a connection-dropping server and return its URL
@ -345,6 +349,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
if (!skill) {
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
state.downloads += 1
const bytes = skill.zipBytes ?? MINIMAL_ZIP
return new Response(bytes as BodyInit, {
status: 200,
@ -364,6 +369,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
if (!skill) {
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
state.downloads += 1
const bytes = skill.zipBytes ?? MINIMAL_ZIP
return new Response(bytes as BodyInit, {
status: 200,
@ -415,6 +421,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
if (fileField instanceof File) {
fileName = fileField.name || fileName
}
state.validate = {
namespace,
fileName,
@ -442,7 +449,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
const namespace = publishMatch[1]!
// Parse multipart form data asynchronously — return a Promise<Response>.
return req.formData().then(form => {
return req.formData().then(async form => {
const fileField = form.get('file')
const visibility = (form.get('visibility') as string | null) ?? 'PUBLIC'
@ -451,13 +458,17 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
if (fileField instanceof File) {
fileName = fileField.name || fileName
}
const archiveEntries = fileField instanceof File
? Object.keys(unzipSync(new Uint8Array(await fileField.arrayBuffer()))).sort()
: []
// Record for test assertions.
state.publish = {
namespace,
fileName,
visibility,
rejectExistingVersion: form.get('rejectExistingVersion') === 'true'
rejectExistingVersion: form.get('rejectExistingVersion') === 'true',
archiveEntries
}
return Response.json({

View file

@ -1,4 +1,4 @@
import { mkdtemp, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
@ -117,6 +117,25 @@ describe('publish command — P0', () => {
expect(registry.received.publish!.visibility).toBe('PUBLIC')
})
test('directory publish excludes local SkillHub installation metadata', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '# Demo'])
await mkdir(join(dir, '.skillhub'), { recursive: true })
await writeFile(join(dir, '.skillhub', 'metadata.json'), '{"registry":"private"}')
const result = await runCli(['publish', dir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
expect(registry.received.publish!.archiveEntries).toContain('SKILL.md')
expect(registry.received.publish!.archiveEntries.some(path => path.startsWith('.skillhub'))).toBe(false)
})
test('zip file happy path: exit 0, fileName matches passed file', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })

View file

@ -0,0 +1,224 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { strToU8, zipSync } from 'fflate'
import { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
let registries: Array<Awaited<ReturnType<typeof startFakeRegistry>>> = []
afterEach(() => {
for (const registry of registries) registry.stop()
registries = []
})
function makeSkillZip(content: string): Uint8Array {
return zipSync({ 'SKILL.md': strToU8(content) })
}
describe('upgrade command', () => {
test('check is side-effect free and execute upgrades an installed skill', async () => {
const env = await createTempHome()
const skill = {
namespace: 'global',
slug: 'skillhub-registry',
version: '1.0.0',
versionId: 1,
fingerprint: 'fp-v1',
zipBytes: makeSkillZip('# v1')
}
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const rootDir = join(env.cwd, 'skills')
await mkdir(rootDir, { recursive: true })
const installed = await runCli([
'install', '@global/skillhub-registry', '--dir', rootDir, '--registry', registry.url
], { HOME: env.home, USERPROFILE: env.home })
expect(installed.exitCode).toBe(0)
skill.version = '1.1.0'
skill.versionId = 2
skill.fingerprint = 'fp-v2'
skill.zipBytes = makeSkillZip('# v2')
const checked = await runCli([
'upgrade', '@global/skillhub-registry', '--registry', registry.url, '--check', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(checked.exitCode).toBe(0)
expect(JSON.parse(checked.stdout).items[0]).toMatchObject({
coordinate: '@global/skillhub-registry',
currentVersion: '1.0.0',
remoteVersion: '1.1.0',
action: 'upgrade'
})
expect(await readFile(join(rootDir, 'skillhub-registry', 'SKILL.md'), 'utf-8')).toBe('# v1')
expect(registry.received.downloads).toBe(1)
const upgraded = await runCli([
'upgrade', '@global/skillhub-registry', '--registry', registry.url, '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(upgraded.exitCode).toBe(0)
expect(JSON.parse(upgraded.stdout).items[0].action).toBe('upgraded')
expect(await readFile(join(rootDir, 'skillhub-registry', 'SKILL.md'), 'utf-8')).toBe('# v2')
expect(registry.received.downloads).toBe(2)
const metadata = JSON.parse(await readFile(
join(rootDir, 'skillhub-registry', '.skillhub', 'metadata.json'),
'utf-8'
))
expect(metadata).toMatchObject({ schemaVersion: 1, version: '1.1.0', versionId: 2, fingerprint: 'fp-v2' })
})
test('local changes block by default and --force replaces only the same source', async () => {
const env = await createTempHome()
const skill = {
namespace: 'team',
slug: 'code-review',
version: '1.0.0',
fingerprint: 'fp-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', '@team/code-review', '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
await writeFile(join(rootDir, 'code-review', 'SKILL.md'), '# locally edited')
skill.version = '1.1.0'
skill.fingerprint = 'fp-v2'
skill.zipBytes = makeSkillZip('# v2')
const blocked = await runCli([
'upgrade', '@team/code-review', '--registry', registry.url, '--check', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(blocked.exitCode).toBe(6)
expect(JSON.parse(blocked.stdout).items[0]).toMatchObject({ action: 'blocked' })
expect(JSON.parse(blocked.stdout).items[0].reason).toContain('local changes')
expect(await readFile(join(rootDir, 'code-review', 'SKILL.md'), 'utf-8')).toBe('# locally edited')
const forced = await runCli([
'upgrade', '@team/code-review', '--registry', registry.url, '--force', '--json'
], { HOME: env.home, USERPROFILE: env.home })
expect(forced.exitCode).toBe(0)
expect(await readFile(join(rootDir, 'code-review', 'SKILL.md'), 'utf-8')).toBe('# v2')
})
test('source conflict is a hard block even with --force', async () => {
const env = await createTempHome()
const skill = {
namespace: 'global',
slug: 'demo',
version: '1.0.0',
fingerprint: 'fp-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/demo', '--dir', rootDir, '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
const metadataPath = join(rootDir, 'demo', '.skillhub', 'metadata.json')
const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'))
metadata.namespace = 'another-team'
await writeFile(metadataPath, JSON.stringify(metadata))
skill.version = '2.0.0'
skill.fingerprint = 'fp-v2'
const result = await runCli([
'upgrade', '@global/demo', '--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('source-conflict')
expect(await readFile(join(rootDir, 'demo', 'SKILL.md'), 'utf-8')).toBe('# v1')
})
test('a bare slug must identify exactly one installed source', async () => {
const env = await createTempHome()
const skillA = { namespace: 'team-a', slug: 'demo', version: '1.0.0', fingerprint: 'a', zipBytes: makeSkillZip('# A') }
const skillB = { namespace: 'team-b', slug: 'demo', version: '1.0.0', fingerprint: 'b', zipBytes: makeSkillZip('# B') }
const registryA = await startFakeRegistry({ skills: [skillA] })
const registryB = await startFakeRegistry({ skills: [skillB] })
registries.push(registryA, registryB)
const rootA = join(env.cwd, 'a')
const rootB = join(env.cwd, 'b')
await mkdir(rootA, { recursive: true })
await mkdir(rootB, { recursive: true })
await runCli(['install', '@team-a/demo', '--dir', rootA, '--registry', registryA.url], {
HOME: env.home,
USERPROFILE: env.home
})
await runCli(['install', '@team-b/demo', '--dir', rootB, '--registry', registryB.url], {
HOME: env.home,
USERPROFILE: env.home
})
const ambiguous = await runCli(['upgrade', 'demo', '--check'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(ambiguous.exitCode).toBe(5)
expect(ambiguous.stderr).toContain('ambiguous')
const selected = await runCli(['upgrade', 'demo', '--namespace', 'team-a', '--registry', registryA.url, '--check'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(selected.exitCode).toBe(0)
expect(selected.stdout).toContain('@team-a/demo')
})
test('one resolved archive is reused for every managed target', async () => {
const env = await createTempHome()
const skill = {
namespace: 'global',
slug: 'shared',
version: '1.0.0',
fingerprint: 'fp-v1',
zipBytes: makeSkillZip('# v1')
}
const registry = await startFakeRegistry({ skills: [skill] })
registries.push(registry)
const installed = await runCli([
'install', '@global/shared', '--agent', 'codex', '--agent', 'claude-code', '--registry', registry.url
], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
expect(installed.exitCode).toBe(0)
expect(registry.received.downloads).toBe(1)
skill.version = '1.1.0'
skill.fingerprint = 'fp-v2'
skill.zipBytes = makeSkillZip('# v2')
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')
})
test('never installs a missing skill and never offers an implicit upgrade-all', async () => {
const env = await createTempHome()
const missing = await runCli(['upgrade', '@global/missing', '--check'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(missing.exitCode).toBe(5)
expect(missing.stderr).toContain('use skillhub install')
const empty = await runCli(['upgrade'], { HOME: env.home, USERPROFILE: env.home })
expect(empty.exitCode).toBe(5)
expect(empty.stderr).toContain('at least one')
})
})

View file

@ -51,6 +51,22 @@ function installFetchWithDownloadResponse(downloadResponse: Response): typeof fe
return fakeFetch as unknown as typeof fetch
}
async function writeManagedMetadata(
skillDir: string,
identity: { registry: string; namespace: string; slug: string } = {
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo'
}
): Promise<void> {
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), JSON.stringify({
...identity,
version: '0.1.0',
source: 'skillhub'
}))
}
describe('installSkill', () => {
afterEach(() => {
globalThis.fetch = originalFetch
@ -135,6 +151,7 @@ describe('installSkill', () => {
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'stale.txt'), 'old')
await writeManagedMetadata(skillDir)
await installSkill({
registry: 'http://registry.test',
@ -149,12 +166,13 @@ describe('installSkill', () => {
expect(await exists(join(skillDir, 'stale.txt'))).toBe(false)
})
test('force removes stale inventory records that point at the replaced install directory', async () => {
test('force rejects a different namespace at the same install directory', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Team Demo' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeManagedMetadata(skillDir)
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(inventoryPath, JSON.stringify({
@ -172,18 +190,18 @@ describe('installSkill', () => {
}]
}))
await installSkill({
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'team',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})
})).rejects.toThrow('source conflict')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]).toMatchObject({ namespace: 'team', slug: 'demo' })
expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo' })
expect(inventory.items[0].targets).toHaveLength(1)
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
})
@ -195,6 +213,7 @@ describe('installSkill', () => {
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
await writeManagedMetadata(skillDir)
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(inventoryPath, JSON.stringify({
@ -234,6 +253,7 @@ describe('installSkill', () => {
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
await writeManagedMetadata(skillDir)
const invalidHome = join(rootDir, 'home-is-a-file')
await writeFile(invalidHome, 'not a directory')

View file

@ -157,7 +157,7 @@ skillhub install pdf-parser --agent codex --agent claude-code
# Install to custom directory
skillhub install pdf-parser --dir ~/.claude/skills
# Force overwrite existing installation
# Reinstall a SkillHub-managed installation from the same source
skillhub install pdf-parser --force
```
@ -214,15 +214,40 @@ For a custom path or an unsupported Agent directory, use `--dir` to specify the
```json
{
"schemaVersion": 1,
"registry": "https://skill.xfyun.cn",
"namespace": "global",
"slug": "pdf-parser",
"version": "1.0.0",
"versionId": 123,
"fingerprint": "sha256:...",
"files": { "SKILL.md": "sha256..." },
"agent": "codex",
"installedAt": "2026-04-28T06:00:00.000Z"
}
```
The CLI creates this file after extracting the registry package. It is not included in the downloaded
ZIP, and `.skillhub/` is excluded when an installed directory is published again.
## Upgrade Installed Skills
```bash
# Preview without changing files
skillhub upgrade @global/skillhub-registry --check
# Upgrade one or a bounded list of installed Skills
skillhub upgrade @global/skillhub-registry
skillhub upgrade @team/code-review @team/java-guide
# Deterministic machine-readable plan
skillhub upgrade @team/code-review --check --json
```
`upgrade` never installs a missing Skill and has no implicit upgrade-all mode. Local changes require
`--force`; it only replaces a managed installation whose full `registry + namespace + slug` source
matches.
## Local Management
### List Installed Skills
@ -504,11 +529,26 @@ Options:
- `--version <v>` — Version (default: latest)
- `--agent <profile>` — Agent profile (repeatable)
- `--dir <path>` — Custom installation directory (mutually exclusive with `--scope` and `--agent`)
- `--force` — Overwrite existing installation
- `--force` — Replace an existing SkillHub-managed installation from the same source
- `--registry <url>` — Registry URL
- `--token <token>` — API token
- `--json` — JSON output
### upgrade
```bash
skillhub upgrade <coordinate...> [options]
```
Options:
- `--namespace <slug>` — Filter a bare slug by namespace
- `--agent <profile>` — Filter installed targets by Agent (repeatable)
- `--dir <path>` — Filter installed targets by directory
- `--registry <url>` — Filter by installation source registry
- `--check` — Print the exact plan without modifying files
- `--force` — Replace local changes only for the same full source identity
- `--json` — JSON output
### list
```bash
@ -604,7 +644,7 @@ skillhub search test --registry https://skillhub.example.com
### Installation Directory Conflict
```bash
# Use --force to overwrite
# Reinstall only when the existing directory has matching SkillHub source metadata
skillhub install pdf-parser --force
# Or remove first then install
@ -612,6 +652,9 @@ skillhub remove pdf-parser
skillhub install pdf-parser
```
`--force` never overwrites an unmanaged directory or a Skill installed from another registry,
namespace, or slug. Move or explicitly remove that directory first.
### Corrupted Inventory
```bash