fix(cli): add token auth to search

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-12 14:01:21 +08:00
parent 4776550391
commit c68a4842cf
5 changed files with 170 additions and 4 deletions

View file

@ -112,6 +112,9 @@ Logout only removes the token for the specified registry, preserving registry co
# Keyword search
skillhub search pdf
# Search with a one-off token
skillhub search pdf --token sk_xxx
# List all skills (empty query)
skillhub search "" --limit 50
@ -333,7 +336,7 @@ Update mechanism:
| `skillhub login --token <token> [--registry <url>] [--json]` | Save token and registry configuration |
| `skillhub logout [--registry <url>] [--json]` | Remove token for specified registry |
| `skillhub whoami [--registry <url>] [--token <token>] [--json]` | Validate current token and display user information |
| `skillhub search <query> [--registry <url>] [--limit <n>] [--json]` | Search published skills |
| `skillhub search <query> [--registry <url>] [--token <token>] [--limit <n>] [--json]` | Search published skills |
| `skillhub install <slug> [--scope <user\|project>] [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--registry <url>] [--token <token>] [--json]` | Install a skill |
| `skillhub list [--agent <profile>] [--dir <path>] [--registry <url>] [--json]` | List installed skills |
| `skillhub remove <slug> [--agent <profile>] [--all] [--remote] [--hard] [--namespace <slug>] [--registry <url>] [--token <token>] [--json]` | Remove a skill |

View file

@ -28,8 +28,8 @@ export const commands = {
},
search: {
summary: 'Search published skills',
usage: 'skillhub search [query] [--limit <n>] [--registry <url>] [--json]',
examples: ['skillhub search', 'skillhub search pdf']
usage: 'skillhub search [query] [--limit <n>] [--registry <url>] [--token <token>] [--json]',
examples: ['skillhub search', 'skillhub search pdf', 'skillhub search pdf --token sk_xxx']
},
install: {
summary: 'Install a skill locally',

View file

@ -223,9 +223,10 @@ cli
cli
.command('search [query]', 'Search published skills')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--limit <n>', 'Max results', { default: 20 })
.option('--json', 'Output JSON')
.action((query: string | undefined, options: { registry?: string; limit?: number; json?: boolean }) => {
.action((query: string | undefined, options: { registry?: string; token?: string; limit?: number; json?: boolean }) => {
return runCommand(() => searchCommand(query ?? '', options), Boolean(options.json))
})

View file

@ -218,6 +218,65 @@ describe('install command — P1', () => {
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
// -------------------------------------------------------------------------

View file

@ -10,6 +10,109 @@ afterEach(() => {
})
describe('search command', () => {
test('--token sends bearer auth and takes priority over SKILLHUB_TOKEN', async () => {
let capturedAuth = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedAuth = req.headers.get('authorization') ?? ''
return Response.json({
code: 0,
data: {
items: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }],
total: 1,
limit: 20
}
})
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const result = await runCli(
['search', 'pdf', '--registry', `http://localhost:${server.port}`, '--token', 'sk_ok'],
{ SKILLHUB_TOKEN: 'sk_bad' }
)
expect(result.exitCode).toBe(0)
expect(capturedAuth).toBe('Bearer sk_ok')
expect(result.stdout).toContain('global/pdf-parser')
} finally {
server.stop()
}
})
test('bad --token fails with auth output and does not retry anonymously', async () => {
const authHeaders: Array<string | null> = []
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
const auth = req.headers.get('authorization')
authHeaders.push(auth)
if (auth === 'Bearer sk_bad') {
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
}
return Response.json({
code: 0,
data: {
items: [{ namespace: 'global', slug: 'anonymous-only', latestVersion: '1.0.0', summary: 'anonymous fallback' }],
total: 1,
limit: 20
}
})
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const registryUrl = `http://localhost:${server.port}`
const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad'])
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(authHeaders).toEqual(['Bearer sk_bad'])
} finally {
server.stop()
}
})
test('bad --token returns structured json auth error', async () => {
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const registryUrl = `http://localhost:${server.port}`
const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad', '--json'])
expect(result.exitCode).toBe(2)
const parsed = JSON.parse(result.stderr)
expect(parsed.ok).toBe(false)
expect(parsed.message).toBe('authentication failed')
expect(parsed.exitCode).toBe(2)
expect(parsed.details.registry).toBe(registryUrl)
expect(typeof parsed.details.next).toBe('string')
expect(parsed.details.next).toContain('skillhub login')
} finally {
server.stop()
}
})
test('prints compact search table', async () => {
registry = await startFakeRegistry({
searchItems: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }]