diff --git a/cli/src/clients/skillhub-client.ts b/cli/src/clients/skillhub-client.ts index c8ee09ae..1842a178 100644 --- a/cli/src/clients/skillhub-client.ts +++ b/cli/src/clients/skillhub-client.ts @@ -179,7 +179,9 @@ export class SkillHubClient { } else if (response.status === 404) { fallback = kind === 'download' ? 'skill or version not found' : 'resource not found' } else if (response.status === 502 || response.status === 503) { - fallback = `registry returned ${response.status}` + fallback = kind === 'download' + ? `download failed with status ${response.status}` + : `registry returned ${response.status}` exitCode = EXIT.network } else { fallback = kind === 'download' diff --git a/cli/test/unit/clients/skillhub-client.test.ts b/cli/test/unit/clients/skillhub-client.test.ts index 55d0bfb7..187350c1 100644 --- a/cli/test/unit/clients/skillhub-client.test.ts +++ b/cli/test/unit/clients/skillhub-client.test.ts @@ -92,6 +92,17 @@ describe('SkillHubClient', () => { await err.toHaveProperty('exitCode', EXIT.generic) }) + test('download() retains its fallback while classifying 502 as a network error', async () => { + const fetchImpl = (async () => new Response(null, { status: 502 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + await expect(client.download('ns', 'slug')).rejects.toMatchObject({ + message: 'download failed with status 502', + exitCode: EXIT.network, + details: { registry: 'http://registry.test' } + }) + }) + test('download() throws network error on fetch failure', async () => { const fetchImpl = (async () => { throw new TypeError('fetch failed') }) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) @@ -179,6 +190,27 @@ describe('SkillHubClient', () => { // --- handleJsonResponse() non-2xx classification --- + test('whoami() preserves public fields and ignores unknown fields on a structured 401', async () => { + const fetchImpl = (async () => Response.json({ + code: 401, + msg: 'token has been revoked', + requestId: 'req-401', + detail: 'internal token state', + stack: 'internal stack trace' + }, { status: 401 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + const error = await client.whoami().catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(CliError) + expect((error as CliError).message).toBe('token has been revoked') + expect((error as CliError).exitCode).toBe(EXIT.auth) + expect((error as CliError).details).toEqual({ + registry: 'http://registry.test', + requestId: 'req-401', + next: 'run `skillhub login`' + }) + }) + test('search() preserves a public 403 message and request ID', async () => { const fetchImpl = (async () => Response.json({ code: 403, @@ -262,6 +294,17 @@ describe('SkillHubClient', () => { } }) + test('whoami() uses the resource fallback on an unstructured 404', async () => { + const fetchImpl = (async () => new Response(null, { status: 404 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + await expect(client.whoami()).rejects.toMatchObject({ + message: 'resource not found', + exitCode: EXIT.generic, + details: { registry: 'http://registry.test' } + }) + }) + test('download() preserves a structured 403 message and request ID', async () => { const fetchImpl = (async () => Response.json({ code: 403, @@ -313,20 +356,63 @@ describe('SkillHubClient', () => { }) }) - test('whoami() throws generic error on 500', async () => { - const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch + test('whoami() preserves public fields on a structured 500', async () => { + const fetchImpl = (async () => Response.json({ + code: 500, + msg: 'registry operation failed', + requestId: 'req-500', + detail: 'internal database error' + }, { status: 500 })) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) - const err = expect(client.whoami()).rejects - await err.toBeInstanceOf(CliError) - await err.toHaveProperty('exitCode', EXIT.generic) + + await expect(client.whoami()).rejects.toMatchObject({ + message: 'registry operation failed', + exitCode: EXIT.generic, + details: { + registry: 'http://registry.test', + requestId: 'req-500' + } + }) }) - test('search() throws network error on 502', async () => { + test('whoami() does not expose a raw non-JSON 500 body', async () => { + const fetchImpl = (async () => new Response('internal stack trace', { status: 500 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + await expect(client.whoami()).rejects.toMatchObject({ + message: 'registry returned 500', + exitCode: EXIT.generic, + details: { registry: 'http://registry.test' } + }) + }) + + test('search() preserves public fields and network classification on a structured 502', async () => { + const fetchImpl = (async () => Response.json({ + code: 502, + msg: 'registry upstream unavailable', + requestId: 'req-502' + }, { status: 502 })) as unknown as typeof fetch + const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) + + await expect(client.search('test', 20)).rejects.toMatchObject({ + message: 'registry upstream unavailable', + exitCode: EXIT.network, + details: { + registry: 'http://registry.test', + requestId: 'req-502' + } + }) + }) + + test('search() uses the network fallback on an unstructured 502', async () => { const fetchImpl = (async () => new Response(null, { status: 502 })) as unknown as typeof fetch const client = new SkillHubClient('http://registry.test', 'token', fetchImpl) - const err = expect(client.search('test', 20)).rejects - await err.toBeInstanceOf(CliError) - await err.toHaveProperty('exitCode', EXIT.network) + + await expect(client.search('test', 20)).rejects.toMatchObject({ + message: 'registry returned 502', + exitCode: EXIT.network, + details: { registry: 'http://registry.test' } + }) }) // --- deleteRemote() (P1) --- diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index 49428c9d..4712cbca 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -125,15 +125,24 @@ Output format: `namespace/slug version summary` ## Install Skills +Install coordinates accept a bare slug (resolved to `global` by default) and +three equivalent explicit namespace forms. When an explicit coordinate and +`--namespace` are both present, they must match. + ```bash # Install to auto-detected Agent directory skillhub install pdf-parser +# Equivalent namespace coordinates +skillhub install team/my-skill +skillhub install @team/my-skill +skillhub install team--my-skill + # Choose install scope explicitly skillhub install pdf-parser --scope user skillhub install pdf-parser --scope project --agent codex -# Specify namespace (default: global) +# Specify a namespace for a bare slug skillhub install pdf-parser --namespace myspace # Specify version diff --git a/docs/skillhub/guide/cli.md b/docs/skillhub/guide/cli.md index 4389940a..b4c00da7 100644 --- a/docs/skillhub/guide/cli.md +++ b/docs/skillhub/guide/cli.md @@ -125,15 +125,23 @@ skillhub search pdf --json ## 安装技能 +安装坐标支持裸 slug(默认解析到 `global`)和三种等价的显式 namespace +形式。显式坐标与 `--namespace` 同时出现时,两者必须一致。 + ```bash # 安装到自动探测的 Agent 目录 skillhub install pdf-parser +# 等价的 namespace 坐标 +skillhub install team/my-skill +skillhub install @team/my-skill +skillhub install team--my-skill + # 显式指定安装范围 skillhub install pdf-parser --scope user skillhub install pdf-parser --scope project --agent codex -# 指定 namespace(默认 global) +# 为裸 slug 指定 namespace skillhub install pdf-parser --namespace myspace # 指定版本 diff --git a/docs/superpowers/plans/2026-07-28-cli-namespace-errors.md b/docs/superpowers/plans/2026-07-28-cli-namespace-errors.md index 098e3542..ee71ab89 100644 --- a/docs/superpowers/plans/2026-07-28-cli-namespace-errors.md +++ b/docs/superpowers/plans/2026-07-28-cli-namespace-errors.md @@ -16,14 +16,14 @@ revalidation did not recreate historical RED states; it reran the current GREEN gates with the repository-pinned Bun 1.3.13: - Focused namespace/error/help regression: 142 tests passed. -- Complete CLI regression: 373 tests passed with - `bun test --max-concurrency=1` (peak RSS 170224 KiB). +- Complete CLI regression: 378 tests passed with + `bun test --max-concurrency=1` (peak RSS 152128 KiB). - Typecheck, lint, and build passed. - The packed `@astron-team/skillhub@0.1.9` artifact contained `dist/index.js`, `README.md`, `CHANGELOG.md`, `LICENSE`, and `package.json`. -- Packed Node artifact smoke passed for `version`, `help install`, and - `remove --help`; the install help listed all three namespaced coordinate - forms. +- Packed Node artifact smoke passed for `version`, `help install`, all three + namespaced coordinate forms, coordinate/`--namespace` conflict handling, and + structured 403 message/request-ID rendering. - The Chinese and English VitePress documentation build passed. ---