Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions cli/src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})

Expand Down
59 changes: 59 additions & 0 deletions cli/test/integration/install-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down
103 changes: 103 additions & 0 deletions cli/test/integration/search-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }]
Expand Down
9 changes: 5 additions & 4 deletions docs/03-authentication-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ API Token 仍保留,但定位从“CLI 唯一认证方式”调整为“平台
- 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成
- 存储:只存 SHA-256 哈希,明文只展示一次
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401,不能回退为匿名访问
- 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage`

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

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

SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles);

return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope);
return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, labelSlugs, scope, false);
}

public SearchResponse searchInstallableLatest(
String keyword,
String namespaceSlug,
String sortBy,
int page,
int size,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles);
SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles);
return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, List.of(), scope, true);
}

private Long resolveNamespaceId(String namespaceSlug, String userId, Map<Long, NamespaceRole> userNsRoles) {
Expand Down Expand Up @@ -133,15 +146,17 @@ private SearchResponse searchVisibleSkills(
int page,
int size,
List<String> labelSlugs,
SearchVisibilityScope scope) {
SearchVisibilityScope scope,
boolean requireInstallableLatest) {
SearchResult result = searchQueryService.search(new SearchQuery(
keyword,
namespaceId,
scope,
sortBy,
page,
size,
normalizeLabelSlugs(labelSlugs)
normalizeLabelSlugs(labelSlugs),
requireInstallableLatest
));
List<SkillSummaryResponse> pageItems = mapVisibleSkillSummaries(result.skillIds());
return new SearchResponse(pageItems, result.total(), page, size);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ public record CliSearchItem(String namespace, String slug, String latestVersion,
public record CliSearchResult(List<CliSearchItem> items, long total, int limit) {}

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

List<CliSearchItem> items = response.items().stream()
.map(item -> new CliSearchItem(
item.namespace(),
item.slug(),
item.publishedVersion() != null ? item.publishedVersion().version() : null,
item.publishedVersion().version(),
item.summary()
))
.toList();
Expand Down
Loading
Loading