Skip to content

Commit f8ea4e6

Browse files
authored
feat(cli): support anonymous public search and install
feat(cli): support anonymous public search and install
2 parents ea73c30 + cf22f56 commit f8ea4e6

25 files changed

Lines changed: 1422 additions & 111 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' }]

docs/03-authentication-design.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台
377377
- 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成
378378
- 存储:只存 SHA-256 哈希,明文只展示一次
379379
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
380+
- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401,不能回退为匿名访问
380381
- 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage`
381382

382383
> **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。
@@ -604,8 +605,8 @@ window.location.href = '/oauth2/authorization/github'
604605
| `GET /api/v1/skills`(搜索) |`PUBLIC`,且仅搜索 `ACTIVE`、非 hidden、已索引 skill | `PUBLIC + NAMESPACE_ONLY(成员空间)+ PRIVATE(owner/admin)` | `SearchVisibilityScope` + 搜索索引状态 |
605606
| `GET /api/v1/skills/{ns}/{slug}` | 仅已发布且可见的 `PUBLIC` skill | 同左,另加 owner 可读未发布 skill、namespace `ADMIN` / `OWNER` 可读 hidden | `visibility + latest_version_id + hidden + namespace 成员关系` |
606607
| `GET /api/v1/skills/{ns}/{slug}/versions` |`PUBLISHED` 版本 | owner / namespace `ADMIN` / `OWNER` 可见全部五种状态 | 同上 + version status 过滤 |
607-
| `GET /api/v1/skills/{ns}/{slug}/download` | 仅全局 namespace 下的 `PUBLIC` skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须是 `PUBLISHED` | visibility + namespace type + version status |
608-
| `GET /api/v1/skills/{ns}/{slug}/resolve` | 仅全局 namespace 下的 `PUBLIC` skill 可匿名 | 同上 | visibility + namespace type + version status |
608+
| `GET /api/v1/skills/{ns}/{slug}/download` | `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 支持匿名下载 | 已登录后按 visibility 判定;下载目标版本必须可安装 | visibility + namespace status + `SkillInstallability` |
609+
| `GET /api/v1/skills/{ns}/{slug}/resolve` | `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装的 skill 可匿名 | 同上 | visibility + namespace status + `SkillInstallability` |
609610
| `GET /api/v1/namespaces` | 全部 | 全部 | 无限制 |
610611

611612
### 10.2 Authenticated API
@@ -654,6 +655,6 @@ window.location.href = '/oauth2/authorization/github'
654655
|------|---------|---------|
655656
| `GET /api/v1/whoami` | 任意有效 Bearer Token ||
656657
| `GET /api/v1/search` | 可选(匿名限 PUBLIC) | `SearchVisibilityScope` |
657-
| `GET /api/v1/resolve` | 可选(匿名仅限全局 namespace 下的 PUBLIC) | visibility + namespace type + version status |
658-
| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限全局 namespace 下的 PUBLIC) | visibility + namespace type + version status |
658+
| `GET /api/v1/resolve` | 可选(匿名仅限 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装| visibility + namespace status + `SkillInstallability` |
659+
| `GET /api/v1/download/{slug}/{version}` | 可选(匿名仅限 `PUBLIC``ACTIVE`、非 hidden、命名空间未归档且目标版本可安装| visibility + namespace status + `SkillInstallability` |
659660
| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过(namespace 由 canonical slug 解析) |

server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,20 @@ public SearchResponse search(
8585

8686
SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles);
8787

88-
return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope);
88+
return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope, false);
89+
}
90+
91+
public SearchResponse searchInstallableLatest(
92+
String keyword,
93+
String namespaceSlug,
94+
String sortBy,
95+
int page,
96+
int size,
97+
String userId,
98+
Map<Long, NamespaceRole> userNsRoles) {
99+
Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles);
100+
SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles);
101+
return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, List.of(), scope, true);
89102
}
90103

91104
private Long resolveNamespaceId(String namespaceSlug, String userId, Map<Long, NamespaceRole> userNsRoles) {
@@ -133,15 +146,17 @@ private SearchResponse searchVisibleSkills(
133146
int page,
134147
int size,
135148
List<String> labelSlugs,
136-
SearchVisibilityScope scope) {
149+
SearchVisibilityScope scope,
150+
boolean requireInstallableLatest) {
137151
SearchResult result = searchQueryService.search(new SearchQuery(
138152
keyword,
139153
namespaceId,
140154
scope,
141155
sortBy,
142156
page,
143157
size,
144-
normalizeLabelSlugs(labelSlugs)
158+
normalizeLabelSlugs(labelSlugs),
159+
requireInstallableLatest
145160
));
146161
List<SkillSummaryResponse> pageItems = mapVisibleSkillSummaries(result.skillIds());
147162
return new SearchResponse(pageItems, result.total(), page, size);

server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,15 @@ public record CliSearchItem(String namespace, String slug, String latestVersion,
5151
public record CliSearchResult(List<CliSearchItem> items, long total, int limit) {}
5252

5353
public CliSearchResult search(String q, int limit, String userId, Map<Long, NamespaceRole> userNsRoles) {
54-
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
54+
SkillSearchAppService.SearchResponse response = skillSearchAppService.searchInstallableLatest(
5555
q, null, "newest", 0, limit, userId, userNsRoles
5656
);
5757

5858
List<CliSearchItem> items = response.items().stream()
5959
.map(item -> new CliSearchItem(
6060
item.namespace(),
6161
item.slug(),
62-
item.publishedVersion() != null ? item.publishedVersion().version() : null,
62+
item.publishedVersion().version(),
6363
item.summary()
6464
))
6565
.toList();

0 commit comments

Comments
 (0)