mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-22 00:31:21 +00:00
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:
commit
307c6669fc
333 changed files with 25525 additions and 361 deletions
140
.agents/skills/api-and-namespace-design/SKILL.md
Normal file
140
.agents/skills/api-and-namespace-design/SKILL.md
Normal 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
|
||||
108
.agents/skills/backend-module-structure/SKILL.md
Normal file
108
.agents/skills/backend-module-structure/SKILL.md
Normal 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.
|
||||
135
.agents/skills/code-conventions/SKILL.md
Normal file
135
.agents/skills/code-conventions/SKILL.md
Normal 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
|
||||
194
.agents/skills/dev-workflow/SKILL.md
Normal file
194
.agents/skills/dev-workflow/SKILL.md
Normal 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 |
|
||||
124
.agents/skills/frontend-conventions/SKILL.md
Normal file
124
.agents/skills/frontend-conventions/SKILL.md
Normal 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
|
||||
93
.agents/skills/pr-submission/SKILL.md
Normal file
93
.agents/skills/pr-submission/SKILL.md
Normal 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
|
||||
151
.agents/skills/skill-lifecycle/SKILL.md
Normal file
151
.agents/skills/skill-lifecycle/SKILL.md
Normal 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
|
||||
117
.agents/skills/testing-and-ci/SKILL.md
Normal file
117
.agents/skills/testing-and-ci/SKILL.md
Normal 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
|
||||
|
|
@ -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
42
.github/release-template.md
vendored
Normal 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}}
|
||||
|
||||
41
.github/scripts/github.ts
vendored
41
.github/scripts/github.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
80
.github/scripts/issue-llm-provider.ts
vendored
80
.github/scripts/issue-llm-provider.ts
vendored
|
|
@ -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
53
.github/scripts/release-notes-config.ts
vendored
Normal 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;
|
||||
}
|
||||
271
.github/scripts/release-notes-generator.ts
vendored
Normal file
271
.github/scripts/release-notes-generator.ts
vendored
Normal 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
79
.github/scripts/release-notes.ts
vendored
Normal 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
33
.github/workflows/pr-cli.yml
vendored
Normal 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
305
.github/workflows/release-cli.yml
vendored
Normal 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
46
.github/workflows/release-notes.yml
vendored
Normal 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
6
.gitignore
vendored
|
|
@ -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
599
AGENTS.md
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
28
Makefile
28
Makefile
|
|
@ -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: ## 发布 CLI(patch 版本)- bump + tag + push,触发 CI 自动发布
|
||||
./scripts/publish-cli.sh patch
|
||||
|
||||
publish-cli-minor: ## 发布 CLI(minor 版本)- bump + tag + push,触发 CI 自动发布
|
||||
./scripts/publish-cli.sh minor
|
||||
|
||||
publish-cli-major: ## 发布 CLI(major 版本)- 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
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -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
|
||||
|
|
|
|||
24
README_zh.md
24
README_zh.md
|
|
@ -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
11
cli/.env.example
Normal 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
13
cli/.eslintrc.cjs
Normal 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
201
cli/LICENSE
Normal 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
395
cli/README.md
Normal 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
146
cli/RELEASE.md
Normal 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
302
cli/bun.lock
Normal 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
2
cli/bunfig.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[test]
|
||||
root = "./test"
|
||||
65
cli/package.json
Normal file
65
cli/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
29
cli/scripts/generate-pkg-info.ts
Normal file
29
cli/scripts/generate-pkg-info.ts
Normal 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}`)
|
||||
31
cli/src/agents/detector.ts
Normal file
31
cli/src/agents/detector.ts
Normal 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]))
|
||||
2
cli/src/agents/profiles/claude-code.ts
Normal file
2
cli/src/agents/profiles/claude-code.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const claudeCodeProfile = makeProfile('claude-code', 'Claude Code', '.claude/skills', '.claude/skills')
|
||||
2
cli/src/agents/profiles/codex.ts
Normal file
2
cli/src/agents/profiles/codex.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const codexProfile = makeProfile('codex', 'Codex', '.codex/skills', '.codex/skills')
|
||||
2
cli/src/agents/profiles/cursor.ts
Normal file
2
cli/src/agents/profiles/cursor.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const cursorProfile = makeProfile('cursor', 'Cursor', '.cursor/skills', '.cursor/skills')
|
||||
2
cli/src/agents/profiles/gemini-cli.ts
Normal file
2
cli/src/agents/profiles/gemini-cli.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const geminiCliProfile = makeProfile('gemini-cli', 'Gemini CLI', '.gemini/skills', '.gemini/skills')
|
||||
2
cli/src/agents/profiles/generic-fallback.ts
Normal file
2
cli/src/agents/profiles/generic-fallback.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const genericFallbackProfile = makeProfile('generic', 'Generic Fallback', '.agents/skills', '.agents/skills')
|
||||
2
cli/src/agents/profiles/github-copilot.ts
Normal file
2
cli/src/agents/profiles/github-copilot.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const githubCopilotProfile = makeProfile('github-copilot', 'GitHub Copilot', '.github-copilot/skills', '.github-copilot/skills')
|
||||
2
cli/src/agents/profiles/kilo.ts
Normal file
2
cli/src/agents/profiles/kilo.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const kiloProfile = makeProfile('kilo', 'Kilo', '.kilo/skills', '.kilo/skills')
|
||||
2
cli/src/agents/profiles/kiro-cli.ts
Normal file
2
cli/src/agents/profiles/kiro-cli.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const kiroCliProfile = makeProfile('kiro-cli', 'Kiro CLI', '.kiro/skills', '.kiro/skills')
|
||||
30
cli/src/agents/profiles/make-profile.ts
Normal file
30
cli/src/agents/profiles/make-profile.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
2
cli/src/agents/profiles/openclaw.ts
Normal file
2
cli/src/agents/profiles/openclaw.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const openclawProfile = makeProfile('openclaw', 'OpenClaw', '.openclaw/skills', '.openclaw/skills')
|
||||
2
cli/src/agents/profiles/opencode.ts
Normal file
2
cli/src/agents/profiles/opencode.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const opencodeProfile = makeProfile('opencode', 'OpenCode', '.opencode/skills', '.opencode/skills')
|
||||
2
cli/src/agents/profiles/openhands.ts
Normal file
2
cli/src/agents/profiles/openhands.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const openhandsProfile = makeProfile('openhands', 'OpenHands', '.openhands/skills', '.openhands/skills')
|
||||
2
cli/src/agents/profiles/roo.ts
Normal file
2
cli/src/agents/profiles/roo.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const rooProfile = makeProfile('roo', 'Roo', '.roo/skills', '.roo/skills')
|
||||
2
cli/src/agents/profiles/trae-cn.ts
Normal file
2
cli/src/agents/profiles/trae-cn.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const traeCnProfile = makeProfile('trae-cn', 'Trae CN', '.trae-cn/skills', '.trae-cn/skills')
|
||||
2
cli/src/agents/profiles/trae.ts
Normal file
2
cli/src/agents/profiles/trae.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const traeProfile = makeProfile('trae', 'Trae', '.trae/skills', '.trae/skills')
|
||||
2
cli/src/agents/profiles/windsurf.ts
Normal file
2
cli/src/agents/profiles/windsurf.ts
Normal 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
101
cli/src/agents/resolver.ts
Normal 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
14
cli/src/agents/types.ts
Normal 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'
|
||||
}
|
||||
89
cli/src/clients/npm-registry-client.ts
Normal file
89
cli/src/clients/npm-registry-client.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
191
cli/src/clients/skillhub-client.ts
Normal file
191
cli/src/clients/skillhub-client.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
33
cli/src/commands/doctor.ts
Normal file
33
cli/src/commands/doctor.ts
Normal 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
94
cli/src/commands/help.ts
Normal 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()
|
||||
}
|
||||
44
cli/src/commands/install.ts
Normal file
44
cli/src/commands/install.ts
Normal 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
57
cli/src/commands/list.ts
Normal 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
21
cli/src/commands/login.ts
Normal 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}`
|
||||
}
|
||||
19
cli/src/commands/logout.ts
Normal file
19
cli/src/commands/logout.ts
Normal 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
124
cli/src/commands/publish.ts
Normal 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, '_')
|
||||
}
|
||||
75
cli/src/commands/remove.ts
Normal file
75
cli/src/commands/remove.ts
Normal 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')
|
||||
}
|
||||
27
cli/src/commands/search.ts
Normal file
27
cli/src/commands/search.ts
Normal 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')
|
||||
}
|
||||
80
cli/src/commands/update.ts
Normal file
80
cli/src/commands/update.ts
Normal 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
|
||||
)
|
||||
}
|
||||
7
cli/src/commands/version.ts
Normal file
7
cli/src/commands/version.ts
Normal 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)
|
||||
}
|
||||
26
cli/src/commands/whoami.ts
Normal file
26
cli/src/commands/whoami.ts
Normal 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}`
|
||||
}
|
||||
3
cli/src/generated/pkg-info.ts
Normal file
3
cli/src/generated/pkg-info.ts
Normal 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
303
cli/src/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
73
cli/src/platform/archive.ts
Normal file
73
cli/src/platform/archive.ts
Normal 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
19
cli/src/platform/os.ts
Normal 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
|
||||
}
|
||||
49
cli/src/platform/package-manager.ts
Normal file
49
cli/src/platform/package-manager.ts
Normal 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
31
cli/src/platform/paths.ts
Normal 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)
|
||||
}
|
||||
45
cli/src/platform/updater.ts
Normal file
45
cli/src/platform/updater.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
cli/src/services/auth-service.ts
Normal file
26
cli/src/services/auth-service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
195
cli/src/services/doctor-service.ts
Normal file
195
cli/src/services/doctor-service.ts
Normal 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
|
||||
}
|
||||
74
cli/src/services/install-service.ts
Normal file
74
cli/src/services/install-service.ts
Normal 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 }
|
||||
}
|
||||
21
cli/src/services/registry-service.ts
Normal file
21
cli/src/services/registry-service.ts
Normal 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(/\/+$/, '')
|
||||
}
|
||||
76
cli/src/services/remove-service.ts
Normal file
76
cli/src/services/remove-service.ts
Normal 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 }
|
||||
}
|
||||
99
cli/src/services/update-service.ts
Normal file
99
cli/src/services/update-service.ts
Normal 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`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
cli/src/shared/constants.ts
Normal file
13
cli/src/shared/constants.ts
Normal 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
10
cli/src/shared/errors.ts
Normal 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
44
cli/src/shared/output.ts
Normal 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
10
cli/src/shared/types.ts
Normal 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
|
||||
}
|
||||
31
cli/src/stores/config-store.ts
Normal file
31
cli/src/stores/config-store.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
40
cli/src/stores/credentials-store.ts
Normal file
40
cli/src/stores/credentials-store.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
168
cli/src/stores/inventory-store.ts
Normal file
168
cli/src/stores/inventory-store.ts
Normal 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
|
||||
}
|
||||
}
|
||||
436
cli/test/helpers/fake-registry.ts
Normal file
436
cli/test/helpers/fake-registry.ts
Normal 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
|
||||
}
|
||||
}
|
||||
54
cli/test/helpers/run-cli.ts
Normal file
54
cli/test/helpers/run-cli.ts
Normal 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 }
|
||||
}
|
||||
9
cli/test/helpers/temp-env.ts
Normal file
9
cli/test/helpers/temp-env.ts
Normal 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 }
|
||||
}
|
||||
192
cli/test/integration/auth-commands.test.ts
Normal file
192
cli/test/integration/auth-commands.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
159
cli/test/integration/auth-resolution.test.ts
Normal file
159
cli/test/integration/auth-resolution.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
164
cli/test/integration/concurrency.test.ts
Normal file
164
cli/test/integration/concurrency.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
509
cli/test/integration/cross-command.test.ts
Normal file
509
cli/test/integration/cross-command.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
463
cli/test/integration/doctor-command.test.ts
Normal file
463
cli/test/integration/doctor-command.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
84
cli/test/integration/error-output.test.ts
Normal file
84
cli/test/integration/error-output.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
66
cli/test/integration/help-command.test.ts
Normal file
66
cli/test/integration/help-command.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
811
cli/test/integration/install-command.test.ts
Normal file
811
cli/test/integration/install-command.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
107
cli/test/integration/inventory-resilience.test.ts
Normal file
107
cli/test/integration/inventory-resilience.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
462
cli/test/integration/list-command.test.ts
Normal file
462
cli/test/integration/list-command.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
110
cli/test/integration/multi-registry.test.ts
Normal file
110
cli/test/integration/multi-registry.test.ts
Normal 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
Loading…
Add table
Reference in a new issue