feat(cli): add namespace workspace sync

Signed-off-by: mrLi <50289372+15258725278@users.noreply.github.com>
This commit is contained in:
mrLi 2026-08-18 18:24:11 +08:00
parent d2403bb591
commit 84feb38931
18 changed files with 1182 additions and 32 deletions

View file

@ -32,6 +32,9 @@ skillhub list
# Publish skill
skillhub publish ./my-skill --namespace myspace
# Synchronize a team workspace
skillhub sync pull --namespace myspace
```
## 🌐 Registry Configuration
@ -229,11 +232,45 @@ For a custom path or an unsupported Agent directory, use `--dir` to specify the
"namespace": "global",
"slug": "pdf-parser",
"version": "1.0.0",
"fingerprint": "sha256:...",
"source": "skillhub",
"agent": "codex",
"installedAt": "2026-04-28T06:00:00.000Z"
}
```
## 🔄 Namespace Workspaces
Use namespace synchronization when an Agent workspace should maintain all installable skills from one team space.
```bash
# Pull new and updated skills into ./.agents/skills
skillhub sync pull --namespace team-a
# Use an explicit workspace directory
skillhub sync pull --namespace team-a --dir ./.claude/skills
# Check without downloading
skillhub sync pull --namespace team-a --check
# Show local edits and remote updates
skillhub sync status --namespace team-a --json
skillhub sync diff --namespace team-a
# Remove only unchanged SkillHub-managed skills that no longer exist remotely
skillhub sync pull --namespace team-a --prune
# Validate and upload every local skill for review
skillhub sync push --all --namespace team-a --dry-run
skillhub sync push --all --namespace team-a --submit-review
```
The default workspace is `<cwd>/.agents/skills`. Pull never overwrites local changes unless `--force` is supplied. Remote removals are reported as `orphaned` and are retained unless `--prune` is supplied. Both destructive cases still require explicit flags.
Workspace push is non-overwriting: an existing namespace/slug/version is reported as a conflict, including versions that are still uploaded or pending review. Other skills in the same `--all` run continue processing.
Namespace sync writes `.skillhub/namespace-sync.json` in the workspace and per-skill `.skillhub/metadata.json` files. These files contain the registry coordinate, published version, aggregate fingerprint, and file hashes used by `status` and `diff`.
## 📋 Local Management
### List Installed Skills

View file

@ -1,6 +1,6 @@
{
"name": "@astron-team/skillhub",
"version": "0.1.9",
"version": "0.1.10",
"description": "Manage and install skills for AI coding agents",
"keywords": [
"skillhub",

View file

@ -42,6 +42,30 @@ export interface PublishResponse {
slug: string
version: string
visibility: string
status: string
}
export interface NamespaceSyncItem {
namespace: string
slug: string
version: string
versionId: number
fingerprint: string
updatedAt: string
visibility: string
downloadUrl: string
}
export interface NamespaceSyncResponse {
items: NamespaceSyncItem[]
nextCursor?: string | null
}
export interface SubmitReviewResponse {
skillId: number
versionId: number
action: string
status: string
}
export interface DryRunResponse {
@ -80,6 +104,12 @@ export class SkillHubClient {
return this.getJson(`/skills/${namespace}/${slug}/resolve${params}`)
}
async listNamespaceSkills(namespace: string, cursor?: string, limit = 100): Promise<NamespaceSyncResponse> {
const params = new URLSearchParams({ limit: String(limit) })
if (cursor) params.set('cursor', cursor)
return this.getJson(`/namespaces/${encodeURIComponent(namespace)}/skills?${params}`)
}
async downloadUrl(namespace: string, slug: string, version?: string): Promise<string> {
if (version) {
return `${this.registry}/api/cli/v1/skills/${namespace}/${slug}/versions/${version}/download`
@ -105,10 +135,17 @@ export class SkillHubClient {
return this.deleteJson(`/skills/${namespace}/${slug}`)
}
async publish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<PublishResponse> {
async publish(
namespace: string,
file: Blob,
visibility: string,
fileName = 'skill.zip',
rejectExistingVersion = false
): Promise<PublishResponse> {
const formData = new FormData()
formData.append('file', file, fileName)
formData.append('visibility', visibility)
if (rejectExistingVersion) formData.append('rejectExistingVersion', 'true')
let response: Response
try {
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish`, {
@ -122,10 +159,17 @@ export class SkillHubClient {
return this.handleJsonResponse<PublishResponse>(response)
}
async validatePublish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<DryRunResponse> {
async validatePublish(
namespace: string,
file: Blob,
visibility: string,
fileName = 'skill.zip',
rejectExistingVersion = false
): Promise<DryRunResponse> {
const formData = new FormData()
formData.append('file', file, fileName)
formData.append('visibility', visibility)
if (rejectExistingVersion) formData.append('rejectExistingVersion', 'true')
let response: Response
try {
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish/validate`, {
@ -139,6 +183,28 @@ export class SkillHubClient {
return this.handleJsonResponse<DryRunResponse>(response)
}
async submitReview(
namespace: string,
slug: string,
version: string,
targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY'
): Promise<SubmitReviewResponse> {
let response: Response
try {
response = await this.fetchImpl(
`${this.registry}/api/v1/skills/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/submit-review`,
{
method: 'POST',
headers: { ...this.headers(), 'Content-Type': 'application/json' },
body: JSON.stringify({ version, targetVisibility })
}
)
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
return this.handleJsonResponse<SubmitReviewResponse>(response)
}
private async getJson<T>(path: string): Promise<T> {
let response: Response
try {

View file

@ -43,6 +43,15 @@ export const commands = {
'skillhub install pdf-parser --scope project --agent codex'
]
},
sync: {
summary: 'Synchronize and maintain namespace workspaces',
usage: 'skillhub sync <pull|status|diff|push> [options]',
examples: [
'skillhub sync pull --namespace team-a',
'skillhub sync status --namespace team-a --json',
'skillhub sync push --all --namespace team-a --submit-review'
]
},
list: {
summary: 'List local installs',
usage: 'skillhub list [--agent <profile>] [--dir <path>] [--registry <url>] [--json]',

179
cli/src/commands/sync.ts Normal file
View file

@ -0,0 +1,179 @@
import { join, resolve } from 'node:path'
import { ConfigStore } from '../stores/config-store'
import { CredentialsStore } from '../stores/credentials-store'
import { SkillHubClient } from '../clients/skillhub-client'
import { resolveRegistry, resolveToken } from '../services/registry-service'
import {
discoverSkillDirectories,
inspectNamespaceWorkspace,
pullNamespace,
pushSkills,
type PullResult,
type PushResultItem,
type SyncStatusEntry
} from '../services/sync-service'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
export interface SyncCommonOptions {
namespace?: string
dir?: string
registry?: string
token?: string
json?: boolean
}
export interface SyncPullOptions extends SyncCommonOptions {
check?: boolean
prune?: boolean
force?: boolean
}
export interface SyncPushOptions extends SyncCommonOptions {
all?: boolean
visibility?: string
dryRun?: boolean
submitReview?: boolean
}
export async function syncPullCommand(options: SyncPullOptions): Promise<string> {
const context = await resolveSyncContext(options)
const result = await pullNamespace({
...context,
check: Boolean(options.check),
prune: Boolean(options.prune),
force: Boolean(options.force)
})
const output = renderPullResult(result, Boolean(options.json), Boolean(options.check))
if (result.failures.length > 0) {
process.stdout.write(`${output}\n`)
throw new CliError('namespace sync completed with failures', EXIT.generic, {
namespace: context.namespace,
failures: result.failures
})
}
return output
}
export async function syncStatusCommand(options: SyncCommonOptions): Promise<string> {
const context = await resolveSyncContext(options)
const result = await inspectNamespaceWorkspace(context)
return renderStatusEntries(context.namespace, context.rootDir, result.entries, Boolean(options.json))
}
export async function syncDiffCommand(options: SyncCommonOptions): Promise<string> {
const context = await resolveSyncContext(options)
const result = await inspectNamespaceWorkspace(context)
const changed = result.entries.filter(entry => entry.status !== 'up-to-date')
if (options.json) {
return JSON.stringify({ ok: true, namespace: context.namespace, rootDir: context.rootDir, items: changed })
}
if (changed.length === 0) return `No differences for namespace ${context.namespace}.`
return changed.flatMap(entry => {
const lines = [`${entry.status.padEnd(16)} ${entry.slug}`]
for (const path of entry.changedFiles) lines.push(` ${path}`)
if (entry.reason) lines.push(` ${entry.reason}`)
return lines
}).join('\n')
}
export async function syncPushCommand(path: string | undefined, options: SyncPushOptions): Promise<string> {
const context = await resolveSyncContext(options)
if (path && options.all) {
throw new CliError('path cannot be combined with --all', EXIT.usage)
}
if (!path && !options.all) {
throw new CliError('provide a skill path or pass --all', EXIT.usage)
}
const visibility = normalizeVisibility(options.visibility ?? 'namespace-only')
if (options.submitReview && visibility === 'PRIVATE') {
throw new CliError('--submit-review requires public or namespace-only visibility', EXIT.usage)
}
const paths = options.all
? await discoverSkillDirectories(context.rootDir)
: [resolve(path!)]
if (paths.length === 0) {
throw new CliError(`no skill directories found in ${context.rootDir}`, EXIT.filesystem, { path: context.rootDir })
}
const results = await pushSkills({
client: context.client,
namespace: context.namespace,
paths,
visibility,
dryRun: Boolean(options.dryRun),
submitReview: Boolean(options.submitReview)
})
const output = renderPushResults(context.namespace, results, Boolean(options.json), Boolean(options.dryRun))
if (results.some(item => item.action === 'failed')) {
process.stdout.write(`${output}\n`)
throw new CliError('one or more skills failed to push', EXIT.validation, {
namespace: context.namespace,
failed: results.filter(item => item.action === 'failed')
})
}
return output
}
async function resolveSyncContext(options: SyncCommonOptions): Promise<{
client: SkillHubClient
registry: string
token: string
namespace: string
rootDir: string
}> {
const configStore = new ConfigStore()
const credentialsStore = new CredentialsStore()
const registry = resolveRegistry(options, process.env, await configStore.read())
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
if (!token) {
throw new CliError('authentication required for namespace sync', EXIT.auth, { next: 'run `skillhub login`' })
}
const namespace = options.namespace ?? 'global'
const rootDir = resolve(options.dir ?? join(process.cwd(), '.agents', 'skills'))
return { client: new SkillHubClient(registry, token), registry, token, namespace, rootDir }
}
function renderPullResult(result: PullResult, json: boolean, check: boolean): string {
if (json) {
return JSON.stringify({ ok: result.failures.length === 0, check, ...result })
}
const lines = [
`${check ? 'Checked' : 'Synchronized'} ${result.namespace} in ${result.rootDir}`,
...result.actions.map(item => `${item.action.padEnd(10)} ${item.slug}`),
...result.entries
.filter(entry => !result.actions.some(action => action.slug === entry.slug))
.map(entry => `${entry.status.padEnd(16)} ${entry.slug}`),
...result.failures.map(item => `failed ${item.slug}: ${item.message}`)
]
return lines.join('\n')
}
function renderStatusEntries(namespace: string, rootDir: string, entries: SyncStatusEntry[], json: boolean): string {
if (json) return JSON.stringify({ ok: true, namespace, rootDir, items: entries })
if (entries.length === 0) return `No installable skills found in namespace ${namespace}.`
return entries.map(entry => {
const versions = entry.remoteVersion
? ` local=${entry.localVersion ?? '-'} remote=${entry.remoteVersion}`
: ` local=${entry.localVersion ?? '-'}`
return `${entry.status.padEnd(16)} ${entry.slug}${versions}`
}).join('\n')
}
function renderPushResults(namespace: string, results: PushResultItem[], json: boolean, dryRun: boolean): string {
if (json) return JSON.stringify({ ok: results.every(item => item.action !== 'failed'), namespace, dryRun, items: results })
return results.map(item => {
const coordinate = item.slug ? `${namespace}/${item.slug}${item.version ? `@${item.version}` : ''}` : item.path
const detail = item.errors?.length ? `: ${item.errors.join('; ')}` : ''
return `${item.action.padEnd(16)} ${coordinate}${detail}`
}).join('\n')
}
function normalizeVisibility(value: string): 'PUBLIC' | 'NAMESPACE_ONLY' | 'PRIVATE' {
const normalized = value.toUpperCase().replace(/-/g, '_')
if (normalized !== 'PUBLIC' && normalized !== 'NAMESPACE_ONLY' && normalized !== 'PRIVATE') {
throw new CliError('visibility must be public, namespace-only, or private', EXIT.usage)
}
return normalized
}

View file

@ -1,3 +1,3 @@
// Generated by scripts/generate-pkg-info.ts - do not edit by hand.
export const PKG_NAME = "@astron-team/skillhub"
export const PKG_VERSION = "0.1.9"
export const PKG_VERSION = "0.1.10"

View file

@ -9,9 +9,11 @@ import { logoutCommand } from './commands/logout'
import { publishCommand, type PublishCommandOptions } from './commands/publish'
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 { versionCommand } from './commands/version'
import { whoamiCommand } from './commands/whoami'
import { EXIT } from './shared/constants'
import { CliError } from './shared/errors'
import { renderError } from './shared/output'
@ -245,6 +247,37 @@ cli
return runCommand(() => installCommand(slug, { ...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' })
.option('--dir <path>', 'Skill workspace directory')
.option('--check', 'Show changes without downloading')
.option('--prune', 'Remove managed local skills missing remotely')
.option('--force', 'Overwrite local changes')
.option('--all', 'Push every skill directory in the workspace')
.option('--visibility <v>', 'Visibility (public|namespace-only|private)', { default: 'namespace-only' })
.option('--dry-run', 'Validate without uploading')
.option('--submit-review', 'Submit an uploaded version for review when required')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--json', 'Output JSON')
.action((action: string, path: string | undefined, options: SyncPullOptions & SyncPushOptions) => {
const command = action === 'pull'
? () => syncPullCommand(options)
: action === 'status'
? () => syncStatusCommand(options as SyncCommonOptions)
: action === 'diff'
? () => syncDiffCommand(options as SyncCommonOptions)
: action === 'push'
? () => syncPushCommand(path, options)
: () => Promise.reject(new CliError(
`unknown sync action: ${action}`,
EXIT.usage,
{ next: 'use pull, status, diff, or push' }
))
return runCommand(command, Boolean(options.json))
})
cli
.command('list', 'List local installs')
.option('--agent <profile>', 'Filter by agent (repeatable)')

View file

@ -111,21 +111,31 @@ function findEndOfCentralDirectory(view: DataView): number {
* Returns the archive as a Blob.
* Pure JS implementation using fflate — no system commands needed.
*/
export async function createZip(dirPath: string): Promise<Blob> {
export interface CreateZipOptions {
exclude?: (relativePath: string) => boolean
}
export async function createZip(dirPath: string, options: CreateZipOptions = {}): Promise<Blob> {
const entries: Record<string, Uint8Array> = {}
await collectFiles(dirPath, dirPath, entries)
await collectFiles(dirPath, dirPath, entries, options)
const zipped = zipSync(entries, { level: 6 })
return new Blob([zipped.buffer as ArrayBuffer], { type: 'application/zip' })
}
async function collectFiles(basePath: string, currentPath: string, entries: Record<string, Uint8Array>): Promise<void> {
async function collectFiles(
basePath: string,
currentPath: string,
entries: Record<string, Uint8Array>,
options: CreateZipOptions
): Promise<void> {
const items = await readdir(currentPath, { withFileTypes: true })
for (const item of items) {
const fullPath = join(currentPath, item.name)
const relPath = relative(basePath, fullPath)
const relPath = relative(basePath, fullPath).split('\\').join('/')
if (options.exclude?.(relPath)) continue
if (item.isDirectory()) {
entries[relPath + '/'] = new Uint8Array(0)
await collectFiles(basePath, fullPath, entries)
await collectFiles(basePath, fullPath, entries, options)
} else if (item.isFile()) {
entries[relPath] = new Uint8Array(await readFile(fullPath))
}

View file

@ -7,7 +7,9 @@ import { EXIT } from '../shared/constants'
import { extractZip } from '../platform/archive'
import { readBoundedResponseBody } from '../platform/download'
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
import { snapshotSkillDirectory } from './skill-fingerprint'
import type { AgentCandidate } from '../agents/types'
import type { ResolveResponse } from '../clients/skillhub-client'
export interface InstallOptions {
registry: string
@ -18,6 +20,7 @@ export interface InstallOptions {
targets: AgentCandidate[]
force: boolean
home?: string | undefined
resolved?: ResolveResponse | undefined
}
async function preflightInstallTargets(
@ -55,7 +58,7 @@ 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 client = new SkillHubClient(options.registry, options.token)
const resolved = await client.resolve(options.namespace, options.slug, options.version)
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)
@ -71,6 +74,7 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
await extractZip(buffer, tempDir)
const installedAt = new Date().toISOString()
const snapshot = await snapshotSkillDirectory(tempDir)
const metaDir = join(tempDir, '.skillhub')
await mkdir(metaDir, { recursive: true })
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
@ -78,6 +82,9 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
namespace: options.namespace,
slug: options.slug,
version: resolved.version,
fingerprint: resolved.fingerprint,
files: snapshot.files,
source: 'skillhub',
agent: target.agent,
installedAt
}, null, 2))
@ -89,14 +96,30 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
})
}
if (await pathExists(skillDir) && options.force) {
await store.removeTargetsByInstallDir(skillDir)
await rm(skillDir, { recursive: true, force: true })
}
const backupDir = `${skillDir}.skillhub-backup-${process.pid}-${Date.now()}`
let backupCreated = false
try {
if (await pathExists(skillDir)) {
await rename(skillDir, backupDir)
backupCreated = true
}
await rename(tempDir, skillDir)
movedIntoPlace = true
await store.replaceTargetAtInstallDir(options.registry, options.namespace, options.slug, resolved.version, {
agent: target.agent,
rootDir: target.rootDir,
installDir: skillDir,
installedAt
}, resolved.fingerprint)
if (backupCreated) await rm(backupDir, { recursive: true, force: true }).catch(() => {})
} catch (error) {
if (movedIntoPlace) {
await rm(skillDir, { recursive: true, force: true }).catch(() => {})
movedIntoPlace = false
}
if (backupCreated) await rename(backupDir, skillDir).catch(() => {})
if (!options.force && await pathExists(skillDir)) {
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
path: skillDir,
@ -105,14 +128,6 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
}
throw error
}
movedIntoPlace = true
await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, {
agent: target.agent,
rootDir: target.rootDir,
installDir: skillDir,
installedAt
})
} finally {
if (!movedIntoPlace) {
await rm(tempDir, { recursive: true, force: true }).catch(() => {})

View file

@ -0,0 +1,54 @@
import { createHash } from 'node:crypto'
import { readdir, readFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
export interface SkillSnapshot {
fingerprint: string
files: Record<string, string>
}
export async function snapshotSkillDirectory(skillDir: string): Promise<SkillSnapshot> {
const paths = await listSkillFiles(skillDir)
const files: Record<string, string> = {}
const aggregate = createHash('sha256')
for (const path of paths) {
const content = await readFile(join(skillDir, path))
const fileHash = createHash('sha256').update(content).digest('hex')
files[path] = fileHash
aggregate.update(`${path}:${fileHash}\n`, 'utf8')
}
return { fingerprint: `sha256:${aggregate.digest('hex')}`, files }
}
export function diffSkillFiles(
baseline: Record<string, string> | undefined,
current: Record<string, string>
): string[] {
if (!baseline) return []
const paths = new Set([...Object.keys(baseline), ...Object.keys(current)])
return [...paths]
.filter(path => baseline[path] !== current[path])
.sort((left, right) => left.localeCompare(right))
}
async function listSkillFiles(root: string): Promise<string[]> {
const files: string[] = []
async function walk(current: string): Promise<void> {
const entries = await readdir(current, { withFileTypes: true })
for (const entry of entries) {
if (entry.name === '.skillhub') continue
const absolute = join(current, entry.name)
if (entry.isDirectory()) {
await walk(absolute)
} else if (entry.isFile()) {
files.push(relative(root, absolute).split('\\').join('/'))
}
}
}
await walk(root)
return files.sort((left, right) => left.localeCompare(right))
}

View file

@ -0,0 +1,353 @@
import { readdir, readFile, rename, rm, stat } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { SkillHubClient, type NamespaceSyncItem } from '../clients/skillhub-client'
import { installSkill } from './install-service'
import { diffSkillFiles, snapshotSkillDirectory } from './skill-fingerprint'
import { InventoryStore } from '../stores/inventory-store'
import { SyncWorkspaceStore, type NamespaceSyncState } from '../stores/sync-workspace-store'
import { createZip, isZipFile } from '../platform/archive'
import { pathExists } from '../platform/paths'
export type SyncStatus = 'up-to-date' | 'update-available' | 'local-changed' | 'orphaned' | 'not-installed'
export interface SkillSyncMetadata {
registry: string
namespace: string
slug: string
version: string
fingerprint: string
files?: Record<string, string>
source?: string
}
export interface SyncStatusEntry {
namespace: string
slug: string
status: SyncStatus
localVersion?: string
remoteVersion?: string
changedFiles: string[]
reason?: string
}
export interface PullResult {
namespace: string
rootDir: string
entries: SyncStatusEntry[]
actions: Array<{ slug: string; action: 'installed' | 'updated' | 'pruned' }>
failures: Array<{ slug: string; message: string }>
}
export interface PushResultItem {
path: string
slug?: string
version?: string
status?: string
action: 'validated' | 'uploaded' | 'submitted-review' | 'failed'
errors?: string[]
warnings?: string[]
}
export async function listAllNamespaceSkills(
client: SkillHubClient,
namespace: string
): Promise<NamespaceSyncItem[]> {
const items: NamespaceSyncItem[] = []
let cursor: string | undefined
do {
const page = await client.listNamespaceSkills(namespace, cursor, 100)
items.push(...page.items)
cursor = page.nextCursor ?? undefined
} while (cursor)
return items
}
export async function inspectNamespaceWorkspace(options: {
client: SkillHubClient
registry: string
namespace: string
rootDir: string
remoteItems?: NamespaceSyncItem[]
}): Promise<{ entries: SyncStatusEntry[]; remoteItems: NamespaceSyncItem[] }> {
const remoteItems = options.remoteItems ?? await listAllNamespaceSkills(options.client, options.namespace)
const managed = await scanManagedSkills(options.rootDir, options.registry, options.namespace)
const entries: SyncStatusEntry[] = []
const remoteSlugs = new Set(remoteItems.map(item => item.slug))
for (const remote of remoteItems) {
const skillDir = join(options.rootDir, remote.slug)
const metadata = managed.get(remote.slug)
if (!(await pathExists(skillDir))) {
entries.push(baseEntry(remote, 'not-installed'))
continue
}
if (!metadata) {
entries.push({ ...baseEntry(remote, 'local-changed'), reason: 'directory is not managed by SkillHub' })
continue
}
const snapshot = await snapshotSkillDirectory(skillDir)
if (snapshot.fingerprint !== metadata.fingerprint) {
entries.push({
...baseEntry(remote, 'local-changed'),
localVersion: metadata.version,
changedFiles: diffSkillFiles(metadata.files, snapshot.files)
})
continue
}
if (metadata.fingerprint !== remote.fingerprint) {
entries.push({
...baseEntry(remote, 'update-available'),
localVersion: metadata.version
})
continue
}
entries.push({ ...baseEntry(remote, 'up-to-date'), localVersion: metadata.version })
}
for (const [slug, metadata] of managed) {
if (!remoteSlugs.has(slug)) {
const snapshot = await snapshotSkillDirectory(join(options.rootDir, slug))
const orphan: SyncStatusEntry = {
namespace: options.namespace,
slug,
status: 'orphaned',
localVersion: metadata.version,
changedFiles: diffSkillFiles(metadata.files, snapshot.files)
}
if (snapshot.fingerprint !== metadata.fingerprint) orphan.reason = 'local changes detected'
entries.push(orphan)
}
}
entries.sort((left, right) => left.slug.localeCompare(right.slug))
return { entries, remoteItems }
}
export async function pullNamespace(options: {
client: SkillHubClient
registry: string
token: string
namespace: string
rootDir: string
check: boolean
prune: boolean
force: boolean
}): Promise<PullResult> {
const inspected = await inspectNamespaceWorkspace(options)
const result: PullResult = {
namespace: options.namespace,
rootDir: options.rootDir,
entries: inspected.entries,
actions: [],
failures: []
}
if (options.check) return result
const remoteBySlug = new Map(inspected.remoteItems.map(item => [item.slug, item]))
for (const entry of inspected.entries) {
if (entry.status === 'up-to-date' || entry.status === 'orphaned') continue
if (entry.status === 'local-changed' && !options.force) {
result.failures.push({ slug: entry.slug, message: entry.reason ?? 'local changes detected; pass --force to overwrite' })
continue
}
const remote = remoteBySlug.get(entry.slug)
if (!remote) continue
try {
await installSkill({
registry: options.registry,
token: options.token,
namespace: options.namespace,
slug: remote.slug,
version: remote.version,
resolved: {
namespace: remote.namespace,
slug: remote.slug,
version: remote.version,
versionId: remote.versionId,
fingerprint: remote.fingerprint,
downloadUrl: remote.downloadUrl
},
targets: [{ agent: 'workspace', rootDir: options.rootDir, scope: 'project', source: 'explicit' }],
force: entry.status !== 'not-installed' || options.force
})
result.actions.push({ slug: entry.slug, action: entry.status === 'not-installed' ? 'installed' : 'updated' })
} catch (error) {
result.failures.push({ slug: entry.slug, message: error instanceof Error ? error.message : 'install failed' })
}
}
if (options.prune) {
for (const entry of inspected.entries.filter(item => item.status === 'orphaned')) {
if (entry.reason && !options.force) {
result.failures.push({ slug: entry.slug, message: 'orphan has local changes; pass --force to prune' })
continue
}
const installDir = join(options.rootDir, entry.slug)
const backupDir = `${installDir}.skillhub-prune-${process.pid}-${Date.now()}`
try {
await rename(installDir, backupDir)
try {
await new InventoryStore().removeTargetsByInstallDir(installDir)
} catch (error) {
await rename(backupDir, installDir).catch(() => {})
throw error
}
await rm(backupDir, { recursive: true, force: true }).catch(() => {})
result.actions.push({ slug: entry.slug, action: 'pruned' })
} catch (error) {
result.failures.push({
slug: entry.slug,
message: error instanceof Error ? error.message : 'prune failed'
})
}
}
}
if (result.failures.length === 0) {
const state: NamespaceSyncState = {
registry: options.registry,
namespace: options.namespace,
lastSyncAt: new Date().toISOString(),
skills: Object.fromEntries(inspected.remoteItems.map(item => [item.slug, {
version: item.version,
fingerprint: item.fingerprint
}]))
}
await new SyncWorkspaceStore(options.rootDir).write(state)
}
return result
}
export async function pushSkills(options: {
client: SkillHubClient
namespace: string
paths: string[]
visibility: 'PUBLIC' | 'NAMESPACE_ONLY' | 'PRIVATE'
dryRun: boolean
submitReview: boolean
}): Promise<PushResultItem[]> {
const results: PushResultItem[] = []
for (const path of options.paths) {
try {
const archive = await prepareArchive(path)
const validation = await options.client.validatePublish(
options.namespace, archive.blob, options.visibility, archive.fileName, true)
if (!validation.valid) {
const failed: PushResultItem = {
path,
action: 'failed',
errors: validation.errors,
warnings: validation.warnings
}
if (validation.resolvedSlug) failed.slug = validation.resolvedSlug
if (validation.resolvedVersion) failed.version = validation.resolvedVersion
results.push(failed)
continue
}
if (options.dryRun) {
const validated: PushResultItem = {
path,
action: 'validated',
warnings: validation.warnings
}
if (validation.resolvedSlug) validated.slug = validation.resolvedSlug
if (validation.resolvedVersion) validated.version = validation.resolvedVersion
results.push(validated)
continue
}
const published = await options.client.publish(
options.namespace, archive.blob, options.visibility, archive.fileName, true)
let action: PushResultItem['action'] = 'uploaded'
let status = published.status
if (options.submitReview && published.status === 'PENDING_REVIEW') {
action = 'submitted-review'
} else if (options.submitReview && published.status === 'UPLOADED') {
if (options.visibility === 'PRIVATE') {
throw new Error('--submit-review requires public or namespace-only visibility')
}
const review = await options.client.submitReview(
options.namespace,
published.slug,
published.version,
options.visibility
)
action = 'submitted-review'
status = review.status
}
results.push({ path, slug: published.slug, version: published.version, status, action })
} catch (error) {
results.push({ path, action: 'failed', errors: [error instanceof Error ? error.message : 'push failed'] })
}
}
return results
}
export async function discoverSkillDirectories(rootDir: string): Promise<string[]> {
if (!(await pathExists(rootDir))) return []
const entries = await readdir(rootDir, { withFileTypes: true })
const paths: string[] = []
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === '.skillhub') continue
const path = join(rootDir, entry.name)
if (await pathExists(join(path, 'SKILL.md'))) paths.push(path)
}
return paths.sort((left, right) => left.localeCompare(right))
}
async function prepareArchive(path: string): Promise<{ blob: Blob; fileName: string }> {
const pathStat = await stat(path)
if (pathStat.isDirectory()) {
return {
blob: await createZip(path, { exclude: relativePath => relativePath === '.skillhub' || relativePath.startsWith('.skillhub/') }),
fileName: `${basename(path)}.zip`
}
}
if (pathStat.isFile() && await isZipFile(path)) {
return { blob: new Blob([await readFile(path)], { type: 'application/zip' }), fileName: basename(path) }
}
throw new Error(`path must be a skill directory or zip archive: ${path}`)
}
async function scanManagedSkills(
rootDir: string,
registry: string,
namespace: string
): Promise<Map<string, SkillSyncMetadata>> {
const managed = new Map<string, SkillSyncMetadata>()
if (!(await pathExists(rootDir))) return managed
const entries = await readdir(rootDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === '.skillhub') continue
const metadataPath = join(rootDir, entry.name, '.skillhub', 'metadata.json')
if (!(await pathExists(metadataPath))) continue
try {
const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as SkillSyncMetadata
if (metadata.source === 'skillhub'
&& normalizeRegistry(metadata.registry) === normalizeRegistry(registry)
&& metadata.namespace === namespace
&& metadata.slug === entry.name) {
managed.set(entry.name, metadata)
}
} catch {
// Corrupt metadata is treated as an unmanaged local directory.
}
}
return managed
}
function baseEntry(remote: NamespaceSyncItem, status: SyncStatus): SyncStatusEntry {
return {
namespace: remote.namespace,
slug: remote.slug,
status,
remoteVersion: remote.version,
changedFiles: []
}
}
function normalizeRegistry(registry: string): string {
return registry.replace(/\/+$/, '')
}

View file

@ -14,6 +14,7 @@ export interface InventoryItem {
namespace: string
slug: string
version: string
fingerprint?: string
targets: InventoryTarget[]
}
@ -117,17 +118,19 @@ export class InventoryStore {
namespace: string,
slug: string,
version: string,
target: InventoryTarget
target: InventoryTarget,
fingerprint?: string
): Promise<void> {
const inventory = await this.read()
let item = inventory.items.find(
const existing = inventory.items.find(
i => i.registry === registry && i.namespace === namespace && i.slug === slug
)
if (!item) {
item = { registry, namespace, slug, version, targets: [] }
const item: InventoryItem = existing ?? { registry, namespace, slug, version, targets: [] }
if (!existing) {
inventory.items.push(item)
}
item.version = version
if (fingerprint !== undefined) item.fingerprint = fingerprint
const existingIdx = item.targets.findIndex(t => t.installDir === target.installDir)
if (existingIdx >= 0) {
item.targets[existingIdx] = target
@ -165,4 +168,30 @@ export class InventoryStore {
}
return removed
}
async replaceTargetAtInstallDir(
registry: string,
namespace: string,
slug: string,
version: string,
target: InventoryTarget,
fingerprint?: string
): Promise<void> {
const inventory = await this.read()
for (const item of inventory.items) {
item.targets = item.targets.filter(existing => existing.installDir !== target.installDir)
}
inventory.items = inventory.items.filter(item => item.targets.length > 0)
let item = inventory.items.find(candidate =>
candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug)
if (!item) {
item = { registry, namespace, slug, version, targets: [] }
inventory.items.push(item)
}
item.version = version
if (fingerprint !== undefined) item.fingerprint = fingerprint
item.targets.push(target)
await this.writeAtomic(inventory)
}
}

View file

@ -0,0 +1,39 @@
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { pathExists } from '../platform/paths'
export interface NamespaceSyncStateSkill {
version: string
fingerprint: string
}
export interface NamespaceSyncState {
registry: string
namespace: string
lastSyncAt: string
skills: Record<string, NamespaceSyncStateSkill>
}
export class SyncWorkspaceStore {
readonly path: string
constructor(rootDir: string) {
this.path = join(rootDir, '.skillhub', 'namespace-sync.json')
}
async read(): Promise<NamespaceSyncState | null> {
if (!(await pathExists(this.path))) return null
return JSON.parse(await readFile(this.path, 'utf8')) as NamespaceSyncState
}
async write(state: NamespaceSyncState): Promise<void> {
await mkdir(dirname(this.path), { recursive: true })
const tempPath = `${this.path}.${process.pid}.${Date.now()}.tmp`
try {
await writeFile(tempPath, JSON.stringify(state, null, 2))
await rename(tempPath, this.path)
} finally {
await rm(tempPath, { force: true }).catch(() => {})
}
}
}

View file

@ -102,12 +102,14 @@ export interface CapturedPublish {
fileName: string
/** Visibility string from the multipart form field. */
visibility: string
rejectExistingVersion: boolean
}
export interface CapturedValidate {
namespace: string
fileName: string
visibility: string
rejectExistingVersion: boolean
}
/** Last resolve GET: useful for verifying --version is forwarded as ?version=. */
@ -125,6 +127,13 @@ export interface CapturedDelete {
token: string | null
}
export interface CapturedReview {
namespace: string
slug: string
version: string
targetVisibility: string
}
// ---------------------------------------------------------------------------
// Options
// ---------------------------------------------------------------------------
@ -137,6 +146,7 @@ interface FakeRegistryOptions {
skills?: FakeSkill[]
/** Response to return for publish/validate (dry-run) requests. */
dryRunResponse?: { valid: boolean; errors: string[]; warnings: string[]; resolvedSlug: string | null; resolvedVersion: string | null }
publishStatus?: string
/**
* Per-endpoint failure injection. When set for an endpoint, that endpoint
* ignores all other logic and returns the specified failure (or throws for
@ -150,6 +160,8 @@ interface FakeRegistryOptions {
deleteRemote?: FailureMode
publish?: FailureMode
validate?: FailureMode
namespaceSync?: FailureMode
submitReview?: FailureMode
}
}
@ -190,7 +202,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
resolve: CapturedResolve | null
delete: CapturedDelete | null
validate: CapturedValidate | null
} = { publish: null, resolve: null, delete: null, validate: null }
review: CapturedReview | null
} = { publish: null, resolve: null, delete: null, validate: null, review: null }
// 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
@ -263,6 +276,31 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
})
}
const namespaceSyncMatch = path.match(/^\/api\/cli\/v1\/namespaces\/([^/]+)\/skills$/)
if (namespaceSyncMatch && req.method === 'GET') {
if (options.failures?.namespaceSync) return failureResponse(options.failures.namespaceSync)
const authErr = checkAuth(req)
if (authErr) return authErr
const namespace = namespaceSyncMatch[1]!
const skills = (options.skills ?? []).filter(skill => skill.namespace === namespace)
return Response.json({
code: 0,
data: {
items: skills.map((skill, index) => ({
namespace,
slug: skill.slug,
version: skill.version ?? '1.0.0',
versionId: skill.versionId ?? index + 1,
fingerprint: skill.fingerprint ?? 'deadbeef',
updatedAt: '2026-08-18T00:00:00Z',
visibility: 'NAMESPACE_ONLY',
downloadUrl: buildDownloadUrl(baseUrl, namespace, skill.slug, skill.version ?? '1.0.0')
})),
nextCursor: null
}
})
}
// ------------------------------------------------------------------ //
// Route: /api/cli/v1/skills/:namespace/:slug/...
// ------------------------------------------------------------------ //
@ -377,7 +415,12 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
if (fileField instanceof File) {
fileName = fileField.name || fileName
}
state.validate = { namespace, fileName, visibility }
state.validate = {
namespace,
fileName,
visibility,
rejectExistingVersion: form.get('rejectExistingVersion') === 'true'
}
const dryRunData = options.dryRunResponse ?? {
valid: true,
@ -410,7 +453,12 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
}
// Record for test assertions.
state.publish = { namespace, fileName, visibility }
state.publish = {
namespace,
fileName,
visibility,
rejectExistingVersion: form.get('rejectExistingVersion') === 'true'
}
return Response.json({
code: 0,
@ -418,12 +466,30 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
namespace,
slug: fileName.replace(/\.zip$/, ''),
version: '1.0.0',
visibility
visibility,
status: options.publishStatus ?? 'PENDING_REVIEW'
}
})
})
}
const submitReviewMatch = path.match(/^\/api\/v1\/skills\/([^/]+)\/([^/]+)\/submit-review$/)
if (submitReviewMatch && req.method === 'POST') {
if (options.failures?.submitReview) return failureResponse(options.failures.submitReview)
const authErr = checkAuth(req)
if (authErr) return authErr
return req.json().then(body => {
const request = body as { version: string; targetVisibility: string }
const namespace = submitReviewMatch[1]!
const slug = submitReviewMatch[2]!
state.review = { namespace, slug, version: request.version, targetVisibility: request.targetVisibility }
return Response.json({
code: 0,
data: { skillId: 1, versionId: 1, action: 'SUBMIT_REVIEW', status: 'PENDING_REVIEW' }
})
})
}
// ------------------------------------------------------------------ //
// Fallthrough
// ------------------------------------------------------------------ //

View file

@ -0,0 +1,187 @@
import { createHash } from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { strToU8, zipSync } from 'fflate'
import { describe, expect, test } from 'bun:test'
import { startFakeRegistry, type FakeSkill } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
function makeSkill(body: string): { zipBytes: Uint8Array; fingerprint: string } {
const content = strToU8(body)
const fileHash = createHash('sha256').update(content).digest('hex')
const fingerprint = `sha256:${createHash('sha256').update(`SKILL.md:${fileHash}\n`).digest('hex')}`
return { zipBytes: zipSync({ 'SKILL.md': content }), fingerprint }
}
describe('sync command', () => {
test('pull installs a namespace incrementally and writes workspace metadata', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const first = makeSkill('---\nname: first\ndescription: First\nversion: 1.0.0\n---\n')
const second = makeSkill('---\nname: second\ndescription: Second\nversion: 1.0.0\n---\n')
const registry = await startFakeRegistry({
token: 'token',
skills: [
{ namespace: 'team-a', slug: 'first', ...first },
{ namespace: 'team-a', slug: 'second', ...second }
]
})
try {
const pulled = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pulled.exitCode).toBe(0)
expect(JSON.parse(pulled.stdout).actions).toHaveLength(2)
const metadata = JSON.parse(await readFile(join(skillsDir, 'first', '.skillhub', 'metadata.json'), 'utf8'))
expect(metadata).toMatchObject({
source: 'skillhub', namespace: 'team-a', slug: 'first', fingerprint: first.fingerprint
})
expect(await readFile(join(skillsDir, '.skillhub', 'namespace-sync.json'), 'utf8')).toContain('team-a')
const secondPull = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(secondPull.exitCode).toBe(0)
expect(JSON.parse(secondPull.stdout).actions).toHaveLength(0)
expect(JSON.parse(secondPull.stdout).entries.every((item: { status: string }) => item.status === 'up-to-date')).toBe(true)
} finally {
registry.stop()
}
})
test('status detects local changes and pull does not overwrite without force', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
const registry = await startFakeRegistry({
token: 'token',
skills: [{ namespace: 'team-a', slug: 'demo', ...fixture }]
})
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
await writeFile(join(skillsDir, 'demo', 'SKILL.md'), '# local change\n')
const status = await runCli([
'sync', 'status', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(JSON.parse(status.stdout).items[0].status).toBe('local-changed')
const pull = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pull.exitCode).toBe(1)
expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe('# local change\n')
} finally {
registry.stop()
}
})
test('prune removes only unchanged managed orphan skills', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
const skills: FakeSkill[] = [{ namespace: 'team-a', slug: 'demo', ...fixture }]
const registry = await startFakeRegistry({ token: 'token', skills })
try {
await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token'
], { HOME: env.home }, { cwd: env.cwd })
skills.splice(0, skills.length)
const pruned = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--prune',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pruned.exitCode).toBe(0)
expect(JSON.parse(pruned.stdout).actions).toContainEqual({ slug: 'demo', action: 'pruned' })
expect(await Bun.file(join(skillsDir, 'demo')).exists()).toBe(false)
} finally {
registry.stop()
}
})
test('push all validates packages and submits an uploaded version for review', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
const skillDir = join(skillsDir, 'demo')
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"must":"not be uploaded"}')
const registry = await startFakeRegistry({ token: 'token', publishStatus: 'UPLOADED' })
try {
const pushed = await runCli([
'sync', 'push', '--all', '--namespace', 'team-a', '--dir', skillsDir,
'--submit-review', '--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(pushed.exitCode).toBe(0)
expect(JSON.parse(pushed.stdout).items[0].action).toBe('submitted-review')
expect(registry.received.publish?.visibility).toBe('NAMESPACE_ONLY')
expect(registry.received.publish?.rejectExistingVersion).toBe(true)
expect(registry.received.review).toMatchObject({
namespace: 'team-a', slug: 'demo', version: '1.0.0', targetVisibility: 'NAMESPACE_ONLY'
})
} finally {
registry.stop()
await rm(skillsDir, { recursive: true, force: true })
}
})
test('push dry-run uses strict validation without uploading', async () => {
const env = await createTempHome()
const skillDir = join(env.cwd, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
const registry = await startFakeRegistry({ token: 'token' })
try {
const result = await runCli([
'sync', 'push', skillDir, '--namespace', 'team-a', '--dry-run',
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(0)
expect(JSON.parse(result.stdout).items[0].action).toBe('validated')
expect(registry.received.validate?.rejectExistingVersion).toBe(true)
expect(registry.received.publish).toBeNull()
} finally {
registry.stop()
}
})
test('pull refuses to replace an unmanaged conflicting directory', async () => {
const env = await createTempHome()
const skillsDir = join(env.cwd, 'team-skills')
await mkdir(join(skillsDir, 'demo'), { recursive: true })
await writeFile(join(skillsDir, 'demo', 'local.txt'), 'keep')
const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n')
const registry = await startFakeRegistry({
token: 'token', skills: [{ namespace: 'team-a', slug: 'demo', ...fixture }]
})
try {
const result = await runCli([
'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir,
'--registry', registry.url, '--token', 'token', '--json'
], { HOME: env.home }, { cwd: env.cwd })
expect(result.exitCode).toBe(1)
expect(await readFile(join(skillsDir, 'demo', 'local.txt'), 'utf8')).toBe('keep')
} finally {
registry.stop()
}
})
})

View file

@ -15,7 +15,8 @@ describe('SkillHubClient', () => {
namespace: 'team',
slug: 'custom-skill',
version: '1.0.0',
visibility: 'PRIVATE'
visibility: 'PRIVATE',
status: 'UPLOADED'
}
})
}) as unknown as typeof fetch
@ -188,6 +189,34 @@ describe('SkillHubClient', () => {
expect(capturedUrl).toContain('?version=2.0.0')
})
test('listNamespaceSkills forwards cursor and limit', async () => {
let capturedUrl = ''
const fetchImpl = (async (input: URL | RequestInfo) => {
capturedUrl = String(input)
return Response.json({ data: { items: [], nextCursor: null } })
}) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await client.listNamespaceSkills('team a', 'cursor-value', 50)
expect(capturedUrl).toContain('/api/cli/v1/namespaces/team%20a/skills')
expect(capturedUrl).toContain('cursor=cursor-value')
expect(capturedUrl).toContain('limit=50')
})
test('submitReview posts the lifecycle request with bearer auth', async () => {
const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => {
expect(String(input)).toContain('/api/v1/skills/team-a/demo/submit-review')
expect(init?.headers).toMatchObject({ Authorization: 'Bearer token', 'Content-Type': 'application/json' })
expect(JSON.parse(String(init?.body))).toEqual({ version: '1.0.0', targetVisibility: 'NAMESPACE_ONLY' })
return Response.json({ data: { skillId: 1, versionId: 2, action: 'SUBMIT_REVIEW', status: 'PENDING_REVIEW' } })
}) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.submitReview('team-a', 'demo', '1.0.0', 'NAMESPACE_ONLY'))
.resolves.toMatchObject({ status: 'PENDING_REVIEW' })
})
// --- handleJsonResponse() non-2xx classification ---
test('whoami() preserves public fields and ignores unknown fields on a structured 401', async () => {

View file

@ -228,6 +228,27 @@ describe('installSkill', () => {
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
})
test('force restores the old installation when inventory persistence fails', 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 writeFile(join(skillDir, 'SKILL.md'), '# Old')
const invalidHome = join(rootDir, 'home-is-a-file')
await writeFile(invalidHome, 'not a directory')
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home: invalidHome
})).rejects.toThrow()
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old')
})
test('rejects downloads whose content-length exceeds the package limit', async () => {
globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), {
status: 200,

View file

@ -0,0 +1,23 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
import { createTempHome } from '../../helpers/temp-env'
import { diffSkillFiles, snapshotSkillDirectory } from '../../../src/services/skill-fingerprint'
describe('skill fingerprint', () => {
test('ignores SkillHub metadata and reports changed files', async () => {
const env = await createTempHome()
const skillDir = join(env.cwd, 'demo')
await mkdir(join(skillDir, '.skillhub'), { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# one\n')
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"ignored":true}')
const baseline = await snapshotSkillDirectory(skillDir)
await writeFile(join(skillDir, '.skillhub', 'metadata.json'), '{"ignored":false}')
expect((await snapshotSkillDirectory(skillDir)).fingerprint).toBe(baseline.fingerprint)
await writeFile(join(skillDir, 'SKILL.md'), '# two\n')
const current = await snapshotSkillDirectory(skillDir)
expect(diffSkillFiles(baseline.files, current.files)).toEqual(['SKILL.md'])
})
})