Skip to content

Commit ccfd37e

Browse files
committed
fix(cli): add token auth to search
1 parent 4776550 commit ccfd37e

5 files changed

Lines changed: 170 additions & 4 deletions

File tree

cli/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ Logout only removes the token for the specified registry, preserving registry co
112112
# Keyword search
113113
skillhub search pdf
114114

115+
# Search with a one-off token
116+
skillhub search pdf --token sk_xxx
117+
115118
# List all skills (empty query)
116119
skillhub search "" --limit 50
117120

@@ -333,7 +336,7 @@ Update mechanism:
333336
| `skillhub login --token <token> [--registry <url>] [--json]` | Save token and registry configuration |
334337
| `skillhub logout [--registry <url>] [--json]` | Remove token for specified registry |
335338
| `skillhub whoami [--registry <url>] [--token <token>] [--json]` | Validate current token and display user information |
336-
| `skillhub search <query> [--registry <url>] [--limit <n>] [--json]` | Search published skills |
339+
| `skillhub search <query> [--registry <url>] [--token <token>] [--limit <n>] [--json]` | Search published skills |
337340
| `skillhub install <slug> [--scope <user\|project>] [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--registry <url>] [--token <token>] [--json]` | Install a skill |
338341
| `skillhub list [--agent <profile>] [--dir <path>] [--registry <url>] [--json]` | List installed skills |
339342
| `skillhub remove <slug> [--agent <profile>] [--all] [--remote] [--hard] [--namespace <slug>] [--registry <url>] [--token <token>] [--json]` | Remove a skill |

cli/src/commands/help.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ export const commands = {
2828
},
2929
search: {
3030
summary: 'Search published skills',
31-
usage: 'skillhub search [query] [--limit <n>] [--registry <url>] [--json]',
32-
examples: ['skillhub search', 'skillhub search pdf']
31+
usage: 'skillhub search [query] [--limit <n>] [--registry <url>] [--token <token>] [--json]',
32+
examples: ['skillhub search', 'skillhub search pdf', 'skillhub search pdf --token sk_xxx']
3333
},
3434
install: {
3535
summary: 'Install a skill locally',

cli/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,10 @@ cli
223223
cli
224224
.command('search [query]', 'Search published skills')
225225
.option('--registry <url>', 'Registry URL')
226+
.option('--token <token>', 'API token')
226227
.option('--limit <n>', 'Max results', { default: 20 })
227228
.option('--json', 'Output JSON')
228-
.action((query: string | undefined, options: { registry?: string; limit?: number; json?: boolean }) => {
229+
.action((query: string | undefined, options: { registry?: string; token?: string; limit?: number; json?: boolean }) => {
229230
return runCommand(() => searchCommand(query ?? '', options), Boolean(options.json))
230231
})
231232

cli/test/integration/install-command.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,65 @@ describe('install command — P1', () => {
218218
expect(result.stderr.toLowerCase()).toMatch(/auth|unauthorized|401/)
219219
})
220220

221+
test('bad token stops on 401 without retrying resolve anonymously', async () => {
222+
const env = await createTempHome()
223+
const installDir = join(env.cwd, 'skills-no-anon-retry')
224+
await mkdir(installDir, { recursive: true })
225+
226+
const resolveAuthHeaders: Array<string | null> = []
227+
let downloadRequests = 0
228+
const server = Bun.serve({
229+
port: 0,
230+
fetch(req) {
231+
const url = new URL(req.url)
232+
const resolveMatch = url.pathname.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/)
233+
if (resolveMatch) {
234+
const auth = req.headers.get('authorization')
235+
resolveAuthHeaders.push(auth)
236+
if (auth === 'Bearer sk_bad') {
237+
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
238+
}
239+
return Response.json({
240+
code: 0,
241+
data: {
242+
namespace: resolveMatch[1],
243+
slug: resolveMatch[2],
244+
version: '1.0.0',
245+
versionId: 1,
246+
fingerprint: 'abc123',
247+
downloadUrl: `${url.protocol}//${url.host}/api/cli/v1/skills/${resolveMatch[1]}/${resolveMatch[2]}/download`
248+
}
249+
})
250+
}
251+
if (url.pathname.endsWith('/download')) {
252+
downloadRequests += 1
253+
return new Response(makeSkillZip() as BodyInit, {
254+
status: 200,
255+
headers: { 'Content-Type': 'application/zip' }
256+
})
257+
}
258+
return Response.json({ code: 404 }, { status: 404 })
259+
}
260+
})
261+
262+
try {
263+
const registryUrl = `http://localhost:${server.port}`
264+
const result = await runCli(
265+
['install', 'pdf-parser', '--dir', installDir, '--registry', registryUrl, '--token', 'sk_bad'],
266+
{ HOME: env.home, USERPROFILE: env.home }
267+
)
268+
269+
expect(result.exitCode).toBe(2)
270+
expect(result.stderr).toContain('Error: authentication failed')
271+
expect(result.stderr).toContain(`Context: registry ${registryUrl}`)
272+
expect(result.stderr).toContain('Next:')
273+
expect(resolveAuthHeaders).toEqual(['Bearer sk_bad'])
274+
expect(downloadRequests).toBe(0)
275+
} finally {
276+
server.stop()
277+
}
278+
})
279+
221280
// -------------------------------------------------------------------------
222281
// P1 — --namespace override
223282
// -------------------------------------------------------------------------

cli/test/integration/search-command.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,109 @@ afterEach(() => {
1010
})
1111

1212
describe('search command', () => {
13+
test('--token sends bearer auth and takes priority over SKILLHUB_TOKEN', async () => {
14+
let capturedAuth = ''
15+
const server = Bun.serve({
16+
port: 0,
17+
fetch(req) {
18+
const url = new URL(req.url)
19+
if (url.pathname === '/api/cli/v1/skills/search') {
20+
capturedAuth = req.headers.get('authorization') ?? ''
21+
return Response.json({
22+
code: 0,
23+
data: {
24+
items: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }],
25+
total: 1,
26+
limit: 20
27+
}
28+
})
29+
}
30+
return Response.json({ code: 404 }, { status: 404 })
31+
}
32+
})
33+
34+
try {
35+
const result = await runCli(
36+
['search', 'pdf', '--registry', `http://localhost:${server.port}`, '--token', 'sk_ok'],
37+
{ SKILLHUB_TOKEN: 'sk_bad' }
38+
)
39+
40+
expect(result.exitCode).toBe(0)
41+
expect(capturedAuth).toBe('Bearer sk_ok')
42+
expect(result.stdout).toContain('global/pdf-parser')
43+
} finally {
44+
server.stop()
45+
}
46+
})
47+
48+
test('bad --token fails with auth output and does not retry anonymously', async () => {
49+
const authHeaders: Array<string | null> = []
50+
const server = Bun.serve({
51+
port: 0,
52+
fetch(req) {
53+
const url = new URL(req.url)
54+
if (url.pathname === '/api/cli/v1/skills/search') {
55+
const auth = req.headers.get('authorization')
56+
authHeaders.push(auth)
57+
if (auth === 'Bearer sk_bad') {
58+
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
59+
}
60+
return Response.json({
61+
code: 0,
62+
data: {
63+
items: [{ namespace: 'global', slug: 'anonymous-only', latestVersion: '1.0.0', summary: 'anonymous fallback' }],
64+
total: 1,
65+
limit: 20
66+
}
67+
})
68+
}
69+
return Response.json({ code: 404 }, { status: 404 })
70+
}
71+
})
72+
73+
try {
74+
const registryUrl = `http://localhost:${server.port}`
75+
const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad'])
76+
77+
expect(result.exitCode).toBe(2)
78+
expect(result.stderr).toContain('Error: authentication failed')
79+
expect(result.stderr).toContain(`Context: registry ${registryUrl}`)
80+
expect(result.stderr).toContain('Next:')
81+
expect(authHeaders).toEqual(['Bearer sk_bad'])
82+
} finally {
83+
server.stop()
84+
}
85+
})
86+
87+
test('bad --token returns structured json auth error', async () => {
88+
const server = Bun.serve({
89+
port: 0,
90+
fetch(req) {
91+
const url = new URL(req.url)
92+
if (url.pathname === '/api/cli/v1/skills/search') {
93+
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
94+
}
95+
return Response.json({ code: 404 }, { status: 404 })
96+
}
97+
})
98+
99+
try {
100+
const registryUrl = `http://localhost:${server.port}`
101+
const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad', '--json'])
102+
103+
expect(result.exitCode).toBe(2)
104+
const parsed = JSON.parse(result.stderr)
105+
expect(parsed.ok).toBe(false)
106+
expect(parsed.message).toBe('authentication failed')
107+
expect(parsed.exitCode).toBe(2)
108+
expect(parsed.details.registry).toBe(registryUrl)
109+
expect(typeof parsed.details.next).toBe('string')
110+
expect(parsed.details.next).toContain('skillhub login')
111+
} finally {
112+
server.stop()
113+
}
114+
})
115+
13116
test('prints compact search table', async () => {
14117
registry = await startFakeRegistry({
15118
searchItems: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }]

0 commit comments

Comments
 (0)