Merge remote-tracking branch 'origin/main' into review/pr443-fix

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-28 20:03:50 +08:00
commit bbf9e4e714
290 changed files with 18776 additions and 2153 deletions

View file

@ -93,3 +93,6 @@ SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment.
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes

View file

@ -79,6 +79,15 @@ SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_SCOPE=openid,profile,email
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_NAME=OIDC
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI=
# Direct (username/password) authentication for environments without OAuth2.
# To enable, set BOTH:
# - SKILLHUB_AUTH_DIRECT_ENABLED=true (server: enables the /api/v1/auth/direct endpoint)
# - SKILLHUB_WEB_AUTH_DIRECT_ENABLED=true (web: surfaces the username/password form)
# Set SKILLHUB_WEB_AUTH_DIRECT_PROVIDER to the provider id (e.g. "local").
SKILLHUB_AUTH_DIRECT_ENABLED=false
SKILLHUB_WEB_AUTH_DIRECT_ENABLED=false
SKILLHUB_WEB_AUTH_DIRECT_PROVIDER=
# SMTP configuration for password reset verification emails.
SPRING_MAIL_HOST=
SPRING_MAIL_PORT=587
@ -95,6 +104,10 @@ SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
# Security scanner is enabled by default. Set to false to disable scanning.
SKILLHUB_SECURITY_SCANNER_ENABLED=true
# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment.
# runtime.sh generates and persists one automatically when this placeholder is still present.
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes
# Scanner LLM configuration (optional, for AI-powered scanning features)
SKILL_SCANNER_LLM_API_KEY=
SKILL_SCANNER_LLM_BASE_URL=

View file

@ -1,6 +1,2 @@
# https://developers.google.com/gemini-code-assist/docs/customize-gemini-behavior-github
have_fun: false # Just review the code
code_review:
comment_severity_threshold: HIGH # Reduce quantity of comments
pull_request_opened:
summary: false # Don't summarize the PR in a separate comment
disable: true

View file

@ -7,6 +7,9 @@ on:
- 'Makefile'
- '.github/workflows/pr-cli.yml'
permissions:
contents: read
jobs:
cli:
strategy:
@ -16,6 +19,8 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13

View file

@ -34,6 +34,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up pnpm
uses: pnpm/action-setup@v4

37
.github/workflows/pr-scripts.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: PR Scripts
on:
pull_request:
paths:
- 'scripts/**'
- '.env.release.example'
- '.env.release.draft'
- 'compose.release.yml'
- 'Makefile'
- '.github/workflows/pr-cli.yml'
- '.github/workflows/pr-e2e.yml'
- '.github/workflows/pr-tests.yml'
- '.github/workflows/security.yml'
- '.github/workflows/pr-scripts.yml'
- '**/*.py'
permissions:
contents: read
jobs:
scripts-tests:
name: Script Regression Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '21'
- run: bash scripts/tests/publish-cli-test.sh
- run: bash scripts/tests/runtime-secret-test.sh
- run: bash scripts/tests/validate-release-config-test.sh
- run: bash scripts/tests/dev-web-host-test.sh
- run: bash scripts/tests/workflow-security-test.sh

View file

@ -25,6 +25,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up pnpm
uses: pnpm/action-setup@v4
@ -52,6 +54,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Java
uses: actions/setup-java@v4
@ -65,3 +69,39 @@ jobs:
- name: Run backend unit tests
run: make test-backend
docs-build:
name: Docs Build
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Detect docs changes
id: changed
uses: dorny/paths-filter@v3
with:
filters: |
docs:
- 'docs/skillhub/**'
- '.github/workflows/pr-tests.yml'
- name: Set up Node.js
if: steps.changed.outputs.docs == 'true'
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: docs/skillhub/package-lock.json
- name: Install docs dependencies
if: steps.changed.outputs.docs == 'true'
run: cd docs/skillhub && npm ci
- name: Build VitePress site
if: steps.changed.outputs.docs == 'true'
run: cd docs/skillhub && npm run build

87
.github/workflows/security.yml vendored Normal file
View file

@ -0,0 +1,87 @@
name: Security
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
push:
branches:
- main
schedule:
- cron: '23 3 * * 1'
workflow_dispatch:
permissions:
contents: read
jobs:
dependency-review:
name: Dependency Review
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft }}
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Review dependency changes
uses: actions/dependency-review-action@v4
codeql:
name: CodeQL (${{ matrix.language }})
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- language: java-kotlin
build-mode: manual
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Java
if: matrix.language == 'java-kotlin'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Ensure Maven wrapper is executable
if: matrix.language == 'java-kotlin'
run: chmod +x server/mvnw
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Build Java for CodeQL
if: matrix.language == 'java-kotlin'
run: cd server && ./mvnw -q -DskipTests package
- name: Analyze
uses: github/codeql-action/analyze@v3
with:
category: /language:${{ matrix.language }}

View file

@ -6,6 +6,7 @@ DEV_WEB_PID := $(DEV_DIR)/web.pid
DEV_SERVER_LOG := $(DEV_DIR)/server.log
DEV_WEB_LOG := $(DEV_DIR)/web.log
DEV_WEB_URL := http://localhost:3000
DEV_WEB_HOST ?= 127.0.0.1
DEV_API_URL := http://localhost:8080
DEV_SCANNER_URL := http://localhost:8000
STAGING_API_URL := http://localhost:8080
@ -48,7 +49,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \
else \
echo "Starting frontend..."; \
$(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host 0.0.0.0 --strictPort >/dev/null; \
$(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host $(DEV_WEB_HOST) --strictPort >/dev/null; \
fi
@echo "Waiting for backend on $(DEV_API_URL) ..."
@backend_ready=0; \
@ -126,7 +127,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
@echo " Frontend: $(DEV_WEB_LOG)"
dev-server: ## 启动后端开发服务器
cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)'
cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)'
dev-server-restart: ## 重启后端开发服务器
@mkdir -p $(DEV_DIR)
@ -237,7 +238,7 @@ web-install-ci: ## 以 CI 方式安装前端依赖
cd web && CI=true pnpm install --frozen-lockfile
dev-web: ## 启动前端开发服务器
cd web && pnpm run dev
cd web && pnpm exec vite --host $(DEV_WEB_HOST)
build-frontend: web-deps ## 构建前端
cd web && pnpm run build
@ -278,13 +279,13 @@ lint-cli: ## CLI 代码检查
typecheck-cli: ## CLI 类型检查
cd cli && bun run typecheck
publish-cli: ## 发布 CLIpatch 版本)- bump + tag + push触发 CI 自动发布
publish-cli: ## 发布 CLIpatch 版本)- 本地 build+test → 推 release 分支 → 开 PR合并后手动 tag 触发 CI
./scripts/publish-cli.sh patch
publish-cli-minor: ## 发布 CLIminor 版本)- bump + tag + push触发 CI 自动发布
publish-cli-minor: ## 发布 CLIminor 版本)- 本地 build+test → 推 release 分支 → 开 PR合并后手动 tag 触发 CI
./scripts/publish-cli.sh minor
publish-cli-major: ## 发布 CLImajor 版本)- bump + tag + push触发 CI 自动发布
publish-cli-major: ## 发布 CLImajor 版本)- 本地 build+test → 推 release 分支 → 开 PR合并后手动 tag 触发 CI
./scripts/publish-cli.sh major
db-reset: ## 重置数据库

View file

@ -436,6 +436,12 @@ namespace `my-space` plus skill slug `my-skill`.
📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)**
### [Hermes Agent](https://github.com/NousResearch/hermes-agent)
[Hermes Agent](https://github.com/NousResearch/hermes-agent) uses the standard `SKILL.md` format and recursively discovers skills under `$HERMES_HOME/skills/`. Use SkillHub CLI's explicit `--dir` option to install a complete SkillHub package into Hermes without a registry adapter, then verify it with `hermes skills list`.
📖 **[Complete Hermes Agent Integration Guide →](./docs/hermes-integration-en.md)**
### [AstronClaw](https://agent.xfyun.cn/astron-claw)
[AstronClaw](https://agent.xfyun.cn/astron-claw) is a cloud AI assistant built on OpenClaw's core capabilities, providing 24/7 online service through enterprise platforms like WeChat Work, DingTalk, and Feishu. It features a built-in skill system with over 130 official skills. You can connect it to a self-hosted SkillHub registry to enable one-click skill installation, search repository, dialogue-based automatic installation, and even custom skills management within your organization.

View file

@ -370,6 +370,12 @@ namespace `my-space` 和 skill slug `my-skill`。
📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)**
### [Hermes Agent](https://github.com/NousResearch/hermes-agent)
[Hermes Agent](https://github.com/NousResearch/hermes-agent) 使用标准 `SKILL.md` 格式,并会递归发现 `$HERMES_HOME/skills/` 中的技能。通过 SkillHub CLI 的 `--dir` 参数即可把完整技能包安装到 Hermes无需新增 registry 适配器;安装后可使用 `hermes skills list` 验证。
📖 **[完整 Hermes Agent 集成指南 →](./docs/hermes-integration.md)**
### [AstronClaw](https://agent.xfyun.cn/astron-claw)
[AstronClaw](https://agent.xfyun.cn/astron-claw) 是基于 OpenClaw 核心能力打造的云端 AI 助手,提供全天候在线服务,随时随地通过企业微信、钉钉、飞书等渠道提供服务。它内置了丰富的技能系统,您可以将其连接到自托管的 SkillHub 注册中心,支持技能市场一键安装、仓库搜索、对话自动安装,甚至管理和分发组织内部的自定义私有技能。

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
@ -127,6 +130,10 @@ Output format: `namespace/slug version summary`
# Install to auto-detected Agent directory
skillhub install pdf-parser
# Choose install scope explicitly
skillhub install pdf-parser --scope user
skillhub install pdf-parser --scope project --agent codex
# Specify namespace (default: global)
skillhub install pdf-parser --namespace myspace
@ -150,18 +157,21 @@ skillhub install pdf-parser --force
The CLI determines the installation location using the following logic:
1. If `--dir` is specified: Install to that directory, agent marked as `custom`
2. If `--agent` is specified: Install to the corresponding Agent's skills directory
3. If neither is specified: Auto-scan current directory to detect existing Agent config directories
- 1 Agent detected → Install directly
- Multiple Agents detected → Interactive selection (TTY mode) or error (non-interactive mode)
- No Agent detected → Fallback to `<cwd>/.agents/skills/`
1. If `--dir` is specified: Install to that directory, agent marked as `custom`. `--dir` is mutually exclusive with `--scope` and `--agent`.
2. If `--scope user|project` is specified: Limit detection to the chosen scope.
- With `--agent <profile>`: Install to that profile's user or project skills directory directly.
- Without `--agent`: Detect existing skills directories within the chosen scope only. In interactive user scope, the `generic` target (`<home>/.agents/skills/`) is always also offered and can be selected alone or together with detected targets.
- No detected directory in the chosen scope → Fallback to `<home>/.agents/skills/` for `--scope user` or `<cwd>/.agents/skills/` for `--scope project`.
3. If `--agent` is specified (no `--scope`): Install to the corresponding Agent's skills directory (existing behaviour, unchanged).
4. If none of the above is specified:
- **Interactive mode** (stdin and stdout are both TTY, no `--json`): Prompt for `user` or `project` scope first, then continue per the `--scope` rule above.
- **Non-interactive mode**: Auto-scan current directory to detect existing Agent config directories. 1 Agent detected → install directly; multiple → error; none detected → fallback to `<cwd>/.agents/skills/`.
> `--dir` and `--agent` cannot be used together.
> `--dir` cannot be combined with `--scope` or `--agent`.
### Install Paths
Each Agent has both project-level and user-level skills directories:
Each Agent has both project-level and user-level skills directories. Use `--scope user|project` to control which one is used.
| Agent | Project-level Path | User-level Path |
|-------|-------------------|-----------------|
@ -169,9 +179,9 @@ Each Agent has both project-level and user-level skills directories:
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
| `github-copilot` | `<project>/.github-copilot/skills/` | `~/.github-copilot/skills/` |
| `gemini-cli` | `<project>/.gemini-cli/skills/` | `~/.gemini-cli/skills/` |
| `gemini-cli` | `<project>/.gemini/skills/` | `~/.gemini/skills/` |
| `windsurf` | `<project>/.windsurf/skills/` | `~/.windsurf/skills/` |
| `kiro-cli` | `<project>/.kiro-cli/skills/` | `~/.kiro-cli/skills/` |
| `kiro-cli` | `<project>/.kiro/skills/` | `~/.kiro/skills/` |
| `roo` | `<project>/.roo/skills/` | `~/.roo/skills/` |
| `trae` | `<project>/.trae/skills/` | `~/.trae/skills/` |
| `trae-cn` | `<project>/.trae-cn/skills/` | `~/.trae-cn/skills/` |
@ -179,8 +189,9 @@ Each Agent has both project-level and user-level skills directories:
| `openclaw` | `<project>/.openclaw/skills/` | `~/.openclaw/skills/` |
| `opencode` | `<project>/.opencode/skills/` | `~/.opencode/skills/` |
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
For Agents not in the list, use `--dir` to specify the installation path.
For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
### File Structure After Installation
@ -325,8 +336,8 @@ 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 install <slug> [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--registry <url>] [--token <token>] [--json]` | Install a skill |
| `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 |
| `skillhub doctor [--json]` | Scan project directory and rebuild local inventory |

View file

@ -2,7 +2,14 @@
## Overview
CLI releases are fully automated. Running `make publish-cli` on a clean `main` branch bumps the version, commits, creates a `cli-vX.Y.Z` tag, and pushes everything to origin. The GitHub Actions workflow [`release-cli.yml`](../.github/workflows/release-cli.yml) listens for the tag and handles build, test, npm publish, and GitHub Release creation.
CLI releases use a PR-based flow. Running `make publish-cli` on a clean `main` branch:
1. Runs local build-and-test (lint, typecheck, test, build)
2. Computes the next version from the latest `cli-v*` tag on `origin`
3. Creates a `release/cli-vX.Y.Z` branch with the version bump committed
4. Pushes the branch and opens a PR to `main`
After the PR is merged, you manually tag and push — the tag triggers [`release-cli.yml`](../.github/workflows/release-cli.yml) which builds, publishes to npm, and creates a GitHub Release.
## Prerequisites
@ -21,8 +28,9 @@ Configure in GitHub repository → Settings → Secrets and variables → Action
### Local Environment
- `node` and `npm` installed (the script uses `npm version` to bump)
- `git` installed with push access to the repository
- `node` and `bun` installed
- `gh` CLI installed and authenticated (`gh auth login`)
- `git` with push access to the repository
- On the `main` branch with a clean working tree
### Package Configuration
@ -40,7 +48,7 @@ In [`cli/package.json`](./package.json):
## Release Process
### One-shot Release
### Step 1: Run the publish script
From the repository root, on a clean `main` branch:
@ -52,15 +60,31 @@ make publish-cli-major # major: 0.1.5 -> 1.0.0
[`scripts/publish-cli.sh`](../scripts/publish-cli.sh) performs the following steps:
1. Verify the working tree is clean
2. Require the current branch to be `main`, otherwise abort
1. Verify `gh` CLI is installed and authenticated
2. Verify the working tree is clean and on `main`
3. `git pull --ff-only` from `origin/main`
4. Fetch remote tags and align `package.json` with the latest `cli-v*` tag
5. Compute the new version via `npm version <bump>`
6. Verify the new tag does not exist locally or on origin
7. After interactive confirmation: commit the bump, create the `cli-vX.Y.Z` tag, push both commit and tag to origin
4. Run full local build-and-test (lint, typecheck, test, build)
5. Compute the next version from the latest `cli-v*` tag on `origin` (via `git ls-remote`, so local orphan tags from a failed `git push origin cli-vX.Y.Z` are ignored)
6. Verify the tag and release branch don't already exist
7. After interactive confirmation: create release branch, commit version bump, push, and open PR
Pushing the tag triggers CI — no further manual action required.
### Step 2: Merge the PR
Review and merge the PR on GitHub as usual.
### Step 3: Tag and push
After the PR is merged:
```bash
git fetch origin main
git tag cli-vX.Y.Z origin/main # replace with the actual version
git push origin cli-vX.Y.Z
```
This ensures the tag is always placed on the merge commit on `origin/main`, regardless of your local branch state.
Pushing the tag triggers CI which builds, publishes to npm, and creates a GitHub Release.
### CI Workflow
@ -104,6 +128,22 @@ From the Actions UI:
2. Enter an existing tag name matching `cli-vX.Y.Z`
3. Optionally enable skip npm publish
## Error Recovery
The script uses a cleanup state machine. If it fails at different stages:
- **Before push**: release branch is deleted locally, you're returned to `main`
- **After push, before PR**: the script prints recovery instructions (open PR manually or delete the remote branch)
- **After PR opened**: success — no cleanup needed
If you need to manually clean up a failed release:
```bash
git checkout main
git branch -D release/cli-vX.Y.Z # delete local branch
git push origin --delete release/cli-vX.Y.Z # delete remote branch (if pushed)
```
## Troubleshooting
### `releases must be cut from 'main'`
@ -118,6 +158,10 @@ Commit or stash local changes first.
The previous release didn't clean up, or someone else released the same version. Check `git tag --list 'cli-v*'` and remote tags, then retry with a higher version.
### `branch release/cli-vX.Y.Z already exists`
A previous release attempt left a stale branch. Delete it locally and/or on origin, then retry.
### npm Publish Fails
- **403 with 2FA message**: `NPM_TOKEN` is not an Automation Token, or bypass 2FA is not enabled — regenerate with the correct type

View file

@ -1,6 +1,6 @@
{
"name": "@astron-team/skillhub",
"version": "0.1.6",
"version": "0.1.9",
"description": "Manage and install skills for AI coding agents",
"keywords": [
"skillhub",

View file

@ -1,6 +1,7 @@
import { homedir } from 'node:os'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
import type { AgentCandidate } from './types'
import { allProfiles, profileMap } from './detector'
@ -9,20 +10,31 @@ export interface ResolveInstallTargetOptions {
home?: string | undefined
dir?: string | undefined
agents?: string[] | undefined
scope?: 'user' | 'project' | undefined
json: boolean
interactive: boolean
detected?: AgentCandidate[] | undefined
}
export async function resolveInstallTargets(options: ResolveInstallTargetOptions): Promise<AgentCandidate[]> {
if (options.dir && options.agents?.length) {
const agentList = options.agents ?? []
if (options.dir && agentList.length > 0) {
throw new CliError('--dir cannot be used with --agent', EXIT.usage)
}
if (options.dir && options.scope !== undefined) {
throw new CliError('--dir cannot be used with --scope', EXIT.usage)
}
if (options.dir) {
return [{ agent: 'custom', rootDir: options.dir, scope: 'user', source: 'explicit' }]
}
if (options.agents?.length) {
const resolved = await resolveExplicitAgents(options.agents, options.cwd, options.home ?? homedir())
if (options.scope !== undefined) {
return resolveScopedTargets(options, agentList)
}
if (agentList.length > 0) {
const resolved = await resolveExplicitAgents(agentList, options.cwd, options.home ?? homedir())
return dedupeByRoot(resolved)
}
const detected = options.detected ?? await detectAll(options.cwd, options.home ?? '')
@ -39,6 +51,68 @@ export async function resolveInstallTargets(options: ResolveInstallTargetOptions
return [{ agent: 'generic', rootDir: `${options.cwd}/.agents/skills`, scope: 'project', source: 'fallback' }]
}
async function resolveScopedTargets(
options: ResolveInstallTargetOptions,
agentList: string[]
): Promise<AgentCandidate[]> {
const scope = options.scope!
const scopedHome = options.home ?? homedir()
let candidates: AgentCandidate[]
if (agentList.length > 0) {
candidates = await resolveExplicitAgents(agentList, options.cwd, scopedHome, scope)
} else if (options.detected !== undefined) {
candidates = options.detected.filter(c => c.scope === scope)
} else {
candidates = await generateScopedCandidates(scope, options.cwd, scopedHome)
}
candidates = await dedupeByRoot(candidates)
if (scope === 'user' && agentList.length === 0 && options.interactive && !options.json) {
candidates = await dedupeByRoot([
...candidates,
{
agent: 'generic',
rootDir: `${scopedHome}/.agents/skills`,
scope: 'user',
source: 'fallback'
}
])
}
if (candidates.length === 0) {
const fallbackRoot = scope === 'user'
? `${scopedHome}/.agents/skills`
: `${options.cwd}/.agents/skills`
return [{ agent: 'generic', rootDir: fallbackRoot, scope, source: 'fallback' }]
}
if (candidates.length === 1) return candidates
if (options.interactive && !options.json) {
return selectTargetsInteractively(candidates)
}
throw new CliError('multiple install targets detected', EXIT.usage, {
next: 'pass --agent or --dir',
candidates
})
}
async function generateScopedCandidates(
scope: 'user' | 'project',
cwd: string,
home: string
): Promise<AgentCandidate[]> {
const results: AgentCandidate[] = []
for (const profile of allProfiles) {
const roots = scope === 'user' ? profile.userRoots(home) : profile.projectRoots(cwd)
for (const root of roots) {
if (await pathExists(root)) {
results.push({ agent: profile.id, rootDir: root, scope, source: 'detected' })
}
}
}
return results
}
async function detectAll(cwd: string, home: string): Promise<AgentCandidate[]> {
const results: AgentCandidate[] = []
for (const profile of allProfiles) {
@ -48,7 +122,12 @@ async function detectAll(cwd: string, home: string): Promise<AgentCandidate[]> {
return dedupeByRoot(results)
}
async function resolveExplicitAgents(agents: string[], cwd: string, home?: string): Promise<AgentCandidate[]> {
async function resolveExplicitAgents(
agents: string[],
cwd: string,
home: string,
scope?: 'user' | 'project'
): Promise<AgentCandidate[]> {
const results: AgentCandidate[] = []
for (const agentId of agents) {
const profile = profileMap.get(agentId)
@ -57,34 +136,48 @@ async function resolveExplicitAgents(agents: string[], cwd: string, home?: strin
next: 'use a supported agent profile or pass --dir'
})
}
const userRoots = home ? profile.userRoots(home) : []
const roots = userRoots.length > 0 ? userRoots : profile.projectRoots(cwd)
if (roots.length > 0) {
results.push(...roots.map(root => {
const scope: AgentCandidate['scope'] = root.startsWith(cwd) ? 'project' : 'user'
return {
let roots: string[]
if (scope === 'user') {
roots = profile.userRoots(home)
} else if (scope === 'project') {
roots = profile.projectRoots(cwd)
} else {
const userRoots = home ? profile.userRoots(home) : []
roots = userRoots.length > 0 ? userRoots : profile.projectRoots(cwd)
}
const userRootSet = new Set(home ? profile.userRoots(home) : [])
for (const root of roots) {
const candidateScope: AgentCandidate['scope'] = scope !== undefined
? scope
: (userRootSet.has(root) ? 'user' : 'project')
results.push({
agent: agentId,
rootDir: root,
scope,
source: 'explicit' as const
}
}))
scope: candidateScope,
source: 'explicit'
})
}
}
return results
}
function dedupeByRoot(candidates: AgentCandidate[]): AgentCandidate[] {
async function dedupeByRoot(candidates: AgentCandidate[]): Promise<AgentCandidate[]> {
const seen = new Set<string>()
return candidates.filter(c => {
if (seen.has(c.rootDir)) return false
seen.add(c.rootDir)
return true
})
const deduped: AgentCandidate[] = []
for (const candidate of candidates) {
const canonicalRootDir = await canonicalizeExistingPath(candidate.rootDir)
if (seen.has(canonicalRootDir)) continue
seen.add(canonicalRootDir)
deduped.push(candidate)
}
return deduped
}
async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise<AgentCandidate[]> {
const prompts = await import('prompts')
let highlightedIndex = 0
const { selected } = await prompts.default({
type: 'multiselect',
name: 'selected',
@ -92,7 +185,13 @@ async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise
choices: candidates.map(c => ({
title: `${c.agent} (${c.rootDir})`,
value: c
}))
})),
onRender: function (this: { cursor?: number }) {
highlightedIndex = this.cursor ?? highlightedIndex
},
format: (selectedTargets: AgentCandidate[]) => (
selectedTargets.length > 0 ? selectedTargets : [candidates[highlightedIndex] ?? candidates[0]!]
)
})
if (!selected || selected.length === 0) {
throw new CliError('installation cancelled', EXIT.usage)

View file

@ -44,6 +44,19 @@ export interface PublishResponse {
visibility: string
}
export interface DryRunResponse {
valid: boolean
errors: string[]
warnings: string[]
resolvedSlug: string | null
resolvedVersion: string | null
}
interface ErrorEnvelope {
msg?: unknown
requestId?: unknown
}
export class SkillHubClient {
constructor(
readonly registry: string,
@ -80,9 +93,12 @@ export class SkillHubClient {
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
if (response.status === 401 || response.status === 403) {
if (response.status === 401) {
throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' })
}
if (response.status === 403) {
throw await this.createAccessDeniedError(response)
}
if (response.status === 404) {
throw new CliError('skill or version not found', EXIT.generic, { registry: this.registry })
}
@ -113,6 +129,23 @@ export class SkillHubClient {
return this.handleJsonResponse<PublishResponse>(response)
}
async validatePublish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<DryRunResponse> {
const formData = new FormData()
formData.append('file', file, fileName)
formData.append('visibility', visibility)
let response: Response
try {
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish/validate`, {
method: 'POST',
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
body: formData
})
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
return this.handleJsonResponse<DryRunResponse>(response)
}
private async getJson<T>(path: string): Promise<T> {
let response: Response
try {
@ -126,9 +159,12 @@ export class SkillHubClient {
}
private async handleJsonResponse<T>(response: Response): Promise<T> {
if (response.status === 401 || response.status === 403) {
if (response.status === 401) {
throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' })
}
if (response.status === 403) {
throw await this.createAccessDeniedError(response)
}
if (response.status === 404) {
throw new CliError('resource not found', EXIT.generic, { registry: this.registry })
}
@ -144,6 +180,26 @@ export class SkillHubClient {
return body.data as T
}
private async createAccessDeniedError(response: Response): Promise<CliError> {
const error = await this.readErrorEnvelope(response)
return new CliError(error.message ?? 'access denied', EXIT.auth, {
registry: this.registry,
...(error.requestId ? { requestId: error.requestId } : {})
})
}
private async readErrorEnvelope(response: Response): Promise<{ message?: string; requestId?: string }> {
try {
const body = await response.json() as ErrorEnvelope
return {
...(typeof body.msg === 'string' && body.msg.trim() ? { message: body.msg } : {}),
...(typeof body.requestId === 'string' && body.requestId.trim() ? { requestId: body.requestId } : {})
}
} catch {
return {}
}
}
private headers(): HeadersInit {
return this.token ? { Authorization: `Bearer ${this.token}` } : {}
}

View file

@ -28,13 +28,17 @@ 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',
usage: 'skillhub install <slug> [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--json]',
examples: ['skillhub install pdf-parser', 'skillhub install pdf-parser --agent codex']
usage: 'skillhub install <slug> [--scope <user|project>] [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--json]',
examples: [
'skillhub install pdf-parser',
'skillhub install pdf-parser --scope user',
'skillhub install pdf-parser --scope project --agent codex'
]
},
list: {
summary: 'List local installs',

View file

@ -3,34 +3,113 @@ import { CredentialsStore } from '../stores/credentials-store'
import { resolveRegistry, resolveToken } from '../services/registry-service'
import { installSkill } from '../services/install-service'
import { resolveInstallTargets } from '../agents/resolver'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { parseSkillName } from '../shared/skill-name-parser'
export interface InstallCommandOptions {
namespace?: string | undefined
version?: string | undefined
agent?: string[] | undefined
dir?: string | undefined
scope?: string | undefined
force?: boolean | undefined
registry?: string | undefined
token?: string | undefined
json?: boolean | undefined
}
export async function installCommand(slug: string, options: InstallCommandOptions): Promise<string> {
export interface InstallCommandDeps {
promptScope?: () => Promise<'user' | 'project'>
resolveInstallTargets?: typeof resolveInstallTargets
installSkill?: typeof installSkill
isTTY?: () => boolean
}
export function computeStrictIsTTY(env: {
stdinIsTTY: boolean
stdoutIsTTY: boolean
json: boolean
}): boolean {
return env.stdinIsTTY && env.stdoutIsTTY && !env.json
}
export async function resolveEffectiveScope(
options: InstallCommandOptions,
env: { isTTY: boolean; promptScope: () => Promise<'user' | 'project'> }
): Promise<'user' | 'project' | undefined> {
if (options.scope !== undefined && options.scope !== 'user' && options.scope !== 'project') {
throw new CliError('--scope must be "user" or "project"', EXIT.usage)
}
const scope = options.scope as 'user' | 'project' | undefined
const agentList = options.agent ?? []
if (options.dir && scope !== undefined) {
throw new CliError('--dir cannot be used with --scope', EXIT.usage)
}
if (options.dir && agentList.length > 0) {
throw new CliError('--dir cannot be used with --agent', EXIT.usage)
}
if (scope !== undefined) return scope
if (options.dir || agentList.length > 0) return undefined
if (env.isTTY) return await env.promptScope()
return undefined
}
async function defaultPromptScope(): Promise<'user' | 'project'> {
const prompts = await import('prompts')
const { scope } = await prompts.default({
type: 'select',
name: 'scope',
message: 'Install for user or project?',
choices: [
{ title: 'User (install to user-level agent directory)', value: 'user' },
{ title: 'Project (install to project-level agent directory)', value: 'project' }
]
})
if (!scope) {
throw new CliError('installation cancelled', EXIT.usage)
}
return scope
}
export async function installCommand(
skillNameArg: string,
options: InstallCommandOptions,
deps: InstallCommandDeps = {}
): Promise<string> {
const isTTYFn = deps.isTTY ?? (() => computeStrictIsTTY({
stdinIsTTY: process.stdin.isTTY === true,
stdoutIsTTY: process.stdout.isTTY === true,
json: Boolean(options.json)
}))
const isTTY = isTTYFn()
const promptScope = deps.promptScope ?? defaultPromptScope
const effectiveScope = await resolveEffectiveScope(options, { isTTY, promptScope })
const configStore = new ConfigStore()
const credentialsStore = new CredentialsStore()
const registry = resolveRegistry(options, process.env, await configStore.read())
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
const namespace = options.namespace ?? 'global'
const targets = await resolveInstallTargets({
const parsed = parseSkillName(skillNameArg)
const namespace = options.namespace ?? parsed.namespace
const slug = parsed.slug
const resolveTargets = deps.resolveInstallTargets ?? resolveInstallTargets
const targets = await resolveTargets({
cwd: process.cwd(),
scope: effectiveScope,
dir: options.dir,
agents: options.agent ?? [],
json: Boolean(options.json),
interactive: process.stdout.isTTY === true
interactive: isTTY
})
const result = await installSkill({
const installFn = deps.installSkill ?? installSkill
const result = await installFn({
registry, token, namespace, slug,
version: options.version,
targets,

View file

@ -14,6 +14,7 @@ export interface PublishCommandOptions {
registry?: string
token?: string
json?: boolean
dryRun?: boolean
}
export async function publishCommand(path: string, options: PublishCommandOptions): Promise<string> {
@ -40,7 +41,6 @@ export async function publishCommand(path: string, options: PublishCommandOption
let archiveBlob: Blob
let archiveName: string
if (pathStat.isFile()) {
// If input is a file, check if it's already a zip
if (await isZipFile(path)) {
const buffer = await readFile(path)
archiveBlob = new Blob([buffer], { type: 'application/zip' })
@ -49,7 +49,6 @@ export async function publishCommand(path: string, options: PublishCommandOption
throw new CliError(`file must be a zip archive: ${path}`, EXIT.filesystem, { path })
}
} else if (pathStat.isDirectory()) {
// If input is a directory, create zip from it
archiveBlob = await createZip(path)
archiveName = `${basename(path)}.zip`
} else {
@ -57,6 +56,49 @@ export async function publishCommand(path: string, options: PublishCommandOption
}
const client = new SkillHubClient(registry, token)
if (options.dryRun) {
const result = await client.validatePublish(namespace, archiveBlob, toServerVisibility(visibility), archiveName)
if (options.json) {
if (!result.valid) {
process.stdout.write(JSON.stringify(result) + '\n')
throw new CliError('validation failed', EXIT.validation)
}
return JSON.stringify(result)
}
const lines: string[] = []
if (result.valid) {
lines.push('Validation passed')
} else {
lines.push('Validation failed')
}
if (result.resolvedSlug) {
lines.push(` Slug: ${result.resolvedSlug}`)
}
if (result.resolvedVersion) {
lines.push(` Version: ${result.resolvedVersion}`)
}
if (result.errors.length > 0) {
lines.push('Errors:')
for (const error of result.errors) {
lines.push(` - ${error}`)
}
}
if (result.warnings.length > 0) {
lines.push('Warnings:')
for (const warning of result.warnings) {
lines.push(` - ${warning}`)
}
}
if (!result.valid) {
process.stdout.write(lines.join('\n') + '\n')
throw new CliError('validation failed', EXIT.validation)
}
return lines.join('\n')
}
const result = await client.publish(namespace, archiveBlob, toServerVisibility(visibility), archiveName)
const detailUrl = `${registry}/space/${result.namespace}/${encodeURIComponent(result.slug)}`

View file

@ -5,6 +5,7 @@ import { resolveRegistry, resolveToken } from '../services/registry-service'
import { removeLocalSkill } from '../services/remove-service'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { parseSkillName } from '../shared/skill-name-parser'
export interface RemoveCommandOptions {
agent?: string[] | undefined
@ -17,7 +18,7 @@ export interface RemoveCommandOptions {
json?: boolean | undefined
}
export async function removeCommand(slug: string, options: RemoveCommandOptions): Promise<string> {
export async function removeCommand(skillNameArg: string, options: RemoveCommandOptions): Promise<string> {
if (options.all && options.agent?.length) {
throw new CliError('--all cannot be used with --agent', EXIT.usage)
}
@ -29,9 +30,12 @@ export async function removeCommand(slug: string, options: RemoveCommandOptions)
const credentialsStore = new CredentialsStore()
const registry = resolveRegistry(options, process.env, await configStore.read())
const parsed = parseSkillName(skillNameArg)
const namespace = options.namespace ?? parsed.namespace
const slug = parsed.slug
if (options.remote) {
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
const namespace = options.namespace ?? 'global'
if (!options.hard && process.stdout.isTTY) {
const prompts = await import('prompts')

View file

@ -1,3 +1,3 @@
// Generated by scripts/generate-pkg-info.ts - do not edit by hand.
export const PKG_NAME = "@astron-team/skillhub"
export const PKG_VERSION = "0.1.6"
export const PKG_VERSION = "0.1.9"

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))
})
@ -233,6 +234,7 @@ cli
.command('install <slug>', 'Install a skill locally')
.option('--namespace <slug>', 'Namespace', { default: 'global' })
.option('--version <v>', 'Version')
.option('--scope <scope>', 'Install scope: user or project')
.option('--agent <profile>', 'Agent profile (repeatable)')
.option('--dir <path>', 'Install directory')
.option('--force', 'Overwrite existing')
@ -278,6 +280,7 @@ cli
.command('publish <path>', 'Publish a local skill package')
.option('--namespace <slug>', 'Namespace')
.option('--visibility <v>', 'Visibility (public|namespace-only|private)')
.option('--dry-run', 'Validate without publishing')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--json', 'Output JSON')

View file

@ -1,6 +1,14 @@
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { zipSync, unzipSync } from 'fflate'
import { MAX_PACKAGE_BYTES } from './download'
const MAX_ZIP_ENTRIES = 500
const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024
const EOCD_SIGNATURE = 0x06054b50
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50
const ZIP64_MARKER_16 = 0xffff
const ZIP64_MARKER_32 = 0xffffffff
/**
* Extract a zip archive buffer into the target directory.
@ -8,9 +16,15 @@ import { zipSync, unzipSync } from 'fflate'
*/
export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promise<void> {
await mkdir(targetDir, { recursive: true })
const files = unzipSync(new Uint8Array(buffer))
for (const [name, data] of Object.entries(files)) {
const filePath = safeJoin(targetDir, name)
const archive = new Uint8Array(buffer)
validateZipCentralDirectory(archive)
const files = unzipSync(archive)
const entries = Object.entries(files).map(([name, data]) => ({
name,
data,
filePath: safeJoin(targetDir, name),
}))
for (const { name, data, filePath } of entries) {
if (name.endsWith('/')) {
await mkdir(filePath, { recursive: true })
continue
@ -20,6 +34,78 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis
}
}
function validateZipCentralDirectory(archive: Uint8Array): void {
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
const eocdOffset = findEndOfCentralDirectory(view)
if (eocdOffset < 0) {
throw new Error('invalid zip central directory')
}
const diskNumber = view.getUint16(eocdOffset + 4, true)
const centralDirectoryDisk = view.getUint16(eocdOffset + 6, true)
const entriesOnDisk = view.getUint16(eocdOffset + 8, true)
const totalEntries = view.getUint16(eocdOffset + 10, true)
const centralDirectorySize = view.getUint32(eocdOffset + 12, true)
const centralDirectoryOffset = view.getUint32(eocdOffset + 16, true)
if (
entriesOnDisk === ZIP64_MARKER_16 ||
totalEntries === ZIP64_MARKER_16 ||
centralDirectorySize === ZIP64_MARKER_32 ||
centralDirectoryOffset === ZIP64_MARKER_32
) {
throw new Error('zip64 archives are not supported')
}
if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== totalEntries) {
throw new Error('multi-disk zip archives are not supported')
}
if (totalEntries > MAX_ZIP_ENTRIES) {
throw new Error('zip entry count exceeds limit')
}
if (centralDirectoryOffset + centralDirectorySize > archive.byteLength) {
throw new Error('invalid zip central directory')
}
let offset = centralDirectoryOffset
let totalUncompressedSize = 0
const decoder = new TextDecoder()
for (let i = 0; i < totalEntries; i++) {
if (offset + 46 > archive.byteLength || view.getUint32(offset, true) !== CENTRAL_DIRECTORY_SIGNATURE) {
throw new Error('invalid zip central directory')
}
const uncompressedSize = view.getUint32(offset + 24, true)
const nameLength = view.getUint16(offset + 28, true)
const extraLength = view.getUint16(offset + 30, true)
const commentLength = view.getUint16(offset + 32, true)
const nameStart = offset + 46
const nameEnd = nameStart + nameLength
const nextOffset = nameEnd + extraLength + commentLength
if (nameEnd > archive.byteLength || nextOffset > archive.byteLength) {
throw new Error('invalid zip central directory')
}
const entryName = decoder.decode(archive.subarray(nameStart, nameEnd))
if (!entryName.endsWith('/') && uncompressedSize > MAX_SINGLE_FILE_BYTES) {
throw new Error('zip entry size exceeds limit')
}
totalUncompressedSize += uncompressedSize
if (totalUncompressedSize > MAX_PACKAGE_BYTES) {
throw new Error('zip total uncompressed size exceeds limit')
}
offset = nextOffset
}
}
function findEndOfCentralDirectory(view: DataView): number {
const minOffset = Math.max(0, view.byteLength - 0xffff - 22)
for (let offset = view.byteLength - 22; offset >= minOffset; offset--) {
if (view.getUint32(offset, true) === EOCD_SIGNATURE) {
return offset
}
}
return -1
}
/**
* Create a zip archive from a directory.
* Returns the archive as a Blob.

View file

@ -0,0 +1,56 @@
import { EXIT } from '../shared/constants'
import { CliError } from '../shared/errors'
export const MAX_PACKAGE_BYTES = 100 * 1024 * 1024
export async function readBoundedResponseBody(response: Response, maxBytes = MAX_PACKAGE_BYTES): Promise<ArrayBuffer> {
const contentLength = response.headers.get('content-length')
if (contentLength !== null) {
const declaredLength = Number(contentLength)
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new CliError('download exceeds maximum package size', EXIT.network, {
contentLength: declaredLength,
maxBytes
})
}
}
if (!response.body) {
const buffer = await response.arrayBuffer()
if (buffer.byteLength > maxBytes) {
throw new CliError('download exceeds maximum package size', EXIT.network, {
receivedBytes: buffer.byteLength,
maxBytes
})
}
return buffer
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let receivedBytes = 0
for (;;) {
const { done, value } = await reader.read()
if (done) {
break
}
receivedBytes += value.byteLength
if (receivedBytes > maxBytes) {
await reader.cancel()
throw new CliError('download exceeds maximum package size', EXIT.network, {
receivedBytes,
maxBytes
})
}
chunks.push(value)
}
const result = new Uint8Array(receivedBytes)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.byteLength
}
return result.buffer
}

View file

@ -24,6 +24,15 @@ export async function pathExists(path: string): Promise<boolean> {
}
}
export async function canonicalizeExistingPath(path: string): Promise<string> {
const { realpath } = await import('node:fs/promises')
try {
return await realpath(path)
} catch {
return path
}
}
export async function applyCredentialPermissions(path: string): Promise<void> {
if (process.platform === 'win32') return
const { chmod } = await import('node:fs/promises')

View file

@ -1,11 +1,12 @@
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { SkillHubClient } from '../clients/skillhub-client'
import { InventoryStore } from '../stores/inventory-store'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { extractZip } from '../platform/archive'
import { pathExists } from '../platform/paths'
import { readBoundedResponseBody } from '../platform/download'
import { canonicalizeExistingPath, pathExists } from '../platform/paths'
import type { AgentCandidate } from '../agents/types'
export interface InstallOptions {
@ -19,54 +20,105 @@ export interface InstallOptions {
home?: string | undefined
}
export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> {
const client = new SkillHubClient(options.registry, options.token)
const resolved = await client.resolve(options.namespace, options.slug, options.version)
const response = await client.download(options.namespace, options.slug, resolved.version)
const buffer = await response.arrayBuffer()
async function preflightInstallTargets(
targets: AgentCandidate[],
slug: string,
force: boolean
): Promise<Array<{ target: AgentCandidate; skillDir: string }>> {
const seenSkillDirs = new Set<string>()
const preparedTargets: Array<{ target: AgentCandidate; skillDir: string }> = []
const installed: Array<{ agent: string; dir: string }> = []
const store = new InventoryStore(options.home)
for (const target of targets) {
const canonicalRootDir = await canonicalizeExistingPath(target.rootDir)
const canonicalSkillDir = join(canonicalRootDir, slug)
if (seenSkillDirs.has(canonicalSkillDir)) {
throw new CliError(`multiple install targets resolve to ${canonicalSkillDir}`, EXIT.usage, {
path: canonicalSkillDir,
next: 'select only one target for this directory'
})
}
seenSkillDirs.add(canonicalSkillDir)
for (const target of options.targets) {
const skillDir = join(target.rootDir, options.slug)
if (await pathExists(skillDir) && !options.force) {
const skillDir = join(target.rootDir, slug)
if (await pathExists(skillDir) && !force) {
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
path: skillDir,
next: 'pass --force to overwrite'
})
}
preparedTargets.push({ target, skillDir })
}
if (await pathExists(skillDir) && options.force) {
await store.removeTargetsByInstallDir(skillDir)
await rm(skillDir, { recursive: true, force: true })
return preparedTargets
}
export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> {
const preparedTargets = await preflightInstallTargets(options.targets, options.slug, options.force)
const client = new SkillHubClient(options.registry, options.token)
const resolved = await client.resolve(options.namespace, options.slug, options.version)
const response = await client.download(options.namespace, options.slug, resolved.version)
const buffer = await readBoundedResponseBody(response)
const installed: Array<{ agent: string; dir: string }> = []
const store = new InventoryStore(options.home)
for (const { target, skillDir } of preparedTargets) {
await mkdir(target.rootDir, { recursive: true })
const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`))
let movedIntoPlace = false
try {
await extractZip(buffer, tempDir)
const installedAt = new Date().toISOString()
const metaDir = join(tempDir, '.skillhub')
await mkdir(metaDir, { recursive: true })
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
registry: options.registry,
namespace: options.namespace,
slug: options.slug,
version: resolved.version,
agent: target.agent,
installedAt
}, null, 2))
if (await pathExists(skillDir) && !options.force) {
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
path: skillDir,
next: 'pass --force to overwrite'
})
}
if (await pathExists(skillDir) && options.force) {
await store.removeTargetsByInstallDir(skillDir)
await rm(skillDir, { recursive: true, force: true })
}
try {
await rename(tempDir, skillDir)
} catch (error) {
if (!options.force && await pathExists(skillDir)) {
throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, {
path: skillDir,
next: 'pass --force to overwrite'
})
}
throw error
}
movedIntoPlace = true
await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, {
agent: target.agent,
rootDir: target.rootDir,
installDir: skillDir,
installedAt
})
} finally {
if (!movedIntoPlace) {
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
}
}
// Create skill directory and extract into a clean skill-specific directory.
await mkdir(skillDir, { recursive: true })
await extractZip(buffer, skillDir)
// Write .skillhub/metadata.json
const metaDir = join(skillDir, '.skillhub')
await mkdir(metaDir, { recursive: true })
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
registry: options.registry,
namespace: options.namespace,
slug: options.slug,
version: resolved.version,
agent: target.agent,
installedAt: new Date().toISOString()
}, null, 2))
// Update inventory
await store.upsertTarget(options.registry, options.namespace, options.slug, resolved.version, {
agent: target.agent,
rootDir: target.rootDir,
installDir: skillDir,
installedAt: new Date().toISOString()
})
installed.push({ agent: target.agent, dir: skillDir })
}

View file

@ -8,5 +8,6 @@ export const EXIT = {
auth: 2,
network: 3,
filesystem: 4,
usage: 5
usage: 5,
validation: 6
} as const

View file

@ -30,6 +30,9 @@ export function renderError(error: unknown, json: boolean): string {
if (typeof cliError.details.path === 'string') {
lines.push(`Context: path ${cliError.details.path}`)
}
if (typeof cliError.details.requestId === 'string') {
lines.push(`Request ID: ${cliError.details.requestId}`)
}
if (typeof cliError.details.next === 'string') {
lines.push(`Next: ${cliError.details.next}`)
}

View file

@ -0,0 +1,27 @@
export interface ParsedSkillName {
namespace: string
slug: string
}
export function parseSkillName(skillName: string, defaultNamespace = 'global'): ParsedSkillName {
const separatorIndex = skillName.indexOf('--')
if (separatorIndex <= 0) {
return {
namespace: defaultNamespace,
slug: separatorIndex === 0 ? skillName.slice(2) : skillName
}
}
if (separatorIndex === skillName.length - 2) {
return {
namespace: defaultNamespace,
slug: skillName.slice(0, -2)
}
}
return {
namespace: skillName.slice(0, separatorIndex),
slug: skillName.slice(separatorIndex + 2)
}
}

View file

@ -22,16 +22,23 @@ export function createFakeRegistry(handlers: Record<string, FakeHandler>) {
/**
* Controls how a specific endpoint behaves when a failure is injected:
* 'auth' => 401 { code: 401, message: 'unauthorized' }
* 'forbidden' => 403 with a standard SkillHub error envelope
* 'not_found' => 404 { code: 404, message: 'not found' }
* 'server_error' => 500 { code: 500, message: 'internal error' }
* 'network' => handler throws, causing fetch() to reject with a TypeError
*/
export type FailureMode = 'auth' | 'not_found' | 'server_error' | 'network'
export type FailureMode = 'auth' | 'forbidden' | 'not_found' | 'server_error' | 'network'
function failureResponse(mode: FailureMode): Response {
switch (mode) {
case 'auth':
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
case 'forbidden':
return Response.json({
code: 403,
msg: 'API token is missing required scope: skill:publish',
requestId: 'req-test-forbidden'
}, { status: 403 })
case 'not_found':
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
case 'server_error':
@ -91,6 +98,12 @@ export interface CapturedPublish {
visibility: string
}
export interface CapturedValidate {
namespace: string
fileName: string
visibility: string
}
/** Last resolve GET: useful for verifying --version is forwarded as ?version=. */
export interface CapturedResolve {
namespace: string
@ -116,6 +129,8 @@ interface FakeRegistryOptions {
searchItems?: Array<{ namespace: string; slug: string; latestVersion: string; summary: string }>
/** Skills available for resolve / download / delete / publish. */
skills?: FakeSkill[]
/** Response to return for publish/validate (dry-run) requests. */
dryRunResponse?: { valid: boolean; errors: string[]; warnings: string[]; resolvedSlug: string | null; resolvedVersion: string | null }
/**
* Per-endpoint failure injection. When set for an endpoint, that endpoint
* ignores all other logic and returns the specified failure (or throws for
@ -128,6 +143,7 @@ interface FakeRegistryOptions {
download?: FailureMode
deleteRemote?: FailureMode
publish?: FailureMode
validate?: FailureMode
}
}
@ -167,7 +183,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
publish: CapturedPublish | null
resolve: CapturedResolve | null
delete: CapturedDelete | null
} = { publish: null, resolve: null, delete: null }
validate: CapturedValidate | null
} = { publish: null, resolve: null, delete: null, validate: null }
// If any endpoint is configured with 'network' failure mode, we need a real
// TCP-level failure. Start a connection-dropping server and return its URL
@ -339,6 +356,34 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
})
}
// Validate (dry-run): POST /api/cli/v1/skills/:namespace/publish/validate
const validateMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/publish\/validate$/)
if (validateMatch && req.method === 'POST') {
if (options.failures?.validate) return failureResponse(options.failures.validate)
const authErr = checkAuth(req)
if (authErr) return authErr
const namespace = validateMatch[1]!
return req.formData().then(form => {
const fileField = form.get('file')
const visibility = (form.get('visibility') as string | null) ?? 'PUBLIC'
let fileName = 'skill.zip'
if (fileField instanceof File) {
fileName = fileField.name || fileName
}
state.validate = { namespace, fileName, visibility }
const dryRunData = options.dryRunResponse ?? {
valid: true,
errors: [],
warnings: [],
resolvedSlug: fileName.replace(/\.zip$/, ''),
resolvedVersion: '1.0.0'
}
return Response.json({ code: 0, data: dryRunData })
})
}
// Publish: POST /api/cli/v1/skills/:namespace/publish
const publishMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/publish$/)
if (publishMatch && req.method === 'POST') {

View file

@ -0,0 +1,159 @@
/**
* End-to-end integration coverage for token / registry priority resolution.
*
* The unit test in test/unit/services/registry-service.test.ts pins the
* resolution function in isolation. These tests verify the same priorities
* are wired through the actual CLI subprocess: --flag > SKILLHUB_* env >
* stored config / credentials > built-in default.
*
* Why this matters: a regression in the wiring (e.g. command forgets to
* forward `process.env`) would silently downgrade users to the wrong
* registry / token without surfacing in unit tests.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
let registryB: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop(); registry = undefined
registryB?.stop(); registryB = undefined
})
async function seedCredentials(home: string, registryUrl: string, token: string): Promise<void> {
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(
join(home, '.skillhub', 'credentials.json'),
JSON.stringify({ tokens: { [registryUrl]: token } })
)
}
async function seedConfig(home: string, registryUrl: string): Promise<void> {
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(
join(home, '.skillhub', 'config.json'),
JSON.stringify({ registry: registryUrl })
)
}
// ---------------------------------------------------------------------------
// Token priority: --token > SKILLHUB_TOKEN > stored
// ---------------------------------------------------------------------------
describe('auth resolution — token priority', () => {
test('--token flag wins over SKILLHUB_TOKEN env', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_from_flag',
user: { handle: 'flag-user', displayName: 'Flag' }
})
const result = await runCli(
['whoami', '--registry', registry.url, '--token', 'sk_from_flag'],
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_TOKEN: 'sk_wrong_from_env' }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('flag-user')
})
test('SKILLHUB_TOKEN env wins over stored token', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_from_env',
user: { handle: 'env-user', displayName: 'Env' }
})
await seedCredentials(env.home, registry.url, 'sk_wrong_from_storage')
const result = await runCli(
['whoami', '--registry', registry.url],
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_TOKEN: 'sk_from_env' }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('env-user')
})
test('stored token used when neither --token nor env is set', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_from_storage',
user: { handle: 'storage-user', displayName: 'Storage' }
})
await seedCredentials(env.home, registry.url, 'sk_from_storage')
const result = await runCli(
['whoami', '--registry', registry.url],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('storage-user')
})
})
// ---------------------------------------------------------------------------
// Registry priority: --registry > SKILLHUB_REGISTRY > config.json
// ---------------------------------------------------------------------------
describe('auth resolution — registry priority', () => {
test('--registry flag wins over SKILLHUB_REGISTRY env', async () => {
const env = await createTempHome()
// Each registry only authenticates its own token. The wrong registry
// would 401, so a successful whoami proves the right one was used.
registry = await startFakeRegistry({
token: 'sk_a',
user: { handle: 'a-user', displayName: 'A' }
})
registryB = await startFakeRegistry({
token: 'sk_b',
user: { handle: 'b-user', displayName: 'B' }
})
const result = await runCli(
['whoami', '--registry', registry.url, '--token', 'sk_a'],
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_REGISTRY: registryB.url }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('a-user')
})
test('SKILLHUB_REGISTRY env wins over config.registry', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_env',
user: { handle: 'env-reg', displayName: 'EnvReg' }
})
registryB = await startFakeRegistry({
token: 'sk_config',
user: { handle: 'config-reg', displayName: 'ConfigReg' }
})
await seedConfig(env.home, registryB.url)
const result = await runCli(
['whoami', '--token', 'sk_env'],
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_REGISTRY: registry.url }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('env-reg')
})
test('config.registry used when no --registry / env present', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_config',
user: { handle: 'config-only-user', displayName: 'CfgOnly' }
})
await seedConfig(env.home, registry.url)
await seedCredentials(env.home, registry.url, 'sk_config')
const result = await runCli(
['whoami'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('config-only-user')
})
})

View file

@ -0,0 +1,164 @@
/**
* Concurrency tests for inventory.json bookkeeping.
*
* inventory-store.ts uses an OS-level lock file with retry + stale-lock
* detection. These tests exercise that path through real CLI subprocesses
* (Bun.spawn) running in parallel the same way users hit it when scripts
* fan out installs.
*
* The unit test in test/unit/stores/inventory-store.test.ts pins the
* single-process lock recovery; here we cover the cross-process case.
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop(); registry = undefined
})
function makeSkillZip(): Uint8Array {
return zipSync({ 'SKILL.md': strToU8('# c') })
}
describe('cross-process concurrency on inventory.json', () => {
// KNOWN BUG (documented here, not yet fixed):
// inventory-store.upsertTarget() reads inventory, modifies in memory,
// then writeAtomic() acquires the lock only over the write half. Two
// concurrent installs each read the (empty) inventory, each adds their
// own item, and the second writer overwrites the first — a classic
// lost-update.
//
// When the fix lands (lock spans read+write, or upsertTarget acquires
// the lock first and re-reads), tighten the inventory assertion to
// `expect(slugs).toEqual(['first', 'second'])`.
test('two parallel installs of distinct slugs: filesystem is correct, inventory has at least one (lost-update bug pinned)', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [
{ namespace: 'global', slug: 'first', version: '1.0.0', zipBytes: makeSkillZip() },
{ namespace: 'global', slug: 'second', version: '1.0.0', zipBytes: makeSkillZip() }
]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const dirA = join(env.cwd, 'A')
const dirB = join(env.cwd, 'B')
await mkdir(dirA, { recursive: true })
await mkdir(dirB, { recursive: true })
const [r1, r2] = await Promise.all([
runCli(
['install', 'first', '--dir', dirA, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
),
runCli(
['install', 'second', '--dir', dirB, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
])
// Both subprocess installs report success — neither errored at the
// protocol level even though the inventory bookkeeping race ate one of
// their inventory writes.
expect(r1.exitCode).toBe(0)
expect(r2.exitCode).toBe(0)
// Filesystem is correct: both bundles extracted independently.
expect(await Bun.file(join(dirA, 'first', 'SKILL.md')).exists()).toBe(true)
expect(await Bun.file(join(dirB, 'second', 'SKILL.md')).exists()).toBe(true)
const inv = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string }> }
const slugs = inv.items.map(i => i.slug).sort()
// Today: at least one slug always lands; under the lost-update race
// both may NOT be there. When the lock widens to cover read+write,
// upgrade this to `toEqual(['first', 'second'])`.
expect(slugs.length).toBeGreaterThanOrEqual(1)
const lastSlug = slugs[slugs.length - 1]!
expect(['first', 'second']).toContain(lastSlug)
})
test('two parallel installs of the same slug to the same dir: exactly one wins, one conflicts', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'race', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'race-dir')
await mkdir(installDir, { recursive: true })
const [r1, r2] = await Promise.all([
runCli(
['install', 'race', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
),
runCli(
['install', 'race', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
])
// Two valid outcomes: (a) both succeed because the loser's existence
// check ran BEFORE the winner extracted, OR (b) one succeeds and the
// other reports already-installed (EXIT.filesystem).
// Either way, inventory must end up coherent (single item, single
// target — no duplicates).
const codes = [r1.exitCode, r2.exitCode].sort((a, b) => a - b)
expect(codes[0]).toBe(0) // at least one succeeded
const otherCode = codes[1]!
expect([0, 4]).toContain(otherCode) // other either succeeded or got conflict
const inv = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
const item = inv.items.find(i => i.slug === 'race')
expect(item).toBeDefined()
expect(item!.targets).toHaveLength(1) // no duplicate targets
})
test('install proceeds after a stale lock file from a dead process', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'after-stale', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Plant a stale lock file: PID 1 (init, never the same as our test
// child, and won't match the spawned subprocess's PID), with a very
// old timestamp so the store treats it as stale.
const skillhubDir = join(env.home, '.skillhub')
await mkdir(skillhubDir, { recursive: true })
const lockPath = join(skillhubDir, 'inventory.json.lock')
const ancientTimestamp = Date.now() - 600_000 // 10 minutes ago — past the 30s stale threshold
await writeFile(lockPath, JSON.stringify({ pid: 1, timestamp: ancientTimestamp }))
const installDir = join(env.cwd, 'stale')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'after-stale', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const inv = JSON.parse(
await readFile(join(skillhubDir, 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string }> }
expect(inv.items.find(i => i.slug === 'after-stale')).toBeDefined()
})
})

View file

@ -0,0 +1,509 @@
/**
* Cross-command flow tests.
*
* Per-command tests verify each subcommand in isolation. These cases pin
* behaviors that only emerge when commands chain e.g. "logout then install
* fails with auth" or "install + fs-delete + list reports status=missing".
* Bugs in the boundaries between commands (shared inventory, credentials,
* config) tend to slip through single-command suites.
*/
import { mkdir, rm, writeFile, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
let registryB: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop(); registry = undefined
registryB?.stop(); registryB = undefined
})
function makeSkillZip(): Uint8Array {
return zipSync({ 'SKILL.md': strToU8('# x-cross') })
}
// ---------------------------------------------------------------------------
// 1. Auth lifecycle: login → whoami → logout → whoami
// ---------------------------------------------------------------------------
describe('cross-command — auth lifecycle', () => {
test('login → whoami(success) → logout → whoami(not logged in)', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'cycle-user', displayName: 'Cycle' }
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const w1 = await runCli(['whoami', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
expect(w1.exitCode).toBe(0)
expect(w1.stdout).toContain('cycle-user')
await runCli(['logout', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
const w2 = await runCli(['whoami', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
expect(w2.exitCode).toBe(2)
expect(w2.stderr.toLowerCase()).toContain('not logged in')
})
test('logout-then-install against an auth-required registry fails with EXIT.auth', async () => {
const env = await createTempHome()
// Inject auth failure on resolve so this fake server behaves like a
// production registry that requires a bearer token even on resolve.
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
failures: { resolve: 'auth' }
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['logout', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'after-logout')
await mkdir(installDir, { recursive: true })
// No --token here — credentials were just cleared by logout.
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(2) // EXIT.auth
expect(result.stderr.toLowerCase()).toMatch(/auth|401|unauthorized/)
})
})
// ---------------------------------------------------------------------------
// 2. Full local lifecycle: install → list → remove → list
// ---------------------------------------------------------------------------
describe('cross-command — local lifecycle', () => {
test('install → list → remove --all → list shows empty', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'lifecycle')
await mkdir(installDir, { recursive: true })
await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
const list1 = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(JSON.parse(list1.stdout).items).toHaveLength(1)
await runCli(
['remove', 'pdf-parser', '--all', '--registry', registry.url],
{ HOME: env.home, USERPROFILE: env.home }
)
const list2 = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(JSON.parse(list2.stdout).items).toHaveLength(0)
})
test('install x2 same slug + same dir without --force conflicts; --force succeeds; second install replaces first', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'reinstall-here')
await mkdir(installDir, { recursive: true })
const r1 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r1.exitCode).toBe(0)
const r2 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r2.exitCode).toBe(4) // EXIT.filesystem (already installed)
const r3 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r3.exitCode).toBe(0)
// Inventory has exactly one target, not two duplicates.
const inv = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
const item = inv.items.find(i => i.slug === 'pdf-parser')
expect(item?.targets).toHaveLength(1)
})
test('install A then install B (different slugs, same parent dir) → list shows both', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [
{ namespace: 'global', slug: 'a-skill', version: '1.0.0', zipBytes: makeSkillZip() },
{ namespace: 'global', slug: 'b-skill', version: '1.0.0', zipBytes: makeSkillZip() }
]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'two-skills')
await mkdir(installDir, { recursive: true })
await runCli(
['install', 'a-skill', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
await runCli(
['install', 'b-skill', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
const list = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
const items = JSON.parse(list.stdout).items as Array<{ slug: string }>
expect(items.map(i => i.slug).sort()).toEqual(['a-skill', 'b-skill'])
})
test('remove --all → install same slug again succeeds (no stale inventory state)', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'reuse')
await mkdir(installDir, { recursive: true })
await runCli(['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['remove', 'pdf-parser', '--all', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
// Re-install at the same dir without --force should now succeed, since
// the previous install was removed.
const reinstall = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(reinstall.exitCode).toBe(0)
})
})
// ---------------------------------------------------------------------------
// 3. Filesystem drift between install dir and inventory
// ---------------------------------------------------------------------------
describe('cross-command — filesystem drift', () => {
test('install → fs-delete the install dir → list reports status=missing', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'drift')
await mkdir(installDir, { recursive: true })
await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
// External clobber: delete the install dir behind the CLI's back.
await rm(join(installDir, 'pdf-parser'), { recursive: true, force: true })
const list = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(list.exitCode).toBe(0)
const items = JSON.parse(list.stdout).items as Array<{ slug: string; status: string }>
expect(items[0]?.slug).toBe('pdf-parser')
expect(items[0]?.status).toBe('missing')
})
// After commit a14d89d8 ("refactor(cli): improve doctor command
// semantics and transparency") doctor switched from REPLACE to MERGE
// semantics: it never removes inventory entries, even when the install
// dir on disk is gone. Stale entries are surfaced via `list --json`'s
// status="missing" instead. This test pins that contract.
test('install → fs-delete the install dir → doctor preserves the entry; list reports status=missing', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Install into an agent-shaped dir under cwd so doctor will scan it.
const codexSkills = join(env.cwd, '.codex', 'skills')
await mkdir(codexSkills, { recursive: true })
await runCli(
['install', 'pdf-parser', '--dir', codexSkills, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
// Wipe the install but leave the dir tree shape — metadata gone.
await rm(join(codexSkills, 'pdf-parser'), { recursive: true, force: true })
const doctor = await runCli(['doctor', '--json'], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
expect(doctor.exitCode).toBe(0)
// Inventory still has the entry — doctor preserved it because the
// installDir was NOT in the (now-empty) scan result.
const inv = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string }> }
expect(inv.items.find(i => i.slug === 'pdf-parser')).toBeDefined()
// The user-facing surface for "this is gone on disk" is `list` — it
// reports status="missing" by stat'ing the installDir at read time.
const list = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
const items = JSON.parse(list.stdout).items as Array<{ slug: string; status: string }>
expect(items.find(i => i.slug === 'pdf-parser')?.status).toBe('missing')
})
})
// ---------------------------------------------------------------------------
// 4. doctor idempotence
// ---------------------------------------------------------------------------
describe('cross-command — doctor idempotence', () => {
test('two consecutive doctor runs produce identical inventory (idempotent)', async () => {
const env = await createTempHome()
// Seed one valid metadata file.
const metaDir = join(env.cwd, '.codex', 'skills', 'pdf-parser', '.skillhub')
await mkdir(metaDir, { recursive: true })
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'pdf-parser',
version: '1.0.0',
agent: 'codex',
installedAt: '2026-04-20T12:00:00Z'
}))
await runCli(['doctor'], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
const after1 = await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
await runCli(['doctor'], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
const after2 = await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
expect(after2).toBe(after1)
})
})
// ---------------------------------------------------------------------------
// 5. publish does not change local inventory
// ---------------------------------------------------------------------------
describe('cross-command — publish vs local inventory', () => {
test('publish does NOT add the published skill to local inventory', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', user: { handle: 'u', displayName: 'U' } })
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Build a tiny skill dir to publish.
const dir = join(env.cwd, 'src-skill')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'SKILL.md'), '---\nname: pub-only\ndescription: x\n---\n# pub-only')
const pub = await runCli(['publish', dir, '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
expect(pub.exitCode).toBe(0)
const list = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(list.exitCode).toBe(0)
expect(JSON.parse(list.stdout).items).toHaveLength(0)
})
})
// ---------------------------------------------------------------------------
// 6. Cross-registry isolation in queries
// ---------------------------------------------------------------------------
describe('cross-command — cross-registry isolation', () => {
test('list scoped to registry A does not show items installed from registry B', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_a',
user: { handle: 'a', displayName: 'A' },
skills: [{ namespace: 'global', slug: 'a-only', version: '1.0.0', zipBytes: makeSkillZip() }]
})
registryB = await startFakeRegistry({
token: 'sk_b',
user: { handle: 'b', displayName: 'B' },
skills: [{ namespace: 'global', slug: 'b-only', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['login', '--registry', registryB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
const dirA = join(env.cwd, 'A')
const dirB = join(env.cwd, 'B')
await mkdir(dirA, { recursive: true })
await mkdir(dirB, { recursive: true })
await runCli(['install', 'a-only', '--dir', dirA, '--registry', registry.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['install', 'b-only', '--dir', dirB, '--registry', registryB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
const listA = await runCli(['list', '--registry', registry.url, '--json'], { HOME: env.home, USERPROFILE: env.home })
const slugsA = (JSON.parse(listA.stdout).items as Array<{ slug: string }>).map(i => i.slug)
expect(slugsA).toEqual(['a-only'])
const listB = await runCli(['list', '--registry', registryB.url, '--json'], { HOME: env.home, USERPROFILE: env.home })
const slugsB = (JSON.parse(listB.stdout).items as Array<{ slug: string }>).map(i => i.slug)
expect(slugsB).toEqual(['b-only'])
})
})
// ---------------------------------------------------------------------------
// 7. Auto-detect + list filter integration (project-level)
// ---------------------------------------------------------------------------
describe('cross-command — auto-detect + list', () => {
test('install auto-detects project-level .codex; subsequent list --agent codex shows it', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Pre-create .codex/skills so auto-detect picks codex/project-level.
await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
const inst = await runCli(
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(inst.exitCode).toBe(0)
const list = await runCli(
['list', '--agent', 'codex', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(list.exitCode).toBe(0)
const items = JSON.parse(list.stdout).items as Array<{ slug: string; agent: string }>
expect(items.some(i => i.slug === 'pdf-parser' && i.agent === 'codex')).toBe(true)
})
})
// ---------------------------------------------------------------------------
// 8. Inventory metadata corruption resilience after install
// ---------------------------------------------------------------------------
describe('cross-command — metadata.json drift', () => {
test('install → manually corrupt metadata.json → list reports the row but with sane handling', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'meta-drift')
await mkdir(installDir, { recursive: true })
await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
// Corrupt the installed metadata. inventory.json (the authoritative
// source for `list`) is untouched, so `list` should still work.
await writeFile(
join(installDir, 'pdf-parser', '.skillhub', 'metadata.json'),
'{ truncated'
)
const list = await runCli(
['list', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(list.exitCode).toBe(0)
const items = JSON.parse(list.stdout).items as Array<{ slug: string; status: string }>
expect(items[0]?.slug).toBe('pdf-parser')
// Status remains "ok" because list uses inventory.json, not metadata.json.
expect(items[0]?.status).toBe('ok')
})
})
// ---------------------------------------------------------------------------
// 9. Registry priority chain end-to-end
// ---------------------------------------------------------------------------
describe('cross-command — registry priority end-to-end', () => {
test('search uses --registry over SKILLHUB_REGISTRY env over default', async () => {
registry = await startFakeRegistry({
searchItems: [{ namespace: 'global', slug: 'wins', latestVersion: '1.0.0', summary: 'right one' }]
})
registryB = await startFakeRegistry({
searchItems: [{ namespace: 'global', slug: 'loses', latestVersion: '1.0.0', summary: 'wrong one' }]
})
const result = await runCli(
['search', '', '--registry', registry.url, '--json'],
{ SKILLHUB_REGISTRY: registryB.url }
)
expect(result.exitCode).toBe(0)
const items = JSON.parse(result.stdout).items as Array<{ slug: string }>
expect(items.map(i => i.slug)).toEqual(['wins'])
})
})
// ---------------------------------------------------------------------------
// 10. Help / Version ergonomics across commands
// ---------------------------------------------------------------------------
describe('cross-command — help reaches every documented command', () => {
test('every command listed in help responds to --help with non-empty body', async () => {
const helpResult = await runCli(['help'])
expect(helpResult.exitCode).toBe(0)
const commandNames = [
'help', 'version', 'login', 'logout', 'whoami',
'search', 'install', 'list', 'remove', 'doctor',
'publish', 'update'
]
for (const cmd of commandNames) {
expect(helpResult.stdout).toContain(cmd)
const sub = await runCli([cmd, '--help'])
// --help exits 0 for cac-style CLIs; we don't insist on that, just
// that some informative output makes it to stdout.
expect(sub.stdout.length).toBeGreaterThan(0)
}
})
})

View file

@ -143,6 +143,139 @@ describe('doctor command', () => {
expect(json.inventoryPath).toContain('inventory.json')
})
// -------------------------------------------------------------------------
// P1: same registry+namespace+slug appearing in two agent dirs with
// different versions surfaces in `conflicts` and is excluded from items.
// -------------------------------------------------------------------------
test('doctor reports conflicts when two agent dirs disagree on version', async () => {
const { home, cwd } = await createTempHome()
// Two installs of the same global/pdf-parser with mismatched versions.
await seedSkill(cwd, {
agentDir: '.codex',
slug: 'pdf-parser',
metadata: {
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'pdf-parser',
version: '1.0.0',
agent: 'codex',
installedAt: '2026-04-20T12:00:00Z'
}
})
await seedSkill(cwd, {
agentDir: '.claude',
slug: 'pdf-parser',
metadata: {
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'pdf-parser',
version: '2.0.0',
agent: 'claude-code',
installedAt: '2026-04-21T09:00:00Z'
}
})
const result = await runCli(['doctor', '--json'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as {
ok: boolean
itemsScanned: number
targetsScanned: number
conflicts: Array<{ key: string; versions: string[] }>
}
expect(json.ok).toBe(true)
// Conflicting group is dropped from items, recorded as a conflict.
expect(json.itemsScanned).toBe(0)
expect(json.targetsScanned).toBe(0)
expect(json.conflicts).toHaveLength(1)
expect(json.conflicts[0]?.key).toBe('https://skill.xfyun.cn|global|pdf-parser')
expect(json.conflicts[0]?.versions.sort()).toEqual(['1.0.0', '2.0.0'])
// The persisted inventory must mirror the JSON output: no items.
const inventory = JSON.parse(
await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: unknown[] }
expect(inventory.items).toHaveLength(0)
})
// -------------------------------------------------------------------------
// P1: malformed metadata (unparseable JSON, or missing required fields)
// is reported in `skipped` and does not produce inventory entries. Two
// distinct failure modes are seeded to exercise both branches in
// scanMetadata: JSON.parse throw and the post-parse field check.
// -------------------------------------------------------------------------
test('doctor reports skipped entries for malformed and incomplete metadata', async () => {
const { home, cwd } = await createTempHome()
// (1) Bad JSON: triggers the catch around JSON.parse → "no .skillhub/metadata.json"
// because the catch block is shared with the readFile failure path.
const badJsonDir = join(cwd, '.codex', 'skills', 'broken-json', '.skillhub')
await mkdir(badJsonDir, { recursive: true })
await writeFile(join(badJsonDir, 'metadata.json'), '{ this is not json')
// (2) Incomplete fields: parses fine but is missing `version`.
const incompleteDir = join(cwd, '.claude', 'skills', 'incomplete', '.skillhub')
await mkdir(incompleteDir, { recursive: true })
await writeFile(
join(incompleteDir, 'metadata.json'),
JSON.stringify({
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'incomplete',
// version intentionally missing
agent: 'claude-code',
installedAt: '2026-04-22T10:00:00Z'
})
)
// (3) A valid sibling so we can prove skipped entries don't poison the
// surrounding scan — the valid skill should still land in inventory.
await seedSkill(cwd, {
agentDir: '.codex',
slug: 'good-skill',
metadata: {
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'good-skill',
version: '1.0.0',
agent: 'codex',
installedAt: '2026-04-22T10:00:00Z'
}
})
const result = await runCli(['doctor', '--json'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as {
ok: boolean
itemsScanned: number
skipped: Array<{ path: string; reason: string }>
}
expect(json.ok).toBe(true)
// Both broken entries should be in skipped, the good one in items.
const broken = json.skipped.find(s => s.path.endsWith('broken-json'))
expect(broken).toBeDefined()
const incomplete = json.skipped.find(s => s.path.endsWith('incomplete'))
expect(incomplete).toBeDefined()
expect(incomplete?.reason).toContain('incomplete')
expect(json.itemsScanned).toBe(1) // only good-skill
const inventory = JSON.parse(
await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string }> }
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]?.slug).toBe('good-skill')
})
test('doctor backs up existing inventory.json and reports backupPath', async () => {
const { home, cwd } = await createTempHome()
@ -227,4 +360,104 @@ describe('doctor command', () => {
expect.arrayContaining(['external-skill', 'local-skill'])
)
})
// -------------------------------------------------------------------------
// P1 — Symlink safety: doctor must skip (not follow) symlinked agent /
// skill / .skillhub directories. This protects against malicious or
// accidental symlinks that would otherwise let metadata be slurped from
// arbitrary filesystem locations.
// -------------------------------------------------------------------------
test('doctor skips an agent dir that is a symlink', async () => {
const { home, cwd } = await createTempHome()
const { symlink, mkdir: mkdirP } = await import('node:fs/promises')
// Real target with a valid metadata file off in /tmp.
const realRoot = join(cwd, '__real__', '.codex', 'skills', 'pdf-parser', '.skillhub')
await mkdirP(realRoot, { recursive: true })
await writeFile(join(realRoot, 'metadata.json'), JSON.stringify({
registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser',
version: '1.0.0', agent: 'codex', installedAt: '2026-04-20T12:00:00Z'
}))
// Symlink ./.codex -> __real__/.codex inside cwd. Doctor scans cwd.
await symlink(join(cwd, '__real__', '.codex'), join(cwd, '.codex'))
const result = await runCli(['doctor', '--json'], {
HOME: home, USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as {
itemsScanned: number
skipped: Array<{ path: string; reason: string }>
}
// The symlinked agent dir must NOT contribute an inventory item.
expect(json.itemsScanned).toBe(0)
expect(json.skipped.some(s => s.path.endsWith('.codex') && s.reason.includes('regular directory'))).toBe(true)
})
test('doctor skips a slug dir that is a symlink (real agent dir, symlinked slug)', async () => {
const { home, cwd } = await createTempHome()
const { symlink, mkdir: mkdirP } = await import('node:fs/promises')
// Real metadata under cwd/__real__/pdf-parser/.skillhub/
const realSlug = join(cwd, '__real__', 'pdf-parser')
const realSkillhub = join(realSlug, '.skillhub')
await mkdirP(realSkillhub, { recursive: true })
await writeFile(join(realSkillhub, 'metadata.json'), JSON.stringify({
registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser',
version: '1.0.0', agent: 'codex', installedAt: '2026-04-20T12:00:00Z'
}))
// .codex/skills exists as a real dir, but pdf-parser inside it is a
// symlink to the real metadata location.
const skillsDir = join(cwd, '.codex', 'skills')
await mkdirP(skillsDir, { recursive: true })
await symlink(realSlug, join(skillsDir, 'pdf-parser'))
const result = await runCli(['doctor', '--json'], {
HOME: home, USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as {
itemsScanned: number
skipped: Array<{ path: string; reason: string }>
}
expect(json.itemsScanned).toBe(0)
const symlinked = json.skipped.find(s => s.path.endsWith('pdf-parser'))
expect(symlinked).toBeDefined()
expect(symlinked?.reason).toContain('regular directory')
})
test('doctor skips a .skillhub dir that is a symlink', async () => {
const { home, cwd } = await createTempHome()
const { symlink, mkdir: mkdirP } = await import('node:fs/promises')
// Real metadata reachable through a symlinked .skillhub directory.
const realSkillhub = join(cwd, '__real_meta__')
await mkdirP(realSkillhub, { recursive: true })
await writeFile(join(realSkillhub, 'metadata.json'), JSON.stringify({
registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser',
version: '1.0.0', agent: 'codex', installedAt: '2026-04-20T12:00:00Z'
}))
const slugDir = join(cwd, '.codex', 'skills', 'pdf-parser')
await mkdirP(slugDir, { recursive: true })
await symlink(realSkillhub, join(slugDir, '.skillhub'))
const result = await runCli(['doctor', '--json'], {
HOME: home, USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as {
itemsScanned: number
skipped: Array<{ path: string; reason: string }>
}
expect(json.itemsScanned).toBe(0)
const skipped = json.skipped.find(s => s.path.endsWith('pdf-parser'))
expect(skipped).toBeDefined()
expect(skipped?.reason.toLowerCase()).toMatch(/skillhub|regular directory/)
})
})

View file

@ -1,4 +1,4 @@
import { mkdir, readFile } from 'node:fs/promises'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
@ -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
// -------------------------------------------------------------------------
@ -272,3 +331,691 @@ describe('install command — P1', () => {
// test/unit/agents/resolver.test.ts.
// -------------------------------------------------------------------------
})
// ---------------------------------------------------------------------------
// P0/P1 — Conflict & --force handling
// ---------------------------------------------------------------------------
describe('install command — conflict and --force', () => {
test('re-installing without --force into an existing dir errors with EXIT.filesystem', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-conflict')
await mkdir(installDir, { recursive: true })
const first = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(first.exitCode).toBe(0)
const second = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(second.exitCode).toBe(4) // EXIT.filesystem
expect(second.stderr).toContain('already installed')
expect(second.stderr).toContain('--force')
})
test('--force overwrites stale files left in the install dir', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-force')
await mkdir(installDir, { recursive: true })
await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
// Tamper with SKILL.md to prove the second install replaces it.
const skillFile = join(installDir, 'pdf-parser', 'SKILL.md')
await writeFile(skillFile, '# tampered content')
expect(await readFile(skillFile, 'utf-8')).toBe('# tampered content')
const forced = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(forced.exitCode).toBe(0)
expect(await readFile(skillFile, 'utf-8')).toBe('# test skill')
})
})
// ---------------------------------------------------------------------------
// P1 — Server-side error mapping during install
// ---------------------------------------------------------------------------
describe('install command — server errors', () => {
test('resolve 404 surfaces an error and aborts install (no metadata.json written)', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
failures: { resolve: 'not_found' }
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-resolve-404')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'no-such-slug', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/404|not found/i)
// No metadata file should have been created at the install destination.
const metaPath = join(installDir, 'no-such-slug', '.skillhub', 'metadata.json')
expect(await Bun.file(metaPath).exists()).toBe(false)
})
// Regression test for the production bug observed on 2026-05-06: server
// marks `bundle_ready=true` in DB but the bundle file is missing on disk.
// /resolve returns 200 with a downloadUrl, then /download returns 404. The
// CLI must surface a non-zero exit and a meaningful stderr — not silently
// succeed with an empty install dir.
test('download 404 (resolve OK) is reported as a download failure', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
// resolve succeeds (skill is present in fixture list) but the download
// endpoint is forced to 404 to simulate a missing bundle on storage.
skills: [{ namespace: 'global', slug: 'orphan-bundle', version: '1.0.0', zipBytes: makeSkillZip() }],
failures: { download: 'not_found' }
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-bundle-missing')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'orphan-bundle', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
expect(result.stderr.toLowerCase()).toMatch(/download|404|not found/)
})
// -------------------------------------------------------------------------
// P1 — Path safety: install only writes inside <dir>/<slug>/
// -------------------------------------------------------------------------
test('install only writes inside <dir>/<slug>/ — sibling files in <dir> are untouched', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'shared-dir')
await mkdir(installDir, { recursive: true })
// Place an unrelated file as a sibling of the future <slug>/ subdir.
const sibling = join(installDir, 'IMPORTANT.txt')
await writeFile(sibling, 'this file must survive install')
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
// Sibling file must still exist with original content.
expect(await readFile(sibling, 'utf-8')).toBe('this file must survive install')
// <slug>/ subdir created.
expect(await Bun.file(join(installDir, 'pdf-parser', 'SKILL.md')).exists()).toBe(true)
})
test('--force re-install does not touch sibling files in <dir>', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'shared-force')
await mkdir(installDir, { recursive: true })
await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
// After first install, drop a sibling file; --force should not delete it.
const sibling = join(installDir, 'sibling-after-install.bin')
await writeFile(sibling, 'sentinel')
const r2 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r2.exitCode).toBe(0)
expect(await readFile(sibling, 'utf-8')).toBe('sentinel')
})
test('install --dir creates the <slug> subdir even when <dir> is empty', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'empty-dir')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
expect(await Bun.file(join(installDir, 'pdf-parser', 'SKILL.md')).exists()).toBe(true)
expect(await Bun.file(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
})
test('install --dir pointing at a regular file (not a directory) fails before download', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Create a file at the location --dir would otherwise treat as a directory.
const filePath = join(env.cwd, 'not-a-dir')
await writeFile(filePath, 'i am a file, not a dir')
const result = await runCli(
['install', 'pdf-parser', '--dir', filePath, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
// Original file must still be unchanged (the install should not have
// scribbled on it before bailing).
expect(await readFile(filePath, 'utf-8')).toBe('i am a file, not a dir')
})
test('--json emits a parseable error envelope when install fails', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
failures: { resolve: 'not_found' }
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-json-error')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'no-such-slug', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
// JSON error envelope is printed to stdout (or stderr, depending on the
// command); we accept either to keep the test resilient to that choice.
const candidate = result.stdout || result.stderr
const json = JSON.parse(candidate) as {
ok: boolean
message: string
exitCode: number
}
expect(json.ok).toBe(false)
expect(typeof json.message).toBe('string')
expect(json.exitCode).toBe(result.exitCode)
})
})
// ---------------------------------------------------------------------------
// P1 — Multi-agent and auto-detect targeting
// ---------------------------------------------------------------------------
describe('install command — multi-agent & auto-detect', () => {
test('multi --agent installs the same skill into every specified user-level dir', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const result = await runCli(
[
'install', 'pdf-parser',
'--agent', 'codex',
'--agent', 'claude-code',
'--registry', registry.url,
'--token', 'sk_ok',
'--json'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string }> }
const agents = parsed.installed.map(t => t.agent).sort()
expect(agents).toEqual(['claude-code', 'codex'])
// Both metadata files exist on disk under user-level <home>/.<agent>/skills.
expect(await Bun.file(join(env.home, '.codex', 'skills', 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
expect(await Bun.file(join(env.home, '.claude', 'skills', 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
})
test('duplicate --agent dedupes to one target', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const result = await runCli(
[
'install', 'pdf-parser',
'--agent', 'codex',
'--agent', 'codex',
'--registry', registry.url,
'--token', 'sk_ok',
'--json'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string }> }
expect(parsed.installed).toHaveLength(1)
expect(parsed.installed[0]?.agent).toBe('codex')
})
test('--agent unknown-id surfaces a usage error with hint', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const result = await runCli(
['install', 'pdf-parser', '--agent', 'totally-not-a-real-agent', '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(5) // EXIT.usage
expect(result.stderr.toLowerCase()).toMatch(/unknown agent|--dir/)
})
test('auto-detect: cwd with only .codex/skills present installs project-level there', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Pre-create the codex skills dir so auto-detect picks project scope.
await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
const result = await runCli(
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout) as { installed: Array<{ dir: string; agent: string }> }
expect(parsed.installed[0]?.agent).toBe('codex')
// On macOS env.cwd may resolve through /private/var/... symlinks; assert
// against the structural part of the path instead of an exact prefix.
// Use a regex that accepts both Unix (/) and Windows (\) path separators.
expect(parsed.installed[0]?.dir).toMatch(/[/\\]\.codex[/\\]skills[/\\]pdf-parser/)
expect(parsed.installed[0]?.dir).not.toContain(env.home) // not user-level
})
test('auto-detect: multiple agent dirs in cwd and non-interactive mode fails with hint', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
await mkdir(join(env.cwd, '.claude', 'skills'), { recursive: true })
const result = await runCli(
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(5) // EXIT.usage
expect(result.stderr.toLowerCase()).toMatch(/multiple install targets|--agent|--dir/)
})
test('auto-detect: cwd with no agent dirs falls back to .agents/skills', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const result = await runCli(
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout) as { installed: Array<{ dir: string; agent: string }> }
expect(parsed.installed[0]?.agent).toBe('generic')
expect(parsed.installed[0]?.dir).toContain('.agents')
})
// -------------------------------------------------------------------------
// P1 — Bundle integrity: download body that's not a valid zip
// -------------------------------------------------------------------------
test('download body that is not a valid zip surfaces an extraction error', async () => {
const env = await createTempHome()
// Stand up a custom server that returns valid resolve JSON but plain
// text on download.
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
const baseUrl = `${url.protocol}//${url.host}`
const resolveMatch = url.pathname.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/)
if (resolveMatch && req.method === 'GET') {
return Response.json({
code: 0,
data: {
namespace: resolveMatch[1],
slug: resolveMatch[2],
version: '1.0.0',
versionId: 1,
fingerprint: 'deadbeef',
downloadUrl: `${baseUrl}/api/cli/v1/skills/${resolveMatch[1]}/${resolveMatch[2]}/versions/1.0.0/download`
}
})
}
if (url.pathname.includes('/download')) {
// NOT a zip — plain text.
return new Response('this is plain text, not a zip', {
status: 200, headers: { 'Content-Type': 'application/zip' }
})
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'bad-bundle')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
// No metadata should have been written.
expect(await Bun.file(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(false)
} finally {
server.stop()
}
})
// -------------------------------------------------------------------------
// P2 — Slug edge cases (Unicode, very long)
// -------------------------------------------------------------------------
test('slug with non-ASCII characters round-trips through resolve URL (encoded)', async () => {
const env = await createTempHome()
let resolveUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.includes('/resolve')) {
resolveUrl = req.url
// Return 404 — we only care that the URL was constructed correctly.
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'unicode-slug')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', '中文-技能', '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
// Server returns 404 — install fails. Just confirm CLI didn't crash
// before hitting the server.
expect(result.exitCode).not.toBe(0)
// The slug must appear URL-percent-encoded in the resolve URL.
expect(resolveUrl).toMatch(/%E4%B8%AD%E6%96%87/)
} finally {
server.stop()
}
})
test('slug 200+ characters is forwarded as-is to /resolve (server is authoritative)', async () => {
const env = await createTempHome()
let resolveUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.includes('/resolve')) {
resolveUrl = req.url
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'long-slug')
await mkdir(installDir, { recursive: true })
const longSlug = 'a'.repeat(220)
const result = await runCli(
['install', longSlug, '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
expect(resolveUrl).toContain(longSlug)
} finally {
server.stop()
}
})
})
// ---------------------------------------------------------------------------
// P0 — --scope flag
// ---------------------------------------------------------------------------
describe('install command — --scope', () => {
test('--scope project --agent codex installs to <cwd>/.codex/skills', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(
['login', '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
const result = await runCli(
['install', 'foo', '--scope', 'project', '--agent', 'codex',
'--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(0)
const metaPath = join(env.cwd, '.codex', 'skills', 'foo', '.skillhub', 'metadata.json')
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.slug).toBe('foo')
})
test('--scope user --agent codex installs to <home>/.codex/skills', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(
['login', '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
const result = await runCli(
['install', 'foo', '--scope', 'user', '--agent', 'codex',
'--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(0)
const metaPath = join(env.home, '.codex', 'skills', 'foo', '.skillhub', 'metadata.json')
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.slug).toBe('foo')
})
test('--scope user clean env falls back to <home>/.agents/skills', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(
['login', '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
const result = await runCli(
['install', 'foo', '--scope', 'user',
'--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(0)
const metaPath = join(env.home, '.agents', 'skills', 'foo', '.skillhub', 'metadata.json')
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.slug).toBe('foo')
})
test('--scope project --agent codex --json output omits scope field on installed entries', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(
['login', '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
const result = await runCli(
['install', 'foo', '--scope', 'project', '--agent', 'codex', '--json',
'--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home },
{ cwd: env.cwd }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout)
expect(parsed).toMatchObject({ ok: true, namespace: 'global', slug: 'foo' })
expect(parsed.installed[0]).toHaveProperty('agent')
expect(parsed.installed[0]).toHaveProperty('dir')
expect(parsed.installed[0]).not.toHaveProperty('scope')
})
test('--scope invalid returns exit code 5 with usage error', async () => {
const result = await runCli(['install', 'foo', '--scope', 'invalid'])
expect(result.exitCode).toBe(5)
expect(result.stderr).toMatch(/user.+project|"user".+"project"/)
})
test('--scope invalid --json returns JSON error shape', async () => {
const result = await runCli(['install', 'foo', '--scope', 'invalid', '--json'])
expect(result.exitCode).toBe(5)
const parsed = JSON.parse(result.stderr)
expect(parsed.ok).toBe(false)
expect(parsed.exitCode).toBe(5)
expect(parsed.message).toMatch(/user.+project/)
})
test('--dir + --scope returns usage error', async () => {
const result = await runCli(['install', 'foo', '--dir', '/tmp/x', '--scope', 'user'])
expect(result.exitCode).toBe(5)
expect(result.stderr).toMatch(/--dir cannot be used with --scope/)
})
test('--dir + --scope --json returns JSON usage error', async () => {
const result = await runCli(
['install', 'foo', '--dir', '/tmp/x', '--scope', 'user', '--json']
)
expect(result.exitCode).toBe(5)
const parsed = JSON.parse(result.stderr)
expect(parsed.ok).toBe(false)
expect(parsed.message).toMatch(/--dir cannot be used with --scope/)
})
test('help install includes --scope usage and examples', async () => {
const result = await runCli(['help', 'install'])
expect(result.exitCode).toBe(0)
expect(result.stdout).toMatch(/--scope/)
expect(result.stdout).toMatch(/--scope user/)
expect(result.stdout).toMatch(/--scope project --agent codex/)
})
})

View file

@ -0,0 +1,107 @@
/**
* inventory.json resilience.
*
* inventory.json is the local manifest of installed skills. These tests pin
* how the CLI behaves when that file is corrupt or written by overlapping
* operations:
* - list against a corrupt inventory should fail loudly (not silently)
* - install against a corrupt inventory should still complete the
* filesystem extraction even if inventory bookkeeping fails partial
* state surfaces a clear error
* - sequential installs of distinct skills do not corrupt the manifest
*
* The unit test in test/unit/stores/inventory-store.test.ts asserts the
* lock-file recovery path. These cover the user-facing CLI surface.
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop(); registry = undefined
})
function makeSkillZip(): Uint8Array {
return zipSync({ 'SKILL.md': strToU8('# test') })
}
describe('inventory resilience', () => {
test('list exits non-zero when inventory.json is malformed (documents current generic-error UX)', async () => {
const env = await createTempHome()
await mkdir(join(env.home, '.skillhub'), { recursive: true })
await writeFile(join(env.home, '.skillhub', 'inventory.json'), '{ this is not JSON')
const result = await runCli(['list'], { HOME: env.home, USERPROFILE: env.home })
// Contract: CLI must not crash silently or print a stack trace. It
// exits non-zero and emits a short message.
expect(result.exitCode).not.toBe(0)
expect(result.stderr.length).toBeGreaterThan(0)
expect(result.stderr.length).toBeLessThan(2000)
// Documented gap: today's message is the generic "unexpected failure"
// and does not mention `inventory` or `JSON`. When the CLI surfaces a
// more specific message in the future, tighten this assertion.
expect(result.stderr).toContain('Error')
})
test('list --json on a corrupt inventory emits a parseable error envelope (not a stack trace)', async () => {
const env = await createTempHome()
await mkdir(join(env.home, '.skillhub'), { recursive: true })
await writeFile(join(env.home, '.skillhub', 'inventory.json'), '{"items":')
const result = await runCli(['list', '--json'], { HOME: env.home, USERPROFILE: env.home })
expect(result.exitCode).not.toBe(0)
const candidate = result.stdout || result.stderr
expect(candidate.length).toBeLessThan(2000)
// Contract: --json error path is machine-parseable, regardless of the
// (currently generic) human message.
const json = JSON.parse(candidate) as { ok: boolean; message: string; exitCode: number }
expect(json.ok).toBe(false)
expect(typeof json.message).toBe('string')
expect(json.exitCode).toBe(result.exitCode)
})
test('two sequential installs of distinct slugs leave a coherent inventory', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [
{ namespace: 'global', slug: 'one', version: '1.0.0', zipBytes: makeSkillZip() },
{ namespace: 'global', slug: 'two', version: '1.0.0', zipBytes: makeSkillZip() }
]
})
const baseDir = join(env.cwd, 'pool')
await mkdir(baseDir, { recursive: true })
const r1 = await runCli(
['install', 'one', '--dir', baseDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r1.exitCode).toBe(0)
const r2 = await runCli(
['install', 'two', '--dir', baseDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r2.exitCode).toBe(0)
const inventory = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
const slugs = inventory.items.map(i => i.slug).sort()
expect(slugs).toEqual(['one', 'two'])
for (const item of inventory.items) {
expect(item.targets.length).toBeGreaterThan(0)
}
})
})

View file

@ -351,4 +351,112 @@ describe('list command', () => {
expect(json.items).toHaveLength(1)
expect(json.items[0].status).toBe('missing')
})
// -------------------------------------------------------------------------
// P1: Combined filters — --agent + --registry should narrow precisely
// -------------------------------------------------------------------------
test('--agent codex --registry A shows only codex targets from registry A', async () => {
const { home } = await createTempHome()
const codexA = join(home, 'a', 'codex', 'pdf')
const claudeA = join(home, 'a', 'claude', 'pdf')
const codexB = join(home, 'b', 'codex', 'pdf')
for (const d of [codexA, claudeA, codexB]) await mkdir(d, { recursive: true })
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'pdf', version: '1.0.0',
targets: [
{ agent: 'codex', rootDir: join(home, 'a', 'codex'), installDir: codexA, installedAt: INSTALLED_AT },
{ agent: 'claude-code', rootDir: join(home, 'a', 'claude'), installDir: claudeA, installedAt: INSTALLED_AT }
]
},
{
registry: FAKE_REGISTRY_B, namespace: 'global', slug: 'pdf', version: '1.0.0',
targets: [
{ agent: 'codex', rootDir: join(home, 'b', 'codex'), installDir: codexB, installedAt: INSTALLED_AT }
]
}
])
const result = await runCli(
['list', '--agent', 'codex', '--registry', FAKE_REGISTRY_A, '--json'],
{ HOME: home, USERPROFILE: home }
)
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as { items: Array<{ agent: string; installDir: string }> }
expect(json.items).toHaveLength(1)
expect(json.items[0]?.agent).toBe('codex')
expect(json.items[0]?.installDir).toBe(codexA)
})
// -------------------------------------------------------------------------
// P1: --agent + --dir should compose AND, not OR
// -------------------------------------------------------------------------
test('--agent + --dir composes as AND: only items matching both surface', async () => {
const { home } = await createTempHome()
const codexHere = join(home, 'here', 'codex', 'pdf')
const codexElse = join(home, 'else', 'codex', 'pdf')
await mkdir(codexHere, { recursive: true })
await mkdir(codexElse, { recursive: true })
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'pdf', version: '1.0.0',
targets: [
{ agent: 'codex', rootDir: join(home, 'here', 'codex'), installDir: codexHere, installedAt: INSTALLED_AT }
]
},
{
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'pdf-elsewhere', version: '1.0.0',
targets: [
{ agent: 'codex', rootDir: join(home, 'else', 'codex'), installDir: codexElse, installedAt: INSTALLED_AT }
]
}
])
const result = await runCli(
['list', '--registry', FAKE_REGISTRY_A, '--agent', 'codex', '--dir', join(home, 'here'), '--json'],
{ HOME: home, USERPROFILE: home }
)
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as { items: Array<{ slug: string }> }
expect(json.items).toHaveLength(1)
expect(json.items[0]?.slug).toBe('pdf')
})
// -------------------------------------------------------------------------
// P1: SKILLHUB_REGISTRY env scopes list to the env-specified registry
// (registry priority --registry > env > config > default also applies to
// list, not just to network-touching commands).
// -------------------------------------------------------------------------
test('SKILLHUB_REGISTRY env scopes list to that registry, hiding the other', async () => {
const { home } = await createTempHome()
const dirA = join(home, 'a', 'codex', 'one')
const dirB = join(home, 'b', 'codex', 'two')
await mkdir(dirA, { recursive: true })
await mkdir(dirB, { recursive: true })
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'one', version: '1.0.0',
targets: [{ agent: 'codex', rootDir: join(home, 'a', 'codex'), installDir: dirA, installedAt: INSTALLED_AT }]
},
{
registry: FAKE_REGISTRY_B, namespace: 'global', slug: 'two', version: '1.0.0',
targets: [{ agent: 'codex', rootDir: join(home, 'b', 'codex'), installDir: dirB, installedAt: INSTALLED_AT }]
}
])
// No --registry flag — scope comes from SKILLHUB_REGISTRY env.
const result = await runCli(
['list', '--json'],
{ HOME: home, USERPROFILE: home, SKILLHUB_REGISTRY: FAKE_REGISTRY_B }
)
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout) as { items: Array<{ slug: string }> }
expect(json.items).toHaveLength(1)
expect(json.items[0]?.slug).toBe('two')
})
})

View file

@ -0,0 +1,110 @@
/**
* Multi-registry credential isolation.
*
* credentials.json keys tokens by registry URL. Operations on one registry
* must not leak into another. These tests cover:
* - Logging into A then B preserves both tokens.
* - Logging out of A leaves B's token intact.
* - whoami after logout reflects per-registry session state.
* - Re-login to A overwrites only A's slot.
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
let regA: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
let regB: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
regA?.stop(); regA = undefined
regB?.stop(); regB = undefined
})
async function readCreds(home: string): Promise<{ tokens: Record<string, string> }> {
return JSON.parse(await readFile(join(home, '.skillhub', 'credentials.json'), 'utf-8'))
}
describe('multi-registry credential isolation', () => {
test('login to A then B leaves both tokens in credentials.json', async () => {
const env = await createTempHome()
regA = await startFakeRegistry({ token: 'sk_a', user: { handle: 'a', displayName: 'A' } })
regB = await startFakeRegistry({ token: 'sk_b', user: { handle: 'b', displayName: 'B' } })
await runCli(
['login', '--registry', regA.url, '--token', 'sk_a'],
{ HOME: env.home, USERPROFILE: env.home }
)
await runCli(
['login', '--registry', regB.url, '--token', 'sk_b'],
{ HOME: env.home, USERPROFILE: env.home }
)
const creds = await readCreds(env.home)
expect(creds.tokens[regA.url]).toBe('sk_a')
expect(creds.tokens[regB.url]).toBe('sk_b')
})
test('logout from A removes A token while B token survives', async () => {
const env = await createTempHome()
regA = await startFakeRegistry({ token: 'sk_a', user: { handle: 'a', displayName: 'A' } })
regB = await startFakeRegistry({ token: 'sk_b', user: { handle: 'b', displayName: 'B' } })
await runCli(['login', '--registry', regA.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['login', '--registry', regB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['logout', '--registry', regA.url], { HOME: env.home, USERPROFILE: env.home })
const creds = await readCreds(env.home)
expect(creds.tokens[regA.url]).toBeUndefined()
expect(creds.tokens[regB.url]).toBe('sk_b')
})
test('whoami after logout-A: A reports not-logged-in, B still authenticates', async () => {
const env = await createTempHome()
regA = await startFakeRegistry({ token: 'sk_a', user: { handle: 'a-user', displayName: 'A' } })
regB = await startFakeRegistry({ token: 'sk_b', user: { handle: 'b-user', displayName: 'B' } })
await runCli(['login', '--registry', regA.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['login', '--registry', regB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['logout', '--registry', regA.url], { HOME: env.home, USERPROFILE: env.home })
const whoamiA = await runCli(
['whoami', '--registry', regA.url],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(whoamiA.exitCode).toBe(2) // EXIT.auth
expect(whoamiA.stderr.toLowerCase()).toContain('not logged in')
const whoamiB = await runCli(
['whoami', '--registry', regB.url],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(whoamiB.exitCode).toBe(0)
expect(whoamiB.stdout).toContain('b-user')
})
test('re-login to A overwrites only A entry; B token unchanged', async () => {
const env = await createTempHome()
// Don't pin a token on either registry so any value passes whoami; we
// only care about credentials.json bookkeeping here.
regA = await startFakeRegistry({ user: { handle: 'a', displayName: 'A' } })
regB = await startFakeRegistry({ user: { handle: 'b', displayName: 'B' } })
await runCli(['login', '--registry', regA.url, '--token', 'sk_a_old'], { HOME: env.home, USERPROFILE: env.home })
await runCli(['login', '--registry', regB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
{
const creds = await readCreds(env.home)
expect(creds.tokens[regA.url]).toBe('sk_a_old')
expect(creds.tokens[regB.url]).toBe('sk_b')
}
await runCli(['login', '--registry', regA.url, '--token', 'sk_a_new'], { HOME: env.home, USERPROFILE: env.home })
const creds = await readCreds(env.home)
expect(creds.tokens[regA.url]).toBe('sk_a_new')
expect(creds.tokens[regB.url]).toBe('sk_b')
})
})

View file

@ -237,3 +237,295 @@ describe('publish command — P1', () => {
expect(result.stderr).toContain('registry')
})
})
// ---------------------------------------------------------------------------
// P1 — content shape: directory layout and edge files
// ---------------------------------------------------------------------------
import { mkdir } from 'node:fs/promises'
import { unzipSync, strFromU8 } from 'fflate'
describe('publish command — content shape', () => {
/**
* Spin up a publish endpoint that captures the raw zip body and lets us
* inspect entries server-side. Returns the captured bytes alongside a
* stop() handle so tests can assert what the CLI actually packaged.
*/
async function startCapturingPublishServer() {
let capturedBytes: Uint8Array | null = null
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
const form = await req.formData()
const file = form.get('file')
if (file instanceof File) {
capturedBytes = new Uint8Array(await file.arrayBuffer())
}
return Response.json({
code: 0,
data: { namespace: 'global', slug: 'captured', version: '1.0.0', visibility: 'PUBLIC' }
})
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
return {
url: `http://localhost:${server.port}`,
stop: () => server.stop(),
getCaptured: () => capturedBytes
}
}
test('publishing a directory with subdirs packages every file at its relative path', async () => {
const env = await createTempHome()
const server = await startCapturingPublishServer()
try {
await login(env, server.url)
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-nested-'))
await writeFile(join(dir, 'SKILL.md'), '# nested')
await mkdir(join(dir, 'references'), { recursive: true })
await writeFile(join(dir, 'references', 'a.md'), 'aa')
await mkdir(join(dir, 'scripts'), { recursive: true })
await writeFile(join(dir, 'scripts', 'run.sh'), '#!/bin/sh\necho ok\n')
const result = await runCli(['publish', dir, '--registry', server.url], {
HOME: env.home, USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
const captured = server.getCaptured()
expect(captured).not.toBeNull()
const rawEntries = unzipSync(captured!)
// Normalize all entry keys to use forward slashes for cross-platform compatibility
const entries = Object.fromEntries(
Object.entries(rawEntries).map(([key, value]) => [key.replace(/\\/g, '/'), value])
)
// Filter out directory marker entries (zip records empty entries for
// dirs with a trailing slash); we only care about file entries.
const files = Object.keys(entries).filter(k => !k.endsWith('/')).sort()
expect(files).toEqual([
'SKILL.md',
'references/a.md',
'scripts/run.sh'
])
expect(strFromU8(entries['SKILL.md']!)).toBe('# nested')
expect(strFromU8(entries['references/a.md']!)).toBe('aa')
} finally {
server.stop()
}
})
test('publishing a directory with hidden dotfiles packages them as-is', async () => {
const env = await createTempHome()
const server = await startCapturingPublishServer()
try {
await login(env, server.url)
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-hidden-'))
await writeFile(join(dir, 'SKILL.md'), '# h')
await writeFile(join(dir, '.DS_Store'), 'macos junk')
await writeFile(join(dir, '.editorconfig'), 'root = true\n')
const result = await runCli(['publish', dir, '--registry', server.url], {
HOME: env.home, USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
const captured = server.getCaptured()
expect(captured).not.toBeNull()
const rawEntries = unzipSync(captured!)
// Normalize all entry keys to use forward slashes for cross-platform compatibility
const entries = Object.fromEntries(
Object.entries(rawEntries).map(([key, value]) => [key.replace(/\\/g, '/'), value])
)
// Pin current behavior so future filtering changes are intentional.
expect(Object.keys(entries).sort()).toEqual(['.DS_Store', '.editorconfig', 'SKILL.md'])
} finally {
server.stop()
}
})
test('publishing an empty directory still issues a request and reports the server outcome', async () => {
const env = await createTempHome()
// Fake registry accepts publish unconditionally; CLI is not authoritative
// on SKILL.md presence (server is). We assert only that the CLI does not
// crash client-side and exits with whatever the server returned.
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-empty-'))
const result = await runCli(['publish', dir, '--registry', registry.url], {
HOME: env.home, USERPROFILE: env.home
})
// Today's contract: empty dir → empty zip uploaded → server returns 200.
// If the server adds client-side or server-side validation later this
// assertion will need to flip; that's intentional and traceable.
expect(result.exitCode).toBe(0)
})
test('server 422 with a JSON validation body surfaces a non-zero exit and stderr', async () => {
const env = await createTempHome()
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
return Response.json(
{ code: 422, message: 'validation.token.name.size', errors: ['name exceeds 64 chars'] },
{ status: 422 }
)
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await login(env, url)
const dir = await makeTempDir(['SKILL.md', '# x'])
const result = await runCli(['publish', dir, '--registry', url], {
HOME: env.home, USERPROFILE: env.home
})
expect(result.exitCode).not.toBe(0)
// The server's HTTP status should propagate visibly so a CI log
// shows what happened.
expect(result.stderr).toMatch(/422|registry|validation/i)
} finally {
server.stop()
}
})
// 502/503 are special-cased to EXIT.network because they indicate
// infrastructure-level unavailability (gateway/proxy failure).
test('server 503 Service Unavailable maps to EXIT.network with status in stderr', async () => {
const env = await createTempHome()
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
return Response.json({ code: 503, message: 'service unavailable' }, { status: 503 })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await login(env, url)
const dir = await makeTempDir(['SKILL.md', '# x'])
const result = await runCli(['publish', dir, '--registry', url], {
HOME: env.home, USERPROFILE: env.home
})
expect(result.exitCode).toBe(3) // EXIT.network
expect(result.stderr).toMatch(/503|registry/i)
} finally {
server.stop()
}
})
test('server 401 mid-session (token revoked) maps to EXIT.auth', async () => {
const env = await createTempHome()
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
// Whoami succeeds (login step). Publish then returns 401 as if the
// server revoked the token between the login + publish calls.
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await login(env, url)
const dir = await makeTempDir(['SKILL.md', '# x'])
const result = await runCli(['publish', dir, '--registry', url], {
HOME: env.home, USERPROFILE: env.home
})
expect(result.exitCode).toBe(2) // EXIT.auth
expect(result.stderr.toLowerCase()).toMatch(/auth|401|unauthorized/)
} finally {
server.stop()
}
})
test('publish response missing required fields is handled without crash', async () => {
const env = await createTempHome()
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
// 200 OK but body shape doesn't match the expected schema.
return Response.json({ code: 0, data: { unexpected: true } })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await login(env, url)
const dir = await makeTempDir(['SKILL.md', '# x'])
const result = await runCli(['publish', dir, '--registry', url, '--json'], {
HOME: env.home, USERPROFILE: env.home
})
// Either parses with placeholder values or fails — the contract we
// want is "no crash". Pin: exit 0 means current behavior accepts
// partial responses; flip if/when stricter validation lands.
expect([0, 1, 2, 3]).toContain(result.exitCode)
// Either way, output is bounded — no stack trace dump.
expect((result.stdout + result.stderr).length).toBeLessThan(2000)
} finally {
server.stop()
}
})
test('server 413 Payload Too Large maps to a network-class non-zero exit', async () => {
const env = await createTempHome()
const server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/auth/whoami') {
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
}
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
return Response.json({ code: 413, message: 'payload too large' }, { status: 413 })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
try {
const url = `http://localhost:${server.port}`
await login(env, url)
const dir = await makeTempDir(['SKILL.md', '# x'])
const result = await runCli(['publish', dir, '--registry', url], {
HOME: env.home, USERPROFILE: env.home
})
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/413|registry/i)
} finally {
server.stop()
}
})
})

View file

@ -0,0 +1,177 @@
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop()
registry = undefined
})
async function login(env: { home: string }, registryUrl: string) {
const result = await runCli(['login', '--registry', registryUrl, '--token', 'sk_ok'], {
HOME: env.home,
USERPROFILE: env.home
})
if (result.exitCode !== 0) {
throw new Error(`login failed: ${result.stderr}`)
}
}
async function makeTempDir(...files: Array<[string, string]>) {
const dir = await mkdtemp(join(tmpdir(), 'skillhub-dryrun-'))
for (const [name, content] of files) {
await writeFile(join(dir, name), content)
}
return dir
}
describe('publish --dry-run', () => {
test('calls validate endpoint and reports success', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: my-skill\ndescription: A test\n---\n# Hello'])
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Validation passed')
expect(registry.received.validate).not.toBeNull()
expect(registry.received.validate!.namespace).toBe('global')
expect(registry.received.publish).toBeNull()
})
test('--dry-run with --json returns structured response on warnings (valid=false)', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
dryRunResponse: {
valid: false,
errors: [],
warnings: ['Disallowed file extension: data.bin'],
resolvedSlug: 'my-skill',
resolvedVersion: '2.0.0'
}
})
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: my-skill\ndescription: test\n---\n'])
const result = await runCli(['publish', dir, '--dry-run', '--json', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(6)
const json = JSON.parse(result.stdout)
expect(json.valid).toBe(false)
expect(json.resolvedSlug).toBe('my-skill')
expect(json.resolvedVersion).toBe('2.0.0')
expect(json.warnings).toContain('Disallowed file extension: data.bin')
})
test('--dry-run reports validation errors', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
dryRunResponse: {
valid: false,
errors: ['Missing required file: SKILL.md at root'],
warnings: [],
resolvedSlug: null,
resolvedVersion: null
}
})
await login(env, registry.url)
const dir = await makeTempDir(['README.md', '# No SKILL.md here'])
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(6)
expect(result.stdout).toContain('Validation failed')
expect(result.stdout).toContain('Missing required file: SKILL.md at root')
})
test('--dry-run does not actually publish', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(registry.received.publish).toBeNull()
})
test('--dry-run respects --namespace', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
await runCli(['publish', dir, '--dry-run', '--namespace', 'myteam', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(registry.received.validate!.namespace).toBe('myteam')
})
test('--dry-run forwards --visibility to server', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
await runCli(['publish', dir, '--dry-run', '--visibility', 'private', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(registry.received.validate!.visibility).toBe('PRIVATE')
})
test('--dry-run requires authentication', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('authentication')
})
test('--dry-run reports scope error on 403', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', failures: { validate: 'forbidden' } })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('scope')
expect(result.stderr).toContain('Request ID: req-test-forbidden')
})
})

View file

@ -320,4 +320,111 @@ describe('remove command — local remove (P1)', () => {
const agents = parsed.removed.map((r: { agent: string }) => r.agent).sort()
expect(agents).toEqual(['claude-code', 'cursor'])
})
// -------------------------------------------------------------------------
// P1: --remote --hard against a slug that doesn't exist on the server
// -------------------------------------------------------------------------
test('--remote --hard for a nonexistent slug surfaces server 404 as non-zero exit', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' }
// No skills configured → DELETE returns 404.
})
const result = await runCli(
[
'remove', 'never-published',
'--remote', '--hard',
'--namespace', 'global',
'--registry', registry.url,
'--token', 'sk_ok'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).not.toBe(0)
expect(result.stderr.toLowerCase()).toMatch(/404|not found|registry returned 4/)
})
// -------------------------------------------------------------------------
// P1: --agent on a multi-target inventory leaves OTHER agents' targets
// intact in the inventory file (not just in the JSON envelope).
// -------------------------------------------------------------------------
test('--agent removes one target while leaving others in inventory.json', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
const rootDir = `${env.home}/agents`
const codexDir = `${rootDir}/codex/skills/keep-others`
const claudeDir = `${rootDir}/claude-code/skills/keep-others`
await createInstallDir(codexDir)
await createInstallDir(claudeDir)
await seedInventory(env.home, [
{
registry: registry.url,
namespace: 'global',
slug: 'keep-others',
version: '1.0.0',
targets: [
{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: codexDir, installedAt: '2026-04-20T00:00:00Z' },
{ agent: 'claude-code', rootDir: `${rootDir}/claude-code`, installDir: claudeDir, installedAt: '2026-04-20T00:00:00Z' }
]
}
])
const result = await runCli(
['remove', 'keep-others', '--agent', 'codex', '--registry', registry.url, '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as {
items: Array<{ slug: string; targets: Array<{ agent: string }> }>
}
const survived = inv.items.find(i => i.slug === 'keep-others')
expect(survived).toBeDefined()
expect(survived!.targets.map(t => t.agent)).toEqual(['claude-code'])
})
// -------------------------------------------------------------------------
// P1: --agent + --namespace together filter precisely so a same-slug skill
// in a different namespace is not collateral damage.
// -------------------------------------------------------------------------
test('--agent + --namespace filters precisely; same slug under different namespace is untouched', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
const rootDir = `${env.home}/agents`
const aDir = `${rootDir}/codex/skills/dup-slug-A`
const bDir = `${rootDir}/codex/skills/dup-slug-B`
await createInstallDir(aDir)
await createInstallDir(bDir)
await seedInventory(env.home, [
{
registry: registry.url, namespace: 'team-a', slug: 'dup-slug-A', version: '1.0.0',
targets: [{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: aDir, installedAt: '2026-04-20T00:00:00Z' }]
},
{
registry: registry.url, namespace: 'team-b', slug: 'dup-slug-B', version: '1.0.0',
targets: [{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: bDir, installedAt: '2026-04-20T00:00:00Z' }]
}
])
// Remove dup-slug-A only — dup-slug-B should survive even though both
// share the codex agent.
const result = await runCli(
['remove', 'dup-slug-A', '--agent', 'codex', '--registry', registry.url],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as {
items: Array<{ slug: string }>
}
const slugs = inv.items.map(i => i.slug).sort()
expect(slugs).toEqual(['dup-slug-B'])
})
})

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' }]
@ -103,4 +206,235 @@ describe('search command', () => {
expect(result.exitCode).toBe(3) // EXIT.network
expect(result.stderr).toMatch(/registry unreachable|registry returned 5\d\d/)
})
// -------------------------------------------------------------------------
// P2: query containing non-ASCII characters must be URL-encoded in the
// outgoing request. We capture the raw URL via a custom Bun.serve and
// assert the q parameter is the percent-encoded UTF-8 form of "中文测试".
// -------------------------------------------------------------------------
test('non-ASCII query is URL-encoded as UTF-8 percent escapes', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
const registryUrl = `http://localhost:${server.port}`
try {
const result = await runCli(['search', '中文测试', '--registry', registryUrl])
expect(result.exitCode).toBe(0)
// UTF-8 of 中文测试 = E4 B8 AD E6 96 87 E6 B5 8B E8 AF 95
expect(capturedUrl).toContain('q=%E4%B8%AD%E6%96%87%E6%B5%8B%E8%AF%95')
} finally {
server.stop()
}
})
// -------------------------------------------------------------------------
// P2: queries containing special characters (script tags, ampersands,
// equals signs) are percent-encoded so they don't break the query string.
// -------------------------------------------------------------------------
test('special-character query is encoded so the URL stays parseable', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
const registryUrl = `http://localhost:${server.port}`
try {
const result = await runCli(['search', '<script>&q=evil', '--registry', registryUrl])
expect(result.exitCode).toBe(0)
// Re-parse the captured URL and read q via URLSearchParams to confirm
// the original payload survives a round-trip without splitting.
const captured = new URL(capturedUrl)
expect(captured.searchParams.get('q')).toBe('<script>&q=evil')
} finally {
server.stop()
}
})
// -------------------------------------------------------------------------
// P2: --limit 0 still forwards limit=0 to the registry. The CLI does not
// validate boundary values; the server contract decides how to respond.
// We assert the CLI forwards faithfully and exits cleanly when the server
// returns an empty list.
// -------------------------------------------------------------------------
test('--limit 0 forwards limit=0 and renders no skills', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 0 } })
}
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
const registryUrl = `http://localhost:${server.port}`
try {
const result = await runCli(['search', 'pdf', '--limit', '0', '--registry', registryUrl])
expect(result.exitCode).toBe(0)
expect(capturedUrl).toContain('limit=0')
expect(result.stdout).toBe('No skills found.')
} finally {
server.stop()
}
})
// -------------------------------------------------------------------------
// P1: 5xx server response. Per commit a14d89d8 (refactor: unify
// download/handleJsonResponse error mapping) non-2xx responses surface
// as EXIT.generic (1), distinct from EXIT.network (3) which is reserved
// for "couldn't even reach the registry". stderr still carries the HTTP
// status so the user can debug.
// -------------------------------------------------------------------------
test('5xx server error returns EXIT.generic with status in stderr', async () => {
registry = await startFakeRegistry({ failures: { search: 'server_error' } })
const result = await runCli(['search', 'pdf', '--registry', registry.url])
expect(result.exitCode).toBe(1) // EXIT.generic
expect(result.stderr).toMatch(/registry returned 500/)
})
// -------------------------------------------------------------------------
// P2: extra --limit values, including high integers and negative inputs.
// CLI does not validate; server contract decides. We only assert the
// outgoing URL faithfully reflects the user's input.
// -------------------------------------------------------------------------
test('--limit 100 forwards limit=100 in the search URL', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 100 } })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const result = await runCli(['search', 'pdf', '--limit', '100', '--registry', `http://localhost:${server.port}`])
expect(result.exitCode).toBe(0)
expect(capturedUrl).toContain('limit=100')
} finally {
server.stop()
}
})
test('query with + and = characters round-trips faithfully through URL encoding', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const tricky = 'a+b=c&d e'
const result = await runCli(['search', tricky, '--registry', `http://localhost:${server.port}`])
expect(result.exitCode).toBe(0)
const captured = new URL(capturedUrl)
expect(captured.searchParams.get('q')).toBe(tricky)
} finally {
server.stop()
}
})
test('1KB long query is forwarded without truncation', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const longQuery = 'q'.repeat(1024)
const result = await runCli(['search', longQuery, '--registry', `http://localhost:${server.port}`])
expect(result.exitCode).toBe(0)
const captured = new URL(capturedUrl)
expect(captured.searchParams.get('q')).toBe(longQuery)
} finally {
server.stop()
}
})
test('literal % in query is encoded so it survives a round-trip without being mistaken for an escape', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
// A literal '%' must be escaped as %25 so the server doesn't read it
// as the start of an existing escape sequence.
const tricky = '50% off'
const result = await runCli(['search', tricky, '--registry', `http://localhost:${server.port}`])
expect(result.exitCode).toBe(0)
expect(capturedUrl).toContain('%25')
const captured = new URL(capturedUrl)
expect(captured.searchParams.get('q')).toBe(tricky)
} finally {
server.stop()
}
})
test('query with multiple shell metacharacters survives both shell quoting and URL encoding', async () => {
let capturedUrl = ''
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/api/cli/v1/skills/search') {
capturedUrl = req.url
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
}
return Response.json({ code: 404 }, { status: 404 })
}
})
try {
const tricky = "$VAR `cmd` 'quote' \"dq\""
const result = await runCli(['search', tricky, '--registry', `http://localhost:${server.port}`])
expect(result.exitCode).toBe(0)
const captured = new URL(capturedUrl)
expect(captured.searchParams.get('q')).toBe(tricky)
} finally {
server.stop()
}
})
})

View file

@ -0,0 +1,162 @@
/**
* CLI skill version-upgrade flow.
*
* These tests cover scenarios that span multiple `install` invocations
* against a registry whose state changes between runs i.e. the user-facing
* "upgrade an installed skill" workflow. They complement the per-command
* tests in install-command.test.ts which use a single static registry.
*
* Coverage focus (per test-case-design-skill methodology):
* - VU1 (state-transition): full v1 v2 upgrade lifecycle. Asserts that
* metadata.json, inventory.json AND the on-disk bundle all reflect v2
* after `install --force`, with no orphaned v1 entry left behind.
* - VU2 (equivalence-class on `--version`): pinning to a specific version
* forwards `?version=` to /resolve so the registry can serve the
* intended bundle.
*/
import { mkdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
import { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
function makeSkillZipWithBody(body: string): Uint8Array {
return zipSync({ 'SKILL.md': strToU8(body) })
}
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop()
registry = undefined
})
describe('version upgrade flow', () => {
// -------------------------------------------------------------------------
// VU1 — full upgrade lifecycle:
// 1. Registry serves pdf-parser@1.0.0 → install → metadata=v1, content=v1
// 2. Stop registry, start a new one serving pdf-parser@2.0.0
// 3. Install --force using the new registry URL
// 4. metadata.json, inventory.json AND on-disk SKILL.md all reflect v2
// -------------------------------------------------------------------------
test('VU1 install v1 then upgrade to v2 with --force replaces metadata, inventory, and content', async () => {
const env = await createTempHome()
// --- Stage 1: install v1 ----------------------------------------------
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{
namespace: 'global',
slug: 'pdf-parser',
version: '1.0.0',
zipBytes: makeSkillZipWithBody('# pdf-parser v1\n\nVersion one body.')
}]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-upgrade')
await mkdir(installDir, { recursive: true })
const r1 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r1.exitCode).toBe(0)
const skillFile = join(installDir, 'pdf-parser', 'SKILL.md')
const metaPath = join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')
const inventoryPath = join(env.home, '.skillhub', 'inventory.json')
{
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.version).toBe('1.0.0')
const body = await readFile(skillFile, 'utf-8')
expect(body).toContain('Version one body.')
}
// --- Stage 2: swap registry to v2 -------------------------------------
registry.stop()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{
namespace: 'global',
slug: 'pdf-parser',
version: '2.0.0',
zipBytes: makeSkillZipWithBody('# pdf-parser v2\n\nVersion two body.')
}]
})
// Re-login against the new registry (URL changed, so credentials are
// keyed differently).
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const r2 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r2.exitCode).toBe(0)
// --- Stage 3: assert end state ----------------------------------------
const finalMeta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(finalMeta.version).toBe('2.0.0')
expect(finalMeta.registry).toBe(registry.url)
const finalBody = await readFile(skillFile, 'utf-8')
expect(finalBody).toContain('Version two body.')
expect(finalBody).not.toContain('Version one body.')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) as {
items: Array<{ namespace: string; slug: string; version: string; targets: Array<{ installDir: string }> }>
}
const matching = inventory.items.filter(i => i.namespace === 'global' && i.slug === 'pdf-parser')
expect(matching).toHaveLength(1) // no duplicate v1 entry
expect(matching[0]?.version).toBe('2.0.0')
})
// -------------------------------------------------------------------------
// VU2 — version pinning:
// `install --version=X` must forward ?version=X to /resolve so the
// registry can serve the requested bundle. The fake registry captures
// the resolve query for assertion.
// -------------------------------------------------------------------------
test('VU2 --version pins resolve to the requested version string', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{
namespace: 'global',
slug: 'pdf-parser',
version: '1.5.0',
zipBytes: makeSkillZipWithBody('# pinned')
}]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-pin')
await mkdir(installDir, { recursive: true })
const result = await runCli(
[
'install', 'pdf-parser',
'--version', '1.5.0',
'--dir', installDir,
'--registry', registry.url,
'--token', 'sk_ok'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
expect(registry.received.resolve?.namespace).toBe('global')
expect(registry.received.resolve?.slug).toBe('pdf-parser')
expect(registry.received.resolve?.version).toBe('1.5.0')
const meta = JSON.parse(await readFile(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json'), 'utf-8'))
expect(meta.version).toBe('1.5.0')
})
})

View file

@ -98,4 +98,54 @@ describe('whoami command', () => {
expect(result.exitCode).toBe(2)
expect(result.stderr.toLowerCase()).toContain('authentication failed')
})
// ---------------------------------------------------------------------------
// P1 — Stored-token revocation: token persists in credentials.json but
// server now rejects it. whoami must surface the auth failure on first
// call, not silently use a stale principal.
// ---------------------------------------------------------------------------
test('stored token that the server now rejects surfaces EXIT.auth on whoami', async () => {
// Configure a registry that requires sk_new but seed sk_old in credentials
// — simulates a token that was valid at login time but has since been
// revoked or rotated server-side.
registry = await startFakeRegistry({
token: 'sk_new',
user: { handle: 'should-not-see', displayName: 'X' }
})
const { home } = await createTempHome()
const env = { HOME: home, USERPROFILE: home }
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(
join(home, '.skillhub', 'credentials.json'),
JSON.stringify({ tokens: { [registry.url]: 'sk_old_revoked' } })
)
const result = await runCli(['whoami', '--registry', registry.url], env)
expect(result.exitCode).toBe(2)
expect(result.stderr.toLowerCase()).toMatch(/auth|401|unauthorized/)
})
// ---------------------------------------------------------------------------
// P1 — Token priority --token > SKILLHUB_TOKEN > stored, end-to-end via
// whoami. Cross-checks the auth-resolution suite by verifying the wired
// contract on this specific command.
// ---------------------------------------------------------------------------
test('--token wins over SKILLHUB_TOKEN env on whoami', async () => {
registry = await startFakeRegistry({
token: 'sk_winner',
user: { handle: 'winner', displayName: 'W' }
})
const { home } = await createTempHome()
const env = { HOME: home, USERPROFILE: home, SKILLHUB_TOKEN: 'sk_loser_env' }
const result = await runCli(
['whoami', '--token', 'sk_winner', '--registry', registry.url],
env
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('winner')
})
})

View file

@ -0,0 +1,76 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
import type { AgentCandidate } from '../../../src/agents/types'
interface PromptChoice {
value: AgentCandidate
}
interface PromptOptions {
choices?: PromptChoice[]
onRender?: (this: { cursor?: number }) => void
format?: (selectedTargets: AgentCandidate[]) => AgentCandidate[]
}
const defaultSelectedTargets = (options: PromptOptions): AgentCandidate[] => options.format?.([]) ?? []
let selectPromptTargets = defaultSelectedTargets
mock.module('prompts', () => ({
default: (options: PromptOptions) => {
options.onRender?.call({ cursor: 1 })
return { selected: selectPromptTargets(options) }
}
}))
afterEach(() => {
selectPromptTargets = defaultSelectedTargets
})
const { resolveInstallTargets } = await import('../../../src/agents/resolver')
describe('resolveInstallTargets interactive prompt', () => {
test('uses the highlighted target when Enter submits an empty multiselect', async () => {
const detected: AgentCandidate[] = [
{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' },
{ agent: 'claude-code', rootDir: '/repo/.claude/skills', scope: 'project', source: 'detected' }
]
const highlighted = detected[1]!
const targets = await resolveInstallTargets({
cwd: '/repo',
agents: [],
json: false,
interactive: true,
detected
})
expect(targets).toEqual([highlighted])
})
test('allows selecting generic alongside detected user targets', async () => {
selectPromptTargets = options => options.choices?.map(choice => choice.value) ?? []
const codex: AgentCandidate = {
agent: 'codex',
rootDir: '/home/u/.codex/skills',
scope: 'user',
source: 'detected'
}
const generic: AgentCandidate = {
agent: 'generic',
rootDir: '/home/u/.agents/skills',
scope: 'user',
source: 'fallback'
}
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: [],
scope: 'user',
json: false,
interactive: true,
detected: [codex]
})
expect(targets).toEqual([codex, generic])
})
})

View file

@ -1,5 +1,9 @@
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
import { resolveInstallTargets } from '../../../src/agents/resolver'
import type { AgentCandidate } from '../../../src/agents/types'
describe('resolveInstallTargets', () => {
test('rejects dir and agent together before filesystem writes', async () => {
@ -44,6 +48,18 @@ describe('resolveInstallTargets', () => {
expect(targets).toEqual([{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }])
})
test('explicit agent without scope labels root by userRoots membership when cwd === home', async () => {
const targets = await resolveInstallTargets({
cwd: '/home/u',
home: '/home/u',
agents: ['codex'],
json: false,
interactive: false
})
expect(targets[0]!.scope).toBe('user')
expect(targets[0]!.rootDir).toBe('/home/u/.codex/skills')
})
test('deduplicates repeated explicit agents by target root', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
@ -89,4 +105,151 @@ describe('resolveInstallTargets', () => {
interactive: false
})).rejects.toThrow('unknown agent: unknown-agent')
})
test('rejects dir and scope together', async () => {
await expect(resolveInstallTargets({
cwd: '/repo',
dir: '/tmp/skills',
scope: 'user',
json: false,
interactive: false
})).rejects.toThrow('--dir cannot be used with --scope')
})
test('scope=project + agent codex returns project root with project scope', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: ['codex'],
scope: 'project',
json: false,
interactive: false
})
expect(targets).toEqual([{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'explicit' }])
})
test('scope=user + agent codex returns user root with user scope', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: ['codex'],
scope: 'user',
json: false,
interactive: false
})
expect(targets).toEqual([{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }])
})
test('scope=user + cwd === home + agent codex still labels candidate as user', async () => {
const targets = await resolveInstallTargets({
cwd: '/home/u',
home: '/home/u',
agents: ['codex'],
scope: 'user',
json: false,
interactive: false
})
expect(targets[0]!.scope).toBe('user')
})
test('scope=user clean env falls back to user agents skills', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/nonexistent-home-' + Math.random().toString(36).slice(2),
agents: [],
scope: 'user',
json: false,
interactive: false
})
expect(targets).toEqual([{
agent: 'generic',
rootDir: targets[0]!.rootDir,
scope: 'user',
source: 'fallback'
}])
expect(targets[0]!.rootDir).toMatch(/\.agents\/skills$/)
expect(targets[0]!.rootDir.startsWith('/nonexistent-home-')).toBe(true)
})
test('scope=project clean env falls back to cwd agents skills', async () => {
const targets = await resolveInstallTargets({
cwd: '/nonexistent-repo-' + Math.random().toString(36).slice(2),
home: '/home/u',
agents: [],
scope: 'project',
json: false,
interactive: false
})
expect(targets).toHaveLength(1)
expect(targets[0]!.scope).toBe('project')
expect(targets[0]!.source).toBe('fallback')
expect(targets[0]!.rootDir).toMatch(/\.agents\/skills$/)
})
test('scope filters detected candidates and falls back when filtered empty', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: [],
scope: 'user',
json: false,
interactive: false,
detected: [
{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' }
]
})
expect(targets).toHaveLength(1)
expect(targets[0]!.source).toBe('fallback')
expect(targets[0]!.scope).toBe('user')
})
test('scope filters detected candidates keeps matching scope', async () => {
const targets = await resolveInstallTargets({
cwd: '/repo',
home: '/home/u',
agents: [],
scope: 'user',
json: false,
interactive: false,
detected: [
{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' },
{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'detected' }
]
})
expect(targets).toHaveLength(1)
expect(targets[0]!.rootDir).toBe('/home/u/.codex/skills')
expect(targets[0]!.scope).toBe('user')
})
test('deduplicates a symlinked detected target and the generic user target', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-resolver-home-'))
const genericRoot = join(home, '.agents', 'skills')
const codexRoot = join(home, '.codex', 'skills')
const codex: AgentCandidate = {
agent: 'codex',
rootDir: codexRoot,
scope: 'user',
source: 'detected'
}
try {
await mkdir(genericRoot, { recursive: true })
await mkdir(join(home, '.codex'), { recursive: true })
await symlink(genericRoot, codexRoot, process.platform === 'win32' ? 'junction' : 'dir')
const targets = await resolveInstallTargets({
cwd: '/repo',
home,
agents: [],
scope: 'user',
json: false,
interactive: true,
detected: [codex]
})
expect(targets).toEqual([codex])
} finally {
await rm(home, { recursive: true, force: true })
}
})
})

View file

@ -37,12 +37,21 @@ describe('SkillHubClient', () => {
})
test('download() throws auth error on 403', async () => {
const fetchImpl = (async () => new Response(null, { status: 403 })) as unknown as typeof fetch
const fetchImpl = (async () => Response.json({
code: 403,
msg: 'API token is missing required scope: skill:read',
requestId: 'req-download'
}, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
const err = expect(client.download('ns', 'slug')).rejects
await err.toBeInstanceOf(CliError)
await err.toHaveProperty('message', 'authentication failed')
await err.toHaveProperty('exitCode', EXIT.auth)
await expect(client.download('ns', 'slug')).rejects.toMatchObject({
message: 'API token is missing required scope: skill:read',
exitCode: EXIT.auth,
details: {
registry: 'http://registry.test',
requestId: 'req-download'
}
})
})
test('download() throws not-found error on 404', async () => {
@ -159,6 +168,35 @@ describe('SkillHubClient', () => {
// --- handleJsonResponse() non-2xx classification ---
test('whoami() surfaces server reason and request ID on 403', async () => {
const fetchImpl = (async () => Response.json({
code: 403,
msg: 'API token cannot access endpoint: /api/cli/v1/whoami',
requestId: 'req-610'
}, { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.whoami()).rejects.toMatchObject({
message: 'API token cannot access endpoint: /api/cli/v1/whoami',
exitCode: EXIT.auth,
details: {
registry: 'http://registry.test',
requestId: 'req-610'
}
})
})
test('whoami() falls back to generic access denied when 403 body is invalid', async () => {
const fetchImpl = (async () => new Response('not-json', { status: 403 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
await expect(client.whoami()).rejects.toMatchObject({
message: 'access denied',
exitCode: EXIT.auth,
details: { registry: 'http://registry.test' }
})
})
test('whoami() throws generic error on 500', async () => {
const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)

View file

@ -0,0 +1,200 @@
import { describe, expect, test } from 'bun:test'
import { CliError } from '../../../src/shared/errors'
import {
computeStrictIsTTY,
installCommand,
resolveEffectiveScope,
type InstallCommandDeps,
type InstallCommandOptions
} from '../../../src/commands/install'
import type { AgentCandidate } from '../../../src/agents/types'
import type { ResolveInstallTargetOptions } from '../../../src/agents/resolver'
describe('computeStrictIsTTY', () => {
test('true when stdin and stdout are TTY and not json', () => {
expect(computeStrictIsTTY({ stdinIsTTY: true, stdoutIsTTY: true, json: false })).toBe(true)
})
test('false when stdin is not TTY', () => {
expect(computeStrictIsTTY({ stdinIsTTY: false, stdoutIsTTY: true, json: false })).toBe(false)
})
test('false when stdout is not TTY', () => {
expect(computeStrictIsTTY({ stdinIsTTY: true, stdoutIsTTY: false, json: false })).toBe(false)
})
test('false when json is true', () => {
expect(computeStrictIsTTY({ stdinIsTTY: true, stdoutIsTTY: true, json: true })).toBe(false)
})
})
describe('resolveEffectiveScope', () => {
function neverPrompt(): Promise<'user' | 'project'> {
throw new Error('promptScope should not be called')
}
test('rejects invalid --scope value', async () => {
await expect(resolveEffectiveScope(
{ scope: 'team' } as InstallCommandOptions,
{ isTTY: false, promptScope: neverPrompt }
)).rejects.toThrow('--scope must be "user" or "project"')
})
test('rejects --dir with --scope', async () => {
await expect(resolveEffectiveScope(
{ scope: 'user', dir: '/tmp/x' } as InstallCommandOptions,
{ isTTY: false, promptScope: neverPrompt }
)).rejects.toThrow('--dir cannot be used with --scope')
})
test('rejects --dir with --agent', async () => {
await expect(resolveEffectiveScope(
{ dir: '/tmp/x', agent: ['codex'] } as InstallCommandOptions,
{ isTTY: false, promptScope: neverPrompt }
)).rejects.toThrow('--dir cannot be used with --agent')
})
test('returns explicit --scope value', async () => {
const scope = await resolveEffectiveScope(
{ scope: 'user' } as InstallCommandOptions,
{ isTTY: true, promptScope: neverPrompt }
)
expect(scope).toBe('user')
})
test('--agent without --scope returns undefined (regression protection)', async () => {
const scope = await resolveEffectiveScope(
{ agent: ['codex'] } as InstallCommandOptions,
{ isTTY: true, promptScope: neverPrompt }
)
expect(scope).toBeUndefined()
})
test('--dir without --scope returns undefined (regression protection)', async () => {
const scope = await resolveEffectiveScope(
{ dir: '/tmp/x' } as InstallCommandOptions,
{ isTTY: true, promptScope: neverPrompt }
)
expect(scope).toBeUndefined()
})
test('non-interactive bare install returns undefined without calling promptScope', async () => {
const scope = await resolveEffectiveScope(
{} as InstallCommandOptions,
{ isTTY: false, promptScope: neverPrompt }
)
expect(scope).toBeUndefined()
})
test('interactive bare install calls promptScope and returns user', async () => {
let calls = 0
const scope = await resolveEffectiveScope(
{} as InstallCommandOptions,
{
isTTY: true,
promptScope: async () => { calls++; return 'user' }
}
)
expect(scope).toBe('user')
expect(calls).toBe(1)
})
test('interactive bare install + promptScope returns project', async () => {
const scope = await resolveEffectiveScope(
{} as InstallCommandOptions,
{ isTTY: true, promptScope: async () => 'project' }
)
expect(scope).toBe('project')
})
test('interactive bare install + promptScope cancel propagates CliError', async () => {
await expect(resolveEffectiveScope(
{} as InstallCommandOptions,
{
isTTY: true,
promptScope: async () => { throw new CliError('installation cancelled', 5) }
}
)).rejects.toThrow('installation cancelled')
})
test('empty agent array does not skip promptScope', async () => {
let calls = 0
const scope = await resolveEffectiveScope(
{ agent: [] } as InstallCommandOptions,
{
isTTY: true,
promptScope: async () => { calls++; return 'user' }
}
)
expect(scope).toBe('user')
expect(calls).toBe(1)
})
})
describe('installCommand dependency injection', () => {
function fakeInstallSkill(): NonNullable<InstallCommandDeps['installSkill']> {
return async () => ({ installed: [{ agent: 'codex', dir: '/home/u/.codex/skills/foo' }] })
}
test('passes prompted scope and strict isTTY into resolveInstallTargets', async () => {
const calls: { promptScope: number; resolverCalls: ResolveInstallTargetOptions[] } = {
promptScope: 0,
resolverCalls: []
}
const deps: InstallCommandDeps = {
isTTY: () => true,
promptScope: async () => { calls.promptScope++; return 'user' },
resolveInstallTargets: async (opts) => {
calls.resolverCalls.push(opts)
return [{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }] as AgentCandidate[]
},
installSkill: fakeInstallSkill()
}
await installCommand('foo', { registry: 'http://localhost', token: 'sk' }, deps)
expect(calls.promptScope).toBe(1)
expect(calls.resolverCalls).toHaveLength(1)
expect(calls.resolverCalls[0]!.scope).toBe('user')
expect(calls.resolverCalls[0]!.interactive).toBe(true)
})
test('does not call promptScope when --agent is provided', async () => {
let promptCalls = 0
let resolverScope: 'user' | 'project' | undefined = 'user'
const deps: InstallCommandDeps = {
isTTY: () => true,
promptScope: async () => { promptCalls++; return 'user' },
resolveInstallTargets: async (opts) => {
resolverScope = opts.scope
return [{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }] as AgentCandidate[]
},
installSkill: fakeInstallSkill()
}
await installCommand('foo', {
registry: 'http://localhost',
token: 'sk',
agent: ['codex']
}, deps)
expect(promptCalls).toBe(0)
expect(resolverScope).toBeUndefined()
})
test('passes interactive=false when isTTY returns false', async () => {
let interactiveFlag: boolean | undefined
const deps: InstallCommandDeps = {
isTTY: () => false,
promptScope: async () => { throw new Error('should not be called') },
resolveInstallTargets: async (opts) => {
interactiveFlag = opts.interactive
return [{ agent: 'generic', rootDir: '/tmp/.agents/skills', scope: 'project', source: 'fallback' }] as AgentCandidate[]
},
installSkill: fakeInstallSkill()
}
await installCommand('foo', { registry: 'http://localhost', token: 'sk' }, deps)
expect(interactiveFlag).toBe(false)
})
})

View file

@ -91,4 +91,173 @@ describe('updateCommand branches', () => {
expect(caught).toBeInstanceOf(CliError)
expect((caught as CliError).message).toContain('install command failed')
})
// -------------------------------------------------------------------------
// bun-global success branch — symmetric to npm-global success but routes
// through `bun add -g` rather than `npm install -g`. Command-level output
// still uses the same "Updated skillhub X -> Y" copy, but the run dep
// receives a different argv. We assert both: (a) the command captured the
// bun argv on the dep and (b) the output formatting matches.
// -------------------------------------------------------------------------
test('updated success branch — bun-global with run() success captures bun argv (human + json)', async () => {
const calls: string[][] = []
const deps: Required<UpdateCommandDeps> = {
latestVersion: async () => '99.0.0',
detectInstallMode: () => 'bun-global',
run: async (cmd) => {
calls.push([...cmd])
return { success: true, output: '' }
}
}
const human = await updateCommand({}, deps)
expect(human).toContain(`Updated skillhub ${CLI_VERSION} -> 99.0.0`)
const json = await updateCommand({ json: true }, deps)
expect(JSON.parse(json)).toEqual({ ok: true, updated: true, from: CLI_VERSION, to: '99.0.0' })
// Both invocations must have routed through `bun add -g` — never `npm`.
expect(calls).toHaveLength(2)
for (const cmd of calls) {
expect(cmd[0]).toBe('bun')
expect(cmd[1]).toBe('add')
expect(cmd[2]).toBe('-g')
expect(cmd[3]).toMatch(/@latest$/)
}
})
// -------------------------------------------------------------------------
// unknown install mode — falls through to the manual-upgrade hint. The
// command must NOT invoke run() (we'd be guessing the package manager),
// and the human output must spell out both npm and bun fallbacks so the
// user can pick whichever is on their system.
// -------------------------------------------------------------------------
test('available-not-updated branch — unknown mode emits manual upgrade hint and never calls run()', async () => {
let runCalls = 0
const deps: Required<UpdateCommandDeps> = {
latestVersion: async () => '99.0.0',
detectInstallMode: () => 'unknown',
run: async () => {
runCalls += 1
return { success: true, output: '' }
}
}
const human = await updateCommand({}, deps)
expect(human).toContain(`Update available: ${CLI_VERSION} -> 99.0.0`)
expect(human).toContain('npm install -g')
expect(human).toContain('bun add -g')
const json = await updateCommand({ json: true }, deps)
const parsed = JSON.parse(json)
expect(parsed.ok).toBe(true)
expect(parsed.available).toBe(true)
expect(typeof parsed.next).toBe('string')
expect(parsed.next).toMatch(/npm install -g.*bun add -g|bun add -g.*npm install -g/)
// Neither invocation should have attempted to spawn an installer.
expect(runCalls).toBe(0)
})
// -------------------------------------------------------------------------
// checkOnly short-circuit — even in install modes that WOULD execute an
// upgrade (npm-global, bun-global), passing { check: true } must short
// circuit the service before it reaches `run()`. Output is the bare
// "Update available" line with no `next` hint (next is only populated
// for npx / unknown branches, not on checkOnly's early return).
// -------------------------------------------------------------------------
test('checkOnly with npm-global short-circuits and never calls run()', async () => {
let runCalls = 0
const deps: Required<UpdateCommandDeps> = {
latestVersion: async () => '99.0.0',
detectInstallMode: () => 'npm-global',
run: async () => {
runCalls += 1
return { success: true, output: '' }
}
}
const human = await updateCommand({ check: true }, deps)
expect(human.trim()).toBe(`Update available: ${CLI_VERSION} -> 99.0.0`)
const json = await updateCommand({ check: true, json: true }, deps)
const parsed = JSON.parse(json)
expect(parsed.ok).toBe(true)
expect(parsed.available).toBe(true)
// No `next` hint on the checkOnly path.
expect(parsed.next).toBeUndefined()
expect(runCalls).toBe(0)
})
// -------------------------------------------------------------------------
// P1 — Version comparison edge cases. semver.gt drives the available
// gate; these tests pin the boundary where "no upgrade" must hold.
// -------------------------------------------------------------------------
test('latest version equal to current is reported as up-to-date', async () => {
const deps = buildDeps({
latest: CLI_VERSION,
mode: 'npm-global',
runResult: { success: true, output: '' }
})
const human = await updateCommand({}, deps)
expect(human).toContain('Already up to date')
})
test('latest version older than current is treated as up-to-date (no downgrade)', async () => {
const deps = buildDeps({
latest: '0.0.1',
mode: 'npm-global',
runResult: { success: true, output: '' }
})
const human = await updateCommand({}, deps)
expect(human).toContain('Already up to date')
// Make sure we didn't accidentally invoke npm with an older version.
const jsonOut = await updateCommand({ json: true }, deps)
expect(JSON.parse(jsonOut).updated).toBeUndefined()
})
test('checkOnly + already up-to-date emits a non-available envelope', async () => {
const deps = buildDeps({
latest: CLI_VERSION,
mode: 'unknown',
runResult: { success: true, output: '' }
})
const json = await updateCommand({ check: true, json: true }, deps)
const parsed = JSON.parse(json)
expect(parsed).toMatchObject({ ok: true, upToDate: true, version: CLI_VERSION })
})
test('bun-global run() failure surfaces the failure output as a CliError message', async () => {
const deps = buildDeps({
latest: '99.0.0',
mode: 'bun-global',
runResult: { success: false, output: 'bun add: permission denied' }
})
let caught: unknown
try {
await updateCommand({}, deps)
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(CliError)
expect((caught as CliError).message).toContain('bun add')
})
test('checkOnly with npx emits Update-available envelope WITHOUT a next hint (next is mode-specific)', async () => {
// Per service code, npx's `next` is added only when checkOnly is false.
// checkOnly returns earlier and never enters the mode switch.
const deps = buildDeps({
latest: '99.0.0',
mode: 'npx',
runResult: { success: true, output: '' }
})
const json = await updateCommand({ check: true, json: true }, deps)
const parsed = JSON.parse(json)
expect(parsed.ok).toBe(true)
expect(parsed.available).toBe(true)
expect(parsed.next).toBeUndefined()
expect(parsed.from).toBe(CLI_VERSION)
expect(parsed.to).toBe('99.0.0')
})
})

View file

@ -32,6 +32,17 @@ describe('archive helpers', () => {
await expect(extractZip(unsafe.buffer as ArrayBuffer, target)).rejects.toThrow('unsafe zip entry path')
})
test('rejects unsafe zip before writing earlier safe entries', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-partial-'))
const unsafe = zipSync({
'SKILL.md': new TextEncoder().encode('# Partial'),
'../escape.txt': new TextEncoder().encode('bad'),
})
await expect(extractZip(unsafe.buffer as ArrayBuffer, target)).rejects.toThrow('unsafe zip entry path')
await expect(readFile(join(target, 'SKILL.md'), 'utf-8')).rejects.toThrow()
})
test('rejects zip entries with absolute paths', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-abs-'))
const unsafe = zipSync({ '/etc/passwd': new TextEncoder().encode('bad') })
@ -56,4 +67,32 @@ describe('archive helpers', () => {
const entries = await readdir(target)
expect(entries).toEqual([])
})
test('rejects zip archives with more than 500 entries before extraction', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-many-'))
const manyEntries = Object.fromEntries(
Array.from({ length: 501 }, (_, index) => [`file-${index}.txt`, new Uint8Array(0)])
)
const archive = zipSync(manyEntries)
await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry count exceeds limit')
})
test('rejects zip entries larger than 10 MiB before extraction', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-file-'))
const archive = zipSync({ 'large.bin': new Uint8Array(10 * 1024 * 1024 + 1) })
await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry size exceeds limit')
})
test('rejects zip archives larger than 100 MiB after decompression before extraction', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-total-'))
const oneMiB = new Uint8Array(1024 * 1024)
const entries = Object.fromEntries(
Array.from({ length: 101 }, (_, index) => [`file-${index}.bin`, oneMiB])
)
const archive = zipSync(entries)
await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip total uncompressed size exceeds limit')
})
})

View file

@ -1,4 +1,4 @@
import { access, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
@ -21,6 +21,13 @@ function installFetch(zipEntries: Record<string, string>): typeof fetch {
Object.entries(zipEntries).map(([name, content]) => [name, new TextEncoder().encode(content)])
))
return installFetchWithDownloadResponse(new Response(
archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer,
{ status: 200 }
))
}
function installFetchWithDownloadResponse(downloadResponse: Response): typeof fetch {
const fakeFetch = async (input: URL | RequestInfo) => {
const path = new URL(String(input)).pathname
if (path.endsWith('/resolve')) {
@ -37,8 +44,7 @@ function installFetch(zipEntries: Record<string, string>): typeof fetch {
})
}
if (path.endsWith('/download')) {
const body = archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer
return new Response(body, { status: 200 })
return downloadResponse.clone()
}
return Response.json({ code: 404 }, { status: 404 })
}
@ -66,6 +72,62 @@ describe('installSkill', () => {
})).rejects.toThrow('skill already installed')
})
test('preflights all targets before writing when a later target is occupied', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const firstRoot = await mkdtemp(join(tmpdir(), 'skillhub-install-first-root-'))
const secondRoot = await mkdtemp(join(tmpdir(), 'skillhub-install-second-root-'))
const firstSkillDir = join(firstRoot, 'demo')
const secondSkillDir = join(secondRoot, 'demo')
await mkdir(secondSkillDir, { recursive: true })
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [
{ agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' },
{ agent: 'claude-code', rootDir: secondRoot, scope: 'project', source: 'explicit' }
],
force: false,
home
})).rejects.toThrow(`skill already installed at ${secondSkillDir}`)
expect(await exists(firstSkillDir)).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('rejects canonical target aliases before writing any installation', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const targetParent = await mkdtemp(join(tmpdir(), 'skillhub-install-targets-'))
const genericRoot = join(targetParent, 'generic')
const codexRoot = join(targetParent, 'codex')
const skillDir = join(genericRoot, 'demo')
try {
await mkdir(genericRoot, { recursive: true })
await symlink(genericRoot, codexRoot, process.platform === 'win32' ? 'junction' : 'dir')
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [
{ agent: 'codex', rootDir: codexRoot, scope: 'user', source: 'detected' },
{ agent: 'generic', rootDir: genericRoot, scope: 'user', source: 'fallback' }
],
force: false,
home
})).rejects.toThrow('multiple install targets resolve to')
expect(await exists(skillDir)).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
} finally {
await rm(home, { recursive: true, force: true })
await rm(targetParent, { recursive: true, force: true })
}
})
test('force replaces the old skill directory instead of overlaying files', async () => {
globalThis.fetch = installFetch({ 'SKILL.md': '# New' })
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
@ -125,4 +187,60 @@ describe('installSkill', () => {
expect(inventory.items[0].targets).toHaveLength(1)
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
})
test('force keeps old installation and inventory when replacement extraction fails', async () => {
globalThis.fetch = installFetchWithDownloadResponse(new Response(new TextEncoder().encode('not a zip'), { status: 200 }))
const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
const skillDir = join(rootDir, 'demo')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Old')
const inventoryPath = join(home, '.skillhub', 'inventory.json')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(inventoryPath, JSON.stringify({
items: [{
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
version: '0.1.0',
targets: [{
agent: 'codex',
rootDir,
installDir: skillDir,
installedAt: '2026-04-20T00:00:00.000Z'
}]
}]
}, null, 2))
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home
})).rejects.toThrow('invalid zip central directory')
expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Old')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo', version: '0.1.0' })
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
})
test('rejects downloads whose content-length exceeds the package limit', async () => {
globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), {
status: 200,
headers: { 'Content-Length': String(100 * 1024 * 1024 + 1) }
}))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false
})).rejects.toThrow('download exceeds maximum package size')
})
})

View file

@ -16,11 +16,13 @@ describe('renderError', () => {
test('renders human error without stack trace', () => {
const error = new CliError('registry unreachable', 3, {
registry: 'https://registry.example.com',
requestId: 'req-610',
next: 'check network or pass --registry'
})
expect(renderError(error, false)).toBe([
'Error: registry unreachable',
'Context: registry https://registry.example.com',
'Request ID: req-610',
'Next: check network or pass --registry'
].join('\n'))
})

View file

@ -0,0 +1,90 @@
import { describe, test, expect } from 'bun:test'
import { parseSkillName } from '../../../src/shared/skill-name-parser'
describe('parseSkillName', () => {
describe('with namespace--slug format', () => {
test('should parse namespace and slug separated by double dash', () => {
const result = parseSkillName('astroclaw--api-gateway')
expect(result).toEqual({
namespace: 'astroclaw',
slug: 'api-gateway'
})
})
test('should handle namespace and slug with single dashes', () => {
const result = parseSkillName('my-org--my-skill-name')
expect(result).toEqual({
namespace: 'my-org',
slug: 'my-skill-name'
})
})
test('should handle multiple double dashes by using first as separator', () => {
const result = parseSkillName('namespace--slug--with--dashes')
expect(result).toEqual({
namespace: 'namespace',
slug: 'slug--with--dashes'
})
})
})
describe('with slug only format', () => {
test('should use default namespace when no separator present', () => {
const result = parseSkillName('api-gateway')
expect(result).toEqual({
namespace: 'global',
slug: 'api-gateway'
})
})
test('should use custom default namespace when provided', () => {
const result = parseSkillName('api-gateway', 'myorg')
expect(result).toEqual({
namespace: 'myorg',
slug: 'api-gateway'
})
})
test('should handle slug with single dashes', () => {
const result = parseSkillName('my-skill-name')
expect(result).toEqual({
namespace: 'global',
slug: 'my-skill-name'
})
})
})
describe('edge cases', () => {
test('should handle separator at start', () => {
const result = parseSkillName('--api-gateway')
expect(result).toEqual({
namespace: 'global',
slug: 'api-gateway'
})
})
test('should handle separator at end', () => {
const result = parseSkillName('astroclaw--')
expect(result).toEqual({
namespace: 'global',
slug: 'astroclaw'
})
})
test('should handle empty string', () => {
const result = parseSkillName('')
expect(result).toEqual({
namespace: 'global',
slug: ''
})
})
test('should handle just separator', () => {
const result = parseSkillName('--')
expect(result).toEqual({
namespace: 'global',
slug: ''
})
})
})
})

View file

@ -58,6 +58,7 @@ services:
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-}
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:?required}
SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3}
STORAGE_BASE_PATH: /var/lib/skillhub/storage
SKILLHUB_STORAGE_S3_ENDPOINT: ${SKILLHUB_STORAGE_S3_ENDPOINT:-}
@ -72,6 +73,7 @@ services:
SKILLHUB_SECURITY_SCANNER_ENABLED: ${SKILLHUB_SECURITY_SCANNER_ENABLED:-true}
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
SKILLHUB_SECURITY_SCANNER_MODE: upload
SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false}
BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-false}
BOOTSTRAP_ADMIN_USER_ID: ${BOOTSTRAP_ADMIN_USER_ID:-docker-admin}
BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin}
@ -98,6 +100,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
skill-scanner:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 10s
@ -114,6 +118,8 @@ services:
SKILLHUB_API_UPSTREAM: ${SKILLHUB_API_UPSTREAM:-http://server:8080}
SKILLHUB_WEB_API_BASE_URL: ${SKILLHUB_WEB_API_BASE_URL:-}
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
SKILLHUB_WEB_AUTH_DIRECT_ENABLED: ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED:-false}
SKILLHUB_WEB_AUTH_DIRECT_PROVIDER: ${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER:-}
depends_on:
server:
condition: service_healthy

View file

@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |
| skill-scanner-llm-api-key | LLM API 密钥 | 否 |
| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 |
| skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 |
### 3. 选择部署方式
@ -192,6 +194,7 @@ kubectl apply -k overlays/with-infra/ # 或 overlays/external/
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |
| skill-scanner-llm-api-key | LLM API 密钥 | 否 |
| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 |
| skill-scanner-llm-model | LLM 模型名称 | 否 |
### 存储配置

View file

@ -28,6 +28,12 @@ spec:
name: skillhub-secret
key: skill-scanner-llm-api-key
optional: true
- name: SKILL_SCANNER_LLM_BASE_URL
valueFrom:
secretKeyRef:
name: skillhub-secret
key: skill-scanner-llm-base-url
optional: true
- name: SKILL_SCANNER_LLM_MODEL
valueFrom:
secretKeyRef:

View file

@ -24,6 +24,7 @@ stringData:
# LLM 配置(可选,用于技能扫描)
skill-scanner-llm-api-key: ""
skill-scanner-llm-base-url: ""
skill-scanner-llm-model: ""
# S3 存储配置(可选,使用 S3/OSS 时配置)

View file

@ -42,11 +42,17 @@ services:
BOOTSTRAP_ADMIN_EMAIL: admin@skillhub.local
OAUTH2_GITHUB_CLIENT_ID: local-placeholder
OAUTH2_GITHUB_CLIENT_SECRET: local-placeholder
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: staging-download-secret-32-bytes
SKILLHUB_SECURITY_SCANNER_ENABLED: "true"
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
SKILLHUB_SECURITY_SCANNER_MODE: upload
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
skill-scanner:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 10s

View file

@ -74,7 +74,7 @@ ClawHub CLI 使用单一 slug 模型slug 校验规则为 `[a-z0-9]([a-z0-9-]*
- `SKILL.md` 格式兼容frontmatter + markdown body
- 技能包目录结构约定SKILL.md + references/ + scripts/ + assets/
- 四级目录优先级(`.agent/skills` → `~/.agent/skills` → `.claude/skills``~/.claude/skills`
- 四级目录优先级(`.agents/skills` → `~/.agents/skills` → `.claude/skills``~/.claude/skills`
- 目录名作为 lookup key安装后目录名 = skill slug
- AGENTS.md `<skill>` 描述块格式兼容
- 目标skillhub CLI 安装的技能可被 OpenSkills/Claude 兼容客户端发现和使用

View file

@ -377,7 +377,9 @@ API Token 仍保留但定位从“CLI 唯一认证方式”调整为“平台
- 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成
- 存储:只存 SHA-256 哈希,明文只展示一次
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401不能回退为匿名访问
- 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage`
- 拒绝原因API Token 缺少作用域或不能访问某个接口时403 响应返回本地化的安全原因和 `requestId`;其他授权失败仍返回通用信息,避免暴露内部异常
> **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER则该用户的任何 Token只要包含 `skill:publish` scope都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。
@ -484,23 +486,20 @@ Session 中存储以下字段:
"code": 0,
"msg": "获取成功",
"data": {
"userId": 42,
"userId": "usr_42",
"displayName": "zhangsan",
"email": "zhangsan@company.com",
"avatarUrl": "https://...",
"oauthProvider": "github",
"platformRoles": ["SKILL_ADMIN", "AUDITOR"],
"namespaces": [
{ "slug": "ai-team", "role": "ADMIN" },
{ "slug": "global", "role": "MEMBER" }
]
"oauthProvider": "local",
"canChangePassword": true,
"platformRoles": ["SKILL_ADMIN", "AUDITOR"]
},
"timestamp": "2026-03-12T06:00:00Z",
"requestId": "req-123"
}
```
前端权限判定基于 `platformRoles` + `namespaces[].role`后端通过 `role_permission` 表查询权限码。
前端平台级权限判定基于 `platformRoles`;是否展示修改密码入口和表单基于后端返回的 `canChangePassword`后端通过 `role_permission` 表查询权限码。
统一约束:
- `/api/v1/auth/me``/api/v1/auth/providers` 等 JSON 响应必须统一使用 `code/msg/data/timestamp/requestId` 外层结构。
@ -604,8 +603,8 @@ window.location.href = '/oauth2/authorization/github'
| `GET /api/v1/skills`(搜索) | 仅 `PUBLIC`,且仅搜索 `ACTIVE`、非 hidden、已索引 skill | `PUBLIC + NAMESPACE_ONLY成员空间+ PRIVATEowner/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
@ -654,6 +653,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 解析) |

View file

@ -112,7 +112,7 @@
| skill owner | 可 | 可 | 不可 | 不可 | 可 | 不可 | 不可 | 不可 |
| namespace ADMIN / OWNER | 可 | 可为本空间 skill 提交审核 | 可 | 不可 | 可 | 不可 | 不可 | 不可 |
| SKILL_ADMIN | 可提交并可代提审;但普通发布仍非直发 | 可 | 可 | 可 | 可 | 可,但不能审自己的 promotion | 不可 | 可 |
| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可review 场景下还能审自己的提交 | 可 | 可 |
| SUPER_ADMIN | 可跨 namespace 发布且直接 `PUBLISHED`,跳过 membership 检查和 review task | 可 | 可 | 可 | 可 | 可;promotion 和 review 场景下还能审自己的提交 | 可 | 可 |
### 对象存储写入策略

View file

@ -319,7 +319,7 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN
|------|------|------|
| GET | `/api/v1/admin/users` | 用户列表 |
| GET | `/api/v1/admin/users/{id}` | 用户详情 |
| PUT | `/api/v1/admin/users/{id}/roles` | 修改用户角色USER_ADMIN 不可分配 SUPER_ADMIN |
| PUT | `/api/v1/admin/users/{id}/role` | 修改用户角色USER_ADMIN 不可分配 SUPER_ADMIN,也不可修改已有 SUPER_ADMIN 的角色状态 |
| POST | `/api/v1/admin/users/{id}/approve` | 审批待准入用户 |
| POST | `/api/v1/admin/users/{id}/disable` | 封禁用户 |
| POST | `/api/v1/admin/users/{id}/enable` | 解封用户 |

View file

@ -8,7 +8,7 @@ skillhub 的目标是客户端可互操作skillhub CLI 安装的技能可以
- SKILL.md 格式frontmatter + markdown body
- 技能包目录结构约定SKILL.md + references/ + scripts/ + assets/
- 四级目录优先级:skillhub CLI 遵循 `.agent/skills` → `~/.agent/skills` → `.claude/skills``~/.claude/skills` 的发现顺序,与 OpenSkills/Claude 一致
- 四级目录优先级:`.agents/skills` → `~/.agents/skills` → `.claude/skills``~/.claude/skills`(与 OpenSkills/Claude 一致)。详见 §8.4。
- 目录名作为 lookup key安装后的目录名等于 `skill.slug`(即 SKILL.md 的 `name` 字段),客户端通过目录名发现技能
- AGENTS.md `<skill>` 描述块格式skillhub CLI 生成的 AGENTS.md 索引区块与 OpenSkills 格式兼容
@ -66,7 +66,7 @@ my-skill/
```
校验规则:
- 根目录必须包含 `SKILL.md`
- 根目录必须包含规范入口文件 `SKILL.md`;上传时服务端兼容 `skill.md``Skill.md` 等大小写变体,并在内部归一化为 `SKILL.md`
- 文件类型白名单:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg`
- 单文件大小限制1MB可配置
- 总包大小限制10MB可配置
@ -78,8 +78,8 @@ skillhub CLI 遵循以下目录优先级,与 OpenSkills/Claude 保持互操作
| 优先级 | 路径 | 说明 |
|--------|------|------|
| 1 | `./.agent/skills/` | 项目级universal 模式 |
| 2 | `~/.agent/skills/` | 全局级universal 模式 |
| 1 | `./.agents/skills/` | 项目级universal 模式 |
| 2 | `~/.agents/skills/` | 全局级universal 模式 |
| 3 | `./.claude/skills/` | 项目级Claude 默认 |
| 4 | `~/.claude/skills/` | 全局级Claude 默认 |

View file

@ -57,6 +57,10 @@
- 数据库列统一为 `TIMESTAMPTZ`
- 读写都按 UTC 绝对时间处理
进度登记:
- `audit_log.created_at` 已通过 V42 迁移到 `TIMESTAMPTZ`,详见 `docs/16-backend-time-inventory.md` §3.1
### 3.2 业务输入时间
适用场景:

View file

@ -131,6 +131,8 @@
- `review_task.submitted_at / reviewed_at`
- `promotion_request.submitted_at / reviewed_at`
- `idempotency_record.created_at / expires_at`
- `V42__audit_log_created_at_timestamptz.sql`
- `audit_log.created_at`
### 3.2 当前状态

View file

@ -0,0 +1,269 @@
# 云存储链接内置 Skills 配置指南
本文说明如何通过仓库内 manifest 配置 SkillHub 内置 Skills以及应用启动时这些 Skills 如何从云存储同步到 `@global` 空间。
适用场景:
- 希望 SkillHub 新部署实例默认带有一批官方内置 Skills。
- 不希望把完整 Skill 包目录长期放在代码仓库和镜像中。
- 内置 Skill 包已经上传到官方可控的云存储域名。
## 1. 方案概览
内置 Skills 不再以本地目录包的形式直接随仓库维护。当前方案只在仓库中维护一个 manifest 文件,应用启动时根据 manifest 中的云存储 URL 下载 zip 包,并通过 SkillHub 现有发布链路发布到 `@global`
流程:
```text
维护 manifest -> 构建/部署 SkillHub 镜像 -> 应用 ready -> 后台读取 manifest -> 下载云存储 zip 包 -> 校验包内容 -> 发布到 @global -> 对所有用户公开可见
```
核心文件:
```text
server/skillhub-app/src/main/resources/builtin-skills/manifest.json
```
首版 manifest 只需要维护三个字段:
- `slug`Skill 在 `@global` 下的 slug。
- `version`:期望同步的 Skill 版本。
- `url`Skill zip 包的云存储 HTTPS 链接。
## 2. Manifest 配置
manifest 文件格式如下:
```json
{
"skills": [
{
"slug": "skillhub-hello",
"version": "1.0.0",
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.0.0.zip"
}
]
}
```
可以配置多个 Skills也可以为同一个 `slug` 配置多个版本:
```json
{
"skills": [
{
"slug": "skillhub-hello",
"version": "1.0.0",
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.0.0.zip"
},
{
"slug": "skillhub-hello",
"version": "1.1.0",
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-hello-1.1.0.zip"
},
{
"slug": "skillhub-guide",
"version": "1.0.0",
"url": "https://bjcdn.openstorage.cn/<path-to-builtin-skill-zip>/skillhub-guide-1.0.0.zip"
}
]
}
```
配置要求:
- `skills` 必须是数组。
- 每一项必须同时填写 `slug``version``url`
- `slug` 必须符合 SkillHub slug 规则。
- 同一个 `slug + version` 重复出现时,只处理第一条,后续重复项会被跳过。
- manifest 最多处理前 100 条 entries。
- 同一个 `slug` 的多个版本建议按从旧到新的顺序排列;运行时按 manifest 文件顺序处理,不做自动版本排序。
## 3. Skill 包要求
manifest 中的 `url` 必须指向 zip 包。zip 包需要满足 SkillHub Skill 包协议:
- zip 可以在根目录直接包含 `SKILL.md`,也可以包含一个单独的顶层 Skill 目录,并在该目录下包含 `SKILL.md`
- `SKILL.md` frontmatter 中必须包含合法的 `name``description``version` 等元数据。
- `SKILL.md` 中的 `name` 经过 slug 归一化后,必须等于 manifest 中的 `slug`
- `SKILL.md` 中的 `version` 必须等于 manifest 中的 `version`
- 包内容仍会经过 SkillHub 现有发布校验,包括文件数量、文件大小、扩展名、文件类型等规则。
示例:
```text
skillhub-hello-1.0.0.zip
├── SKILL.md
├── README.md
└── scripts/
└── check.js
```
同样支持标准单目录 Skill 包:
```text
skillhub-hello-1.0.0.zip
└── skillhub-hello/
├── SKILL.md
└── README.md
```
如果 zip 中存在多个顶层目录,或在多个目录中同时出现 `SKILL.md`,同步器会跳过该项并记录错误,避免误选入口。
## 4. URL 安全限制
内置 Skill 同步由后端在启动时主动下载远程文件,因此 URL 有严格限制。
首版只允许:
- `https://` 协议。
- host 为 `bjcdn.openstorage.cn`
- host 为 `bjcdn.openstorage.cn` 的子域名,例如 `assets.bjcdn.openstorage.cn`
- 默认 HTTPS 端口,或显式 `:443`
以下 URL 会被跳过:
- `http://...`
- 非 `bjcdn.openstorage.cn` 及其子域名。
- 带 userinfo 的 URL例如 `https://user:pass@bjcdn.openstorage.cn/file.zip`
- 非 443 端口,例如 `https://bjcdn.openstorage.cn:8443/file.zip`
- `localhost`、IP 地址、IPv6 literal 等 host。
- 需要 HTTP redirect 才能拿到文件的链接。
如果某一项 URL 不符合规则SkillHub 会记录日志并跳过该项,不会阻塞应用启动。
## 5. 启动同步流程
应用 ready 后同步器会在后台执行一次,不阻塞应用 ready。
详细流程:
1. 检查 `skillhub.builtin-skills.enabled` 是否开启。
2. 读取 `classpath:builtin-skills/manifest.json`
3. 查询 `@global` 命名空间是否存在;如果不存在,跳过同步。
4. 确保系统发布者 `builtin-skill-publisher` 存在,并且该账号带有系统账号标记。
5. 如果该用户 ID 已被非系统账号占用,直接跳过本次内置 Skill 同步,不授予 `@global` 权限。
6. 如果系统发布者还不是 `@global` 成员,则创建 `OWNER` 成员记录;已有成员记录不会自动改角色。
7. 按 manifest 顺序处理每一个 item。
8. 下载前先检查 `@global/{slug}` 和目标版本是否已经存在;如果已经确定应跳过,则不发起远程下载。
9. 只有需要发布新 Skill 或新版本时,才下载对应 zip 包。
10. 解包并校验 Skill 入口 `SKILL.md`
11. 校验 manifest 中的 `slug``version` 与包内元数据一致。
12. 发布前再次检查是否已存在同名 Skill 或同版本,处理并发启动场景。
13. 需要发布时调用现有 `SkillPublishService.publishFromEntries(...)`
14. 发布完成后,该 Skill 位于 `@global/{slug}`,可见性为 `PUBLIC`
同步逻辑不会直接写数据库 seed 数据。它复用现有发布服务因此会保留现有的包校验、对象存储写入、版本记录、latest version 更新、事件和搜索索引同步。
## 6. 幂等与冲突处理
内置 Skill 同步支持重复启动和多次部署。
幂等键:
```text
@global/{slug} + version
```
行为说明:
| 场景 | 行为 |
|---|---|
| `@global/{slug}` 不存在 | 发布 manifest 中的 Skill |
| `@global/{slug}` 已存在owner 是 `builtin-skill-publisher`,但目标版本不存在 | 发布新版本 |
| 同版本已存在且已发布 | 下载前跳过 |
| 同版本已存在但不是 `PUBLISHED` | 下载前跳过并记录日志 |
| `@global/{slug}` 已被其他 owner 创建或发布 | 下载前跳过并记录 warning |
这意味着内置同步不会接管用户或管理员已经创建的同 slug Skill即使该 Skill 仍处于待审、未发布或已拒绝状态,也会跳过对应 manifest item。
同版本已存在时,同步器不会重新下载远端 zip也不会验证远端对象内容是否发生漂移。
如果多实例同时启动,可能出现多个实例同时尝试发布同一个内置版本。同步器会在发布失败后重新查询目标版本;如果发现同版本已经以相同内容发布成功,则视为并发场景下的正常跳过。
## 7. 开关配置
内置 Skill 同步默认开启。
Spring 配置项:
```yaml
skillhub:
builtin-skills:
enabled: true
```
环境变量:
```dotenv
SKILLHUB_BUILTIN_SKILLS_ENABLED=true
```
如需禁用启动同步:
```dotenv
SKILLHUB_BUILTIN_SKILLS_ENABLED=false
```
禁用后,应用 ready 后不会读取 manifest也不会下载或发布任何内置 Skill。
## 8. 维护流程
新增一个内置 Skill 的推荐步骤:
1. 准备 Skill 包,并确认 zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`
2. 检查 `SKILL.md` 中的 `name``version`
3. 上传 zip 到 `bjcdn.openstorage.cn` 或其子域名下的官方云存储路径。
4. 在 `server/skillhub-app/src/main/resources/builtin-skills/manifest.json` 中新增一项。
5. 确保 manifest 中的 `slug` 等于 `SKILL.md name` 归一化后的 slug。
6. 确保 manifest 中的 `version` 等于 `SKILL.md version`
7. 本地或测试环境启动 SkillHub查看后端日志确认同步结果。
8. 在 Web UI 或 API 中确认 `@global/{slug}` 已公开可见。
更新一个已有内置 Skill 的推荐步骤:
1. 不要覆盖已经发布过的旧版本 zip 内容。
2. 在 `SKILL.md` 中提升 `version`
3. 重新打包并上传新的 zip 文件。
4. 在 manifest 中新增一条同 `slug`、新 `version` 的记录。
5. 保留旧版本记录,除非产品明确不再需要该旧版本在新实例中预置。
不推荐:
- 修改旧版本 zip 内容但保持同一个 `version`
- 把 URL 指向会发生内容变化的临时对象。
- 使用需要登录、签名跳转或重定向的下载链接。
## 9. 日志与排查
启动时可以通过后端日志观察同步结果。
常见日志含义:
| 日志含义 | 处理建议 |
|---|---|
| manifest not found | 确认 `builtin-skills/manifest.json` 是否被打进 classpath |
| publisher account id already exists but is not a system account | `builtin-skill-publisher` 已被普通账号占用;需要人工处理账号冲突后再启用内置同步 |
| slug, version, and url are required | 检查 manifest item 是否缺字段或字段不是字符串 |
| slug is invalid | 检查 slug 是否符合 SkillHub slug 规则 |
| URL is not allowed | 检查 URL 是否为 HTTPS、host 是否为 `bjcdn.openstorage.cn` 或其子域名 |
| package download failed | 检查云存储对象是否存在、是否返回 HTTP 200、是否超时 |
| package must contain SKILL.md | 检查 zip 是否存在唯一可识别的 `SKILL.md` 入口 |
| manifest version does not match package version | 检查 manifest `version``SKILL.md version` 是否一致 |
| slug already belongs to another user | 说明 `@global/{slug}` 已被非内置发布者创建或发布,内置同步不会覆盖 |
| published fingerprint differs | 并发发布异常后发现同一内置版本已存在但内容不同,需要人工确认是否发生了版本冲突 |
如果某个 manifest item 失败,后续 item 仍会继续处理,应用可用状态不受影响。
## 10. 验收检查
配置或新增内置 Skill 后,建议至少完成以下检查:
- manifest JSON 格式合法。
- 每个 item 都包含 `slug``version``url`
- URL 使用 `https://bjcdn.openstorage.cn/...` 或可信子域名。
- zip 根目录直接包含 `SKILL.md`,或只有一个顶层 Skill 目录且该目录包含 `SKILL.md`
- `SKILL.md name` 归一化后的 slug 与 manifest `slug` 一致。
- `SKILL.md version` 与 manifest `version` 一致。
- 启动日志没有该 item 的 warning 或 error。
- Web UI 中可以看到 `@global/{slug}`
- Skill 可被匿名或登录用户按公开 Skill 规则发现。

View file

@ -0,0 +1,278 @@
# Hermes Agent Integration Guide
This guide explains how to install skills from SkillHub into [NousResearch Hermes Agent](https://github.com/NousResearch/hermes-agent), then discover, load, update, and remove those skills in Hermes.
“Hermes” in this guide means `NousResearch/hermes-agent`; it does not cover other projects with the same name.
## Validated scope
| Component | Validated version | Notes |
|-----------|-------------------|-------|
| SkillHub Server | `v0.2.13` | Public or self-hosted registry |
| SkillHub CLI | `0.1.8` | npm package `@astron-team/skillhub` |
| Hermes Agent | `0.18.2` | Upstream tag [`v2026.7.7.2`](https://github.com/NousResearch/hermes-agent/tree/v2026.7.7.2) |
Validation date: 2026-07-17.
Hermes 0.18.2 uses an [Agent Skills](https://agentskills.io/)-compatible `SKILL.md` format and recursively scans `$HERMES_HOME/skills/`. SkillHub CLI can extract a complete skill package into any explicit `--dir` target. The current integration therefore needs no format conversion, Hermes-specific CLI profile, or server adapter:
```text
SkillHub registry
-> skillhub install --dir <Hermes skills directory>
-> <Hermes skills directory>/<skill-slug>/SKILL.md
-> Hermes discovers and loads the skill on demand
```
> Hermes 0.18.2 has no native SkillHub registry source. This guide uses SkillHub CLI for search, download, and local installation, while Hermes handles discovery and execution.
## Prerequisites
1. Install and initialize Hermes Agent.
2. Install SkillHub CLI:
```bash
npm install -g @astron-team/skillhub
skillhub version
hermes version
```
3. Ensure the skill package has a valid root `SKILL.md` with at least `name` and `description` frontmatter.
The examples below use Bash/zsh. On Windows, use the same directory structure, replace the default Hermes home with `$HOME\.hermes`, and set variables using PowerShell syntax.
## Quick start
### 1. Configure the SkillHub registry
Set the public or self-hosted SkillHub URL:
```bash
export SKILLHUB_REGISTRY=https://skillhub.your-company.com
```
You can skip login for public skills that allow anonymous downloads. For team namespaces, restricted skills, or private deployments, save an API token first:
```bash
skillhub login \
--registry "$SKILLHUB_REGISTRY" \
--token YOUR_API_TOKEN
skillhub whoami --registry "$SKILLHUB_REGISTRY"
```
Use placeholder tokens in examples. Never write a real token into `SKILL.md`, scripts, or version control.
### 2. Search for a skill
```bash
skillhub search "pdf" --registry "$SKILLHUB_REGISTRY"
```
Record the namespace, slug, and required version. The following examples use `my-team/my-skill`:
```bash
export SKILLHUB_NAMESPACE=my-team
export SKILLHUB_SKILL=my-skill
```
### 3. Install into the primary Hermes skills directory
Set the home of the active Hermes profile. The default profile normally uses `~/.hermes`; if you use a custom `HERMES_HOME` or a named profile, point it at the actual profile directory:
```bash
export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
export HERMES_SKILLHUB_DIR="$HERMES_HOME/skills/skillhub/$SKILLHUB_NAMESPACE"
```
Install the skill:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
```
SkillHub CLI preserves `SKILL.md`, `references/`, `scripts/`, `templates/`, `assets/`, and other package files. It also writes `.skillhub/metadata.json` to record the installation source. The resulting layout looks like this:
```text
$HERMES_HOME/skills/
└── skillhub/
└── my-team/
└── my-skill/
├── SKILL.md
├── references/ # optional
├── scripts/ # optional
└── .skillhub/
└── metadata.json
```
Separating target directories by namespace reduces filesystem collisions between skills with the same slug. Hermes recursively scans these levels.
### 4. Verify and load the skill in Hermes
First, confirm that Hermes discovers the skill:
```bash
hermes skills list --source local --enabled-only
```
Then start Hermes and invoke the slash command normalized from the skill `name`:
```bash
hermes
```
```text
/my-skill
```
You can also ask Hermes in natural language to use the skill. Hermes lists the raw `SKILL.md` frontmatter `name`, but its slash command lowercases that name, replaces spaces and underscores with hyphens, removes other characters outside `a-z0-9-`, and collapses repeated hyphens. For example, `PDF_Tools` becomes `/pdf-tools`. The command may therefore differ from the SkillHub slug.
If a running session does not immediately show a new skill, run `/reload-skills` or restart the session.
## Update a skill
SkillHub CLI 0.1.8 overwrites a local skill by repeating the install command with `--force`. Omitting `--version` resolves the latest published version; you can also pin one explicitly:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
# Pinned version example
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--version 1.2.0 \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
```
Review the new version before overwriting because `--force` replaces the existing skill directory. Afterward, run:
```bash
skillhub list \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
hermes skills list --source local --enabled-only
```
> `skillhub update` updates SkillHub CLI itself; it does not update installed skills. Refresh an installed skill with `skillhub install ... --force`.
## Remove a skill
First, list every installation from the same registry and confirm that there are no other same-slug skills you need to keep:
```bash
skillhub list \
--registry "$SKILLHUB_REGISTRY"
```
Then remove the local installation:
```bash
skillhub remove "$SKILLHUB_SKILL" \
--registry "$SKILLHUB_REGISTRY"
```
SkillHub CLI deletes both the skill directory and the local inventory record. Local `remove` in this version matches only registry and slug; it does not filter by namespace or directory. Every same-slug target from that registry, across all namespaces and installation directories, is removed. If the unfiltered `skillhub list` shows a match you need to keep, do not run the command; namespace- or directory-scoped removal requires a future CLI capability.
After removal, run `/reload-skills`, restart the Hermes session, or confirm that the skill is gone with:
```bash
hermes skills list --source local --enabled-only
```
## Optional: use a shared external skills directory
When several agents share `~/.agents/skills`, install SkillHub skills into that shared tree instead of the primary Hermes directory:
```bash
export SHARED_SKILLHUB_DIR="$HOME/.agents/skills/skillhub/$SKILLHUB_NAMESPACE"
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$SHARED_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
```
Merge the shared root into `$HERMES_HOME/config.yaml` without replacing existing `skills` settings:
```yaml
skills:
external_dirs:
- ~/.agents/skills
```
Hermes lists and loads external skills alongside local skills. Do not rely on local shadowing: Hermes 0.18.2 refuses ambiguous `skill_view` matches across the local skills directory and `external_dirs`. Rename or remove a colliding copy instead.
> `external_dirs` is not a read-only boundary. If the Hermes process can write to an external directory, Hermes skill-management tools can modify its files. Use filesystem permissions or an isolated Hermes profile when shared skills must remain read-only.
## Compatibility and security boundaries
- **Format compatibility is not complete runtime compatibility.** Hermes can read `SKILL.md` and supporting files, but agent-specific tools, MCP servers, commands, environment variables, and platform capabilities referenced by a skill still need individual verification.
- **Hermes treats this path as local.** A skill copied by SkillHub CLI does not run through the Hermes Skills Hub community-install scanner. Review the SkillHub security report and the skill contents before installation, and use Hermes terminal isolation where appropriate.
- **Keep multi-file packages intact.** Do not replace SkillHub CLI with the Hermes 0.18.2 direct-URL source for multi-file skills. That release guarantees a single `SKILL.md` for URL installs, whereas SkillHub CLI extracts the complete package.
- **Avoid name collisions.** Namespace-separated filesystem paths do not resolve slash-command collisions. Keep normalized command names unique within one Hermes profile. For example, `PDF Tools` and `pdf_tools` both become `/pdf-tools`.
- **Protect credentials.** A registry token is only for SkillHub access and does not belong in a skill package. Skills that need runtime secrets should use Hermes environment-variable and security settings.
## Troubleshooting
### The new skill is missing from the Hermes list
Check these items in order:
1. The current session uses the same `HERMES_HOME` used during installation.
2. The final path contains `<skill-directory>/SKILL.md`.
3. `SKILL.md` contains valid `name` and `description` fields.
4. `platforms` or other frontmatter does not exclude the current operating system.
5. The skill appears after `/reload-skills` or in a new session.
```bash
skillhub list --dir "$HERMES_SKILLHUB_DIR" --registry "$SKILLHUB_REGISTRY"
hermes skills list --source local
```
### Installation reports `skill already installed`
Existing directories are not overwritten by default. Review the target version, then add `--force`:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
```
### The CLI reports `registry unreachable` or a download failure
- Confirm that `SKILLHUB_REGISTRY` is the SkillHub root URL.
- Run `skillhub search` against the same registry to distinguish registry reachability from a download failure.
- Check proxy, DNS, certificate, and self-hosted service status.
- Retry a transient network error only after confirming the service is healthy; do not bypass certificate failures by disabling TLS verification.
### The skill is listed but fails during execution
Check tool names, shell commands, script runtimes, packages, MCP servers, environment variables, and operating-system restrictions referenced by that skill. Those are skill-specific runtime compatibility concerns, not failures of `SKILL.md` discovery.
### Can `hermes skills install` consume a SkillHub coordinate directly?
Hermes 0.18.2 has no SkillHub registry source and cannot resolve a SkillHub namespace/slug directly. Use `skillhub install --dir ...` as shown in this guide. Native search, installation, updates, and security scanning inside Hermes would require a separately designed Hermes source adapter with its own protocol and acceptance scope.
## Regression checks after upgrades
After upgrading SkillHub CLI or Hermes, verify at least the following:
1. `skillhub install --dir` still creates `<slug>/SKILL.md` and preserves support files.
2. `hermes skills list --source local --enabled-only` discovers the skill.
3. `/skill-name` loads `SKILL.md` and exposes support-file paths; then read one referenced file with `skill_view(name, file_path)` or exercise the script/asset the skill actually uses.
4. `skillhub install --force` overwrites the skill while keeping a healthy inventory.
5. Hermes no longer discovers the skill after `skillhub remove`.
Upstream reference: [Hermes Skills System at v0.18.2](https://github.com/NousResearch/hermes-agent/blob/v2026.7.7.2/website/docs/user-guide/features/skills.md).

278
docs/hermes-integration.md Normal file
View file

@ -0,0 +1,278 @@
# Hermes Agent 集成指南
本文档说明如何把 SkillHub 中的技能安装到 [NousResearch Hermes Agent](https://github.com/NousResearch/hermes-agent),并在 Hermes 中发现、加载、更新和移除这些技能。
本文中的 “Hermes” 特指 `NousResearch/hermes-agent`,不适用于其他同名项目。
## 已验证范围
| 组件 | 已验证版本 | 说明 |
|------|------------|------|
| SkillHub Server | `v0.2.13` | 公开或自托管 registry |
| SkillHub CLI | `0.1.8` | npm 包 `@astron-team/skillhub` |
| Hermes Agent | `0.18.2` | 上游 tag [`v2026.7.7.2`](https://github.com/NousResearch/hermes-agent/tree/v2026.7.7.2) |
验证日期2026-07-17。
Hermes 0.18.2 使用兼容 [Agent Skills](https://agentskills.io/) 的 `SKILL.md` 格式,并递归扫描 `$HERMES_HOME/skills/`。SkillHub CLI 可以通过 `--dir` 把完整技能包解压到指定目录。因此当前兼容链路不需要格式转换、Hermes 专用 CLI profile 或服务端适配:
```text
SkillHub registry
-> skillhub install --dir <Hermes 技能目录>
-> <Hermes 技能目录>/<skill-slug>/SKILL.md
-> Hermes 发现并按需加载
```
> Hermes 0.18.2 没有原生 SkillHub registry source。本指南使用 SkillHub CLI 负责搜索、下载和本地安装Hermes 负责发现和执行技能。
## 前置条件
1. 已安装并初始化 Hermes Agent。
2. 已安装 SkillHub CLI
```bash
npm install -g @astron-team/skillhub
skillhub version
hermes version
```
3. 技能包根目录包含有效的 `SKILL.md`,其中至少有 `name``description` frontmatter。
以下示例使用 Bash/zsh。Windows 用户可使用同一目录结构,将默认 Hermes 主目录替换为 `$HOME\.hermes`,并按 PowerShell 语法设置变量。
## 快速开始
### 1. 配置 SkillHub registry
设置公开或自托管 SkillHub 地址:
```bash
export SKILLHUB_REGISTRY=https://skillhub.your-company.com
```
公开且允许匿名下载的技能可以跳过登录。访问团队命名空间、受限技能或私有部署时,先保存 API Token
```bash
skillhub login \
--registry "$SKILLHUB_REGISTRY" \
--token YOUR_API_TOKEN
skillhub whoami --registry "$SKILLHUB_REGISTRY"
```
请使用占位 Token 演示,不要把真实 Token 写入 `SKILL.md`、脚本或版本库。
### 2. 搜索技能
```bash
skillhub search "pdf" --registry "$SKILLHUB_REGISTRY"
```
记录结果中的 namespace、slug 和所需版本。下面以 `my-team/my-skill` 为例:
```bash
export SKILLHUB_NAMESPACE=my-team
export SKILLHUB_SKILL=my-skill
```
### 3. 安装到 Hermes 主技能目录
设置当前 Hermes profile 的主目录。默认 profile 通常是 `~/.hermes`;如果使用自定义 `HERMES_HOME` 或命名 profile请指向实际 profile 目录:
```bash
export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
export HERMES_SKILLHUB_DIR="$HERMES_HOME/skills/skillhub/$SKILLHUB_NAMESPACE"
```
安装技能:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
```
SkillHub CLI 会保留技能包中的 `SKILL.md``references/``scripts/``templates/``assets/` 等文件,并额外写入 `.skillhub/metadata.json` 记录安装来源。目录结构类似:
```text
$HERMES_HOME/skills/
└── skillhub/
└── my-team/
└── my-skill/
├── SKILL.md
├── references/ # 可选
├── scripts/ # 可选
└── .skillhub/
└── metadata.json
```
按 namespace 分目录可以减少不同命名空间中同 slug 技能的文件路径冲突。Hermes 会递归扫描这些层级。
### 4. 在 Hermes 中验证和加载
先确认 Hermes 发现了技能:
```bash
hermes skills list --source local --enabled-only
```
然后启动 Hermes在会话中使用由技能 `name` 规范化得到的斜杠命令:
```bash
hermes
```
```text
/my-skill
```
也可以在自然语言请求中明确要求 Hermes 使用该技能。Hermes 列表显示 `SKILL.md` frontmatter 中的原始 `name`,斜杠命令会把它转为小写、把空格和下划线替换为连字符、移除其他非 `a-z0-9-` 字符,并合并重复连字符。例如 `PDF_Tools` 对应 `/pdf-tools`。该命令不一定与 SkillHub slug 相同。
已经运行的会话未立即显示新技能时,执行 `/reload-skills` 或重新启动会话。
## 更新技能
SkillHub CLI 0.1.8 使用同一安装命令加 `--force` 覆盖本地技能。省略 `--version` 会解析最新已发布版本;也可以显式固定版本:
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
# 固定版本示例
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--version 1.2.0 \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
```
覆盖前请先审查新版本,因为 `--force` 会替换现有技能目录。更新后重新运行:
```bash
skillhub list \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
hermes skills list --source local --enabled-only
```
> `skillhub update` 更新的是 SkillHub CLI 自身,不会更新已安装技能。已安装技能使用 `skillhub install ... --force` 刷新。
## 移除技能
先列出同一 registry 中的全部安装,确认没有其他需要保留的同 slug 技能:
```bash
skillhub list \
--registry "$SKILLHUB_REGISTRY"
```
再移除本地安装:
```bash
skillhub remove "$SKILLHUB_SKILL" \
--registry "$SKILLHUB_REGISTRY"
```
SkillHub CLI 会同时删除技能目录和本地 inventory 记录。当前版本的本地 `remove` 仅按 registry 和 slug 匹配,不按 namespace 或目录过滤;同一 registry 下所有 namespace、所有安装目录中的相同 slug 都会被移除。如果未过滤的 `skillhub list` 中存在需要保留的匹配项,请不要执行该命令;按 namespace 或目录精确移除需要后续 CLI 能力支持。
移除后,使用 `/reload-skills`、重启 Hermes 会话,或运行以下命令确认技能已消失:
```bash
hermes skills list --source local --enabled-only
```
## 可选:使用共享的 external skill 目录
如果多个 Agent 共用 `~/.agents/skills`,可以把 SkillHub 技能安装到共享目录,而不是 Hermes 主目录:
```bash
export SHARED_SKILLHUB_DIR="$HOME/.agents/skills/skillhub/$SKILLHUB_NAMESPACE"
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$SHARED_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY"
```
然后把共享根目录合并到 `$HERMES_HOME/config.yaml`,不要覆盖已有的 `skills` 配置:
```yaml
skills:
external_dirs:
- ~/.agents/skills
```
Hermes 会把 external skill 与本地技能一起列出和加载。不要依赖本地技能覆盖 external skillHermes 0.18.2 会拒绝加载本地技能目录与 `external_dirs` 之间存在歧义的 `skill_view` 匹配;请改名或移除其中一个冲突副本。
> `external_dirs` 不是只读边界。只要 Hermes 进程拥有写权限Hermes 的技能管理工具就可能修改其中的文件。共享目录需要只读保护时,请使用文件系统权限或隔离的 Hermes profile。
## 兼容性与安全边界
- **格式兼容不等于运行时完全兼容。** Hermes 能读取 `SKILL.md` 和配套文件,但技能引用的 Agent 专用工具、MCP server、命令、环境变量或平台能力仍需逐项验证。
- **Hermes 将此路径识别为 local skill。** 通过 SkillHub CLI 复制到本地的技能不会经过 Hermes Skills Hub 的 community 安装扫描。安装前应查看 SkillHub 安全报告并审查技能内容,必要时使用 Hermes 的终端隔离能力。
- **保留多文件包。** 不要把多文件 SkillHub 技能改成 Hermes 0.18.2 的直接 URL 安装;该版本的 URL source 只保证单个 `SKILL.md`,而 SkillHub CLI 会解压完整包。
- **避免名称冲突。** 文件路径按 namespace 隔离仍不能解决斜杠命令冲突;同一 Hermes profile 内应保持规范化后的命令名唯一。例如 `PDF Tools``pdf_tools` 都会变成 `/pdf-tools`
- **保护凭证。** Token 只用于 SkillHub registry 访问,不应写进技能包。需要运行时 secret 的技能应遵循 Hermes 的环境变量和安全设置方式。
## 常见问题
### Hermes 列表中没有新技能
依次检查:
1. 当前会话的 `HERMES_HOME` 是否与安装时一致。
2. 最终路径下是否存在 `<skill-directory>/SKILL.md`
3. `SKILL.md` 是否包含有效的 `name``description`
4. `platforms` 等 frontmatter 是否排除了当前操作系统。
5. 执行 `/reload-skills` 或启动新会话后是否出现。
```bash
skillhub list --dir "$HERMES_SKILLHUB_DIR" --registry "$SKILLHUB_REGISTRY"
hermes skills list --source local
```
### 安装提示 `skill already installed`
已有目录默认不会被覆盖。先审查目标版本,再增加 `--force`
```bash
skillhub install "$SKILLHUB_SKILL" \
--namespace "$SKILLHUB_NAMESPACE" \
--dir "$HERMES_SKILLHUB_DIR" \
--registry "$SKILLHUB_REGISTRY" \
--force
```
### 提示 `registry unreachable` 或下载失败
- 核对 `SKILLHUB_REGISTRY` 是否是 SkillHub 根地址。
- 先运行同一 registry 的 `skillhub search` 判断 registry 是否可达。
- 检查代理、DNS、证书和自托管服务状态。
- 短暂网络错误可以在确认服务正常后重试;不要通过关闭 TLS 校验绕过证书问题。
### 技能已列出但执行失败
检查技能引用的工具名称、shell 命令、脚本解释器、依赖包、MCP server、环境变量和操作系统限制。此类问题属于具体技能的运行时兼容性不代表 `SKILL.md` 发现链路失败。
### 能否直接运行 `hermes skills install` 安装 SkillHub 坐标?
Hermes 0.18.2 没有 SkillHub registry source不能直接解析 SkillHub 的 namespace/slug。请使用本指南中的 `skillhub install --dir ...`。如果未来需要 Hermes 内原生搜索、安装、更新和安全扫描,应单独设计 Hermes source adapter并重新定义协议和验收范围。
## 升级后的回归检查
升级 SkillHub CLI 或 Hermes 后,至少重新验证:
1. `skillhub install --dir` 仍生成 `<slug>/SKILL.md` 并保留配套文件。
2. `hermes skills list --source local --enabled-only` 能发现技能。
3. `/skill-name` 能加载 `SKILL.md` 并暴露配套文件路径;再通过 `skill_view(name, file_path)` 读取一个实际引用文件,或执行技能使用的脚本/资产验证其运行时路径。
4. `skillhub install --force` 能覆盖更新且 inventory 正常。
5. `skillhub remove` 后 Hermes 不再发现该技能。
上游参考:[Hermes Skills Systemv0.18.2](https://github.com/NousResearch/hermes-agent/blob/v2026.7.7.2/website/docs/user-guide/features/skills.md)。

View file

@ -61,6 +61,7 @@ Important environment variables:
Scanner-side optional environment variables:
- `SKILL_SCANNER_LLM_API_KEY`
- `SKILL_SCANNER_LLM_BASE_URL`
- `SKILL_SCANNER_LLM_MODEL`
If the LLM variables are absent, the scanner should still run with non-LLM analyzers.

View file

@ -7,6 +7,11 @@ export default defineConfig({
ignoreDeadLinks: [/^http:\/\/localhost/],
head: [],
vite: {
build: {
target: 'es2020',
},
},
// Define root locale for redirect
locales: {
@ -124,4 +129,4 @@ export default defineConfig({
},
},
},
})
})

View file

@ -122,7 +122,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version v0.2.0
```
> **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway.
> **Note**: It is recommended to back up the database and object storage before upgrading. Database migrations are handled automatically by Flyway. Upgrading does not wipe the database, so already-registered skill packages will not be lost.
## Q: Why can't administrators (admin) and regular users create namespaces?
@ -136,11 +136,199 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
A: When using the OpenClaw CLI, you can specify the namespace using the `<namespace>--<skill-name>` format for operations like search or installation. If you encounter issues finding it on the web interface, you can also manage it by exporting the skill package and importing it into your target namespace.
## Q: What is the recommended deployment method? Can I pull the images and deploy manually?
A: We recommend the official one-line deployment script. Pulling images and deploying manually is not recommended (manual deployment is prone to initialization issues such as being redirected back to the login page after logging in):
```bash
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
The script performs a series of initialization steps. The generated runtime configuration is located at `/tmp/skillhub-runtime/` by default (containing `.env.release` and the docker-compose file).
## Q: After deployment, I enter the correct username and password but get redirected back to the login page?
A: This is most commonly seen with **manual deployment** (caused by API errors or incomplete initialization). Suggestions:
1. Switch to the one-line script above for deployment.
2. If necessary, clear and recreate the PostgreSQL data volume, then log in again.
3. If a reverse proxy is in front, verify that it forwards requests correctly.
## Q: How do I change the admin password? Why don't my config changes take effect?
A: Environment variables are injected when a container is created, so you must recreate the containers after changing them; `restart` alone does not re-inject environment variables.
1. Edit `/tmp/skillhub-runtime/.env.release` in the runtime directory (refer to [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example)).
2. Recreate the relevant containers:
```bash
docker compose \
--env-file /tmp/skillhub-runtime/.env.release \
-f /tmp/skillhub-runtime/compose.release.yml \
up -d --force-recreate
```
3. If the password was already persisted to the database and the change still doesn't take effect, you may need to clear the corresponding data and re-initialize.
## Q: Is an email verification code required to change / reset a password?
A: Yes. By default, passwords are changed or reset via an email verification code, so SMTP must be configured first. See [docs/19-smtp-password-reset-email-setup.md](https://github.com/iflytek/skillhub/blob/main/docs/19-smtp-password-reset-email-setup.md). Administrators can also reset it via `.env.release`.
## Q: Can a skill have a Chinese name?
A: Skill names are generally in English; Chinese names are not currently supported (using a Chinese skill name in OpenClaw will cause an error).
## Q: Can unreviewed skills be downloaded?
A: As long as you have permission to view it, it can generally be downloaded.
## Q: How do I hide or remove the GitHub / GitLab SSO login options on the login page?
A: Edit `application.yml` and comment out or delete the `github` and `gitlab` blocks under `spring.security.oauth2.client.registration`, along with their corresponding `provider` sections. Spring Boot then won't create these registrations at startup, and the login page won't show those entries.
## Q: Is SkillHub's security scanning (Skill Scanner) developed in-house by iFLYTEK? What license does it use?
A: SkillHub has built-in security scanning. The scanner integration, task orchestration, audit persistence, and deployment integration are implemented by the iFLYTEK team; the underlying scanning service uses Cisco's [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner) (Apache License 2.0, copyright Cisco).
## Q: Which version of cisco-ai-skill-scanner does SkillHub use?
A: `scanner/Dockerfile` runs `pip install cisco-ai-skill-scanner` directly without pinning a version, so the latest version on PyPI is pulled when the image is built. To pin a version, do so yourself when customizing the build.
## Q: How do I troubleshoot a `registry returned 400` error from `skillhub publish` (CLI)?
A: A 400 usually means backend validation failed. Common causes:
- `SKILL.md` is not in the package root directory;
- `SKILL.md` frontmatter is missing `name` / `description` or is malformed;
- name or version conflict (e.g. `error.skill.publish.nameConflict`, meaning a skill with the same name is already published in that namespace) — change `name` in `SKILL.md`, use another namespace, or have an admin handle the existing skill;
- the namespace does not exist, or you are not a member of it;
- the package contains suspected tokens/secrets that the CLI cannot confirm skipping;
- file type / size / path is not allowed.
You can inspect the server logs to locate the cause:
```bash
docker logs --tail=300 <skillhub-server container> 2>&1 | grep -Ei 'publish|SKILL.md|namespace|400|BadRequest'
```
## Q: What directory structure does a skill package require?
A: The package root directory must contain a `SKILL.md` file, whose frontmatter must include fields such as `name` and `description`.
## Q: Publishing fails with "package validation failed / malformed input" — what do I do?
A: This error occurs while unzipping and reading file names, usually because the archive is not UTF-8 encoded (e.g. created with the built-in Windows compression tool) or contains Chinese/non-ASCII paths. Repackage using UTF-8 encoding and avoid Chinese / special-character paths.
## Q: How many files can a skill package contain? What if I hit the file-count limit?
A: The default limit is **100 files** (this is separate from the 100MB size limit). To raise it, change the `skillhub.publish.max-file-count` setting, or override it via an environment variable at deploy time:
```bash
SKILLHUB_PUBLISH_MAX_FILE_COUNT=500
```
Recreate the containers for the change to take effect; `restart` alone does not re-inject environment variables. Note that `compose.release.yml` must also reference this variable; older versions (e.g. v0.2.6) may hard-code the value, so upgrading to the latest version is recommended.
## Q: Is there a server version requirement for using the CLI (publish / download, etc.)?
A: A SkillHub server image of **v0.2.7 or later** is required for CLI features.
## Q: Does SkillHub support MySQL?
A: Currently only PostgreSQL is supported; MySQL is not supported.
## Q: Can SkillHub be used to distribute Plugins?
A: Not supported for now.
## Q: How do I check the SkillHub version? How do I customize it (e.g. change the logo)?
A:
- Check the server image version:
```bash
docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .Config.Labels "org.opencontainers.image.version"}}'
```
- Check the CLI version: `skillhub version`.
- For customization (e.g. changing the logo), it is recommended to fork the latest code, modify it, and build your own Docker image.
## Q: The page loads, but the login / register APIs return 502?
A: The page is served by the `web` container, while login, register and other APIs are proxied by `web` to `server` (default `SKILLHUB_API_UPSTREAM=http://server:8080`). When the page works but the API returns 502, check whether `server` started correctly first; a wrong upstream, DNS, or container-network problem can also produce a 502.
Troubleshooting order:
```bash
# 1. Check whether server is running
docker compose --env-file .env.release -f compose.release.yml ps
# 2. Look at the first error in the server startup log
docker compose --env-file .env.release -f compose.release.yml logs server | head -50
```
One common startup failure is:
```
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder
```
This means `server` still reads the placeholder from the template. Replace it in `.env.release` with your own random string (**at least 32 characters**) and recreate the containers:
```bash
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=<your own random string, at least 32 characters>
```
Running `make validate-release-config` before startup validates `.env.release` and surfaces placeholders and missing values early.
## Q: Why doesn't my configuration change take effect?
A: Two common causes:
1. **Edited the wrong file**: `.env.release.example` is only a template; Compose reads the file passed via `--env-file`, i.e. `.env.release`. Run `cp .env.release.example .env.release` first, then edit `.env.release`.
2. **Restarted instead of recreated**: environment variables are injected when the container is created, and `restart` does not re-inject them. Recreate the containers after a config change:
```bash
docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate
```
## Q: What external dependencies does SkillHub require at runtime?
A: PostgreSQL and Redis are required. Object storage supports both `local` and S3, controlled by `SKILLHUB_STORAGE_PROVIDER`. `.env.release.example` explicitly selects `local`, but if the variable is completely unset when using `compose.release.yml`, the Compose fallback is `s3`. Set it explicitly; S3 is recommended for production (configured via `SKILLHUB_STORAGE_S3_*`). Only PostgreSQL is supported as the database — MySQL is not.
The release Compose file already bundles PostgreSQL and Redis, bound to `127.0.0.1` by default.
## Q: How does an account created through OAuth (GitHub / GitLab, etc.) get admin rights?
A: The first OAuth login creates a regular user. An existing `SUPER_ADMIN` (for example the bootstrap admin created during initialization) has to promote it from the admin console.
A `USER_ADMIN` can manage user status and assign platform roles other than `SUPER_ADMIN`, but cannot grant `SUPER_ADMIN` to any account or change the role of an existing `SUPER_ADMIN`. Only a `SUPER_ADMIN` can perform those two operations.
## Q: How do I install multiple skills in bulk?
A: The CLI `install` command handles one skill at a time. Both examples below use `--dir` to install the skills under the same target root; each skill is placed in `$target_dir/<skill-slug>/`:
```bash
target_dir=/opt/skillhub-skills
# install one by one
for skill in skill-a skill-b skill-c; do
skillhub install "$skill" --dir "$target_dir"
done
# or read from a manifest file (one skill name per line)
xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir"
```
Since **SkillHub Server v0.2.12**, public skills support anonymous search and install. Note that an invalid bearer token now fails the command instead of falling back to anonymous access — update or remove the stale credential in that case.
## Q: What should I do if I encounter issues?
A: You can get help through the following channels:
- **GitHub Issues**: https://github.com/iflytek/skillhub/issues
- **Online Docs**: https://iflytek.github.io/skillhub/
- **Documentation**: Refer to the project README.md
- **Community Discussions**: https://github.com/iflytek/skillhub/discussions

View file

@ -83,6 +83,8 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com
`login` validates the token, stores it in `~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`.
When an API-token request is denied, the CLI shows the safe reason returned by the server and its `Request ID`. Use that ID to correlate the failure with server logs. Other authorization failures continue to use a generic message.
### Check Current Identity
```bash
@ -127,6 +129,10 @@ Output format: `namespace/slug version summary`
# Install to auto-detected Agent directory
skillhub install pdf-parser
# Choose install scope explicitly
skillhub install pdf-parser --scope user
skillhub install pdf-parser --scope project --agent codex
# Specify namespace (default: global)
skillhub install pdf-parser --namespace myspace
@ -150,18 +156,21 @@ skillhub install pdf-parser --force
The CLI determines the installation location using the following logic:
1. If `--dir` is specified: Install to that directory, agent marked as `custom`
2. If `--agent` is specified: Install to the corresponding Agent's skills directory
3. If neither is specified: Auto-scan current directory to detect existing Agent config directories
- 1 Agent detected → Install directly
- Multiple Agents detected → Interactive selection (TTY mode) or error (non-interactive mode)
- No Agent detected → Fallback to `<cwd>/.agents/skills/`
1. If `--dir` is specified: Install to that directory, agent marked as `custom`. `--dir` is mutually exclusive with `--scope` and `--agent`.
2. If `--scope user|project` is specified: Limit detection to the chosen scope.
- With `--agent <profile>`: Install to that profile's user or project skills directory directly.
- Without `--agent`: Detect existing skills directories within the chosen scope only. In interactive user scope, the `generic` target (`<home>/.agents/skills/`) is always also offered and can be selected alone or together with detected targets.
- No detected directory in the chosen scope → Fallback to `<home>/.agents/skills/` for `--scope user` or `<cwd>/.agents/skills/` for `--scope project`.
3. If `--agent` is specified (no `--scope`): Install to the corresponding Agent's skills directory (existing behaviour, unchanged).
4. If none of the above is specified:
- **Interactive mode** (stdin and stdout are both TTY, no `--json`): Prompt for `user` or `project` scope first, then continue per the `--scope` rule above.
- **Non-interactive mode**: Auto-scan current directory to detect existing Agent config directories. 1 Agent detected → install directly; multiple → error; none detected → fallback to `<cwd>/.agents/skills/`.
> `--dir` and `--agent` cannot be used together.
> `--dir` cannot be combined with `--scope` or `--agent`.
### Install Paths
Each Agent has both project-level and user-level skills directories:
Each Agent has both project-level and user-level skills directories. Use `--scope user|project` to control which one is used.
| Agent | Project-level Path | User-level Path |
|-------|-------------------|-----------------|
@ -169,9 +178,9 @@ Each Agent has both project-level and user-level skills directories:
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
| `github-copilot` | `<project>/.github-copilot/skills/` | `~/.github-copilot/skills/` |
| `gemini-cli` | `<project>/.gemini-cli/skills/` | `~/.gemini-cli/skills/` |
| `gemini-cli` | `<project>/.gemini/skills/` | `~/.gemini/skills/` |
| `windsurf` | `<project>/.windsurf/skills/` | `~/.windsurf/skills/` |
| `kiro-cli` | `<project>/.kiro-cli/skills/` | `~/.kiro-cli/skills/` |
| `kiro-cli` | `<project>/.kiro/skills/` | `~/.kiro/skills/` |
| `roo` | `<project>/.roo/skills/` | `~/.roo/skills/` |
| `trae` | `<project>/.trae/skills/` | `~/.trae/skills/` |
| `trae-cn` | `<project>/.trae-cn/skills/` | `~/.trae-cn/skills/` |
@ -179,8 +188,9 @@ Each Agent has both project-level and user-level skills directories:
| `openclaw` | `<project>/.openclaw/skills/` | `~/.openclaw/skills/` |
| `opencode` | `<project>/.opencode/skills/` | `~/.opencode/skills/` |
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
For Agents not in the list, use `--dir` to specify the installation path.
For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
### File Structure After Installation
@ -466,10 +476,11 @@ skillhub install <slug> [options]
```
Options:
- `--scope <user|project>` — Install scope (omit for interactive prompt in TTY, or fall back to existing detection in non-TTY)
- `--namespace <slug>` — Namespace (default: `global`)
- `--version <v>` — Version (default: latest)
- `--agent <profile>` — Agent profile (repeatable)
- `--dir <path>` — Custom installation directory
- `--dir <path>` — Custom installation directory (mutually exclusive with `--scope` and `--agent`)
- `--force` — Overwrite existing installation
- `--registry <url>` — Registry URL
- `--token <token>` — API token

View file

@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml
| oauth2-github-client-id | GitHub OAuth ID | No |
| oauth2-github-client-secret | GitHub OAuth secret | No |
| skill-scanner-llm-api-key | LLM API key | No |
| skill-scanner-llm-base-url | Local/custom LLM service base URL | No |
| skill-scanner-llm-model | LLM model name used by the scanner | No |
### 3. Choose Deployment Method

View file

@ -79,6 +79,8 @@ Enabling the LLM analysis engine can improve the accuracy of security detection:
| `SKILLHUB_SCANNER_USE_LLM` | Enable LLM analysis | `false` |
| `SKILLHUB_SCANNER_LLM_PROVIDER` | LLM provider (anthropic / openai / azure) | `anthropic` |
| `SKILL_SCANNER_LLM_API_KEY` | LLM API key | - |
| `SKILL_SCANNER_LLM_BASE_URL` | Local/custom LLM service base URL | - |
| `SKILL_SCANNER_LLM_MODEL` | LLM model name | - |
### Deployment Notes

View file

@ -122,7 +122,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --version v0.2.0
```
> **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。
> **注意**:升级前建议先备份数据库和对象存储。数据库迁移由 Flyway 自动执行。升级不会清空数据库,已录入的技能包不会丢失。
## Q: 为什么管理员admin和普通用户都无法创建命名空间
@ -136,11 +136,199 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
A: 使用 OpenClaw CLI 命令行工具时,可以通过 `<namespace>--<skill-name>` 的格式来指定命名空间进行操作(例如搜索、安装)。如果在网页端搜索遇到问题,也可以尝试通过先导出技能、再导入到目标命名空间的方式来完成跨空间操作。
## Q: 推荐的部署方式是什么?可以自己拉镜像手动部署吗?
A: 推荐使用官方一键部署脚本,不建议自己拉取镜像手动部署(手动部署容易出现登录后跳回登录页等初始化问题):
```bash
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
脚本会执行一系列初始化操作,生成的运行时配置默认位于 `/tmp/skillhub-runtime/`(包含 `.env.release` 和 docker-compose 文件)。
## Q: 部署后输入正确的账号密码,却又跳回登录页?
A: 该现象多见于「手动部署」场景(接口异常或初始化未完成导致)。建议:
1. 改用上面的一键脚本部署。
2. 必要时清空 PostgreSQL 数据卷后重建再登录。
3. 若前置了反向代理,检查代理配置是否正确转发。
## Q: 如何修改 admin 密码?修改配置后不生效?
A: 环境变量在容器创建时注入,修改后必须重新创建容器才会生效;仅执行 `restart` 不会重新注入环境变量。
1. 修改运行时目录下的 `/tmp/skillhub-runtime/.env.release`(参考仓库 [.env.release.example](https://github.com/iflytek/skillhub/blob/main/.env.release.example))。
2. 重新创建相关容器:
```bash
docker compose \
--env-file /tmp/skillhub-runtime/.env.release \
-f /tmp/skillhub-runtime/compose.release.yml \
up -d --force-recreate
```
3. 若此前密码已写入数据库导致仍不生效,可能需要清理对应数据后重新初始化。
## Q: 修改 / 找回密码必须使用邮箱验证码吗?
A: 是的,默认通过邮箱验证码修改或找回密码,因此需要先配置 SMTP。配置方法参考 [docs/19-smtp-password-reset-email-setup.md](https://github.com/iflytek/skillhub/blob/main/docs/19-smtp-password-reset-email-setup.md)。管理员也可在 `.env.release` 中进行重置。
## Q: skill 可以起中文名吗?
A: skill name 一般使用英文,目前不支持中文名(在 OpenClaw 中使用中文 skill 名会报错)。
## Q: 未审核的 skill 可以下载吗?
A: 只要拥有可查看的权限,一般都可以下载。
## Q: 如何隐藏或删除登录页的 GitHub / GitLab SSO 登录方式?
A: 修改 `application.yml`,注释或删除 `spring.security.oauth2.client.registration` 下的 `github``gitlab` 两块,并删除对应的 `provider` 段。Spring Boot 启动时便不会创建这两个注册,登录页也不会再显示对应入口。
## Q: SkillHub 的安全扫描Skill Scanner是讯飞自研的吗使用什么协议
A: SkillHub 内置安全扫描能力。其中扫描接入、任务编排、审计落库和部署集成由讯飞团队实现;底层扫描服务使用 Cisco 的 [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner)Apache License 2.0,版权归 Cisco
## Q: SkillHub 使用的 cisco-ai-skill-scanner 是哪个版本?
A: `scanner/Dockerfile` 中直接执行 `pip install cisco-ai-skill-scanner`,未锁定版本,因此构建镜像时会拉取 PyPI 上的最新版本。如需固定版本,可在二次开发时自行锁定。
## Q: 使用 CLI `skillhub publish` 报错 `registry returned 400` 怎么排查?
A: 400 通常是后端校验未通过。常见原因:
- `SKILL.md` 不在技能包根目录;
- `SKILL.md` 的 frontmatter 缺少 `name` / `description` 或格式错误;
- 名称或版本冲突(如 `error.skill.publish.nameConflict`,表示该 namespace 下已存在同名的已发布技能)——可改 `SKILL.md` 里的 `name`、换一个 namespace或让管理员处理已有同名技能
- namespace 不存在,或你不是该 namespace 的成员;
- 包内含疑似 token/secretCLI 无法确认跳过;
- 文件类型 / 大小 / 路径不合规。
可用以下命令查看服务端日志定位:
```bash
docker logs --tail=300 <skillhub-server 容器名> 2>&1 | grep -Ei 'publish|SKILL.md|namespace|400|BadRequest'
```
## Q: 技能包的目录结构有什么要求?
A: 技能包根目录必须包含一个 `SKILL.md` 文件,且其 frontmatter 需包含 `name``description` 等字段。
## Q: 发布时报“技能包校验失败 / malformed input”怎么办
A: 该错误发生在 zip 解包读取文件名阶段,通常是压缩包不是 UTF-8 编码(例如用 Windows 自带压缩工具生成)或包内含中文路径导致。请使用 UTF-8 编码重新打包,并避免中文 / 特殊字符路径。
## Q: 技能包能包含多少个文件?提示文件数超限怎么办?
A: 默认上限为 **100 个文件**(这与 100MB 的大小限制是两回事)。如需放宽,修改配置项 `skillhub.publish.max-file-count`,或在部署时用环境变量覆盖:
```bash
SKILLHUB_PUBLISH_MAX_FILE_COUNT=500
```
修改后需重新创建容器才会生效;仅执行 `restart` 不会重新注入环境变量。注意 `compose.release.yml` 中也需引用该变量;较旧版本(如 v0.2.6)可能将该值写死,建议升级到最新版本。
## Q: 使用 CLI发布 / 下载等)对服务端版本有要求吗?
A: 需要 SkillHub 服务端镜像 **v0.2.7 及以上** 才支持 CLI 功能。
## Q: SkillHub 支持 MySQL 数据库吗?
A: 目前仅支持 PostgreSQL暂不支持 MySQL。
## Q: SkillHub 可以用来分发 Plugin 吗?
A: 暂不支持。
## Q: 如何查看 SkillHub 的版本?想做定制(如修改 logo怎么办
A:
- 查看服务端镜像版本:
```bash
docker image inspect ghcr.io/iflytek/skillhub-server:latest --format '{{index .Config.Labels "org.opencontainers.image.version"}}'
```
- 查看 CLI 版本:`skillhub version`
- 如需定制(如修改 logo 等),建议基于最新代码进行二次开发并自行构建 docker 镜像。
## Q: 页面能打开,但登录 / 注册接口返回 502
A: 页面由 `web` 容器提供,登录、注册等接口由 `web` 转发给 `server`(默认 `SKILLHUB_API_UPSTREAM=http://server:8080`)。出现「页面正常但 API 502」时通常先检查 `server` 是否正常启动upstream 配置、DNS 或容器网络异常也可能返回 502。
排查顺序:
```bash
# 1. 看 server 是否处于运行状态
docker compose --env-file .env.release -f compose.release.yml ps
# 2. 看 server 启动日志中的第一条错误
docker compose --env-file .env.release -f compose.release.yml logs server | head -50
```
一条常见的启动失败日志是:
```
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder
```
说明 `server` 读到的仍是模板里的占位值。在 `.env.release` 中改成自己的随机字符串(**至少 32 个字符**)后重建容器即可:
```bash
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=<替换成你自己的随机字符串至少 32 个字符>
```
启动前可以先执行 `make validate-release-config`,它会校验 `.env.release`,提前暴露这类占位值和缺失项。
## Q: 改了配置为什么不生效?
A: 两个高频原因:
1. **改错了文件**`.env.release.example` 只是模板Compose 实际读取的是 `--env-file` 指定的 `.env.release`。请先 `cp .env.release.example .env.release`,然后修改 `.env.release`
2. **只重启没重建**:环境变量在容器创建时注入,`restart` 不会重新注入。改完配置需要重建容器:
```bash
docker compose --env-file .env.release -f compose.release.yml up -d --force-recreate
```
## Q: SkillHub 运行时需要哪些外部依赖?
A: 必需 PostgreSQL 和 Redis对象存储支持 `local` 与 S3 两种模式,由 `SKILLHUB_STORAGE_PROVIDER` 控制。`.env.release.example` 显式配置为 `local`,但如果使用 `compose.release.yml` 时完全没有设置该变量Compose 的回退值是 `s3`。建议始终显式设置;生产环境推荐使用 S3通过 `SKILLHUB_STORAGE_S3_*` 配置)。数据库仅支持 PostgreSQL暂不支持 MySQL。
发布版 Compose 已内置 PostgreSQL 与 Redis默认只绑定在 `127.0.0.1`
## Q: 通过 OAuthGitHub / GitLab 等)登录的账号,如何取得管理员权限?
A: OAuth 首次登录创建的是普通用户。需要由已有的 `SUPER_ADMIN`(例如初始化时的 bootstrap admin在后台将其提升为管理员。
`USER_ADMIN` 可以管理用户状态,并分配除 `SUPER_ADMIN` 之外的平台角色;但不能向任何账号授予 `SUPER_ADMIN`,也不能修改已有 `SUPER_ADMIN` 账号的角色。这两类操作只有 `SUPER_ADMIN` 可以执行。
## Q: 如何批量安装多个技能包?
A: CLI 的 `install` 一次处理一个技能包。下面两个示例都通过 `--dir` 将技能批量安装到同一个目标根目录;每个技能实际位于 `$target_dir/<skill-slug>/`
```bash
target_dir=/opt/skillhub-skills
# 逐个安装
for skill in skill-a skill-b skill-c; do
skillhub install "$skill" --dir "$target_dir"
done
# 或从清单文件读取(每行一个技能名)
xargs -a skills.txt -I {} skillhub install "{}" --dir "$target_dir"
```
**SkillHub Server v0.2.12** 起,公开技能支持匿名搜索与安装;如果配置了无效的 Bearer Token命令会直接失败而不再回退匿名访问遇到这种情况请更新凭据或先移除无效 Token。
## Q: 遇到问题怎么办?
A: 可以通过以下方式获取帮助:
- **GitHub Issues**: https://github.com/iflytek/skillhub/issues
- **在线文档**: https://iflytek.github.io/skillhub/
- **文档**: 参考项目 README.md
- **社区讨论**: https://github.com/iflytek/skillhub/discussions

View file

@ -83,6 +83,8 @@ skillhub login --token sk_xxx --registry https://skillhub.example.com
`login` 会验证 token 有效性,然后将 token 存储到 `~/.skillhub/credentials.json`,同时将 registry 写入 `~/.skillhub/config.json`
API Token 请求被拒绝时CLI 会显示服务端返回的具体原因和 `Request ID`。排查问题时可使用该 ID 对照服务端日志;非 API Token 的授权失败仍只显示通用信息。
### 查看当前身份
```bash
@ -127,6 +129,10 @@ skillhub search pdf --json
# 安装到自动探测的 Agent 目录
skillhub install pdf-parser
# 显式指定安装范围
skillhub install pdf-parser --scope user
skillhub install pdf-parser --scope project --agent codex
# 指定 namespace默认 global
skillhub install pdf-parser --namespace myspace
@ -150,18 +156,21 @@ skillhub install pdf-parser --force
CLI 按以下逻辑确定安装位置:
1. 指定 `--dir`安装到该目录agent 标记为 `custom`
2. 指定 `--agent`:安装到对应 Agent 的 skills 目录
3. 未指定:自动扫描当前目录,探测已存在的 Agent 配置目录
- 探测到 1 个 Agent → 直接安装
- 探测到多个 Agent → 交互式选择TTY 模式)或报错(非交互模式)
- 未探测到 → 回退到 `<cwd>/.agents/skills/`
1. 指定 `--dir`安装到该目录agent 标记为 `custom``--dir``--scope``--agent` 互斥。
2. 指定 `--scope user|project`:探测限定在该 scope 内。
- 同时指定 `--agent <profile>`:直接安装到该 profile 对应 scope 的 skills 目录。
- 未指定 `--agent`:只探测该 scope 下已存在的 skills 目录。在交互式 user scope 下,始终额外提供 `generic` 目标(`<home>/.agents/skills/`),可单独选择或与已探测目标同时选择。
- 该 scope 下未探测到 → fallback`--scope user` 回退到 `<home>/.agents/skills/``--scope project` 回退到 `<cwd>/.agents/skills/`
3. 指定 `--agent`(无 `--scope`):安装到对应 Agent 的 skills 目录(沿用现有行为,不变)。
4. 三者均未指定:
- **交互模式**stdin 和 stdout 都是 TTY 且未传 `--json`):先交互式询问 user 还是 project scope再按 `--scope` 规则继续。
- **非交互模式**:自动扫描当前目录探测已存在的 Agent 配置目录。1 个 → 直接安装;多个 → 报错;未探测到 → 回退到 `<cwd>/.agents/skills/`
> `--dir``--agent` 不能同时使用。
> `--dir` 不能与 `--scope``--agent` 同时使用。
### 安装路径
每个 Agent 有项目级和用户级两个 skills 目录
每个 Agent 有项目级和用户级两个 skills 目录`--scope user|project` 决定使用哪一个。
| Agent | 项目级路径 | 用户级路径 |
|-------|-----------|-----------|
@ -169,9 +178,9 @@ CLI 按以下逻辑确定安装位置:
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
| `github-copilot` | `<project>/.github-copilot/skills/` | `~/.github-copilot/skills/` |
| `gemini-cli` | `<project>/.gemini-cli/skills/` | `~/.gemini-cli/skills/` |
| `gemini-cli` | `<project>/.gemini/skills/` | `~/.gemini/skills/` |
| `windsurf` | `<project>/.windsurf/skills/` | `~/.windsurf/skills/` |
| `kiro-cli` | `<project>/.kiro-cli/skills/` | `~/.kiro-cli/skills/` |
| `kiro-cli` | `<project>/.kiro/skills/` | `~/.kiro/skills/` |
| `roo` | `<project>/.roo/skills/` | `~/.roo/skills/` |
| `trae` | `<project>/.trae/skills/` | `~/.trae/skills/` |
| `trae-cn` | `<project>/.trae-cn/skills/` | `~/.trae-cn/skills/` |
@ -179,8 +188,9 @@ CLI 按以下逻辑确定安装位置:
| `openclaw` | `<project>/.openclaw/skills/` | `~/.openclaw/skills/` |
| `opencode` | `<project>/.opencode/skills/` | `~/.opencode/skills/` |
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
对于不在列表中的 Agent使用 `--dir` 指定安装路径。
对于自定义路径或不在列表中的 Agent 目录,使用 `--dir` 显式指定安装路径。交互式 user scope 下会与已探测 Agent 目标一同提供 `generic` 目标;当 `--scope user|project` 找不到匹配的 agent 目录时CLI 会回退到上表的 `_fallback_` 行。
### 安装后的文件结构
@ -466,10 +476,11 @@ skillhub install <slug> [options]
```
选项:
- `--scope <user|project>` — 安装范围不传时TTY 模式下交互式询问,非 TTY 模式沿用现有探测逻辑)
- `--namespace <slug>` — namespace默认 `global`
- `--version <v>` — 版本(默认最新版本)
- `--agent <profile>` — Agent 配置(可重复)
- `--dir <path>` — 自定义安装目录
- `--dir <path>` — 自定义安装目录(与 `--scope``--agent` 互斥)
- `--force` — 覆盖已存在的安装
- `--registry <url>` — Registry URL
- `--token <token>` — API token

View file

@ -63,6 +63,8 @@ cp secret.yaml.example secret.yaml
| oauth2-github-client-id | GitHub OAuth ID | 否 |
| oauth2-github-client-secret | GitHub OAuth 密钥 | 否 |
| skill-scanner-llm-api-key | LLM API 密钥 | 否 |
| skill-scanner-llm-base-url | 本地/自定义 LLM 服务地址 | 否 |
| skill-scanner-llm-model | Scanner 使用的 LLM 模型名 | 否 |
### 3. 选择部署方式

View file

@ -79,6 +79,8 @@ Skill Scanner 执行多引擎分析
| `SKILLHUB_SCANNER_USE_LLM` | 启用 LLM 分析 | `false` |
| `SKILLHUB_SCANNER_LLM_PROVIDER` | LLM 提供商anthropic / openai / azure | `anthropic` |
| `SKILL_SCANNER_LLM_API_KEY` | LLM API 密钥 | - |
| `SKILL_SCANNER_LLM_BASE_URL` | 本地/自定义 LLM 服务地址 | - |
| `SKILL_SCANNER_LLM_MODEL` | LLM 模型名称 | - |
### 部署说明

File diff suppressed because it is too large Load diff

View file

@ -9,5 +9,10 @@
},
"devDependencies": {
"vitepress": "^1.6.3"
},
"overrides": {
"vite": "^6.4.3",
"postcss": "^8.5.10",
"esbuild": "^0.28.1"
}
}

View file

@ -23,7 +23,7 @@ SkillHub 采用基于角色的访问控制RBAC系统。
| 角色 | 代码 | 实际能力 |
|------|------|----------|
| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;但仍不能审批自己提交的 promotion普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 |
| 超级管理员 | `SUPER_ADMIN` | 拥有全部权限;`RbacService#getUserPermissions` 会直接返回全部权限码;可访问所有 `SUPER_ADMIN`/`SKILL_ADMIN`/`USER_ADMIN`/`AUDITOR` 能访问的接口;可分配 `SUPER_ADMIN`;发布技能时可绕过命名空间成员校验并直接自动发布;可以审批自己提交的 promotion普通审核单若是自己提交的,也只有 `SUPER_ADMIN` 能特判审批。 |
| 技能管理员 | `SKILL_ADMIN` | 可访问技能治理后台接口;可隐藏/取消隐藏技能、撤回版本yank、处理技能举报可查看和处理全局空间审核、promotion 审核、治理工作台收件箱中的 review/promotion/report不能分配平台角色、不能看审计日志、不能管理用户。 |
| 用户管理员 | `USER_ADMIN` | 可访问用户管理接口;可列表用户、审批用户、启用/禁用用户、修改平台角色;不能分配 `SUPER_ADMIN`;不能处理技能治理、不能看审计日志。 |
| 审计员 | `AUDITOR` | 只读查看审计日志;可访问 `/api/v1/admin/audit-logs``/actuator/prometheus`;治理工作台中只能看 activity不能处理 review/promotion/report也不能管理用户或技能。 |

View file

@ -23,7 +23,7 @@ The database migration seeds only 4 explicit platform roles:
| Role | Code | Effective behavior |
|------|------|--------------------|
| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Still cannot approve their own promotion request, and for normal review tasks the self-submission exception is only bypassed by `SUPER_ADMIN`. |
| Super Admin | `SUPER_ADMIN` | Has all permissions. `RbacService#getUserPermissions` returns all permission codes for this role. Can access all endpoints available to `SUPER_ADMIN` / `SKILL_ADMIN` / `USER_ADMIN` / `AUDITOR`. Can assign `SUPER_ADMIN`. Can bypass namespace membership checks during publish and auto-publish directly. Can approve their own promotion request. For normal review tasks, the self-submission exception is also only bypassed by `SUPER_ADMIN`. |
| Skill Admin | `SKILL_ADMIN` | Can access skill governance admin endpoints. Can hide/unhide skills, yank versions, and resolve/dismiss skill reports. Can review global namespace review tasks, promotion requests, and governance inbox items for review/promotion/report. Cannot manage users or read audit logs. |
| User Admin | `USER_ADMIN` | Can access user management endpoints. Can list users, approve users, enable/disable users, and change platform roles. Cannot assign `SUPER_ADMIN`. Cannot perform skill governance or read audit logs. |
| Auditor | `AUDITOR` | Read-only audit access. Can access `/api/v1/admin/audit-logs` and `/actuator/prometheus`. In the governance workbench this role can read activity, but cannot process review/promotion/report items and cannot manage users or skills. |

View file

@ -1,10 +1,16 @@
FROM python:3.11-alpine
ARG SKILL_SCANNER_VERSION=1.0.2
WORKDIR /app
RUN apk add --no-cache --virtual .build-deps gcc musl-dev libffi-dev && \
pip install --no-cache-dir cisco-ai-skill-scanner && \
apk del .build-deps && \
COPY backports/apply_1_0_2_llm_base_url_backport.py /tmp/apply_1_0_2_llm_base_url_backport.py
RUN pip install --no-cache-dir \
"cisco-ai-skill-scanner==${SKILL_SCANNER_VERSION}" \
"litellm==1.90.2" && \
python /tmp/apply_1_0_2_llm_base_url_backport.py /usr/local/lib/python3.11/site-packages && \
rm /tmp/apply_1_0_2_llm_base_url_backport.py && \
addgroup -S app && \
adduser -S app -G app && \
mkdir -p /tmp/skillhub-scans && \

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Backport SKILL_SCANNER_LLM_BASE_URL support into cisco-ai-skill-scanner 1.0.2."""
from __future__ import annotations
import re
import sys
from pathlib import Path
EXPECTED_DIST_INFO = "cisco_ai_skill_scanner-1.0.2.dist-info"
ROUTER_RELATIVE_PATH = Path("skill_scanner/api/router.py")
def replace_exact(content: str, old: str, new: str, expected_count: int, label: str) -> str:
actual_count = content.count(old)
if actual_count != expected_count:
raise SystemExit(f"Expected {expected_count} occurrences of {label}, found {actual_count}.")
return content.replace(old, new, expected_count)
def replace_regex(content: str, pattern: str, replacement: str, expected_count: int, label: str) -> str:
updated, actual_count = re.subn(pattern, replacement, content, count=expected_count, flags=re.MULTILINE)
if actual_count != expected_count:
raise SystemExit(f"Expected {expected_count} regex replacements for {label}, found {actual_count}.")
return updated
def main() -> int:
site_packages = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/usr/local/lib/python3.11/site-packages")
dist_info = site_packages / EXPECTED_DIST_INFO
if not dist_info.exists():
raise SystemExit(f"Expected {EXPECTED_DIST_INFO} under {site_packages}, but it was not found.")
router_path = site_packages / ROUTER_RELATIVE_PATH
content = router_path.read_text(encoding="utf-8")
content = replace_regex(
content,
r'^(?P<indent>\s*)llm_model = os.getenv\("SKILL_SCANNER_LLM_MODEL"\)$',
r'\g<0>\n\g<indent>llm_base_url = os.getenv("SKILL_SCANNER_LLM_BASE_URL")',
2,
"llm_model environment lookup",
)
content = replace_exact(
content,
"LLMAnalyzer(model=llm_model)",
"LLMAnalyzer(model=llm_model, base_url=llm_base_url)",
2,
"LLMAnalyzer model constructor",
)
content = replace_exact(
content,
"LLMAnalyzer(provider=provider_str)",
"LLMAnalyzer(provider=provider_str, base_url=llm_base_url)",
2,
"LLMAnalyzer provider constructor",
)
router_path.write_text(content, encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -18,7 +18,7 @@ skillhub:
security:
scanner:
# 基础配置
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false}
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true}
base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000}
mode: ${SKILLHUB_SECURITY_SCANNER_MODE:local}
@ -48,20 +48,20 @@ skillhub:
#### `enabled`
- **类型**Boolean
- **默认值**`false`
- **默认值**`true`
- **环境变量**`SKILLHUB_SECURITY_SCANNER_ENABLED`
- **说明**:是否启用安全扫描功能
- **影响**
- `true`:技能包发布时会触发安全扫描
- `false`跳过安全扫描,直接进入审核流程
- `false`仅允许 `PRIVATE` 技能跳过扫描;`PUBLIC` / `NAMESPACE_ONLY` 发布会失败并提示必须启用 Scanner
**示例**
```yaml
# 开发环境:禁用扫描
# 仅本地私有技能调试:禁用扫描
enabled: false
# 生产环境:启用扫描
# 公共或命名空间可见发布:启用扫描
enabled: true
```
@ -103,8 +103,8 @@ base-url: https://scanner.example.com
**示例**
```yaml
# Docker Compose 环境(共享卷
mode: local
# Docker Compose 环境(Scanner 独立容器,无共享发布目录
mode: upload
# Kubernetes 环境(独立 Pod
mode: upload
@ -298,7 +298,7 @@ services:
environment:
- SKILLHUB_SECURITY_SCANNER_ENABLED=true
- SKILLHUB_SECURITY_SCANNER_URL=http://skill-scanner:8000
- SKILLHUB_SECURITY_SCANNER_MODE=local
- SKILLHUB_SECURITY_SCANNER_MODE=upload
- SKILLHUB_SCANNER_USE_BEHAVIORAL=false
- SKILLHUB_SCANNER_USE_LLM=false
- SKILLHUB_SCANNER_USE_META=true
@ -348,9 +348,9 @@ stringData:
skillhub:
security:
scanner:
enabled: false # 开发时禁用扫描,加快迭代速度
enabled: true # 默认启用PUBLIC/NAMESPACE_ONLY 发布依赖扫描
base-url: http://localhost:8000
mode: local
mode: upload
analyzers:
meta: true # 只启用元数据分析
policy:
@ -366,7 +366,7 @@ skillhub:
scanner:
enabled: true # 测试环境启用扫描
base-url: http://skill-scanner:8000
mode: local
mode: upload
analyzers:
behavioral: true
meta: true

View file

@ -2,12 +2,10 @@
# Release entrypoint for the SkillHub CLI.
#
# This script bumps cli/package.json, commits the bump, creates a `cli-vX.Y.Z`
# tag, and pushes it. The GitHub Actions workflow `release-cli.yml` picks up
# the tag and performs the actual build + npm publish + GitHub Release.
#
# Prefer this over direct `npm publish`: avoids local network/TLS issues with
# registry.npmjs.org, and keeps release provenance tied to CI.
# Runs local build-and-test (lint, typecheck, test, build), bumps the version
# in cli/package.json, pushes a release branch, and opens a PR to main.
# Tagging is done manually after the PR is merged — the tag push triggers
# `release-cli.yml` which builds and publishes to npm.
set -euo pipefail
@ -36,6 +34,87 @@ if [[ "$BUMP_TYPE" != "patch" && "$BUMP_TYPE" != "minor" && "$BUMP_TYPE" != "maj
exit 1
fi
# --- Cleanup state machine ---
#
# Tracks how far we got so the ERR trap can roll back the right pieces.
# Stages advance monotonically; the trap inspects this to decide what to undo.
#
# pre-branch — nothing created yet
# on-release — checked out release branch (may have uncommitted edits)
# committed — version bump committed locally, not yet pushed
# pushed — release branch pushed to origin (PR not yet open)
# pr-opened — PR opened (terminal success state, trap is a no-op)
#
CLEANUP_STAGE="pre-branch"
ORIGINAL_BRANCH=""
RELEASE_BRANCH=""
cleanup_on_error() {
local exit_code=$?
trap - ERR EXIT INT TERM
set +e
# When triggered by a signal with no failed command, $? may be 0; force a
# non-zero exit so the caller sees the interruption.
if [[ $exit_code -eq 0 ]]; then
exit_code=130
fi
case "$CLEANUP_STAGE" in
pre-branch)
# build-and-test runs `bun run lint/typecheck/build`, all of which
# regenerate cli/src/generated/pkg-info.ts via their pre-* hooks. If
# we got interrupted mid-flight, restore it so the working tree is
# clean for the next attempt.
git -C "$REPO_ROOT" checkout -- "$CLI_DIR/src/generated/pkg-info.ts" 2>/dev/null
;;
pr-opened)
# Already succeeded.
;;
on-release)
echo "" >&2
echo "[publish-cli] error before commit — rolling back release branch" >&2
git -C "$REPO_ROOT" checkout -f "$ORIGINAL_BRANCH" 2>/dev/null
git -C "$REPO_ROOT" branch -D "$RELEASE_BRANCH" 2>/dev/null
;;
committed)
echo "" >&2
echo "[publish-cli] error after commit but before push — rolling back local branch" >&2
git -C "$REPO_ROOT" checkout -f "$ORIGINAL_BRANCH" 2>/dev/null
git -C "$REPO_ROOT" branch -D "$RELEASE_BRANCH" 2>/dev/null
;;
pushed)
echo "" >&2
echo "ERROR: release branch '$RELEASE_BRANCH' was pushed but PR creation failed." >&2
echo "" >&2
echo "To recover:" >&2
echo "" >&2
echo " 1. Open the PR manually:" >&2
echo " gh pr create --base main --head $RELEASE_BRANCH" >&2
echo "" >&2
echo " 2. Or roll back:" >&2
echo " git checkout $ORIGINAL_BRANCH" >&2
echo " git branch -D $RELEASE_BRANCH" >&2
echo " git push origin --delete $RELEASE_BRANCH" >&2
echo "" >&2
;;
esac
exit "$exit_code"
}
trap cleanup_on_error ERR INT TERM
if ! command -v gh >/dev/null 2>&1; then
echo "gh CLI is required (https://cli.github.com)" >&2
exit 1
fi
if ! gh auth status >/dev/null 2>&1; then
echo "gh CLI is not authenticated. Run: gh auth login" >&2
exit 1
fi
log_stage "checking git working tree"
if [[ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]]; then
echo "git working tree is not clean — commit or stash changes first" >&2
@ -47,124 +126,166 @@ if [[ "$CURRENT_BRANCH" != "main" ]]; then
echo "releases must be cut from 'main' (current: '$CURRENT_BRANCH')" >&2
exit 1
fi
ORIGINAL_BRANCH="$CURRENT_BRANCH"
log_stage "pulling latest from origin/$CURRENT_BRANCH"
git -C "$REPO_ROOT" pull --ff-only origin "$CURRENT_BRANCH"
log_stage "fetching tags from origin"
git -C "$REPO_ROOT" fetch --tags --prune origin
# --- Compute version & pre-flight checks (cheap, run before build) ---
#
# Baseline is read from origin via `ls-remote`, not from local tags. A local
# orphan tag left over from a failed `git push origin cli-vX.Y.Z` must not
# influence the next version — otherwise we'd skip versions or release on top
# of something that was never published.
log_stage "checking for unpushed release artifacts"
UNPUSHED_COMMITS="$(git -C "$REPO_ROOT" log --oneline origin/"$CURRENT_BRANCH"..HEAD 2>/dev/null || true)"
# Detect local cli-v* tags that don't exist on origin.
# This catches both: (a) commit not pushed + tag not pushed, and
# (b) commit pushed but tag push failed (where --no-merged would miss it).
UNPUSHED_RELEASE_TAGS=""
while IFS= read -r local_tag; do
[[ -z "$local_tag" ]] && continue
if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$local_tag" >/dev/null 2>&1; then
UNPUSHED_RELEASE_TAGS="${UNPUSHED_RELEASE_TAGS:+$UNPUSHED_RELEASE_TAGS
}$local_tag"
fi
done < <(git -C "$REPO_ROOT" tag --list 'cli-v*' 2>/dev/null)
if [[ -n "$UNPUSHED_COMMITS" ]] || [[ -n "$UNPUSHED_RELEASE_TAGS" ]]; then
echo "" >&2
echo "ERROR: Detected unpushed release artifacts from a previous failed push:" >&2
echo "" >&2
if [[ -n "$UNPUSHED_COMMITS" ]]; then
echo " Unpushed commits:" >&2
echo "$UNPUSHED_COMMITS" | sed 's/^/ /' >&2
echo "" >&2
fi
if [[ -n "$UNPUSHED_RELEASE_TAGS" ]]; then
echo " Unpushed tags:" >&2
echo "$UNPUSHED_RELEASE_TAGS" | sed 's/^/ /' >&2
echo "" >&2
fi
echo "Choose a recovery option:" >&2
echo "" >&2
echo " 1. Retry push (if the previous failure was temporary, e.g., network issue):" >&2
if [[ -n "$UNPUSHED_RELEASE_TAGS" ]]; then
FIRST_TAG="$(echo "$UNPUSHED_RELEASE_TAGS" | head -n1)"
echo " git push origin $CURRENT_BRANCH $FIRST_TAG" >&2
else
echo " git push origin $CURRENT_BRANCH" >&2
fi
echo "" >&2
echo " 2. Rollback and retry release (if you want to start fresh):" >&2
if [[ -n "$UNPUSHED_RELEASE_TAGS" ]]; then
echo " git tag -d $UNPUSHED_RELEASE_TAGS" | tr '\n' ' ' | sed 's/ $/\n/' >&2
fi
echo " git reset --hard origin/$CURRENT_BRANCH" >&2
echo " # Then re-run: make publish-cli" >&2
echo "" >&2
exit 1
fi
log_stage "resolving baseline version from latest cli-v* tag"
LATEST_TAG="$(git -C "$REPO_ROOT" tag --list 'cli-v*' --sort=-version:refname | head -n1)"
log_stage "computing next version from latest origin cli-v* tag"
LATEST_TAG="$(git -C "$REPO_ROOT" ls-remote --tags --refs origin 'cli-v*' \
| awk '{sub(/^refs\/tags\//, "", $2); print $2}' \
| sort -V \
| tail -n1)"
if [[ -n "$LATEST_TAG" ]]; then
BASE_VERSION="${LATEST_TAG#cli-v}"
CURRENT_PKG_VERSION="$(node -p "require('$PACKAGE_JSON').version")"
if [[ "$BASE_VERSION" != "$CURRENT_PKG_VERSION" ]]; then
log_stage "syncing package.json $CURRENT_PKG_VERSION -> $BASE_VERSION (from $LATEST_TAG)"
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('$PACKAGE_JSON', 'utf8'));
pkg.version = '$BASE_VERSION';
fs.writeFileSync('$PACKAGE_JSON', JSON.stringify(pkg, null, 2) + '\n');
"
# Reject prerelease tags (e.g., cli-v0.2.0-rc.1). Only pure X.Y.Z is supported.
if [[ "$BASE_VERSION" =~ [^0-9.] ]]; then
echo "latest origin tag $LATEST_TAG contains prerelease suffix: $BASE_VERSION" >&2
echo "this script only supports pure X.Y.Z versions" >&2
echo "skip prerelease tags manually or use a different baseline" >&2
exit 1
fi
log_stage "baseline: $BASE_VERSION (from origin $LATEST_TAG)"
else
log_stage "no cli-v* tags found, bumping from package.json"
BASE_VERSION="$(PACKAGE_JSON="$PACKAGE_JSON" node -p "require(process.env.PACKAGE_JSON).version")"
log_stage "no cli-v* tags on origin, baseline: $BASE_VERSION (from package.json)"
fi
log_stage "bumping version ($BUMP_TYPE)"
NPM_VERSION_OUTPUT="$(cd "$CLI_DIR" && npm version "$BUMP_TYPE" --no-git-tag-version)"
NEW_VERSION="${NPM_VERSION_OUTPUT#v}"
NEW_VERSION="$(BASE_VERSION="$BASE_VERSION" BUMP_TYPE="$BUMP_TYPE" node -e "
const v = process.env.BASE_VERSION.split('.').map(Number);
if (v.length !== 3 || v.some(Number.isNaN)) {
console.error('invalid baseline version: ' + process.env.BASE_VERSION);
process.exit(1);
}
const t = process.env.BUMP_TYPE;
if (t === 'patch') v[2]++;
else if (t === 'minor') { v[1]++; v[2] = 0; }
else if (t === 'major') { v[0]++; v[1] = 0; v[2] = 0; }
console.log(v.join('.'));
")"
if [[ -z "$NEW_VERSION" ]]; then
echo "failed to parse version from npm output: $NPM_VERSION_OUTPUT" >&2
git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON"
echo "failed to compute new version from baseline $BASE_VERSION" >&2
exit 1
fi
TAG="cli-v${NEW_VERSION}"
RELEASE_BRANCH="release/${TAG}"
if git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "tag $TAG already exists locally" >&2
git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON"
exit 1
fi
if git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
echo "tag $TAG already exists on origin" >&2
git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON"
exit 1
fi
log_stage "new version: $NEW_VERSION (tag: $TAG)"
if ! confirm "Commit, tag, and push $TAG to origin?"; then
echo "release cancelled — reverting package.json" >&2
git -C "$REPO_ROOT" checkout -- "$PACKAGE_JSON"
if git -C "$REPO_ROOT" rev-parse -q --verify "refs/heads/$RELEASE_BRANCH" >/dev/null; then
echo "branch $RELEASE_BRANCH already exists locally" >&2
exit 1
fi
if git -C "$REPO_ROOT" ls-remote --exit-code --heads origin "refs/heads/$RELEASE_BRANCH" >/dev/null 2>&1; then
echo "branch $RELEASE_BRANCH already exists on origin" >&2
exit 1
fi
log_stage "target: $NEW_VERSION (branch: $RELEASE_BRANCH)"
# --- Local build-and-test ---
log_stage "installing dependencies"
(cd "$CLI_DIR" && bun install --frozen-lockfile)
log_stage "running lint"
(cd "$CLI_DIR" && bun run lint)
log_stage "running typecheck"
(cd "$CLI_DIR" && bun run typecheck)
log_stage "running tests"
(cd "$CLI_DIR" && bun test)
log_stage "running build"
(cd "$CLI_DIR" && bun run build)
log_stage "build-and-test passed"
# Reset only the codegen file that build-and-test regenerates. We rewrite
# pkg-info.ts again after the version bump, so this just keeps the working
# tree clean before branching.
git -C "$REPO_ROOT" checkout -- "$CLI_DIR/src/generated/pkg-info.ts"
if ! confirm "Push $RELEASE_BRANCH and open PR to main?"; then
echo "release cancelled" >&2
exit 1
fi
# --- Create branch, bump, push, open PR ---
log_stage "creating release branch $RELEASE_BRANCH"
git -C "$REPO_ROOT" checkout -b "$RELEASE_BRANCH"
CLEANUP_STAGE="on-release"
log_stage "writing version $NEW_VERSION to package.json"
NEW_VERSION="$NEW_VERSION" PACKAGE_JSON="$PACKAGE_JSON" node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync(process.env.PACKAGE_JSON, 'utf8'));
pkg.version = process.env.NEW_VERSION;
fs.writeFileSync(process.env.PACKAGE_JSON, JSON.stringify(pkg, null, 2) + '\n');
"
log_stage "regenerating pkg-info.ts with new version"
(cd "$CLI_DIR" && bun run scripts/generate-pkg-info.ts)
log_stage "committing version bump"
git -C "$REPO_ROOT" add "$PACKAGE_JSON"
git -C "$REPO_ROOT" add "$PACKAGE_JSON" "$CLI_DIR/src/generated/pkg-info.ts"
git -C "$REPO_ROOT" commit -m "chore(cli): bump version to $NEW_VERSION"
CLEANUP_STAGE="committed"
log_stage "creating tag $TAG"
git -C "$REPO_ROOT" tag "$TAG"
log_stage "pushing release branch to origin"
git -C "$REPO_ROOT" push -u origin "$RELEASE_BRANCH"
CLEANUP_STAGE="pushed"
log_stage "pushing commit and tag to origin (atomic)"
git -C "$REPO_ROOT" push --atomic origin "$CURRENT_BRANCH" "$TAG"
log_stage "opening pull request"
PR_BODY="Bumps CLI version to \`$NEW_VERSION\`.
log_stage "release triggered — CI workflow will build and publish"
log_stage "watch progress at: https://github.com/iflytek/skillhub/actions/workflows/release-cli.yml"
Local build-and-test passed (lint, typecheck, test, build).
After merging, tag and push to trigger the release:
\`\`\`bash
git fetch origin main
git checkout main
git merge --ff-only origin/main
git tag $TAG origin/main
git push origin $TAG
\`\`\`
Watch the release: https://github.com/iflytek/skillhub/actions/workflows/release-cli.yml"
gh pr create \
--base main \
--head "$RELEASE_BRANCH" \
--title "chore(cli): release $NEW_VERSION" \
--body "$PR_BODY"
CLEANUP_STAGE="pr-opened"
log_stage "returning to $CURRENT_BRANCH"
git -C "$REPO_ROOT" checkout "$CURRENT_BRANCH"
git -C "$REPO_ROOT" branch -D "$RELEASE_BRANCH"
log_stage "done — PR opened. After merge, tag manually:"
echo ""
echo " git fetch origin main"
echo " git tag $TAG origin/main"
echo " git push origin $TAG"
echo ""

View file

@ -159,13 +159,34 @@ set_env_value() {
fi
tmp="$ENV_FILE.tmp"
if grep -q "^$key=" "$ENV_FILE"; then
sed "s|^$key=.*|$key=$value|" "$ENV_FILE" >"$tmp"
else
cat "$ENV_FILE" >"$tmp"
printf '%s=%s\n' "$key" "$value" >>"$tmp"
fi
found=false
old_umask="$(umask)"
umask 077
{
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"$key="*)
printf '%s=%s\n' "$key" "$value"
found=true
;;
*)
printf '%s\n' "$line"
;;
esac
done <"$ENV_FILE"
if [ "$found" = "false" ]; then
printf '%s=%s\n' "$key" "$value"
fi
} >"$tmp"
umask "$old_umask"
mv "$tmp" "$ENV_FILE"
secure_env_file
}
secure_env_file() {
if [ -f "$ENV_FILE" ]; then
chmod 600 "$ENV_FILE"
fi
}
get_env_value() {
@ -180,6 +201,42 @@ get_env_value() {
fi
}
generate_secret() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
return 0
fi
if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then
dd if=/dev/urandom bs=32 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n'
return 0
fi
echo "Unable to generate SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET. Install openssl or configure it manually." >&2
exit 1
}
is_placeholder_secret() {
case "$1" in
""|change-me-in-production|replace-me|replace-with-random-download-secret-32-bytes|TODO*|todo*|replace*)
return 0
;;
*)
return 1
;;
esac
}
ensure_anonymous_download_secret() {
secret="$(get_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "")"
if ! is_placeholder_secret "$secret" && [ "${#secret}" -ge 32 ]; then
return 0
fi
set_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$(generate_secret)"
echo "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET in $ENV_FILE"
}
wait_for_postgres_ready() {
postgres_user="$1"
postgres_db="$2"
@ -199,6 +256,23 @@ wait_for_postgres_ready() {
exit 1
}
wait_for_redis_ready() {
attempt=1
while [ "$attempt" -le 60 ]; do
if run_compose exec -T redis redis-cli ping >/dev/null 2>&1; then
return 0
fi
attempt=$((attempt + 1))
sleep 2
done
echo "Redis did not become ready in time." >&2
run_compose logs redis >&2 || true
exit 1
}
ensure_postgres_password_matches_env() {
postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")"
postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")"
@ -231,8 +305,12 @@ prepare_runtime_files() {
download_file "$SKILLHUB_RAW_BASE/.env.release.example" "$ENV_EXAMPLE_FILE"
if [ ! -f "$ENV_FILE" ]; then
old_umask="$(umask)"
umask 077
cp "$ENV_EXAMPLE_FILE" "$ENV_FILE"
umask "$old_umask"
fi
secure_env_file
if [ -n "$SKILLHUB_MIRROR_REGISTRY_VALUE" ]; then
mirror_registry="${SKILLHUB_MIRROR_REGISTRY_VALUE%/}"
@ -280,6 +358,12 @@ prepare_runtime_files() {
if [ -n "$SKILLHUB_PUBLIC_BASE_URL_VALUE" ]; then
set_env_value "SKILLHUB_PUBLIC_BASE_URL" "$SKILLHUB_PUBLIC_BASE_URL_VALUE"
fi
if [ "$DISABLE_SCANNER" = "true" ]; then
set_env_value "SKILLHUB_SECURITY_SCANNER_ENABLED" "false"
fi
ensure_anonymous_download_secret
}
run_compose() {
@ -295,7 +379,9 @@ case "$COMMAND" in
run_compose up -d postgres
ensure_postgres_password_matches_env
if [ "$DISABLE_SCANNER" = "true" ]; then
SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --scale skill-scanner=0
run_compose up -d redis
wait_for_redis_ready
SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --no-deps --scale skill-scanner=0 server web
else
run_compose up -d
fi

View file

@ -36,8 +36,8 @@ echo "Target: $BASE_URL"
echo
check "Health endpoint" "$BASE_URL/actuator/health" "200"
check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200"
check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200"
check "Prometheus metrics requires auth" "$BASE_URL/actuator/prometheus" "401"
check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401"
check "Auth required" "$BASE_URL/api/v1/auth/me" "401"
curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null
@ -67,6 +67,15 @@ else
FAIL=$((FAIL + 1))
fi
NAMESPACES_AUTH_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/namespaces" || true)"
if [[ "$NAMESPACES_AUTH_STATUS" == "200" ]]; then
echo "PASS: Namespaces API with session (HTTP $NAMESPACES_AUTH_STATUS)"
PASS=$((PASS + 1))
else
echo "FAIL: Namespaces API with session (got $NAMESPACES_AUTH_STATUS)"
FAIL=$((FAIL + 1))
fi
CHANGE_PASSWORD_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
-X POST "$BASE_URL/api/v1/auth/local/change-password" \
-b "$COOKIE_JAR" \

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
MAKEFILE="$REPO_ROOT/Makefile"
fail() {
echo "FAIL: $*" >&2
exit 1
}
grep -Eq '^DEV_WEB_HOST[[:space:]]*\?=[[:space:]]*127\.0\.0\.1$' "$MAKEFILE" \
|| fail "Makefile must default DEV_WEB_HOST to 127.0.0.1"
grep -Fq 'pnpm exec vite --host $(DEV_WEB_HOST)' "$MAKEFILE" \
|| fail "dev web startup must pass DEV_WEB_HOST to vite"
grep -Fq "cd server && /bin/sh -lc '\$(DEV_SERVER_PREPARE) && exec env \$(DEV_SERVER_SCANNER_ENV) \$(DEV_SERVER_CMD)'" "$MAKEFILE" \
|| fail "dev-server must inject scanner upload environment"
if grep -Fq 'pnpm exec vite --host 0.0.0.0' "$MAKEFILE"; then
fail "dev web startup must not bind Vite to 0.0.0.0 by default"
fi
echo "dev-web-host-test passed"

View file

@ -2,10 +2,11 @@
# Integration tests for scripts/publish-cli.sh.
#
# The script bumps cli/package.json, commits, tags `cli-vX.Y.Z`, and pushes
# both refs to origin. These tests build a self-contained fake repo for each
# scenario, using a real bare repository as origin so git fetch / pull / push
# are actually exercised. `npm` is stubbed to keep `npm version` deterministic.
# The script runs local build-and-test (lint, typecheck, test, build), bumps
# cli/package.json, pushes a `release/cli-vX.Y.Z` branch, and opens a PR via
# `gh pr create`. These tests build a self-contained fake repo per scenario,
# using a real bare repository as origin so git fetch/pull/push are actually
# exercised. `bun` and `gh` are stubbed to keep the heavy steps deterministic.
set -euo pipefail
@ -37,8 +38,10 @@ fail() {
#
# Builds a minimal repo with:
# - cli/package.json at the requested version
# - cli/src/generated/pkg-info.ts (committed, so script's checkout works)
# - scripts/publish-cli.sh (the script under test)
# - bin/npm stub that implements `npm version <bump> --no-git-tag-version`
# - bin/bun stub: handles install / run lint|typecheck|build|<codegen> / test
# - bin/gh stub: handles `auth status` and `pr create` (logs to gh-stub.log)
# - a sibling bare repo as `origin`
# - HEAD on `main` with the init commit already pushed
# - optional pre-seeded `cli-v*` tags (created locally AND on origin)
@ -53,7 +56,7 @@ init_repo() {
local origin="$repo.origin.git"
TMP_DIRS+=("$origin")
mkdir -p "$repo/cli" "$repo/scripts" "$repo/bin"
mkdir -p "$repo/cli/src/generated" "$repo/scripts" "$repo/bin"
cp "$PUBLISH_SCRIPT" "$repo/scripts/publish-cli.sh"
cat >"$repo/cli/package.json" <<EOF
@ -66,40 +69,103 @@ init_repo() {
}
EOF
# Stub `npm`: only `npm version <patch|minor|major> --no-git-tag-version` is
# supported. Mutates package.json in cwd and echoes `vX.Y.Z` (matching real
# npm behaviour the script depends on).
cat >"$repo/bin/npm" <<'EOF'
cat >"$repo/cli/src/generated/pkg-info.ts" <<EOF
// Generated by scripts/generate-pkg-info.ts - do not edit by hand.
export const PKG_NAME = "@astron-team/skillhub"
export const PKG_VERSION = "$version"
EOF
# Stub `bun`: handles every subcommand the publish script invokes.
# Failure injection via BUN_FAIL_AT (substring match against full args).
cat >"$repo/bin/bun" <<'BUN_EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" != "version" ]]; then
echo "npm stub: unsupported subcommand: $*" >&2
if [[ -n "${BUN_FAIL_AT:-}" ]] && [[ "$*" == *"$BUN_FAIL_AT"* ]]; then
echo "stub bun: forced failure at: $*" >&2
exit 1
fi
BUMP="$2"
node - "$PWD/package.json" "$BUMP" <<'NODE'
const fs = require("fs");
const path = process.argv[2];
const bump = process.argv[3];
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
const parts = pkg.version.split(".").map(Number);
if (bump === "patch") parts[2] += 1;
else if (bump === "minor") { parts[1] += 1; parts[2] = 0; }
else if (bump === "major") { parts[0] += 1; parts[1] = 0; parts[2] = 0; }
else throw new Error("unexpected bump: " + bump);
const next = parts.join(".");
pkg.version = next;
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
console.log("v" + next);
NODE
EOF
chmod +x "$repo/bin/npm"
# Ignore test scaffolding files so they don't make `git status` dirty.
case "${1:-}" in
install)
exit 0
;;
test)
exit 0
;;
run)
case "${2:-}" in
lint|typecheck|build)
exit 0
;;
scripts/generate-pkg-info.ts)
node -e "
const fs = require('fs');
const path = require('path');
const cliRoot = process.cwd();
const pkg = JSON.parse(fs.readFileSync(path.join(cliRoot, 'package.json'), 'utf8'));
const out = path.join(cliRoot, 'src/generated/pkg-info.ts');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out,
'// Generated by scripts/generate-pkg-info.ts - do not edit by hand.\n' +
'export const PKG_NAME = ' + JSON.stringify(pkg.name) + '\n' +
'export const PKG_VERSION = ' + JSON.stringify(pkg.version) + '\n');
"
exit 0
;;
*)
echo "stub bun: unsupported run target: ${2:-}" >&2
exit 1
;;
esac
;;
*)
echo "stub bun: unsupported subcommand: ${1:-}" >&2
exit 1
;;
esac
BUN_EOF
chmod +x "$repo/bin/bun"
# Stub `gh`: handles `auth status` (always 0) and `pr create` (logs args).
# Failure injection via GH_FAIL_AT="auth"|"pr-create".
cat >"$repo/bin/gh" <<'GH_EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ -n "${GH_FAIL_AT:-}" ]]; then
if [[ "$GH_FAIL_AT" == "auth" && "${1:-}" == "auth" ]]; then
exit 1
fi
if [[ "$GH_FAIL_AT" == "pr-create" && "${1:-}" == "pr" && "${2:-}" == "create" ]]; then
echo "stub gh: forced PR create failure" >&2
exit 1
fi
fi
case "${1:-}" in
auth)
exit 0
;;
pr)
if [[ "${2:-}" == "create" ]]; then
printf '%s\n' "$*" >> "${GH_LOG_FILE:-/dev/null}"
exit 0
fi
exit 1
;;
*)
echo "stub gh: unsupported: ${1:-}" >&2
exit 1
;;
esac
GH_EOF
chmod +x "$repo/bin/gh"
cat >"$repo/.gitignore" <<EOF
stdout.log
stderr.log
git-push-log.txt
gh-stub.log
bin-git/
EOF
@ -108,7 +174,8 @@ EOF
git -C "$repo" config user.name "Test User"
git -C "$repo" config user.email "test@example.com"
git -C "$repo" remote add origin "$origin"
git -C "$repo" add cli/package.json scripts/publish-cli.sh bin/npm .gitignore
git -C "$repo" add cli/package.json cli/src/generated/pkg-info.ts \
scripts/publish-cli.sh bin/bun bin/gh .gitignore
git -C "$repo" commit -q -m "init"
git -C "$repo" push -q -u origin main
@ -119,24 +186,32 @@ EOF
done
}
# run_publish <repo> <bump> [stdin]
# Writes stdout to $repo/stdout.log and stderr to $repo/stderr.log.
# Prints the exit code on stdout.
# run_publish <repo> <bump> [stdin] [extra_env=...]
#
# Writes stdout/stderr to $repo/{stdout,stderr}.log and prints exit code.
# `extra_env` (e.g. "BUN_FAIL_AT=lint") is forwarded as bash env assignments.
run_publish() {
local repo="$1"
local bump="$2"
local input="${3-}"
local extra="${4-}"
local status=0
local cmd=(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE
REPO_ROOT="$repo" PATH="$repo/bin:$PATH"
GH_LOG_FILE="$repo/gh-stub.log")
if [[ -n "$extra" ]]; then
# Split "K1=V1 K2=V2" into separate env entries.
local kv
for kv in $extra; do
cmd+=("$kv")
done
fi
cmd+=(bash "$repo/scripts/publish-cli.sh" "$bump")
if [[ -n "$input" ]]; then
printf '%s' "$input" | env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
REPO_ROOT="$repo" PATH="$repo/bin:$PATH" \
bash "$repo/scripts/publish-cli.sh" "$bump" \
>"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
printf '%s' "$input" | "${cmd[@]}" >"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
else
env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
REPO_ROOT="$repo" PATH="$repo/bin:$PATH" \
bash "$repo/scripts/publish-cli.sh" "$bump" \
>"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
"${cmd[@]}" >"$repo/stdout.log" 2>"$repo/stderr.log" || status=$?
fi
echo "$status"
}
@ -157,10 +232,9 @@ grep -F "Usage:" "$REPO1/stderr.log" >/dev/null
echo "[test] dirty working tree aborts"
REPO2="$(new_tmp)"
init_repo "$REPO2"
touch "$REPO2/dirty.txt"
echo "junk" > "$REPO2/cli/package.json"
status="$(run_publish "$REPO2" "patch")"
[[ "$status" -ne 0 ]] || fail "expected non-zero exit for dirty tree"
grep -F "checking git working tree" "$REPO2/stdout.log" >/dev/null
grep -F "git working tree is not clean" "$REPO2/stderr.log" >/dev/null
# ----------------------------------------------------------------------------
@ -176,149 +250,135 @@ grep -F "releases must be cut from 'main'" "$REPO3/stderr.log" >/dev/null
grep -F "feature/x" "$REPO3/stderr.log" >/dev/null
# ----------------------------------------------------------------------------
# Test 4: package.json behind latest cli-v* tag → baseline sync, then bump
# Test 4: gh auth fails → abort early
# ----------------------------------------------------------------------------
echo "[test] baseline sync from latest cli-v* tag, then bump + push"
echo "[test] gh auth failure aborts"
REPO4="$(new_tmp)"
init_repo "$REPO4" "0.1.0" "cli-v0.2.0"
status="$(run_publish "$REPO4" "patch" $'y\n')"
[[ "$status" -eq 0 ]] || { cat "$REPO4/stderr.log" >&2; fail "expected success, got $status"; }
grep -F "syncing package.json 0.1.0 -> 0.2.0 (from cli-v0.2.0)" "$REPO4/stdout.log" >/dev/null
grep -F "bumping version (patch)" "$REPO4/stdout.log" >/dev/null
grep -F "new version: 0.2.1 (tag: cli-v0.2.1)" "$REPO4/stdout.log" >/dev/null
grep -F '"version": "0.2.1"' "$REPO4/cli/package.json" >/dev/null
git -C "$REPO4" rev-parse "cli-v0.2.1" >/dev/null \
|| fail "local tag cli-v0.2.1 missing"
git -C "$REPO4" log --oneline | grep -F "chore(cli): bump version to 0.2.1" >/dev/null
git -C "$REPO4.origin.git" rev-parse "cli-v0.2.1" >/dev/null \
|| fail "origin tag cli-v0.2.1 missing — atomic push not delivered"
init_repo "$REPO4"
status="$(run_publish "$REPO4" "patch" "" "GH_FAIL_AT=auth")"
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when gh auth fails"
grep -F "gh CLI is not authenticated" "$REPO4/stderr.log" >/dev/null
# ----------------------------------------------------------------------------
# Test 5: no cli-v* tags → fall back to package.json
# Test 5: release branch already exists → abort before build (fail-fast)
# ----------------------------------------------------------------------------
echo "[test] no cli-v* tags falls back to package.json"
echo "[test] existing release branch aborts before build"
REPO5="$(new_tmp)"
init_repo "$REPO5" "0.1.0"
status="$(run_publish "$REPO5" "minor" $'y\n')"
[[ "$status" -eq 0 ]] || { cat "$REPO5/stderr.log" >&2; fail "expected success, got $status"; }
grep -F "no cli-v* tags found, bumping from package.json" "$REPO5/stdout.log" >/dev/null
grep -F "new version: 0.2.0 (tag: cli-v0.2.0)" "$REPO5/stdout.log" >/dev/null
git -C "$REPO5.origin.git" rev-parse "cli-v0.2.0" >/dev/null \
|| fail "origin tag cli-v0.2.0 missing"
init_repo "$REPO5" "0.1.0" "cli-v0.1.0"
# Baseline is cli-v0.1.0, patch target is cli-v0.1.1, branch=release/cli-v0.1.1.
# Create that branch locally so the pre-flight check catches it.
git -C "$REPO5" branch "release/cli-v0.1.1"
status="$(run_publish "$REPO5" "patch")"
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when release branch exists"
grep -F "branch release/cli-v0.1.1 already exists locally" "$REPO5/stderr.log" >/dev/null \
|| { cat "$REPO5/stderr.log" >&2; fail "expected 'branch already exists locally' error"; }
# ----------------------------------------------------------------------------
# Test 6: confirmation cancel → revert package.json, no commit, no tag
# Test 6: confirmation cancel → no branch, no commit, no push
# ----------------------------------------------------------------------------
echo "[test] confirmation cancel reverts everything"
echo "[test] confirmation cancel leaves no side effects"
REPO6="$(new_tmp)"
init_repo "$REPO6" "0.1.0" "cli-v0.1.0"
INITIAL_HEAD="$(git -C "$REPO6" rev-parse HEAD)"
status="$(run_publish "$REPO6" "patch" $'n\n')"
[[ "$status" -ne 0 ]] || fail "expected non-zero exit on cancel"
grep -F "release cancelled" "$REPO6/stderr.log" >/dev/null
grep -F '"version": "0.1.0"' "$REPO6/cli/package.json" >/dev/null \
|| fail "package.json not reverted to 0.1.0 after cancel"
[[ "$(git -C "$REPO6" rev-parse HEAD)" == "$INITIAL_HEAD" ]] \
|| fail "HEAD advanced after cancel — extra commit was made"
if git -C "$REPO6" rev-parse -q --verify "refs/tags/cli-v0.1.1" >/dev/null 2>&1; then
fail "tag cli-v0.1.1 must not exist after cancel"
|| fail "HEAD advanced after cancel"
[[ "$(git -C "$REPO6" rev-parse --abbrev-ref HEAD)" == "main" ]] \
|| fail "not on main after cancel"
if git -C "$REPO6" rev-parse -q --verify "refs/heads/release/cli-v0.1.1" >/dev/null 2>&1; then
fail "release branch must not exist after cancel"
fi
[[ -z "$(git -C "$REPO6" status --porcelain)" ]] \
|| fail "working tree not clean after cancel — revert incomplete"
|| fail "working tree not clean after cancel"
# ----------------------------------------------------------------------------
# Test 7: happy path — atomic push delivers branch + tag together
# Test 7: happy path — branch pushed, PR opened, returned to main
# ----------------------------------------------------------------------------
echo "[test] happy path pushes branch and tag atomically"
echo "[test] happy path pushes branch and opens PR"
REPO7="$(new_tmp)"
init_repo "$REPO7" "0.5.0"
status="$(run_publish "$REPO7" "patch" $'y\n')"
[[ "$status" -eq 0 ]] || { cat "$REPO7/stderr.log" >&2; fail "expected success, got $status"; }
ORIGIN7="$REPO7.origin.git"
git -C "$ORIGIN7" rev-parse "cli-v0.5.1" >/dev/null \
|| fail "origin missing tag cli-v0.5.1"
ORIGIN_HEAD="$(git -C "$ORIGIN7" rev-parse main)"
LOCAL_HEAD="$(git -C "$REPO7" rev-parse main)"
[[ "$ORIGIN_HEAD" == "$LOCAL_HEAD" ]] \
|| fail "origin/main HEAD did not advance to match local main"
TAG_COMMIT="$(git -C "$ORIGIN7" rev-parse "cli-v0.5.1^{commit}")"
[[ "$TAG_COMMIT" == "$ORIGIN_HEAD" ]] \
|| fail "origin tag cli-v0.5.1 does not point to origin/main HEAD"
grep -F "release triggered" "$REPO7/stdout.log" >/dev/null
# ----------------------------------------------------------------------------
# Test 8: push failure → script exits non-zero (commit + tag stay local)
#
# Use a git wrapper that fails only on `git push`, so pull/fetch succeed
# but the final push does not.
# ----------------------------------------------------------------------------
echo "[test] push failure surfaces error"
REPO8="$(new_tmp)"
init_repo "$REPO8" "0.6.0"
mkdir -p "$REPO8/bin-git"
cat >"$REPO8/bin-git/git" <<'WRAPPER'
#!/usr/bin/env bash
if [[ "$*" == *"push"* ]]; then
echo "fatal: could not read from remote repository." >&2
exit 128
# Returned to main with branch deleted locally.
[[ "$(git -C "$REPO7" rev-parse --abbrev-ref HEAD)" == "main" ]] \
|| fail "not back on main after success"
if git -C "$REPO7" rev-parse -q --verify "refs/heads/release/cli-v0.5.1" >/dev/null 2>&1; then
fail "local release branch should be deleted after success"
fi
exec /usr/bin/git "$@"
WRAPPER
chmod +x "$REPO8/bin-git/git"
status=0
printf 'y\n' | env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
REPO_ROOT="$REPO8" PATH="$REPO8/bin-git:$REPO8/bin:$PATH" \
bash "$REPO8/scripts/publish-cli.sh" "patch" \
>"$REPO8/stdout.log" 2>"$REPO8/stderr.log" || status=$?
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when push fails"
git -C "$REPO8" rev-parse "cli-v0.6.1" >/dev/null \
|| fail "local tag cli-v0.6.1 missing after push failure"
git -C "$REPO8" log --oneline | grep -F "chore(cli): bump version to 0.6.1" >/dev/null \
|| fail "local bump commit missing after push failure"
# Branch on origin with bump commit.
ORIGIN7="$REPO7.origin.git"
git -C "$ORIGIN7" rev-parse "refs/heads/release/cli-v0.5.1" >/dev/null \
|| fail "origin missing release branch"
TIP_MSG="$(git -C "$ORIGIN7" log -1 --format=%s "refs/heads/release/cli-v0.5.1")"
[[ "$TIP_MSG" == "chore(cli): bump version to 0.5.1" ]] \
|| fail "unexpected commit message on release branch: $TIP_MSG"
# pkg-info.ts AND package.json both updated in the bump commit.
git -C "$ORIGIN7" show "refs/heads/release/cli-v0.5.1:cli/package.json" \
| grep -F '"version": "0.5.1"' >/dev/null \
|| fail "package.json on branch missing new version"
git -C "$ORIGIN7" show "refs/heads/release/cli-v0.5.1:cli/src/generated/pkg-info.ts" \
| grep -F 'PKG_VERSION = "0.5.1"' >/dev/null \
|| fail "pkg-info.ts on branch missing new version"
# gh pr create was invoked with expected args.
[[ -f "$REPO7/gh-stub.log" ]] || fail "gh stub log missing"
grep -F "pr create" "$REPO7/gh-stub.log" >/dev/null \
|| fail "gh pr create not invoked"
grep -F -- "--head release/cli-v0.5.1" "$REPO7/gh-stub.log" >/dev/null \
|| fail "gh pr create missing --head"
grep -F -- "--base main" "$REPO7/gh-stub.log" >/dev/null \
|| fail "gh pr create missing --base"
# ----------------------------------------------------------------------------
# Test 9: unpushed detection catches "branch pushed, tag not pushed" state
#
# Simulate: commit is on origin/main, local tag exists but was never pushed.
# The old `--no-merged` approach would miss this because the tagged commit is
# already reachable from origin/main. The new ls-remote approach catches it.
# Test 8: baseline from latest cli-v* tag (not package.json)
# ----------------------------------------------------------------------------
echo "[test] unpushed detection catches tag-only failure"
echo "[test] baseline taken from latest cli-v* tag"
REPO8="$(new_tmp)"
init_repo "$REPO8" "0.1.0" "cli-v0.2.0"
status="$(run_publish "$REPO8" "patch" $'y\n')"
[[ "$status" -eq 0 ]] || { cat "$REPO8/stderr.log" >&2; fail "expected success, got $status"; }
grep -F "baseline: 0.2.0 (from origin cli-v0.2.0)" "$REPO8/stdout.log" >/dev/null \
|| fail "baseline log missing — version computed from wrong source"
git -C "$REPO8.origin.git" rev-parse "refs/heads/release/cli-v0.2.1" >/dev/null \
|| fail "expected branch release/cli-v0.2.1 on origin"
# ----------------------------------------------------------------------------
# Test 9: gh pr create fails → branch stays on origin, recovery printed
# ----------------------------------------------------------------------------
echo "[test] gh pr create failure triggers pushed-stage cleanup"
REPO9="$(new_tmp)"
init_repo "$REPO9" "0.7.0"
cd "$REPO9/cli"
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = '0.7.1';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
cd "$REPO9"
git -C "$REPO9" add cli/package.json
git -C "$REPO9" commit -q -m "chore(cli): bump version to 0.7.1"
git -C "$REPO9" tag "cli-v0.7.1"
git -C "$REPO9" push -q origin main
# Tag NOT pushed — simulates atomic push partial failure recovery
status="$(run_publish "$REPO9" "patch")"
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when unpushed tag detected"
grep -F "Unpushed tags" "$REPO9/stderr.log" >/dev/null \
|| { cat "$REPO9/stderr.log" >&2; fail "expected unpushed tag warning"; }
grep -F "cli-v0.7.1" "$REPO9/stderr.log" >/dev/null \
|| fail "expected cli-v0.7.1 in unpushed tag warning"
init_repo "$REPO9" "0.6.0"
status="$(run_publish "$REPO9" "patch" $'y\n' "GH_FAIL_AT=pr-create")"
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when gh pr create fails"
# Recovery instructions emitted by the trap.
grep -F "release branch 'release/cli-v0.6.1' was pushed but PR creation failed" \
"$REPO9/stderr.log" >/dev/null \
|| { cat "$REPO9/stderr.log" >&2; fail "missing pushed-stage recovery message"; }
grep -F "git push origin --delete release/cli-v0.6.1" "$REPO9/stderr.log" >/dev/null \
|| fail "missing rollback hint in recovery message"
# Branch is on origin (push succeeded before gh failed).
git -C "$REPO9.origin.git" rev-parse "refs/heads/release/cli-v0.6.1" >/dev/null \
|| fail "expected branch on origin after push, before failed PR"
# ----------------------------------------------------------------------------
# Test 10: --atomic flag is actually passed to git push
#
# Use a git wrapper to capture the push command and verify --atomic is present.
# Test 10: push fails → committed-stage cleanup, branch deleted locally
# ----------------------------------------------------------------------------
echo "[test] push uses --atomic flag"
echo "[test] push failure rolls back local branch"
REPO10="$(new_tmp)"
init_repo "$REPO10" "0.8.0"
init_repo "$REPO10" "0.7.0"
mkdir -p "$REPO10/bin-git"
cat >"$REPO10/bin-git/git" <<'WRAPPER'
#!/usr/bin/env bash
if [[ "$*" == *"push"* ]]; then
echo "GIT_PUSH_ARGS: $*" >> "$REPO_ROOT/git-push-log.txt"
if [[ "${1:-}" == "-C" && "${3:-}" == "push" ]]; then
echo "fatal: forced push failure" >&2
exit 128
fi
exec /usr/bin/git "$@"
WRAPPER
@ -326,10 +386,59 @@ chmod +x "$REPO10/bin-git/git"
status=0
printf 'y\n' | env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE \
REPO_ROOT="$REPO10" PATH="$REPO10/bin-git:$REPO10/bin:$PATH" \
GH_LOG_FILE="$REPO10/gh-stub.log" \
bash "$REPO10/scripts/publish-cli.sh" "patch" \
>"$REPO10/stdout.log" 2>"$REPO10/stderr.log" || status=$?
[[ "$status" -eq 0 ]] || { cat "$REPO10/stderr.log" >&2; fail "expected success, got $status"; }
grep -F -- "--atomic" "$REPO10/git-push-log.txt" >/dev/null \
|| { cat "$REPO10/git-push-log.txt" >&2; fail "git push did not include --atomic flag"; }
[[ "$status" -ne 0 ]] || fail "expected non-zero exit when push fails"
grep -F "rolling back local branch" "$REPO10/stderr.log" >/dev/null \
|| { cat "$REPO10/stderr.log" >&2; fail "missing committed-stage rollback message"; }
# Back on main, release branch deleted.
[[ "$(git -C "$REPO10" rev-parse --abbrev-ref HEAD)" == "main" ]] \
|| fail "not on main after push failure cleanup"
if git -C "$REPO10" rev-parse -q --verify "refs/heads/release/cli-v0.7.1" >/dev/null 2>&1; then
fail "local release branch should be deleted after push failure"
fi
# Origin must NOT have the branch (push was blocked).
if git -C "$REPO10.origin.git" rev-parse -q --verify "refs/heads/release/cli-v0.7.1" >/dev/null 2>&1; then
fail "origin should not have branch when push failed"
fi
# gh pr create must NOT have run.
if [[ -f "$REPO10/gh-stub.log" ]] && grep -F "pr create" "$REPO10/gh-stub.log" >/dev/null; then
fail "gh pr create ran despite push failure"
fi
# ----------------------------------------------------------------------------
# Test 11: prerelease tag → abort with clear message
# ----------------------------------------------------------------------------
echo "[test] prerelease tag aborts with message"
REPO11="$(new_tmp)"
init_repo "$REPO11" "0.1.0" "cli-v0.2.0-rc.1"
status="$(run_publish "$REPO11" "patch")"
[[ "$status" -ne 0 ]] || fail "expected non-zero exit for prerelease tag"
grep -F "contains prerelease suffix" "$REPO11/stderr.log" >/dev/null \
|| { cat "$REPO11/stderr.log" >&2; fail "expected prerelease rejection message"; }
grep -F "only supports pure X.Y.Z" "$REPO11/stderr.log" >/dev/null \
|| fail "expected X.Y.Z hint in error"
# ----------------------------------------------------------------------------
# Test 12: local-only orphan tag must not influence baseline
# ----------------------------------------------------------------------------
# Simulates the failure mode where `git push origin cli-vX.Y.Z` failed after
# `git tag cli-vX.Y.Z origin/main` succeeded locally. The orphan tag exists
# locally but not on origin. Baseline must come from origin only.
echo "[test] local-only orphan tag ignored for baseline"
REPO12="$(new_tmp)"
init_repo "$REPO12" "0.1.0" "cli-v0.1.0"
git -C "$REPO12" tag "cli-v0.3.0"
status="$(run_publish "$REPO12" "patch" $'y\n')"
[[ "$status" -eq 0 ]] || { cat "$REPO12/stderr.log" >&2; fail "expected success, got $status"; }
grep -F "baseline: 0.1.0 (from origin cli-v0.1.0)" "$REPO12/stdout.log" >/dev/null \
|| { cat "$REPO12/stdout.log" >&2; fail "baseline must come from origin (0.1.0), not local orphan (0.3.0)"; }
git -C "$REPO12.origin.git" rev-parse "refs/heads/release/cli-v0.1.1" >/dev/null \
|| fail "expected branch release/cli-v0.1.1 on origin (orphan should not have shifted target to 0.3.1)"
echo "all tests passed"

View file

@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/runtime.sh"
TMP_DIRS=()
cleanup() {
local d
for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do
rm -rf "$d"
done
}
trap cleanup EXIT
new_tmp() {
local d
d="$(mktemp -d)"
TMP_DIRS+=("$d")
echo "$d"
}
fail() {
echo "FAIL: $*" >&2
exit 1
}
install_fake_tools() {
local bin_dir="$1"
mkdir -p "$bin_dir"
cat >"$bin_dir/docker" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "${DOCKER_LOG:?DOCKER_LOG is required}"
if [[ "${1:-}" == "compose" && "${2:-}" == "version" ]]; then
exit 0
fi
exit 0
EOF
chmod +x "$bin_dir/docker"
cat >"$bin_dir/openssl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "rand" && "${2:-}" == "-hex" && "${3:-}" == "32" ]]; then
printf '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n'
exit 0
fi
echo "unsupported openssl args: $*" >&2
exit 1
EOF
chmod +x "$bin_dir/openssl"
}
run_runtime() {
local home="$1"
local bin_dir="$2"
local stdout="$3"
shift 3
DOCKER_LOG="$home/docker.log" \
SKILLHUB_HOME="$home" \
SKILLHUB_RAW_BASE="file://$REPO_ROOT" \
PATH="$bin_dir:$PATH" \
sh "$SCRIPT" up --version sha-test --public-url http://localhost "$@" >"$stdout"
}
file_mode() {
local file="$1"
if stat -c %a "$file" >/dev/null 2>&1; then
stat -c %a "$file"
else
stat -f %Lp "$file"
fi
}
tmp="$(new_tmp)"
bin_dir="$tmp/bin"
install_fake_tools "$bin_dir"
home_generated="$tmp/generated"
stdout_generated="$tmp/generated.out"
mkdir -p "$home_generated"
run_runtime "$home_generated" "$bin_dir" "$stdout_generated"
generated_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_generated/.env.release" | cut -d= -f2-)"
[[ "$generated_secret" == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ]] \
|| fail "runtime should generate a persisted anonymous download secret"
[[ "$(file_mode "$home_generated/.env.release")" == "600" ]] \
|| fail "runtime env file must be readable only by the owner"
grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_generated" \
|| fail "runtime should explain that it generated the secret"
if grep -Fq "$generated_secret" "$stdout_generated"; then
fail "runtime must not print the generated secret value"
fi
home_preserved="$tmp/preserved"
stdout_preserved="$tmp/preserved.out"
mkdir -p "$home_preserved"
cat >"$home_preserved/.env.release" <<'EOF'
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=already-valid-runtime-secret-32-bytes
EOF
run_runtime "$home_preserved" "$bin_dir" "$stdout_preserved"
preserved_secret="$(grep '^SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=' "$home_preserved/.env.release" | cut -d= -f2-)"
[[ "$preserved_secret" == "already-valid-runtime-secret-32-bytes" ]] \
|| fail "runtime must preserve an existing valid anonymous download secret"
[[ "$(file_mode "$home_preserved/.env.release")" == "600" ]] \
|| fail "runtime env file must remain owner-readable only when an existing secret is preserved"
if grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_preserved"; then
fail "runtime must not regenerate an existing valid secret"
fi
home_no_scanner="$tmp/no-scanner"
stdout_no_scanner="$tmp/no-scanner.out"
mkdir -p "$home_no_scanner"
run_runtime "$home_no_scanner" "$bin_dir" "$stdout_no_scanner" --no-scanner
grep -Fq "SKILLHUB_SECURITY_SCANNER_ENABLED=false" "$home_no_scanner/.env.release" \
|| fail "runtime should persist scanner disabled state for --no-scanner"
grep -Fq -- "up -d --no-deps --scale skill-scanner=0 server web" "$home_no_scanner/docker.log" \
|| fail "runtime --no-scanner should start server/web without waiting on scanner dependencies"
echo "runtime-secret-test passed"

View file

@ -0,0 +1,232 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCANNER_DIR="$REPO_ROOT/scanner"
TMP_DIRS=()
cleanup() {
local status=$?
local d
for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do
rm -rf "$d"
done
exit "$status"
}
trap cleanup EXIT
new_tmp() {
local d
d="$(mktemp -d)"
TMP_DIRS+=("$d")
echo "$d"
}
fail() {
echo "FAIL: $*" >&2
exit 1
}
tmp="$(new_tmp)"
skill_dir="$tmp/skill"
mkdir -p "$skill_dir/demo-skill"
cat >"$skill_dir/demo-skill/SKILL.md" <<'EOF'
---
name: demo-skill
description: Minimal valid skill used for scanner integration coverage.
license: Apache-2.0
---
This is a harmless demo skill used for scanner integration testing.
EOF
cat >"$skill_dir/demo-skill/run.sh" <<'EOF'
#!/usr/bin/env sh
echo "demo"
EOF
chmod +x "$skill_dir/demo-skill/run.sh"
IMAGE_TAG="skillhub-scanner-llm-base-url-test:$(date +%s)"
docker build --no-cache -t "$IMAGE_TAG" "$SCANNER_DIR" >/dev/null
docker run --rm -i \
-v "$skill_dir:/work/skill:ro" \
--entrypoint python \
"$IMAGE_TAG" - <<'PY'
import asyncio
from datetime import datetime, timezone
import http.server
import io
import inspect
import json
import os
from pathlib import Path
import threading
import urllib.request
import zipfile
from fastapi.params import Query
from skill_scanner.core.models import ScanResult
import skill_scanner.api.router as router
signature = inspect.signature(router.scan_uploaded_skill)
if not isinstance(signature.parameters["use_llm"].default, Query):
raise SystemExit("scan-upload use_llm should remain a Query parameter")
if not isinstance(signature.parameters["llm_provider"].default, Query):
raise SystemExit("scan-upload llm_provider should remain a Query parameter")
state = {"base_urls": [], "paths": []}
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args): # noqa: A003
return
def do_POST(self): # noqa: N802
length = int(self.headers.get("content-length", "0"))
self.rfile.read(length)
state["paths"].append(self.path)
payload = json.dumps(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": int(datetime.now(timezone.utc).timestamp()),
"model": "local-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "No findings."},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
target_base_url = f"http://127.0.0.1:{server.server_port}/v1"
os.environ["SKILL_SCANNER_LLM_BASE_URL"] = target_base_url
os.environ["SKILL_SCANNER_LLM_MODEL"] = "test-model"
class FakeStaticAnalyzer:
pass
class FakeLLMAnalyzer:
def __init__(self, model=None, provider=None, base_url=None):
self.model = model
self.provider = provider
self.base_url = base_url
state["base_urls"].append(base_url)
def analyze(self, skill_path):
request = urllib.request.Request(
self.base_url + "/chat/completions",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
response.read()
class FakeSkillScanner:
def __init__(self, analyzers):
self.analyzers = analyzers
def scan_skill(self, skill_path):
for analyzer in self.analyzers:
analyze = getattr(analyzer, "analyze", None)
if callable(analyze):
analyze(skill_path)
return ScanResult(
skill_name="demo-skill",
skill_directory=str(skill_path),
findings=[],
scan_duration_seconds=0.05,
analyzers_used=["fake-llm"],
timestamp=datetime.now(timezone.utc),
)
router.StaticAnalyzer = FakeStaticAnalyzer
router.LLMAnalyzer = FakeLLMAnalyzer
router.SkillScanner = FakeSkillScanner
router.LLM_AVAILABLE = True
request = router.ScanRequest(
skill_directory="/work/skill/demo-skill",
use_llm=True,
llm_provider="openai",
use_behavioral=False,
use_aidefense=False,
aidefense_api_key=None,
)
def build_skill_archive_bytes(skill_root: str) -> bytes:
skill_path = Path(skill_root)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in skill_path.rglob("*"):
if path.is_file():
archive.writestr(str(path.relative_to(skill_path.parent)), path.read_bytes())
return buffer.getvalue()
class FakeUploadFile:
def __init__(self, filename: str, payload: bytes):
self.filename = filename
self._payload = payload
async def read(self) -> bytes:
return self._payload
try:
direct_response = asyncio.run(router.scan_skill(request))
upload_response = asyncio.run(
router.scan_uploaded_skill(
file=FakeUploadFile("demo-skill.zip", build_skill_archive_bytes("/work/skill/demo-skill")),
use_llm=True,
llm_provider="openai",
use_behavioral=False,
use_aidefense=False,
aidefense_api_key=None,
)
)
finally:
server.shutdown()
thread.join(timeout=5)
if not getattr(direct_response, "scan_id", None):
raise SystemExit("scan_skill should still return a scan response")
if not getattr(upload_response, "scan_id", None):
raise SystemExit("scan_uploaded_skill should still return a scan response")
if len(state["base_urls"]) != 2:
raise SystemExit(f"expected two LLM analyzer constructions, got {len(state['base_urls'])}")
if any(base_url != target_base_url for base_url in state["base_urls"]):
raise SystemExit(f"expected every base_url to be {target_base_url}, got {state['base_urls']}")
if len(state["paths"]) != 2:
raise SystemExit(f"expected two LLM requests, got {state['paths']}")
if not all(path.startswith("/v1/") for path in state["paths"]):
raise SystemExit(f"expected every request path to start with /v1/, got {state['paths']}")
PY
grep -Fq "name: SKILL_SCANNER_LLM_BASE_URL" "$REPO_ROOT/deploy/k8s/base/scanner-deployment.yaml" \
|| fail "Kubernetes scanner deployment must expose SKILL_SCANNER_LLM_BASE_URL"
grep -Fq "skill-scanner-llm-base-url" "$REPO_ROOT/deploy/k8s/base/secret.yaml.example" \
|| fail "Kubernetes secret example must document skill-scanner-llm-base-url"
echo "scanner-llm-base-url-test passed"

View file

@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/validate-release-config.sh"
TMP_DIRS=()
cleanup() {
local d
for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do
rm -rf "$d"
done
}
trap cleanup EXIT
new_tmp() {
local d
d="$(mktemp -d)"
TMP_DIRS+=("$d")
echo "$d"
}
fail() {
echo "FAIL: $*" >&2
exit 1
}
write_env() {
local file="$1"
local secret="${2:-}"
local include_secret="${3:-yes}"
cat >"$file" <<EOF
SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com
POSTGRES_DB=skillhub
POSTGRES_USER=skillhub
POSTGRES_PASSWORD=strong-postgres-password
SESSION_COOKIE_SECURE=true
BOOTSTRAP_ADMIN_ENABLED=false
SKILLHUB_STORAGE_PROVIDER=s3
SKILLHUB_STORAGE_S3_ENDPOINT=https://storage.example.com
SKILLHUB_STORAGE_S3_BUCKET=skillhub
SKILLHUB_STORAGE_S3_ACCESS_KEY=release-access-key
SKILLHUB_STORAGE_S3_SECRET_KEY=release-secret-key
SKILLHUB_STORAGE_S3_REGION=us-east-1
SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE=false
SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET=false
EOF
if [[ "$include_secret" == "yes" ]]; then
printf 'SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=%s\n' "$secret" >>"$file"
fi
}
expect_fail() {
local file="$1"
local expected="$2"
local output
if output="$("$SCRIPT" "$file" 2>&1)"; then
fail "expected validation to fail for $file"
fi
if [[ "$output" != *"$expected"* ]]; then
fail "expected output to contain '$expected', got: $output"
fi
}
tmp="$(new_tmp)"
valid_env="$tmp/valid.env"
write_env "$valid_env" "release-download-secret-32-bytes-minimum"
"$SCRIPT" "$valid_env" >/dev/null
missing_env="$tmp/missing.env"
write_env "$missing_env" "" no
expect_fail "$missing_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required"
placeholder_env="$tmp/placeholder.env"
write_env "$placeholder_env" "change-me-in-production"
expect_fail "$placeholder_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET still uses placeholder/default value"
short_env="$tmp/short.env"
write_env "$short_env" "too-short"
expect_fail "$short_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters"
draft_env="$tmp/draft.env"
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=*)
printf '%s\n' "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=release-download-secret-32-bytes-minimum"
;;
*)
printf '%s\n' "$line"
;;
esac
done <"$REPO_ROOT/.env.release.draft" >"$draft_env"
expect_fail "$draft_env" "POSTGRES_PASSWORD"
echo "validate-release-config-test passed"

View file

@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SECURITY_WORKFLOW="$REPO_ROOT/.github/workflows/security.yml"
PR_SCRIPTS_WORKFLOW="$REPO_ROOT/.github/workflows/pr-scripts.yml"
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_pr_workflow_hardened() {
local workflow="$1"
grep -Eq '^permissions:[[:space:]]*$' "$workflow" \
|| fail "$workflow must declare top-level permissions"
grep -Eq '^[[:space:]]+contents:[[:space:]]+read[[:space:]]*$' "$workflow" \
|| fail "$workflow GITHUB_TOKEN permissions must include contents: read"
grep -Fq 'persist-credentials: false' "$workflow" \
|| fail "$workflow checkout steps must not persist credentials"
}
[[ -f "$SECURITY_WORKFLOW" ]] || fail ".github/workflows/security.yml is required"
assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-cli.yml"
assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-e2e.yml"
assert_pr_workflow_hardened "$REPO_ROOT/.github/workflows/pr-tests.yml"
assert_pr_workflow_hardened "$PR_SCRIPTS_WORKFLOW"
assert_pr_workflow_hardened "$SECURITY_WORKFLOW"
grep -Fq 'actions/dependency-review-action' "$SECURITY_WORKFLOW" \
|| fail "security workflow must run dependency review"
grep -Fq 'github/codeql-action/init' "$SECURITY_WORKFLOW" \
|| fail "security workflow must initialize CodeQL"
grep -Fq 'cd server && ./mvnw -q -DskipTests package' "$SECURITY_WORKFLOW" \
|| fail "security workflow must build Java with the server Maven wrapper"
grep -Fq 'security-events: write' "$SECURITY_WORKFLOW" \
|| fail "security workflow must grant SARIF upload permission"
python_source="$(find "$REPO_ROOT" \
\( -path "$REPO_ROOT/.git" -o -path '*/node_modules' -o -path '*/.venv' \) -prune -o \
-type f -name '*.py' -print -quit)"
if [[ -n "$python_source" ]]; then
grep -Fq 'language: python' "$SECURITY_WORKFLOW" \
|| fail "security workflow must run Python CodeQL when Python source exists"
else
! grep -Fq 'language: python' "$SECURITY_WORKFLOW" \
|| fail "security workflow must not run Python CodeQL without Python source"
fi
grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when security workflow changes"
grep -Fq '.github/workflows/pr-cli.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when PR CLI workflow changes"
grep -Fq '.github/workflows/pr-e2e.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when PR E2E workflow changes"
grep -Fq '.github/workflows/pr-tests.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when PR Tests workflow changes"
grep -Fq "'**/*.py'" "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when Python source changes"
grep -Fq '.env.release.example' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when release env example changes"
grep -Fq '.env.release.draft' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when release env draft changes"
grep -Fq 'compose.release.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when release compose changes"
grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run validate-release-config-test"
grep -Fq 'bash scripts/tests/runtime-secret-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run runtime-secret-test"
grep -Fq 'bash scripts/tests/dev-web-host-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run dev-web-host-test"
grep -Fq 'bash scripts/tests/workflow-security-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run workflow-security-test"
echo "workflow-security-test passed"

View file

@ -52,6 +52,23 @@ reject_values() {
done
}
reject_patterns() {
var_name="$1"
shift
eval "var_value=\${$var_name:-}"
if [ -z "$var_value" ]; then
return 0
fi
for pattern in "$@"; do
case "$var_value" in
$pattern)
error "$var_name still uses placeholder/default pattern: $var_value"
return 0
;;
esac
done
}
validate_url() {
var_name="$1"
eval "var_value=\${$var_name:-}"
@ -97,17 +114,40 @@ validate_port() {
esac
}
validate_min_length() {
var_name="$1"
min_length="$2"
eval "var_value=\${$var_name:-}"
if [ -z "$var_value" ]; then
return 0
fi
if [ "${#var_value}" -lt "$min_length" ]; then
error "$var_name must be at least $min_length characters"
fi
}
require_non_empty SKILLHUB_PUBLIC_BASE_URL
validate_url SKILLHUB_PUBLIC_BASE_URL
validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL
require_non_empty SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET
reject_values SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "change-me-in-production" "replace-me" "replace-with-random-download-secret-32-bytes"
reject_patterns SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "TODO_*" "todo_*" "replace*"
validate_min_length SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET 32
reject_values POSTGRES_PASSWORD "change-this-postgres-password" "skillhub_demo" "skillhub_dev"
reject_patterns POSTGRES_PASSWORD "TODO_*" "todo_*"
reject_values BOOTSTRAP_ADMIN_PASSWORD "replace-this-admin-password" "ChangeMe!2026" "Admin@2026"
reject_patterns BOOTSTRAP_ADMIN_PASSWORD "TODO_*" "todo_*" "replace*"
if [ "${BOOTSTRAP_ADMIN_ENABLED:-false}" = "true" ]; then
require_non_empty BOOTSTRAP_ADMIN_PASSWORD
fi
reject_values SKILLHUB_STORAGE_S3_ACCESS_KEY "replace-me"
reject_values SKILLHUB_STORAGE_S3_SECRET_KEY "replace-me"
reject_patterns SKILLHUB_STORAGE_S3_ACCESS_KEY "TODO_*" "todo_*" "replace*"
reject_patterns SKILLHUB_STORAGE_S3_SECRET_KEY "TODO_*" "todo_*" "replace*"
reject_patterns SPRING_MAIL_USERNAME "TODO_*" "todo_*" "replace*"
reject_patterns SPRING_MAIL_PASSWORD "TODO_*" "todo_*" "replace*"
validate_boolean SESSION_COOKIE_SECURE
validate_boolean BOOTSTRAP_ADMIN_ENABLED

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub;
import com.iflytek.skillhub.bootstrap.BuiltinSkillProperties;
import com.iflytek.skillhub.config.ProfileFieldPolicyProperties;
import com.iflytek.skillhub.config.ProfileModerationProperties;
import org.springframework.boot.SpringApplication;
@ -10,7 +11,11 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
* Main Spring Boot entry point for the SkillHub backend application.
*/
@SpringBootApplication
@EnableConfigurationProperties({ProfileModerationProperties.class, ProfileFieldPolicyProperties.class})
@EnableConfigurationProperties({
BuiltinSkillProperties.class,
ProfileModerationProperties.class,
ProfileFieldPolicyProperties.class
})
public class SkillhubApplication {
public static void main(String[] args) {
SpringApplication.run(SkillhubApplication.class, args);

View file

@ -0,0 +1,439 @@
package com.iflytek.skillhub.bootstrap;
import com.iflytek.skillhub.bootstrap.BuiltinSkillManifestLoader.ManifestItem;
import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.SlugValidator;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillFile;
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* Best-effort startup synchronizer for remotely hosted built-in skill packages.
*/
@Component
public class BuiltinSkillInitializer {
static final String GLOBAL_NAMESPACE = "global";
static final String SYSTEM_PUBLISHER_ID = "builtin-skill-publisher";
private static final Logger log = LoggerFactory.getLogger(BuiltinSkillInitializer.class);
private static final Set<String> SYSTEM_PUBLISHER_ROLES = Set.of("SUPER_ADMIN");
private static final boolean CONFIRM_BUILTIN_PUBLISH_WARNINGS = true;
private final BuiltinSkillProperties properties;
private final BuiltinSkillManifestLoader manifestLoader;
private final BuiltinSkillRemotePackageDownloader downloader;
private final BuiltinSkillPackageExtractor extractor;
private final SkillMetadataParser metadataParser;
private final NamespaceRepository namespaceRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
private final UserAccountRepository userAccountRepository;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillFileRepository skillFileRepository;
private final SkillPublishService skillPublishService;
public BuiltinSkillInitializer(
BuiltinSkillProperties properties,
BuiltinSkillManifestLoader manifestLoader,
BuiltinSkillRemotePackageDownloader downloader,
BuiltinSkillPackageExtractor extractor,
SkillMetadataParser metadataParser,
NamespaceRepository namespaceRepository,
NamespaceMemberRepository namespaceMemberRepository,
UserAccountRepository userAccountRepository,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillFileRepository skillFileRepository,
SkillPublishService skillPublishService) {
this.properties = properties;
this.manifestLoader = manifestLoader;
this.downloader = downloader;
this.extractor = extractor;
this.metadataParser = metadataParser;
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.userAccountRepository = userAccountRepository;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillFileRepository = skillFileRepository;
this.skillPublishService = skillPublishService;
}
@EventListener(ApplicationReadyEvent.class)
@Async("skillhubEventExecutor")
public void synchronizeAfterApplicationReady() {
synchronize();
}
void synchronize() {
if (!properties.isEnabled()) {
log.info("Built-in skill startup synchronization is disabled");
return;
}
Optional<Namespace> namespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE);
if (namespace.isEmpty()) {
log.warn("Global namespace '{}' does not exist, skipping built-in skill synchronization",
GLOBAL_NAMESPACE);
return;
}
List<ManifestItem> items = manifestLoader.load();
if (items.isEmpty()) {
log.info("No built-in skill manifest items to synchronize");
return;
}
try {
if (!ensureSystemPublisher(namespace.get())) {
return;
}
} catch (RuntimeException exception) {
log.error("Failed to initialize built-in skill system publisher, skipping synchronization: {}",
exception.getMessage(), exception);
return;
}
int published = 0;
int idempotentSkipped = 0;
int conflictSkipped = 0;
int failed = 0;
for (ManifestItem item : items) {
try {
SyncOutcome outcome = syncItem(namespace.get(), item);
switch (outcome) {
case PUBLISHED -> published++;
case IDEMPOTENT_SKIPPED -> idempotentSkipped++;
case CONFLICT_SKIPPED -> conflictSkipped++;
case FAILED -> failed++;
}
} catch (Exception exception) {
failed++;
log.error(
"Failed to synchronize built-in skill slug={} version={}: {}",
item.slug(),
item.version(),
exception.getMessage(),
exception
);
}
}
log.info(
"Built-in skill synchronization finished: total={}, published={}, idempotentSkipped={}, conflictSkipped={}, failed={}",
items.size(),
published,
idempotentSkipped,
conflictSkipped,
failed
);
}
private boolean ensureSystemPublisher(Namespace namespace) {
Optional<UserAccount> existingPublisher = userAccountRepository.findById(SYSTEM_PUBLISHER_ID);
UserAccount publisher;
if (existingPublisher.isPresent()) {
publisher = existingPublisher.get();
} else {
publisher = UserAccount.systemAccount(
SYSTEM_PUBLISHER_ID,
"Built-in Skill Publisher",
null,
null
);
userAccountRepository.save(publisher);
}
if (!publisher.isSystemAccount()) {
log.error("Built-in skill publisher account id '{}' already exists but is not a system account; "
+ "skipping built-in skill synchronization", SYSTEM_PUBLISHER_ID);
return false;
}
if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), SYSTEM_PUBLISHER_ID).isEmpty()) {
namespaceMemberRepository.save(new NamespaceMember(
namespace.getId(),
SYSTEM_PUBLISHER_ID,
NamespaceRole.OWNER
));
}
return true;
}
private SyncOutcome syncItem(Namespace namespace, ManifestItem item) throws Exception {
Optional<SyncOutcome> skipBeforeDownload = shouldSkipBeforeDownload(namespace.getId(), item);
if (skipBeforeDownload.isPresent()) {
return skipBeforeDownload.get();
}
Optional<URI> packageUri = parsePackageUri(item);
if (packageUri.isEmpty()) {
return SyncOutcome.FAILED;
}
Optional<byte[]> packageBytes = downloader.download(packageUri.get());
if (packageBytes.isEmpty()) {
log.warn("Skipping built-in skill slug={} version={} because package download failed",
item.slug(), item.version());
return SyncOutcome.FAILED;
}
SkillPackageArchiveExtractor.ExtractionResult extractionResult = extractor.extract(packageBytes.get());
List<PackageEntry> entries = extractionResult.entries();
SkillMetadata metadata = parseSkillMetadata(entries);
String packageSlug = SlugValidator.slugify(metadata.name());
if (!item.slug().equals(packageSlug)) {
log.warn(
"Skipping built-in skill manifest slug={} version={} because package slug is {}",
item.slug(),
item.version(),
packageSlug
);
return SyncOutcome.FAILED;
}
if (!item.version().equals(metadata.version())) {
log.warn(
"Skipping built-in skill slug={} because manifest version {} does not match package version {}",
item.slug(),
item.version(),
metadata.version()
);
return SyncOutcome.FAILED;
}
Optional<SyncOutcome> skipExisting = shouldSkipExisting(namespace.getId(), item, entries);
if (skipExisting.isPresent()) {
return skipExisting.get();
}
try {
skillPublishService.publishFromEntries(
GLOBAL_NAMESPACE,
entries,
SYSTEM_PUBLISHER_ID,
SkillVisibility.PUBLIC,
SYSTEM_PUBLISHER_ROLES,
CONFIRM_BUILTIN_PUBLISH_WARNINGS
);
log.info("Published built-in skill slug={} version={} to @{}",
item.slug(), item.version(), GLOBAL_NAMESPACE);
return SyncOutcome.PUBLISHED;
} catch (RuntimeException exception) {
if (isAlreadyPublishedWithSameFingerprint(namespace.getId(), item, entries)) {
log.info("Built-in skill slug={} version={} was published concurrently, skipping",
item.slug(), item.version());
return SyncOutcome.IDEMPOTENT_SKIPPED;
}
log.error("Failed to publish built-in skill slug={} version={}: {}",
item.slug(), item.version(), exception.getMessage(), exception);
return SyncOutcome.FAILED;
}
}
private Optional<URI> parsePackageUri(ManifestItem item) {
try {
return Optional.of(URI.create(item.url()));
} catch (IllegalArgumentException exception) {
log.warn("Skipping built-in skill slug={} version={} because URL is not allowed: {}",
item.slug(), item.version(), exception.getMessage());
return Optional.empty();
}
}
private SkillMetadata parseSkillMetadata(List<PackageEntry> entries) {
PackageEntry skillMd = entries.stream()
.filter(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"Built-in skill package must contain " + SkillPackagePolicy.SKILL_MD_PATH));
return metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8));
}
private Optional<SyncOutcome> shouldSkipBeforeDownload(Long namespaceId, ManifestItem item) {
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug());
if (hasOtherOwnerConflict(existingSkills)) {
log.warn("Skipping built-in skill slug={} before download because the slug already belongs to another user",
item.slug());
return Optional.of(SyncOutcome.CONFLICT_SKIPPED);
}
Optional<Skill> builtinSkill = existingSkills.stream()
.filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()))
.findFirst();
if (builtinSkill.isEmpty()) {
return Optional.empty();
}
Optional<SkillVersion> existingVersion = skillVersionRepository
.findBySkillIdAndVersion(builtinSkill.get().getId(), item.version());
if (existingVersion.isEmpty()) {
return Optional.empty();
}
SkillVersion version = existingVersion.get();
if (version.getStatus() == SkillVersionStatus.PUBLISHED) {
log.info("Skipping built-in skill slug={} version={} before download because it is already published",
item.slug(), item.version());
} else {
log.info("Skipping built-in skill slug={} version={} before download because existing version status is {}",
item.slug(), item.version(), version.getStatus());
}
return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED);
}
private Optional<SyncOutcome> shouldSkipExisting(Long namespaceId, ManifestItem item, List<PackageEntry> entries) {
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug());
if (hasOtherOwnerConflict(existingSkills)) {
log.warn("Skipping built-in skill slug={} because the slug already belongs to another user",
item.slug());
return Optional.of(SyncOutcome.CONFLICT_SKIPPED);
}
Optional<Skill> builtinSkill = existingSkills.stream()
.filter(skill -> SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()))
.findFirst();
if (builtinSkill.isEmpty()) {
return Optional.empty();
}
Optional<SkillVersion> existingVersion = skillVersionRepository
.findBySkillIdAndVersion(builtinSkill.get().getId(), item.version());
if (existingVersion.isEmpty()) {
return Optional.empty();
}
SkillVersion version = existingVersion.get();
if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
log.info("Skipping built-in skill slug={} version={} because existing version status is {}",
item.slug(), item.version(), version.getStatus());
return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED);
}
String packageFingerprint = computeFingerprint(entries);
String existingFingerprint = computeFingerprint(version);
if (packageFingerprint.equals(existingFingerprint)) {
log.info("Skipping built-in skill slug={} version={} because it is already published",
item.slug(), item.version());
return Optional.of(SyncOutcome.IDEMPOTENT_SKIPPED);
} else {
log.warn(
"Skipping built-in skill slug={} version={} because published fingerprint differs: existing={}, package={}",
item.slug(),
item.version(),
existingFingerprint,
packageFingerprint
);
return Optional.of(SyncOutcome.CONFLICT_SKIPPED);
}
}
private boolean isAlreadyPublishedWithSameFingerprint(Long namespaceId, ManifestItem item, List<PackageEntry> entries) {
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespaceId, item.slug());
for (Skill skill : existingSkills) {
if (!SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId())) {
continue;
}
Optional<SkillVersion> version = skillVersionRepository
.findBySkillIdAndVersion(skill.getId(), item.version());
if (version.isPresent() && version.get().getStatus() == SkillVersionStatus.PUBLISHED) {
String packageFingerprint = computeFingerprint(entries);
String existingFingerprint = computeFingerprint(version.get());
if (packageFingerprint.equals(existingFingerprint)) {
return true;
}
log.warn(
"Built-in skill slug={} version={} was published concurrently with different content: existing={}, package={}",
item.slug(),
item.version(),
existingFingerprint,
packageFingerprint
);
return false;
}
}
return false;
}
private boolean hasOtherOwnerConflict(List<Skill> existingSkills) {
return existingSkills.stream()
.anyMatch(skill -> !SYSTEM_PUBLISHER_ID.equals(skill.getOwnerId()));
}
private String computeFingerprint(SkillVersion version) {
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId()).stream()
.sorted(Comparator.comparing(SkillFile::getFilePath))
.toList();
return computeFingerprintFromFileDigests(files.stream()
.map(file -> new FileDigest(file.getFilePath(), file.getSha256()))
.toList());
}
private String computeFingerprint(List<PackageEntry> entries) {
return computeFingerprintFromFileDigests(entries.stream()
.map(entry -> new FileDigest(entry.path(), sha256(entry.content())))
.toList());
}
private String computeFingerprintFromFileDigests(List<FileDigest> files) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
for (FileDigest file : files.stream().sorted(Comparator.comparing(FileDigest::path)).toList()) {
String line = file.path() + ":" + file.sha256() + "\n";
digest.update(line.getBytes(StandardCharsets.UTF_8));
}
return "sha256:" + HexFormat.of().formatHex(digest.digest());
} catch (Exception exception) {
throw new IllegalStateException("Failed to compute built-in skill fingerprint", exception);
}
}
private static String sha256(byte[] content) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(content));
} catch (Exception exception) {
throw new IllegalStateException("Failed to compute built-in skill file digest", exception);
}
}
private record FileDigest(String path, String sha256) {
}
private enum SyncOutcome {
PUBLISHED,
IDEMPOTENT_SKIPPED,
CONFLICT_SKIPPED,
FAILED
}
}

View file

@ -0,0 +1,108 @@
package com.iflytek.skillhub.bootstrap;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.namespace.SlugValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Component
public class BuiltinSkillManifestLoader {
static final String MANIFEST_LOCATION = "classpath:builtin-skills/manifest.json";
static final int MAX_ITEMS = 100;
private static final Logger log = LoggerFactory.getLogger(BuiltinSkillManifestLoader.class);
private final ObjectMapper objectMapper;
private final ResourceLoader resourceLoader;
public BuiltinSkillManifestLoader(ObjectMapper objectMapper, ResourceLoader resourceLoader) {
this.objectMapper = objectMapper;
this.resourceLoader = resourceLoader;
}
public List<ManifestItem> load() {
Resource resource = resourceLoader.getResource(MANIFEST_LOCATION);
if (!resource.exists()) {
log.warn("Built-in skill manifest not found at {}", MANIFEST_LOCATION);
return List.of();
}
JsonNode root;
try (InputStream inputStream = resource.getInputStream()) {
root = objectMapper.readTree(inputStream);
} catch (IOException | RuntimeException ex) {
log.warn("Failed to read built-in skill manifest at {}: {}", MANIFEST_LOCATION, ex.getMessage());
return List.of();
}
if (root == null || root.isNull()) {
log.warn("Built-in skill manifest at {} is empty", MANIFEST_LOCATION);
return List.of();
}
JsonNode skillsNode = root.path("skills");
if (!skillsNode.isArray()) {
log.warn("Built-in skill manifest at {} does not contain an array field 'skills'", MANIFEST_LOCATION);
return List.of();
}
List<ManifestItem> items = new ArrayList<>();
Set<String> seenSlugVersions = new HashSet<>();
int totalEntries = skillsNode.size();
if (totalEntries > MAX_ITEMS) {
log.warn("Built-in skill manifest has {} entries, only the first {} entries will be processed",
totalEntries, MAX_ITEMS);
}
int limit = Math.min(totalEntries, MAX_ITEMS);
for (int index = 0; index < limit; index++) {
JsonNode itemNode = skillsNode.get(index);
String slug = text(itemNode, "slug");
String version = text(itemNode, "version");
String url = text(itemNode, "url");
if (!StringUtils.hasText(slug) || !StringUtils.hasText(version) || !StringUtils.hasText(url)) {
log.warn("Skipping built-in skill manifest item {} because slug, version, and url are required", index);
continue;
}
try {
SlugValidator.validate(slug);
} catch (RuntimeException ex) {
log.warn("Skipping built-in skill manifest item {} because slug is invalid [slug={}]: {}",
index, slug, ex.getMessage());
continue;
}
String key = slug + "\n" + version;
if (!seenSlugVersions.add(key)) {
log.warn("Skipping duplicate built-in skill manifest item for slug={} version={}", slug, version);
continue;
}
items.add(new ManifestItem(slug, version, url));
}
return List.copyOf(items);
}
private static String text(JsonNode node, String fieldName) {
JsonNode value = node.get(fieldName);
if (value == null || !value.isTextual()) {
return "";
}
return value.asText().trim();
}
public record ManifestItem(String slug, String version, String url) {
}
}

View file

@ -0,0 +1,78 @@
package com.iflytek.skillhub.bootstrap;
import com.iflytek.skillhub.controller.support.SkillPackageArchiveExtractor;
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
@Component
public class BuiltinSkillPackageExtractor {
private final SkillPackageArchiveExtractor archiveExtractor;
public BuiltinSkillPackageExtractor(SkillPackageArchiveExtractor archiveExtractor) {
this.archiveExtractor = archiveExtractor;
}
public SkillPackageArchiveExtractor.ExtractionResult extract(byte[] zipBytes) throws IOException {
SkillPackageArchiveExtractor.ExtractionResult result =
archiveExtractor.extractWithWarnings(new ByteArrayMultipartFile(zipBytes));
if (!result.warnings().isEmpty()) {
throw new IllegalArgumentException("Built-in skill package has warnings: "
+ String.join("; ", result.warnings()));
}
boolean hasSkillMd = result.entries().stream()
.anyMatch(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path()));
if (!hasSkillMd) {
throw new IllegalArgumentException("Built-in skill package must contain " + SkillPackagePolicy.SKILL_MD_PATH);
}
return result;
}
private record ByteArrayMultipartFile(byte[] bytes) implements MultipartFile {
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return "builtin-skill.zip";
}
@Override
public String getContentType() {
return "application/zip";
}
@Override
public boolean isEmpty() {
return bytes.length == 0;
}
@Override
public long getSize() {
return bytes.length;
}
@Override
public byte[] getBytes() {
return bytes.clone();
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(bytes);
}
@Override
public void transferTo(java.io.File dest) throws IOException {
throw new UnsupportedOperationException("Built-in skill zip adapter is read-only");
}
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.bootstrap;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "skillhub.builtin-skills")
public class BuiltinSkillProperties {
private boolean enabled = true;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}

Some files were not shown because too many files have changed in this diff Show more