skillhub/cli/test/integration/install-command.test.ts
dongmucat 9520cf63e0 fix(cli): add token auth to search
Signed-off-by: dongmucat <1127093059@qq.com>
2026-06-17 11:35:16 +08:00

1021 lines
40 KiB
TypeScript

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 { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build a minimal valid zip containing a SKILL.md file. */
function makeSkillZip(extra: Record<string, string> = {}): Uint8Array {
const entries: Record<string, Uint8Array> = {
'SKILL.md': strToU8('# test skill'),
...Object.fromEntries(Object.entries(extra).map(([k, v]) => [k, strToU8(v)]))
}
return zipSync(entries)
}
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop()
registry = undefined
})
// ---------------------------------------------------------------------------
// P0 — Happy-path install: metadata.json + inventory.json
// ---------------------------------------------------------------------------
describe('install command — P0', () => {
test('happy-path: exit 0, writes metadata.json and inventory.json', 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',
versionId: 1,
fingerprint: 'abc123',
zipBytes: makeSkillZip()
}
]
})
// Login first so credentials are stored
const loginResult = await runCli(
['login', '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(loginResult.exitCode).toBe(0)
// The claude-code profile installs into <cwd>/.claude/skills
// We use --dir to pin the install directory to a known temp path so we
// can assert on it without depending on agent detection.
const installDir = join(env.cwd, 'skills')
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)
// --- metadata.json ---
const metaPath = join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta).toMatchObject({
registry: registry.url,
namespace: 'global',
slug: 'pdf-parser',
version: '1.0.0'
})
expect(typeof meta.installedAt).toBe('string')
// --- inventory.json ---
const inventoryPath = join(env.home, '.skillhub', 'inventory.json')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items).toBeArray()
const item = inventory.items.find(
(i: { namespace: string; slug: string }) => i.namespace === 'global' && i.slug === 'pdf-parser'
)
expect(item).toBeDefined()
expect(item.targets.length).toBeGreaterThan(0)
const target = item.targets.find(
(t: { installDir: string }) => t.installDir === join(installDir, 'pdf-parser')
)
expect(target).toBeDefined()
})
// -------------------------------------------------------------------------
// P0 — --json output shape
// -------------------------------------------------------------------------
test('--json output matches { ok, namespace, slug, installed }', 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-json')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout)
expect(parsed).toMatchObject({
ok: true,
namespace: 'global',
slug: 'pdf-parser'
})
expect(Array.isArray(parsed.installed)).toBe(true)
expect(parsed.installed.length).toBeGreaterThan(0)
expect(parsed.installed[0]).toHaveProperty('agent')
expect(parsed.installed[0]).toHaveProperty('dir')
})
})
// ---------------------------------------------------------------------------
// P1 — --version forwarding
// ---------------------------------------------------------------------------
describe('install command — P1', () => {
test('--version forwards to resolve and installs the requested version', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [
{
namespace: 'global',
slug: 'pdf-parser',
// fake-registry returns this as the resolved version regardless of
// the ?version= query param; we just verify the metadata records it.
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-ver')
await mkdir(installDir, { recursive: true })
const result = await runCli(
[
'install', 'pdf-parser',
'--version', '1.0.0',
'--dir', installDir,
'--registry', registry.url,
'--token', 'sk_ok'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
expect(registry.received.resolve?.version).toBe('1.0.0')
const metaPath = join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.version).toBe('1.0.0')
})
// -------------------------------------------------------------------------
// P1 — 401 on resolve → EXIT.auth (exit code 2)
// -------------------------------------------------------------------------
test('401 on resolve returns auth exit code and stderr message', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
failures: { resolve: 'auth' }
})
const installDir = join(env.cwd, 'skills-auth')
await mkdir(installDir, { recursive: true })
const result = await runCli(
[
'install', 'pdf-parser',
'--dir', installDir,
'--registry', registry.url,
'--token', 'sk_bad'
],
{ HOME: env.home, USERPROFILE: env.home }
)
// EXIT.auth = 2
expect(result.exitCode).toBe(2)
expect(result.stderr.toLowerCase()).toMatch(/auth|unauthorized|401/)
})
test('bad token stops on 401 without retrying resolve anonymously', async () => {
const env = await createTempHome()
const installDir = join(env.cwd, 'skills-no-anon-retry')
await mkdir(installDir, { recursive: true })
const resolveAuthHeaders: Array<string | null> = []
let downloadRequests = 0
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
const resolveMatch = url.pathname.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/)
if (resolveMatch) {
const auth = req.headers.get('authorization')
resolveAuthHeaders.push(auth)
if (auth === 'Bearer sk_bad') {
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
}
return Response.json({
code: 0,
data: {
namespace: resolveMatch[1],
slug: resolveMatch[2],
version: '1.0.0',
versionId: 1,
fingerprint: 'abc123',
downloadUrl: `${url.protocol}//${url.host}/api/cli/v1/skills/${resolveMatch[1]}/${resolveMatch[2]}/download`
}
})
}
if (url.pathname.endsWith('/download')) {
downloadRequests += 1
return new Response(makeSkillZip() as BodyInit, {
status: 200,
headers: { 'Content-Type': 'application/zip' }
})
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const registryUrl = `http://localhost:${server.port}`
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registryUrl, '--token', 'sk_bad'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('Error: authentication failed')
expect(result.stderr).toContain(`Context: registry ${registryUrl}`)
expect(result.stderr).toContain('Next:')
expect(resolveAuthHeaders).toEqual(['Bearer sk_bad'])
expect(downloadRequests).toBe(0)
} finally {
server.stop()
}
})
// -------------------------------------------------------------------------
// P1 — --namespace override
// -------------------------------------------------------------------------
test('--namespace override installs under the specified namespace', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [
{
namespace: 'myteam',
slug: 'mything',
version: '2.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-ns')
await mkdir(installDir, { recursive: true })
const result = await runCli(
[
'install', 'mything',
'--namespace', 'myteam',
'--dir', installDir,
'--registry', registry.url,
'--token', 'sk_ok'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const metaPath = join(installDir, 'mything', '.skillhub', 'metadata.json')
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.namespace).toBe('myteam')
expect(meta.slug).toBe('mything')
expect(meta.version).toBe('2.0.0')
})
// -------------------------------------------------------------------------
// NOTE: multi-target interactive selection (TTY branch) is not tested here
// because Bun.spawn does not support PTY allocation. The interactive path
// in resolveInstallTargets() is covered by the unit tests in
// test/unit/agents/resolver.test.ts.
// -------------------------------------------------------------------------
})
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
describe('install command — --scope', () => {
test('--scope project --agent codex installs to <cwd>/.codex/skills', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{ namespace: 'global', slug: 'foo', 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', 'foo', '--scope', 'project', '--agent', 'codex',
'--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(0)
const metaPath = join(env.cwd, '.codex', 'skills', 'foo', '.skillhub', 'metadata.json')
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.slug).toBe('foo')
})
test('--scope user --agent codex installs to <home>/.codex/skills', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{ namespace: 'global', slug: 'foo', 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', 'foo', '--scope', 'user', '--agent', 'codex',
'--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(0)
const metaPath = join(env.home, '.codex', 'skills', 'foo', '.skillhub', 'metadata.json')
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.slug).toBe('foo')
})
test('--scope user clean env falls back to <home>/.agents/skills', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{ namespace: 'global', slug: 'foo', 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', 'foo', '--scope', 'user',
'--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(0)
const metaPath = join(env.home, '.agents', 'skills', 'foo', '.skillhub', 'metadata.json')
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.slug).toBe('foo')
})
test('--scope project --agent codex --json output omits scope field on installed entries', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{ namespace: 'global', slug: 'foo', 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', 'foo', '--scope', 'project', '--agent', 'codex', '--json',
'--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout)
expect(parsed).toMatchObject({ ok: true, namespace: 'global', slug: 'foo' })
expect(parsed.installed[0]).toHaveProperty('agent')
expect(parsed.installed[0]).toHaveProperty('dir')
expect(parsed.installed[0]).not.toHaveProperty('scope')
})
test('--scope invalid returns exit code 5 with usage error', async () => {
const result = await runCli(['install', 'foo', '--scope', 'invalid'])
expect(result.exitCode).toBe(5)
expect(result.stderr).toMatch(/user.+project|"user".+"project"/)
})
test('--scope invalid --json returns JSON error shape', async () => {
const result = await runCli(['install', 'foo', '--scope', 'invalid', '--json'])
expect(result.exitCode).toBe(5)
const parsed = JSON.parse(result.stderr)
expect(parsed.ok).toBe(false)
expect(parsed.exitCode).toBe(5)
expect(parsed.message).toMatch(/user.+project/)
})
test('--dir + --scope returns usage error', async () => {
const result = await runCli(['install', 'foo', '--dir', '/tmp/x', '--scope', 'user'])
expect(result.exitCode).toBe(5)
expect(result.stderr).toMatch(/--dir cannot be used with --scope/)
})
test('--dir + --scope --json returns JSON usage error', async () => {
const result = await runCli(
['install', 'foo', '--dir', '/tmp/x', '--scope', 'user', '--json']
)
expect(result.exitCode).toBe(5)
const parsed = JSON.parse(result.stderr)
expect(parsed.ok).toBe(false)
expect(parsed.message).toMatch(/--dir cannot be used with --scope/)
})
test('help install includes --scope usage and examples', async () => {
const result = await runCli(['help', 'install'])
expect(result.exitCode).toBe(0)
expect(result.stdout).toMatch(/--scope/)
expect(result.stdout).toMatch(/--scope user/)
expect(result.stdout).toMatch(/--scope project --agent codex/)
})
})