Merge branch 'main' into feat/cli-install-scope

This commit is contained in:
dongmucat 2026-05-19 09:48:49 +08:00
commit c6fa37bb78
32 changed files with 3646 additions and 17 deletions

View file

@ -1,6 +0,0 @@
# https://developers.google.com/gemini-code-assist/docs/customize-gemini-behavior-github
have_fun: false # Just review the code
code_review:
comment_severity_threshold: HIGH # Reduce quantity of comments
pull_request_opened:
summary: false # Don't summarize the PR in a separate comment

View file

@ -44,6 +44,14 @@ export interface PublishResponse {
visibility: string
}
export interface DryRunResponse {
valid: boolean
errors: string[]
warnings: string[]
resolvedSlug: string | null
resolvedVersion: string | null
}
export class SkillHubClient {
constructor(
readonly registry: string,
@ -113,6 +121,23 @@ export class SkillHubClient {
return this.handleJsonResponse<PublishResponse>(response)
}
async validatePublish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<DryRunResponse> {
const formData = new FormData()
formData.append('file', file, fileName)
formData.append('visibility', visibility)
let response: Response
try {
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish/validate`, {
method: 'POST',
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
body: formData
})
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
return this.handleJsonResponse<DryRunResponse>(response)
}
private async getJson<T>(path: string): Promise<T> {
let response: Response
try {
@ -126,9 +151,12 @@ export class SkillHubClient {
}
private async handleJsonResponse<T>(response: Response): Promise<T> {
if (response.status === 401 || response.status === 403) {
if (response.status === 401) {
throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' })
}
if (response.status === 403) {
throw new CliError('access denied — token may lack required scope', EXIT.auth, { registry: this.registry, next: 'regenerate token with required scopes or run `skillhub login`' })
}
if (response.status === 404) {
throw new CliError('resource not found', EXIT.generic, { registry: this.registry })
}

View file

@ -14,6 +14,7 @@ export interface PublishCommandOptions {
registry?: string
token?: string
json?: boolean
dryRun?: boolean
}
export async function publishCommand(path: string, options: PublishCommandOptions): Promise<string> {
@ -40,7 +41,6 @@ export async function publishCommand(path: string, options: PublishCommandOption
let archiveBlob: Blob
let archiveName: string
if (pathStat.isFile()) {
// If input is a file, check if it's already a zip
if (await isZipFile(path)) {
const buffer = await readFile(path)
archiveBlob = new Blob([buffer], { type: 'application/zip' })
@ -49,7 +49,6 @@ 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()) {
// If input is a directory, create zip from it
archiveBlob = await createZip(path)
archiveName = `${basename(path)}.zip`
} else {
@ -57,6 +56,49 @@ export async function publishCommand(path: string, options: PublishCommandOption
}
const client = new SkillHubClient(registry, token)
if (options.dryRun) {
const result = await client.validatePublish(namespace, archiveBlob, toServerVisibility(visibility), archiveName)
if (options.json) {
if (!result.valid) {
process.stdout.write(JSON.stringify(result) + '\n')
throw new CliError('validation failed', EXIT.validation)
}
return JSON.stringify(result)
}
const lines: string[] = []
if (result.valid) {
lines.push('Validation passed')
} else {
lines.push('Validation failed')
}
if (result.resolvedSlug) {
lines.push(` Slug: ${result.resolvedSlug}`)
}
if (result.resolvedVersion) {
lines.push(` Version: ${result.resolvedVersion}`)
}
if (result.errors.length > 0) {
lines.push('Errors:')
for (const error of result.errors) {
lines.push(` - ${error}`)
}
}
if (result.warnings.length > 0) {
lines.push('Warnings:')
for (const warning of result.warnings) {
lines.push(` - ${warning}`)
}
}
if (!result.valid) {
process.stdout.write(lines.join('\n') + '\n')
throw new CliError('validation failed', EXIT.validation)
}
return lines.join('\n')
}
const result = await client.publish(namespace, archiveBlob, toServerVisibility(visibility), archiveName)
const detailUrl = `${registry}/space/${result.namespace}/${encodeURIComponent(result.slug)}`

View file

@ -279,6 +279,7 @@ cli
.command('publish <path>', 'Publish a local skill package')
.option('--namespace <slug>', 'Namespace')
.option('--visibility <v>', 'Visibility (public|namespace-only|private)')
.option('--dry-run', 'Validate without publishing')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--json', 'Output JSON')

View file

@ -8,5 +8,6 @@ export const EXIT = {
auth: 2,
network: 3,
filesystem: 4,
usage: 5
usage: 5,
validation: 6
} as const

View file

@ -22,16 +22,19 @@ export function createFakeRegistry(handlers: Record<string, FakeHandler>) {
/**
* Controls how a specific endpoint behaves when a failure is injected:
* 'auth' => 401 { code: 401, message: 'unauthorized' }
* 'forbidden' => 403 { code: 403, message: 'forbidden' }
* 'not_found' => 404 { code: 404, message: 'not found' }
* 'server_error' => 500 { code: 500, message: 'internal error' }
* 'network' => handler throws, causing fetch() to reject with a TypeError
*/
export type FailureMode = 'auth' | 'not_found' | 'server_error' | 'network'
export type FailureMode = 'auth' | 'forbidden' | 'not_found' | 'server_error' | 'network'
function failureResponse(mode: FailureMode): Response {
switch (mode) {
case 'auth':
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
case 'forbidden':
return Response.json({ code: 403, message: 'forbidden' }, { status: 403 })
case 'not_found':
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
case 'server_error':
@ -91,6 +94,12 @@ export interface CapturedPublish {
visibility: string
}
export interface CapturedValidate {
namespace: string
fileName: string
visibility: string
}
/** Last resolve GET: useful for verifying --version is forwarded as ?version=. */
export interface CapturedResolve {
namespace: string
@ -116,6 +125,8 @@ interface FakeRegistryOptions {
searchItems?: Array<{ namespace: string; slug: string; latestVersion: string; summary: string }>
/** Skills available for resolve / download / delete / publish. */
skills?: FakeSkill[]
/** Response to return for publish/validate (dry-run) requests. */
dryRunResponse?: { valid: boolean; errors: string[]; warnings: string[]; resolvedSlug: string | null; resolvedVersion: string | null }
/**
* Per-endpoint failure injection. When set for an endpoint, that endpoint
* ignores all other logic and returns the specified failure (or throws for
@ -128,6 +139,7 @@ interface FakeRegistryOptions {
download?: FailureMode
deleteRemote?: FailureMode
publish?: FailureMode
validate?: FailureMode
}
}
@ -167,7 +179,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
publish: CapturedPublish | null
resolve: CapturedResolve | null
delete: CapturedDelete | null
} = { publish: null, resolve: null, delete: null }
validate: CapturedValidate | null
} = { publish: null, resolve: null, delete: null, validate: 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
@ -339,6 +352,34 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
})
}
// Validate (dry-run): POST /api/cli/v1/skills/:namespace/publish/validate
const validateMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/publish\/validate$/)
if (validateMatch && req.method === 'POST') {
if (options.failures?.validate) return failureResponse(options.failures.validate)
const authErr = checkAuth(req)
if (authErr) return authErr
const namespace = validateMatch[1]!
return req.formData().then(form => {
const fileField = form.get('file')
const visibility = (form.get('visibility') as string | null) ?? 'PUBLIC'
let fileName = 'skill.zip'
if (fileField instanceof File) {
fileName = fileField.name || fileName
}
state.validate = { namespace, fileName, visibility }
const dryRunData = options.dryRunResponse ?? {
valid: true,
errors: [],
warnings: [],
resolvedSlug: fileName.replace(/\.zip$/, ''),
resolvedVersion: '1.0.0'
}
return Response.json({ code: 0, data: dryRunData })
})
}
// Publish: POST /api/cli/v1/skills/:namespace/publish
const publishMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/publish$/)
if (publishMatch && req.method === 'POST') {

View file

@ -0,0 +1,159 @@
/**
* End-to-end integration coverage for token / registry priority resolution.
*
* The unit test in test/unit/services/registry-service.test.ts pins the
* resolution function in isolation. These tests verify the same priorities
* are wired through the actual CLI subprocess: --flag > SKILLHUB_* env >
* stored config / credentials > built-in default.
*
* Why this matters: a regression in the wiring (e.g. command forgets to
* forward `process.env`) would silently downgrade users to the wrong
* registry / token without surfacing in unit tests.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
let registryB: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop(); registry = undefined
registryB?.stop(); registryB = undefined
})
async function seedCredentials(home: string, registryUrl: string, token: string): Promise<void> {
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(
join(home, '.skillhub', 'credentials.json'),
JSON.stringify({ tokens: { [registryUrl]: token } })
)
}
async function seedConfig(home: string, registryUrl: string): Promise<void> {
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(
join(home, '.skillhub', 'config.json'),
JSON.stringify({ registry: registryUrl })
)
}
// ---------------------------------------------------------------------------
// Token priority: --token > SKILLHUB_TOKEN > stored
// ---------------------------------------------------------------------------
describe('auth resolution — token priority', () => {
test('--token flag wins over SKILLHUB_TOKEN env', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_from_flag',
user: { handle: 'flag-user', displayName: 'Flag' }
})
const result = await runCli(
['whoami', '--registry', registry.url, '--token', 'sk_from_flag'],
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_TOKEN: 'sk_wrong_from_env' }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('flag-user')
})
test('SKILLHUB_TOKEN env wins over stored token', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_from_env',
user: { handle: 'env-user', displayName: 'Env' }
})
await seedCredentials(env.home, registry.url, 'sk_wrong_from_storage')
const result = await runCli(
['whoami', '--registry', registry.url],
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_TOKEN: 'sk_from_env' }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('env-user')
})
test('stored token used when neither --token nor env is set', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_from_storage',
user: { handle: 'storage-user', displayName: 'Storage' }
})
await seedCredentials(env.home, registry.url, 'sk_from_storage')
const result = await runCli(
['whoami', '--registry', registry.url],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('storage-user')
})
})
// ---------------------------------------------------------------------------
// Registry priority: --registry > SKILLHUB_REGISTRY > config.json
// ---------------------------------------------------------------------------
describe('auth resolution — registry priority', () => {
test('--registry flag wins over SKILLHUB_REGISTRY env', async () => {
const env = await createTempHome()
// Each registry only authenticates its own token. The wrong registry
// would 401, so a successful whoami proves the right one was used.
registry = await startFakeRegistry({
token: 'sk_a',
user: { handle: 'a-user', displayName: 'A' }
})
registryB = await startFakeRegistry({
token: 'sk_b',
user: { handle: 'b-user', displayName: 'B' }
})
const result = await runCli(
['whoami', '--registry', registry.url, '--token', 'sk_a'],
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_REGISTRY: registryB.url }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('a-user')
})
test('SKILLHUB_REGISTRY env wins over config.registry', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_env',
user: { handle: 'env-reg', displayName: 'EnvReg' }
})
registryB = await startFakeRegistry({
token: 'sk_config',
user: { handle: 'config-reg', displayName: 'ConfigReg' }
})
await seedConfig(env.home, registryB.url)
const result = await runCli(
['whoami', '--token', 'sk_env'],
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_REGISTRY: registry.url }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('env-reg')
})
test('config.registry used when no --registry / env present', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_config',
user: { handle: 'config-only-user', displayName: 'CfgOnly' }
})
await seedConfig(env.home, registry.url)
await seedCredentials(env.home, registry.url, 'sk_config')
const result = await runCli(
['whoami'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('config-only-user')
})
})

View file

@ -0,0 +1,164 @@
/**
* Concurrency tests for inventory.json bookkeeping.
*
* inventory-store.ts uses an OS-level lock file with retry + stale-lock
* detection. These tests exercise that path through real CLI subprocesses
* (Bun.spawn) running in parallel the same way users hit it when scripts
* fan out installs.
*
* The unit test in test/unit/stores/inventory-store.test.ts pins the
* single-process lock recovery; here we cover the cross-process case.
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop(); registry = undefined
})
function makeSkillZip(): Uint8Array {
return zipSync({ 'SKILL.md': strToU8('# c') })
}
describe('cross-process concurrency on inventory.json', () => {
// KNOWN BUG (documented here, not yet fixed):
// inventory-store.upsertTarget() reads inventory, modifies in memory,
// then writeAtomic() acquires the lock only over the write half. Two
// concurrent installs each read the (empty) inventory, each adds their
// own item, and the second writer overwrites the first — a classic
// lost-update.
//
// When the fix lands (lock spans read+write, or upsertTarget acquires
// the lock first and re-reads), tighten the inventory assertion to
// `expect(slugs).toEqual(['first', 'second'])`.
test('two parallel installs of distinct slugs: filesystem is correct, inventory has at least one (lost-update bug pinned)', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [
{ namespace: 'global', slug: 'first', version: '1.0.0', zipBytes: makeSkillZip() },
{ namespace: 'global', slug: 'second', version: '1.0.0', zipBytes: makeSkillZip() }
]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const dirA = join(env.cwd, 'A')
const dirB = join(env.cwd, 'B')
await mkdir(dirA, { recursive: true })
await mkdir(dirB, { recursive: true })
const [r1, r2] = await Promise.all([
runCli(
['install', 'first', '--dir', dirA, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
),
runCli(
['install', 'second', '--dir', dirB, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
])
// Both subprocess installs report success — neither errored at the
// protocol level even though the inventory bookkeeping race ate one of
// their inventory writes.
expect(r1.exitCode).toBe(0)
expect(r2.exitCode).toBe(0)
// Filesystem is correct: both bundles extracted independently.
expect(await Bun.file(join(dirA, 'first', 'SKILL.md')).exists()).toBe(true)
expect(await Bun.file(join(dirB, 'second', 'SKILL.md')).exists()).toBe(true)
const inv = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string }> }
const slugs = inv.items.map(i => i.slug).sort()
// Today: at least one slug always lands; under the lost-update race
// both may NOT be there. When the lock widens to cover read+write,
// upgrade this to `toEqual(['first', 'second'])`.
expect(slugs.length).toBeGreaterThanOrEqual(1)
const lastSlug = slugs[slugs.length - 1]!
expect(['first', 'second']).toContain(lastSlug)
})
test('two parallel installs of the same slug to the same dir: exactly one wins, one conflicts', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'race', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'race-dir')
await mkdir(installDir, { recursive: true })
const [r1, r2] = await Promise.all([
runCli(
['install', 'race', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
),
runCli(
['install', 'race', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
])
// Two valid outcomes: (a) both succeed because the loser's existence
// check ran BEFORE the winner extracted, OR (b) one succeeds and the
// other reports already-installed (EXIT.filesystem).
// Either way, inventory must end up coherent (single item, single
// target — no duplicates).
const codes = [r1.exitCode, r2.exitCode].sort((a, b) => a - b)
expect(codes[0]).toBe(0) // at least one succeeded
const otherCode = codes[1]!
expect([0, 4]).toContain(otherCode) // other either succeeded or got conflict
const inv = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
const item = inv.items.find(i => i.slug === 'race')
expect(item).toBeDefined()
expect(item!.targets).toHaveLength(1) // no duplicate targets
})
test('install proceeds after a stale lock file from a dead process', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'after-stale', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Plant a stale lock file: PID 1 (init, never the same as our test
// child, and won't match the spawned subprocess's PID), with a very
// old timestamp so the store treats it as stale.
const skillhubDir = join(env.home, '.skillhub')
await mkdir(skillhubDir, { recursive: true })
const lockPath = join(skillhubDir, 'inventory.json.lock')
const ancientTimestamp = Date.now() - 600_000 // 10 minutes ago — past the 30s stale threshold
await writeFile(lockPath, JSON.stringify({ pid: 1, timestamp: ancientTimestamp }))
const installDir = join(env.cwd, 'stale')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'after-stale', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const inv = JSON.parse(
await readFile(join(skillhubDir, 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string }> }
expect(inv.items.find(i => i.slug === 'after-stale')).toBeDefined()
})
})

View file

@ -0,0 +1,509 @@
/**
* Cross-command flow tests.
*
* Per-command tests verify each subcommand in isolation. These cases pin
* behaviors that only emerge when commands chain e.g. "logout then install
* fails with auth" or "install + fs-delete + list reports status=missing".
* Bugs in the boundaries between commands (shared inventory, credentials,
* config) tend to slip through single-command suites.
*/
import { mkdir, rm, writeFile, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
let registryB: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop(); registry = undefined
registryB?.stop(); registryB = undefined
})
function makeSkillZip(): Uint8Array {
return zipSync({ 'SKILL.md': strToU8('# x-cross') })
}
// ---------------------------------------------------------------------------
// 1. Auth lifecycle: login → whoami → logout → whoami
// ---------------------------------------------------------------------------
describe('cross-command — auth lifecycle', () => {
test('login → whoami(success) → logout → whoami(not logged in)', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'cycle-user', displayName: 'Cycle' }
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const w1 = await runCli(['whoami', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
expect(w1.exitCode).toBe(0)
expect(w1.stdout).toContain('cycle-user')
await runCli(['logout', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
const w2 = await runCli(['whoami', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
expect(w2.exitCode).toBe(2)
expect(w2.stderr.toLowerCase()).toContain('not logged in')
})
test('logout-then-install against an auth-required registry fails with EXIT.auth', async () => {
const env = await createTempHome()
// Inject auth failure on resolve so this fake server behaves like a
// production registry that requires a bearer token even on resolve.
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
failures: { resolve: 'auth' }
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['logout', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'after-logout')
await mkdir(installDir, { recursive: true })
// No --token here — credentials were just cleared by logout.
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(2) // EXIT.auth
expect(result.stderr.toLowerCase()).toMatch(/auth|401|unauthorized/)
})
})
// ---------------------------------------------------------------------------
// 2. Full local lifecycle: install → list → remove → list
// ---------------------------------------------------------------------------
describe('cross-command — local lifecycle', () => {
test('install → list → remove --all → list shows empty', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'lifecycle')
await mkdir(installDir, { recursive: true })
await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
const list1 = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(JSON.parse(list1.stdout).items).toHaveLength(1)
await runCli(
['remove', 'pdf-parser', '--all', '--registry', registry.url],
{ HOME: env.home, USERPROFILE: env.home }
)
const list2 = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(JSON.parse(list2.stdout).items).toHaveLength(0)
})
test('install x2 same slug + same dir without --force conflicts; --force succeeds; second install replaces first', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'reinstall-here')
await mkdir(installDir, { recursive: true })
const r1 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r1.exitCode).toBe(0)
const r2 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r2.exitCode).toBe(4) // EXIT.filesystem (already installed)
const r3 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r3.exitCode).toBe(0)
// Inventory has exactly one target, not two duplicates.
const inv = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
const item = inv.items.find(i => i.slug === 'pdf-parser')
expect(item?.targets).toHaveLength(1)
})
test('install A then install B (different slugs, same parent dir) → list shows both', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [
{ namespace: 'global', slug: 'a-skill', version: '1.0.0', zipBytes: makeSkillZip() },
{ namespace: 'global', slug: 'b-skill', version: '1.0.0', zipBytes: makeSkillZip() }
]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'two-skills')
await mkdir(installDir, { recursive: true })
await runCli(
['install', 'a-skill', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
await runCli(
['install', 'b-skill', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
const list = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
const items = JSON.parse(list.stdout).items as Array<{ slug: string }>
expect(items.map(i => i.slug).sort()).toEqual(['a-skill', 'b-skill'])
})
test('remove --all → install same slug again succeeds (no stale inventory state)', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'reuse')
await mkdir(installDir, { recursive: true })
await runCli(['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['remove', 'pdf-parser', '--all', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
// Re-install at the same dir without --force should now succeed, since
// the previous install was removed.
const reinstall = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(reinstall.exitCode).toBe(0)
})
})
// ---------------------------------------------------------------------------
// 3. Filesystem drift between install dir and inventory
// ---------------------------------------------------------------------------
describe('cross-command — filesystem drift', () => {
test('install → fs-delete the install dir → list reports status=missing', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'drift')
await mkdir(installDir, { recursive: true })
await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
// External clobber: delete the install dir behind the CLI's back.
await rm(join(installDir, 'pdf-parser'), { recursive: true, force: true })
const list = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(list.exitCode).toBe(0)
const items = JSON.parse(list.stdout).items as Array<{ slug: string; status: string }>
expect(items[0]?.slug).toBe('pdf-parser')
expect(items[0]?.status).toBe('missing')
})
// After commit a14d89d8 ("refactor(cli): improve doctor command
// semantics and transparency") doctor switched from REPLACE to MERGE
// semantics: it never removes inventory entries, even when the install
// dir on disk is gone. Stale entries are surfaced via `list --json`'s
// status="missing" instead. This test pins that contract.
test('install → fs-delete the install dir → doctor preserves the entry; list reports status=missing', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Install into an agent-shaped dir under cwd so doctor will scan it.
const codexSkills = join(env.cwd, '.codex', 'skills')
await mkdir(codexSkills, { recursive: true })
await runCli(
['install', 'pdf-parser', '--dir', codexSkills, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
// Wipe the install but leave the dir tree shape — metadata gone.
await rm(join(codexSkills, 'pdf-parser'), { recursive: true, force: true })
const doctor = await runCli(['doctor', '--json'], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
expect(doctor.exitCode).toBe(0)
// Inventory still has the entry — doctor preserved it because the
// installDir was NOT in the (now-empty) scan result.
const inv = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string }> }
expect(inv.items.find(i => i.slug === 'pdf-parser')).toBeDefined()
// The user-facing surface for "this is gone on disk" is `list` — it
// reports status="missing" by stat'ing the installDir at read time.
const list = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
const items = JSON.parse(list.stdout).items as Array<{ slug: string; status: string }>
expect(items.find(i => i.slug === 'pdf-parser')?.status).toBe('missing')
})
})
// ---------------------------------------------------------------------------
// 4. doctor idempotence
// ---------------------------------------------------------------------------
describe('cross-command — doctor idempotence', () => {
test('two consecutive doctor runs produce identical inventory (idempotent)', async () => {
const env = await createTempHome()
// Seed one valid metadata file.
const metaDir = join(env.cwd, '.codex', 'skills', 'pdf-parser', '.skillhub')
await mkdir(metaDir, { recursive: true })
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'pdf-parser',
version: '1.0.0',
agent: 'codex',
installedAt: '2026-04-20T12:00:00Z'
}))
await runCli(['doctor'], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
const after1 = await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
await runCli(['doctor'], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
const after2 = await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
expect(after2).toBe(after1)
})
})
// ---------------------------------------------------------------------------
// 5. publish does not change local inventory
// ---------------------------------------------------------------------------
describe('cross-command — publish vs local inventory', () => {
test('publish does NOT add the published skill to local inventory', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', user: { handle: 'u', displayName: 'U' } })
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Build a tiny skill dir to publish.
const dir = join(env.cwd, 'src-skill')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'SKILL.md'), '---\nname: pub-only\ndescription: x\n---\n# pub-only')
const pub = await runCli(['publish', dir, '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
expect(pub.exitCode).toBe(0)
const list = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(list.exitCode).toBe(0)
expect(JSON.parse(list.stdout).items).toHaveLength(0)
})
})
// ---------------------------------------------------------------------------
// 6. Cross-registry isolation in queries
// ---------------------------------------------------------------------------
describe('cross-command — cross-registry isolation', () => {
test('list scoped to registry A does not show items installed from registry B', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_a',
user: { handle: 'a', displayName: 'A' },
skills: [{ namespace: 'global', slug: 'a-only', version: '1.0.0', zipBytes: makeSkillZip() }]
})
registryB = await startFakeRegistry({
token: 'sk_b',
user: { handle: 'b', displayName: 'B' },
skills: [{ namespace: 'global', slug: 'b-only', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['login', '--registry', registryB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
const dirA = join(env.cwd, 'A')
const dirB = join(env.cwd, 'B')
await mkdir(dirA, { recursive: true })
await mkdir(dirB, { recursive: true })
await runCli(['install', 'a-only', '--dir', dirA, '--registry', registry.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['install', 'b-only', '--dir', dirB, '--registry', registryB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
const listA = await runCli(['list', '--registry', registry.url, '--json'], { HOME: env.home, USERPROFILE: env.home })
const slugsA = (JSON.parse(listA.stdout).items as Array<{ slug: string }>).map(i => i.slug)
expect(slugsA).toEqual(['a-only'])
const listB = await runCli(['list', '--registry', registryB.url, '--json'], { HOME: env.home, USERPROFILE: env.home })
const slugsB = (JSON.parse(listB.stdout).items as Array<{ slug: string }>).map(i => i.slug)
expect(slugsB).toEqual(['b-only'])
})
})
// ---------------------------------------------------------------------------
// 7. Auto-detect + list filter integration (project-level)
// ---------------------------------------------------------------------------
describe('cross-command — auto-detect + list', () => {
test('install auto-detects project-level .codex; subsequent list --agent codex shows it', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Pre-create .codex/skills so auto-detect picks codex/project-level.
await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
const inst = await runCli(
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(inst.exitCode).toBe(0)
const list = await runCli(
['list', '--agent', 'codex', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(list.exitCode).toBe(0)
const items = JSON.parse(list.stdout).items as Array<{ slug: string; agent: string }>
expect(items.some(i => i.slug === 'pdf-parser' && i.agent === 'codex')).toBe(true)
})
})
// ---------------------------------------------------------------------------
// 8. Inventory metadata corruption resilience after install
// ---------------------------------------------------------------------------
describe('cross-command — metadata.json drift', () => {
test('install → manually corrupt metadata.json → list reports the row but with sane handling', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'meta-drift')
await mkdir(installDir, { recursive: true })
await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
// Corrupt the installed metadata. inventory.json (the authoritative
// source for `list`) is untouched, so `list` should still work.
await writeFile(
join(installDir, 'pdf-parser', '.skillhub', 'metadata.json'),
'{ truncated'
)
const list = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(list.exitCode).toBe(0)
const items = JSON.parse(list.stdout).items as Array<{ slug: string; status: string }>
expect(items[0]?.slug).toBe('pdf-parser')
// Status remains "ok" because list uses inventory.json, not metadata.json.
expect(items[0]?.status).toBe('ok')
})
})
// ---------------------------------------------------------------------------
// 9. Registry priority chain end-to-end
// ---------------------------------------------------------------------------
describe('cross-command — registry priority end-to-end', () => {
test('search uses --registry over SKILLHUB_REGISTRY env over default', async () => {
registry = await startFakeRegistry({
searchItems: [{ namespace: 'global', slug: 'wins', latestVersion: '1.0.0', summary: 'right one' }]
})
registryB = await startFakeRegistry({
searchItems: [{ namespace: 'global', slug: 'loses', latestVersion: '1.0.0', summary: 'wrong one' }]
})
const result = await runCli(
['search', '', '--registry', registry.url, '--json'],
{ SKILLHUB_REGISTRY: registryB.url }
)
expect(result.exitCode).toBe(0)
const items = JSON.parse(result.stdout).items as Array<{ slug: string }>
expect(items.map(i => i.slug)).toEqual(['wins'])
})
})
// ---------------------------------------------------------------------------
// 10. Help / Version ergonomics across commands
// ---------------------------------------------------------------------------
describe('cross-command — help reaches every documented command', () => {
test('every command listed in help responds to --help with non-empty body', async () => {
const helpResult = await runCli(['help'])
expect(helpResult.exitCode).toBe(0)
const commandNames = [
'help', 'version', 'login', 'logout', 'whoami',
'search', 'install', 'list', 'remove', 'doctor',
'publish', 'update'
]
for (const cmd of commandNames) {
expect(helpResult.stdout).toContain(cmd)
const sub = await runCli([cmd, '--help'])
// --help exits 0 for cac-style CLIs; we don't insist on that, just
// that some informative output makes it to stdout.
expect(sub.stdout.length).toBeGreaterThan(0)
}
})
})

View file

@ -143,6 +143,139 @@ describe('doctor command', () => {
expect(json.inventoryPath).toContain('inventory.json')
})
// -------------------------------------------------------------------------
// P1: same registry+namespace+slug appearing in two agent dirs with
// different versions surfaces in `conflicts` and is excluded from items.
// -------------------------------------------------------------------------
test('doctor reports conflicts when two agent dirs disagree on version', async () => {
const { home, cwd } = await createTempHome()
// Two installs of the same global/pdf-parser with mismatched versions.
await seedSkill(cwd, {
agentDir: '.codex',
slug: 'pdf-parser',
metadata: {
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'pdf-parser',
version: '1.0.0',
agent: 'codex',
installedAt: '2026-04-20T12:00:00Z'
}
})
await seedSkill(cwd, {
agentDir: '.claude',
slug: 'pdf-parser',
metadata: {
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'pdf-parser',
version: '2.0.0',
agent: 'claude-code',
installedAt: '2026-04-21T09:00:00Z'
}
})
const result = await runCli(['doctor', '--json'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as {
ok: boolean
itemsScanned: number
targetsScanned: number
conflicts: Array<{ key: string; versions: string[] }>
}
expect(json.ok).toBe(true)
// Conflicting group is dropped from items, recorded as a conflict.
expect(json.itemsScanned).toBe(0)
expect(json.targetsScanned).toBe(0)
expect(json.conflicts).toHaveLength(1)
expect(json.conflicts[0]?.key).toBe('https://skill.xfyun.cn|global|pdf-parser')
expect(json.conflicts[0]?.versions.sort()).toEqual(['1.0.0', '2.0.0'])
// The persisted inventory must mirror the JSON output: no items.
const inventory = JSON.parse(
await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: unknown[] }
expect(inventory.items).toHaveLength(0)
})
// -------------------------------------------------------------------------
// P1: malformed metadata (unparseable JSON, or missing required fields)
// is reported in `skipped` and does not produce inventory entries. Two
// distinct failure modes are seeded to exercise both branches in
// scanMetadata: JSON.parse throw and the post-parse field check.
// -------------------------------------------------------------------------
test('doctor reports skipped entries for malformed and incomplete metadata', async () => {
const { home, cwd } = await createTempHome()
// (1) Bad JSON: triggers the catch around JSON.parse → "no .skillhub/metadata.json"
// because the catch block is shared with the readFile failure path.
const badJsonDir = join(cwd, '.codex', 'skills', 'broken-json', '.skillhub')
await mkdir(badJsonDir, { recursive: true })
await writeFile(join(badJsonDir, 'metadata.json'), '{ this is not json')
// (2) Incomplete fields: parses fine but is missing `version`.
const incompleteDir = join(cwd, '.claude', 'skills', 'incomplete', '.skillhub')
await mkdir(incompleteDir, { recursive: true })
await writeFile(
join(incompleteDir, 'metadata.json'),
JSON.stringify({
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'incomplete',
// version intentionally missing
agent: 'claude-code',
installedAt: '2026-04-22T10:00:00Z'
})
)
// (3) A valid sibling so we can prove skipped entries don't poison the
// surrounding scan — the valid skill should still land in inventory.
await seedSkill(cwd, {
agentDir: '.codex',
slug: 'good-skill',
metadata: {
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'good-skill',
version: '1.0.0',
agent: 'codex',
installedAt: '2026-04-22T10:00:00Z'
}
})
const result = await runCli(['doctor', '--json'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as {
ok: boolean
itemsScanned: number
skipped: Array<{ path: string; reason: string }>
}
expect(json.ok).toBe(true)
// Both broken entries should be in skipped, the good one in items.
const broken = json.skipped.find(s => s.path.endsWith('broken-json'))
expect(broken).toBeDefined()
const incomplete = json.skipped.find(s => s.path.endsWith('incomplete'))
expect(incomplete).toBeDefined()
expect(incomplete?.reason).toContain('incomplete')
expect(json.itemsScanned).toBe(1) // only good-skill
const inventory = JSON.parse(
await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string }> }
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]?.slug).toBe('good-skill')
})
test('doctor backs up existing inventory.json and reports backupPath', async () => {
const { home, cwd } = await createTempHome()
@ -227,4 +360,104 @@ describe('doctor command', () => {
expect.arrayContaining(['external-skill', 'local-skill'])
)
})
// -------------------------------------------------------------------------
// P1 — Symlink safety: doctor must skip (not follow) symlinked agent /
// skill / .skillhub directories. This protects against malicious or
// accidental symlinks that would otherwise let metadata be slurped from
// arbitrary filesystem locations.
// -------------------------------------------------------------------------
test('doctor skips an agent dir that is a symlink', async () => {
const { home, cwd } = await createTempHome()
const { symlink, mkdir: mkdirP } = await import('node:fs/promises')
// Real target with a valid metadata file off in /tmp.
const realRoot = join(cwd, '__real__', '.codex', 'skills', 'pdf-parser', '.skillhub')
await mkdirP(realRoot, { recursive: true })
await writeFile(join(realRoot, 'metadata.json'), JSON.stringify({
registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser',
version: '1.0.0', agent: 'codex', installedAt: '2026-04-20T12:00:00Z'
}))
// Symlink ./.codex -> __real__/.codex inside cwd. Doctor scans cwd.
await symlink(join(cwd, '__real__', '.codex'), join(cwd, '.codex'))
const result = await runCli(['doctor', '--json'], {
HOME: home, USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as {
itemsScanned: number
skipped: Array<{ path: string; reason: string }>
}
// The symlinked agent dir must NOT contribute an inventory item.
expect(json.itemsScanned).toBe(0)
expect(json.skipped.some(s => s.path.endsWith('.codex') && s.reason.includes('regular directory'))).toBe(true)
})
test('doctor skips a slug dir that is a symlink (real agent dir, symlinked slug)', async () => {
const { home, cwd } = await createTempHome()
const { symlink, mkdir: mkdirP } = await import('node:fs/promises')
// Real metadata under cwd/__real__/pdf-parser/.skillhub/
const realSlug = join(cwd, '__real__', 'pdf-parser')
const realSkillhub = join(realSlug, '.skillhub')
await mkdirP(realSkillhub, { recursive: true })
await writeFile(join(realSkillhub, 'metadata.json'), JSON.stringify({
registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser',
version: '1.0.0', agent: 'codex', installedAt: '2026-04-20T12:00:00Z'
}))
// .codex/skills exists as a real dir, but pdf-parser inside it is a
// symlink to the real metadata location.
const skillsDir = join(cwd, '.codex', 'skills')
await mkdirP(skillsDir, { recursive: true })
await symlink(realSlug, join(skillsDir, 'pdf-parser'))
const result = await runCli(['doctor', '--json'], {
HOME: home, USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as {
itemsScanned: number
skipped: Array<{ path: string; reason: string }>
}
expect(json.itemsScanned).toBe(0)
const symlinked = json.skipped.find(s => s.path.endsWith('pdf-parser'))
expect(symlinked).toBeDefined()
expect(symlinked?.reason).toContain('regular directory')
})
test('doctor skips a .skillhub dir that is a symlink', async () => {
const { home, cwd } = await createTempHome()
const { symlink, mkdir: mkdirP } = await import('node:fs/promises')
// Real metadata reachable through a symlinked .skillhub directory.
const realSkillhub = join(cwd, '__real_meta__')
await mkdirP(realSkillhub, { recursive: true })
await writeFile(join(realSkillhub, 'metadata.json'), JSON.stringify({
registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser',
version: '1.0.0', agent: 'codex', installedAt: '2026-04-20T12:00:00Z'
}))
const slugDir = join(cwd, '.codex', 'skills', 'pdf-parser')
await mkdirP(slugDir, { recursive: true })
await symlink(realSkillhub, join(slugDir, '.skillhub'))
const result = await runCli(['doctor', '--json'], {
HOME: home, USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as {
itemsScanned: number
skipped: Array<{ path: string; reason: string }>
}
expect(json.itemsScanned).toBe(0)
const skipped = json.skipped.find(s => s.path.endsWith('pdf-parser'))
expect(skipped).toBeDefined()
expect(skipped?.reason.toLowerCase()).toMatch(/skillhub|regular directory/)
})
})

View file

@ -1,4 +1,4 @@
import { mkdir, readFile } from 'node:fs/promises'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
@ -273,6 +273,543 @@ describe('install command — P1', () => {
// -------------------------------------------------------------------------
})
// ---------------------------------------------------------------------------
// P0/P1 — Conflict & --force handling
// ---------------------------------------------------------------------------
describe('install command — conflict and --force', () => {
test('re-installing without --force into an existing dir errors with EXIT.filesystem', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-conflict')
await mkdir(installDir, { recursive: true })
const first = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(first.exitCode).toBe(0)
const second = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(second.exitCode).toBe(4) // EXIT.filesystem
expect(second.stderr).toContain('already installed')
expect(second.stderr).toContain('--force')
})
test('--force overwrites stale files left in the install dir', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-force')
await mkdir(installDir, { recursive: true })
await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
// Tamper with SKILL.md to prove the second install replaces it.
const skillFile = join(installDir, 'pdf-parser', 'SKILL.md')
await writeFile(skillFile, '# tampered content')
expect(await readFile(skillFile, 'utf-8')).toBe('# tampered content')
const forced = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(forced.exitCode).toBe(0)
expect(await readFile(skillFile, 'utf-8')).toBe('# test skill')
})
})
// ---------------------------------------------------------------------------
// P1 — Server-side error mapping during install
// ---------------------------------------------------------------------------
describe('install command — server errors', () => {
test('resolve 404 surfaces an error and aborts install (no metadata.json written)', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
failures: { resolve: 'not_found' }
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-resolve-404')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'no-such-slug', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/404|not found/i)
// No metadata file should have been created at the install destination.
const metaPath = join(installDir, 'no-such-slug', '.skillhub', 'metadata.json')
expect(await Bun.file(metaPath).exists()).toBe(false)
})
// Regression test for the production bug observed on 2026-05-06: server
// marks `bundle_ready=true` in DB but the bundle file is missing on disk.
// /resolve returns 200 with a downloadUrl, then /download returns 404. The
// CLI must surface a non-zero exit and a meaningful stderr — not silently
// succeed with an empty install dir.
test('download 404 (resolve OK) is reported as a download failure', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
// resolve succeeds (skill is present in fixture list) but the download
// endpoint is forced to 404 to simulate a missing bundle on storage.
skills: [{ namespace: 'global', slug: 'orphan-bundle', version: '1.0.0', zipBytes: makeSkillZip() }],
failures: { download: 'not_found' }
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-bundle-missing')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'orphan-bundle', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
expect(result.stderr.toLowerCase()).toMatch(/download|404|not found/)
})
// -------------------------------------------------------------------------
// P1 — Path safety: install only writes inside <dir>/<slug>/
// -------------------------------------------------------------------------
test('install only writes inside <dir>/<slug>/ — sibling files in <dir> are untouched', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'shared-dir')
await mkdir(installDir, { recursive: true })
// Place an unrelated file as a sibling of the future <slug>/ subdir.
const sibling = join(installDir, 'IMPORTANT.txt')
await writeFile(sibling, 'this file must survive install')
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
// Sibling file must still exist with original content.
expect(await readFile(sibling, 'utf-8')).toBe('this file must survive install')
// <slug>/ subdir created.
expect(await Bun.file(join(installDir, 'pdf-parser', 'SKILL.md')).exists()).toBe(true)
})
test('--force re-install does not touch sibling files in <dir>', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'shared-force')
await mkdir(installDir, { recursive: true })
await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
// After first install, drop a sibling file; --force should not delete it.
const sibling = join(installDir, 'sibling-after-install.bin')
await writeFile(sibling, 'sentinel')
const r2 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r2.exitCode).toBe(0)
expect(await readFile(sibling, 'utf-8')).toBe('sentinel')
})
test('install --dir creates the <slug> subdir even when <dir> is empty', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'empty-dir')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
expect(await Bun.file(join(installDir, 'pdf-parser', 'SKILL.md')).exists()).toBe(true)
expect(await Bun.file(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
})
test('install --dir pointing at a regular file (not a directory) fails before download', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Create a file at the location --dir would otherwise treat as a directory.
const filePath = join(env.cwd, 'not-a-dir')
await writeFile(filePath, 'i am a file, not a dir')
const result = await runCli(
['install', 'pdf-parser', '--dir', filePath, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
// Original file must still be unchanged (the install should not have
// scribbled on it before bailing).
expect(await readFile(filePath, 'utf-8')).toBe('i am a file, not a dir')
})
test('--json emits a parseable error envelope when install fails', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
failures: { resolve: 'not_found' }
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-json-error')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'no-such-slug', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
// JSON error envelope is printed to stdout (or stderr, depending on the
// command); we accept either to keep the test resilient to that choice.
const candidate = result.stdout || result.stderr
const json = JSON.parse(candidate) as {
ok: boolean
message: string
exitCode: number
}
expect(json.ok).toBe(false)
expect(typeof json.message).toBe('string')
expect(json.exitCode).toBe(result.exitCode)
})
})
// ---------------------------------------------------------------------------
// P1 — Multi-agent and auto-detect targeting
// ---------------------------------------------------------------------------
describe('install command — multi-agent & auto-detect', () => {
test('multi --agent installs the same skill into every specified user-level dir', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const result = await runCli(
[
'install', 'pdf-parser',
'--agent', 'codex',
'--agent', 'claude-code',
'--registry', registry.url,
'--token', 'sk_ok',
'--json'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string }> }
const agents = parsed.installed.map(t => t.agent).sort()
expect(agents).toEqual(['claude-code', 'codex'])
// Both metadata files exist on disk under user-level <home>/.<agent>/skills.
expect(await Bun.file(join(env.home, '.codex', 'skills', 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
expect(await Bun.file(join(env.home, '.claude', 'skills', 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
})
test('duplicate --agent dedupes to one target', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const result = await runCli(
[
'install', 'pdf-parser',
'--agent', 'codex',
'--agent', 'codex',
'--registry', registry.url,
'--token', 'sk_ok',
'--json'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string }> }
expect(parsed.installed).toHaveLength(1)
expect(parsed.installed[0]?.agent).toBe('codex')
})
test('--agent unknown-id surfaces a usage error with hint', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const result = await runCli(
['install', 'pdf-parser', '--agent', 'totally-not-a-real-agent', '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(5) // EXIT.usage
expect(result.stderr.toLowerCase()).toMatch(/unknown agent|--dir/)
})
test('auto-detect: cwd with only .codex/skills present installs project-level there', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Pre-create the codex skills dir so auto-detect picks project scope.
await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
const result = await runCli(
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout) as { installed: Array<{ dir: string; agent: string }> }
expect(parsed.installed[0]?.agent).toBe('codex')
// On macOS env.cwd may resolve through /private/var/... symlinks; assert
// against the structural part of the path instead of an exact prefix.
// Use a regex that accepts both Unix (/) and Windows (\) path separators.
expect(parsed.installed[0]?.dir).toMatch(/[/\\]\.codex[/\\]skills[/\\]pdf-parser/)
expect(parsed.installed[0]?.dir).not.toContain(env.home) // not user-level
})
test('auto-detect: multiple agent dirs in cwd and non-interactive mode fails with hint', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
await mkdir(join(env.cwd, '.claude', 'skills'), { recursive: true })
const result = await runCli(
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(5) // EXIT.usage
expect(result.stderr.toLowerCase()).toMatch(/multiple install targets|--agent|--dir/)
})
test('auto-detect: cwd with no agent dirs falls back to .agents/skills', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const result = await runCli(
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout) as { installed: Array<{ dir: string; agent: string }> }
expect(parsed.installed[0]?.agent).toBe('generic')
expect(parsed.installed[0]?.dir).toContain('.agents')
})
// -------------------------------------------------------------------------
// P1 — Bundle integrity: download body that's not a valid zip
// -------------------------------------------------------------------------
test('download body that is not a valid zip surfaces an extraction error', async () => {
const env = await createTempHome()
// Stand up a custom server that returns valid resolve JSON but plain
// text on download.
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
const baseUrl = `${url.protocol}//${url.host}`
const resolveMatch = url.pathname.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/)
if (resolveMatch && req.method === 'GET') {
return Response.json({
code: 0,
data: {
namespace: resolveMatch[1],
slug: resolveMatch[2],
version: '1.0.0',
versionId: 1,
fingerprint: 'deadbeef',
downloadUrl: `${baseUrl}/api/cli/v1/skills/${resolveMatch[1]}/${resolveMatch[2]}/versions/1.0.0/download`
}
})
}
if (url.pathname.includes('/download')) {
// NOT a zip — plain text.
return new Response('this is plain text, not a zip', {
status: 200, headers: { 'Content-Type': 'application/zip' }
})
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'bad-bundle')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
// No metadata should have been written.
expect(await Bun.file(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(false)
} finally {
server.stop()
}
})
// -------------------------------------------------------------------------
// P2 — Slug edge cases (Unicode, very long)
// -------------------------------------------------------------------------
test('slug with non-ASCII characters round-trips through resolve URL (encoded)', async () => {
const env = await createTempHome()
let resolveUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.includes('/resolve')) {
resolveUrl = req.url
// Return 404 — we only care that the URL was constructed correctly.
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'unicode-slug')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', '中文-技能', '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
// Server returns 404 — install fails. Just confirm CLI didn't crash
// before hitting the server.
expect(result.exitCode).not.toBe(0)
// The slug must appear URL-percent-encoded in the resolve URL.
expect(resolveUrl).toMatch(/%E4%B8%AD%E6%96%87/)
} finally {
server.stop()
}
})
test('slug 200+ characters is forwarded as-is to /resolve (server is authoritative)', async () => {
const env = await createTempHome()
let resolveUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.includes('/resolve')) {
resolveUrl = req.url
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'long-slug')
await mkdir(installDir, { recursive: true })
const longSlug = 'a'.repeat(220)
const result = await runCli(
['install', longSlug, '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
expect(resolveUrl).toContain(longSlug)
} finally {
server.stop()
}
})
})
// ---------------------------------------------------------------------------
// P0 — --scope flag
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,107 @@
/**
* inventory.json resilience.
*
* inventory.json is the local manifest of installed skills. These tests pin
* how the CLI behaves when that file is corrupt or written by overlapping
* operations:
* - list against a corrupt inventory should fail loudly (not silently)
* - install against a corrupt inventory should still complete the
* filesystem extraction even if inventory bookkeeping fails partial
* state surfaces a clear error
* - sequential installs of distinct skills do not corrupt the manifest
*
* The unit test in test/unit/stores/inventory-store.test.ts asserts the
* lock-file recovery path. These cover the user-facing CLI surface.
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop(); registry = undefined
})
function makeSkillZip(): Uint8Array {
return zipSync({ 'SKILL.md': strToU8('# test') })
}
describe('inventory resilience', () => {
test('list exits non-zero when inventory.json is malformed (documents current generic-error UX)', async () => {
const env = await createTempHome()
await mkdir(join(env.home, '.skillhub'), { recursive: true })
await writeFile(join(env.home, '.skillhub', 'inventory.json'), '{ this is not JSON')
const result = await runCli(['list'], { HOME: env.home, USERPROFILE: env.home })
// Contract: CLI must not crash silently or print a stack trace. It
// exits non-zero and emits a short message.
expect(result.exitCode).not.toBe(0)
expect(result.stderr.length).toBeGreaterThan(0)
expect(result.stderr.length).toBeLessThan(2000)
// Documented gap: today's message is the generic "unexpected failure"
// and does not mention `inventory` or `JSON`. When the CLI surfaces a
// more specific message in the future, tighten this assertion.
expect(result.stderr).toContain('Error')
})
test('list --json on a corrupt inventory emits a parseable error envelope (not a stack trace)', async () => {
const env = await createTempHome()
await mkdir(join(env.home, '.skillhub'), { recursive: true })
await writeFile(join(env.home, '.skillhub', 'inventory.json'), '{"items":')
const result = await runCli(['list', '--json'], { HOME: env.home, USERPROFILE: env.home })
expect(result.exitCode).not.toBe(0)
const candidate = result.stdout || result.stderr
expect(candidate.length).toBeLessThan(2000)
// Contract: --json error path is machine-parseable, regardless of the
// (currently generic) human message.
const json = JSON.parse(candidate) as { ok: boolean; message: string; exitCode: number }
expect(json.ok).toBe(false)
expect(typeof json.message).toBe('string')
expect(json.exitCode).toBe(result.exitCode)
})
test('two sequential installs of distinct slugs leave a coherent inventory', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [
{ namespace: 'global', slug: 'one', version: '1.0.0', zipBytes: makeSkillZip() },
{ namespace: 'global', slug: 'two', version: '1.0.0', zipBytes: makeSkillZip() }
]
})
const baseDir = join(env.cwd, 'pool')
await mkdir(baseDir, { recursive: true })
const r1 = await runCli(
['install', 'one', '--dir', baseDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r1.exitCode).toBe(0)
const r2 = await runCli(
['install', 'two', '--dir', baseDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r2.exitCode).toBe(0)
const inventory = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
const slugs = inventory.items.map(i => i.slug).sort()
expect(slugs).toEqual(['one', 'two'])
for (const item of inventory.items) {
expect(item.targets.length).toBeGreaterThan(0)
}
})
})

View file

@ -351,4 +351,112 @@ describe('list command', () => {
expect(json.items).toHaveLength(1)
expect(json.items[0].status).toBe('missing')
})
// -------------------------------------------------------------------------
// P1: Combined filters — --agent + --registry should narrow precisely
// -------------------------------------------------------------------------
test('--agent codex --registry A shows only codex targets from registry A', async () => {
const { home } = await createTempHome()
const codexA = join(home, 'a', 'codex', 'pdf')
const claudeA = join(home, 'a', 'claude', 'pdf')
const codexB = join(home, 'b', 'codex', 'pdf')
for (const d of [codexA, claudeA, codexB]) await mkdir(d, { recursive: true })
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'pdf', version: '1.0.0',
targets: [
{ agent: 'codex', rootDir: join(home, 'a', 'codex'), installDir: codexA, installedAt: INSTALLED_AT },
{ agent: 'claude-code', rootDir: join(home, 'a', 'claude'), installDir: claudeA, installedAt: INSTALLED_AT }
]
},
{
registry: FAKE_REGISTRY_B, namespace: 'global', slug: 'pdf', version: '1.0.0',
targets: [
{ agent: 'codex', rootDir: join(home, 'b', 'codex'), installDir: codexB, installedAt: INSTALLED_AT }
]
}
])
const result = await runCli(
['list', '--agent', 'codex', '--registry', FAKE_REGISTRY_A, '--json'],
{ HOME: home, USERPROFILE: home }
)
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as { items: Array<{ agent: string; installDir: string }> }
expect(json.items).toHaveLength(1)
expect(json.items[0]?.agent).toBe('codex')
expect(json.items[0]?.installDir).toBe(codexA)
})
// -------------------------------------------------------------------------
// P1: --agent + --dir should compose AND, not OR
// -------------------------------------------------------------------------
test('--agent + --dir composes as AND: only items matching both surface', async () => {
const { home } = await createTempHome()
const codexHere = join(home, 'here', 'codex', 'pdf')
const codexElse = join(home, 'else', 'codex', 'pdf')
await mkdir(codexHere, { recursive: true })
await mkdir(codexElse, { recursive: true })
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'pdf', version: '1.0.0',
targets: [
{ agent: 'codex', rootDir: join(home, 'here', 'codex'), installDir: codexHere, installedAt: INSTALLED_AT }
]
},
{
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'pdf-elsewhere', version: '1.0.0',
targets: [
{ agent: 'codex', rootDir: join(home, 'else', 'codex'), installDir: codexElse, installedAt: INSTALLED_AT }
]
}
])
const result = await runCli(
['list', '--registry', FAKE_REGISTRY_A, '--agent', 'codex', '--dir', join(home, 'here'), '--json'],
{ HOME: home, USERPROFILE: home }
)
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as { items: Array<{ slug: string }> }
expect(json.items).toHaveLength(1)
expect(json.items[0]?.slug).toBe('pdf')
})
// -------------------------------------------------------------------------
// P1: SKILLHUB_REGISTRY env scopes list to the env-specified registry
// (registry priority --registry > env > config > default also applies to
// list, not just to network-touching commands).
// -------------------------------------------------------------------------
test('SKILLHUB_REGISTRY env scopes list to that registry, hiding the other', async () => {
const { home } = await createTempHome()
const dirA = join(home, 'a', 'codex', 'one')
const dirB = join(home, 'b', 'codex', 'two')
await mkdir(dirA, { recursive: true })
await mkdir(dirB, { recursive: true })
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'one', version: '1.0.0',
targets: [{ agent: 'codex', rootDir: join(home, 'a', 'codex'), installDir: dirA, installedAt: INSTALLED_AT }]
},
{
registry: FAKE_REGISTRY_B, namespace: 'global', slug: 'two', version: '1.0.0',
targets: [{ agent: 'codex', rootDir: join(home, 'b', 'codex'), installDir: dirB, installedAt: INSTALLED_AT }]
}
])
// No --registry flag — scope comes from SKILLHUB_REGISTRY env.
const result = await runCli(
['list', '--json'],
{ HOME: home, USERPROFILE: home, SKILLHUB_REGISTRY: FAKE_REGISTRY_B }
)
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as { items: Array<{ slug: string }> }
expect(json.items).toHaveLength(1)
expect(json.items[0]?.slug).toBe('two')
})
})

View file

@ -0,0 +1,110 @@
/**
* Multi-registry credential isolation.
*
* credentials.json keys tokens by registry URL. Operations on one registry
* must not leak into another. These tests cover:
* - Logging into A then B preserves both tokens.
* - Logging out of A leaves B's token intact.
* - whoami after logout reflects per-registry session state.
* - Re-login to A overwrites only A's slot.
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
let regA: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
let regB: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
regA?.stop(); regA = undefined
regB?.stop(); regB = undefined
})
async function readCreds(home: string): Promise<{ tokens: Record<string, string> }> {
return JSON.parse(await readFile(join(home, '.skillhub', 'credentials.json'), 'utf-8'))
}
describe('multi-registry credential isolation', () => {
test('login to A then B leaves both tokens in credentials.json', async () => {
const env = await createTempHome()
regA = await startFakeRegistry({ token: 'sk_a', user: { handle: 'a', displayName: 'A' } })
regB = await startFakeRegistry({ token: 'sk_b', user: { handle: 'b', displayName: 'B' } })
await runCli(
['login', '--registry', regA.url, '--token', 'sk_a'],
{ HOME: env.home, USERPROFILE: env.home }
)
await runCli(
['login', '--registry', regB.url, '--token', 'sk_b'],
{ HOME: env.home, USERPROFILE: env.home }
)
const creds = await readCreds(env.home)
expect(creds.tokens[regA.url]).toBe('sk_a')
expect(creds.tokens[regB.url]).toBe('sk_b')
})
test('logout from A removes A token while B token survives', async () => {
const env = await createTempHome()
regA = await startFakeRegistry({ token: 'sk_a', user: { handle: 'a', displayName: 'A' } })
regB = await startFakeRegistry({ token: 'sk_b', user: { handle: 'b', displayName: 'B' } })
await runCli(['login', '--registry', regA.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['login', '--registry', regB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['logout', '--registry', regA.url], { HOME: env.home, USERPROFILE: env.home })
const creds = await readCreds(env.home)
expect(creds.tokens[regA.url]).toBeUndefined()
expect(creds.tokens[regB.url]).toBe('sk_b')
})
test('whoami after logout-A: A reports not-logged-in, B still authenticates', async () => {
const env = await createTempHome()
regA = await startFakeRegistry({ token: 'sk_a', user: { handle: 'a-user', displayName: 'A' } })
regB = await startFakeRegistry({ token: 'sk_b', user: { handle: 'b-user', displayName: 'B' } })
await runCli(['login', '--registry', regA.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['login', '--registry', regB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['logout', '--registry', regA.url], { HOME: env.home, USERPROFILE: env.home })
const whoamiA = await runCli(
['whoami', '--registry', regA.url],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(whoamiA.exitCode).toBe(2) // EXIT.auth
expect(whoamiA.stderr.toLowerCase()).toContain('not logged in')
const whoamiB = await runCli(
['whoami', '--registry', regB.url],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(whoamiB.exitCode).toBe(0)
expect(whoamiB.stdout).toContain('b-user')
})
test('re-login to A overwrites only A entry; B token unchanged', async () => {
const env = await createTempHome()
// Don't pin a token on either registry so any value passes whoami; we
// only care about credentials.json bookkeeping here.
regA = await startFakeRegistry({ user: { handle: 'a', displayName: 'A' } })
regB = await startFakeRegistry({ user: { handle: 'b', displayName: 'B' } })
await runCli(['login', '--registry', regA.url, '--token', 'sk_a_old'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['login', '--registry', regB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
{
const creds = await readCreds(env.home)
expect(creds.tokens[regA.url]).toBe('sk_a_old')
expect(creds.tokens[regB.url]).toBe('sk_b')
}
await runCli(['login', '--registry', regA.url, '--token', 'sk_a_new'], { HOME: env.home, USERPROFILE: env.home })
const creds = await readCreds(env.home)
expect(creds.tokens[regA.url]).toBe('sk_a_new')
expect(creds.tokens[regB.url]).toBe('sk_b')
})
})

View file

@ -237,3 +237,295 @@ describe('publish command — P1', () => {
expect(result.stderr).toContain('registry')
})
})
// ---------------------------------------------------------------------------
// P1 — content shape: directory layout and edge files
// ---------------------------------------------------------------------------
import { mkdir } from 'node:fs/promises'
import { unzipSync, strFromU8 } from 'fflate'
describe('publish command — content shape', () => {
/**
* Spin up a publish endpoint that captures the raw zip body and lets us
* inspect entries server-side. Returns the captured bytes alongside a
* stop() handle so tests can assert what the CLI actually packaged.
*/
async function startCapturingPublishServer() {
let capturedBytes: Uint8Array | null = null
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
const form = await req.formData()
const file = form.get('file')
if (file instanceof File) {
capturedBytes = new Uint8Array(await file.arrayBuffer())
}
return Response.json({
code: 0,
data: { namespace: 'global', slug: 'captured', version: '1.0.0', visibility: 'PUBLIC' }
})
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
return {
url: `http://localhost:${server.port}`,
stop: () => server.stop(),
getCaptured: () => capturedBytes
}
}
test('publishing a directory with subdirs packages every file at its relative path', async () => {
const env = await createTempHome()
const server = await startCapturingPublishServer()
try {
await login(env, server.url)
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-nested-'))
await writeFile(join(dir, 'SKILL.md'), '# nested')
await mkdir(join(dir, 'references'), { recursive: true })
await writeFile(join(dir, 'references', 'a.md'), 'aa')
await mkdir(join(dir, 'scripts'), { recursive: true })
await writeFile(join(dir, 'scripts', 'run.sh'), '#!/bin/sh\necho ok\n')
const result = await runCli(['publish', dir, '--registry', server.url], {
HOME: env.home, USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
const captured = server.getCaptured()
expect(captured).not.toBeNull()
const rawEntries = unzipSync(captured!)
// Normalize all entry keys to use forward slashes for cross-platform compatibility
const entries = Object.fromEntries(
Object.entries(rawEntries).map(([key, value]) => [key.replace(/\\/g, '/'), value])
)
// Filter out directory marker entries (zip records empty entries for
// dirs with a trailing slash); we only care about file entries.
const files = Object.keys(entries).filter(k => !k.endsWith('/')).sort()
expect(files).toEqual([
'SKILL.md',
'references/a.md',
'scripts/run.sh'
])
expect(strFromU8(entries['SKILL.md']!)).toBe('# nested')
expect(strFromU8(entries['references/a.md']!)).toBe('aa')
} finally {
server.stop()
}
})
test('publishing a directory with hidden dotfiles packages them as-is', async () => {
const env = await createTempHome()
const server = await startCapturingPublishServer()
try {
await login(env, server.url)
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-hidden-'))
await writeFile(join(dir, 'SKILL.md'), '# h')
await writeFile(join(dir, '.DS_Store'), 'macos junk')
await writeFile(join(dir, '.editorconfig'), 'root = true\n')
const result = await runCli(['publish', dir, '--registry', server.url], {
HOME: env.home, USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
const captured = server.getCaptured()
expect(captured).not.toBeNull()
const rawEntries = unzipSync(captured!)
// Normalize all entry keys to use forward slashes for cross-platform compatibility
const entries = Object.fromEntries(
Object.entries(rawEntries).map(([key, value]) => [key.replace(/\\/g, '/'), value])
)
// Pin current behavior so future filtering changes are intentional.
expect(Object.keys(entries).sort()).toEqual(['.DS_Store', '.editorconfig', 'SKILL.md'])
} finally {
server.stop()
}
})
test('publishing an empty directory still issues a request and reports the server outcome', async () => {
const env = await createTempHome()
// Fake registry accepts publish unconditionally; CLI is not authoritative
// on SKILL.md presence (server is). We assert only that the CLI does not
// crash client-side and exits with whatever the server returned.
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-empty-'))
const result = await runCli(['publish', dir, '--registry', registry.url], {
HOME: env.home, USERPROFILE: env.home
})
// Today's contract: empty dir → empty zip uploaded → server returns 200.
// If the server adds client-side or server-side validation later this
// assertion will need to flip; that's intentional and traceable.
expect(result.exitCode).toBe(0)
})
test('server 422 with a JSON validation body surfaces a non-zero exit and stderr', async () => {
const env = await createTempHome()
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
return Response.json(
{ code: 422, message: 'validation.token.name.size', errors: ['name exceeds 64 chars'] },
{ status: 422 }
)
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await login(env, url)
const dir = await makeTempDir(['SKILL.md', '# x'])
const result = await runCli(['publish', dir, '--registry', url], {
HOME: env.home, USERPROFILE: env.home
})
expect(result.exitCode).not.toBe(0)
// The server's HTTP status should propagate visibly so a CI log
// shows what happened.
expect(result.stderr).toMatch(/422|registry|validation/i)
} finally {
server.stop()
}
})
// 502/503 are special-cased to EXIT.network because they indicate
// infrastructure-level unavailability (gateway/proxy failure).
test('server 503 Service Unavailable maps to EXIT.network with status in stderr', async () => {
const env = await createTempHome()
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
return Response.json({ code: 503, message: 'service unavailable' }, { status: 503 })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await login(env, url)
const dir = await makeTempDir(['SKILL.md', '# x'])
const result = await runCli(['publish', dir, '--registry', url], {
HOME: env.home, USERPROFILE: env.home
})
expect(result.exitCode).toBe(3) // EXIT.network
expect(result.stderr).toMatch(/503|registry/i)
} finally {
server.stop()
}
})
test('server 401 mid-session (token revoked) maps to EXIT.auth', async () => {
const env = await createTempHome()
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
// Whoami succeeds (login step). Publish then returns 401 as if the
// server revoked the token between the login + publish calls.
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await login(env, url)
const dir = await makeTempDir(['SKILL.md', '# x'])
const result = await runCli(['publish', dir, '--registry', url], {
HOME: env.home, USERPROFILE: env.home
})
expect(result.exitCode).toBe(2) // EXIT.auth
expect(result.stderr.toLowerCase()).toMatch(/auth|401|unauthorized/)
} finally {
server.stop()
}
})
test('publish response missing required fields is handled without crash', async () => {
const env = await createTempHome()
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
// 200 OK but body shape doesn't match the expected schema.
return Response.json({ code: 0, data: { unexpected: true } })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await login(env, url)
const dir = await makeTempDir(['SKILL.md', '# x'])
const result = await runCli(['publish', dir, '--registry', url, '--json'], {
HOME: env.home, USERPROFILE: env.home
})
// Either parses with placeholder values or fails — the contract we
// want is "no crash". Pin: exit 0 means current behavior accepts
// partial responses; flip if/when stricter validation lands.
expect([0, 1, 2, 3]).toContain(result.exitCode)
// Either way, output is bounded — no stack trace dump.
expect((result.stdout + result.stderr).length).toBeLessThan(2000)
} finally {
server.stop()
}
})
test('server 413 Payload Too Large maps to a network-class non-zero exit', async () => {
const env = await createTempHome()
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
return Response.json({ code: 413, message: 'payload too large' }, { status: 413 })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await login(env, url)
const dir = await makeTempDir(['SKILL.md', '# x'])
const result = await runCli(['publish', dir, '--registry', url], {
HOME: env.home, USERPROFILE: env.home
})
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/413|registry/i)
} finally {
server.stop()
}
})
})

View file

@ -0,0 +1,176 @@
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop()
registry = undefined
})
async function login(env: { home: string }, registryUrl: string) {
const result = await runCli(['login', '--registry', registryUrl, '--token', 'sk_ok'], {
HOME: env.home,
USERPROFILE: env.home
})
if (result.exitCode !== 0) {
throw new Error(`login failed: ${result.stderr}`)
}
}
async function makeTempDir(...files: Array<[string, string]>) {
const dir = await mkdtemp(join(tmpdir(), 'skillhub-dryrun-'))
for (const [name, content] of files) {
await writeFile(join(dir, name), content)
}
return dir
}
describe('publish --dry-run', () => {
test('calls validate endpoint and reports success', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: my-skill\ndescription: A test\n---\n# Hello'])
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Validation passed')
expect(registry.received.validate).not.toBeNull()
expect(registry.received.validate!.namespace).toBe('global')
expect(registry.received.publish).toBeNull()
})
test('--dry-run with --json returns structured response on warnings (valid=false)', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
dryRunResponse: {
valid: false,
errors: [],
warnings: ['Disallowed file extension: data.bin'],
resolvedSlug: 'my-skill',
resolvedVersion: '2.0.0'
}
})
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: my-skill\ndescription: test\n---\n'])
const result = await runCli(['publish', dir, '--dry-run', '--json', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(6)
const json = JSON.parse(result.stdout)
expect(json.valid).toBe(false)
expect(json.resolvedSlug).toBe('my-skill')
expect(json.resolvedVersion).toBe('2.0.0')
expect(json.warnings).toContain('Disallowed file extension: data.bin')
})
test('--dry-run reports validation errors', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
dryRunResponse: {
valid: false,
errors: ['Missing required file: SKILL.md at root'],
warnings: [],
resolvedSlug: null,
resolvedVersion: null
}
})
await login(env, registry.url)
const dir = await makeTempDir(['README.md', '# No SKILL.md here'])
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(6)
expect(result.stdout).toContain('Validation failed')
expect(result.stdout).toContain('Missing required file: SKILL.md at root')
})
test('--dry-run does not actually publish', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(registry.received.publish).toBeNull()
})
test('--dry-run respects --namespace', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
await runCli(['publish', dir, '--dry-run', '--namespace', 'myteam', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(registry.received.validate!.namespace).toBe('myteam')
})
test('--dry-run forwards --visibility to server', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
await runCli(['publish', dir, '--dry-run', '--visibility', 'private', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(registry.received.validate!.visibility).toBe('PRIVATE')
})
test('--dry-run requires authentication', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('authentication')
})
test('--dry-run reports scope error on 403', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', failures: { validate: 'forbidden' } })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('scope')
})
})

View file

@ -320,4 +320,111 @@ describe('remove command — local remove (P1)', () => {
const agents = parsed.removed.map((r: { agent: string }) => r.agent).sort()
expect(agents).toEqual(['claude-code', 'cursor'])
})
// -------------------------------------------------------------------------
// P1: --remote --hard against a slug that doesn't exist on the server
// -------------------------------------------------------------------------
test('--remote --hard for a nonexistent slug surfaces server 404 as non-zero exit', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' }
// No skills configured → DELETE returns 404.
})
const result = await runCli(
[
'remove', 'never-published',
'--remote', '--hard',
'--namespace', 'global',
'--registry', registry.url,
'--token', 'sk_ok'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
expect(result.stderr.toLowerCase()).toMatch(/404|not found|registry returned 4/)
})
// -------------------------------------------------------------------------
// P1: --agent on a multi-target inventory leaves OTHER agents' targets
// intact in the inventory file (not just in the JSON envelope).
// -------------------------------------------------------------------------
test('--agent removes one target while leaving others in inventory.json', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
const rootDir = `${env.home}/agents`
const codexDir = `${rootDir}/codex/skills/keep-others`
const claudeDir = `${rootDir}/claude-code/skills/keep-others`
await createInstallDir(codexDir)
await createInstallDir(claudeDir)
await seedInventory(env.home, [
{
registry: registry.url,
namespace: 'global',
slug: 'keep-others',
version: '1.0.0',
targets: [
{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: codexDir, installedAt: '2026-04-20T00:00:00Z' },
{ agent: 'claude-code', rootDir: `${rootDir}/claude-code`, installDir: claudeDir, installedAt: '2026-04-20T00:00:00Z' }
]
}
])
const result = await runCli(
['remove', 'keep-others', '--agent', 'codex', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as {
items: Array<{ slug: string; targets: Array<{ agent: string }> }>
}
const survived = inv.items.find(i => i.slug === 'keep-others')
expect(survived).toBeDefined()
expect(survived!.targets.map(t => t.agent)).toEqual(['claude-code'])
})
// -------------------------------------------------------------------------
// P1: --agent + --namespace together filter precisely so a same-slug skill
// in a different namespace is not collateral damage.
// -------------------------------------------------------------------------
test('--agent + --namespace filters precisely; same slug under different namespace is untouched', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
const rootDir = `${env.home}/agents`
const aDir = `${rootDir}/codex/skills/dup-slug-A`
const bDir = `${rootDir}/codex/skills/dup-slug-B`
await createInstallDir(aDir)
await createInstallDir(bDir)
await seedInventory(env.home, [
{
registry: registry.url, namespace: 'team-a', slug: 'dup-slug-A', version: '1.0.0',
targets: [{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: aDir, installedAt: '2026-04-20T00:00:00Z' }]
},
{
registry: registry.url, namespace: 'team-b', slug: 'dup-slug-B', version: '1.0.0',
targets: [{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: bDir, installedAt: '2026-04-20T00:00:00Z' }]
}
])
// Remove dup-slug-A only — dup-slug-B should survive even though both
// share the codex agent.
const result = await runCli(
['remove', 'dup-slug-A', '--agent', 'codex', '--registry', registry.url],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as {
items: Array<{ slug: string }>
}
const slugs = inv.items.map(i => i.slug).sort()
expect(slugs).toEqual(['dup-slug-B'])
})
})

View file

@ -103,4 +103,235 @@ describe('search command', () => {
expect(result.exitCode).toBe(3) // EXIT.network
expect(result.stderr).toMatch(/registry unreachable|registry returned 5\d\d/)
})
// -------------------------------------------------------------------------
// P2: query containing non-ASCII characters must be URL-encoded in the
// outgoing request. We capture the raw URL via a custom Bun.serve and
// assert the q parameter is the percent-encoded UTF-8 form of "中文测试".
// -------------------------------------------------------------------------
test('non-ASCII query is URL-encoded as UTF-8 percent escapes', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
const registryUrl = `http://localhost:${server.port}`
try {
const result = await runCli(['search', '中文测试', '--registry', registryUrl])
expect(result.exitCode).toBe(0)
// UTF-8 of 中文测试 = E4 B8 AD E6 96 87 E6 B5 8B E8 AF 95
expect(capturedUrl).toContain('q=%E4%B8%AD%E6%96%87%E6%B5%8B%E8%AF%95')
} finally {
server.stop()
}
})
// -------------------------------------------------------------------------
// P2: queries containing special characters (script tags, ampersands,
// equals signs) are percent-encoded so they don't break the query string.
// -------------------------------------------------------------------------
test('special-character query is encoded so the URL stays parseable', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
const registryUrl = `http://localhost:${server.port}`
try {
const result = await runCli(['search', '<script>&q=evil', '--registry', registryUrl])
expect(result.exitCode).toBe(0)
// Re-parse the captured URL and read q via URLSearchParams to confirm
// the original payload survives a round-trip without splitting.
const captured = new URL(capturedUrl)
expect(captured.searchParams.get('q')).toBe('<script>&q=evil')
} finally {
server.stop()
}
})
// -------------------------------------------------------------------------
// P2: --limit 0 still forwards limit=0 to the registry. The CLI does not
// validate boundary values; the server contract decides how to respond.
// We assert the CLI forwards faithfully and exits cleanly when the server
// returns an empty list.
// -------------------------------------------------------------------------
test('--limit 0 forwards limit=0 and renders no skills', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 0 } })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
const registryUrl = `http://localhost:${server.port}`
try {
const result = await runCli(['search', 'pdf', '--limit', '0', '--registry', registryUrl])
expect(result.exitCode).toBe(0)
expect(capturedUrl).toContain('limit=0')
expect(result.stdout).toBe('No skills found.')
} finally {
server.stop()
}
})
// -------------------------------------------------------------------------
// P1: 5xx server response. Per commit a14d89d8 (refactor: unify
// download/handleJsonResponse error mapping) non-2xx responses surface
// as EXIT.generic (1), distinct from EXIT.network (3) which is reserved
// for "couldn't even reach the registry". stderr still carries the HTTP
// status so the user can debug.
// -------------------------------------------------------------------------
test('5xx server error returns EXIT.generic with status in stderr', async () => {
registry = await startFakeRegistry({ failures: { search: 'server_error' } })
const result = await runCli(['search', 'pdf', '--registry', registry.url])
expect(result.exitCode).toBe(1) // EXIT.generic
expect(result.stderr).toMatch(/registry returned 500/)
})
// -------------------------------------------------------------------------
// P2: extra --limit values, including high integers and negative inputs.
// CLI does not validate; server contract decides. We only assert the
// outgoing URL faithfully reflects the user's input.
// -------------------------------------------------------------------------
test('--limit 100 forwards limit=100 in the search URL', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 100 } })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const result = await runCli(['search', 'pdf', '--limit', '100', '--registry', `http://localhost:${server.port}`])
expect(result.exitCode).toBe(0)
expect(capturedUrl).toContain('limit=100')
} finally {
server.stop()
}
})
test('query with + and = characters round-trips faithfully through URL encoding', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const tricky = 'a+b=c&d e'
const result = await runCli(['search', tricky, '--registry', `http://localhost:${server.port}`])
expect(result.exitCode).toBe(0)
const captured = new URL(capturedUrl)
expect(captured.searchParams.get('q')).toBe(tricky)
} finally {
server.stop()
}
})
test('1KB long query is forwarded without truncation', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const longQuery = 'q'.repeat(1024)
const result = await runCli(['search', longQuery, '--registry', `http://localhost:${server.port}`])
expect(result.exitCode).toBe(0)
const captured = new URL(capturedUrl)
expect(captured.searchParams.get('q')).toBe(longQuery)
} finally {
server.stop()
}
})
test('literal % in query is encoded so it survives a round-trip without being mistaken for an escape', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
// A literal '%' must be escaped as %25 so the server doesn't read it
// as the start of an existing escape sequence.
const tricky = '50% off'
const result = await runCli(['search', tricky, '--registry', `http://localhost:${server.port}`])
expect(result.exitCode).toBe(0)
expect(capturedUrl).toContain('%25')
const captured = new URL(capturedUrl)
expect(captured.searchParams.get('q')).toBe(tricky)
} finally {
server.stop()
}
})
test('query with multiple shell metacharacters survives both shell quoting and URL encoding', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const tricky = "$VAR `cmd` 'quote' \"dq\""
const result = await runCli(['search', tricky, '--registry', `http://localhost:${server.port}`])
expect(result.exitCode).toBe(0)
const captured = new URL(capturedUrl)
expect(captured.searchParams.get('q')).toBe(tricky)
} finally {
server.stop()
}
})
})

View file

@ -0,0 +1,162 @@
/**
* CLI skill version-upgrade flow.
*
* These tests cover scenarios that span multiple `install` invocations
* against a registry whose state changes between runs i.e. the user-facing
* "upgrade an installed skill" workflow. They complement the per-command
* tests in install-command.test.ts which use a single static registry.
*
* Coverage focus (per test-case-design-skill methodology):
* - VU1 (state-transition): full v1 v2 upgrade lifecycle. Asserts that
* metadata.json, inventory.json AND the on-disk bundle all reflect v2
* after `install --force`, with no orphaned v1 entry left behind.
* - VU2 (equivalence-class on `--version`): pinning to a specific version
* forwards `?version=` to /resolve so the registry can serve the
* intended bundle.
*/
import { mkdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
import { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
function makeSkillZipWithBody(body: string): Uint8Array {
return zipSync({ 'SKILL.md': strToU8(body) })
}
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop()
registry = undefined
})
describe('version upgrade flow', () => {
// -------------------------------------------------------------------------
// VU1 — full upgrade lifecycle:
// 1. Registry serves pdf-parser@1.0.0 → install → metadata=v1, content=v1
// 2. Stop registry, start a new one serving pdf-parser@2.0.0
// 3. Install --force using the new registry URL
// 4. metadata.json, inventory.json AND on-disk SKILL.md all reflect v2
// -------------------------------------------------------------------------
test('VU1 install v1 then upgrade to v2 with --force replaces metadata, inventory, and content', async () => {
const env = await createTempHome()
// --- Stage 1: install v1 ----------------------------------------------
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{
namespace: 'global',
slug: 'pdf-parser',
version: '1.0.0',
zipBytes: makeSkillZipWithBody('# pdf-parser v1\n\nVersion one body.')
}]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-upgrade')
await mkdir(installDir, { recursive: true })
const r1 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r1.exitCode).toBe(0)
const skillFile = join(installDir, 'pdf-parser', 'SKILL.md')
const metaPath = join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')
const inventoryPath = join(env.home, '.skillhub', 'inventory.json')
{
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.version).toBe('1.0.0')
const body = await readFile(skillFile, 'utf-8')
expect(body).toContain('Version one body.')
}
// --- Stage 2: swap registry to v2 -------------------------------------
registry.stop()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{
namespace: 'global',
slug: 'pdf-parser',
version: '2.0.0',
zipBytes: makeSkillZipWithBody('# pdf-parser v2\n\nVersion two body.')
}]
})
// Re-login against the new registry (URL changed, so credentials are
// keyed differently).
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const r2 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r2.exitCode).toBe(0)
// --- Stage 3: assert end state ----------------------------------------
const finalMeta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(finalMeta.version).toBe('2.0.0')
expect(finalMeta.registry).toBe(registry.url)
const finalBody = await readFile(skillFile, 'utf-8')
expect(finalBody).toContain('Version two body.')
expect(finalBody).not.toContain('Version one body.')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) as {
items: Array<{ namespace: string; slug: string; version: string; targets: Array<{ installDir: string }> }>
}
const matching = inventory.items.filter(i => i.namespace === 'global' && i.slug === 'pdf-parser')
expect(matching).toHaveLength(1) // no duplicate v1 entry
expect(matching[0]?.version).toBe('2.0.0')
})
// -------------------------------------------------------------------------
// VU2 — version pinning:
// `install --version=X` must forward ?version=X to /resolve so the
// registry can serve the requested bundle. The fake registry captures
// the resolve query for assertion.
// -------------------------------------------------------------------------
test('VU2 --version pins resolve to the requested version string', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{
namespace: 'global',
slug: 'pdf-parser',
version: '1.5.0',
zipBytes: makeSkillZipWithBody('# pinned')
}]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-pin')
await mkdir(installDir, { recursive: true })
const result = await runCli(
[
'install', 'pdf-parser',
'--version', '1.5.0',
'--dir', installDir,
'--registry', registry.url,
'--token', 'sk_ok'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
expect(registry.received.resolve?.namespace).toBe('global')
expect(registry.received.resolve?.slug).toBe('pdf-parser')
expect(registry.received.resolve?.version).toBe('1.5.0')
const meta = JSON.parse(await readFile(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json'), 'utf-8'))
expect(meta.version).toBe('1.5.0')
})
})

View file

@ -98,4 +98,54 @@ describe('whoami command', () => {
expect(result.exitCode).toBe(2)
expect(result.stderr.toLowerCase()).toContain('authentication failed')
})
// ---------------------------------------------------------------------------
// P1 — Stored-token revocation: token persists in credentials.json but
// server now rejects it. whoami must surface the auth failure on first
// call, not silently use a stale principal.
// ---------------------------------------------------------------------------
test('stored token that the server now rejects surfaces EXIT.auth on whoami', async () => {
// Configure a registry that requires sk_new but seed sk_old in credentials
// — simulates a token that was valid at login time but has since been
// revoked or rotated server-side.
registry = await startFakeRegistry({
token: 'sk_new',
user: { handle: 'should-not-see', displayName: 'X' }
})
const { home } = await createTempHome()
const env = { HOME: home, USERPROFILE: home }
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(
join(home, '.skillhub', 'credentials.json'),
JSON.stringify({ tokens: { [registry.url]: 'sk_old_revoked' } })
)
const result = await runCli(['whoami', '--registry', registry.url], env)
expect(result.exitCode).toBe(2)
expect(result.stderr.toLowerCase()).toMatch(/auth|401|unauthorized/)
})
// ---------------------------------------------------------------------------
// P1 — Token priority --token > SKILLHUB_TOKEN > stored, end-to-end via
// whoami. Cross-checks the auth-resolution suite by verifying the wired
// contract on this specific command.
// ---------------------------------------------------------------------------
test('--token wins over SKILLHUB_TOKEN env on whoami', async () => {
registry = await startFakeRegistry({
token: 'sk_winner',
user: { handle: 'winner', displayName: 'W' }
})
const { home } = await createTempHome()
const env = { HOME: home, USERPROFILE: home, SKILLHUB_TOKEN: 'sk_loser_env' }
const result = await runCli(
['whoami', '--token', 'sk_winner', '--registry', registry.url],
env
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('winner')
})
})

View file

@ -91,4 +91,173 @@ describe('updateCommand branches', () => {
expect(caught).toBeInstanceOf(CliError)
expect((caught as CliError).message).toContain('install command failed')
})
// -------------------------------------------------------------------------
// bun-global success branch — symmetric to npm-global success but routes
// through `bun add -g` rather than `npm install -g`. Command-level output
// still uses the same "Updated skillhub X -> Y" copy, but the run dep
// receives a different argv. We assert both: (a) the command captured the
// bun argv on the dep and (b) the output formatting matches.
// -------------------------------------------------------------------------
test('updated success branch — bun-global with run() success captures bun argv (human + json)', async () => {
const calls: string[][] = []
const deps: Required<UpdateCommandDeps> = {
latestVersion: async () => '99.0.0',
detectInstallMode: () => 'bun-global',
run: async (cmd) => {
calls.push([...cmd])
return { success: true, output: '' }
}
}
const human = await updateCommand({}, deps)
expect(human).toContain(`Updated skillhub ${CLI_VERSION} -> 99.0.0`)
const json = await updateCommand({ json: true }, deps)
expect(JSON.parse(json)).toEqual({ ok: true, updated: true, from: CLI_VERSION, to: '99.0.0' })
// Both invocations must have routed through `bun add -g` — never `npm`.
expect(calls).toHaveLength(2)
for (const cmd of calls) {
expect(cmd[0]).toBe('bun')
expect(cmd[1]).toBe('add')
expect(cmd[2]).toBe('-g')
expect(cmd[3]).toMatch(/@latest$/)
}
})
// -------------------------------------------------------------------------
// unknown install mode — falls through to the manual-upgrade hint. The
// command must NOT invoke run() (we'd be guessing the package manager),
// and the human output must spell out both npm and bun fallbacks so the
// user can pick whichever is on their system.
// -------------------------------------------------------------------------
test('available-not-updated branch — unknown mode emits manual upgrade hint and never calls run()', async () => {
let runCalls = 0
const deps: Required<UpdateCommandDeps> = {
latestVersion: async () => '99.0.0',
detectInstallMode: () => 'unknown',
run: async () => {
runCalls += 1
return { success: true, output: '' }
}
}
const human = await updateCommand({}, deps)
expect(human).toContain(`Update available: ${CLI_VERSION} -> 99.0.0`)
expect(human).toContain('npm install -g')
expect(human).toContain('bun add -g')
const json = await updateCommand({ json: true }, deps)
const parsed = JSON.parse(json)
expect(parsed.ok).toBe(true)
expect(parsed.available).toBe(true)
expect(typeof parsed.next).toBe('string')
expect(parsed.next).toMatch(/npm install -g.*bun add -g|bun add -g.*npm install -g/)
// Neither invocation should have attempted to spawn an installer.
expect(runCalls).toBe(0)
})
// -------------------------------------------------------------------------
// checkOnly short-circuit — even in install modes that WOULD execute an
// upgrade (npm-global, bun-global), passing { check: true } must short
// circuit the service before it reaches `run()`. Output is the bare
// "Update available" line with no `next` hint (next is only populated
// for npx / unknown branches, not on checkOnly's early return).
// -------------------------------------------------------------------------
test('checkOnly with npm-global short-circuits and never calls run()', async () => {
let runCalls = 0
const deps: Required<UpdateCommandDeps> = {
latestVersion: async () => '99.0.0',
detectInstallMode: () => 'npm-global',
run: async () => {
runCalls += 1
return { success: true, output: '' }
}
}
const human = await updateCommand({ check: true }, deps)
expect(human.trim()).toBe(`Update available: ${CLI_VERSION} -> 99.0.0`)
const json = await updateCommand({ check: true, json: true }, deps)
const parsed = JSON.parse(json)
expect(parsed.ok).toBe(true)
expect(parsed.available).toBe(true)
// No `next` hint on the checkOnly path.
expect(parsed.next).toBeUndefined()
expect(runCalls).toBe(0)
})
// -------------------------------------------------------------------------
// P1 — Version comparison edge cases. semver.gt drives the available
// gate; these tests pin the boundary where "no upgrade" must hold.
// -------------------------------------------------------------------------
test('latest version equal to current is reported as up-to-date', async () => {
const deps = buildDeps({
latest: CLI_VERSION,
mode: 'npm-global',
runResult: { success: true, output: '' }
})
const human = await updateCommand({}, deps)
expect(human).toContain('Already up to date')
})
test('latest version older than current is treated as up-to-date (no downgrade)', async () => {
const deps = buildDeps({
latest: '0.0.1',
mode: 'npm-global',
runResult: { success: true, output: '' }
})
const human = await updateCommand({}, deps)
expect(human).toContain('Already up to date')
// Make sure we didn't accidentally invoke npm with an older version.
const jsonOut = await updateCommand({ json: true }, deps)
expect(JSON.parse(jsonOut).updated).toBeUndefined()
})
test('checkOnly + already up-to-date emits a non-available envelope', async () => {
const deps = buildDeps({
latest: CLI_VERSION,
mode: 'unknown',
runResult: { success: true, output: '' }
})
const json = await updateCommand({ check: true, json: true }, deps)
const parsed = JSON.parse(json)
expect(parsed).toMatchObject({ ok: true, upToDate: true, version: CLI_VERSION })
})
test('bun-global run() failure surfaces the failure output as a CliError message', async () => {
const deps = buildDeps({
latest: '99.0.0',
mode: 'bun-global',
runResult: { success: false, output: 'bun add: permission denied' }
})
let caught: unknown
try {
await updateCommand({}, deps)
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(CliError)
expect((caught as CliError).message).toContain('bun add')
})
test('checkOnly with npx emits Update-available envelope WITHOUT a next hint (next is mode-specific)', async () => {
// Per service code, npx's `next` is added only when checkOnly is false.
// checkOnly returns earlier and never enters the mode switch.
const deps = buildDeps({
latest: '99.0.0',
mode: 'npx',
runResult: { success: true, output: '' }
})
const json = await updateCommand({ check: true, json: true }, deps)
const parsed = JSON.parse(json)
expect(parsed.ok).toBe(true)
expect(parsed.available).toBe(true)
expect(parsed.next).toBeUndefined()
expect(parsed.from).toBe(CLI_VERSION)
expect(parsed.to).toBe('99.0.0')
})
})

View file

@ -95,6 +95,30 @@ public class CliSkillController extends BaseApiController {
namespace, slug, principal.userId(), AuditRequestContext.from(request)));
}
@PostMapping(value = "/{namespace}/publish/validate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RateLimit(category = "publish", authenticated = 10, anonymous = 0)
public ApiResponse<CliDryRunResponse> validatePublish(
@PathVariable String namespace,
@RequestPart("file") MultipartFile file,
@RequestPart(value = "visibility", required = false) String visibility,
@AuthenticationPrincipal PlatformPrincipal principal) throws IOException {
List<PackageEntry> entries;
try {
entries = archiveExtractor.extract(file);
} catch (IllegalArgumentException e) {
throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage());
}
SkillVisibility resolvedVisibility;
try {
resolvedVisibility = SkillVisibility.valueOf((visibility != null ? visibility : "PUBLIC").toUpperCase());
} catch (IllegalArgumentException e) {
throw new DomainBadRequestException("error.skill.publish.visibility.invalid", visibility);
}
var result = cliSkillAppService.validatePublish(
namespace, entries, principal.userId(), resolvedVisibility, principal.platformRoles());
return ok("response.success.read", result);
}
@PostMapping(value = "/{namespace}/publish", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RateLimit(category = "publish", authenticated = 10, anonymous = 0)
public ApiResponse<CliPublishResponse> publish(

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto.cli;
import java.util.List;
public record CliDryRunResponse(
boolean valid,
List<String> errors,
List<String> warnings,
String resolvedSlug,
String resolvedVersion
) {}

View file

@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.dto.cli.CliDeleteResponse;
import com.iflytek.skillhub.dto.cli.CliDryRunResponse;
import com.iflytek.skillhub.dto.cli.CliPublishResponse;
import com.iflytek.skillhub.dto.cli.CliResolveResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
@ -119,6 +120,18 @@ public class CliSkillAppService {
);
}
public CliDryRunResponse validatePublish(String namespace, List<PackageEntry> entries, String publisherId, SkillVisibility visibility, Set<String> platformRoles) {
SkillPublishService.DryRunResult result = skillPublishService.validateOnly(
namespace, entries, publisherId, visibility, platformRoles);
return new CliDryRunResponse(
result.valid(),
result.errors(),
result.warnings(),
result.resolvedSlug(),
result.resolvedVersion()
);
}
public CliPublishResponse publish(String namespace, List<PackageEntry> entries, String publisherId, SkillVisibility visibility, Set<String> platformRoles) {
SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
namespace, entries, publisherId, visibility, platformRoles, false

View file

@ -0,0 +1,124 @@
package com.iflytek.skillhub.controller.cli;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.dto.cli.CliDryRunResponse;
import com.iflytek.skillhub.service.cli.CliSkillAppService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class CliDryRunValidateTest {
@Autowired MockMvc mockMvc;
@MockBean CliSkillAppService cliSkillAppService;
private UsernamePasswordAuthenticationToken auth() {
PlatformPrincipal principal = new PlatformPrincipal(
"user-1", "tester", "t@example.com", "", "api_token", Set.of("USER"));
return new UsernamePasswordAuthenticationToken(
principal, null, List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("SCOPE_skill:publish")));
}
@Test
void validatePublish_returnsValidResult() throws Exception {
given(cliSkillAppService.validatePublish(
eq("global"), any(), eq("user-1"), eq(SkillVisibility.PUBLIC), eq(Set.of("USER"))))
.willReturn(new CliDryRunResponse(
true, List.of(), List.of(),
"my-skill", "1.0.0"));
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
.file(file)
.with(authentication(auth())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.valid").value(true))
.andExpect(jsonPath("$.data.resolvedSlug").value("my-skill"))
.andExpect(jsonPath("$.data.resolvedVersion").value("1.0.0"));
}
@Test
void validatePublish_returnsInvalidResult() throws Exception {
given(cliSkillAppService.validatePublish(
eq("global"), any(), eq("user-1"), eq(SkillVisibility.PUBLIC), eq(Set.of("USER"))))
.willReturn(new CliDryRunResponse(
false, List.of("Missing required file: SKILL.md at root"), List.of(),
null, null));
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
.file(file)
.with(authentication(auth())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.valid").value(false))
.andExpect(jsonPath("$.data.errors[0]").value("Missing required file: SKILL.md at root"))
.andExpect(jsonPath("$.data.resolvedSlug").doesNotExist());
}
@Test
void validatePublish_acceptsCustomVisibility() throws Exception {
given(cliSkillAppService.validatePublish(
eq("global"), any(), eq("user-1"), eq(SkillVisibility.PRIVATE), eq(Set.of("USER"))))
.willReturn(new CliDryRunResponse(
true, List.of(), List.of(), "my-skill", "1.0.0"));
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
.file(file)
.file(new MockMultipartFile("visibility", "", "text/plain", "PRIVATE".getBytes()))
.with(authentication(auth())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.valid").value(true));
}
@Test
void validatePublish_rejectsInvalidVisibility() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
.file(file)
.file(new MockMultipartFile("visibility", "", "text/plain", "BOGUS".getBytes()))
.with(authentication(auth())))
.andExpect(status().isBadRequest());
}
@Test
void validatePublish_requiresAuthentication() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
.file(file))
.andExpect(status().isUnauthorized());
}
}

View file

@ -103,7 +103,9 @@ class CliSkillControllerTest {
PlatformPrincipal principal = new PlatformPrincipal(
"user-1", "tester", "t@example.com", "", "api_token", Set.of("USER"));
var auth = new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
principal, null, List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("SCOPE_skill:delete")));
given(cliSkillAppService.deleteRemote(
org.mockito.ArgumentMatchers.eq("global"),

View file

@ -82,7 +82,8 @@ public class RouteSecurityPolicyRegistry {
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/*/*/download"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/*/*/versions/*/download"),
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/cli/v1/skills/*/*"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/cli/v1/skills/*/publish")
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/cli/v1/skills/*/publish"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/cli/v1/skills/*/publish/validate")
);
private static final List<ApiTokenPolicy> API_TOKEN_POLICIES = List.of(
@ -121,7 +122,8 @@ public class RouteSecurityPolicyRegistry {
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/*/*/download"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/*/*/versions/*/download"),
ApiTokenPolicy.require(HttpMethod.DELETE, "/api/cli/v1/skills/*/*", "skill:delete"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/cli/v1/skills/*/publish", "skill:publish")
ApiTokenPolicy.require(HttpMethod.POST, "/api/cli/v1/skills/*/publish", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/cli/v1/skills/*/publish/validate", "skill:publish")
);
private final AntPathMatcher pathMatcher = new AntPathMatcher();

View file

@ -70,7 +70,8 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter {
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path == null || (!path.startsWith("/api/v1/")
&& !path.startsWith("/api/web/"));
&& !path.startsWith("/api/web/")
&& !path.startsWith("/api/cli/"));
}
private boolean isApiTokenAuthentication(Authentication authentication) {

View file

@ -80,6 +80,8 @@ class RouteSecurityPolicyRegistryTest {
assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/skills/global/demo/resolve", Set.of()).allowed());
assertFalse(registry.authorizeApiToken("POST", "/api/cli/v1/skills/global/publish", Set.of()).allowed());
assertTrue(registry.authorizeApiToken("POST", "/api/cli/v1/skills/global/publish", Set.of("skill:publish")).allowed());
assertFalse(registry.authorizeApiToken("POST", "/api/cli/v1/skills/global/publish/validate", Set.of()).allowed());
assertTrue(registry.authorizeApiToken("POST", "/api/cli/v1/skills/global/publish/validate", Set.of("skill:publish")).allowed());
assertTrue(registry.authorizeApiToken("DELETE", "/api/cli/v1/skills/global/demo", Set.of("skill:delete")).allowed());
}

View file

@ -134,4 +134,40 @@ class ApiTokenScopeFilterTest {
assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish"));
verify(chain, never()).doFilter(request, response);
}
@Test
void shouldDenyApiCliRequestsWithoutRequiredScope() throws Exception {
AccessDeniedHandler handler = (request, response, accessDeniedException) -> {
response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
};
ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
PlatformPrincipal principal = new PlatformPrincipal(
"user-4",
"Dave",
"dave@example.com",
"",
"api_token",
Set.of("USER")
);
var authentication = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("SCOPE_skill:read")
)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/cli/v1/skills/global/publish/validate");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
filter.doFilter(request, response, chain);
assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus());
assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish"));
verify(chain, never()).doFilter(request, response);
}
}

View file

@ -119,6 +119,137 @@ public class SkillPublishService {
this.clock = clock;
}
public record DryRunResult(
boolean valid,
List<String> errors,
List<String> warnings,
String resolvedSlug,
String resolvedVersion
) {}
/**
* Validates a package without persisting anything. Used by the --dry-run CLI flow.
*
* <p>Warnings make the result invalid because the CLI publish flow uses
* {@code confirmWarnings=false}, which causes real publish to reject any
* package with warnings. Treating warnings as making the dry-run invalid
* keeps the two flows in lockstep.
*/
@Transactional(readOnly = true)
public DryRunResult validateOnly(
String namespaceSlug,
List<PackageEntry> entries,
String publisherId,
SkillVisibility visibility,
Set<String> platformRoles) {
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
String resolvedSlug = null;
String resolvedVersion = null;
// 1. Find namespace
var namespaceOpt = namespaceRepository.findBySlug(namespaceSlug);
if (namespaceOpt.isEmpty()) {
errors.add("Namespace not found: " + namespaceSlug);
return new DryRunResult(false, errors, warnings, null, null);
}
Namespace namespace = namespaceOpt.get();
if (namespace.getStatus() == NamespaceStatus.FROZEN) {
errors.add("Namespace is frozen: " + namespaceSlug);
}
if (namespace.getStatus() == NamespaceStatus.ARCHIVED) {
errors.add("Namespace is archived: " + namespaceSlug);
}
// 2. Check membership
boolean isSuperAdmin = platformRoles.contains("SUPER_ADMIN");
if (!isSuperAdmin) {
var member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId);
if (member.isEmpty()) {
errors.add("Publisher is not a member of namespace: " + namespaceSlug);
}
}
// 3. Package validation
ValidationResult packageValidation = skillPackageValidator.validate(entries);
errors.addAll(packageValidation.errors());
warnings.addAll(packageValidation.warnings());
if (!packageValidation.passed()) {
return new DryRunResult(false, errors, warnings, null, null);
}
// 4. Parse SKILL.md
PackageEntry skillMd = entries.stream()
.filter(e -> e.path().equals("SKILL.md"))
.findFirst()
.orElse(null);
if (skillMd == null) {
errors.add("Missing required file: SKILL.md at root");
return new DryRunResult(false, errors, warnings, null, null);
}
SkillMetadata metadata;
try {
metadata = skillMetadataParser.parse(new String(skillMd.content(), java.nio.charset.StandardCharsets.UTF_8));
} catch (Exception e) {
errors.add("Invalid SKILL.md: " + e.getMessage());
return new DryRunResult(false, errors, warnings, null, null);
}
if (metadata.version() == null || metadata.version().isBlank()) {
resolvedVersion = AUTO_VERSION_FORMATTER.format(currentTime());
} else {
resolvedVersion = metadata.version();
}
try {
resolvedSlug = SlugValidator.slugify(metadata.name());
} catch (Exception e) {
errors.add("Invalid skill name for slug generation: " + e.getMessage());
return new DryRunResult(false, errors, warnings, resolvedSlug, resolvedVersion);
}
// 5. Pre-publish validation (credential scan)
PrePublishValidator.SkillPackageContext context = new PrePublishValidator.SkillPackageContext(
entries, metadata, publisherId, namespace.getId());
ValidationResult prePublishValidation = prePublishValidator.validate(context);
errors.addAll(prePublishValidation.errors());
warnings.addAll(prePublishValidation.warnings());
// 6. Slug conflict, archived skill, and version-exists checks
if (resolvedSlug != null && errors.isEmpty()) {
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), resolvedSlug);
for (Skill existing : existingSkills) {
if (existing.getOwnerId().equals(publisherId)) {
if (existing.getStatus() == SkillStatus.ARCHIVED) {
errors.add("Cannot publish to archived skill: " + resolvedSlug);
}
if (resolvedVersion != null) {
var existingVersion = skillVersionRepository.findBySkillIdAndVersion(existing.getId(), resolvedVersion);
if (existingVersion.isPresent() && existingVersion.get().getStatus() == SkillVersionStatus.PUBLISHED) {
errors.add("Version already published: " + resolvedVersion);
}
}
} else {
boolean hasPublished = !skillVersionRepository
.findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED)
.isEmpty();
if (hasPublished) {
errors.add("Name conflict: slug \"" + resolvedSlug + "\" is already published by another user");
break;
}
}
}
}
// Warnings make valid=false: real publish rejects them when confirmWarnings=false,
// which is the only mode the CLI uses today.
boolean valid = errors.isEmpty() && warnings.isEmpty();
return new DryRunResult(valid, errors, warnings, resolvedSlug, resolvedVersion);
}
/**
* Publishes an extracted package into the target namespace.
*

View file

@ -1652,6 +1652,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/cli/v1/skills/{namespace}/publish/validate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["validatePublish"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/user/profile": {
parameters: {
query?: never;
@ -3948,6 +3964,22 @@ export interface components {
version?: string;
visibility?: string;
};
ApiResponseCliDryRunResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["CliDryRunResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
CliDryRunResponse: {
valid?: boolean;
errors?: string[];
warnings?: string[];
resolvedSlug?: string | null;
resolvedVersion?: string | null;
};
UpdateProfileRequest: {
displayName?: string;
};
@ -8294,6 +8326,36 @@ export interface operations {
};
};
};
validatePublish: {
parameters: {
query?: never;
header?: never;
path: {
namespace: string;
};
cookie?: never;
};
requestBody?: {
content: {
"multipart/form-data": {
/** Format: binary */
file: string;
visibility?: string;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseCliDryRunResponse"];
};
};
};
};
getProfile: {
parameters: {
query?: never;