Merge branch 'main' into fix/runtime-auth-env-vars

Resolve conflict in .env.release.example: place Direct Auth section after
GitLab/OIDC blocks (added by main) and before SMTP, matching the logical
ordering of auth provider configurations.

Also improve the Direct Auth comment to explicitly document the dual-switch
requirement (server + web), and add a why-comment to 30-runtime-config.sh
explaining why session-bootstrap variables are defaulted here but not exposed
in compose.release.yml.
This commit is contained in:
PR Review Helper 2026-05-19 10:28:33 +08:00
commit 307c6669fc
333 changed files with 25525 additions and 361 deletions

View file

@ -0,0 +1,140 @@
---
name: api-and-namespace-design
description: API design conventions, namespace coordinate system, RBAC roles, ClawHub compatibility layer, OpenAPI contract sync rules, and CSRF/session handling.
license: Apache-2.0
---
# API and Namespace Design Skill
## Trigger
Use this skill when:
- Adding or modifying REST API endpoints
- Changing namespace, skill, or user coordinate logic
- Working on ClawHub CLI compatibility layer
- Modifying OpenAPI specifications or generated types
- Adding new admin or governance endpoints
## Namespace Coordinate System
SkillHub uses a two-axis coordinate model:
```
@{namespace_slug}/{skill_slug}
```
- `@global/my-skill` — Global namespace skill
- `@my-team/my-skill` — Team namespace skill (namespace slug is any valid slug)
- `@department-ops/my-skill` — Department namespace skill
### Namespace Model
Namespaces (`domain/namespace/`):
- **Slug**: unique identifier, validated by `SlugValidator`
- **Status**: `ACTIVE`, `FROZEN`, `ARCHIVED`
- **Roles**: `OWNER`, `ADMIN`, `MEMBER`
- Frozen or archived namespaces cannot publish skills
### RBAC Roles
**Namespace-level** (`domain/namespace/NamespaceRole`):
- `OWNER` — Full control over namespace and all skills
- `ADMIN` — Can manage members, archive skills, publish
- `MEMBER` — Can publish skills to the namespace
**Platform-level**:
- `SUPER_ADMIN` — Bypasses all permission checks, can publish directly without review
## ClawHub Compatibility Layer
ClawHub CLI uses a single-slug model (no `/` allowed in slugs). Mapping:
| SkillHub Coordinate | Canonical Slug | Notes |
|---------------------|----------------|-------|
| `@global/my-skill` | `my-skill` | Global namespace omits prefix |
| `@team-name/my-skill` | `team-name--my-skill` | Double-dash separator |
**Conflict resolution**: `--` split takes priority. `@global/team-name--my-skill` would conflict
with `@team-name/my-skill`, resolved to the team namespace skill. Global skill slugs must NOT
contain `--`.
## API Design
### Controllers
- Controllers in `skillhub-app` (`com.iflytek.skillhub.controller/`) are **transport only**
- Responsibilities: extract auth context, bind request params, wrap responses
- Complex business logic belongs in domain services (`skillhub-domain`) or app services
- Use Springdoc OpenAPI annotations (`@Operation`, `@ApiResponse`) for API documentation
- User identity is always **String** in API inputs and outputs
### Request/Response Patterns
- DTOs in `com.iflytek.skillhub.dto/`
- `ReviewTaskRequest` / `ReviewTaskResponse` for review workflow
- Response wrapping handled at controller layer
- Validation errors use `DomainBadRequestException` with i18n message keys
### Session and CSRF
- Session-based auth with cookie storage
- CSRF protection via `XSRF-TOKEN` cookie and `X-XSRF-TOKEN` header
- Smoke tests validate the full register → login → CSRF → action → logout flow
- Mock auth uses `X-Mock-User-Id` header in local dev
### Well-known Discovery
`/.well-known/clawhub.json` returns `{ "apiBase": "/api/v1" }` for ClawHub CLI auto-discovery.
## OpenAPI Contract Sync
When backend API contracts change:
```bash
make generate-api
```
This runs `openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts`.
Commit the updated `web/src/api/generated/schema.d.ts` with the PR.
To verify no drift:
```bash
./scripts/check-openapi-generated.sh
```
This starts local dependencies, boots the backend, regenerates the schema, and fails if the
checked-in SDK is stale.
## Versioning and Tags
- Semantic versioning for skill versions (`major.minor.patch`)
- `latest` tag is system-reserved, read-only, auto-follows `Skill.latestVersionId`
- Custom tags (`stable`, `beta`) are manually maintained
- `latest` cannot be moved manually
- Auto-generated versions use `yyyyMMdd.HHmmss` format when no version is specified in SKILL.md
## Key API Endpoints
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/auth/me` | Current user info (401 if unauthenticated) |
| `POST` | `/api/v1/auth/local/login` | Local account login |
| `POST` | `/api/v1/auth/local/register` | Local account registration |
| `POST` | `/api/v1/auth/logout` | Logout (302/200/204) |
| `POST` | `/api/v1/auth/local/change-password` | Password change |
| `GET` | `/api/v1/namespaces` | List namespaces |
| `GET` | `/api/v1/labels` | List visible labels (public) |
| `POST` | `/api/v1/admin/labels` | Create label definition (admin) |
| `DELETE` | `/api/v1/admin/labels/{slug}` | Delete label definition (admin) |
| `GET` | `/actuator/health` | Health check |
| `GET` | `/actuator/prometheus` | Prometheus metrics |
## Common Pitfalls
- Forgetting CSRF token on POST/PUT/DELETE requests (needs `X-XSRF-TOKEN` header)
- Using numeric user IDs in API — all user identities are **String**
- Not regenerating OpenAPI types after adding/changing endpoints
- Putting business logic in controllers instead of domain/app services
- Assuming namespace slugs follow a specific prefix pattern — they are arbitrary valid slugs

View file

@ -0,0 +1,108 @@
---
name: backend-module-structure
description: Rules for the SkillHub backend Maven multi-module clean architecture. Ensures agents place new code in the correct module and respect dependency direction.
license: Apache-2.0
---
# Backend Module Structure Skill
## Trigger
Use this skill when:
- Adding or modifying Java backend code
- Creating new services, controllers, repositories, or entities
- Refactoring backend code across files
- Reviewing backend code placement
## Rules
### Dependency Direction
The design-doc dependency direction:
```
app → domain, auth, search, storage, infra, notification
infra → domain # implements domain repository interfaces
auth → domain
search → domain
notification → domain
storage → (independent) # pure SPI
```
**Design intent**: `skillhub-domain` should be the innermost layer, defining entities,
repository interfaces, and domain services without depending on infra, auth, search, or storage.
**Code reality**: `skillhub-domain` declares a Maven dependency on `skillhub-storage` (via
`pom.xml`), and several domain services (`SkillHardDeleteService`, `SkillDownloadService`,
`SkillPublishService`, `SkillGovernanceService`, `SkillQueryService`,
`SkillStorageDeletionCompensationService`) import `com.iflytek.skillhub.storage.ObjectStorageService`.
This is an existing deviation from the ideal clean architecture. New code should avoid adding
further cross-module dependencies from domain.
### Where to Place Code
| Code Type | Module | Java Package |
|-----------|--------|-------------|
| Entity / Value Object | skillhub-domain | `com.iflytek.skillhub.domain.{submodule}/` |
| Repository Interface | skillhub-domain | `com.iflytek.skillhub.domain.{submodule}/` |
| Domain Service | skillhub-domain | `com.iflytek.skillhub.domain.{submodule}/service/` |
| Domain Event | skillhub-domain | `com.iflytek.skillhub.domain/event/` |
| Domain Exception | skillhub-domain | `com.iflytek.skillhub.domain/shared/exception/` |
| JPA Repository Impl | skillhub-infra | `com.iflytek.skillhub.infra.repository/` |
| Controller | skillhub-app | `com.iflytek.skillhub.controller/` |
| App Service | skillhub-app | `com.iflytek.skillhub.service/` |
| Query Repository | skillhub-app | `com.iflytek.skillhub.repository/` |
| DTO / Response | skillhub-app | `com.iflytek.skillhub.dto/` |
| OAuth2 / Auth Config | skillhub-auth | `com.iflytek.skillhub.auth/` |
| Search SPI / Impl | skillhub-search | `com.iflytek.skillhub.search/` |
| Storage SPI / Impl | skillhub-storage | `com.iflytek.skillhub.storage/` |
| Notification Service | skillhub-notification | `com.iflytek.skillhub.notification/` |
### Maven Modules
The parent POM (`server/pom.xml`) defines 7 modules with `spring-boot-starter-parent:3.2.3`:
```
skillhub-app | skillhub-domain | skillhub-auth | skillhub-search
skillhub-storage | skillhub-infra | skillhub-notification
```
### Repository vs Query Repository
- **Domain Repository** (`skillhub-domain`): Aggregate reads, state transitions, rule evaluation.
Returns domain objects. Defined as interfaces, implemented in `skillhub-infra` via Spring Data JPA.
- **Query Repository** (`com.iflytek.skillhub.repository`): Read-model assembly, joins multiple
sources, presentation projection. Returns DTOs. Implemented directly in `skillhub-app`.
Current query repositories:
- `GovernanceQueryRepository` / `JpaGovernanceQueryRepository`
- `MySkillQueryRepository` / `JpaMySkillQueryRepository`
- `ProfileReviewQueryRepository` / `JpaProfileReviewQueryRepository`
- `AdminSkillReportQueryRepository` / `JpaAdminSkillReportQueryRepository`
When a new read use case arrives:
1. If it's for state transition or domain rule → domain repository port
2. If it's for page/list/detail response assembly with joins → app query repository
3. If it's a thin single-aggregate read → direct domain repository call from app service
4. If direct SQL/EntityManager is needed → add class-level comment explaining why
### Building Backend Tests
Never run `./mvnw -pl skillhub-app clean test` directly under `server/`. Use:
```bash
make test-backend-app # skillhub-app + dependencies (includes -am)
make test-backend # all backend modules
```
Running clean test on skillhub-app alone can fall back to stale artifacts from the local Maven
repository, surfacing misleading `cannot find symbol` and signature-mismatch errors.
### User Identity Type
User identity is **always String** throughout the codebase. This covers:
- Authentication, API params, permissions, audit
- Resource owner, creator, reviewer, actor, submittedBy
- All user-associated fields
The `UserAccount` entity uses `@Column(length = 128)` for its ID. The platform needs to support
external SSO/OIDC/SCIM identity sources whose UIDs are typically stable strings.

View file

@ -0,0 +1,135 @@
---
name: code-conventions
description: Code style, logging, and testing conventions for SkillHub backend (Java) and frontend (TypeScript). Use when writing or reviewing code.
license: Apache-2.0
---
# Code Conventions Skill
## Java / Backend Conventions
### User Identity Type
User identity is **always `String`** throughout the codebase. This covers:
- Authentication and authorization
- API parameters and responses
- Permission checks
- Audit logs
- Resource owner, creator, reviewer, actor, submittedBy fields
Never introduce `int`, `long`, or `bigint` as user identifiers. The platform needs to support
external SSO/OIDC/SCIM identity sources whose UIDs are typically stable strings.
### Exception Handling
- Use `LocalizedDomainException` for user-facing error messages (supports i18n)
- Use `DomainBadRequestException` for invalid client input
- Use `DomainNotFoundException` for missing resources
- Use `DomainForbiddenException` for authorization failures
- Exception classes live in `skillhub-domain/shared/exception/`
### Domain Services
- Return domain objects, not DTOs
- Contain business rules and state transitions
- Use domain events for cross-cutting side effects (publishing, notifications)
- Located in `domain/{submodule}/service/`
### Controllers
- Transport only: extract auth context, bind request params, wrap responses
- No business logic in controllers
- Located in `com.iflytek.skillhub.controller/`
### Query Repositories
- Handle read-model joins and presentation projection
- Return DTOs or presentation models
- Located in `com.iflytek.skillhub.repository/`
- Named like `*QueryRepository` (e.g., `GovernanceQueryRepository`, `MySkillQueryRepository`)
### App Services
- Workflow orchestration: coordinate domain services and query repositories
- Should express "what this endpoint does", not "how it assembles DTOs"
- Located in `com.iflytek.skillhub.service/`
### Logging
- Use SLF4J with structured logging
- Use MDC for request tracing
- Log at appropriate levels: INFO for business events, DEBUG for troubleshooting, ERROR for failures
## TypeScript / Frontend Conventions
### Type Safety
- Strict TypeScript mode. No `any` types.
- Use generated OpenAPI types from `web/src/api/generated/schema.d.ts` for all API interactions.
- Additional types in `web/src/types/`
### Data Fetching
- **Always use TanStack Query** (`@tanstack/react-query`) for server state
- **Never use `useEffect`** for data fetching
- Use `openapi-fetch` client for type-safe API calls
### Component Composition
- **Radix UI** primitives: `@radix-ui/react-dropdown-menu`, `@radix-ui/react-select`
- **class-variance-authority** (cva) for component variants
- **clsx** + **tailwind-merge** for class merging
- **`cn()` utility**: `web/src/shared/lib/utils.ts`
- shadcn/ui is NOT used as a library
### State Management
- **TanStack Query** for server state (API data, caching, invalidation)
- **Zustand** for local/UI state (theme, sidebar, modals, form state)
### Feature-Sliced Design
| Layer | Path | Purpose |
|-------|------|---------|
| Pages | `web/src/pages/` | Route-level page components |
| Features | `web/src/features/` | Self-contained business features |
| Entities | `web/src/entities/` | Domain entity display logic |
| Shared | `web/src/shared/` | Reusable UI components, hooks, utilities |
Place code at the lowest appropriate layer. Do not put page-level logic in shared.
### Styling
- Tailwind CSS for all styling
- Follow existing component patterns
- Use `cn()` for conditional class merging
### Internationalization
- Use i18next + react-i18next
- All user-facing text must be translatable
- Translation keys in `web/src/i18n/`
## Testing Philosophy
### Backend
- JUnit 5 + Mockito + AssertJ
- Use Spring Boot test slices where possible (`@WebMvcTest`, `@DataJpaTest`)
- Test behaviors, not implementations
- Use `make test-backend-app` (includes `-am` for dependent modules)
- Never run `./mvnw -pl skillhub-app clean test` directly — stale Maven cache causes misleading errors
### Frontend
- Vitest for unit tests
- Playwright for E2E tests
- Test component behavior and user interactions
## Common Pitfalls
- **Maven multi-module**: Always use `-am` flag or Makefile targets to include dependent modules
- **OpenAPI types**: Must regenerate and commit after API contract changes
- **String identity**: Never use numeric types for user identifiers
- **Controller business logic**: Move to domain service or app service
- **Complex read-models in app service**: Extract to query repository

View file

@ -0,0 +1,194 @@
---
name: dev-workflow
description: The complete development workflow for SkillHub contributors including local dev, staging validation, testing, and PR creation. Ensures agents follow the correct sequence of steps.
license: Apache-2.0
---
# Development Workflow Skill
## Trigger
Use this skill when:
- Starting local development
- Running tests or validation
- Preparing a pull request
- Setting up the development environment
- Working with parallel agent worktrees
## Prerequisites
- Java 21+ (`java -version`)
- Maven wrapper (`./mvnw` in `server/`)
- Node.js + pnpm
- Docker + docker compose
- `gh` CLI (for PR creation)
- `curl` (for smoke tests and health checks)
## Workflow Stages
### Stage 1: Local Development (fast iteration)
**One-command start:**
```bash
make dev-all # Start full stack: Postgres, Redis, MinIO, scanner, backend, frontend
make dev-all-down # Stop everything
make dev-all-reset # Full reset (clears data volumes)
make dev-status # Check service status
```
**Access points:**
- Web UI: `http://localhost:3000`
- Backend API: `http://localhost:8080`
- Scanner: `http://localhost:8000`
**Individual components:**
```bash
make dev # Start dependency services only (Postgres, Redis, MinIO, scanner)
make dev-server # Start backend in foreground (blocking)
make dev-web # Start Vite dev server (HMR enabled)
make dev-server-restart # Restart backend process
make dev-down # Stop dependency services
make dev-logs # View backend logs (use SERVICE=frontend for frontend logs)
```
**Backend development**: After editing Java code, run `make dev-server-restart`.
**Frontend development**: Vite HMR enabled — save a file for instant browser updates.
**Scanner**: The security scanner is enabled by default in dev. Health checked at `http://localhost:8000/health`.
### Stage 2: Testing
| Command | Scope | Notes |
|---------|-------|-------|
| `make test-backend-app` | Backend unit tests | skillhub-app + dependencies (`-am`) |
| `make test-backend` | All backend modules | All modules via `./mvnw test` |
| `make test-frontend` | Frontend unit tests | Vitest (pnpm run test) |
| `make test-e2e-frontend` | Frontend E2E tests | Playwright |
| `make test-e2e-smoke-frontend` | Frontend E2E smoke | Playwright subset |
| `make typecheck-web` | TypeScript type check | `tsc --noEmit` |
| `make lint-web` | ESLint check | Frontend linting |
**Important**: Never run `./mvnw -pl skillhub-app clean test` directly under `server/`.
Use `-am` or Makefile targets to include dependent modules.
### Stage 3: Staging Regression (pre-PR validation)
```bash
make staging # Build backend Docker image + frontend static + smoke test
make staging-down # Tear down
make staging-logs # View backend logs
SERVICE=web make staging-logs # View Nginx logs
```
Staging validates the containerized deployment path:
- Backend: built as Docker image from local source (`Dockerfile.dev`)
- Frontend: built as static files (`pnpm build`), served by Nginx
- Dependencies: same Postgres/Redis/MinIO as local dev
- Smoke test runs against staging via `scripts/smoke-test.sh`
**Staging URLs:**
- Web UI: `http://localhost`
- Backend API: `http://localhost:8080`
**Staging credentials** (for bootstrap admin):
- Username: `admin`
- Password: `Admin@staging2026`
### Stage 4: Pull Request
```bash
make pr # Push branch + create PR (requires gh CLI)
```
Requirements:
- `gh` CLI installed and authenticated
- Not on main/master branch
- All changes committed (will prompt if uncommitted changes exist)
### Useful Commands
| Command | Description |
|---------|-------------|
| `make generate-api` | Regenerate OpenAPI types from running backend |
| `make namespace-smoke` | Namespace workflow smoke test |
| `make db-reset` | Reset database only (Flyway migrate) |
| `make validate-release-config` | Validate release env vars (.env.release) |
| `./scripts/smoke-test.sh` | Basic API smoke test (health, auth, labels) |
| `./scripts/namespace-smoke-test.sh` | Namespace CRUD + membership smoke test |
| `./scripts/check-openapi-generated.sh` | Verify OpenAPI types are not stale |
| `make parallel-init TASK=name` | Create parallel worktree for agent |
### Mock Auth Users
| User ID | Role | Header |
|---------|------|--------|
| `local-user` | Regular user | `X-Mock-User-Id: local-user` |
| `local-admin` | Super admin | `X-Mock-User-Id: local-admin` |
Bootstrap admin (local profile):
- Username: `admin`
- Password: `ChangeMe!2026`
### Smoke Test Coverage
`scripts/smoke-test.sh` validates:
1. Health endpoint (`/actuator/health` → 200)
2. Prometheus metrics (`/actuator/prometheus` → 200)
3. Namespaces API (`/api/v1/namespaces` → 200)
4. Auth required (`/api/v1/auth/me` → 401 without session)
5. User registration flow (with CSRF)
6. Auth me with session
7. Password change
8. Logout + verify 401 after
9. Admin login
10. Label CRUD (admin only)
Additional smoke tests:
- `scripts/namespace-smoke-test.sh` — Namespace creation, membership, publishing
- `scripts/governance-smoke-test.sh` — Governance and moderation
- `scripts/promotion-smoke-test.sh` — Skill promotion between scopes
### Parallel Agent Workflow
For parallel agent development with isolated worktrees:
```bash
make parallel-init TASK=feature-name # Create worktree
make parallel-sync SOURCES="feat1 feat2" # Merge feature branches
make parallel-up SOURCES="feat1 feat2" # Merge + start dev environment
make parallel-down # Stop parallel environment
```
See `docs/13-parallel-workflow.md` for full details.
### Commit Style
Use conventional commit format:
```
<type>(<scope>): <description>
```
Examples:
```
feat(auth): add local account login
fix(publish): resolve null pointer when skill metadata is missing name
docs(deploy): clarify runtime image usage
test(namespace): add membership service edge case tests
refactor(review): extract query repository for governance list
chore(ci): add parallel workflow scripts
```
### Common Issues
| Issue | Solution |
|-------|----------|
| Backend won't start | Check Java version (`java -version`), must be 21+ |
| Port 8080 in use | `lsof -i :8080` to find and kill the process |
| Maven download timeout | Configure mirror in `~/.m2/settings.xml` |
| Frontend won't start | Run `make web-deps` to ensure node_modules exist |
| Staging build fails | Check `Dockerfile.dev` and ensure Maven build succeeds first |
| CSRF errors in tests | Ensure cookie jar is shared and CSRF token refreshed after login |

View file

@ -0,0 +1,124 @@
---
name: frontend-conventions
description: Coding conventions, architecture patterns, and testing rules for the SkillHub React frontend. Ensures agents follow Feature-Sliced Design and use the generated OpenAPI types.
license: Apache-2.0
---
# Frontend Conventions Skill
## Trigger
Use this skill when:
- Adding or modifying React/TypeScript frontend code
- Creating new pages, features, entities, or shared components
- Changing API client calls or data fetching patterns
## Rules
### Feature-Sliced Design
Place code at the lowest appropriate layer:
| Layer | Path | Purpose |
|-------|------|---------|
| Pages | `web/src/pages/` | Route-level page components |
| Features | `web/src/features/` | Business features (search, upload, review, etc.) |
| Entities | `web/src/entities/` | Domain entity display logic (skill, user, namespace) |
| Shared | `web/src/shared/` | Reusable UI components, hooks, utilities |
Current features:
- `admin` — Admin panel (user management, labels, search)
- `auth` — Login, OAuth flows, device auth
- `governance` — Skill governance actions (hide, yank, archive)
- `namespace` — Namespace management (members, settings)
- `notification` — User notifications and inbox
- `promotion` — Skill promotion between scopes
- `publish` — Skill upload/publish UI
- `report` — Skill reporting
- `review` — Review workflow UI
- `search` — Skill search and filtering
- `security-audit` — Security audit viewer
- `skill` — Skill detail, listing, cards
- `social` — Stars, ratings, subscriptions
- `token` — API token management
### Data Fetching
- **Always use TanStack Query** (`@tanstack/react-query`) for server state.
- **Never use `useEffect`** for data fetching.
- Use `openapi-fetch` client with generated types from `web/src/api/generated/schema.d.ts`.
- Never use `any` types.
### State Management
- **TanStack Query** for server state (API data, caching, invalidation, optimistic updates)
- **Zustand** for local/UI state (theme, sidebar, modals, form state)
### Component Composition
- **Radix UI** primitives: `@radix-ui/react-dropdown-menu`, `@radix-ui/react-select`
- **class-variance-authority** (cva) for component variants
- **clsx** + **tailwind-merge** for class merging
- **`cn()` utility**: `web/src/shared/lib/utils.ts`
- **shadcn/ui is NOT used** as a library
### API Type Generation
When backend OpenAPI contracts change:
```bash
make generate-api
```
This runs `openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts`.
Commit the updated `web/src/api/generated/schema.d.ts` with the PR.
To verify the generated file is not stale:
```bash
./scripts/check-openapi-generated.sh
```
### Styling
- **Tailwind CSS** for all styling
- **`cn()` utility** for conditional class merging
- Follow existing component patterns in `web/src/shared/components/`
### Internationalization
- **i18next** + **react-i18next** for translations
- All user-facing text must be translatable
- Translation keys in `web/src/i18n/`
### Build & Development
```bash
make dev-web # Start Vite dev server (HMR enabled)
make build-frontend # Production build
make typecheck-web # TypeScript type check (tsc --noEmit)
make lint-web # ESLint check
make test-frontend # Vitest unit tests
make test-e2e-frontend # Playwright E2E tests
make test-e2e-smoke-frontend # Playwright smoke tests
```
Vite HMR is enabled by default — save a file and the browser updates instantly.
### Frontend Dependencies
Key dependencies (from `web/package.json`):
- `react` 19, `react-dom` 19
- `@tanstack/react-query` 5
- `@tanstack/react-router` 1
- `@radix-ui/react-dropdown-menu`, `@radix-ui/react-select`
- `class-variance-authority`, `clsx`, `tailwind-merge`
- `openapi-fetch` 0.13
- `i18next`, `react-i18next`
- `zustand` 5
- `react-markdown`, `rehype-highlight`, `rehype-sanitize`
- `lucide-react` (icons)
- `sonner` (toasts)
Build tools: Vite 6, TypeScript 5.7, Vitest 3.2, Playwright 1.58

View file

@ -0,0 +1,93 @@
---
name: pr-submission
description: PR title format, commit conventions, and pre-PR checklist for SkillHub. Use when preparing or reviewing pull requests.
license: Apache-2.0
---
# PR Submission Skill
## Workflow
1. Identify the scope of your change (feature, bug fix, docs, test, refactor, chore)
2. Format PR title and commits using the conventions below
3. Run the pre-PR checklist commands
4. Open the PR with a descriptive body
## PR Title Format
Use conventional commit style:
```
<type>(<scope>): <description>
```
**Types:**
| Type | When to Use |
|------|-------------|
| `feat` | New feature or capability |
| `fix` | Bug fix |
| `docs` | Documentation changes only |
| `test` | Adding or updating tests |
| `refactor` | Code restructuring with no behavior change |
| `chore` | Build, CI, tooling, or maintenance tasks |
**Scopes:** Use module or domain names: `auth`, `search`, `publish`, `review`, `namespace`, `governance`, `deploy`, `ci`, `frontend`, `scanner`
**Examples:**
```
feat(auth): add local account login with password reset
fix(publish): resolve null pointer when skill metadata is missing name
docs(deploy): clarify runtime image usage
test(namespace): add membership service edge case tests
refactor(review): extract query repository for governance list
chore(ci): add parallel workflow scripts for multi-agent development
```
## Commit Message Format
Same convention as PR titles. One logical change per commit.
**Types:**
- **feat**: A new feature for the user
- **fix**: A bug fix for the user
- **docs**: Documentation changes only
- **test**: Adding or updating tests
- **refactor**: Code change that neither fixes a bug nor adds a feature
- **chore**: Changes to build process, CI, or maintenance tasks
**Examples:**
```
fix(auth): resolve session cookie conflict in device flow
feat(publish): support security scan before review submission
docs(skill-protocol): add nested SKILL.md discovery rules
test(search): verify jieba analysis with Chinese skill descriptions
refactor(storage): simplify LocalFile path normalization
```
## Pre-PR Checklist
- [ ] Backend tests pass: `make test-backend-app`
- [ ] Frontend typecheck passes: `make typecheck-web`
- [ ] If API changed: `make generate-api` was run and `web/src/api/generated/schema.d.ts` is committed
- [ ] Smoke test passes: `make staging`
- [ ] Follow existing module boundaries and dependency direction
- [ ] Add/update tests for new behavior
- [ ] Update design docs when APIs, auth flows, deployment, or operator workflows change
## PR Body Structure
When creating a PR, include:
1. **What** — Summary of the change
2. **Why** — Motivation (link to issue if applicable)
3. **How** — Key implementation details (especially for non-obvious decisions)
4. **Testing** — How to verify the change works
5. **Impact** — Breaking changes, migration notes, or rollout considerations
## Review Conventions
- When reviewing, cite the specific AGENTS.md rule that applies if suggesting a convention change
- For backend code, check dependency direction does not violate clean architecture rules
- For frontend code, check OpenAPI types are regenerated if API changed

View file

@ -0,0 +1,151 @@
---
name: skill-lifecycle
description: The authoritative skill lifecycle state model including container states, version states, review workflow states, visibility overlay, and governance actions. Ensures agents don't introduce invalid states or transitions.
license: Apache-2.0
---
# Skill Lifecycle Skill
## Trigger
Use this skill when:
- Modifying skill publish, review, or unpublish flows
- Adding or changing skill/version status fields
- Working on search, detail pages, or listing pages that show skill state
- Implementing governance actions (hide, yank, archive)
- Adding new state transitions or permission checks
## State Model
### Skill Container States
Enum `SkillStatus` (`domain/skill/SkillStatus.java`):
| Value | Meaning |
|-------|---------|
| `ACTIVE` | Skill is operational and can have versions published |
| `HIDDEN` | Skill hidden by platform governance (design doc says prefer boolean `hidden` flag instead) |
| `ARCHIVED` | Skill archived by owner/namespace admin, cannot publish new versions |
**Design-vs-code note**: `docs/14-skill-lifecycle.md` specifies `hidden` should be a governance
overlay (boolean flag) rather than a lifecycle enum state. The current code still defines
`SkillStatus.HIDDEN`. New code should use the `skill.hidden` boolean field, not the enum value.
### SkillVersion States
Enum `SkillVersionStatus` (`domain/skill/SkillVersionStatus.java`):
| Value | Meaning |
|-------|---------|
| `DRAFT` | Non-public draft, can resubmit or delete |
| `SCANNING` | Undergoing security scan |
| `SCAN_FAILED` | Security scan failed |
| `UPLOADED` | Uploaded but not yet submitted for review (or withdrawn from review) |
| `PENDING_REVIEW` | Frozen pending reviewer action |
| `PUBLISHED` | Currently distributable |
| `REJECTED` | Review denied, retained |
| `YANKED` | Was published, withdrawn from distribution |
### ReviewTask States
Enum `ReviewTaskStatus` (`domain/review/ReviewTaskStatus.java`):
| Value | Meaning |
|-------|---------|
| `PENDING` | Awaiting reviewer |
| `APPROVED` | Reviewer approved |
| `REJECTED` | Reviewer rejected |
### Visibility Model
Enum `SkillVisibility` (used in `SkillPublishService`):
| Value | Publish Path |
|-------|-------------|
| `PUBLIC` | Creates `PENDING_REVIEW` version, review task, security scan |
| `NAMESPACE_ONLY` | Same as PUBLIC but limited visibility scope |
| `PRIVATE` | Goes directly to `UPLOADED` status, no review task |
`SUPER_ADMIN` role bypasses review — versions go directly to `PUBLISHED`.
### Latest Version Pointer
`Skill.latestVersionId` is **only** the latest published pointer:
- Can only point to a `PUBLISHED` version
- May be `null` if no published version exists
- `latest` tag auto-follows this pointer (read-only)
- When yanking: recalculates to newest remaining `PUBLISHED` version, or `null`
### Key Transitions
| Action | From | To | Notes | Source |
|--------|------|-----|-------|--------|
| First upload (PUBLIC/NAMESPACE_ONLY) | — | `PENDING_REVIEW` | Review task created | `SkillPublishService` |
| First upload (SUPER_ADMIN) | — | `PUBLISHED` | Direct publish, `SkillPublishedEvent` emitted | `SkillPublishService` |
| First upload (PRIVATE) | — | `UPLOADED` | No review task, `latestVersionId` updated | `SkillPublishService` |
| Review approve | `PENDING_REVIEW` | `PUBLISHED` | Updates `latestVersionId` | Review workflow |
| Review reject | `PENDING_REVIEW` | `REJECTED` | Version retained | Review workflow |
| Withdraw review | `PENDING_REVIEW` | `UPLOADED` | Deletes pending `ReviewTask` | `SkillGovernanceService.withdrawPendingVersion` |
| Yank | `PUBLISHED` | `YANKED` | Recalculates `latestVersionId` | `SkillGovernanceService.yankVersion` |
| Hide | — | `hidden=true` | Independent overlay | `SkillGovernanceService.hideSkill` |
| Restore | — | `hidden=false` | Independent overlay | `SkillGovernanceService.unhideSkill` |
| Archive | `ACTIVE` | `ARCHIVED` | `SkillStatusChangedEvent` emitted | `SkillGovernanceService.archiveSkill` |
| Unarchive | `ARCHIVED` | `ACTIVE` | `SkillStatusChangedEvent` emitted | `SkillGovernanceService.unarchiveSkill` |
| New publish (existing pending) | `PENDING_REVIEW` | `UPLOADED` | Auto-withdraw + delete review task | `SkillPublishService` |
| Delete version | `DRAFT`/`REJECTED`/`SCAN_FAILED`/`UPLOADED` | — | Last version protected | `SkillGovernanceService.deleteVersion` |
### Yank Pointer Recalculation
When yanking the current `latestVersionId` (`SkillGovernanceService`):
1. Query all remaining `PUBLISHED` versions for the skill
2. Sort by `publishedAt` DESC, then `createdAt` DESC, then `id` DESC
3. Point `latestVersionId` to the top result, or `null` if none remain
### Lifecycle Projection
Read models (detail, my-skills, favorites, search) use `*QueryRepository` patterns:
- `headlineVersion` — Main display version for the page
- `publishedVersion` — Latest published version
- `ownerPreviewVersion` — Pending review version (visible to owner/namespace admin)
- `resolutionMode``PUBLISHED`, `OWNER_PREVIEW`, or `NONE`
**Public browsing, install, download, search only use `publishedVersion`.**
### Permission Boundaries
| Action | Who |
|--------|-----|
| Withdraw review | Submitter only |
| Delete version | Owner or namespace admin, only `DRAFT`/`REJECTED`/`SCAN_FAILED`/`UPLOADED` |
| Archive/unarchive | Owner or namespace admin (`ADMIN` or `OWNER` role) |
| Hide/restore | Platform governance (no permission check in code) |
| Yank | Platform governance (no permission check in code) |
| Publish PUBLIC skill | Namespace member (or `SUPER_ADMIN`) |
| Publish PRIVATE skill | Namespace member (or `SUPER_ADMIN`) |
### Delete Version Constraints
`SkillGovernanceService.deleteVersion` enforces:
- Only `DRAFT`, `REJECTED`, `SCAN_FAILED`, or `UPLOADED` versions can be deleted
- Cannot delete the last remaining version of a skill
- Deletes associated storage keys (individual files + `bundle.zip`)
- Deletes associated security scan records
- Updates `latestVersionId` if the deleted version was the pointer
- Storage deletion happens after transaction commit with compensation recording
### Domain Events
| Event | When Emitted |
|-------|-------------|
| `SkillStatusChangedEvent` | Archive or unarchive |
| `SkillPublishedEvent` | SUPER_ADMIN direct publish |
| `SkillVersionYankedEvent` | Yank action |
| `ReviewSubmittedEvent` | Create review task for PUBLIC/NAMESPACE_ONLY |
### Common Pitfalls
- Setting `SkillStatus.HIDDEN` directly — use `skill.setHidden(true)` via `SkillGovernanceService` instead
- Forgetting to recalculate `latestVersionId` after yank or version deletion
- Not auto-withdrawing pending versions when publishing a new version
- Missing the `confirmWarnings` two-step publish flow (warnings require explicit confirmation)
- Assuming all publish flows create review tasks — `PRIVATE` visibility skips review

View file

@ -0,0 +1,117 @@
---
name: testing-and-ci
description: Testing conventions, CI pipeline rules, and smoke test coverage for SkillHub. Ensures agents write tests correctly and understand the CI gate requirements.
license: Apache-2.0
---
# Testing and CI Skill
## Trigger
Use this skill when:
- Adding or modifying backend tests
- Adding or modifying frontend tests
- Changing CI/CD workflows
- Adding smoke tests or E2E tests
## Rules
### Backend Testing
Tests live alongside source in each module's `src/test/java/`:
- `server/skillhub-app/src/test/java/` — Controller integration tests, service tests
- `server/skillhub-domain/src/test/java/` — Domain service unit tests
- `server/skillhub-auth/src/test/java/` — Auth flow tests
**Tools**: JUnit 5 + Mockito + AssertJ + Spring Boot test slices (`@WebMvcTest`, `@DataJpaTest`)
**Build commands:**
```bash
make test-backend-app # skillhub-app + dependencies (includes -am)
make test-backend # all backend modules
```
**Never** run `./mvnw -pl skillhub-app clean test` directly under `server/`.
`skillhub-app` depends on sibling modules, and a standalone clean build can fall back to stale
artifacts from the local Maven repository, surfacing misleading `cannot find symbol` and
signature-mismatch errors. Use `-am`, or the Makefile targets above.
**Test naming conventions:**
- Controller tests: `{ControllerName}Test.java` (e.g., `SkillControllerTest.java`)
- Service tests: `{ServiceName}Test.java`
- Integration tests: `{FlowName}IntegrationTest.java`
- Security tests: `{ControllerName}SecurityTest.java`
### Frontend Testing
**Tools**: Vitest (unit), Playwright (E2E)
```bash
make test-frontend # Vitest unit tests (pnpm run test)
make test-e2e-frontend # Playwright E2E tests
make test-e2e-smoke-frontend # Playwright smoke tests (subset)
```
E2E tests live in `web/e2e/`.
### Smoke Tests
Smoke tests validate end-to-end operator workflows against a running backend:
| Script | Purpose |
|--------|---------|
| `scripts/smoke-test.sh` | Basic API health, auth, label CRUD |
| `scripts/namespace-smoke-test.sh` | Namespace creation, membership, publishing |
| `scripts/governance-smoke-test.sh` | Governance and moderation flows |
| `scripts/promotion-smoke-test.sh` | Skill promotion between scopes |
When operator-facing workflows change, update the corresponding smoke test.
### CI Pipeline
GitHub Actions workflows in `.github/workflows/`:
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `pr-tests.yml` | PR | Backend + frontend unit tests |
| `pr-e2e.yml` | PR | E2E smoke tests against staging |
| `pr-batch-test-deploy.yml` | workflow_dispatch | Batch test and deploy |
| `publish-images.yml` | release published / workflow_dispatch | Build and publish Docker images to GHCR |
| `deploy-docs.yml` | push to docs | Deploy documentation site |
| `issue-triage.yml` | issues | Auto-triage incoming issues |
| `issue-backlog-rescore.yml` | cron (every 6h) | Rescore backlog issues |
| `release-notes.yml` | workflow_dispatch | Generate release notes |
| `deepwiki.yml` | release published | Update DeepWiki documentation |
| `claim-issue-reward.yml` | issue_comment | Auto-claim issue rewards |
| `statistic-member-reward.yml` | cron/schedule | Calculate member rewards |
All workflows live in `.github/workflows/`. Deno scripts for triage, release notes, and rewards
live in `.github/scripts/`.
### Staging
Before opening a PR, validate with staging:
```bash
make staging # Build backend Docker image + frontend static + smoke test
make staging-down # Tear down
SERVICE=web make staging-logs # View Nginx logs
```
Staging validates the containerized deployment path:
- Backend: built as Docker image from local source (`Dockerfile.dev`)
- Frontend: built as static files (`pnpm build`), served by Nginx
- Dependencies: same Postgres/Redis/MinIO as local dev
If staging passes, the environment stays running at:
- Web UI: `http://localhost`
- Backend API: `http://localhost:8080`
### Pre-PR Testing Checklist
- [ ] `make test-backend-app` passes
- [ ] `make typecheck-web` passes
- [ ] `make lint-web` passes (if frontend changed)
- [ ] `make staging` passes (full regression)
- [ ] If API changed: `make generate-api` run and generated file committed
- [ ] New behavior has corresponding tests

View file

@ -34,10 +34,14 @@ SKILLHUB_STORAGE_PROVIDER=local
SKILLHUB_STORAGE_S3_ENDPOINT=https://oss-cn-example.aliyuncs.com
SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT=
SKILLHUB_STORAGE_S3_BUCKET=skillhub-prod
# Static credentials for S3-compatible storage (MinIO, Alibaba OSS, etc.).
# Leave both blank to use IAM authentication (EC2 instance profile, ECS task role, EKS IRSA).
SKILLHUB_STORAGE_S3_ACCESS_KEY=replace-me
SKILLHUB_STORAGE_S3_SECRET_KEY=replace-me
SKILLHUB_STORAGE_S3_REGION=cn-shanghai
SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE=false
# Aliyun OSS rejects aws-chunked encoding; set to true when targeting Aliyun OSS.
SKILLHUB_STORAGE_S3_DISABLE_CHUNKED_ENCODING=true
SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET=false
SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY=PT10M
@ -56,8 +60,30 @@ DEVICE_AUTH_VERIFICATION_URI=
OAUTH2_GITHUB_CLIENT_ID=
OAUTH2_GITHUB_CLIENT_SECRET=
# Direct (username/password) authentication. Enable for environments without OAuth2.
# Requires SKILLHUB_AUTH_DIRECT_ENABLED=true in server and matching frontend config below.
# Optional: configure real GitLab OAuth before exposing the stack to other users.
# Set OAUTH2_GITLAB_BASE_URI to your self-hosted GitLab URL when applicable.
OAUTH2_GITLAB_CLIENT_ID=
OAUTH2_GITLAB_CLIENT_SECRET=
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
# Optional: OIDC login (e.g. Keycloak, Okta, Azure AD).
# Replace "OIDC" in variable names with your registration id (uppercase).
# The registration id becomes identity_binding.provider_code — keep it stable.
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_ID=
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_SECRET=
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_PROVIDER=oidc
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_AUTHORIZATION_GRANT_TYPE=authorization_code
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_REDIRECT_URI={baseUrl}/login/oauth2/code/{registrationId}
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=

42
.github/release-template.md vendored Normal file
View file

@ -0,0 +1,42 @@
# SkillHub {{version}}
{{One-line summary of the key changes in this release}}
## 🌟 Highlights
- {{Highlight 1}}
- {{Highlight 2}}
- {{Highlight 3}}
## 🚨 Breaking Changes
⚠️ {{If any, describe impact and migration guide}}
## ✨ Features
- {{Feature description}} by @author in #PR
## 🐛 Bug Fixes
- {{Fix description}} by @author in #PR
## ⚡ Performance
- {{Performance improvement}} by @author in #PR
## 📚 Documentation
- {{Documentation changes}} by @author in #PR
## 🔧 Chore
- {{Maintenance work}} by @author in #PR
## 📖 Documentation
- Docs site: https://iflytek.github.io/skillhub/
## 👥 New Contributors
{{Keep as-is}}
**Full Changelog**: https://github.com/iflytek/skillhub/compare/{{prev_tag}}...{{tag}}

View file

@ -138,6 +138,47 @@ export class GitHubClient {
);
}
async listCommitPulls(sha: string): Promise<Array<{ number: number; title: string }>> {
return this.request(
"GET",
`/repos/${this.owner}/${this.repo}/commits/${sha}/pulls`,
);
}
async createDraftRelease(
tag: string,
name: string,
body: string,
): Promise<{ id: number; upload_url: string }> {
return this.request("POST", `/repos/${this.owner}/${this.repo}/releases`, {
tag_name: tag,
name,
body,
draft: true,
prerelease: false,
});
}
async uploadReleaseAsset(
releaseId: number,
filename: string,
content: string,
): Promise<void> {
const uploadUrl = `https://uploads.github.com/repos/${this.owner}/${this.repo}/releases/${releaseId}/assets?name=${encodeURIComponent(filename)}`;
const response = await fetch(uploadUrl, {
method: "POST",
headers: {
...this.headers(),
"Content-Type": "text/markdown",
},
body: content,
});
if (!response.ok) {
throw await GitHubApiError.fromResponse(response);
}
}
private async paginate<T>(path: string): Promise<T[]> {
const collected: T[] = [];
let nextPath: string | null = path;

View file

@ -37,6 +37,35 @@ export async function requestOpenAiCompatibleJson(
throw lastError ?? new Error("LLM request failed for an unknown reason.");
}
export async function requestOpenAiCompatibleMarkdown(
config: IssueLlmConfig,
systemPrompt: string,
userPrompt: string,
): Promise<string> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
try {
return await requestOnceMarkdown(config, systemPrompt, userPrompt);
} catch (error) {
const normalized = normalizeRequestError(
error,
attempt,
config.maxAttempts,
);
lastError = normalized;
if (!shouldRetry(error) || attempt >= config.maxAttempts) {
throw normalized;
}
await sleep(resolveRetryDelay(error, config.retryBackoffMs, attempt));
}
}
throw lastError ?? new Error("LLM request failed for an unknown reason.");
}
async function requestOnce(
config: IssueLlmConfig,
systemPrompt: string,
@ -88,6 +117,57 @@ async function requestOnce(
}
}
async function requestOnceMarkdown(
config: IssueLlmConfig,
systemPrompt: string,
userPrompt: string,
): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
try {
const response = await fetch(`${config.baseUrl}/chat/completions`, {
method: "POST",
signal: controller.signal,
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: config.model,
temperature: config.temperature,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
}),
});
if (!response.ok) {
const message =
`LLM request failed with status ${response.status}: ${await response
.text()}`;
throw new RetryableHttpError(
message,
response.status,
response.headers.get("retry-after"),
);
}
const payload = (await response.json()) as OpenAiCompatibleResponse;
const content = payload.choices?.[0]?.message?.content;
const text = normalizeMessageContent(content);
if (!text) {
throw new Error("LLM response did not include message content.");
}
return text;
} finally {
clearTimeout(timeout);
}
}
class RetryableHttpError extends Error {
status: number;
retryAfterSeconds: number | null;

53
.github/scripts/release-notes-config.ts vendored Normal file
View file

@ -0,0 +1,53 @@
import { IssueLlmConfig } from "./issue-llm-types.ts";
const DEFAULT_TIMEOUT_MS = 30000;
const DEFAULT_MAX_ATTEMPTS = 2;
const DEFAULT_RETRY_BACKOFF_MS = 1500;
const DEFAULT_TEMPERATURE = 0.2;
export function readReleaseNotesLlmConfig(): IssueLlmConfig | null {
const baseUrl = normalizeUrl(
Deno.env.get("RELEASE_NOTES_LLM_BASE_URL") ||
Deno.env.get("ISSUE_TRIAGE_LLM_BASE_URL"),
);
const apiKey = (
Deno.env.get("RELEASE_NOTES_LLM_API_KEY") ||
Deno.env.get("ISSUE_TRIAGE_LLM_API_KEY")
)?.trim() ?? "";
const model = (
Deno.env.get("RELEASE_NOTES_LLM_MODEL") ||
Deno.env.get("ISSUE_TRIAGE_LLM_MODEL")
)?.trim() ?? "";
if (!baseUrl || !apiKey || !model) {
console.warn(
"LLM config missing, will use fallback mode (conventional commit grouping)",
);
return null;
}
return {
mode: "assist",
provider: "openai-compatible",
baseUrl,
apiKey,
model,
timeoutMs: DEFAULT_TIMEOUT_MS,
maxAttempts: DEFAULT_MAX_ATTEMPTS,
retryBackoffMs: DEFAULT_RETRY_BACKOFF_MS,
temperature: DEFAULT_TEMPERATURE,
maxComments: 0,
maxCommentChars: 0,
maxBodyChars: 0,
};
}
function normalizeUrl(value: string | undefined | null) {
const trimmed = value?.trim();
if (!trimmed) {
return "";
}
return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
}

View file

@ -0,0 +1,271 @@
import { GitHubClient } from "./github.ts";
import { IssueLlmConfig } from "./issue-llm-types.ts";
import { requestOpenAiCompatibleMarkdown } from "./issue-llm-provider.ts";
interface RawCommit {
sha: string;
message: string;
author: string;
prNumber?: number;
prTitle?: string;
excluded: boolean;
}
interface ChangeEntry {
type: string;
scope: string;
description: string;
prNumber?: number;
authors: string[];
commits: string[];
}
export async function generateReleaseNotes(
owner: string,
repo: string,
tag: string,
prevTag: string,
llmConfig: IssueLlmConfig | null,
dryRun: boolean,
): Promise<string> {
const github = new GitHubClient(Deno.env.get("GH_TOKEN") ?? "", owner, repo);
console.log(`Collecting changes from ${prevTag} to ${tag}...`);
const changes = await collectChanges(github, prevTag, tag);
console.log(`Found ${changes.length} changes after deduplication`);
if (llmConfig) {
console.log("Generating release notes with LLM...");
try {
const markdown = await generateMarkdownWithLLM(
llmConfig,
changes,
tag,
prevTag,
owner,
repo,
);
return markdown;
} catch (error) {
console.error("LLM generation failed:", error);
console.log("Falling back to conventional commit grouping...");
return generateFallback(changes, tag, prevTag, owner, repo);
}
} else {
console.log("Using fallback mode (conventional commit grouping)...");
return generateFallback(changes, tag, prevTag, owner, repo);
}
}
async function collectChanges(
github: GitHubClient,
prevTag: string,
tag: string,
): Promise<ChangeEntry[]> {
const gitLogCmd = new Deno.Command("git", {
args: ["log", `${prevTag}..${tag}`, "--format=%H|%s|%an"],
stdout: "piped",
});
const gitLogOutput = await gitLogCmd.output();
const gitLogText = new TextDecoder().decode(gitLogOutput.stdout);
const rawCommits: RawCommit[] = [];
for (const line of gitLogText.trim().split("\n")) {
if (!line) continue;
const [sha, message, author] = line.split("|");
rawCommits.push({
sha,
message,
author,
excluded: false,
});
}
for (const commit of rawCommits) {
if (/^Revert "(.+)"$/.test(commit.message)) {
commit.excluded = true;
const revertedMsg = commit.message.match(/^Revert "(.+)"$/)?.[1];
if (revertedMsg) {
const reverted = rawCommits.find((c) => c.message === revertedMsg);
if (reverted) reverted.excluded = true;
}
}
if (/^Merge (pull request|branch|remote-tracking)/.test(commit.message)) {
commit.excluded = true;
}
}
for (const commit of rawCommits.filter((c) => !c.excluded)) {
try {
const pulls = await github.listCommitPulls(commit.sha);
if (pulls.length > 0) {
commit.prNumber = pulls[0].number;
commit.prTitle = pulls[0].title;
}
} catch (error) {
console.warn(`Failed to fetch PR for commit ${commit.sha}:`, error);
}
}
const prMap = new Map<number, ChangeEntry>();
const standaloneCommits: ChangeEntry[] = [];
for (const commit of rawCommits.filter((c) => !c.excluded)) {
const parsed = parseConventionalCommit(commit.message);
const description = commit.prTitle || parsed.description;
if (commit.prNumber) {
if (!prMap.has(commit.prNumber)) {
prMap.set(commit.prNumber, {
type: parsed.type,
scope: parsed.scope,
description,
prNumber: commit.prNumber,
authors: [commit.author],
commits: [commit.sha],
});
} else {
const entry = prMap.get(commit.prNumber)!;
if (!entry.authors.includes(commit.author)) {
entry.authors.push(commit.author);
}
entry.commits.push(commit.sha);
}
} else {
standaloneCommits.push({
type: parsed.type,
scope: parsed.scope,
description,
authors: [commit.author],
commits: [commit.sha],
});
}
}
return [...prMap.values(), ...standaloneCommits];
}
function parseConventionalCommit(message: string): {
type: string;
scope: string;
description: string;
} {
const match = message.match(/^(\w+)(?:\(([^)]+)\))?: (.+)$/);
if (match) {
return {
type: match[1],
scope: match[2] || "",
description: match[3],
};
}
return {
type: "other",
scope: "",
description: message,
};
}
async function generateMarkdownWithLLM(
config: IssueLlmConfig,
changes: ChangeEntry[],
tag: string,
prevTag: string,
owner: string,
repo: string,
): Promise<string> {
const template = await Deno.readTextFile(".github/release-template.md");
const systemPrompt = `You are a senior product manager and technical documentation expert. Rewrite the following technical change list into user-friendly Release Notes.
Requirements:
1. Strictly follow the Markdown structure and heading levels of the template below
2. Remove any section entirely (including its heading) if there are no items for it
3. Rewrite technical jargon into language that end-users can understand
4. For Breaking Changes, add an upgrade / migration guide
5. Highlights must contain 2-4 items, distilled from the most important changes
6. PR number format: #123
7. Contributor format: @username
8. Replace {{version}}, {{prev_tag}}, {{tag}} with actual values
9. Output ONLY the Markdown content no extra commentary, no code fences
Template:
---
${template}
---`;
const changesList = changes.map((c) => {
const pr = c.prNumber ? ` (#${c.prNumber})` : "";
const authors = c.authors.map((a) => `@${a}`).join(", ");
return `- ${c.type}(${c.scope}): ${c.description}${pr} by ${authors}`;
}).join("\n");
const userPrompt = `Version: ${tag}
Previous version: ${prevTag}
Repository: ${owner}/${repo}
Change list:
${changesList}`;
const markdown = await requestOpenAiCompatibleMarkdown(
config,
systemPrompt,
userPrompt,
);
return markdown
.replace(/\{\{version\}\}/g, tag)
.replace(/\{\{prev_tag\}\}/g, prevTag)
.replace(/\{\{tag\}\}/g, tag);
}
function generateFallback(
changes: ChangeEntry[],
tag: string,
prevTag: string,
owner: string,
repo: string,
): string {
const grouped = new Map<string, ChangeEntry[]>();
for (const change of changes) {
const type = change.type;
if (!grouped.has(type)) {
grouped.set(type, []);
}
grouped.get(type)!.push(change);
}
const typeLabels: Record<string, string> = {
feat: "## ✨ Features",
fix: "## 🐛 Bug Fixes",
docs: "## 📚 Documentation",
perf: "## ⚡ Performance",
refactor: "## 🔧 Improvements",
test: "## 🧪 Tests",
chore: "## 🔧 Chore",
};
let md = `# SkillHub ${tag}\n\n`;
md += `> [Auto-generated - LLM unavailable]\n\n`;
for (const [type, items] of grouped.entries()) {
const label = typeLabels[type] || `## ${type}`;
md += `${label}\n\n`;
for (const item of items) {
const pr = item.prNumber ? ` in #${item.prNumber}` : "";
const authors = item.authors.map((a) => `@${a}`).join(", ");
md += `- ${item.description}${pr} by ${authors}\n`;
}
md += "\n";
}
const contributors = [
...new Set(changes.flatMap((c) => c.authors)),
];
md += `## 👥 Contributors\n\n`;
md += contributors.map((a) => `@${a}`).join(", ") + "\n\n";
md += `**Full Changelog**: https://github.com/${owner}/${repo}/compare/${prevTag}...${tag}\n`;
return md;
}

79
.github/scripts/release-notes.ts vendored Normal file
View file

@ -0,0 +1,79 @@
import { GitHubClient } from "./github.ts";
import { readReleaseNotesLlmConfig } from "./release-notes-config.ts";
import { generateReleaseNotes } from "./release-notes-generator.ts";
function readFlag(name: string): string | null {
const index = Deno.args.indexOf(name);
if (index === -1 || index === Deno.args.length - 1) {
return null;
}
return Deno.args[index + 1];
}
function hasFlag(name: string): boolean {
return Deno.args.includes(name);
}
async function detectPrevTag(tag: string): Promise<string> {
const cmd = new Deno.Command("git", {
args: ["tag", "--sort=-v:refname"],
stdout: "piped",
});
const output = await cmd.output();
const tags = new TextDecoder().decode(output.stdout).trim().split("\n");
const currentIndex = tags.indexOf(tag);
if (currentIndex === -1 || currentIndex === tags.length - 1) {
throw new Error(`Cannot find previous tag for ${tag}`);
}
return tags[currentIndex + 1];
}
async function main() {
const owner = readFlag("--owner");
const repo = readFlag("--repo");
const tag = readFlag("--tag");
const prevTagArg = readFlag("--prev-tag");
const dryRun = hasFlag("--dry-run");
const skipLlm = hasFlag("--skip-llm");
if (!owner || !repo || !tag) {
console.error("Usage: release-notes.ts --owner <owner> --repo <repo> --tag <tag> [--prev-tag <prev-tag>] [--dry-run] [--skip-llm]");
Deno.exit(1);
}
const prevTag = prevTagArg || await detectPrevTag(tag);
console.log(`Generating release notes for ${tag} (previous: ${prevTag})`);
const llmConfig = skipLlm ? null : readReleaseNotesLlmConfig();
const markdown = await generateReleaseNotes(
owner,
repo,
tag,
prevTag,
llmConfig,
dryRun,
);
if (dryRun) {
console.log("\n=== DRY RUN MODE ===\n");
console.log(markdown);
console.log("\n=== END DRY RUN ===");
return;
}
console.log("Creating draft release...");
const github = new GitHubClient(Deno.env.get("GH_TOKEN") ?? "", owner, repo);
const release = await github.createDraftRelease(tag, tag, markdown);
console.log(`Draft release created: ${release.id}`);
console.log(`\nDraft release created successfully!`);
console.log(`View at: https://github.com/${owner}/${repo}/releases/tag/${tag}`);
}
main().catch((error) => {
console.error("Error:", error);
Deno.exit(1);
});

33
.github/workflows/pr-cli.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: PR CLI
on:
pull_request:
paths:
- 'cli/**'
- 'Makefile'
- '.github/workflows/pr-cli.yml'
jobs:
cli:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- run: bun install --frozen-lockfile
working-directory: cli
- run: bun run lint
working-directory: cli
- run: bun run typecheck
working-directory: cli
- run: bun test
working-directory: cli
- run: bun run build
working-directory: cli
- run: node dist/index.js version
working-directory: cli

305
.github/workflows/release-cli.yml vendored Normal file
View file

@ -0,0 +1,305 @@
name: Release CLI
on:
push:
tags: ['cli-v*']
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. cli-v0.1.5)'
required: true
skip_npm:
description: 'Skip npm publish'
type: boolean
default: false
permissions:
contents: write
concurrency:
group: release-cli-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs:
build-and-test:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.extract.outputs.version }}
package_name: ${{ steps.extract.outputs.package_name }}
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Validate tag (workflow_dispatch only)
if: github.event_name == 'workflow_dispatch'
run: |
TAG="${{ github.event.inputs.tag }}"
# Verify tag exists
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "ERROR: Tag '$TAG' does not exist in the repository" >&2
exit 1
fi
# Verify current checkout matches the tag
TAG_SHA=$(git rev-parse "refs/tags/$TAG^{commit}")
CURRENT_SHA=$(git rev-parse HEAD)
if [ "$TAG_SHA" != "$CURRENT_SHA" ]; then
echo "ERROR: Current checkout SHA does not match tag '$TAG'" >&2
echo " Tag SHA: $TAG_SHA" >&2
echo " Current SHA: $CURRENT_SHA" >&2
echo "" >&2
echo "This indicates the checkout did not switch to the specified tag." >&2
exit 1
fi
echo "✓ Tag '$TAG' validated (SHA: $TAG_SHA)"
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- name: Extract version from tag
id: extract
working-directory: cli
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
if [[ ! "$TAG" =~ ^cli-v([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]]; then
echo "Invalid tag format: $TAG (expected cli-vX.Y.Z)"
exit 1
fi
VERSION="${BASH_REMATCH[1]}"
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = '$VERSION';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
PACKAGE_NAME=$(node -p "require('./package.json').name")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "package_name=$PACKAGE_NAME" >> "$GITHUB_OUTPUT"
echo "Version set to: $VERSION"
echo "Package name: $PACKAGE_NAME"
- name: Install dependencies
working-directory: cli
run: bun install --frozen-lockfile
- name: Run linter
working-directory: cli
run: bun run lint
- name: Run type check
working-directory: cli
run: bun run typecheck
- name: Run tests
working-directory: cli
run: bun test
- name: Build CLI
working-directory: cli
run: bun run build
- name: Verify built CLI
working-directory: cli
run: |
node dist/index.js version
RUNTIME_VERSION=$(node dist/index.js version | sed -E 's/^SkillHub CLI //')
if [ "$RUNTIME_VERSION" != "${{ steps.extract.outputs.version }}" ]; then
echo "Version mismatch: runtime=$RUNTIME_VERSION, tag=${{ steps.extract.outputs.version }}"
exit 1
fi
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: cli-dist
path: |
cli/dist/
cli/package.json
cli/README.md
cli/LICENSE
retention-days: 7
publish-npm:
needs: build-and-test
runs-on: ubuntu-latest
if: ${{ !inputs.skip_npm }}
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- name: Set version from tag
working-directory: cli
run: |
VERSION="${{ needs.build-and-test.outputs.version }}"
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = '$VERSION';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
- name: Install dependencies
working-directory: cli
run: bun install --frozen-lockfile
- name: Build CLI
working-directory: cli
run: bun run build
- name: Check if version exists on npm
id: check_npm
env:
NPM_REGISTRY: ${{ vars.NPM_REGISTRY || 'https://registry.npmjs.org' }}
run: |
PACKAGE_NAME="${{ needs.build-and-test.outputs.package_name }}"
VERSION="${{ needs.build-and-test.outputs.version }}"
# Three-state check: success (exists) / 404 (missing) / error (fail job)
set +e
NPM_OUTPUT=$(npm view "${PACKAGE_NAME}@${VERSION}" version --registry "$NPM_REGISTRY" 2>&1)
NPM_EXIT_CODE=$?
set -e
if [ $NPM_EXIT_CODE -eq 0 ]; then
# Success: version exists on registry
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Version $VERSION already exists on registry, skipping publish"
elif echo "$NPM_OUTPUT" | grep -Eiq '(E404|404 Not Found|is not in this registry|Not found)'; then
# Explicit 404: version does not exist
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Version $VERSION does not exist on registry, proceeding with publish"
else
# Uncertain state: network error, auth failure, registry error, etc.
echo "ERROR: Failed to check npm registry (exit code: $NPM_EXIT_CODE)" >&2
echo "Output: $NPM_OUTPUT" >&2
echo "" >&2
echo "This could be due to:" >&2
echo " - Network connectivity issues" >&2
echo " - Registry service errors (5xx)" >&2
echo " - Authentication/authorization failures" >&2
echo " - DNS or TLS problems" >&2
echo "" >&2
echo "Cannot safely determine if version exists. Failing job to prevent silent skip." >&2
exit 1
fi
- name: Configure npm authentication
if: steps.check_npm.outputs.exists == 'false'
env:
NPM_REGISTRY: ${{ vars.NPM_REGISTRY || 'https://registry.npmjs.org' }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
REGISTRY_HOST="${NPM_REGISTRY#http://}"
REGISTRY_HOST="${REGISTRY_HOST#https://}"
REGISTRY_HOST="${REGISTRY_HOST%/}"
cat > ~/.npmrc <<EOF
registry=${NPM_REGISTRY}
//${REGISTRY_HOST}/:_authToken=${NPM_TOKEN}
always-auth=true
EOF
- name: Publish to npm
if: steps.check_npm.outputs.exists == 'false'
working-directory: cli
env:
NPM_REGISTRY: ${{ vars.NPM_REGISTRY || 'https://registry.npmjs.org' }}
run: |
npm publish --access public --registry "$NPM_REGISTRY"
echo "Published ${{ needs.build-and-test.outputs.package_name }}@${{ needs.build-and-test.outputs.version }}"
create-release:
needs: [build-and-test, publish-npm]
runs-on: ubuntu-latest
# Only create the GitHub Release after npm publish has actually succeeded
# (or was explicitly skipped via skip_npm=true). This prevents a
# half-released state where Release exists but `npm install -g` fails.
if: ${{ always() && needs.build-and-test.result == 'success' && (needs.publish-npm.result == 'success' || (inputs.skip_npm && needs.publish-npm.result == 'skipped')) }}
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: cli-dist
path: cli-release
- name: Create release archives
run: |
VERSION="${{ needs.build-and-test.outputs.version }}"
# Create tar.gz
tar -czf "skillhub-cli-${VERSION}.tar.gz" -C cli-release .
# Create zip
(cd cli-release && zip -r "../skillhub-cli-${VERSION}.zip" .)
# Generate checksums
sha256sum "skillhub-cli-${VERSION}.tar.gz" > "skillhub-cli-${VERSION}.tar.gz.sha256"
sha256sum "skillhub-cli-${VERSION}.zip" > "skillhub-cli-${VERSION}.zip.sha256"
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
VERSION="${{ needs.build-and-test.outputs.version }}"
PACKAGE_NAME="${{ needs.build-and-test.outputs.package_name }}"
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $TAG already exists, skipping"
exit 0
fi
# Generate release notes
cat > release-notes.md <<EOF
# SkillHub CLI ${VERSION}
## Installation
### npm
\`\`\`bash
npm install -g ${PACKAGE_NAME}@${VERSION}
\`\`\`
### From source
Download and extract the archive, then:
\`\`\`bash
npm install -g .
\`\`\`
## Verify installation
\`\`\`bash
skillhub version
\`\`\`
## Changes
See commit history for details.
EOF
gh release create "$TAG" \
--title "CLI ${VERSION}" \
--notes-file release-notes.md \
"skillhub-cli-${VERSION}.tar.gz" \
"skillhub-cli-${VERSION}.tar.gz.sha256" \
"skillhub-cli-${VERSION}.zip" \
"skillhub-cli-${VERSION}.zip.sha256"

46
.github/workflows/release-notes.yml vendored Normal file
View file

@ -0,0 +1,46 @@
name: AI Release Notes
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Tag to generate release notes for (e.g. v0.3.0)"
required: true
prev_tag:
description: "Previous tag (auto-detect if empty)"
required: false
permissions:
contents: write
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
with:
deno-version: v2.x
- name: Generate release notes
env:
GH_TOKEN: ${{ github.token }}
RELEASE_NOTES_LLM_BASE_URL: ${{ vars.RELEASE_NOTES_LLM_BASE_URL || 'https://models.inference.ai.azure.com' }}
RELEASE_NOTES_LLM_API_KEY: ${{ secrets.RELEASE_NOTES_LLM_API_KEY || github.token }}
RELEASE_NOTES_LLM_MODEL: ${{ vars.RELEASE_NOTES_LLM_MODEL || 'gpt-4o-mini' }}
ISSUE_TRIAGE_LLM_BASE_URL: ${{ vars.ISSUE_TRIAGE_LLM_BASE_URL }}
ISSUE_TRIAGE_LLM_API_KEY: ${{ secrets.ISSUE_TRIAGE_LLM_API_KEY }}
ISSUE_TRIAGE_LLM_MODEL: ${{ vars.ISSUE_TRIAGE_LLM_MODEL }}
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
PREV="${{ github.event.inputs.prev_tag }}"
ARGS="--owner ${{ github.repository_owner }} --repo ${{ github.event.repository.name }} --tag $TAG"
[ -n "$PREV" ] && ARGS="$ARGS --prev-tag $PREV"
deno run --allow-env --allow-net --allow-run --allow-read \
.github/scripts/release-notes.ts $ARGS

6
.gitignore vendored
View file

@ -62,6 +62,7 @@ package-lock.json
**/.playwright/
**/playwright-report/
**/test-results/
.playwright-mcp/
# Python / temporary files
*.py[cod]
@ -74,11 +75,14 @@ __pycache__/
# Superpowers (AI planning artifacts)
.superpowers/
docs/agents/
docs/prds/
docs/requirements/
docs/review/
docs/superpowers/
# Local workspace metadata
AGENTS.md
CLAUDE.md
# Local config file
.mcp.json

599
AGENTS.md Normal file
View file

@ -0,0 +1,599 @@
# SkillHub — AGENTS.md
**SkillHub** is an **enterprise-grade, self-hosted agent skill registry** for publishing,
discovering, and managing reusable skill packages across an organization. It provides a **REST API
backend**, a **React web UI**, a **security scanner**, and a **ClawHub CLI compatibility layer**.
## Quick Reference
| Item | Value |
|------------|------------------------------------------------------------|
| Backend | Spring Boot 3.2.3, Java 21, Maven multi-module (7 modules) |
| Frontend | React 19, TypeScript, Vite, pnpm |
| Scanner | Python (FastAPI), port 8000 |
| Database | PostgreSQL 16 (Flyway migrations) |
| Cache | Redis 7 (sessions, distributed locks, idempotency) |
| Storage | LocalFile (dev) / S3/MinIO (prod) |
| Build | `make dev-all` (dev), `make staging` (pre-PR) |
| Docs | `docs/` (design), `document/` (VitePress user guide) |
| CI | GitHub Actions (`.github/workflows/`) |
## Directory Map
```
skillhub/
├── server/ # Maven multi-module Spring Boot backend
│ ├── skillhub-app/ # Application layer: bootstrap, controllers, assembly
│ │ ├── bootstrap/ # Bootstrap admin & local dev data initializers
│ │ ├── compat/ # ClawHub CLI compatibility layer controllers
│ │ ├── config/ # Spring configuration classes
│ │ ├── controller/ # REST controllers (transport only)
│ │ │ ├── admin/ # Admin controllers (user mgmt, labels, search)
│ │ │ ├── portal/ # Portal controllers (skills, governance, security)
│ │ │ └── support/ # Package extractors (zip, multipart)
│ │ ├── dto/ # Request/response DTOs
│ │ ├── exception/ # Exception handling
│ │ ├── filter/ # Servlet filters (auth context, rate limiting)
│ │ ├── listener/ # Event listeners (notification recipients, etc.)
│ │ ├── metrics/ # Micrometer metrics
│ │ ├── projection/ # Lifecycle projection models
│ │ ├── ratelimit/ # Rate limiting logic
│ │ ├── repository/ # Query repositories (read-model assembly)
│ │ ├── security/ # Security configuration
│ │ ├── service/ # App services (workflow orchestration)
│ │ ├── stream/ # SSE streaming endpoints
│ │ ├── task/ # Background task scheduling
│ │ └── SkillhubApplication.java # Spring Boot entry point
│ │
│ ├── skillhub-domain/ # Domain layer: entities, rules, services (innermost)
│ │ ├── audit/ # AuditLog entity, repository, service
│ │ ├── auth/ # Password reset entities
│ │ ├── event/ # Domain event classes (SkillPublishedEvent, etc.)
│ │ ├── governance/ # Governance notification service
│ │ ├── idempotency/ # Idempotency records
│ │ ├── label/ # Skill label management
│ │ ├── namespace/ # Namespace, members, roles, policies
│ │ ├── report/ # Skill reporting/governance
│ │ ├── review/ # Review tasks, promotion requests
│ │ ├── security/ # Security scanning domain model
│ │ ├── shared/ # Shared domain utilities
│ │ │ └── exception/ # Domain exceptions (LocalizedDomainException, etc.)
│ │ ├── skill/ # Core skill entities and services
│ │ │ ├── metadata/ # SKILL.md frontmatter parsing
│ │ │ ├── service/ # Skill domain services (publish, query, governance)
│ │ │ └── validation/ # Package validation (SkillPackagePolicy, etc.)
│ │ ├── social/ # Star, rating, subscription entities
│ │ └── user/ # UserAccount, profile moderation
│ │
│ ├── skillhub-auth/ # Authentication & authorization
│ │ ├── config/ # Spring Security configuration
│ │ ├── device/ # OAuth Device Flow for CLI auth
│ │ ├── identity/ # Identity binding service
│ │ ├── local/ # Local (password) auth
│ │ ├── merge/ # Account merging
│ │ ├── oauth/ # OAuth2 login handlers
│ │ ├── policy/ # Route security policies
│ │ ├── rbac/ # RBAC service and role definitions
│ │ ├── token/ # API token management
│ │ └── user/ # User-related auth services
│ │
│ ├── skillhub-search/ # Search SPI + PostgreSQL full-text implementation
│ │ ├── postgres/ # PostgresFullTextIndexService, QueryService
│ │ └── service/ # Search SPI interfaces
│ │
│ ├── skillhub-storage/ # Object storage SPI
│ │ ├── local/ # LocalFileStorageService
│ │ └── s3/ # S3StorageService (AWS SDK v2)
│ │
│ ├── skillhub-infra/ # Infrastructure: JPA repos, utilities
│ │ └── repository/ # Spring Data JPA repository implementations
│ │
│ ├── skillhub-notification/ # Notification service (SSE, email)
│ │ ├── domain/ # Notification domain model
│ │ ├── service/ # Notification delivery services
│ │ └── sse/ # SSE endpoint support
│ │
│ ├── Dockerfile.dev # Dockerfile for staging builds
│ ├── Dockerfile # Production multi-stage build
│ ├── pom.xml # Parent POM (Spring Boot 3.2.3 parent)
│ └── scripts/
│ └── run-dev-app.sh # Local dev startup script
├── web/ # React frontend (Vite + pnpm)
│ ├── src/
│ │ ├── api/ # OpenAPI-generated types + fetch client
│ │ │ └── generated/
│ │ │ └── schema.d.ts # Generated OpenAPI types (CHECKED IN)
│ │ ├── app/ # Router, layout, global providers
│ │ ├── docs/ # In-app documentation pages
│ │ ├── entities/ # Domain entity display logic
│ │ │ ├── skill/ # Skill card, detail components
│ │ │ ├── user/ # User profile components
│ │ │ └── namespace/ # Namespace display components
│ │ ├── features/ # Business feature modules
│ │ │ ├── admin/ # Admin panel features
│ │ │ ├── auth/ # Login, OAuth flows
│ │ │ ├── governance/ # Skill governance actions
│ │ │ ├── namespace/ # Namespace management
│ │ │ ├── notification/ # User notifications
│ │ │ ├── promotion/ # Skill promotion workflows
│ │ │ ├── publish/ # Skill upload/publish UI
│ │ │ ├── report/ # Skill reporting
│ │ │ ├── review/ # Review workflow UI
│ │ │ ├── search/ # Skill search and filtering
│ │ │ ├── security-audit/ # Security audit viewer
│ │ │ ├── skill/ # Skill detail, listing
│ │ │ ├── social/ # Stars, ratings, subscriptions
│ │ │ └── token/ # API token management
│ │ ├── i18n/ # Internationalization
│ │ ├── pages/ # Route-level page components
│ │ ├── shared/ # Shared UI, hooks, utilities
│ │ │ ├── components/ # Reusable UI components
│ │ │ ├── hooks/ # Custom React hooks
│ │ │ ├── lib/
│ │ │ │ └── utils.ts # cn() class merging utility
│ │ │ └── ui/ # Radix UI-based primitives
│ │ └── types/ # Additional TypeScript types
│ ├── e2e/ # Playwright E2E tests
│ ├── nginx.conf.template # Nginx runtime config template
│ ├── Dockerfile # Multi-stage build (Node → Nginx)
│ └── package.json # Dependencies (React 19, TanStack Query, Radix UI, etc.)
├── scanner/ # Security scanner (Python/FastAPI)
│ ├── docs/ # Scanner documentation
│ ├── examples/ # Example scan inputs/outputs
│ └── Dockerfile # Scanner container build
├── docs/ # Design documents (source of truth)
│ ├── prds/ # Product requirement documents
│ ├── skillhub/ # VitePress user guide source
│ └── superpowers/ # Internal tooling docs
├── document/ # VitePress documentation site (published)
│ ├── docs/ # Markdown documentation
│ ├── src/ # VitePress theme
│ └── i18n/ # Internationalization
├── deploy/k8s/ # Kubernetes manifests (basic)
├── monitoring/ # Prometheus + Grafana stack
├── scripts/ # Build, test, and deployment scripts
│ ├── smoke-test.sh # Basic API smoke test
│ ├── namespace-smoke-test.sh # Namespace workflow smoke test
│ ├── governance-smoke-test.sh # Governance flow smoke test
│ ├── promotion-smoke-test.sh # Promotion flow smoke test
│ ├── check-openapi-generated.sh # Verify OpenAPI SDK is not stale
│ ├── validate-release-config.sh # Validate release env configuration
│ ├── dev-process.sh # Local process manager (PID-based)
│ ├── runtime.sh # Runtime deployment script
│ ├── parallel-init.sh # Parallel worktree initialization
│ ├── parallel-sync.sh # Merge worktrees in integration branch
│ ├── parallel-up.sh # Merge + start dev environment
│ ├── parallel-down.sh # Stop parallel dev environment
│ └── prepare-pr-batch.sh # Batch PR preparation
├── .github/
│ ├── workflows/ # GitHub Actions CI/CD
│ ├── ISSUE_TEMPLATE/ # Issue templates
│ └── scripts/ # Deno scripts for triage, release notes, rewards
├── AGENTS.md # AI agent rules (this file)
├── .agents/skills/ # Focused AI skill definitions
├── Makefile # Top-level build/test/dev orchestration
├── docker-compose.yml # Local dev dependency services
├── compose.release.yml # Production release compose file
├── CONTRIBUTING.md # Contribution guidelines
├── CODE_OF_CONDUCT.md # Community standards
└── README.md # Project overview
```
**Key Locations for Common Tasks:**
| Task | Where to Look |
|------|---------------|
| Add REST endpoint | `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/` |
| Add domain entity/service | `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/` |
| Add auth logic | `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/` |
| Add search logic | `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/` |
| Add query repository | `server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/` |
| Change RBAC/roles | `server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/` |
| Change skill validation | `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/` |
| Add frontend page | `web/src/pages/` |
| Add frontend feature | `web/src/features/` |
| Add shared component | `web/src/shared/components/` |
| Change API contract | Backend controller → run `make generate-api` → commit generated file |
| Add smoke test | `scripts/` (new `.sh` file) |
| Add E2E test | `web/e2e/` (Playwright) |
| Add backend test | `server/skillhub-*/src/test/java/` (alongside source module) |
## Critical Rules
### Do Not Manually Edit Generated Files
- `web/src/api/generated/schema.d.ts` — regenerated via `make generate-api`
- `document/docs/` — auto-generated user documentation (VitePress)
- `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/` — some DTOs may be generated
### After Making Changes
**Backend changes:**
- Edit Java code → `make dev-server-restart` (local dev)
- Add/modify controller → `make generate-api` to regenerate frontend types
- Add/modify domain service → `make test-backend-app` to verify tests
**Frontend changes:**
- Edit TypeScript/React → Vite HMR handles reload automatically
- After `make generate-api` → commit updated `web/src/api/generated/schema.d.ts`
**Always run before PR:**
```bash
make test-backend-app # Backend tests (with dependent modules)
make typecheck-web # Frontend type check
make lint-web # Frontend lint
make staging # Full staging regression + smoke test
```
### File-Specific Requirements
- **Controllers** (`skillhub-app/controller/`) are transport only: extract auth context,
bind request params, wrap responses. No business logic.
- **App Services** (`skillhub-app/service/`) orchestrate workflows. Do not embed complex
read-model assembly here — extract to query repositories.
- **Query Repositories** (`skillhub-app/repository/`) handle read-model joins and presentation
projection. Named like `*QueryRepository`.
- **Domain Services** (`skillhub-domain/*/service/`) contain business rules and state transitions.
Return domain objects, not DTOs.
- **Repository Interfaces** are defined in `skillhub-domain`, implemented in `skillhub-infra`.
- **Domain Exceptions** use `LocalizedDomainException` for user-facing messages with i18n keys.
- **Package-info files** (`package-info.java`) should exist for all packages.
## Development Workflow
### Build & Start
```bash
make dev-all # Start full stack: Postgres, Redis, MinIO, backend, frontend
make dev-all-down # Stop everything
make dev-all-reset # Full reset (clears data volumes)
make dev-status # Check service status
make dev-server-restart # Restart backend after Java changes
```
**Access points:**
- Web UI: `http://localhost:3000`
- Backend API: `http://localhost:8080`
- Scanner: `http://localhost:8000`
**Local mock users** (no password needed):
| User ID | Role | Header |
|---------|------|--------|
| `local-user` | Regular user | `X-Mock-User-Id: local-user` |
| `local-admin` | Super admin | `X-Mock-User-Id: local-admin` |
**Bootstrap admin** (password-based, local profile):
- Username: `admin` / Password: `ChangeMe!2026`
- Disable with `BOOTSTRAP_ADMIN_ENABLED=false`
### Lint & Format
```bash
# Backend: enforced by Maven build (no separate lint target)
# Frontend:
make lint-web # ESLint check
make typecheck-web # TypeScript check
```
### Testing
```bash
make test-backend-app # Backend unit tests (skillhub-app + dependencies)
make test-backend # All backend module tests
make test-frontend # Frontend unit tests (Vitest)
make test-e2e-frontend # Frontend E2E tests (Playwright)
make test-e2e-smoke-frontend # Frontend E2E smoke tests
./scripts/smoke-test.sh # API smoke test
make namespace-smoke # Namespace workflow smoke test
```
### Staging (Pre-PR Regression)
```bash
make staging # Build backend Docker image + frontend static + smoke test
make staging-down # Tear down
SERVICE=web make staging-logs # View Nginx logs
```
Staging validates the containerized deployment path:
- Backend: built as Docker image from local source
- Frontend: built as static files, served by Nginx
- Dependencies: same Postgres/Redis/MinIO as local dev
### Parallel Agent Workflow
For parallel development with isolated worktrees:
```bash
make parallel-init TASK=feature-name
```
Creates dedicated Claude, Codex, and integration worktrees as sibling directories.
See `docs/13-parallel-workflow.md` for details.
## PR Submission
### PR Title Format
Use conventional commit style:
```
<type>(<scope>): <description>
```
**Types:**
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `test`: Adding or updating tests
- `refactor`: Code restructuring (no behavior change)
- `chore`: Build, CI, or maintenance tasks
**Scopes:** Module or domain name (e.g., `auth`, `search`, `publish`, `review`, `namespace`)
**Examples:**
```
feat(auth): add local account login
fix(publish): resolve null pointer in skill validation
docs(deploy): clarify runtime image usage
test(namespace): add membership service tests
refactor(review): extract query repository for governance list
chore(ci): add parallel workflow scripts
```
### Pre-PR Checklist
- [ ] Backend tests pass: `make test-backend-app`
- [ ] Frontend typecheck passes: `make typecheck-web`
- [ ] If API changed: `make generate-api` was run and `web/src/api/generated/schema.d.ts` is committed
- [ ] Smoke test passes: `make staging`
- [ ] Follow existing module boundaries and dependency direction
- [ ] Add/update tests for new behavior
- [ ] Update docs when APIs, auth flows, deployment, or operator workflows change
## Core Concepts
### Backend Clean Architecture
```
app → domain, auth, search, storage, infra, notification
infra → domain # implements domain repository interfaces
auth → domain
search → domain
notification → domain
storage → (independent) # pure SPI
```
**Design intent**: `skillhub-domain` is the innermost layer. It defines entities, repository
interfaces, and domain services without depending on infra, auth, search, or storage.
**Code reality**: `skillhub-domain` declares a Maven dependency on `skillhub-storage`, and several
domain services (`SkillHardDeleteService`, `SkillDownloadService`, `SkillPublishService`,
`SkillGovernanceService`, `SkillQueryService`, `SkillStorageDeletionCompensationService`) import
`com.iflytek.skillhub.storage.ObjectStorageService`. This is an existing deviation from the ideal.
New code should avoid adding further cross-module dependencies from domain.
### Repository / Query Boundary
When adding new read logic, follow these rules:
1. **Domain repository ports** (`skillhub-domain`): Aggregate reads, state transitions, rule
evaluation. Used by domain services.
2. **App query repositories** (`com.iflytek.skillhub.repository`): Read-model assembly that joins
multiple sources, presentation projection. Used by controllers and app services.
3. **App services** (`com.iflytek.skillhub.service`): Workflow orchestration. Should express "what
this endpoint does", not "how it assembles DTOs".
4. **Direct SQL / EntityManager**: Only when necessary, with class-level comment explaining why.
**Do not** add complex read-model assembly logic inside app services. Extract it into a query
repository when it joins multiple sources, does presentation projection, or is reused across services.
### Skill Lifecycle
`SkillVersionStatus` values: `DRAFT`, `SCANNING`, `SCAN_FAILED`, `UPLOADED`, `PENDING_REVIEW`,
`PUBLISHED`, `REJECTED`, `YANKED`.
`SkillStatus` enum values: `ACTIVE`, `HIDDEN`, `ARCHIVED`.
The design doc (`docs/14-skill-lifecycle.md`) specifies that `hidden` should be treated as a
governance overlay rather than a lifecycle state. The current code still defines
`SkillStatus.HIDDEN` in the enum. Follow the design doc's intent for new code.
**Key transitions:**
- Normal user first upload → `PENDING_REVIEW` (no initial DRAFT)
- SUPER_ADMIN first upload → `PUBLISHED` (direct publish)
- Review approve → `PENDING_REVIEW``PUBLISHED` (updates `latestVersionId`)
- Review reject → `PENDING_REVIEW``REJECTED`
- Withdraw review → `PENDING_REVIEW``UPLOADED` (also deletes pending review_task)
- Yank → `PUBLISHED``YANKED` (must recalculate `latestVersionId`)
- Hide/restore → independent `hidden` flag (governance overlay)
- Archive/Unarchive → `ACTIVE``ARCHIVED` (container state)
### Namespace Coordinate System
SkillHub uses `@{namespace_slug}/{skill_slug}`:
- `@global/my-skill` — Platform-level public namespace
- `@my-team/my-skill` — Team/department namespace
ClawHub CLI compatibility maps:
| SkillHub | Canonical Slug |
|----------|---------------|
| `@global/my-skill` | `my-skill` |
| `@team-name/my-skill` | `team-name--my-skill` |
### Authentication
- Web: OAuth2 (GitHub) + local password auth
- CLI: OAuth Device Flow (web authorization → CLI credentials)
- Programmatic: API tokens (prefix-based secure hashing)
- Session: Spring Session + Redis
### RBAC
Platform roles: `SUPER_ADMIN`, `SKILL_ADMIN`, `USER_ADMIN`, `AUDITOR`
Namespace roles: `OWNER`, `ADMIN`, `MEMBER`
### Skill Package Protocol
- Root: `SKILL.md` with YAML frontmatter (`name`, `description` required)
- Allowed extensions (50+ types): `.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.ts`, `.py`,
`.sh`, `.png`, `.jpg`, `.svg`, and many more (see `SkillPackagePolicy.ALLOWED_EXTENSIONS`)
- Limits: 10MB per file, 100MB total, 500 files max
- File type signatures validated (PNG magic bytes, SVG content check, etc.)
### Frontend State Management
- **TanStack Query** (`@tanstack/react-query`): All server state (API data)
- **Zustand**: Local/UI state (theme, sidebar, modals)
- **Never** use `useEffect` for data fetching
### Frontend Component Composition
- **Radix UI** primitives: `@radix-ui/react-dropdown-menu`, `@radix-ui/react-select`
- **class-variance-authority** (cva) for component variants
- **clsx** + **tailwind-merge** for class merging
- **`cn()` utility**: `web/src/shared/lib/utils.ts`
- shadcn/ui is NOT used as a library — only Radix primitives + utility composition
## Common Patterns
### Code Style
**Java:**
- User identity type is always `String` throughout the codebase
- Use Java 21 features (records, pattern matching, virtual threads)
- Follow existing naming patterns in the domain layer
- Error strings for `DomainBadRequestException`, etc. should be clear and actionable
**TypeScript:**
- Strict mode. No `any` types.
- Use generated OpenAPI types for all API interactions.
- Feature-Sliced Design: place code at the lowest appropriate layer.
### Testing Philosophy
- Backend: JUnit 5 + Mockito + AssertJ
- Frontend: Vitest for unit tests, Playwright for E2E
- **Use `make test-backend-app`** (includes `-am` for dependent modules) — never run
`./mvnw -pl skillhub-app clean test` directly, as it can use stale Maven cache artifacts
- Test behaviors, not implementations
- Use Spring Boot test slices where possible (`@WebMvcTest`, `@DataJpaTest`)
### Frontend Testing
```bash
make test-frontend # Vitest unit tests
make test-e2e-frontend # Playwright E2E
make test-e2e-smoke-frontend # Playwright smoke (subset of E2E)
```
### Logging Conventions
- **Backend**: SLF4J + Spring Boot logging. Use structured logging with MDC for request tracing.
- **Frontend**: `console.error` for errors, `console.warn` for deprecations, avoid `console.log` in production code.
- **Scanner**: Python logging module with structured JSON output.
### Security
- API tokens are stored as prefix-based secure hashes, never in plaintext
- OAuth2 client secrets and other secrets must not be logged or committed
- User identity is `String` (supports external SSO/OIDC/SCIM identity sources)
- The bootstrap admin (`BOOTSTRAP_ADMIN_ENABLED`) is for zero-config quickstart only
## Search Tips
```bash
# Find all REST endpoints
rg "@(Get|Post|Put|Delete|Patch)Mapping" --type java
# Find domain services
rg "class.*Service" server/skillhub-domain/
# Find query repositories
rg "QueryRepository" server/skillhub-app/
# Find controllers
rg "@RestController" server/skillhub-app/
# Find RBAC role checks
rg "@PreAuthorize" server/skillhub-app/
# Find skill validation logic
rg "SkillPackage" server/skillhub-domain/
# Find frontend features
rg "export" web/src/features/
# Find OpenAPI type generation script
rg "generate-api" web/package.json
# Find event listeners
rg "@EventListener" server/
```
## Design Philosophy
- **Hub first**: The server-side registry is the core product; CLI and agent integrations are entry capabilities
- **Compatibility first**: Support `SKILL.md` format and common directory conventions
- **Layered architecture**: Search and object storage must have replaceable boundaries (SPI pattern)
- **Open authentication**: OAuth2-based, extensible to multiple providers beyond GitHub
- **Audit first**: Enterprise distribution requires audit trails for publish, download, delete, and authorization
## References
### Essential Files
- **`Makefile`** — All build/test/dev automation targets
- **`CONTRIBUTING.md`** — Contribution guidelines and commit style
- **`CODE_OF_CONDUCT.md`** — Community standards
- **`server/pom.xml`** — Maven parent POM, module definitions, dependency versions
- **`web/package.json`** — Frontend dependencies and scripts
- **`.github/workflows/pr-tests.yml`** — PR test pipeline
- **`.github/workflows/publish-images.yml`** — Docker image publish to GHCR
### Key Directories
- **`server/skillhub-domain/`** — Core domain (entities, services, rules)
- **`server/skillhub-app/controller/`** — REST API endpoints
- **`server/skillhub-app/repository/`** — Query repositories
- **`server/skillhub-app/compat/`** — ClawHub CLI compatibility layer
- **`server/skillhub-auth/`** — Authentication and authorization
- **`web/src/features/`** — Frontend feature modules
- **`web/src/api/generated/`** — Generated OpenAPI types
- **`docs/`** — Design documents
- **`scripts/`** — Build, test, and deployment scripts
### Important Scripts
- **`scripts/smoke-test.sh`** — Basic API smoke test
- **`scripts/namespace-smoke-test.sh`** — Namespace workflow test
- **`scripts/check-openapi-generated.sh`** — Verify frontend SDK is current
- **`scripts/validate-release-config.sh`** — Validate production env config
- **`scripts/dev-process.sh`** — Local process manager (PID-based lifecycle)
- **`scripts/parallel-init.sh`** — Create isolated worktrees for parallel development
### Design Documents
- **`00-product-direction.md`** — Product positioning, MVP scope, coordinate system
- **`01-system-architecture.md`** — System architecture, module structure, dependency rules
- **`02-domain-model.md`** — Domain entities and relationships
- **`03-authentication-design.md`** — OAuth2, CLI Device Flow, API tokens
- **`04-search-architecture.md`** — Search SPI and implementations
- **`05-business-flows.md`** — Business process flows
- **`06-api-design.md`** — API contract specifications
- **`07-skill-protocol.md`** — SKILL.md format, package structure, CLI compatibility
- **`08-frontend-architecture.md`** — Frontend patterns and conventions
- **`14-skill-lifecycle.md`** — Skill state model (authoritative)
- **`dev-workflow.md`** — Local development workflow guide
### External Resources
- **SkillHub Docs**: https://iflytek.github.io/skillhub/
- **DeepWiki**: https://deepwiki.com/iflytek/skillhub
- **Discord**: https://discord.gg/qHYvtDNPHS
- **OpenSkills**: https://agents.md/ (skill package format reference)
- **OpenClaw**: https://github.com/openclaw/openclaw (CLI compatibility)
- **AstronClaw**: https://agent.xfyun.cn/astron-claw (cloud AI assistant integration)

View file

@ -6,6 +6,10 @@ SkillHub is a self-hosted registry for agent skills. Contributions should
preserve the existing architecture and product direction documented in
[`docs/`](./docs).
AI coding agents working in this repository should follow the rules in
[`AGENTS.md`](./AGENTS.md), which documents repository architecture,
dependency rules, and agent-specific conventions.
## Before You Start
- Read [`README.md`](./README.md) for local development commands.

View file

@ -1,4 +1,4 @@
.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset dev-logs dev-status build test check clean web-deps web-install web-install-ci dev-server dev-server-restart dev-web build-backend test-backend build-frontend test-frontend test-e2e-frontend test-e2e-smoke-frontend build-web test-web typecheck-web lint-web generate-api db-reset namespace-smoke validate-release-config staging staging-down staging-logs pr parallel-init parallel-sync parallel-up parallel-down docs-dev docs-build docs-preview
.PHONY: build build-backend build-backend-app build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
DEV_DIR := .dev
DEV_SERVER_PID := $(DEV_DIR)/server.pid
@ -261,6 +261,32 @@ typecheck-web: ## 前端类型检查
lint-web: ## 前端代码检查
cd web && pnpm run lint
# CLI 相关目标
cli-install: ## 安装 CLI 依赖
cd cli && bun install --frozen-lockfile
build-cli: ## 构建 CLI
cd cli && bun run build
test-cli: ## 运行 CLI 单元测试
cd cli && bun test
lint-cli: ## CLI 代码检查
cd cli && bun run lint
typecheck-cli: ## CLI 类型检查
cd cli && bun run typecheck
publish-cli: ## 发布 CLIpatch 版本)- bump + tag + push触发 CI 自动发布
./scripts/publish-cli.sh patch
publish-cli-minor: ## 发布 CLIminor 版本)- bump + tag + push触发 CI 自动发布
./scripts/publish-cli.sh minor
publish-cli-major: ## 发布 CLImajor 版本)- bump + tag + push触发 CI 自动发布
./scripts/publish-cli.sh major
db-reset: ## 重置数据库
$(DEV_COMPOSE) down -v --remove-orphans
$(DEV_COMPOSE) up -d --wait --remove-orphans postgres

View file

@ -101,6 +101,30 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
If deployment runs into problems, clear the existing runtime home and retry.
## SkillHub CLI
Install and manage Agent skills from the command line:
```bash
# Install CLI
npm install -g @astron-team/skillhub
# Or run directly
npx @astron-team/skillhub@latest version
# Login
skillhub login --token sk_xxx --registry https://skill.xfyun.cn
# Search and install skills
skillhub search pdf
skillhub install pdf-parser --agent codex
# List installed skills
skillhub list
```
📖 Full guide: [docs/skillhub/en/guide/cli.md](docs/skillhub/en/guide/cli.md)
### Prerequisites
- Docker & Docker Compose

View file

@ -107,6 +107,30 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
/tmp/skillhub-runtime/runtime.sh down
```
## SkillHub CLI
通过命令行安装和管理 Agent 技能:
```bash
# 安装 CLI
npm install -g @astron-team/skillhub
# 或直接运行
npx @astron-team/skillhub@latest version
# 登录
skillhub login --token sk_xxx --registry https://skill.xfyun.cn
# 搜索和安装技能
skillhub search pdf
skillhub install pdf-parser --agent codex
# 查看已安装技能
skillhub list
```
📖 完整指南:[docs/skillhub/guide/cli.md](docs/skillhub/guide/cli.md)
## 开发
### 前置要求

11
cli/.env.example Normal file
View file

@ -0,0 +1,11 @@
# npm publish token with publish permission
NPM_TOKEN=
# npm organization scope without @
NPM_ORG=astron-team
# npm registry endpoint
NPM_REGISTRY=https://registry.npmjs.org
# set true to stop before npm publish
DRY_RUN=false

13
cli/.eslintrc.cjs Normal file
View file

@ -0,0 +1,13 @@
module.exports = {
root: true,
env: {
es2022: true
},
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module'
},
plugins: ['@typescript-eslint'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
ignorePatterns: ['dist']
}

201
cli/LICENSE Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets.) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 iFlytek Co., Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

395
cli/README.md Normal file
View file

@ -0,0 +1,395 @@
# SkillHub CLI
SkillHub CLI is the official command-line tool for SkillHub, designed for searching, installing, managing, and publishing Agent skill packages.
## 📦 Installation
```bash
# Install globally via npm
npm install -g @astron-team/skillhub
# Or run directly with npx
npx @astron-team/skillhub@latest version
# Or install globally via Bun
bun add -g @astron-team/skillhub
```
## 🚀 Quick Start
```bash
# Login
skillhub login --token sk_xxx
# Search skills
skillhub search pdf
# Install skill to Agent directory
skillhub install pdf-parser --agent codex
# List installed skills
skillhub list
# Publish skill
skillhub publish ./my-skill --namespace myspace
```
## 🌐 Registry Configuration
The active registry is resolved in the following priority order:
1. `--registry <url>` command-line argument
2. `SKILLHUB_REGISTRY` environment variable
3. `registry` in `~/.skillhub/config.json`
4. Default value `https://skill.xfyun.cn`
```bash
# Temporarily use another registry
skillhub search pdf --registry https://skillhub.example.com
# Set via environment variable (Linux/macOS)
export SKILLHUB_REGISTRY=https://skillhub.example.com
```
**Windows PowerShell:**
```powershell
$env:SKILLHUB_REGISTRY="https://skillhub.example.com"
```
**Windows CMD:**
```cmd
set SKILLHUB_REGISTRY=https://skillhub.example.com
```
## 🔐 Authentication
Token resolution priority:
1. `--token <token>` command-line argument
2. `SKILLHUB_TOKEN` environment variable
3. Token stored in `~/.skillhub/credentials.json` (per registry)
### Login
```bash
# Login with API token
skillhub login --token sk_xxx
# Login to specific registry
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`.
### Check Current Identity
```bash
skillhub whoami
# Check specific registry
skillhub whoami --registry https://skillhub.example.com
# Temporarily use different token
skillhub whoami --token sk_other
```
### Logout
```bash
skillhub logout
# Logout from specific registry
skillhub logout --registry https://skillhub.example.com
```
Logout only removes the token for the specified registry, preserving registry configuration and installation records.
## 🔍 Search
```bash
# Keyword search
skillhub search pdf
# List all skills (empty query)
skillhub search "" --limit 50
# JSON output
skillhub search pdf --json
```
Output format: `namespace/slug version summary`
## 📥 Install Skills
```bash
# Install to auto-detected Agent directory
skillhub install pdf-parser
# Specify namespace (default: global)
skillhub install pdf-parser --namespace myspace
# Specify version
skillhub install pdf-parser --version 1.2.0
# Install to specific Agent
skillhub install pdf-parser --agent codex
# Install to multiple Agents
skillhub install pdf-parser --agent codex --agent claude-code
# Install to custom directory
skillhub install pdf-parser --dir ~/.claude/skills
# Force overwrite existing installation
skillhub install pdf-parser --force
```
### Install Target Resolution
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/`
> `--dir` and `--agent` cannot be used together.
### Install Paths
Each Agent has both project-level and user-level skills directories:
| Agent | Project-level Path | User-level Path |
|-------|-------------------|-----------------|
| `claude-code` | `<project>/.claude/skills/` | `~/.claude/skills/` |
| `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/` |
| `windsurf` | `<project>/.windsurf/skills/` | `~/.windsurf/skills/` |
| `kiro-cli` | `<project>/.kiro-cli/skills/` | `~/.kiro-cli/skills/` |
| `roo` | `<project>/.roo/skills/` | `~/.roo/skills/` |
| `trae` | `<project>/.trae/skills/` | `~/.trae/skills/` |
| `trae-cn` | `<project>/.trae-cn/skills/` | `~/.trae-cn/skills/` |
| `openhands` | `<project>/.openhands/skills/` | `~/.openhands/skills/` |
| `openclaw` | `<project>/.openclaw/skills/` | `~/.openclaw/skills/` |
| `opencode` | `<project>/.opencode/skills/` | `~/.opencode/skills/` |
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
For Agents not in the list, use `--dir` to specify the installation path.
### File Structure After Installation
```
.codex/skills/pdf-parser/
├── ... # Extracted skill package files
└── .skillhub/
└── metadata.json # Installation metadata
```
`metadata.json` example:
```json
{
"registry": "https://skill.xfyun.cn",
"namespace": "global",
"slug": "pdf-parser",
"version": "1.0.0",
"agent": "codex",
"installedAt": "2026-04-28T06:00:00.000Z"
}
```
## 📋 Local Management
### List Installed Skills
```bash
# List all installed skills
skillhub list
# Filter by Agent
skillhub list --agent codex
# Filter by multiple Agents
skillhub list --agent codex --agent claude-code
# Filter by directory
skillhub list --dir ~/.codex/skills
# JSON output
skillhub list --json
```
### Remove Skills
```bash
# Remove all local installation targets
skillhub remove pdf-parser
# Remove only specific Agent's installation
skillhub remove pdf-parser --agent codex
# Remove all targets (skip interactive confirmation)
skillhub remove pdf-parser --all
# Remove remote skill (requires authentication, prompts for confirmation)
skillhub remove pdf-parser --remote --namespace myspace
# Skip remote deletion confirmation
skillhub remove pdf-parser --remote --hard --namespace myspace
```
> Parameter exclusivity rules:
> - `--all` cannot be used with `--agent`
> - `--remote` cannot be used with `--agent` or `--all`
> - Remote deletion in non-interactive environments requires `--hard`
### Rebuild Local Inventory
```bash
skillhub doctor
```
`doctor` performs the following operations:
1. Scans `<cwd>/.<agent>/skills/<slug>/.skillhub/metadata.json`
2. Groups by `registry + namespace + slug`
3. Backs up old `inventory.json` (if exists)
4. Writes new `inventory.json`
If the same skill has version conflicts across different targets, that skill will be skipped and reported.
## 🚢 Publishing
```bash
# Publish directory (auto-packaged as zip)
skillhub publish ./my-skill --namespace myspace
# Publish existing zip file
skillhub publish ./my-skill.zip --namespace myspace
# Specify visibility
skillhub publish ./my-skill --namespace myspace --visibility private
```
Visibility options:
- `public` (default) — Visible to everyone
- `namespace-only` — Visible to namespace members only
- `private` — Visible to yourself only
After successful publication, the skill detail page URL will be displayed.
## ⬆️ Self-Update
```bash
# Check for new version
skillhub update --check
# Execute update
skillhub update
```
Update mechanism:
- Installed via npm globally: Auto-executes `npm install -g @astron-team/skillhub@latest`
- Installed via Bun globally: Auto-executes `bun add -g @astron-team/skillhub@latest`
- Run via npx: Prompts manual update command
- Unknown installation method: Prompts manual update
## 🔧 Environment Variables
| Variable | Description | Priority |
|----------|-------------|----------|
| `SKILLHUB_REGISTRY` | Default registry URL | Lower than `--registry` parameter |
| `SKILLHUB_TOKEN` | API token | Lower than `--token` parameter, higher than stored token |
## 📂 Local File Structure
```
~/.skillhub/
├── config.json # User configuration (registry, defaultAgent, etc.)
├── credentials.json # API tokens (stored per registry, permissions 0600)
└── inventory.json # Installed skills inventory
```
## 📖 Command Reference
| Command | Description |
|---------|-------------|
| `skillhub help [command]` | Display help information |
| `skillhub version [--json]` | Display CLI version |
| `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 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 |
| `skillhub publish <path> [--namespace <slug>] [--visibility <v>] [--registry <url>] [--token <token>] [--json]` | Publish a skill |
| `skillhub update [--check] [--json]` | Check or execute CLI self-update |
## 🔒 Security Notes
- Tokens are stored only in user directory `~/.skillhub/credentials.json`
- On Linux/macOS, credential file permissions are automatically set to `0600`
- Tokens are never written to any project-local files
- Remote delete operations require explicit confirmation or `--hard` parameter
- `remove` command validates path safety to prevent deletion of non-skill directories
## 🐛 Troubleshooting
### Authentication Failure
```bash
# Verify token validity
skillhub whoami
# Re-login
skillhub login --token sk_xxx
```
### Network Error
```bash
# Check if registry is accessible
curl https://skill.xfyun.cn/api/cli/v1/skills/search?q=test&limit=1
# Use alternative registry
skillhub search test --registry https://skillhub.example.com
```
### Installation Directory Conflict
```bash
# Use --force to overwrite
skillhub install pdf-parser --force
# Or remove first then install
skillhub remove pdf-parser
skillhub install pdf-parser
```
### Corrupted Inventory
```bash
# Rebuild inventory
skillhub doctor
```
## 📚 Documentation
- [SkillHub Homepage](https://skill.xfyun.cn)
- [GitHub Repository](https://github.com/iflytek/skillhub)
- [CLI Documentation](https://github.com/iflytek/skillhub/blob/main/docs/skillhub/en/guide/cli.md)
- [Issue Tracker](https://github.com/iflytek/skillhub/issues)
## 📄 License
Apache-2.0
Copyright 2026 iFlytek Co., Ltd.

146
cli/RELEASE.md Normal file
View file

@ -0,0 +1,146 @@
# CLI Release Guide
## 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.
## Prerequisites
### Repository Secrets
Configure in GitHub repository → Settings → Secrets and variables → Actions:
- `NPM_TOKEN`: npm token with publish permissions
- Generate at https://www.npmjs.com/settings/YOUR_USERNAME/tokens
- Use **Classic Automation Token** (bypasses 2FA automatically), or
- **Granular Access Token** with "Allow bypass 2FA" enabled, scoped to the package
### Repository Variables (optional)
- `NPM_REGISTRY`: npm registry URL (default: `https://registry.npmjs.org`)
### Local Environment
- `node` and `npm` installed (the script uses `npm version` to bump)
- `git` installed with push access to the repository
- On the `main` branch with a clean working tree
### Package Configuration
In [`cli/package.json`](./package.json):
```json
{
"name": "@astron-team/skillhub",
"publishConfig": {
"access": "public"
}
}
```
## Release Process
### One-shot Release
From the repository root, on a clean `main` branch:
```bash
make publish-cli # patch: 0.1.5 -> 0.1.6
make publish-cli-minor # minor: 0.1.5 -> 0.2.0
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
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
Pushing the tag triggers CI — no further manual action required.
### CI Workflow
[`release-cli.yml`](../.github/workflows/release-cli.yml) contains three jobs:
1. **build-and-test**
- Extract version from tag name (`cli-v0.1.6``0.1.6`) and write it into `cli/package.json`
- Install deps, lint, typecheck, test, build
- Verify the built CLI's runtime version matches the tag
2. **publish-npm**
- Skip if the target version already exists on the registry
- Configure `~/.npmrc` and run `npm publish --access public`
3. **create-release**
- Package `dist/` + README + LICENSE as `tar.gz` and `zip`
- Generate SHA256 checksums
- Create a GitHub Release and upload artifacts
### Verify Release
- Workflow: https://github.com/iflytek/skillhub/actions/workflows/release-cli.yml
- Release: https://github.com/iflytek/skillhub/releases
- npm: `npm view @astron-team/skillhub@<version>`
## Release Audit Trail
GitHub Actions automatically records on each workflow run page:
- **Triggering user** (the developer who pushed the tag, i.e. `github.actor`)
- **Trigger event** (`push` tag or `workflow_dispatch`)
- **Tag name and commit SHA**
The team can review the full audit trail in the Actions tab without any extra configuration.
## Manual Trigger
From the Actions UI:
1. Actions → Release CLI → "Run workflow"
2. Enter an existing tag name matching `cli-vX.Y.Z`
3. Optionally enable skip npm publish
## Troubleshooting
### `releases must be cut from 'main'`
Switch back to `main`, pull the latest, and retry.
### `git working tree is not clean`
Commit or stash local changes first.
### `tag cli-vX.Y.Z already exists`
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.
### 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
- **403 Forbidden**: Package scope doesn't match token permissions — confirm publish rights for the `@astron-team` org
- **E404**: The registry doesn't host this scope — check `NPM_REGISTRY`
### Build / Test Fails
Reproduce locally:
```bash
make lint-cli && make typecheck-cli && make test-cli && make build-cli
```
Confirm the Bun version matches `packageManager` in [`cli/package.json`](./package.json).
### Version Mismatch (runtime ≠ tag)
CI runs `node dist/index.js version` and requires the output to match the tag. If the CLI's `version` command implementation changes, update the verification logic in [`release-cli.yml`](../.github/workflows/release-cli.yml) accordingly.
## Tag Naming Convention
- CLI releases: `cli-v*` (e.g., `cli-v0.1.6`)
- Repository releases: `v*` (e.g., `v0.3.0`)
The two tag namespaces are independent, allowing CLI and server to version separately.

302
cli/bun.lock Normal file
View file

@ -0,0 +1,302 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "skillhub",
"dependencies": {
"cac": "^6.7.14",
"fflate": "^0.8.2",
"prompts": "^2.4.2",
"semver": "^7.6.3",
"zod": "^3.24.1",
},
"devDependencies": {
"@types/bun": "^1.3.13",
"@types/prompts": "^2.4.9",
"@types/semver": "^7.5.8",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.1",
"typescript": "^5.7.0",
},
},
},
"packages": {
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="],
"@eslint/js": ["@eslint/js@8.57.1", "https://registry.npmmirror.com/@eslint/js/-/js-8.57.1.tgz", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="],
"@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "https://registry.npmmirror.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@types/bun": ["@types/bun@1.3.13", "https://registry.npmmirror.com/@types/bun/-/bun-1.3.13.tgz", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/node": ["@types/node@25.6.0", "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@types/prompts": ["@types/prompts@2.4.9", "https://registry.npmmirror.com/@types/prompts/-/prompts-2.4.9.tgz", { "dependencies": { "@types/node": "*", "kleur": "^3.0.3" } }, "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA=="],
"@types/semver": ["@types/semver@7.7.1", "https://registry.npmmirror.com/@types/semver/-/semver-7.7.1.tgz", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@7.18.0", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/type-utils": "7.18.0", "@typescript-eslint/utils": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "peerDependencies": { "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.56.0" } }, "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@7.18.0", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.18.0.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", "@typescript-eslint/typescript-estree": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.56.0" } }, "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@7.18.0", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", { "dependencies": { "@typescript-eslint/types": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0" } }, "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@7.18.0", "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", { "dependencies": { "@typescript-eslint/typescript-estree": "7.18.0", "@typescript-eslint/utils": "7.18.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, "peerDependencies": { "eslint": "^8.56.0" } }, "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA=="],
"@typescript-eslint/types": ["@typescript-eslint/types@7.18.0", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-7.18.0.tgz", {}, "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@7.18.0", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", { "dependencies": { "@typescript-eslint/types": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^1.3.0" } }, "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@7.18.0", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-7.18.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", "@typescript-eslint/typescript-estree": "7.18.0" }, "peerDependencies": { "eslint": "^8.56.0" } }, "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@7.18.0", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", { "dependencies": { "@typescript-eslint/types": "7.18.0", "eslint-visitor-keys": "^3.4.3" } }, "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"array-union": ["array-union@2.1.0", "https://registry.npmmirror.com/array-union/-/array-union-2.1.0.tgz", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
"balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@1.1.14", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"braces": ["braces@3.0.3", "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"bun-types": ["bun-types@1.3.13", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.13.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"cac": ["cac@6.7.14", "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"callsites": ["callsites@3.1.0", "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["color-convert@2.0.1", "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"dir-glob": ["dir-glob@3.0.1", "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
"doctrine": ["doctrine@3.0.0", "https://registry.npmmirror.com/doctrine/-/doctrine-3.0.0.tgz", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@8.57.1", "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.1", "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA=="],
"eslint-scope": ["eslint-scope@7.2.2", "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"espree": ["espree@9.6.1", "https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="],
"esquery": ["esquery@1.7.0", "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fastq": ["fastq@1.20.1", "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fflate": ["fflate@0.8.2", "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"file-entry-cache": ["file-entry-cache@6.0.1", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="],
"fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@3.2.0", "https://registry.npmmirror.com/flat-cache/-/flat-cache-3.2.0.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="],
"flatted": ["flatted@3.4.2", "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"fs.realpath": ["fs.realpath@1.0.0", "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"glob": ["glob@7.2.3", "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"glob-parent": ["glob-parent@6.0.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@13.24.0", "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="],
"globby": ["globby@11.1.0", "https://registry.npmmirror.com/globby/-/globby-11.1.0.tgz", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="],
"graphemer": ["graphemer@1.4.0", "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
"has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inflight": ["inflight@1.0.6", "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-path-inside": ["is-path-inside@3.0.3", "https://registry.npmmirror.com/is-path-inside/-/is-path-inside-3.0.3.tgz", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
"isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"js-yaml": ["js-yaml@4.1.1", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"json-buffer": ["json-buffer@3.0.1", "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kleur": ["kleur@3.0.3", "https://registry.npmmirror.com/kleur/-/kleur-3.0.3.tgz", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"merge2": ["merge2@1.4.1", "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimatch": ["minimatch@3.1.5", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-type": ["path-type@4.0.0", "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
"picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prompts": ["prompts@2.4.2", "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rimraf": ["rimraf@3.0.2", "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
"run-parallel": ["run-parallel@1.2.0", "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"sisteransi": ["sisteransi@1.0.5", "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"slash": ["slash@3.0.0", "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"text-table": ["text-table@0.2.0", "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="],
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-api-utils": ["ts-api-utils@1.4.3", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="],
"type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@0.20.2", "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
"typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.19.2", "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.1.0", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
}
}

2
cli/bunfig.toml Normal file
View file

@ -0,0 +1,2 @@
[test]
root = "./test"

65
cli/package.json Normal file
View file

@ -0,0 +1,65 @@
{
"name": "@astron-team/skillhub",
"version": "0.1.6",
"description": "Manage and install skills for AI coding agents",
"keywords": [
"skillhub",
"ai",
"coding-agent",
"skills",
"cli"
],
"homepage": "https://github.com/iflytek/skillhub#readme",
"bugs": {
"url": "https://github.com/iflytek/skillhub/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/iflytek/skillhub.git",
"directory": "cli"
},
"license": "Apache-2.0",
"author": "iFLYTEK",
"type": "module",
"packageManager": "bun@1.3.13",
"bin": {
"skillhub": "./dist/index.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"prebuild": "bun run scripts/generate-pkg-info.ts",
"build": "bun build src/index.ts --target=node --outfile=dist/index.js",
"pretest": "bun run scripts/generate-pkg-info.ts",
"test": "bun test",
"pretypecheck": "bun run scripts/generate-pkg-info.ts",
"typecheck": "tsc --noEmit",
"prelint": "bun run scripts/generate-pkg-info.ts",
"lint": "eslint src test --ext .ts"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"cac": "^6.7.14",
"fflate": "^0.8.2",
"prompts": "^2.4.2",
"semver": "^7.6.3",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bun": "^1.3.13",
"@types/prompts": "^2.4.9",
"@types/semver": "^7.5.8",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.1",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View file

@ -0,0 +1,29 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
interface PackageJson {
name: string
version: string
}
const scriptDir = dirname(fileURLToPath(import.meta.url))
const cliRoot = resolve(scriptDir, '..')
const pkgPath = resolve(cliRoot, 'package.json')
const outPath = resolve(cliRoot, 'src/generated/pkg-info.ts')
const pkg = JSON.parse(await readFile(pkgPath, 'utf8')) as PackageJson
if (typeof pkg.name !== 'string' || typeof pkg.version !== 'string') {
throw new Error(`package.json at ${pkgPath} is missing name or version`)
}
const contents =
'// 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`
await mkdir(dirname(outPath), { recursive: true })
await writeFile(outPath, contents, 'utf8')
console.log(`generated ${outPath}`)

View file

@ -0,0 +1,31 @@
import type { AgentProfile } from './types'
import { claudeCodeProfile } from './profiles/claude-code'
import { codexProfile } from './profiles/codex'
import { cursorProfile } from './profiles/cursor'
import { githubCopilotProfile } from './profiles/github-copilot'
import { geminiCliProfile } from './profiles/gemini-cli'
import { openhandsProfile } from './profiles/openhands'
import { windsurfProfile } from './profiles/windsurf'
import { openclawProfile } from './profiles/openclaw'
import { kiroCliProfile } from './profiles/kiro-cli'
import { rooProfile } from './profiles/roo'
import { traeProfile } from './profiles/trae'
import { traeCnProfile } from './profiles/trae-cn'
import { opencodeProfile } from './profiles/opencode'
import { kiloProfile } from './profiles/kilo'
export {
claudeCodeProfile, codexProfile, cursorProfile, githubCopilotProfile,
geminiCliProfile, openhandsProfile, windsurfProfile, openclawProfile,
kiroCliProfile, rooProfile, traeProfile, traeCnProfile,
opencodeProfile, kiloProfile
}
export const allProfiles: AgentProfile[] = [
claudeCodeProfile, codexProfile, cursorProfile, githubCopilotProfile,
geminiCliProfile, openhandsProfile, windsurfProfile, openclawProfile,
kiroCliProfile, rooProfile, traeProfile, traeCnProfile,
opencodeProfile, kiloProfile
]
export const profileMap = new Map(allProfiles.map(p => [p.id, p]))

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const claudeCodeProfile = makeProfile('claude-code', 'Claude Code', '.claude/skills', '.claude/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const codexProfile = makeProfile('codex', 'Codex', '.codex/skills', '.codex/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const cursorProfile = makeProfile('cursor', 'Cursor', '.cursor/skills', '.cursor/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const geminiCliProfile = makeProfile('gemini-cli', 'Gemini CLI', '.gemini/skills', '.gemini/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const genericFallbackProfile = makeProfile('generic', 'Generic Fallback', '.agents/skills', '.agents/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const githubCopilotProfile = makeProfile('github-copilot', 'GitHub Copilot', '.github-copilot/skills', '.github-copilot/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const kiloProfile = makeProfile('kilo', 'Kilo', '.kilo/skills', '.kilo/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const kiroCliProfile = makeProfile('kiro-cli', 'Kiro CLI', '.kiro/skills', '.kiro/skills')

View file

@ -0,0 +1,30 @@
import { pathExists } from '../../platform/paths'
import type { AgentProfile, AgentCandidate } from '../types'
async function dirExists(path: string): Promise<boolean> {
return pathExists(path)
}
export function makeProfile(id: string, displayName: string, projectSkills: string, userSkills: string): AgentProfile {
return {
id,
displayName,
projectRoots: cwd => [`${cwd}/${projectSkills}`],
userRoots: home => [`${home}/${userSkills}`],
async detectInstalled(cwd, home) {
const roots = [...this.projectRoots(cwd), ...this.userRoots(home)]
const results: AgentCandidate[] = []
for (const root of roots) {
if (await dirExists(root)) {
results.push({
agent: this.id,
rootDir: root,
scope: root.startsWith(cwd) ? 'project' : 'user',
source: 'detected'
})
}
}
return results
}
}
}

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const openclawProfile = makeProfile('openclaw', 'OpenClaw', '.openclaw/skills', '.openclaw/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const opencodeProfile = makeProfile('opencode', 'OpenCode', '.opencode/skills', '.opencode/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const openhandsProfile = makeProfile('openhands', 'OpenHands', '.openhands/skills', '.openhands/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const rooProfile = makeProfile('roo', 'Roo', '.roo/skills', '.roo/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const traeCnProfile = makeProfile('trae-cn', 'Trae CN', '.trae-cn/skills', '.trae-cn/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const traeProfile = makeProfile('trae', 'Trae', '.trae/skills', '.trae/skills')

View file

@ -0,0 +1,2 @@
import { makeProfile } from './make-profile'
export const windsurfProfile = makeProfile('windsurf', 'Windsurf', '.windsurf/skills', '.windsurf/skills')

101
cli/src/agents/resolver.ts Normal file
View file

@ -0,0 +1,101 @@
import { homedir } from 'node:os'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import type { AgentCandidate } from './types'
import { allProfiles, profileMap } from './detector'
export interface ResolveInstallTargetOptions {
cwd: string
home?: string | undefined
dir?: string | undefined
agents?: string[] | undefined
json: boolean
interactive: boolean
detected?: AgentCandidate[] | undefined
}
export async function resolveInstallTargets(options: ResolveInstallTargetOptions): Promise<AgentCandidate[]> {
if (options.dir && options.agents?.length) {
throw new CliError('--dir cannot be used with --agent', 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())
return dedupeByRoot(resolved)
}
const detected = options.detected ?? await detectAll(options.cwd, options.home ?? '')
if (detected.length === 1) return detected
if (detected.length > 1 && options.interactive && !options.json) {
return selectTargetsInteractively(detected)
}
if (detected.length > 1 && (!options.interactive || options.json)) {
throw new CliError('multiple install targets detected', EXIT.usage, {
next: 'pass --agent or --dir',
candidates: detected
})
}
return [{ agent: 'generic', rootDir: `${options.cwd}/.agents/skills`, scope: 'project', source: 'fallback' }]
}
async function detectAll(cwd: string, home: string): Promise<AgentCandidate[]> {
const results: AgentCandidate[] = []
for (const profile of allProfiles) {
const candidates = await profile.detectInstalled(cwd, home)
results.push(...candidates)
}
return dedupeByRoot(results)
}
async function resolveExplicitAgents(agents: string[], cwd: string, home?: string): Promise<AgentCandidate[]> {
const results: AgentCandidate[] = []
for (const agentId of agents) {
const profile = profileMap.get(agentId)
if (!profile) {
throw new CliError(`unknown agent: ${agentId}`, EXIT.usage, {
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 {
agent: agentId,
rootDir: root,
scope,
source: 'explicit' as const
}
}))
}
}
return results
}
function dedupeByRoot(candidates: AgentCandidate[]): AgentCandidate[] {
const seen = new Set<string>()
return candidates.filter(c => {
if (seen.has(c.rootDir)) return false
seen.add(c.rootDir)
return true
})
}
async function selectTargetsInteractively(candidates: AgentCandidate[]): Promise<AgentCandidate[]> {
const prompts = await import('prompts')
const { selected } = await prompts.default({
type: 'multiselect',
name: 'selected',
message: 'Select install targets',
choices: candidates.map(c => ({
title: `${c.agent} (${c.rootDir})`,
value: c
}))
})
if (!selected || selected.length === 0) {
throw new CliError('installation cancelled', EXIT.usage)
}
return selected
}

14
cli/src/agents/types.ts Normal file
View file

@ -0,0 +1,14 @@
export interface AgentProfile {
id: string
displayName: string
projectRoots(cwd: string): string[]
userRoots(home: string): string[]
detectInstalled(cwd: string, home: string): Promise<AgentCandidate[]>
}
export interface AgentCandidate {
agent: string
rootDir: string
scope: 'project' | 'user'
source: 'detected' | 'fallback' | 'explicit'
}

View file

@ -0,0 +1,89 @@
import { EXIT, CLI_PACKAGE_NAME } from '../shared/constants'
import { CliError } from '../shared/errors'
const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
function readEnv(env: NodeJS.ProcessEnv, name: string): string | undefined {
const exactValue = env[name]?.trim()
if (exactValue) {
return exactValue
}
const lowerName = name.toLowerCase()
for (const [key, value] of Object.entries(env)) {
const normalizedValue = value?.trim()
if (key.toLowerCase() === lowerName && normalizedValue) {
return normalizedValue
}
}
return undefined
}
function resolveRegistry(env: NodeJS.ProcessEnv): string {
return readEnv(env, 'SKILLHUB_NPM_REGISTRY')
?? readEnv(env, 'npm_config_registry')
?? readEnv(env, 'NPM_CONFIG_REGISTRY')
?? DEFAULT_NPM_REGISTRY
}
function buildLatestUrl(registry: string, packageName: string): string {
try {
const base = registry.endsWith('/') ? registry : `${registry}/`
return new URL(`${encodeURIComponent(packageName)}/latest`, base).toString()
} catch {
throw new CliError('invalid npm registry URL', EXIT.usage, {
registry,
next: 'check npm registry configuration and retry'
})
}
}
export class NpmRegistryClient {
constructor(
private readonly fetchImpl: typeof fetch = fetch,
private readonly timeoutMs = 10_000,
private readonly env: NodeJS.ProcessEnv = process.env
) {}
async latestVersion(packageName = CLI_PACKAGE_NAME): Promise<string> {
const registry = resolveRegistry(this.env)
const url = buildLatestUrl(registry, packageName)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeoutMs)
try {
let response: Response
try {
response = await this.fetchImpl(url, {
signal: controller.signal
})
} catch (error) {
const isTimeout = error instanceof Error && error.name === 'AbortError'
throw new CliError('npm registry unreachable', EXIT.network, {
registry,
cause: error instanceof Error ? error.message : String(error),
next: isTimeout
? 'check npm registry connectivity or proxy settings and retry'
: 'check npm registry/proxy configuration and retry'
})
}
if (!response.ok) {
throw new CliError(`npm registry returned ${response.status}`, EXIT.network, { registry })
}
let body: unknown
try {
body = await response.json()
} catch (error) {
throw new CliError('npm registry response invalid', EXIT.network, {
registry,
cause: error instanceof Error ? error.message : String(error)
})
}
if (typeof body !== 'object' || body === null || !('version' in body) || typeof body.version !== 'string') {
throw new CliError('npm registry response missing version', EXIT.network, { registry })
}
return body.version
} finally {
clearTimeout(timer)
}
}
}

View file

@ -0,0 +1,191 @@
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
export interface WhoAmIResponse {
handle: string
displayName: string
email?: string
}
export interface SearchItem {
namespace: string
slug: string
latestVersion: string
summary: string
}
export interface SearchResponse {
items: SearchItem[]
total: number
limit: number
}
export interface ResolveResponse {
namespace: string
slug: string
version: string
versionId: number
fingerprint: string
downloadUrl: string
}
export interface DeleteResponse {
ok: boolean
scope: string
action: string
namespace: string
slug: string
}
export interface PublishResponse {
namespace: string
slug: string
version: string
visibility: string
}
export interface DryRunResponse {
valid: boolean
errors: string[]
warnings: string[]
resolvedSlug: string | null
resolvedVersion: string | null
}
export class SkillHubClient {
constructor(
readonly registry: string,
readonly token?: string,
private readonly fetchImpl: typeof fetch = fetch
) {}
async whoami(): Promise<WhoAmIResponse> {
return this.getJson('/auth/whoami')
}
async search(query: string, limit: number): Promise<SearchResponse> {
const params = new URLSearchParams({ q: query, limit: String(limit) })
return this.getJson(`/skills/search?${params}`)
}
async resolve(namespace: string, slug: string, version?: string): Promise<ResolveResponse> {
const params = version ? `?version=${encodeURIComponent(version)}` : ''
return this.getJson(`/skills/${namespace}/${slug}/resolve${params}`)
}
async downloadUrl(namespace: string, slug: string, version?: string): Promise<string> {
if (version) {
return `${this.registry}/api/cli/v1/skills/${namespace}/${slug}/versions/${version}/download`
}
return `${this.registry}/api/cli/v1/skills/${namespace}/${slug}/download`
}
async download(namespace: string, slug: string, version?: string): Promise<Response> {
const url = await this.downloadUrl(namespace, slug, version)
let response: Response
try {
response = await this.fetchImpl(url, { headers: this.headers() })
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
if (response.status === 401 || response.status === 403) {
throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' })
}
if (response.status === 404) {
throw new CliError('skill or version not found', EXIT.generic, { registry: this.registry })
}
if (!response.ok) {
throw new CliError(`download failed with status ${response.status}`, EXIT.generic, { registry: this.registry })
}
return response
}
async deleteRemote(namespace: string, slug: string): Promise<DeleteResponse> {
return this.deleteJson(`/skills/${namespace}/${slug}`)
}
async publish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<PublishResponse> {
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`, {
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<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 {
response = await this.fetchImpl(`${this.registry}/api/cli/v1${path}`, {
headers: this.headers()
})
} catch (err) {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
return this.handleJsonResponse<T>(response)
}
private async handleJsonResponse<T>(response: Response): Promise<T> {
if (response.status === 401) {
throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' })
}
if (response.status === 403) {
throw new CliError('access denied — token may lack required scope', EXIT.auth, { registry: this.registry, next: 'regenerate token with required scopes or run `skillhub login`' })
}
if (response.status === 404) {
throw new CliError('resource not found', EXIT.generic, { registry: this.registry })
}
// 502/503 indicate network-level failures (connection refused, service unavailable)
if (response.status === 502 || response.status === 503) {
throw new CliError(`registry returned ${response.status}`, EXIT.network, { registry: this.registry })
}
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new CliError(`registry returned ${response.status}`, EXIT.generic, { registry: this.registry, detail: text })
}
const body = await response.json()
return body.data as T
}
private headers(): HeadersInit {
return this.token ? { Authorization: `Bearer ${this.token}` } : {}
}
private async deleteJson<T>(path: string): Promise<T> {
let response: Response
try {
response = await this.fetchImpl(`${this.registry}/api/cli/v1${path}`, {
method: 'DELETE',
headers: this.headers()
})
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
return this.handleJsonResponse<T>(response)
}
}

View file

@ -0,0 +1,33 @@
import { runDoctor } from '../services/doctor-service'
export interface DoctorCommandOptions {
json?: boolean
}
export async function doctorCommand(options: DoctorCommandOptions): Promise<string> {
const result = await runDoctor(process.cwd())
if (options.json) {
return JSON.stringify({
ok: true,
inventoryPath: result.inventoryPath,
backupPath: result.backupPath,
itemsScanned: result.itemsScanned,
targetsScanned: result.targetsScanned,
itemsPreserved: result.itemsPreserved,
targetsPreserved: result.targetsPreserved,
skipped: result.skipped,
conflicts: result.conflicts
})
}
const lines = [
`Inventory: ${result.inventoryPath}`,
result.backupPath ? `Backup: ${result.backupPath}` : null,
`Scanned: ${result.itemsScanned} items, ${result.targetsScanned} targets`,
result.itemsPreserved > 0
? `Preserved (outside scan): ${result.itemsPreserved} items, ${result.targetsPreserved} targets`
: null,
result.skipped.length > 0 ? `Skipped: ${result.skipped.length} directories` : null,
result.conflicts.length > 0 ? `Conflicts: ${result.conflicts.length} groups` : null
].filter(Boolean)
return lines.join('\n')
}

94
cli/src/commands/help.ts Normal file
View file

@ -0,0 +1,94 @@
import { printResult } from '../shared/output'
export const commands = {
help: {
summary: 'Show available commands',
usage: 'skillhub help [command] [--json]',
examples: ['skillhub help', 'skillhub help install', 'skillhub help --json']
},
version: {
summary: 'Show installed CLI version',
usage: 'skillhub version [--json]',
examples: ['skillhub version', 'skillhub version --json']
},
login: {
summary: 'Save registry and token',
usage: 'skillhub login [--token <token>] [--registry <url>] [--json]',
examples: ['skillhub login --token sk_xxx', 'skillhub login --registry https://skillhub.example.com']
},
logout: {
summary: 'Remove local token',
usage: 'skillhub logout [--registry <url>] [--json]',
examples: ['skillhub logout']
},
whoami: {
summary: 'Verify current token',
usage: 'skillhub whoami [--token <token>] [--registry <url>] [--json]',
examples: ['skillhub whoami', 'skillhub whoami --json']
},
search: {
summary: 'Search published skills',
usage: 'skillhub search [query] [--limit <n>] [--registry <url>] [--json]',
examples: ['skillhub search', 'skillhub search pdf']
},
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']
},
list: {
summary: 'List local installs',
usage: 'skillhub list [--agent <profile>] [--dir <path>] [--registry <url>] [--json]',
examples: ['skillhub list', 'skillhub list --agent codex']
},
remove: {
summary: 'Remove local or remote skill',
usage: 'skillhub remove <slug> [--agent <profile>] [--all] [--remote] [--hard] [--namespace <slug>] [--json]',
examples: ['skillhub remove pdf-parser', 'skillhub remove pdf-parser --remote --hard']
},
doctor: {
summary: 'Scan project and merge into local inventory (preserves entries outside scan scope)',
usage: 'skillhub doctor [--json]',
examples: ['skillhub doctor', 'skillhub doctor --json']
},
publish: {
summary: 'Publish a local skill package',
usage: 'skillhub publish <path> [--namespace <slug>] [--visibility <public|namespace-only|private>] [--registry <url>] [--json]',
examples: ['skillhub publish ./my-skill', 'skillhub publish ./my-skill --namespace myspace']
},
update: {
summary: 'Check or update CLI itself',
usage: 'skillhub update [--check] [--json]',
examples: ['skillhub update --check', 'skillhub update']
}
} as const
export function formatCommandList(): string {
return Object.entries(commands).map(([name, detail]) => `${name.padEnd(10)} ${detail.summary}`).join('\n')
}
export async function helpCommand(args: string[]): Promise<string> {
const json = args.includes('--json')
const topic = args.find(arg => !arg.startsWith('--'))
if (json) {
if (topic) {
// TODO: unknown topic returns undefined and crashes on detail.usage; see help-command.test.ts
const detail = commands[topic as keyof typeof commands]
return printResult({ ok: true, command: topic, ...detail }, true)
}
return printResult({
ok: true,
commands: Object.entries(commands).map(([name, detail]) => ({ name, description: detail.summary }))
}, true)
}
if (topic) {
const detail = commands[topic as keyof typeof commands]
return [
`${topic} - ${detail.summary}`,
`Usage: ${detail.usage}`,
'Examples:',
...detail.examples.map(example => ` ${example}`)
].join('\n')
}
return formatCommandList()
}

View file

@ -0,0 +1,44 @@
import { ConfigStore } from '../stores/config-store'
import { CredentialsStore } from '../stores/credentials-store'
import { resolveRegistry, resolveToken } from '../services/registry-service'
import { installSkill } from '../services/install-service'
import { resolveInstallTargets } from '../agents/resolver'
export interface InstallCommandOptions {
namespace?: string | undefined
version?: string | undefined
agent?: string[] | undefined
dir?: string | undefined
force?: boolean | undefined
registry?: string | undefined
token?: string | undefined
json?: boolean | undefined
}
export async function installCommand(slug: string, options: InstallCommandOptions): Promise<string> {
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({
cwd: process.cwd(),
dir: options.dir,
agents: options.agent ?? [],
json: Boolean(options.json),
interactive: process.stdout.isTTY === true
})
const result = await installSkill({
registry, token, namespace, slug,
version: options.version,
targets,
force: Boolean(options.force)
})
if (options.json) {
return JSON.stringify({ ok: true, namespace, slug, installed: result.installed })
}
return result.installed.map(i => `Installed ${namespace}/${slug} -> ${i.dir} (${i.agent})`).join('\n')
}

57
cli/src/commands/list.ts Normal file
View file

@ -0,0 +1,57 @@
import { stat } from 'node:fs/promises'
import { ConfigStore } from '../stores/config-store'
import { InventoryStore } from '../stores/inventory-store'
import { resolveRegistry } from '../services/registry-service'
export interface ListCommandOptions {
agent?: string[] | undefined
dir?: string | undefined
registry?: string | undefined
json?: boolean | undefined
}
export async function listCommand(options: ListCommandOptions): Promise<string> {
const configStore = new ConfigStore()
const registry = resolveRegistry(options, process.env, await configStore.read())
const store = new InventoryStore()
const inventory = await store.read()
// Flatten targets
type FlatTarget = { namespace: string; slug: string; version: string; agent: string; installDir: string; installedAt: string; status: string }
const flat: FlatTarget[] = []
for (const item of inventory.items) {
if (item.registry !== registry) continue
for (const target of item.targets) {
if (options.agent?.length && !options.agent.includes(target.agent)) continue
if (options.dir && !target.installDir.startsWith(options.dir)) continue
let status = 'ok'
try {
await stat(target.installDir)
} catch {
status = 'missing'
}
flat.push({
namespace: item.namespace,
slug: item.slug,
version: item.version,
agent: target.agent,
installDir: target.installDir,
installedAt: target.installedAt,
status
})
}
}
if (options.json) {
return JSON.stringify({ ok: true, items: flat })
}
if (flat.length === 0) return 'No skills installed.'
return flat.map(t =>
`${t.namespace}/${t.slug}@${t.version} ${t.agent} ${t.installDir} ${t.installedAt} ${t.status}`
).join('\n')
}

21
cli/src/commands/login.ts Normal file
View file

@ -0,0 +1,21 @@
import { ConfigStore } from '../stores/config-store'
import { CredentialsStore } from '../stores/credentials-store'
import { AuthService } from '../services/auth-service'
import { resolveRegistry, resolveToken } from '../services/registry-service'
export interface LoginCommandOptions {
registry?: string
token?: string
json?: boolean
}
export async function loginCommand(options: LoginCommandOptions): Promise<string> {
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 result = await new AuthService(configStore, credentialsStore).login(registry, token)
return options.json
? JSON.stringify({ ok: true, registry, handle: result.handle })
: `Logged in to ${registry} as ${result.handle}`
}

View file

@ -0,0 +1,19 @@
import { ConfigStore } from '../stores/config-store'
import { CredentialsStore } from '../stores/credentials-store'
import { AuthService } from '../services/auth-service'
import { resolveRegistry } from '../services/registry-service'
export interface LogoutCommandOptions {
registry?: string
json?: boolean
}
export async function logoutCommand(options: LogoutCommandOptions): Promise<string> {
const configStore = new ConfigStore()
const credentialsStore = new CredentialsStore()
const registry = resolveRegistry(options, process.env, await configStore.read())
await new AuthService(configStore, credentialsStore).logout(registry)
return options.json
? JSON.stringify({ ok: true, registry })
: `Logged out from ${registry}`
}

124
cli/src/commands/publish.ts Normal file
View file

@ -0,0 +1,124 @@
import { stat, readFile } from 'node:fs/promises'
import { basename } from 'node:path'
import { ConfigStore } from '../stores/config-store'
import { CredentialsStore } from '../stores/credentials-store'
import { SkillHubClient } from '../clients/skillhub-client'
import { resolveRegistry, resolveToken } from '../services/registry-service'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { createZip, isZipFile } from '../platform/archive'
export interface PublishCommandOptions {
namespace?: string
visibility?: string
registry?: string
token?: string
json?: boolean
dryRun?: boolean
}
export async function publishCommand(path: string, options: PublishCommandOptions): Promise<string> {
// Validate local path
let pathStat
try {
pathStat = await stat(path)
} catch {
throw new CliError(`path not found: ${path}`, EXIT.filesystem, { path })
}
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 visibility = options.visibility ?? 'public'
if (!token) {
throw new CliError('authentication required for publish', EXIT.auth, { next: 'run `skillhub login`' })
}
// Create or read archive
let archiveBlob: Blob
let archiveName: string
if (pathStat.isFile()) {
if (await isZipFile(path)) {
const buffer = await readFile(path)
archiveBlob = new Blob([buffer], { type: 'application/zip' })
archiveName = basename(path)
} else {
throw new CliError(`file must be a zip archive: ${path}`, EXIT.filesystem, { path })
}
} else if (pathStat.isDirectory()) {
archiveBlob = await createZip(path)
archiveName = `${basename(path)}.zip`
} else {
throw new CliError(`path must be a file or directory: ${path}`, EXIT.filesystem, { path })
}
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)}`
if (options.json) {
return JSON.stringify({
ok: true,
namespace: result.namespace,
slug: result.slug,
version: result.version,
visibility: result.visibility.toLowerCase(),
detailUrl
})
}
return `Published successfully: ${result.namespace}/${result.slug}@${result.version}\nDetail: ${detailUrl}`
}
/**
* Convert kebab-case visibility to UPPER_SNAKE_CASE for server enum.
*/
function toServerVisibility(visibility: string): string {
return visibility.toUpperCase().replace(/-/g, '_')
}

View file

@ -0,0 +1,75 @@
import { ConfigStore } from '../stores/config-store'
import { CredentialsStore } from '../stores/credentials-store'
import { SkillHubClient } from '../clients/skillhub-client'
import { resolveRegistry, resolveToken } from '../services/registry-service'
import { removeLocalSkill } from '../services/remove-service'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
export interface RemoveCommandOptions {
agent?: string[] | undefined
all?: boolean | undefined
remote?: boolean | undefined
hard?: boolean | undefined
namespace?: string | undefined
registry?: string | undefined
token?: string | undefined
json?: boolean | undefined
}
export async function removeCommand(slug: string, options: RemoveCommandOptions): Promise<string> {
if (options.all && options.agent?.length) {
throw new CliError('--all cannot be used with --agent', EXIT.usage)
}
if (options.remote && (options.agent?.length || options.all)) {
throw new CliError('--remote cannot be used with --agent or --all', EXIT.usage)
}
const configStore = new ConfigStore()
const credentialsStore = new CredentialsStore()
const registry = resolveRegistry(options, process.env, await configStore.read())
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')
const { confirm } = await prompts.default({
type: 'confirm',
name: 'confirm',
message: `Delete remote skill ${namespace}/${slug}?`,
initial: false
})
if (!confirm) {
throw new CliError('remote delete cancelled', EXIT.generic)
}
} else if (!options.hard && !process.stdout.isTTY) {
throw new CliError('non-interactive remote delete requires --hard', EXIT.usage)
}
const client = new SkillHubClient(registry, token)
await client.deleteRemote(namespace, slug)
if (options.json) {
return JSON.stringify({ ok: true, scope: 'remote', action: 'hard-delete', namespace, slug })
}
return `Removed remote skill: ${namespace}/${slug}\nAction: remote-hard-delete`
}
// Local remove
const result = await removeLocalSkill({
registry, slug,
agents: options.agent,
all: options.all
})
if (options.json) {
return JSON.stringify({ ok: true, scope: 'local', removed: result.removed })
}
return result.removed.map(r =>
r.existed
? `Removed ${r.namespace}/${slug} from ${r.dir} (${r.agent})`
: `Cleaned stale record for ${r.namespace}/${slug} at ${r.dir} (${r.agent}, directory already missing)`
).join('\n')
}

View file

@ -0,0 +1,27 @@
import { SkillHubClient } from '../clients/skillhub-client'
import { ConfigStore } from '../stores/config-store'
import { CredentialsStore } from '../stores/credentials-store'
import { resolveRegistry, resolveToken } from '../services/registry-service'
export interface SearchCommandOptions {
registry?: string
token?: string
limit?: number
json?: boolean
}
export async function searchCommand(query: string, options: SearchCommandOptions): Promise<string> {
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 client = new SkillHubClient(registry, token)
const result = await client.search(query ?? '', options.limit ?? 20)
if (options.json) {
return JSON.stringify({ ok: true, items: result.items, total: result.total })
}
if (result.items.length === 0) return 'No skills found.'
return result.items
.map(item => `${item.namespace}/${item.slug} ${item.latestVersion ?? '-'} ${item.summary ?? ''}`)
.join('\n')
}

View file

@ -0,0 +1,80 @@
import { CLI_VERSION, EXIT } from '../shared/constants'
import { CliError } from '../shared/errors'
import { printResult } from '../shared/output'
import { NpmRegistryClient } from '../clients/npm-registry-client'
import { UpdateService } from '../services/update-service'
import { detectInstallMode, type InstallMode } from '../platform/package-manager'
import { runUpdateCommand } from '../platform/updater'
import type { UpdaterRunResult } from '../platform/updater'
export interface UpdateCommandOptions {
check?: boolean
json?: boolean
}
/**
* Injectable runtime dependencies. Defaults are wired to real npm/shell code
* for production. Unit tests pass fakes so they don't need process-global
* module mocks (which leak across files inside Bun's test runner).
*/
export interface UpdateCommandDeps {
latestVersion?: () => Promise<string>
detectInstallMode?: () => InstallMode
run?: (command: readonly string[]) => Promise<UpdaterRunResult>
}
export async function updateCommand(
options: UpdateCommandOptions,
deps: UpdateCommandDeps = {}
): Promise<string> {
const json = Boolean(options.json)
const checkOnly = Boolean(options.check)
const latestVersion = deps.latestVersion ?? (() => new NpmRegistryClient().latestVersion())
const detectMode = deps.detectInstallMode ?? (() => detectInstallMode())
const run = deps.run ?? runUpdateCommand
const service = new UpdateService({
currentVersion: CLI_VERSION,
latestVersion,
detectInstallMode: detectMode,
run
})
const result = await service.update({ checkOnly })
if (!result.available) {
return printResult(
json
? { ok: true, upToDate: true, version: result.currentVersion }
: `Already up to date (${result.currentVersion})`,
json
)
}
if (result.updated) {
return printResult(
json
? { ok: true, updated: true, from: result.currentVersion, to: result.latestVersion }
: `Updated skillhub ${result.currentVersion} -> ${result.latestVersion}`,
json
)
}
if (result.error) {
throw new CliError(result.error, EXIT.generic, { from: result.currentVersion, to: result.latestVersion })
}
// Not updated but available (npx / unknown / checkOnly)
const lines = [`Update available: ${result.currentVersion} -> ${result.latestVersion}`]
if (result.next) {
lines.push(result.next)
}
return printResult(
json
? { ok: true, available: true, from: result.currentVersion, to: result.latestVersion, next: result.next }
: lines.join('\n'),
json
)
}

View file

@ -0,0 +1,7 @@
import { CLI_VERSION } from '../shared/constants'
import { printResult } from '../shared/output'
export async function versionCommand(args: string[]): Promise<string> {
const json = args.includes('--json')
return printResult(json ? { ok: true, version: CLI_VERSION } : `SkillHub CLI ${CLI_VERSION}`, json)
}

View file

@ -0,0 +1,26 @@
import { SkillHubClient } from '../clients/skillhub-client'
import { ConfigStore } from '../stores/config-store'
import { CredentialsStore } from '../stores/credentials-store'
import { resolveRegistry, resolveToken } from '../services/registry-service'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
export interface WhoamiCommandOptions {
registry?: string
token?: string
json?: boolean
}
export async function whoamiCommand(options: WhoamiCommandOptions): Promise<string> {
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))
if (!token) {
throw new CliError('not logged in', EXIT.auth, { registry, next: 'run `skillhub login`' })
}
const user = await new SkillHubClient(registry, token).whoami()
return options.json
? JSON.stringify({ ok: true, registry, handle: user.handle, displayName: user.displayName })
: `Registry: ${registry}\nHandle: ${user.handle}\nName: ${user.displayName}`
}

View file

@ -0,0 +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"

303
cli/src/index.ts Normal file
View file

@ -0,0 +1,303 @@
#!/usr/bin/env node
import { cac } from 'cac'
import { doctorCommand } from './commands/doctor'
import { commands, formatCommandList, helpCommand } from './commands/help'
import { installCommand, type InstallCommandOptions } from './commands/install'
import { listCommand, type ListCommandOptions } from './commands/list'
import { loginCommand } from './commands/login'
import { logoutCommand } from './commands/logout'
import { publishCommand, type PublishCommandOptions } from './commands/publish'
import { removeCommand, type RemoveCommandOptions } from './commands/remove'
import { searchCommand } from './commands/search'
import { updateCommand } from './commands/update'
import { versionCommand } from './commands/version'
import { whoamiCommand } from './commands/whoami'
import { CliError } from './shared/errors'
import { renderError } from './shared/output'
const cli = cac('skillhub')
/** Normalize cac's repeatable option: string | string[] | undefined -> string[] | undefined */
function toArray(val: string | string[] | undefined): string[] | undefined {
if (val === undefined) return undefined
return Array.isArray(val) ? val : [val]
}
async function runCommand(action: () => Promise<string>, json = false): Promise<void> {
try {
const output = await action()
if (output) {
process.stdout.write(`${output}\n`)
}
} catch (error) {
const exitCode = error instanceof CliError ? error.exitCode : 1
process.stderr.write(`${renderError(error, json)}\n`)
process.exit(exitCode)
}
}
const KNOWN_COMMANDS = Object.keys(commands)
function levenshteinDistance(left: string, right: string): number {
const rows = left.length + 1
const cols = right.length + 1
const matrix = Array.from({ length: rows }, () => Array<number>(cols).fill(0))
for (let row = 0; row < rows; row += 1) matrix[row]![0] = row
for (let col = 0; col < cols; col += 1) matrix[0]![col] = col
for (let row = 1; row < rows; row += 1) {
for (let col = 1; col < cols; col += 1) {
const cost = left[row - 1] === right[col - 1] ? 0 : 1
matrix[row]![col] = Math.min(
matrix[row - 1]![col]! + 1,
matrix[row]![col - 1]! + 1,
matrix[row - 1]![col - 1]! + cost
)
}
}
return matrix[left.length]![right.length]!
}
function findCommandSuggestions(input: string): string[] {
return KNOWN_COMMANDS
.map(command => ({
command,
score: command.startsWith(input)
? 0
: command.includes(input)
? 1
: levenshteinDistance(input, command)
}))
.filter(({ command, score }) =>
command.startsWith(input) ||
(input.length > 2 && command.includes(input)) ||
score <= Math.max(2, Math.floor(command.length / 3))
)
.sort((left, right) => left.score - right.score || left.command.localeCompare(right.command))
.map(({ command }) => command)
.slice(0, 3)
}
function renderCommandDirectory(): string {
return ['Available commands:', formatCommandList()].join('\n')
}
function exitWithOutput(output: string, exitCode: number): never {
process.stderr.write(`${output}\n`)
process.exit(exitCode)
}
function exitWithCliError(error: CliError, json: boolean, humanOutput?: string): never {
return exitWithOutput(json ? renderError(error, true) : (humanOutput ?? renderError(error, false)), error.exitCode)
}
function exitUnknownCommand(command: string, json: boolean): never {
const suggestions = findCommandSuggestions(command)
const lines = [`unknown command "${command}" for "skillhub"`, '']
if (suggestions.length > 0) {
lines.push(`Did you mean ${suggestions.length === 1 ? 'this' : 'one of these'}?`)
lines.push(...suggestions.map(suggestion => ` ${suggestion}`))
lines.push('')
}
lines.push('Usage: skillhub <command> [flags]', '')
lines.push(renderCommandDirectory(), '')
lines.push('Run "skillhub help" for more information.')
return exitWithCliError(new CliError(`unknown command "${command}" for "skillhub"`, 5), json, lines.join('\n'))
}
function exitUnknownFlag(flag: string, json: boolean): never {
return exitWithCliError(new CliError(`unknown flag: ${flag}`, 5), json, [
`unknown flag: ${flag}`,
'',
'Usage: skillhub <command> [flags]',
'',
renderCommandDirectory(),
'',
'Run "skillhub help" for more information.'
].join('\n'))
}
function handleCliParseError(error: unknown, json: boolean): never {
if (!(error instanceof Error)) {
return exitWithCliError(new CliError('unexpected failure', 1), json, 'Unexpected error')
}
if (error.name === 'CACError') {
const message = error.message
if (/unknown option/i.test(message)) {
const match = message.match(/unknown option ["`]?([^"`]+)["`]?/i)
return exitUnknownFlag(match?.[1] ?? 'unknown', json)
}
if (message.includes('missing required args')) {
const match = message.match(/command `([^`]+)`/)
const cmdName = match?.[1] ?? 'command'
const firstWord = cmdName.split(' ')[0] ?? 'command'
return exitWithCliError(new CliError('missing required argument', 5), json, [
'Error: missing required argument',
'',
`Usage: skillhub ${cmdName}`,
'',
`Run "skillhub help ${firstWord}" for more information.`
].join('\n'))
}
const cleanMessage = message.replace(/`/g, '"')
return exitWithCliError(new CliError(cleanMessage, 5), json)
}
return exitWithCliError(new CliError('unexpected failure', 1), json, `Unexpected error: ${error.message}`)
}
function isJsonRequested(argv: string[]): boolean {
return argv.includes('--json')
}
function readUnknownCommand(argv: string[]): string | undefined {
const firstArg = argv[0]
if (!firstArg || firstArg.startsWith('-') || KNOWN_COMMANDS.includes(firstArg)) {
return undefined
}
return firstArg
}
cli
.command('', 'Show help')
.action(() => runCommand(() => helpCommand([])))
cli
.command('help [command]', 'Show help')
.option('--json', 'Output JSON')
.action((command: string | undefined, options: { json?: boolean }) => {
// TODO: --json is not forwarded to helpCommand; see help-command.test.ts
return runCommand(() => helpCommand(command ? [command] : []), Boolean(options.json))
})
cli
.command('version', 'Show CLI version')
.option('--json', 'Output JSON')
.action((options: { json?: boolean }) => {
return runCommand(() => versionCommand(options.json ? ['--json'] : []), Boolean(options.json))
})
cli
.command('update', 'Update CLI to latest version')
.option('--check', 'Check for updates without installing')
.option('--json', 'Output JSON')
.action((options: { check?: boolean; json?: boolean }) => {
return runCommand(() => updateCommand(options), Boolean(options.json))
})
cli
.command('login', 'Save registry and token')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--json', 'Output JSON')
.action((options: { registry?: string; token?: string; json?: boolean }) => {
return runCommand(() => loginCommand(options), Boolean(options.json))
})
cli
.command('logout', 'Remove local token')
.option('--registry <url>', 'Registry URL')
.option('--json', 'Output JSON')
.action((options: { registry?: string; json?: boolean }) => {
return runCommand(() => logoutCommand(options), Boolean(options.json))
})
cli
.command('whoami', 'Verify current token')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--json', 'Output JSON')
.action((options: { registry?: string; token?: string; json?: boolean }) => {
return runCommand(() => whoamiCommand(options), Boolean(options.json))
})
cli
.command('search [query]', 'Search published skills')
.option('--registry <url>', 'Registry URL')
.option('--limit <n>', 'Max results', { default: 20 })
.option('--json', 'Output JSON')
.action((query: string | undefined, options: { registry?: string; limit?: number; json?: boolean }) => {
return runCommand(() => searchCommand(query ?? '', options), Boolean(options.json))
})
cli
.command('install <slug>', 'Install a skill locally')
.option('--namespace <slug>', 'Namespace', { default: 'global' })
.option('--version <v>', 'Version')
.option('--agent <profile>', 'Agent profile (repeatable)')
.option('--dir <path>', 'Install directory')
.option('--force', 'Overwrite existing')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--json', 'Output JSON')
.action((slug: string, options: InstallCommandOptions & { agent?: string | string[] }) => {
return runCommand(() => installCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json))
})
cli
.command('list', 'List local installs')
.option('--agent <profile>', 'Filter by agent (repeatable)')
.option('--dir <path>', 'Filter by directory')
.option('--registry <url>', 'Registry URL')
.option('--json', 'Output JSON')
.action((options: ListCommandOptions & { agent?: string | string[] }) => {
return runCommand(() => listCommand({ ...options, agent: toArray(options.agent) }), Boolean(options.json))
})
cli
.command('remove <slug>', 'Remove local or remote skill')
.option('--agent <profile>', 'Filter by agent (repeatable)')
.option('--all', 'Remove all targets')
.option('--remote', 'Delete remote skill')
.option('--hard', 'Skip confirmation for remote delete')
.option('--namespace <slug>', 'Namespace for remote delete')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--json', 'Output JSON')
.action((slug: string, options: RemoveCommandOptions & { agent?: string | string[] }) => {
return runCommand(() => removeCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json))
})
cli
.command('doctor', 'Scan project and merge into local inventory')
.option('--json', 'Output JSON')
.action((options: { json?: boolean }) => {
return runCommand(() => doctorCommand(options), Boolean(options.json))
})
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')
.action((path: string, options: PublishCommandOptions) => {
return runCommand(() => publishCommand(path, options), Boolean(options.json))
})
cli.help()
if (import.meta.main) {
const args = process.argv.slice(2)
const json = isJsonRequested(args)
const unknownCommand = readUnknownCommand(args)
if (unknownCommand) {
exitUnknownCommand(unknownCommand, json)
}
try {
cli.parse(process.argv)
} catch (error) {
handleCliParseError(error, json)
}
}

View file

@ -0,0 +1,73 @@
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { zipSync, unzipSync } from 'fflate'
/**
* Extract a zip archive buffer into the target directory.
* Pure JS implementation using fflate no system commands needed.
*/
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)
if (name.endsWith('/')) {
await mkdir(filePath, { recursive: true })
continue
}
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, data)
}
}
/**
* Create a zip archive from a directory.
* Returns the archive as a Blob.
* Pure JS implementation using fflate no system commands needed.
*/
export async function createZip(dirPath: string): Promise<Blob> {
const entries: Record<string, Uint8Array> = {}
await collectFiles(dirPath, dirPath, entries)
const zipped = zipSync(entries, { level: 6 })
return new Blob([zipped.buffer as ArrayBuffer], { type: 'application/zip' })
}
async function collectFiles(basePath: string, currentPath: string, entries: Record<string, Uint8Array>): Promise<void> {
const items = await readdir(currentPath, { withFileTypes: true })
for (const item of items) {
const fullPath = join(currentPath, item.name)
const relPath = relative(basePath, fullPath)
if (item.isDirectory()) {
entries[relPath + '/'] = new Uint8Array(0)
await collectFiles(basePath, fullPath, entries)
} else if (item.isFile()) {
entries[relPath] = new Uint8Array(await readFile(fullPath))
}
}
}
/**
* Detect whether a path is a zip file by checking magic bytes.
*/
export async function isZipFile(filePath: string): Promise<boolean> {
try {
const buf = new Uint8Array(await readFile(filePath))
return buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4B && buf[2] === 0x03 && buf[3] === 0x04
} catch {
return false
}
}
function safeJoin(targetDir: string, entryName: string): string {
if (isAbsolute(entryName)) {
throw new Error(`unsafe zip entry path: ${entryName}`)
}
const root = resolve(targetDir)
const target = resolve(root, entryName)
const rel = relative(root, target)
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
throw new Error(`unsafe zip entry path: ${entryName}`)
}
return target
}

19
cli/src/platform/os.ts Normal file
View file

@ -0,0 +1,19 @@
/**
* Platform detection utilities.
*/
export type Platform = 'darwin' | 'linux' | 'win32' | 'unknown'
export function currentPlatform(): Platform {
const p = process.platform
if (p === 'darwin' || p === 'linux' || p === 'win32') return p
return 'unknown'
}
export function isWindows(): boolean {
return process.platform === 'win32'
}
export function isTTY(): boolean {
return process.stdout.isTTY === true
}

View file

@ -0,0 +1,49 @@
export type InstallMode = 'npx' | 'npm-global' | 'bun-global' | 'unknown'
/**
* Detect how the CLI was installed by inspecting process.argv and
* environment variables.
*
* - npx: argv[1] contains `_npx/` or npm_execpath points to npx
* - npm-global: resolved binary lives under a global npm prefix
* - bun-global: resolved binary lives under ~/.bun or BUN_INSTALL
* - unknown: fallback
*/
export function detectInstallMode(
argv: string[] = process.argv,
env: NodeJS.ProcessEnv = process.env
): InstallMode {
const execPath = argv[1] ?? ''
// npx detection: npx injects `_npx/` into the path
if (execPath.includes('_npx/') || execPath.includes('_npx\\')) {
return 'npx'
}
// npm_execpath is set when running via npm/npx
const npmExecPath = env.npm_execpath ?? ''
if (npmExecPath.includes('npx')) {
return 'npx'
}
// bun global: BUN_INSTALL or ~/.bun in the path
const bunInstall = env.BUN_INSTALL ?? ''
if (bunInstall && execPath.startsWith(bunInstall)) {
return 'bun-global'
}
if (execPath.includes('/.bun/') || execPath.includes('\\.bun\\')) {
return 'bun-global'
}
// npm global: check if the binary is in a global npm prefix
const npmGlobalPrefix = env.npm_config_prefix ?? ''
if (npmGlobalPrefix && execPath.startsWith(npmGlobalPrefix)) {
return 'npm-global'
}
// Common npm global paths
if (execPath.includes('/lib/node_modules/') || execPath.includes('/node_modules/.bin/')) {
return 'npm-global'
}
return 'unknown'
}

31
cli/src/platform/paths.ts Normal file
View file

@ -0,0 +1,31 @@
export function userStateDir(home = process.env.HOME || process.env.USERPROFILE || ''): string {
if (!home) {
throw new Error('Cannot resolve user home directory')
}
return `${home.replace(/\\/g, '/')}/.skillhub`
}
export function joinPath(...parts: string[]): string {
return parts.join('/').replace(/\/+/g, '/')
}
export async function ensureDir(dir: string): Promise<void> {
const { mkdir } = await import('node:fs/promises')
await mkdir(dir, { recursive: true })
}
export async function pathExists(path: string): Promise<boolean> {
const { access } = await import('node:fs/promises')
try {
await access(path)
return true
} catch {
return false
}
}
export async function applyCredentialPermissions(path: string): Promise<void> {
if (process.platform === 'win32') return
const { chmod } = await import('node:fs/promises')
await chmod(path, 0o600)
}

View file

@ -0,0 +1,45 @@
import { spawn } from 'node:child_process'
import type { ChildProcess, SpawnOptions } from 'node:child_process'
export interface UpdaterRunResult {
success: boolean
output: string
}
/**
* Execute an update command without relying on sh -c.
* Accepts argv array (program + args) to avoid split fragility with spaces or shell metacharacters.
*/
export async function runUpdateCommand(command: readonly string[]): Promise<UpdaterRunResult> {
try {
const [program, ...args] = command
if (!program) {
return { success: false, output: 'empty update command' }
}
// On Windows, use shell: true to let the OS resolve the executable
// This handles both .exe and .cmd extensions automatically
const spawnOptions: SpawnOptions = {
stdio: ['ignore', 'pipe', 'pipe'],
...(process.platform === 'win32' && { shell: true })
}
return await new Promise<UpdaterRunResult>((resolve) => {
const proc: ChildProcess = spawn(program, args, spawnOptions)
const chunks: Buffer[] = []
proc.stdout?.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)))
proc.stderr?.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)))
proc.on('error', (error: Error) => resolve({ success: false, output: error.message }))
proc.on('close', (code: number | null) => resolve({
success: code === 0,
output: Buffer.concat(chunks).toString('utf-8')
}))
})
} catch (error) {
return {
success: false,
output: error instanceof Error ? error.message : String(error)
}
}
}

View file

@ -0,0 +1,26 @@
import { SkillHubClient } from '../clients/skillhub-client'
import { ConfigStore } from '../stores/config-store'
import { CredentialsStore } from '../stores/credentials-store'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
export class AuthService {
constructor(
private readonly configStore: ConfigStore,
private readonly credentialsStore: CredentialsStore
) {}
async login(registry: string, token?: string): Promise<{ handle: string }> {
if (!token) {
throw new CliError('token is required', EXIT.usage, { next: 'pass --token, set SKILLHUB_TOKEN, or use interactive login' })
}
const user = await new SkillHubClient(registry, token).whoami()
await this.configStore.setRegistry(registry)
await this.credentialsStore.setToken(registry, token)
return { handle: user.handle }
}
async logout(registry: string): Promise<void> {
await this.credentialsStore.deleteToken(registry)
}
}

View file

@ -0,0 +1,195 @@
import { lstat, readdir, writeFile, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { InventoryStore, type Inventory, type InventoryItem, type InventoryTarget } from '../stores/inventory-store'
interface MetadataJson {
registry: string
namespace: string
slug: string
version: string
agent: string
installedAt: string
}
interface DoctorResult {
inventoryPath: string
backupPath: string | null
itemsScanned: number
targetsScanned: number
itemsPreserved: number
targetsPreserved: number
skipped: Array<{ path: string; reason: string }>
conflicts: Array<{ key: string; versions: string[] }>
}
export async function runDoctor(cwd: string, home?: string): Promise<DoctorResult> {
const store = new InventoryStore(home)
const skipped: DoctorResult['skipped'] = []
const conflicts: DoctorResult['conflicts'] = []
// Scan <cwd>/.*/skills/<slug>/.skillhub/metadata.json
const entries = await scanMetadata(cwd, skipped)
// Group by registry + namespace + slug
const groups = new Map<string, { metadata: MetadataJson; installDir: string }[]>()
for (const entry of entries) {
const key = `${entry.metadata.registry}|${entry.metadata.namespace}|${entry.metadata.slug}`
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(entry)
}
// Build scanned items
const scannedItems: InventoryItem[] = []
for (const [key, group] of groups) {
const versions = new Set(group.map(e => e.metadata.version))
if (versions.size > 1) {
conflicts.push({ key, versions: [...versions] })
continue
}
const first = group[0]!
const targets: InventoryTarget[] = group.map(e => ({
agent: e.metadata.agent,
rootDir: join(e.installDir, '..'),
installDir: e.installDir,
installedAt: e.metadata.installedAt
}))
scannedItems.push({
registry: first.metadata.registry,
namespace: first.metadata.namespace,
slug: first.metadata.slug,
version: first.metadata.version,
targets
})
}
// Read old inventory
let oldInventory: Inventory
try {
oldInventory = await store.read()
} catch {
oldInventory = { items: [] }
}
// Collect scanned installDirs
const scannedInstallDirs = new Set<string>()
for (const item of scannedItems) {
for (const target of item.targets) {
scannedInstallDirs.add(target.installDir)
}
}
// Preserve old items where installDir is not in scanned set
// This allows the same slug to coexist in different installDirs (e.g., different projects)
const preservedItems: InventoryItem[] = []
for (const oldItem of oldInventory.items) {
const preservedTargets = oldItem.targets.filter(t => !scannedInstallDirs.has(t.installDir))
if (preservedTargets.length > 0) {
preservedItems.push({
...oldItem,
targets: preservedTargets
})
}
}
// Merge scanned and preserved items
const items = [...scannedItems, ...preservedItems]
// Backup old inventory
let backupPath: string | null = null
try {
const oldContent = await readFile(store.path, 'utf-8')
backupPath = `${store.path}.bak`
await writeFile(backupPath, oldContent)
} catch {
// No existing inventory to backup
}
// Atomically write new inventory
const newInventory: Inventory = { items }
await store.writeAtomic(newInventory)
return {
inventoryPath: store.path,
backupPath,
itemsScanned: scannedItems.length,
targetsScanned: scannedItems.reduce((sum, item) => sum + item.targets.length, 0),
itemsPreserved: preservedItems.length,
targetsPreserved: preservedItems.reduce((sum, item) => sum + item.targets.length, 0),
skipped,
conflicts
}
}
async function scanMetadata(cwd: string, skipped: DoctorResult['skipped']): Promise<Array<{ metadata: MetadataJson; installDir: string }>> {
const results: Array<{ metadata: MetadataJson; installDir: string }> = []
let topEntries: string[]
try {
topEntries = await readdir(cwd)
} catch {
throw new CliError('cannot read project directory', EXIT.filesystem, { path: cwd })
}
for (const dirName of topEntries) {
if (!dirName.startsWith('.')) continue
const agentDir = join(cwd, dirName)
try {
const st = await lstat(agentDir)
if (st.isSymbolicLink() || !st.isDirectory()) {
skipped.push({ path: agentDir, reason: 'not a regular directory' })
continue
}
} catch {
skipped.push({ path: agentDir, reason: 'cannot stat' })
continue
}
const skillsDir = join(agentDir, 'skills')
let slugDirs: string[]
try {
slugDirs = await readdir(skillsDir)
} catch {
continue
}
for (const slug of slugDirs) {
const slugPath = join(skillsDir, slug)
try {
const st = await lstat(slugPath)
if (st.isSymbolicLink() || !st.isDirectory()) {
skipped.push({ path: slugPath, reason: 'not a regular directory' })
continue
}
} catch {
skipped.push({ path: slugPath, reason: 'cannot stat' })
continue
}
const skillhubDir = join(slugPath, '.skillhub')
try {
const skillhubSt = await lstat(skillhubDir)
if (skillhubSt.isSymbolicLink() || !skillhubSt.isDirectory()) {
skipped.push({ path: slugPath, reason: '.skillhub is not a regular directory' })
continue
}
} catch {
skipped.push({ path: slugPath, reason: 'no .skillhub directory' })
continue
}
const metadataPath = join(skillhubDir, 'metadata.json')
try {
const content = await readFile(metadataPath, 'utf-8')
const metadata = JSON.parse(content) as MetadataJson
if (!metadata.registry || !metadata.namespace || !metadata.slug || !metadata.version || !metadata.agent || !metadata.installedAt) {
skipped.push({ path: slugPath, reason: 'incomplete metadata' })
continue
}
results.push({ metadata, installDir: slugPath })
} catch {
skipped.push({ path: slugPath, reason: 'no .skillhub/metadata.json' })
}
}
}
return results
}

View file

@ -0,0 +1,74 @@
import { mkdir, 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 type { AgentCandidate } from '../agents/types'
export interface InstallOptions {
registry: string
token?: string | undefined
namespace: string
slug: string
version?: string | undefined
targets: AgentCandidate[]
force: boolean
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()
const installed: Array<{ agent: string; dir: string }> = []
const store = new InventoryStore(options.home)
for (const target of options.targets) {
const skillDir = join(target.rootDir, options.slug)
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 })
}
// 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 })
}
return { installed }
}

View file

@ -0,0 +1,21 @@
import { DEFAULT_REGISTRY } from '../shared/constants'
export function resolveRegistry(
args: { registry?: string | undefined },
env: NodeJS.ProcessEnv,
config: { registry?: string | undefined }
): string {
return normalizeRegistry(args.registry || env.SKILLHUB_REGISTRY || config.registry || DEFAULT_REGISTRY)
}
export function resolveToken(
args: { token?: string | undefined },
env: NodeJS.ProcessEnv,
storedToken?: string | undefined
): string | undefined {
return args.token || env.SKILLHUB_TOKEN || storedToken
}
function normalizeRegistry(registry: string): string {
return registry.replace(/\/+$/, '')
}

View file

@ -0,0 +1,76 @@
import { rm, stat } from 'node:fs/promises'
import { relative, isAbsolute } from 'node:path'
import { InventoryStore } from '../stores/inventory-store'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
/**
* Validate that child path is strictly under parent directory.
* Prevents path traversal attacks during remove operations.
*/
function isPathUnder(child: string, parent: string): boolean {
const rel = relative(parent, child)
return !rel.startsWith('..') && !isAbsolute(rel) && rel.length > 0
}
export interface RemoveLocalOptions {
registry: string
slug: string
agents?: string[] | undefined
all?: boolean | undefined
home?: string | undefined
}
export interface RemoveResult {
removed: Array<{ namespace: string; agent: string; dir: string; existed: boolean }>
}
export async function removeLocalSkill(options: RemoveLocalOptions): Promise<RemoveResult> {
const store = new InventoryStore(options.home)
const inventory = await store.read()
const items = inventory.items.filter(i => i.registry === options.registry && i.slug === options.slug)
if (items.length === 0) {
throw new CliError(`skill not found locally: ${options.slug}`, EXIT.generic, {
next: 'run `skillhub list` to see installed skills'
})
}
const targetsToRemove = items.flatMap(item => {
const targets = options.agents?.length
? item.targets.filter(t => options.agents!.includes(t.agent))
: item.targets
return targets.map(target => ({ item, target }))
})
if (targetsToRemove.length === 0) {
throw new CliError(`no matching targets for agents: ${options.agents?.join(', ')}`, EXIT.generic)
}
const removed: RemoveResult['removed'] = []
for (const { item, target } of targetsToRemove) {
// Validate installDir is strictly under the recorded rootDir
if (!target.rootDir || !isPathUnder(target.installDir, target.rootDir)) {
throw new CliError(`unsafe remove path: ${target.installDir} is not under ${target.rootDir ?? 'unknown root'}`, EXIT.filesystem, {
path: target.installDir,
next: 'verify inventory integrity with `skillhub doctor`'
})
}
let existed = true
try {
await stat(target.installDir)
} catch {
existed = false
}
if (existed) {
await rm(target.installDir, { recursive: true })
}
await store.removeTarget(options.registry, item.namespace, options.slug, target.installDir)
removed.push({ namespace: item.namespace, agent: target.agent, dir: target.installDir, existed })
}
return { removed }
}

View file

@ -0,0 +1,99 @@
import { gt as semverGt } from 'semver'
import { CLI_PACKAGE_NAME } from '../shared/constants'
import type { InstallMode } from '../platform/package-manager'
import type { UpdaterRunResult } from '../platform/updater'
export interface UpdateServiceDeps {
currentVersion: string
latestVersion: () => Promise<string>
detectInstallMode: () => InstallMode
run: (command: readonly string[]) => Promise<UpdaterRunResult>
}
export interface UpdateOptions {
checkOnly: boolean
}
export interface UpdateResult {
updated: boolean
available: boolean
currentVersion?: string | undefined
latestVersion?: string | undefined
next?: string | undefined
error?: string | undefined
}
export class UpdateService {
constructor(private readonly deps: UpdateServiceDeps) {}
async update(options: UpdateOptions): Promise<UpdateResult> {
const current = this.deps.currentVersion
const latest = await this.deps.latestVersion()
// No update available (use semver comparison)
if (!semverGt(latest, current)) {
return {
updated: false,
available: false,
currentVersion: current,
latestVersion: latest
}
}
// Update available but only checking
if (options.checkOnly) {
return {
updated: false,
available: true,
currentVersion: current,
latestVersion: latest
}
}
// Determine install mode and update strategy
const mode = this.deps.detectInstallMode()
switch (mode) {
case 'npx':
return {
updated: false,
available: true,
currentVersion: current,
latestVersion: latest,
next: `Run: npx ${CLI_PACKAGE_NAME}@latest <command> or install globally: npm install -g ${CLI_PACKAGE_NAME}`
}
case 'npm-global': {
const result = await this.deps.run(['npm', 'install', '-g', `${CLI_PACKAGE_NAME}@latest`])
return {
updated: result.success,
available: true,
currentVersion: current,
latestVersion: latest,
error: result.success ? undefined : result.output
}
}
case 'bun-global': {
const result = await this.deps.run(['bun', 'add', '-g', `${CLI_PACKAGE_NAME}@latest`])
return {
updated: result.success,
available: true,
currentVersion: current,
latestVersion: latest,
error: result.success ? undefined : result.output
}
}
case 'unknown':
default:
return {
updated: false,
available: true,
currentVersion: current,
latestVersion: latest,
next: `Update manually: npm install -g ${CLI_PACKAGE_NAME}@latest or bun add -g ${CLI_PACKAGE_NAME}@latest`
}
}
}
}

View file

@ -0,0 +1,13 @@
import { PKG_NAME, PKG_VERSION } from '../generated/pkg-info'
export const DEFAULT_REGISTRY = 'https://skill.xfyun.cn'
export const CLI_VERSION: string = PKG_VERSION
export const CLI_PACKAGE_NAME: string = PKG_NAME
export const EXIT = {
generic: 1,
auth: 2,
network: 3,
filesystem: 4,
usage: 5,
validation: 6
} as const

10
cli/src/shared/errors.ts Normal file
View file

@ -0,0 +1,10 @@
export class CliError extends Error {
constructor(
message: string,
readonly exitCode: number,
readonly details: Record<string, unknown> = {}
) {
super(message)
this.name = 'CliError'
}
}

44
cli/src/shared/output.ts Normal file
View file

@ -0,0 +1,44 @@
import { CliError } from './errors'
export type JsonObject = Record<string, unknown>
export function printResult(result: string | JsonObject, json: boolean): string {
if (json) {
return JSON.stringify(typeof result === 'string' ? { ok: true, message: result } : result)
}
return typeof result === 'string' ? result : humanize(result)
}
export function renderError(error: unknown, json: boolean): string {
const cliError = error instanceof CliError
? error
: new CliError('unexpected failure', 1)
if (json) {
return JSON.stringify({
ok: false,
message: cliError.message,
exitCode: cliError.exitCode,
...(Object.keys(cliError.details).length > 0 ? { details: cliError.details } : {})
})
}
const lines = [`Error: ${cliError.message}`]
if (typeof cliError.details.registry === 'string') {
lines.push(`Context: registry ${cliError.details.registry}`)
}
if (typeof cliError.details.path === 'string') {
lines.push(`Context: path ${cliError.details.path}`)
}
if (typeof cliError.details.next === 'string') {
lines.push(`Next: ${cliError.details.next}`)
}
return lines.join('\n')
}
function humanize(value: JsonObject): string {
return Object.entries(value)
.filter(([key]) => key !== 'ok')
.map(([key, item]) => `${key}: ${String(item)}`)
.join('\n')
}

10
cli/src/shared/types.ts Normal file
View file

@ -0,0 +1,10 @@
export interface CommandContext {
cwd: string
env: NodeJS.ProcessEnv
stdout: { write(text: string): void }
stderr: { write(text: string): void }
}
export interface CommandResult {
exitCode: number
}

View file

@ -0,0 +1,31 @@
import { readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { joinPath, userStateDir, ensureDir, pathExists } from '../platform/paths'
export interface CliConfig {
registry?: string
defaultAgent?: string
lastUpdateCheckAt?: string
}
export class ConfigStore {
readonly path: string
constructor(home?: string) {
this.path = joinPath(userStateDir(home), 'config.json')
}
async read(): Promise<CliConfig> {
if (!(await pathExists(this.path))) return {}
return JSON.parse(await readFile(this.path, 'utf-8')) as CliConfig
}
async write(config: CliConfig): Promise<void> {
await ensureDir(dirname(this.path))
await writeFile(this.path, JSON.stringify(config, null, 2))
}
async setRegistry(registry: string): Promise<void> {
await this.write({ ...(await this.read()), registry })
}
}

View file

@ -0,0 +1,40 @@
import { readFile, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { joinPath, userStateDir, ensureDir, applyCredentialPermissions, pathExists } from '../platform/paths'
interface CredentialsFile {
tokens: Record<string, string>
}
export class CredentialsStore {
readonly path: string
constructor(home?: string) {
this.path = joinPath(userStateDir(home), 'credentials.json')
}
async read(): Promise<CredentialsFile> {
if (!(await pathExists(this.path))) return { tokens: {} }
return JSON.parse(await readFile(this.path, 'utf-8')) as CredentialsFile
}
async getToken(registry: string): Promise<string | undefined> {
return (await this.read()).tokens[registry]
}
async setToken(registry: string, token: string): Promise<void> {
const current = await this.read()
await ensureDir(dirname(this.path))
await writeFile(this.path, JSON.stringify({ tokens: { ...current.tokens, [registry]: token } }, null, 2))
await applyCredentialPermissions(this.path)
}
async deleteToken(registry: string): Promise<void> {
const current = await this.read()
const tokens = { ...current.tokens }
delete tokens[registry]
await ensureDir(dirname(this.path))
await writeFile(this.path, JSON.stringify({ tokens }, null, 2))
await applyCredentialPermissions(this.path)
}
}

View file

@ -0,0 +1,168 @@
import { open, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { joinPath, userStateDir, ensureDir, pathExists } from '../platform/paths'
export interface InventoryTarget {
agent: string
rootDir: string
installDir: string
installedAt: string
}
export interface InventoryItem {
registry: string
namespace: string
slug: string
version: string
targets: InventoryTarget[]
}
export interface Inventory {
items: InventoryItem[]
}
export class InventoryStore {
readonly path: string
constructor(home?: string) {
this.path = joinPath(userStateDir(home), 'inventory.json')
}
async read(): Promise<Inventory> {
if (!(await pathExists(this.path))) return { items: [] }
return JSON.parse(await readFile(this.path, 'utf-8')) as Inventory
}
async write(inventory: Inventory): Promise<void> {
await ensureDir(dirname(this.path))
await writeFile(this.path, JSON.stringify(inventory, null, 2))
}
async writeAtomic(inventory: Inventory): Promise<void> {
await ensureDir(dirname(this.path))
const payload = JSON.stringify(inventory, null, 2)
JSON.parse(payload)
const lockPath = `${this.path}.lock`
const tmpPath = `${this.path}.${process.pid}.${Date.now()}.tmp`
let lockHandle: Awaited<ReturnType<typeof open>> | null = null
try {
// Acquire exclusive lock with retry and stale lock detection
lockHandle = await this.acquireLock(lockPath)
await writeFile(tmpPath, payload)
JSON.parse(await readFile(tmpPath, 'utf-8'))
await rename(tmpPath, this.path)
} finally {
// Clean up temp file if it still exists
await rm(tmpPath, { force: true }).catch(() => {})
// Release lock
if (lockHandle) {
await lockHandle.close().catch(() => {})
await rm(lockPath, { force: true }).catch(() => {})
}
}
}
private async acquireLock(lockPath: string, maxRetries = 10, retryDelayMs = 100): Promise<Awaited<ReturnType<typeof open>>> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
// Try to create lock file with PID and timestamp
const lockHandle = await open(lockPath, 'wx')
const lockData = JSON.stringify({ pid: process.pid, timestamp: Date.now() })
await writeFile(lockPath, lockData)
return lockHandle
} catch (err) {
if (err instanceof Error && 'code' in err && err.code !== 'EEXIST') throw err
// Lock exists, check if it's stale (older than 30 seconds)
// 30s threshold chosen to balance between:
// - Allowing slow operations to complete (e.g., large inventory writes)
// - Recovering quickly from crashed processes
try {
const lockContent = await readFile(lockPath, 'utf-8')
const lockData = JSON.parse(lockContent) as { pid: number; timestamp: number }
const ageMs = Date.now() - lockData.timestamp
if (ageMs > 30000) {
// Stale lock detected - verify the process is actually dead
try {
// process.kill(pid, 0) throws if process doesn't exist
process.kill(lockData.pid, 0)
// Process still alive, wait and retry
} catch {
// Process is dead, safe to remove stale lock
await rm(lockPath, { force: true }).catch(() => {})
continue
}
}
} catch {
// Lock file disappeared or corrupted, retry
continue
}
// Lock is held by another active process, wait and retry with exponential backoff
if (attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelayMs * Math.pow(2, attempt)))
}
}
}
throw new Error(`Failed to acquire lock after ${maxRetries} attempts`)
}
async upsertTarget(
registry: string,
namespace: string,
slug: string,
version: string,
target: InventoryTarget
): Promise<void> {
const inventory = await this.read()
let item = inventory.items.find(
i => i.registry === registry && i.namespace === namespace && i.slug === slug
)
if (!item) {
item = { registry, namespace, slug, version, targets: [] }
inventory.items.push(item)
}
item.version = version
const existingIdx = item.targets.findIndex(t => t.installDir === target.installDir)
if (existingIdx >= 0) {
item.targets[existingIdx] = target
} else {
item.targets.push(target)
}
await this.writeAtomic(inventory)
}
async removeTarget(registry: string, namespace: string, slug: string, installDir: string): Promise<boolean> {
const inventory = await this.read()
const item = inventory.items.find(i => i.registry === registry && i.namespace === namespace && i.slug === slug)
if (!item) return false
const idx = item.targets.findIndex(t => t.installDir === installDir)
if (idx < 0) return false
item.targets.splice(idx, 1)
if (item.targets.length === 0) {
inventory.items = inventory.items.filter(i => i !== item)
}
await this.writeAtomic(inventory)
return true
}
async removeTargetsByInstallDir(installDir: string): Promise<number> {
const inventory = await this.read()
let removed = 0
for (const item of inventory.items) {
const before = item.targets.length
item.targets = item.targets.filter(t => t.installDir !== installDir)
removed += before - item.targets.length
}
if (removed > 0) {
inventory.items = inventory.items.filter(item => item.targets.length > 0)
await this.writeAtomic(inventory)
}
return removed
}
}

View file

@ -0,0 +1,436 @@
type FakeHandler = (req: Request) => Response | Promise<Response>
export function createFakeRegistry(handlers: Record<string, FakeHandler>) {
return async function fakeFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
const path = new URL(url).pathname
for (const [pattern, handler] of Object.entries(handlers)) {
if (path === pattern || path.startsWith(pattern)) {
return handler(new Request(url, init))
}
}
return new Response(JSON.stringify({ error: 'not found' }), { status: 404 })
}
}
// ---------------------------------------------------------------------------
// Failure injection
// ---------------------------------------------------------------------------
/**
* Controls how a specific endpoint behaves when a failure is injected:
* 'auth' => 401 { code: 401, message: 'unauthorized' }
* 'forbidden' => 403 { code: 403, message: 'forbidden' }
* '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' | '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, message: 'forbidden' }, { status: 403 })
case 'not_found':
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
case 'server_error':
return Response.json({ code: 500, message: 'internal error' }, { status: 500 })
case 'network':
// Unreachable at runtime: startFakeRegistry intercepts 'network' failures
// by returning a URL pointing at a TCP server that closes connections
// immediately, so fetch() itself rejects. This case satisfies TypeScript.
return new Response(null, { status: 503 })
}
}
// ---------------------------------------------------------------------------
// Skill fixture shape
// ---------------------------------------------------------------------------
export interface FakeSkill {
namespace: string
slug: string
/** Treated as the "latest" version string. Defaults to '1.0.0'. */
version?: string
/** Numeric version id returned in resolve. Defaults to 1. */
versionId?: number
/** SHA-256 fingerprint string. Defaults to 'deadbeef'. */
fingerprint?: string
/** Raw bytes served as the ZIP body. Defaults to a minimal valid ZIP. */
zipBytes?: Uint8Array
}
// Minimal valid ZIP: local file header + end-of-central-directory record with
// zero entries. Enough for any consumer that just checks Content-Type / length.
const MINIMAL_ZIP = new Uint8Array([
// End of central directory record (22 bytes, zero entries)
0x50, 0x4b, 0x05, 0x06, // signature
0x00, 0x00, // disk number
0x00, 0x00, // disk with start of central directory
0x00, 0x00, // entries on this disk
0x00, 0x00, // total entries
0x00, 0x00, 0x00, 0x00, // size of central directory
0x00, 0x00, 0x00, 0x00, // offset of central directory
0x00, 0x00, // comment length
])
// ---------------------------------------------------------------------------
// Captured publish state
// ---------------------------------------------------------------------------
/**
* Shape of the last publish request received by the fake registry.
* Inspect via `registry.received.publish` in tests.
*/
export interface CapturedPublish {
namespace: string
/** Original file name from the multipart form field. */
fileName: string
/** Visibility string from the multipart form field. */
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
slug: string
version: string | null
token: string | null
}
/** Last DELETE: useful for verifying remote hard-delete actually hit the server. */
export interface CapturedDelete {
namespace: string
slug: string
token: string | null
}
// ---------------------------------------------------------------------------
// Options
// ---------------------------------------------------------------------------
interface FakeRegistryOptions {
token?: string
user?: { handle: string; displayName: string; email?: string }
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
* 'network').
*/
failures?: {
whoami?: FailureMode
search?: FailureMode
resolve?: FailureMode
download?: FailureMode
deleteRemote?: FailureMode
publish?: FailureMode
validate?: FailureMode
}
}
// ---------------------------------------------------------------------------
// startFakeRegistry
// ---------------------------------------------------------------------------
/**
* Allocate a port that terminates every accepted connection. We keep the
* listener alive so the OS cannot reassign the port to another process
* between bind and fetch otherwise the client might connect to someone
* else instead of failing. Any fetch reaches open() and gets terminated,
* which surfaces as a socket error and maps to
* CliError('registry unreachable', EXIT.network) in the SkillHub client.
*/
async function startNetworkFailureServer(): Promise<{ url: string; stop: () => void }> {
const listener = Bun.listen<unknown>({
hostname: "127.0.0.1",
port: 0,
socket: {
open(socket) { socket.terminate() },
data() {},
error() {},
close() {},
}
})
const port = listener.port
return {
url: `http://127.0.0.1:${port}`,
stop: () => { try { listener.stop(true) } catch { /* already stopped */ } }
}
}
export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
// Mutable state captured from incoming requests — readable by tests.
const state: {
publish: CapturedPublish | null
resolve: CapturedResolve | null
delete: CapturedDelete | 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
// so that fetch() itself rejects (ECONNRESET/ECONNREFUSED).
const hasNetworkFailure = options.failures &&
Object.values(options.failures).some(m => m === 'network')
if (hasNetworkFailure) {
const { url, stop } = await startNetworkFailureServer()
return {
url,
stop,
received: state
}
}
// Helper: check bearer token. Returns a 401 Response if auth fails, null if ok.
function checkAuth(req: Request): Response | null {
if (options.token) {
const auth = req.headers.get('Authorization')
if (auth !== `Bearer ${options.token}`) {
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
}
}
return null
}
// Helper: look up a skill by namespace + slug.
function findSkill(namespace: string, slug: string): FakeSkill | undefined {
return options.skills?.find(s => s.namespace === namespace && s.slug === slug)
}
// Helper: build the download URL that resolve returns.
function buildDownloadUrl(baseUrl: string, namespace: string, slug: string, version: string): string {
return `${baseUrl}/api/cli/v1/skills/${namespace}/${slug}/versions/${version}/download`
}
const server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url)
const path = url.pathname
const baseUrl = `${url.protocol}//${url.host}`
// ------------------------------------------------------------------ //
// GET /api/cli/v1/auth/whoami
// ------------------------------------------------------------------ //
if (path === '/api/cli/v1/auth/whoami') {
if (options.failures?.whoami) return failureResponse(options.failures.whoami)
const authErr = checkAuth(req)
if (authErr) return authErr
return Response.json({
code: 0,
data: options.user ?? { handle: 'test-user', displayName: 'Test User', email: 'test@example.com' }
})
}
// ------------------------------------------------------------------ //
// GET /api/cli/v1/skills/search
// ------------------------------------------------------------------ //
if (path === '/api/cli/v1/skills/search') {
if (options.failures?.search) return failureResponse(options.failures.search)
return Response.json({
code: 0,
data: {
items: options.searchItems ?? [],
total: options.searchItems?.length ?? 0,
limit: 20
}
})
}
// ------------------------------------------------------------------ //
// Route: /api/cli/v1/skills/:namespace/:slug/...
// ------------------------------------------------------------------ //
// Resolve: GET /api/cli/v1/skills/:namespace/:slug/resolve
const resolveMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/)
if (resolveMatch && req.method === 'GET') {
if (options.failures?.resolve) return failureResponse(options.failures.resolve)
const namespace = resolveMatch[1]!
const slug = resolveMatch[2]!
state.resolve = {
namespace,
slug,
version: url.searchParams.get('version'),
token: req.headers.get('authorization') ?? null
}
const skill = findSkill(namespace, slug)
if (!skill) {
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
const version = skill.version ?? '1.0.0'
return Response.json({
code: 0,
data: {
namespace,
slug,
version,
versionId: skill.versionId ?? 1,
fingerprint: skill.fingerprint ?? 'deadbeef',
downloadUrl: buildDownloadUrl(baseUrl, namespace, slug, version)
}
})
}
// Download (latest): GET /api/cli/v1/skills/:namespace/:slug/download
const downloadLatestMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/download$/)
if (downloadLatestMatch && req.method === 'GET') {
if (options.failures?.download) return failureResponse(options.failures.download)
const namespace = downloadLatestMatch[1]!
const slug = downloadLatestMatch[2]!
const skill = findSkill(namespace, slug)
if (!skill) {
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
const bytes = skill.zipBytes ?? MINIMAL_ZIP
return new Response(bytes as BodyInit, {
status: 200,
headers: { 'Content-Type': 'application/zip' }
})
}
// Download (versioned): GET /api/cli/v1/skills/:namespace/:slug/versions/:version/download
const downloadVersionedMatch = path.match(
/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/versions\/([^/]+)\/download$/
)
if (downloadVersionedMatch && req.method === 'GET') {
if (options.failures?.download) return failureResponse(options.failures.download)
const namespace = downloadVersionedMatch[1]!
const slug = downloadVersionedMatch[2]!
const skill = findSkill(namespace, slug)
if (!skill) {
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
const bytes = skill.zipBytes ?? MINIMAL_ZIP
return new Response(bytes as BodyInit, {
status: 200,
headers: { 'Content-Type': 'application/zip' }
})
}
// Delete: DELETE /api/cli/v1/skills/:namespace/:slug
const deleteMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)$/)
if (deleteMatch && req.method === 'DELETE') {
if (options.failures?.deleteRemote) return failureResponse(options.failures.deleteRemote)
const authErr = checkAuth(req)
if (authErr) return authErr
const namespace = deleteMatch[1]!
const slug = deleteMatch[2]!
state.delete = {
namespace,
slug,
token: req.headers.get('authorization') ?? null
}
const skill = findSkill(namespace, slug)
if (!skill) {
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
return Response.json({
code: 0,
data: {
ok: true,
scope: namespace,
action: 'deleted',
namespace,
slug
}
})
}
// 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') {
if (options.failures?.publish) return failureResponse(options.failures.publish)
const authErr = checkAuth(req)
if (authErr) return authErr
const namespace = publishMatch[1]!
// Parse multipart form data asynchronously — return a Promise<Response>.
return req.formData().then(form => {
const fileField = form.get('file')
const visibility = (form.get('visibility') as string | null) ?? 'PUBLIC'
// Capture file name from the File object if available, else fallback.
let fileName = 'skill.zip'
if (fileField instanceof File) {
fileName = fileField.name || fileName
}
// Record for test assertions.
state.publish = { namespace, fileName, visibility }
return Response.json({
code: 0,
data: {
namespace,
slug: fileName.replace(/\.zip$/, ''),
version: '1.0.0',
visibility
}
})
})
}
// ------------------------------------------------------------------ //
// Fallthrough
// ------------------------------------------------------------------ //
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
}
})
return {
url: `http://localhost:${server.port}`,
stop: () => server.stop(),
/**
* Inspect state captured from incoming requests.
*
* `received.publish` holds the last publish request's parsed fields:
* { namespace, fileName, visibility }
* It is null until a publish request has been received.
*/
received: state
}
}

View file

@ -0,0 +1,54 @@
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
/**
* Spawn the CLI with a clean environment so host-shell exports like
* SKILLHUB_REGISTRY or SKILLHUB_TOKEN don't leak into the test process and
* silently override stored credentials/config. Tests can still inject any
* SKILLHUB_* variable explicitly via the `env` argument.
*/
function sanitizeProcessEnv(): Record<string, string> {
const cleaned: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (typeof value !== 'string') continue
if (key.startsWith('SKILLHUB_')) continue
cleaned[key] = value
}
return cleaned
}
export interface RunCliOptions {
/**
* Working directory for the child process. Defaults to the CLI package root
* so `bun src/index.ts` resolves. Pass a temp dir when the command scans
* cwd (e.g. `doctor`) so tests don't leak fixtures into the repo tree.
*/
cwd?: string
}
export async function runCli(
args: string[],
env: Record<string, string> = {},
options: RunCliOptions = {}
) {
// Use Bun.which() to find bun in PATH, but verify it exists
const whichBun = await Bun.which('bun')
const bunPath = (whichBun && existsSync(whichBun)) ? whichBun : process.execPath
const cliRoot = fileURLToPath(new URL('../../', import.meta.url))
const entry = `${cliRoot}src/index.ts`
const proc = Bun.spawn({
cmd: [bunPath, entry, ...args],
cwd: options.cwd ?? cliRoot,
env: { ...sanitizeProcessEnv(), ...env },
stdout: 'pipe',
stderr: 'pipe'
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited
])
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
}

View file

@ -0,0 +1,9 @@
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
export async function createTempHome() {
const home = await mkdtemp(join(tmpdir(), 'skillhub-test-home-'))
const cwd = await mkdtemp(join(tmpdir(), 'skillhub-test-cwd-'))
return { home, cwd }
}

View file

@ -0,0 +1,192 @@
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: { url: string; stop: () => void } | undefined
afterEach(() => {
registry?.stop()
registry = undefined
})
describe('auth commands', () => {
// -------------------------------------------------------------------------
// login
// -------------------------------------------------------------------------
test('login stores registry and token only after whoami succeeds', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', user: { handle: 'u1', displayName: 'User One' } })
const result = await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Logged in')
expect(await Bun.file(`${env.home}/.skillhub/config.json`).json()).toMatchObject({ registry: registry.url })
expect(await Bun.file(`${env.home}/.skillhub/credentials.json`).json()).toMatchObject({ tokens: { [registry.url]: 'sk_ok' } })
})
test('login fails with invalid token', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
const result = await runCli(['login', '--registry', registry.url, '--token', 'sk_bad'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('authentication failed')
})
// [P0] missing token → EXIT.usage, stderr contains "token is required"
test('login without --token exits with usage error', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
const result = await runCli(['login', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(5) // EXIT.usage
expect(result.stderr).toContain('token is required')
})
// [P0] whoami failure must NOT write credentials
test('login does not write credentials when whoami fails', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', failures: { whoami: 'auth' } })
const result = await runCli(['login', '--registry', registry.url, '--token', 'sk_bad'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).not.toBe(0)
const credFile = Bun.file(`${env.home}/.skillhub/credentials.json`)
const exists = await credFile.exists()
if (exists) {
const creds = await credFile.json() as { tokens?: Record<string, string> }
expect(creds.tokens?.[registry.url]).toBeUndefined()
}
// file not existing is also acceptable — either way no token was stored
})
// [P1] --json output shape on success
test('login --json emits { ok, registry, handle }', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', user: { handle: 'u1', displayName: 'User One' } })
const result = await runCli(['login', '--registry', registry.url, '--token', 'sk_ok', '--json'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout)
expect(parsed).toEqual({ ok: true, registry: registry.url, handle: 'u1' })
})
// [P1] network error → EXIT.network. The exit code is the contract; the
// exact message can be "registry unreachable" or "registry returned 5xx"
// depending on whether Bun's fetch throws or returns a 5xx Response on
// connection refusal — both indicate the same network-class failure.
test('login with network failure exits with network error', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', failures: { whoami: 'network' } })
const result = await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(3) // EXIT.network
expect(result.stderr).toMatch(/registry unreachable|registry returned 5\d\d/)
})
// -------------------------------------------------------------------------
// logout
// -------------------------------------------------------------------------
test('logout removes token', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', user: { handle: 'u1', displayName: 'User One' } })
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], {
HOME: env.home,
USERPROFILE: env.home
})
const result = await runCli(['logout', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Logged out')
})
// [P0] credentials entry is actually deleted after logout
test('logout actually removes the token from credentials.json', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', user: { handle: 'u1', displayName: 'User One' } })
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], {
HOME: env.home,
USERPROFILE: env.home
})
// Confirm token is present before logout
const before = await Bun.file(`${env.home}/.skillhub/credentials.json`).json() as { tokens: Record<string, string> }
expect(before.tokens[registry.url]).toBe('sk_ok')
await runCli(['logout', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
// Token must be absent after logout
const after = await Bun.file(`${env.home}/.skillhub/credentials.json`).json() as { tokens: Record<string, string> }
expect(after.tokens[registry.url]).toBeUndefined()
})
// [P1] logout when no token exists should still succeed
test('logout when not logged in exits 0 with success message', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({})
const result = await runCli(['logout', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Logged out')
})
// [P1] --json output on logout
test('logout --json emits { ok, registry }', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok', user: { handle: 'u1', displayName: 'User One' } })
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], {
HOME: env.home,
USERPROFILE: env.home
})
const result = await runCli(['logout', '--registry', registry.url, '--json'], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout)
expect(parsed).toEqual({ ok: true, registry: registry.url })
})
})

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

@ -0,0 +1,463 @@
import { describe, expect, test } from 'bun:test'
import { mkdir, writeFile, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createTempHome } from '../helpers/temp-env'
import { runCli } from '../helpers/run-cli'
/**
* Seed a single skill metadata file under the given scan root.
* Doctor scans its cwd for `.<agent>/skills/<slug>/.skillhub/metadata.json`,
* so we seed fixtures inside `scanRoot` (a temp dir) and pass that same dir
* as the CLI's cwd no writes into the repo working tree.
*/
async function seedSkill(scanRoot: string, options: {
agentDir: string
slug: string
metadata: {
registry: string
namespace: string
slug: string
version: string
agent: string
installedAt: string
}
}): Promise<void> {
const metaDir = join(scanRoot, options.agentDir, 'skills', options.slug, '.skillhub')
await mkdir(metaDir, { recursive: true })
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify(options.metadata))
}
describe('doctor command', () => {
test('doctor --json empty home exits 0 and returns parseable JSON', async () => {
const { home, cwd } = await createTempHome()
const result = await runCli(['doctor', '--json'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout)
expect(json.ok).toBe(true)
expect(typeof json.inventoryPath).toBe('string')
expect(json.inventoryPath).toContain('.skillhub')
expect(json.inventoryPath).toContain('inventory.json')
expect(json.backupPath).toBeNull()
expect(json.itemsScanned).toBe(0)
expect(json.targetsScanned).toBe(0)
expect(json.itemsPreserved).toBe(0)
expect(json.targetsPreserved).toBe(0)
expect(Array.isArray(json.skipped)).toBe(true)
expect(Array.isArray(json.conflicts)).toBe(true)
})
test('doctor human output empty home exits 0 and shows Inventory line', async () => {
const { home, cwd } = await createTempHome()
const result = await runCli(['doctor'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Inventory:')
expect(result.stdout).toContain('.skillhub')
expect(result.stdout).toContain('inventory.json')
expect(result.stdout).toContain('Scanned: 0 items, 0 targets')
expect(result.stdout).not.toContain('Backup:')
})
test('doctor rebuilds inventory.json from seeded metadata files', async () => {
const { home, cwd } = await createTempHome()
await seedSkill(cwd, {
agentDir: '.codex',
slug: 'pdf-parser',
metadata: {
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'pdf-parser',
version: '1.2.0',
agent: 'codex',
installedAt: '2026-04-20T12:00:00Z'
}
})
const result = await runCli(['doctor'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const inventoryPath = join(home, '.skillhub', 'inventory.json')
const raw = await readFile(inventoryPath, 'utf-8')
const inventory = JSON.parse(raw) as { items: Array<{
namespace: string
slug: string
version: string
registry: string
}> }
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]).toMatchObject({
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'pdf-parser',
version: '1.2.0'
})
})
test('doctor --json reflects rebuilt inventory items in output', async () => {
const { home, cwd } = await createTempHome()
await seedSkill(cwd, {
agentDir: '.claude',
slug: 'image-resizer',
metadata: {
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'image-resizer',
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)
expect(json.ok).toBe(true)
expect(json.itemsScanned).toBe(1)
expect(json.targetsScanned).toBe(1)
expect(json.backupPath).toBeNull()
expect(Array.isArray(json.skipped)).toBe(true)
expect(json.conflicts).toHaveLength(0)
expect(typeof json.inventoryPath).toBe('string')
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()
const skillhubDir = join(home, '.skillhub')
await mkdir(skillhubDir, { recursive: true })
const inventoryPath = join(skillhubDir, 'inventory.json')
const originalContent = JSON.stringify({ items: [{ registry: 'old', namespace: 'x', slug: 'y', version: '0.0.1', targets: [] }] })
await writeFile(inventoryPath, originalContent)
const result = await runCli(['doctor'], {
HOME: home,
USERPROFILE: home
}, { cwd })
expect(result.exitCode).toBe(0)
const backupPath = `${inventoryPath}.bak`
const backupContent = await readFile(backupPath, 'utf-8')
expect(backupContent).toBe(originalContent)
expect(result.stdout).toContain('Backup:')
expect(result.stdout).toContain('inventory.json.bak')
})
test('doctor merges with existing inventory and preserves out-of-cwd entries', async () => {
const { home, cwd } = await createTempHome()
const skillhubDir = join(home, '.skillhub')
await mkdir(skillhubDir, { recursive: true })
const inventoryPath = join(skillhubDir, 'inventory.json')
await writeFile(inventoryPath, JSON.stringify({
items: [
{
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'external-skill',
version: '1.0.0',
targets: [
{
agent: 'claude-code',
rootDir: '/external/project/.claude',
installDir: '/external/project/.claude/skills/external-skill',
installedAt: '2026-04-01T00:00:00Z'
}
]
}
]
}))
await seedSkill(cwd, {
agentDir: '.claude',
slug: 'local-skill',
metadata: {
registry: 'https://skill.xfyun.cn',
namespace: 'global',
slug: 'local-skill',
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)
expect(json.itemsScanned).toBe(1)
expect(json.itemsPreserved).toBe(1)
expect(json.targetsPreserved).toBe(1)
const raw = await readFile(inventoryPath, 'utf-8')
const inventory = JSON.parse(raw) as {
items: Array<{ slug: string }>
}
expect(inventory.items.map(item => item.slug)).toEqual(
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

@ -0,0 +1,84 @@
import { describe, expect, test } from 'bun:test'
import { runCli } from '../helpers/run-cli'
describe('cli error output', () => {
test('prints gh-style help for unknown commands', async () => {
const result = await runCli(['foo'])
expect(result.exitCode).toBe(5)
expect(result.stderr).toContain('unknown command "foo" for "skillhub"')
expect(result.stderr).toContain('Usage: skillhub <command> [flags]')
expect(result.stderr).toContain('Available commands:')
expect(result.stderr).toContain('help Show available commands')
expect(result.stderr).toContain('publish Publish a local skill package')
})
test('prefers unknown command output when the command is followed by a bad flag', async () => {
const result = await runCli(['foo', '--bad'])
expect(result.exitCode).toBe(5)
expect(result.stderr).toContain('unknown command "foo" for "skillhub"')
expect(result.stderr).not.toContain('unknown flag: --bad')
})
test('prints unknown command as json when --json is requested', async () => {
const result = await runCli(['foo', '--json'])
expect(result.exitCode).toBe(5)
expect(JSON.parse(result.stderr)).toEqual({
ok: false,
message: 'unknown command "foo" for "skillhub"',
exitCode: 5
})
})
test('suggests close matches for mistyped commands', async () => {
const result = await runCli(['serch'])
expect(result.exitCode).toBe(5)
expect(result.stderr).toContain('unknown command "serch" for "skillhub"')
expect(result.stderr).toContain('Did you mean this?')
expect(result.stderr).toContain(' search')
})
test('prints unknown flag message and command directory', async () => {
const result = await runCli(['version', '--badflag'])
expect(result.exitCode).toBe(5)
expect(result.stderr).toContain('unknown flag: --badflag')
expect(result.stderr).toContain('Usage: skillhub <command> [flags]')
expect(result.stderr).toContain('Available commands:')
expect(result.stderr).toContain('version Show installed CLI version')
})
test('prints unknown flag as json when --json is requested', async () => {
const result = await runCli(['version', '--badflag', '--json'])
expect(result.exitCode).toBe(5)
expect(JSON.parse(result.stderr)).toEqual({
ok: false,
message: 'unknown flag: --badflag',
exitCode: 5
})
})
test('prints missing argument usage for install', async () => {
const result = await runCli(['install'])
expect(result.exitCode).toBe(5)
expect(result.stderr).toContain('Error: missing required argument')
expect(result.stderr).toContain('Usage: skillhub install <slug>')
expect(result.stderr).toContain('Run "skillhub help install" for more information.')
})
test('prints parse errors as json when --json is requested', async () => {
const result = await runCli(['install', '--json'])
expect(result.exitCode).toBe(5)
expect(JSON.parse(result.stderr)).toEqual({
ok: false,
message: 'missing required argument',
exitCode: 5
})
})
})

View file

@ -0,0 +1,66 @@
import { describe, expect, test } from 'bun:test'
import { runCli } from '../helpers/run-cli'
describe('help command', () => {
test('prints detailed help for install', async () => {
const result = await runCli(['help', 'install'])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Usage: skillhub install <slug>')
expect(result.stdout).toContain('--agent <profile>')
})
test('prints search help with optional query', async () => {
const result = await runCli(['help', 'search'])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Usage: skillhub search [query]')
expect(result.stdout).toContain('skillhub search')
})
// P1: bare `skillhub help` (no topic) prints the directory of all commands
test('bare help lists all commands in human format', async () => {
const result = await runCli(['help'])
expect(result.exitCode).toBe(0)
// Sample at least 6 of the 12 known commands appear in the output
for (const name of ['login', 'logout', 'search', 'install', 'list', 'publish']) {
expect(result.stdout).toContain(name)
}
})
// P1: `skillhub help --json` is wired in cac but the --json flag is consumed
// by the action wrapper and never reaches helpCommand's args. Today this
// makes the JSON branch unreachable from the CLI surface (helpCommand always
// sees [] or [topic] without --json). We document the current human-only
// behavior here so a future source fix that re-routes --json into
// helpCommand will fail this test loudly and we can convert it into a
// positive JSON assertion at that time.
// TODO source bug: cli/src/index.ts:178 should forward --json into helpCommand args.
test('help --json currently returns human directory (documents source bug)', async () => {
const result = await runCli(['help', '--json'])
expect(result.exitCode).toBe(0)
// Output is NOT valid JSON today.
let isJson = true
try { JSON.parse(result.stdout) } catch { isJson = false }
expect(isJson).toBe(false)
// Sanity: human output still mentions some commands
expect(result.stdout).toContain('install')
})
test('help <topic> --json currently returns human topic detail (documents source bug)', async () => {
const result = await runCli(['help', 'install', '--json'])
expect(result.exitCode).toBe(0)
let isJson = true
try { JSON.parse(result.stdout) } catch { isJson = false }
expect(isJson).toBe(false)
expect(result.stdout).toContain('Usage: skillhub install')
})
// P1: `skillhub help <unknown>` currently crashes inside helpCommand because
// `commands[topic]` is undefined and `detail.usage` dereferences undefined.
// We assert non-zero exit so that a future fix to graceful handling does not
// regress silently. TODO source bug: cli/src/commands/help.ts:75 should
// surface a friendlier "unknown command" message instead of crashing.
test('help <unknown-topic> exits non-zero (documents current crashy behavior)', async () => {
const result = await runCli(['help', 'definitely-not-a-command'])
expect(result.exitCode).not.toBe(0)
})
})

View file

@ -0,0 +1,811 @@
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 { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build a minimal valid zip containing a SKILL.md file. */
function makeSkillZip(extra: Record<string, string> = {}): Uint8Array {
const entries: Record<string, Uint8Array> = {
'SKILL.md': strToU8('# test skill'),
...Object.fromEntries(Object.entries(extra).map(([k, v]) => [k, strToU8(v)]))
}
return zipSync(entries)
}
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop()
registry = undefined
})
// ---------------------------------------------------------------------------
// P0 — Happy-path install: metadata.json + inventory.json
// ---------------------------------------------------------------------------
describe('install command — P0', () => {
test('happy-path: exit 0, writes metadata.json and inventory.json', 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',
versionId: 1,
fingerprint: 'abc123',
zipBytes: makeSkillZip()
}
]
})
// Login first so credentials are stored
const loginResult = await runCli(
['login', '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(loginResult.exitCode).toBe(0)
// The claude-code profile installs into <cwd>/.claude/skills
// We use --dir to pin the install directory to a known temp path so we
// can assert on it without depending on agent detection.
const installDir = join(env.cwd, 'skills')
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)
// --- metadata.json ---
const metaPath = join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta).toMatchObject({
registry: registry.url,
namespace: 'global',
slug: 'pdf-parser',
version: '1.0.0'
})
expect(typeof meta.installedAt).toBe('string')
// --- inventory.json ---
const inventoryPath = join(env.home, '.skillhub', 'inventory.json')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8'))
expect(inventory.items).toBeArray()
const item = inventory.items.find(
(i: { namespace: string; slug: string }) => i.namespace === 'global' && i.slug === 'pdf-parser'
)
expect(item).toBeDefined()
expect(item.targets.length).toBeGreaterThan(0)
const target = item.targets.find(
(t: { installDir: string }) => t.installDir === join(installDir, 'pdf-parser')
)
expect(target).toBeDefined()
})
// -------------------------------------------------------------------------
// P0 — --json output shape
// -------------------------------------------------------------------------
test('--json output matches { ok, namespace, slug, installed }', 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-json')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--json'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const parsed = JSON.parse(result.stdout)
expect(parsed).toMatchObject({
ok: true,
namespace: 'global',
slug: 'pdf-parser'
})
expect(Array.isArray(parsed.installed)).toBe(true)
expect(parsed.installed.length).toBeGreaterThan(0)
expect(parsed.installed[0]).toHaveProperty('agent')
expect(parsed.installed[0]).toHaveProperty('dir')
})
})
// ---------------------------------------------------------------------------
// P1 — --version forwarding
// ---------------------------------------------------------------------------
describe('install command — P1', () => {
test('--version forwards to resolve and installs the requested version', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [
{
namespace: 'global',
slug: 'pdf-parser',
// fake-registry returns this as the resolved version regardless of
// the ?version= query param; we just verify the metadata records it.
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-ver')
await mkdir(installDir, { recursive: true })
const result = await runCli(
[
'install', 'pdf-parser',
'--version', '1.0.0',
'--dir', installDir,
'--registry', registry.url,
'--token', 'sk_ok'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
expect(registry.received.resolve?.version).toBe('1.0.0')
const metaPath = join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.version).toBe('1.0.0')
})
// -------------------------------------------------------------------------
// P1 — 401 on resolve → EXIT.auth (exit code 2)
// -------------------------------------------------------------------------
test('401 on resolve returns auth exit code and stderr message', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
failures: { resolve: 'auth' }
})
const installDir = join(env.cwd, 'skills-auth')
await mkdir(installDir, { recursive: true })
const result = await runCli(
[
'install', 'pdf-parser',
'--dir', installDir,
'--registry', registry.url,
'--token', 'sk_bad'
],
{ HOME: env.home, USERPROFILE: env.home }
)
// EXIT.auth = 2
expect(result.exitCode).toBe(2)
expect(result.stderr.toLowerCase()).toMatch(/auth|unauthorized|401/)
})
// -------------------------------------------------------------------------
// P1 — --namespace override
// -------------------------------------------------------------------------
test('--namespace override installs under the specified namespace', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [
{
namespace: 'myteam',
slug: 'mything',
version: '2.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-ns')
await mkdir(installDir, { recursive: true })
const result = await runCli(
[
'install', 'mything',
'--namespace', 'myteam',
'--dir', installDir,
'--registry', registry.url,
'--token', 'sk_ok'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const metaPath = join(installDir, 'mything', '.skillhub', 'metadata.json')
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.namespace).toBe('myteam')
expect(meta.slug).toBe('mything')
expect(meta.version).toBe('2.0.0')
})
// -------------------------------------------------------------------------
// NOTE: multi-target interactive selection (TTY branch) is not tested here
// because Bun.spawn does not support PTY allocation. The interactive path
// in resolveInstallTargets() is covered by the unit tests in
// 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()
}
})
})

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

@ -0,0 +1,462 @@
import { describe, expect, test } from 'bun:test'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createTempHome } from '../helpers/temp-env'
import { runCli } from '../helpers/run-cli'
const FAKE_REGISTRY_A = 'http://registry-a.test'
const FAKE_REGISTRY_B = 'http://registry-b.test'
const INSTALLED_AT = '2024-01-15T10:00:00.000Z'
/** Write inventory.json into the temp home's .skillhub dir. */
async function seedInventory(home: string, items: object[]) {
const skillhubDir = join(home, '.skillhub')
await mkdir(skillhubDir, { recursive: true })
await writeFile(
join(skillhubDir, 'inventory.json'),
JSON.stringify({ items }, null, 2)
)
}
describe('list command', () => {
// ---------------------------------------------------------------------------
// P0-1: Happy path — human-readable output
// ---------------------------------------------------------------------------
test('human output shows namespace/slug/version, agent, installDir, status ok', async () => {
const { home } = await createTempHome()
const installDir = join(home, 'skills', 'my-agent', 'global', 'pdf-parser')
await mkdir(installDir, { recursive: true })
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A,
namespace: 'global',
slug: 'pdf-parser',
version: '1.2.0',
targets: [
{
agent: 'claude-code',
rootDir: join(home, 'skills', 'my-agent'),
installDir,
installedAt: INSTALLED_AT
}
]
}
])
const result = await runCli(['list', '--registry', FAKE_REGISTRY_A], {
HOME: home,
USERPROFILE: home
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('global/pdf-parser@1.2.0')
expect(result.stdout).toContain('claude-code')
expect(result.stdout).toContain(installDir)
expect(result.stdout).toContain('ok')
})
// ---------------------------------------------------------------------------
// P0-2: --json output
// ---------------------------------------------------------------------------
test('--json output parses correctly with status ok', async () => {
const { home } = await createTempHome()
const installDir = join(home, 'skills', 'my-agent', 'global', 'pdf-parser')
await mkdir(installDir, { recursive: true })
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A,
namespace: 'global',
slug: 'pdf-parser',
version: '1.2.0',
targets: [
{
agent: 'claude-code',
rootDir: join(home, 'skills', 'my-agent'),
installDir,
installedAt: INSTALLED_AT
}
]
}
])
const result = await runCli(['list', '--registry', FAKE_REGISTRY_A, '--json'], {
HOME: home,
USERPROFILE: home
})
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout)
expect(json.ok).toBe(true)
expect(json.items).toHaveLength(1)
expect(json.items[0]).toMatchObject({
namespace: 'global',
slug: 'pdf-parser',
version: '1.2.0',
agent: 'claude-code',
installDir,
installedAt: INSTALLED_AT,
status: 'ok'
})
})
// ---------------------------------------------------------------------------
// P0-3: Empty inventory — no file present
// ---------------------------------------------------------------------------
test('empty inventory prints "No skills installed."', async () => {
const { home } = await createTempHome()
const result = await runCli(['list', '--registry', FAKE_REGISTRY_A], {
HOME: home,
USERPROFILE: home
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe('No skills installed.')
})
// ---------------------------------------------------------------------------
// P0-4: Empty inventory with --json
// ---------------------------------------------------------------------------
test('empty inventory with --json returns { ok: true, items: [] }', async () => {
const { home } = await createTempHome()
const result = await runCli(['list', '--registry', FAKE_REGISTRY_A, '--json'], {
HOME: home,
USERPROFILE: home
})
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout)
expect(json).toEqual({ ok: true, items: [] })
})
// ---------------------------------------------------------------------------
// P0-5: Registry filter — only shows items for the specified registry
// ---------------------------------------------------------------------------
test('registry filter excludes entries from other registries', async () => {
const { home } = await createTempHome()
const installDirA = join(home, 'skills', 'agent-a', 'global', 'skill-a')
const installDirB = join(home, 'skills', 'agent-b', 'global', 'skill-b')
await mkdir(installDirA, { recursive: true })
await mkdir(installDirB, { recursive: true })
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A,
namespace: 'global',
slug: 'skill-a',
version: '1.0.0',
targets: [
{ agent: 'claude-code', rootDir: join(home, 'skills', 'agent-a'), installDir: installDirA, installedAt: INSTALLED_AT }
]
},
{
registry: FAKE_REGISTRY_B,
namespace: 'global',
slug: 'skill-b',
version: '2.0.0',
targets: [
{ agent: 'cursor', rootDir: join(home, 'skills', 'agent-b'), installDir: installDirB, installedAt: INSTALLED_AT }
]
}
])
const result = await runCli(['list', '--registry', FAKE_REGISTRY_A], {
HOME: home,
USERPROFILE: home
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('global/skill-a@1.0.0')
expect(result.stdout).not.toContain('global/skill-b')
})
// ---------------------------------------------------------------------------
// P1-6: --agent filter
// ---------------------------------------------------------------------------
test('--agent filter shows only the matching agent target', async () => {
const { home } = await createTempHome()
const installDirClaude = join(home, 'skills', 'claude', 'global', 'my-skill')
const installDirCursor = join(home, 'skills', 'cursor', 'global', 'my-skill')
await mkdir(installDirClaude, { recursive: true })
await mkdir(installDirCursor, { recursive: true })
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A,
namespace: 'global',
slug: 'my-skill',
version: '1.0.0',
targets: [
{ agent: 'claude-code', rootDir: join(home, 'skills', 'claude'), installDir: installDirClaude, installedAt: INSTALLED_AT },
{ agent: 'cursor', rootDir: join(home, 'skills', 'cursor'), installDir: installDirCursor, installedAt: INSTALLED_AT }
]
}
])
const result = await runCli(
['list', '--registry', FAKE_REGISTRY_A, '--agent', 'claude-code'],
{ HOME: home, USERPROFILE: home }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('claude-code')
expect(result.stdout).toContain(installDirClaude)
expect(result.stdout).not.toContain('cursor')
expect(result.stdout).not.toContain(installDirCursor)
})
// ---------------------------------------------------------------------------
// P1-7: --agent repeatable — both agents shown
// ---------------------------------------------------------------------------
test('--agent repeatable shows all specified agents', async () => {
const { home } = await createTempHome()
const installDirClaude = join(home, 'skills', 'claude', 'global', 'my-skill')
const installDirCursor = join(home, 'skills', 'cursor', 'global', 'my-skill')
const installDirOther = join(home, 'skills', 'other', 'global', 'my-skill')
await mkdir(installDirClaude, { recursive: true })
await mkdir(installDirCursor, { recursive: true })
await mkdir(installDirOther, { recursive: true })
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A,
namespace: 'global',
slug: 'my-skill',
version: '1.0.0',
targets: [
{ agent: 'claude-code', rootDir: join(home, 'skills', 'claude'), installDir: installDirClaude, installedAt: INSTALLED_AT },
{ agent: 'cursor', rootDir: join(home, 'skills', 'cursor'), installDir: installDirCursor, installedAt: INSTALLED_AT },
{ agent: 'other-agent', rootDir: join(home, 'skills', 'other'), installDir: installDirOther, installedAt: INSTALLED_AT }
]
}
])
const result = await runCli(
['list', '--registry', FAKE_REGISTRY_A, '--agent', 'claude-code', '--agent', 'cursor'],
{ HOME: home, USERPROFILE: home }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('claude-code')
expect(result.stdout).toContain('cursor')
expect(result.stdout).not.toContain('other-agent')
})
// ---------------------------------------------------------------------------
// P1-8: --dir prefix filter
// ---------------------------------------------------------------------------
test('--dir prefix filter shows only targets under the prefix', async () => {
const { home } = await createTempHome()
const prefixA = join(home, 'skills', 'prefix-a')
const prefixB = join(home, 'skills', 'prefix-b')
const installDirA = join(prefixA, 'global', 'skill-x')
const installDirB = join(prefixB, 'global', 'skill-x')
await mkdir(installDirA, { recursive: true })
await mkdir(installDirB, { recursive: true })
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A,
namespace: 'global',
slug: 'skill-x',
version: '1.0.0',
targets: [
{ agent: 'agent-a', rootDir: prefixA, installDir: installDirA, installedAt: INSTALLED_AT },
{ agent: 'agent-b', rootDir: prefixB, installDir: installDirB, installedAt: INSTALLED_AT }
]
}
])
const result = await runCli(
['list', '--registry', FAKE_REGISTRY_A, '--dir', prefixA],
{ HOME: home, USERPROFILE: home }
)
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain(installDirA)
expect(result.stdout).not.toContain(installDirB)
})
// ---------------------------------------------------------------------------
// P0-9: status: missing when installDir does not exist
// ---------------------------------------------------------------------------
test('status is "missing" when installDir does not exist on disk', async () => {
const { home } = await createTempHome()
// Intentionally do NOT create this directory
const installDir = join(home, 'skills', 'nonexistent', 'global', 'ghost-skill')
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A,
namespace: 'global',
slug: 'ghost-skill',
version: '1.0.0',
targets: [
{
agent: 'claude-code',
rootDir: join(home, 'skills', 'nonexistent'),
installDir,
installedAt: INSTALLED_AT
}
]
}
])
const result = await runCli(['list', '--registry', FAKE_REGISTRY_A], {
HOME: home,
USERPROFILE: home
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('global/ghost-skill@1.0.0')
expect(result.stdout).toContain('missing')
expect(result.stdout).not.toContain('ok')
})
// ---------------------------------------------------------------------------
// P0-9b: status: missing with --json
// ---------------------------------------------------------------------------
test('--json status is "missing" when installDir does not exist', async () => {
const { home } = await createTempHome()
const installDir = join(home, 'skills', 'nonexistent', 'global', 'ghost-skill')
await seedInventory(home, [
{
registry: FAKE_REGISTRY_A,
namespace: 'global',
slug: 'ghost-skill',
version: '1.0.0',
targets: [
{
agent: 'claude-code',
rootDir: join(home, 'skills', 'nonexistent'),
installDir,
installedAt: INSTALLED_AT
}
]
}
])
const result = await runCli(['list', '--registry', FAKE_REGISTRY_A, '--json'], {
HOME: home,
USERPROFILE: home
})
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout)
expect(json.ok).toBe(true)
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')
})
})

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