diff --git a/CLAUDE.md b/CLAUDE.md index bd12afd..34d0b16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,6 @@ skillr/ │ │ ├── BundleController.php │ │ ├── InboundWebhookController.php │ │ ├── LibraryController.php -│ │ ├── MarketplaceController.php │ │ ├── ModelController.php │ │ ├── ProjectController.php │ │ ├── SearchController.php @@ -95,8 +94,8 @@ skillr/ │ └── web.php # Filament auto-registers here ├── ui/ # React + Vite + TypeScript SPA │ └── src/ -│ ├── pages/ # Projects, ProjectDetail, SkillEditor, Playground, Library, Marketplace, Search, Settings -│ ├── components/ # layout/, skills/, library/, agents/, marketplace/ +│ ├── pages/ # Projects, ProjectDetail, SkillEditor, Playground, Library, Search, Settings +│ ├── components/ # layout/, skills/, library/, agents/ │ ├── store/ # Zustand (useAppStore.ts) │ ├── api/ # Axios client (client.ts) │ └── types/ # TypeScript types (index.ts) @@ -119,7 +118,6 @@ skillr/ | Playground (multi-turn chat) | React SPA | | Version history + diff viewer | React SPA | | Agent configuration + compose preview | React SPA | -| Marketplace (publish/install/vote) | React SPA | | Cross-project search | React SPA | | Bundle export/import | React SPA | | Webhook configuration | React SPA | @@ -127,7 +125,7 @@ skillr/ ## Database Schema -Tables: `projects`, `project_providers`, `skills`, `skill_versions`, `tags`, `skill_tag` (pivot), `library_skills`, `app_settings`, `agents`, `project_agent` (pivot), `agent_skill` (pivot), `marketplace_skills`, `webhooks`, `webhook_deliveries`, `skill_variables`. +Tables: `projects`, `project_providers`, `skills`, `skill_versions`, `tags`, `skill_tag` (pivot), `library_skills`, `app_settings`, `agents`, `project_agent` (pivot), `agent_skill` (pivot), `webhooks`, `webhook_deliveries`, `skill_variables`. - `skills.tools` is a JSON column - `skills.includes` is a JSON column (skill slug references) @@ -181,7 +179,6 @@ Required frontmatter fields: `id`, `name`. All others are optional. - **Session-based auth** using Laravel's `auth:web` guard — cookies, not tokens - **Multi-auth:** email/password, GitHub OAuth, Apple Sign In - **Multi-tenant:** Organizations with role-based access (owner, admin, editor, viewer, member) -- **Plan-based gates:** free, pro, teams — enforced via `CheckPlanFeature`, `CheckPlanLimit`, `CheckUsageBudget` middleware - **Filament admin** protected with Filament's `Authenticate` middleware - **API routes** protected with `auth:web` middleware (session cookies shared with SPA) - **Organization resolution:** `ResolveOrganization` middleware resolves via `X-Organization-Id` header or user's `current_organization_id` @@ -205,9 +202,7 @@ POST /auth/apple/callback # Apple Sign In callback (form_post) ``` GET /api/health # Health check -POST /api/stripe/webhook # Stripe webhooks POST /api/webhooks/github/{projectId} # Inbound GitHub push -GET /api/billing/plans # Plan listing ``` ## API Endpoints @@ -278,13 +273,6 @@ GET /api/projects/{id}/agents/compose POST /api/projects/{id}/export POST /api/projects/{id}/import-bundle -# Marketplace -GET /api/marketplace -GET /api/marketplace/{id} -POST /api/marketplace/publish -POST /api/marketplace/{id}/install -POST /api/marketplace/{id}/vote - # Webhooks GET /api/projects/{id}/webhooks POST /api/projects/{id}/webhooks @@ -326,21 +314,6 @@ POST /api/projects/{id}/import GET /api/models GET|PUT /api/settings -# Billing & Subscriptions -GET /api/billing/status -POST /api/billing/subscribe -POST /api/billing/change-plan -POST /api/billing/cancel -POST /api/billing/resume -POST /api/billing/setup-intent -PUT /api/billing/payment-method -GET /api/billing/invoices -GET /api/billing/usage - -# Stripe Connect (Marketplace Sellers) -POST /api/billing/connect -GET /api/billing/connect/status -GET /api/billing/earnings ``` ## Development Commands diff --git a/PLAN.md b/PLAN.md index 6174835..fc4bc89 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,186 +1,141 @@ # Skillr — Implementation Plan > This file tracks implementation progress across sessions. -> See `NOTES.md` for the architecture decision rationale. > See `CLAUDE.md` for current Laravel architecture details. --- -## Current Direction: NestJS Migration +## Current Direction: CLI-First Open Source Tool -Skillr is migrating from **Laravel/PHP** to **NestJS/TypeScript** to enable: +Skillr is a **portable AI instruction format with cross-provider sync**. The core value is: define skills once in `.skillr/`, compile to native config files for Claude, Cursor, Copilot, Windsurf, Cline, and OpenAI. -1. **Self-contained desktop app** — Tauri + NestJS sidecar with SQLite, no Docker/PHP/MariaDB -2. **Single language stack** — TypeScript everywhere reduces contributor friction -3. **Shared types** — Frontend and API share validation schemas and interfaces - -The React SPA (`ui/`), `.skillr/` file format, and all provider sync output formats remain unchanged. +The strategic priority is shipping a standalone CLI (`npx skillr`) that works without Docker, databases, or a web browser. The Laravel web app continues as an optional power-user dashboard. ``` -┌─────────────────────────────────────────────┐ -│ Tauri Shell │ -│ ┌────────────────┐ ┌───────────────────┐ │ -│ │ React SPA │ │ NestJS Sidecar │ │ -│ │ (WebView) │──│ (Node.js child) │ │ -│ │ │ │ Port: 8000 │ │ -│ └────────────────┘ └───────┬───────────┘ │ -│ │ │ -│ ┌────────┴────────┐ │ -│ │ SQLite DB │ │ -│ │ ~/.skillr/ │ │ -│ └─────────────────┘ │ -└─────────────────────────────────────────────┘ +.skillr/skills/ + ├── code-review.md + ├── testing-strategy.md + └── api-standards.md + │ + ▼ + ┌──────────────┐ + │ Composition │ ← resolve includes, substitute templates + │ Engine │ + └──────┬───────┘ + │ + ▼ + ┌──────────────┐ + │ Provider │ ← pure transform: skills → native format + │ Drivers │ + └──────┬───────┘ + │ + ┌─────┼─────┬─────┬─────┬─────┐ + ▼ ▼ ▼ ▼ ▼ ▼ + CLAUDE .cursor copilot .windsurf .clinerules .openai + .md /rules .md /rules .md ``` -### Stack Migration - -| Layer | Current (Laravel) | Target (NestJS) | -|---|---|---| -| Backend | PHP 8.4 / Laravel 12 | TypeScript / NestJS | -| ORM | Eloquent | Prisma | -| Database | MariaDB 11 | SQLite (desktop) / PostgreSQL (hosted) | -| Admin UI | Filament 3 | Absorbed into React SPA | -| Auth | Laravel session | Passport.js + express-session | -| Queues | Laravel Jobs | BullMQ (hosted) / in-process (desktop) | -| Billing | Laravel Cashier | stripe-node SDK | -| YAML | symfony/yaml | js-yaml | -| Git ops | Shell commands | simple-git | -| SSE | Manual response streaming | NestJS @Sse() decorator | - --- -## Migration Phases - -### Phase 1: Foundation — [Milestone](https://github.com/eooo-io/skillr/milestone/1) +## Phase 1: Formalize .skillr/ Spec v1 — [Milestone 9](https://github.com/eooo-io/skillr/milestone/9) -**Goal:** Runnable NestJS app with auth, projects, skills CRUD. React SPA works against the new backend. +**Goal:** Pin the canonical format as a stable, versioned specification. Everything else (CLI, plugins, community adoption) depends on this. | # | Issue | Status | |---|---|---| -| #1 | Scaffold NestJS project in `api/` directory | | -| #2 | Define Prisma schema for all 24 models | | -| #3 | Auth module — Passport local strategy + sessions | | -| #4 | Organizations module — multi-tenancy + ResolveOrgGuard | | -| #5 | Projects module — CRUD with path validation | | -| #6 | Skills module — CRUD, slug generation, versions, file I/O | | -| #7 | Tags module — CRUD | | -| #8 | Wire React SPA proxy to NestJS backend | | - -### Phase 2: Core Services — [Milestone](https://github.com/eooo-io/skillr/milestone/2) +| #62 | Define Skill Format Spec v1 with versioning | | +| #63 | Define provider output contract | | +| #64 | Define composition and include resolution spec | | +| #65 | Define template variable resolution spec | | -**Goal:** Feature parity for skill composition, provider sync, LLM testing, and Git operations. +**Deliverables:** `docs/reference/spec-v1.md`, `provider-contract.md`, `composition-spec.md`, `template-spec.md` -| # | Issue | Status | -|---|---|---| -| #9 | Manifest module — SkillFileParser, ManifestService, SkillCompositionService | | -| #10 | Sync module — ProviderSyncService + 7 provider drivers | | -| #11 | LLM module — provider factory + 4 providers | | -| #12 | Skill test controller — SSE streaming | | -| #13 | Playground — multi-turn SSE streaming | | -| #14 | Linter module — 8 prompt quality rules | | -| #15 | Git module — log, diff, commit via simple-git | | -| #16 | Versions module — list, show, restore | | +--- -### Phase 3: Ecosystem — [Milestone](https://github.com/eooo-io/skillr/milestone/3) +## Phase 2: Standalone CLI Tool — [Milestone 10](https://github.com/eooo-io/skillr/milestone/10) -**Goal:** Library, marketplace, agents, bundles, search, webhooks. +**Goal:** Ship `npx skillr` — a standalone Node.js CLI that works without Docker, MariaDB, or a browser. | # | Issue | Status | |---|---|---| -| #17 | Agents module — compose, toggle, assign skills | | -| #18 | Library module — browse and import | | -| #19 | Marketplace module — publish, install, vote | | -| #20 | Search module — cross-project full-text search | | -| #21 | Bundles module — ZIP/JSON export and import | | -| #22 | Webhooks module — CRUD, HMAC delivery, event dispatch | | -| #23 | Skills.sh module — GitHub discovery and import | | -| #24 | Import module — reverse-sync from provider configs | | -| #25 | Bulk operations — tag, assign, delete, move | | +| #66 | Scaffold CLI package with TypeScript + Commander.js | | +| #67 | Port SkillFileParser to TypeScript | | +| #68 | Port SkillCompositionService to TypeScript | | +| #69 | Port TemplateResolver to TypeScript | | +| #70 | Port PromptLinter to TypeScript | | +| #71 | Port 6 provider drivers to TypeScript | | +| #72 | `skillr init` command | | +| #73 | `skillr add ` command | | +| #74 | `skillr sync` command | | +| #75 | `skillr diff` command | | +| #76 | `skillr lint` command | | +| #77 | `skillr import` command | | +| #78 | `skillr test ` command | | +| #79 | Publish to npm as `skillr` | | + +**Deliverables:** `cli/` directory, npm package, working `skillr init && skillr sync` flow. -### Phase 4: Platform — [Milestone](https://github.com/eooo-io/skillr/milestone/4) - -**Goal:** Billing, repositories, advanced features, self-contained desktop app. - -| # | Issue | Status | -|---|---|---| -| #26 | Billing module — Stripe subscriptions, usage, Connect | | -| #27 | Repositories module — GitHub/GitLab connect, pull, push | | -| #28 | MCP servers — CRUD | | -| #29 | A2A agents — CRUD | | -| #30 | OpenClaw config — CRUD | | -| #31 | Visualization — project dependency graph API | | -| #32 | Inbound webhooks — GitHub push handler | | -| #33 | Tauri sidecar integration — NestJS as child process | | +--- -### Phase 5: Polish & Cutover — [Milestone](https://github.com/eooo-io/skillr/milestone/5) +## Phase 3: Pluggable Provider Architecture — [Milestone 11](https://github.com/eooo-io/skillr/milestone/11) -**Goal:** Clean transition, remove Laravel entirely. +**Goal:** Make it trivial for the community to add new AI tool support. | # | Issue | Status | |---|---|---| -| #34 | Port seed data — agents and library skills | | -| #35 | Data migration script — MariaDB to SQLite/PostgreSQL | | -| #36 | E2E tests — Jest + Supertest for all endpoints | | -| #37 | GitHub OAuth + Apple Sign In — Passport strategies | | -| #38 | Remove Laravel — delete PHP backend | | -| #39 | Update CLAUDE.md for NestJS architecture | | -| #40 | CI/CD — GitHub Actions for test, build, Tauri releases | | +| #80 | Define ProviderPlugin interface and discovery | | +| #81 | Extract built-in providers as reference implementations | | +| #82 | `skillr provider:add ` command | | +| #83 | Document "How to write a provider" guide | | --- -## Desktop App Config Sync — [Milestone](https://github.com/eooo-io/skillr/milestone/7) - -**Goal:** Extend Skillr to sync MCP server definitions and app settings to desktop AI tools — making Skillr the single source of truth for both project-level provider configs AND user-level desktop app configurations. +## Phase 4: README & Positioning Overhaul — [Milestone 12](https://github.com/eooo-io/skillr/milestone/12) -The fragmentation problem doesn't stop at IDE/CLI instruction files. Desktop apps like Claude Desktop, ChatGPT Desktop, Claude Code, Codex CLI, Cursor, and Windsurf each maintain their own config files for MCP server connections, model preferences, permissions, and approval modes. Skillr already stores MCP server definitions per project — this phase generates desktop app configs from the same source. - -### Feature 1: Desktop MCP Config Sync +**Goal:** Reposition as CLI-first. README should make someone go "oh damn" in 30 seconds. | # | Issue | Status | |---|---|---| -| #49 | Define desktop app config schema and data model | | -| #50 | Desktop MCP sync drivers — Claude Desktop, Claude Code, Cursor, Windsurf | | -| #51 | Desktop MCP sync API endpoints and UI | | -| #52 | Reverse-import MCP servers from desktop app configs | | +| #84 | Rewrite README for CLI-first positioning | | +| #85 | Add CLI quickstart guide to docs | | +| #86 | Update docs homepage and navigation | | +| #87 | Add "How it works" architecture diagram | | -### Feature 2: Desktop App Settings Sync +--- -| # | Issue | Status | -|---|---|---| -| #53 | Desktop app settings model — workspace profiles | | -| #54 | Desktop settings sync drivers — Claude Code, Codex CLI, Cursor | | -| #55 | Desktop config diff preview before sync | | -| #56 | Tests for desktop config sync drivers | | +## Phase 5: Roadmap Realignment — [Milestone 13](https://github.com/eooo-io/skillr/milestone/13) -### Implementation sequence +**Goal:** Clean up planning artifacts from the NestJS migration era. -``` -#49 (data model) → #50 (MCP drivers) + #52 (reverse-import) in parallel → #51 (API + UI) -#53 (workspace profiles) → #54 (settings drivers) → #55 (diff preview) → #56 (tests throughout) -``` +| # | Issue | Status | +|---|---|---| +| #88 | Close NestJS migration milestones and issues | DONE | +| #89 | Update PLAN.md with CLI-first roadmap | DONE | +| #90 | Update CLAUDE.md with CLI architecture | | --- -## Laravel Legacy (Phases 1-26) — COMPLETE +## Laravel Web App — COMPLETE (reference implementation) -The original Laravel implementation built the full Component Layer: +The Laravel app is feature-complete and continues to run as an optional dashboard: - 24 Eloquent models, 28 controllers, 34 services - 7 provider sync drivers (Claude, Cursor, Copilot, Windsurf, Cline, OpenAI, OpenClaw) - 4 LLM providers (Anthropic, OpenAI, Gemini, Ollama) with streaming -- Multi-tenant organizations, Stripe billing, marketplace +- Multi-tenant organizations with role-based access - React SPA with 14 pages, Monaco editor, D3 visualizations -- Authorization policies, rate limiting, webhook encryption +- Skill taxonomy (categories, types, gotchas, supplementary files) +- Desktop app config sync for MCP servers -This code serves as the reference implementation for the NestJS migration. The API contract (routes, request/response shapes) stays identical so the React SPA requires no changes. +The Laravel codebase serves as the reference implementation for the CLI port. The CLI does NOT depend on Laravel — it reads `.skillr/` directly from the filesystem. --- -## Tech Decisions +## Future (post-CLI adoption) + +These are deferred until CLI adoption validates demand: -- NestJS mirrors Laravel architecture: modules = service providers, guards = middleware, pipes = form requests -- Prisma chosen over TypeORM for better type safety and migration ergonomics -- SQLite for desktop (single file, no setup), PostgreSQL for hosted/team deployments -- simple-git replaces shell-based git operations for cross-platform reliability -- BullMQ for hosted queue processing, in-process for desktop (no Redis needed) -- Strapi rejected — too opinionated for Skillr's custom logic (file I/O, YAML parsing, recursive includes, SSE streaming, Git operations) +- Desktop app (Tauri + embedded backend) +- Team collaboration features +- Plugin registry +- CI/CD integrations (GitHub Actions, pre-commit hooks) diff --git a/README.md b/README.md index 4c78fc9..7e129f7 100644 --- a/README.md +++ b/README.md @@ -1,181 +1,255 @@
+Skillr + +**Write AI instructions once. Sync to every tool you use.** + +[![MIT License](https://img.shields.io/badge/license-MIT-22c55e?style=for-the-badge)](LICENSE) +[![npm](https://img.shields.io/badge/npm-skillr-cb3837?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/skillr) +[![Node 18+](https://img.shields.io/badge/node-18%2B-339933?style=for-the-badge&logo=node.js&logoColor=white)](https://nodejs.org) +[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org) + +[Quick Start](#quick-start) · [How It Works](#how-it-works) · [Commands](#commands) · [Providers](#supported-providers) · [Web Dashboard](#web-dashboard) · [Docs](https://eooo-io.github.io/skillr) + +
+ +--- + +Every AI coding tool has its own config format. Claude uses `CLAUDE.md`. Cursor uses `.cursor/rules/`. Copilot uses `.github/copilot-instructions.md`. Windsurf, Cline, OpenAI — all different. + +**Skillr** gives you one canonical format (`.skillr/`) and compiles it to all of them. + ``` - ███████╗██╗ ██╗██╗██╗ ██╗ ██████╗ - ██╔════╝██║ ██╔╝██║██║ ██║ ██╔══██╗ - ███████╗█████╔╝ ██║██║ ██║ ██████╔╝ - ╚════██║██╔═██╗ ██║██║ ██║ ██╔══██╗ - ███████║██║ ██╗██║███████╗███████╗██║ ██║ - ╚══════╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚═╝ ╚═╝ +.skillr/skills/ + ├── code-review.md + ├── testing-strategy.md + └── api-standards.md + │ + ▼ skillr sync + ┌─────┼─────┬─────┬─────┬─────┐ + ▼ ▼ ▼ ▼ ▼ ▼ + CLAUDE .cursor copilot .windsurf .clinerules .openai + .md /rules .md /rules .md ``` -**Universal AI skill & prompt manager — write once, sync everywhere.** +## Quick Start - +```bash +npx skillr init +npx skillr add "Code Review Standards" +# edit .skillr/skills/code-review-standards.md +npx skillr sync +``` + +That's it. Your skill is now in `.claude/CLAUDE.md`, `.cursor/rules/code-review-standards.mdc`, `.github/copilot-instructions.md`, and every other provider you've enabled. + +## How It Works -

- MIT License - PHP 8.4 - Laravel 12 - React 19 - TypeScript -

+A **skill** is a Markdown file with YAML frontmatter: +```markdown +--- +id: code-review +name: Code Review Standards +description: Enforce team code review conventions during AI-assisted development +tags: [code-quality, review] +model: claude-sonnet-4-6 +includes: [base-instructions] +template_variables: + - name: language + default: TypeScript --- - - +You are a senior code reviewer. All code must be written in {{language}}. -## What It Does +## Rules +- No `any` types +- All public functions must have JSDoc +- Prefer composition over inheritance -- **Write skills once** in a portable YAML + Markdown format, stored in `.skillr/skills/` -- **Sync everywhere** — generate native config files for Claude, Cursor, Copilot, Windsurf, Cline, and OpenAI with one click -- **Test against any model** — stream responses from Anthropic, OpenAI, Gemini, and local Ollama models -- **Version everything** — every save creates a snapshot with full diff history and one-click restore +## Output Format +Return a structured review with severity levels: critical, warning, suggestion. +``` -## Supported Providers +**Required fields:** `id`, `name`. Everything else is optional. -| Provider | Output Path | Format | -|---|---|---| -| Claude | `.claude/CLAUDE.md` | All skills under H2 headings | -| Cursor | `.cursor/rules/{slug}.mdc` | One MDC file per skill | -| GitHub Copilot | `.github/copilot-instructions.md` | All skills concatenated | -| Windsurf | `.windsurf/rules/{slug}.md` | One file per skill | -| Cline | `.clinerules` | Single flat file | -| OpenAI | `.openai/instructions.md` | All skills concatenated | +### Composition -## Quick Start +Skills can include other skills. Skillr resolves `includes` recursively (max depth 5, circular dependency detection): -### With Docker +```yaml +includes: [base-instructions, typescript-conventions] +``` -```bash -git clone https://github.com/eooo-io/skillr.git -cd skillr -cp .env.example .env -# Edit .env — set PROJECTS_HOST_PATH to your local dev directory +### Template Variables -make build -make up -make migrate +Use `{{variable}}` placeholders that resolve at sync time: -# Start the React SPA -cd ui && npm install && npm run dev +```yaml +template_variables: + - name: language + description: Primary programming language + default: TypeScript ``` -### Without Docker +### Prompt Linting -```bash -git clone https://github.com/eooo-io/skillr.git -cd skillr -composer install -cp .env.example .env -php artisan key:generate +Built-in quality checks catch vague instructions, weak constraints, conflicting directives, missing output formats, and more: -# Configure DB_HOST=127.0.0.1 and DB credentials in .env -php artisan migrate --seed -cd ui && npm install && cd .. - -# Start everything (server, queue, logs, vite) -composer dev +```bash +npx skillr lint # lint all skills +npx skillr lint code-review # lint one skill ``` -### Access Points +## Commands -| Interface | URL | +| Command | Description | |---|---| -| React SPA | http://localhost:5173 | -| Filament Admin | http://localhost:8000/admin | -| API | http://localhost:8000/api | +| `skillr init` | Initialize `.skillr/` in the current directory | +| `skillr add ` | Create a new skill from a template | +| `skillr sync` | Compile skills to all enabled provider configs | +| `skillr diff` | Preview what sync would change | +| `skillr lint [slug]` | Run prompt quality checks | +| `skillr import` | Reverse-sync: import skills from existing provider configs | +| `skillr test ` | Test a skill against an LLM (Anthropic or OpenAI) | -Default login: `admin@admin.com` / `password` +### Options + +```bash +skillr init --name "My Project" --providers claude,cursor +skillr sync --provider claude # sync one provider only +skillr sync --dry-run # preview without writing +skillr lint --json # machine-readable output +skillr test my-skill --model gpt-4o # override the skill's model +``` -> **Warning:** Change these credentials immediately in any non-local environment. The seeded admin account is for development only. +## Supported Providers -## Features +| Provider | Output | Format | +|---|---|---| +| **Claude** | `.claude/CLAUDE.md` | All skills under H2 headings | +| **Cursor** | `.cursor/rules/{slug}.mdc` | One MDC file per skill | +| **GitHub Copilot** | `.github/copilot-instructions.md` | All skills concatenated | +| **Windsurf** | `.windsurf/rules/{slug}.md` | One file per skill | +| **Cline** | `.clinerules` | Single flat file | +| **OpenAI** | `.openai/instructions.md` | All skills concatenated | -### Skill Management -- **Monaco Editor** with Markdown syntax highlighting -- **YAML frontmatter** — model, tags, tools, max_tokens, template variables -- **Version history** with Monaco diff viewer and one-click restore -- **Skill composition** via `includes:` references with recursive resolution -- **Template variables** — `{{variable}}` placeholders resolved per-project at sync time -- **Prompt linting** — 8 quality rules (vague instructions, missing output format, etc.) -- **Token estimation** with model-specific context limit warnings +### Reverse Import -### Provider Sync -- **6 providers** with format-specific output drivers -- **Diff preview** before writing — see exactly what changes -- **Git auto-commit** on skill save (optional) +Already have AI instructions scattered across provider configs? Import them into `.skillr/` as the single source of truth: -### AI-Powered -- **Skill generation** — describe what you want, get a complete skill -- **Multi-model test runner** — stream from Claude, GPT, Gemini, Ollama -- **Playground** — multi-turn chat with per-turn stats +```bash +npx skillr import +# Detected 3 skills in .claude/CLAUDE.md +# Detected 2 skills in .cursor/rules/ +# Imported 5 skills → .skillr/skills/ +``` -### Organization -- **Command palette** (`Ctrl+K` / `Cmd+K`) for instant fuzzy search -- **Tags** with color-coded filtering -- **Cross-project search** with FULLTEXT -- **Bulk operations** — batch tag, move, delete +## Skill Format Reference -### Sharing -- **25 pre-built skills** across 6 categories -- **Bundle export/import** as ZIP or JSON -- **Marketplace** for publishing and installing community skills +### Flat Files -## Skill Format +Simple skills live as single Markdown files in `.skillr/skills/`: -Skills are stored as YAML frontmatter + Markdown in `.skillr/skills/`: +``` +.skillr/skills/code-review.md +``` -```markdown ---- -id: summarize-doc -name: Summarize Document -description: Summarizes any document to key bullet points -tags: [summarization, documents] -model: claude-sonnet-4-6 -max_tokens: 1000 -includes: [base-instructions] -template_variables: [language, tone] ---- +### Folder Format + +Complex skills with gotchas and supplementary files use folder format: -You are a precise document summarizer. -Write in {{language}} with a {{tone}} tone. +``` +.skillr/skills/api-standards/ + ├── skill.md # main skill file + ├── gotchas.md # common pitfalls (highest-signal content) + └── examples/ + └── response.json # supplementary files ``` -Required fields: `id`, `name`. Everything else is optional. +### Frontmatter Fields -## Development +| Field | Type | Description | +|---|---|---| +| `id` | `string` | **Required.** Unique identifier (kebab-case) | +| `name` | `string` | **Required.** Display name | +| `description` | `string` | When this skill applies (used for agent triggering) | +| `category` | `string` | One of 10 predefined categories | +| `skill_type` | `string` | `capability-uplift` or `encoded-preference` | +| `model` | `string` | Target model (e.g., `claude-sonnet-4-6`) | +| `max_tokens` | `number` | Max output tokens | +| `tags` | `string[]` | Organizational tags | +| `tools` | `object[]` | Tool/function definitions | +| `includes` | `string[]` | Skill slugs to compose with | +| `template_variables` | `object[]` | `{{variable}}` definitions with defaults | +| `gotchas` | `string` | Common pitfalls and edge cases | +| `conditions` | `object` | `file_patterns` and `path_prefixes` for conditional application | + +## Web Dashboard + +Skillr also ships a full-featured web app for teams that want a GUI: + +- **Monaco editor** with syntax highlighting and diff viewer +- **Version history** with one-click restore +- **Multi-model test runner** — stream from Claude, GPT, Gemini, Ollama +- **Playground** — multi-turn chat with per-turn stats +- **Cross-project search**, bulk operations, tag management +- **Bundle export/import** as ZIP or JSON +- **Agent composition** — merge base instructions + skills per provider + +### Running the Web Dashboard ```bash -# Docker -make up # start containers -make down # stop containers -make migrate # run migrations + seed -make fresh # reset database -make test # run tests -make shell # bash into PHP container - -# Local -composer dev # server + queue + logs + vite -composer test # run test suite - -# Type checking -cd ui && npx tsc --noEmit +# With Docker +git clone https://github.com/eooo-io/skillr.git && cd skillr +cp .env.example .env +make build && make up && make migrate +cd ui && npm install && npm run dev + +# Without Docker +composer install && cp .env.example .env && php artisan key:generate +php artisan migrate --seed +cd ui && npm install && cd .. +composer dev ``` -## Tech Stack +| Interface | URL | +|---|---| +| React SPA | http://localhost:5173 | +| Filament Admin | http://localhost:8000/admin | +| API | http://localhost:8000/api | + +Default login: `admin@admin.com` / `password` + +### Web Dashboard Tech Stack | Layer | Technology | |---|---| | Runtime | PHP 8.4 | | Framework | Laravel 12 + Filament 3 | -| Frontend | React 19 + Vite + TypeScript | +| Frontend | React + Vite + TypeScript | | Styling | Tailwind CSS v4 + shadcn/ui | -| Editor | Monaco Editor | | Database | MariaDB 11 | | LLM Providers | Anthropic, OpenAI, Gemini, Ollama | +## Development + +```bash +# CLI +cd cli +npm install +npm run build # compile TypeScript +npm run dev -- init # run commands during development +npm test # run tests (109 tests, vitest) + +# Web Dashboard +make up # start Docker containers +make migrate # run migrations + seed +make test # run PHP tests +make fresh # reset database +cd ui && npx tsc --noEmit # type check frontend +``` + ## Contributing Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions and guidelines. @@ -186,4 +260,4 @@ If you discover a security vulnerability, please see [SECURITY.md](SECURITY.md) ## License -[MIT](LICENSE) -- Copyright 2026 [eooo.io](https://eooo.io) +[MIT](LICENSE) — Copyright 2026 [eooo.io](https://eooo.io) diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..5020f1a --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,2004 @@ +{ + "name": "skillr", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skillr", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "commander": "^13.1.0", + "fast-glob": "^3.3.3", + "js-yaml": "^4.1.0", + "uuid": "^11.1.0" + }, + "bin": { + "skillr": "dist/cli.js" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.13.14", + "@types/uuid": "^10.0.0", + "tsx": "^4.19.3", + "typescript": "^5.8.2", + "vitest": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..8992929 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,58 @@ +{ + "name": "skillr", + "version": "0.1.0", + "description": "Portable AI instruction format with cross-provider sync", + "type": "module", + "bin": { + "skillr": "./dist/cli.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "tsc", + "dev": "tsx src/cli.ts", + "test": "vitest", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "ai", + "skills", + "prompts", + "claude", + "cursor", + "copilot", + "windsurf", + "cline", + "openai", + "sync", + "config" + ], + "author": "eooo.io", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/eooo-io/skillr.git", + "directory": "cli" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "commander": "^13.1.0", + "fast-glob": "^3.3.3", + "js-yaml": "^4.1.0", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.13.14", + "@types/uuid": "^10.0.0", + "tsx": "^4.19.3", + "typescript": "^5.8.2", + "vitest": "^3.1.1" + } +} diff --git a/cli/src/cli.ts b/cli/src/cli.ts new file mode 100644 index 0000000..efa8f4e --- /dev/null +++ b/cli/src/cli.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +import { Command } from 'commander'; +import { initCommand } from './commands/init.js'; +import { addCommand } from './commands/add.js'; +import { syncCommand } from './commands/sync.js'; +import { diffCommand } from './commands/diff.js'; +import { lintCommand } from './commands/lint.js'; +import { importCommand } from './commands/import.js'; +import { testCommand } from './commands/test.js'; + +const program = new Command(); + +program + .name('skillr') + .description('Portable AI instruction format with cross-provider sync') + .version('0.1.0'); + +program + .command('init') + .description('Initialize .skillr/ in the current directory') + .option('-n, --name ', 'Project name (defaults to directory name)') + .option('-p, --providers ', 'Comma-separated provider list (default: all)') + .action(initCommand); + +program + .command('add ') + .description('Create a new skill') + .option('-d, --description ', 'Skill description') + .option('-c, --category ', 'Skill category') + .option('-m, --model ', 'Target model') + .action(addCommand); + +program + .command('sync') + .description('Sync skills to provider config files') + .option('-p, --provider ', 'Sync to a single provider') + .option('--dry-run', 'Preview without writing') + .action(syncCommand); + +program + .command('diff') + .description('Show what sync would change') + .option('-p, --provider ', 'Diff a single provider') + .action(diffCommand); + +program + .command('lint [slug]') + .description('Run prompt quality checks') + .option('--json', 'Output as JSON') + .action(lintCommand); + +program + .command('import') + .description('Import skills from existing provider configs') + .action(importCommand); + +program + .command('test ') + .description('Test a skill against an LLM') + .option('-m, --message ', 'User message to send') + .option('--model ', 'Override the skill model') + .action(testCommand); + +program.parse(); diff --git a/cli/src/commands/add.ts b/cli/src/commands/add.ts new file mode 100644 index 0000000..a166030 --- /dev/null +++ b/cli/src/commands/add.ts @@ -0,0 +1,47 @@ +import { hasSkillrDir, writeSkillFile, readManifest, writeManifest, slugify } from '../services/ManifestService.js'; +import type { SkillFrontmatter } from '../types.js'; +import * as ui from '../ui.js'; + +export async function addCommand( + name: string, + options: { description?: string; category?: string; model?: string }, +): Promise { + const projectPath = process.cwd(); + + if (!(await hasSkillrDir(projectPath))) { + ui.error('No .skillr/ found. Run `skillr init` first.'); + process.exit(1); + } + + const slug = slugify(name); + ui.working(`Creating skill: ${name} (${slug})`); + + const now = new Date().toISOString(); + const frontmatter: SkillFrontmatter = { + id: slug, + name, + description: options.description ?? null, + category: options.category ?? 'general', + model: options.model ?? null, + tags: [], + tools: [], + includes: [], + template_variables: [], + created_at: now, + updated_at: now, + }; + + const body = `You are a helpful assistant.\n\n## Instructions\n\n- Replace this with your skill instructions\n`; + + await writeSkillFile(projectPath, frontmatter, body); + + // Update manifest + const manifest = await readManifest(projectPath); + if (!manifest.skills.includes(slug)) { + manifest.skills.push(slug); + await writeManifest(projectPath, manifest); + } + + ui.success(`Created .skillr/skills/${slug}.md`); + ui.info('Edit the file to add your instructions, then run `skillr sync`.'); +} diff --git a/cli/src/commands/diff.ts b/cli/src/commands/diff.ts new file mode 100644 index 0000000..69e499e --- /dev/null +++ b/cli/src/commands/diff.ts @@ -0,0 +1,69 @@ +import { hasSkillrDir } from '../services/ManifestService.js'; +import { preview } from '../services/SyncService.js'; +import * as ui from '../ui.js'; +import { colors } from '../ui.js'; + +export async function diffCommand(options: { provider?: string }): Promise { + const projectPath = process.cwd(); + + if (!(await hasSkillrDir(projectPath))) { + ui.error('No .skillr/ found. Run `skillr init` first.'); + process.exit(1); + } + + ui.thinking('Computing diff...'); + + try { + const results = await preview(projectPath, {}, options.provider); + + const changed = results.filter((r) => r.status !== 'unchanged'); + if (changed.length === 0) { + ui.success('No changes. Provider configs are up to date.'); + return; + } + + ui.blank(); + for (const result of changed) { + const statusColor = result.status === 'added' ? colors.green : colors.yellow; + console.log(` ${statusColor}[${result.status.toUpperCase()}]${colors.reset} ${result.path}`); + console.log(` ${colors.dim}Provider: ${result.provider}${colors.reset}`); + + if (result.status === 'modified' && result.current !== null) { + const currentLines = result.current.split('\n'); + const proposedLines = result.proposed.split('\n'); + const maxLines = Math.max(currentLines.length, proposedLines.length); + + let diffCount = 0; + for (let i = 0; i < maxLines && diffCount < 20; i++) { + const curr = currentLines[i] ?? ''; + const prop = proposedLines[i] ?? ''; + if (curr !== prop) { + if (curr) console.log(` ${colors.red}- ${curr}${colors.reset}`); + if (prop) console.log(` ${colors.green}+ ${prop}${colors.reset}`); + diffCount++; + } + } + if (diffCount >= 20) { + console.log(` ${colors.dim}... (diff truncated)${colors.reset}`); + } + } else if (result.status === 'added') { + const lines = result.proposed.split('\n').slice(0, 10); + for (const line of lines) { + console.log(` ${colors.green}+ ${line}${colors.reset}`); + } + if (result.proposed.split('\n').length > 10) { + console.log(` ${colors.dim}... (${result.proposed.split('\n').length - 10} more lines)${colors.reset}`); + } + } + + console.log(); + } + + const added = results.filter((r) => r.status === 'added').length; + const modified = results.filter((r) => r.status === 'modified').length; + ui.ready(`${added} added, ${modified} modified. Run \`skillr sync\` to apply.`); + } catch (err) { + ui.error((err as Error).message); + process.exit(1); + } +} diff --git a/cli/src/commands/import.ts b/cli/src/commands/import.ts new file mode 100644 index 0000000..61e5de7 --- /dev/null +++ b/cli/src/commands/import.ts @@ -0,0 +1,157 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { hasSkillrDir, writeSkillFile, readManifest, writeManifest, slugify } from '../services/ManifestService.js'; +import { parseContent } from '../services/SkillFileParser.js'; +import type { SkillFrontmatter } from '../types.js'; +import * as ui from '../ui.js'; + +interface DetectedSkill { + name: string; + body: string; + source: string; +} + +/** + * Detect skills from existing provider config files. + */ +async function detectSkills(projectPath: string): Promise { + const detected: DetectedSkill[] = []; + + // Claude: .claude/CLAUDE.md — split by H2 + await detectFromH2File(path.join(projectPath, '.claude', 'CLAUDE.md'), 'claude', detected); + + // Copilot: .github/copilot-instructions.md — split by H2 + await detectFromH2File(path.join(projectPath, '.github', 'copilot-instructions.md'), 'copilot', detected); + + // Cline: .clinerules — split by H2 + await detectFromH2File(path.join(projectPath, '.clinerules'), 'cline', detected); + + // OpenAI: .openai/instructions.md — split by H2 + await detectFromH2File(path.join(projectPath, '.openai', 'instructions.md'), 'openai', detected); + + // Cursor: .cursor/rules/*.mdc — one per file + await detectFromDirectory(path.join(projectPath, '.cursor', 'rules'), '.mdc', 'cursor', detected); + + // Windsurf: .windsurf/rules/*.md — one per file + await detectFromDirectory(path.join(projectPath, '.windsurf', 'rules'), '.md', 'windsurf', detected); + + return detected; +} + +async function detectFromH2File( + filePath: string, + source: string, + detected: DetectedSkill[], +): Promise { + let content: string; + try { + content = await fs.readFile(filePath, 'utf-8'); + } catch { + return; + } + + const sections = content.split(/^## /m).slice(1); + for (const section of sections) { + const newlineIdx = section.indexOf('\n'); + if (newlineIdx === -1) continue; + const name = section.slice(0, newlineIdx).trim(); + let body = section.slice(newlineIdx + 1).trim(); + // Remove trailing --- + body = body.replace(/\n---\s*$/, '').trim(); + if (name && body) { + detected.push({ name, body, source }); + } + } +} + +async function detectFromDirectory( + dirPath: string, + ext: string, + source: string, + detected: DetectedSkill[], +): Promise { + let files: string[]; + try { + files = await fs.readdir(dirPath); + } catch { + return; + } + + for (const file of files) { + if (!file.endsWith(ext)) continue; + + const content = await fs.readFile(path.join(dirPath, file), 'utf-8'); + const { frontmatter, body } = parseContent(content); + const name = (frontmatter as any).description || file.replace(ext, '').replace(/-/g, ' '); + + if (body) { + detected.push({ name, body, source }); + } + } +} + +export async function importCommand(): Promise { + const projectPath = process.cwd(); + + if (!(await hasSkillrDir(projectPath))) { + ui.error('No .skillr/ found. Run `skillr init` first.'); + process.exit(1); + } + + ui.thinking('Scanning for existing provider configs...'); + + const detected = await detectSkills(projectPath); + + if (detected.length === 0) { + ui.confused('No provider configs found to import.'); + return; + } + + ui.success(`Found ${detected.length} skill(s) in provider configs:`); + ui.blank(); + + for (const skill of detected) { + ui.info(` ${skill.name} (from ${skill.source})`); + } + + ui.blank(); + ui.working(`Importing ${detected.length} skill(s)...`); + + const manifest = await readManifest(projectPath); + let imported = 0; + + for (const skill of detected) { + const slug = slugify(skill.name); + + // Skip if skill already exists + if (manifest.skills.includes(slug)) { + ui.info(` Skipped: ${slug} (already exists)`); + continue; + } + + const now = new Date().toISOString(); + const frontmatter: SkillFrontmatter = { + id: slug, + name: skill.name, + description: null, + category: 'general', + tags: [], + tools: [], + includes: [], + template_variables: [], + created_at: now, + updated_at: now, + }; + + await writeSkillFile(projectPath, frontmatter, skill.body); + manifest.skills.push(slug); + imported++; + + ui.info(` Imported: ${slug}`); + } + + await writeManifest(projectPath, manifest); + + ui.blank(); + ui.success(`Imported ${imported} skill(s). Run \`skillr sync\` to sync them back.`); +} diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts new file mode 100644 index 0000000..3817137 --- /dev/null +++ b/cli/src/commands/init.ts @@ -0,0 +1,29 @@ +import path from 'node:path'; +import { hasSkillrDir, scaffoldProject } from '../services/ManifestService.js'; +import { VALID_PROVIDERS } from '../types.js'; +import * as ui from '../ui.js'; + +export async function initCommand(options: { name?: string; providers?: string }): Promise { + const projectPath = process.cwd(); + const projectName = options.name ?? path.basename(projectPath); + + ui.thinking(`Initializing .skillr/ in ${projectPath}`); + + if (await hasSkillrDir(projectPath)) { + ui.error('.skillr/ already exists in this directory.'); + process.exit(1); + } + + const providers = options.providers + ? options.providers.split(',').map((p) => p.trim()).filter((p) => VALID_PROVIDERS.includes(p as any)) + : [...VALID_PROVIDERS]; + + await scaffoldProject(projectPath, projectName, providers); + + ui.success(`Initialized .skillr/ for "${projectName}"`); + ui.info(`Providers: ${providers.join(', ')}`); + ui.info(''); + ui.info('Next steps:'); + ui.info(' skillr add "My First Skill" Create a skill'); + ui.info(' skillr sync Sync to provider configs'); +} diff --git a/cli/src/commands/lint.ts b/cli/src/commands/lint.ts new file mode 100644 index 0000000..b70a2c0 --- /dev/null +++ b/cli/src/commands/lint.ts @@ -0,0 +1,70 @@ +import { hasSkillrDir, scanProject } from '../services/ManifestService.js'; +import { lint } from '../services/PromptLinter.js'; +import * as ui from '../ui.js'; +import { colors } from '../ui.js'; + +export async function lintCommand(slug?: string, options: { json?: boolean } = {}): Promise { + const projectPath = process.cwd(); + + if (!(await hasSkillrDir(projectPath))) { + ui.error('No .skillr/ found. Run `skillr init` first.'); + process.exit(1); + } + + const { skills } = await scanProject(projectPath); + + if (skills.length === 0) { + ui.confused('No skills found in .skillr/skills/'); + return; + } + + const toLint = slug + ? skills.filter((s) => s.slug === slug) + : skills; + + if (slug && toLint.length === 0) { + ui.error(`Skill not found: ${slug}`); + process.exit(1); + } + + ui.judging(`Linting ${toLint.length} skill(s)...`); + + let totalIssues = 0; + const allResults: Array<{ slug: string; issues: ReturnType }> = []; + + for (const skill of toLint) { + const issues = lint(skill); + allResults.push({ slug: skill.slug, issues }); + totalIssues += issues.length; + } + + if (options.json) { + console.log(JSON.stringify(allResults, null, 2)); + process.exit(totalIssues > 0 ? 1 : 0); + } + + ui.blank(); + for (const { slug: skillSlug, issues } of allResults) { + if (issues.length === 0) continue; + + console.log(`${colors.bold}${skillSlug}${colors.reset}`); + for (const issue of issues) { + const icon = issue.severity === 'warning' + ? `${colors.yellow}⚠${colors.reset}` + : `${colors.blue}ℹ${colors.reset}`; + const loc = issue.line ? `${colors.dim}:${issue.line}${colors.reset}` : ''; + console.log(` ${icon} ${colors.dim}[${issue.rule}]${colors.reset}${loc} ${issue.message}`); + console.log(` ${colors.dim}→${colors.reset} ${issue.suggestion}`); + } + console.log(); + } + + if (totalIssues === 0) { + ui.success('All skills pass lint checks.'); + } else { + const warnings = allResults.flatMap((r) => r.issues).filter((i) => i.severity === 'warning').length; + const suggestions = totalIssues - warnings; + ui.ready(`${warnings} warning(s), ${suggestions} suggestion(s) across ${allResults.filter((r) => r.issues.length > 0).length} skill(s).`); + process.exit(1); + } +} diff --git a/cli/src/commands/sync.ts b/cli/src/commands/sync.ts new file mode 100644 index 0000000..28cd992 --- /dev/null +++ b/cli/src/commands/sync.ts @@ -0,0 +1,41 @@ +import { hasSkillrDir } from '../services/ManifestService.js'; +import { sync } from '../services/SyncService.js'; +import * as ui from '../ui.js'; + +export async function syncCommand(options: { provider?: string; dryRun?: boolean }): Promise { + const projectPath = process.cwd(); + + if (!(await hasSkillrDir(projectPath))) { + ui.error('No .skillr/ found. Run `skillr init` first.'); + process.exit(1); + } + + if (options.dryRun) { + ui.thinking('Dry run — use `skillr diff` for detailed preview.'); + // Just import and run diff instead + const { diffCommand } = await import('./diff.js'); + return diffCommand({ provider: options.provider }); + } + + ui.working('Syncing skills to provider configs...'); + + try { + const results = await sync(projectPath, {}, options.provider); + + ui.blank(); + let totalFiles = 0; + for (const result of results) { + ui.success(`${result.provider}: ${result.files.length} file(s) written`); + for (const file of result.files) { + ui.info(file.path); + } + totalFiles += result.files.length; + } + + ui.blank(); + ui.success(`Synced ${totalFiles} file(s) across ${results.length} provider(s).`); + } catch (err) { + ui.error((err as Error).message); + process.exit(1); + } +} diff --git a/cli/src/commands/test.ts b/cli/src/commands/test.ts new file mode 100644 index 0000000..9fb1c36 --- /dev/null +++ b/cli/src/commands/test.ts @@ -0,0 +1,135 @@ +import { hasSkillrDir, scanProject } from '../services/ManifestService.js'; +import { resolve as resolveIncludes } from '../services/SkillCompositionService.js'; +import { resolve as resolveTemplates } from '../services/TemplateResolver.js'; +import type { ParsedSkill } from '../types.js'; +import * as ui from '../ui.js'; + +export async function testCommand( + slug: string, + options: { message?: string; model?: string }, +): Promise { + const projectPath = process.cwd(); + + if (!(await hasSkillrDir(projectPath))) { + ui.error('No .skillr/ found. Run `skillr init` first.'); + process.exit(1); + } + + const { skills } = await scanProject(projectPath); + const skill = skills.find((s) => s.slug === slug); + + if (!skill) { + ui.error(`Skill not found: ${slug}`); + process.exit(1); + } + + // Resolve includes + const skillMap = new Map(); + for (const s of skills) skillMap.set(s.slug, s); + + let body = resolveIncludes(skill, skillMap); + + // Resolve template defaults + const vars: Record = {}; + for (const def of skill.frontmatter.template_variables ?? []) { + if (def.default != null) vars[def.name] = def.default; + } + if (Object.keys(vars).length > 0) { + body = resolveTemplates(body, vars); + } + + const model = options.model ?? skill.frontmatter.model ?? 'claude-sonnet-4-6'; + const userMessage = options.message ?? 'Hello, please introduce yourself and explain what you can help with.'; + + ui.thinking(`Testing "${skill.frontmatter.name}" with ${model}...`); + ui.blank(); + + // Detect provider from model name + if (model.startsWith('claude') || model.startsWith('anthropic')) { + await testWithAnthropic(body, userMessage, model); + } else if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) { + await testWithOpenAI(body, userMessage, model); + } else { + ui.error(`Unsupported model: ${model}. Set ANTHROPIC_API_KEY or OPENAI_API_KEY and use a supported model.`); + process.exit(1); + } +} + +async function testWithAnthropic(systemPrompt: string, userMessage: string, model: string): Promise { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + ui.error('ANTHROPIC_API_KEY not set. Export it to test with Claude models.'); + process.exit(1); + } + + try { + // @ts-ignore — optional peer dependency + const { default: Anthropic } = await import('@anthropic-ai/sdk'); + const client = new Anthropic({ apiKey }); + const start = Date.now(); + + const stream = client.messages.stream({ + model, + max_tokens: 4096, + system: systemPrompt, + messages: [{ role: 'user', content: userMessage }], + }); + + for await (const event of stream) { + if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { + process.stdout.write(event.delta.text); + } + } + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + const response = await stream.finalMessage(); + const inputTokens = response.usage.input_tokens; + const outputTokens = response.usage.output_tokens; + + ui.blank(); + ui.blank(); + ui.success(`Done in ${elapsed}s — ${inputTokens} input, ${outputTokens} output tokens`); + } catch (err) { + ui.blank(); + ui.error(`Anthropic API error: ${(err as Error).message}`); + process.exit(1); + } +} + +async function testWithOpenAI(systemPrompt: string, userMessage: string, model: string): Promise { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + ui.error('OPENAI_API_KEY not set. Export it to test with OpenAI models.'); + process.exit(1); + } + + try { + // @ts-ignore — optional peer dependency + const { default: OpenAI } = await import('openai'); + const client = new OpenAI({ apiKey }); + const start = Date.now(); + + const stream = await client.chat.completions.create({ + model, + stream: true, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + }); + + for await (const chunk of stream) { + const text = chunk.choices[0]?.delta?.content; + if (text) process.stdout.write(text); + } + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + ui.blank(); + ui.blank(); + ui.success(`Done in ${elapsed}s`); + } catch (err) { + ui.blank(); + ui.error(`OpenAI API error: ${(err as Error).message}`); + process.exit(1); + } +} diff --git a/cli/src/drivers/claude.ts b/cli/src/drivers/claude.ts new file mode 100644 index 0000000..10944ef --- /dev/null +++ b/cli/src/drivers/claude.ts @@ -0,0 +1,33 @@ +import path from 'node:path'; +import type { ProviderDriver, ResolvedSkill, FileOutput } from '../types.js'; + +export const claudeDriver: ProviderDriver = { + name: 'Claude', + slug: 'claude', + + generate(skills: ResolvedSkill[], projectPath: string): FileOutput[] { + let output = '# CLAUDE.md\n\n'; + + for (const skill of skills) { + output += `## ${skill.name}\n\n`; + + if (skill.conditions?.file_patterns?.length) { + const patterns = skill.conditions.file_patterns.join(', '); + output += `> **Applies to:** \`${patterns}\`\n\n`; + } + + output += `${skill.body}\n\n`; + + if (skill.gotchas) { + output += `### Common Gotchas\n\n${skill.gotchas}\n\n`; + } + + output += '---\n\n'; + } + + return [{ + path: path.join(projectPath, '.claude', 'CLAUDE.md'), + content: output.trimEnd() + '\n', + }]; + }, +}; diff --git a/cli/src/drivers/cline.ts b/cli/src/drivers/cline.ts new file mode 100644 index 0000000..e57376e --- /dev/null +++ b/cli/src/drivers/cline.ts @@ -0,0 +1,25 @@ +import path from 'node:path'; +import type { ProviderDriver, ResolvedSkill, FileOutput } from '../types.js'; + +export const clineDriver: ProviderDriver = { + name: 'Cline', + slug: 'cline', + + generate(skills: ResolvedSkill[], projectPath: string): FileOutput[] { + let output = ''; + + for (const skill of skills) { + output += `## ${skill.name}\n\n`; + output += `${skill.body}\n\n`; + if (skill.gotchas) { + output += `### Common Gotchas\n\n${skill.gotchas}\n\n`; + } + output += '---\n\n'; + } + + return [{ + path: path.join(projectPath, '.clinerules'), + content: output.trimEnd() + '\n', + }]; + }, +}; diff --git a/cli/src/drivers/copilot.ts b/cli/src/drivers/copilot.ts new file mode 100644 index 0000000..d0cba46 --- /dev/null +++ b/cli/src/drivers/copilot.ts @@ -0,0 +1,25 @@ +import path from 'node:path'; +import type { ProviderDriver, ResolvedSkill, FileOutput } from '../types.js'; + +export const copilotDriver: ProviderDriver = { + name: 'GitHub Copilot', + slug: 'copilot', + + generate(skills: ResolvedSkill[], projectPath: string): FileOutput[] { + let output = ''; + + for (const skill of skills) { + output += `## ${skill.name}\n\n`; + output += `${skill.body}\n\n`; + if (skill.gotchas) { + output += `### Common Gotchas\n\n${skill.gotchas}\n\n`; + } + output += '---\n\n'; + } + + return [{ + path: path.join(projectPath, '.github', 'copilot-instructions.md'), + content: output.trimEnd() + '\n', + }]; + }, +}; diff --git a/cli/src/drivers/cursor.ts b/cli/src/drivers/cursor.ts new file mode 100644 index 0000000..d8b3f0f --- /dev/null +++ b/cli/src/drivers/cursor.ts @@ -0,0 +1,43 @@ +import path from 'node:path'; +import yaml from 'js-yaml'; +import type { ProviderDriver, ResolvedSkill, FileOutput } from '../types.js'; + +export const cursorDriver: ProviderDriver = { + name: 'Cursor', + slug: 'cursor', + + generate(skills: ResolvedSkill[], projectPath: string): FileOutput[] { + const dir = path.join(projectPath, '.cursor', 'rules'); + const files: FileOutput[] = []; + + for (const skill of skills) { + const hasConditions = !!skill.conditions?.file_patterns?.length; + const frontmatter: Record = { + description: skill.description ?? '', + alwaysApply: !hasConditions, + }; + + if (hasConditions) { + frontmatter.globs = skill.conditions!.file_patterns; + } + + if (skill.tags.length > 0) { + frontmatter.tags = skill.tags; + } + + const yamlStr = yaml.dump(frontmatter, { lineWidth: -1 }); + let content = `---\n${yamlStr}---\n\n${skill.body}\n`; + + if (skill.gotchas) { + content += `\n## Common Gotchas\n\n${skill.gotchas}\n`; + } + + files.push({ + path: path.join(dir, `${skill.slug}.mdc`), + content, + }); + } + + return files; + }, +}; diff --git a/cli/src/drivers/drivers.test.ts b/cli/src/drivers/drivers.test.ts new file mode 100644 index 0000000..8df678f --- /dev/null +++ b/cli/src/drivers/drivers.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; +import { getDriver, getAllDrivers, getDriverSlugs } from './index.js'; +import type { ResolvedSkill } from '../types.js'; + +function makeSkill(overrides: Partial = {}): ResolvedSkill { + return { + slug: 'test-skill', + name: 'Test Skill', + description: 'A test skill.', + body: 'You must always return JSON.', + category: 'general', + skill_type: null, + gotchas: null, + tags: [], + conditions: null, + ...overrides, + }; +} + +describe('driver registry', () => { + it('returns all 6 drivers', () => { + expect(getAllDrivers()).toHaveLength(6); + }); + + it('returns all 6 slugs', () => { + const slugs = getDriverSlugs(); + expect(slugs).toEqual(expect.arrayContaining(['claude', 'cursor', 'copilot', 'windsurf', 'cline', 'openai'])); + }); + + it('throws for unknown provider', () => { + expect(() => getDriver('unknown')).toThrow('Unknown provider: unknown'); + }); +}); + +describe('claude driver', () => { + const driver = getDriver('claude'); + + it('generates a single CLAUDE.md file', () => { + const files = driver.generate([makeSkill()], '/project'); + expect(files).toHaveLength(1); + expect(files[0].path).toContain('.claude/CLAUDE.md'); + }); + + it('includes skill name as H2 heading', () => { + const files = driver.generate([makeSkill()], '/project'); + expect(files[0].content).toContain('## Test Skill'); + }); + + it('includes body content', () => { + const files = driver.generate([makeSkill()], '/project'); + expect(files[0].content).toContain('You must always return JSON.'); + }); + + it('includes gotchas when present', () => { + const files = driver.generate([makeSkill({ gotchas: 'Watch out!' })], '/project'); + expect(files[0].content).toContain('### Common Gotchas'); + expect(files[0].content).toContain('Watch out!'); + }); + + it('includes conditions as blockquote', () => { + const files = driver.generate([makeSkill({ conditions: { file_patterns: ['*.ts', '*.tsx'] } })], '/project'); + expect(files[0].content).toContain('**Applies to:**'); + expect(files[0].content).toContain('*.ts, *.tsx'); + }); + + it('handles multiple skills', () => { + const skills = [makeSkill({ slug: 'a', name: 'Skill A' }), makeSkill({ slug: 'b', name: 'Skill B' })]; + const files = driver.generate(skills, '/project'); + expect(files).toHaveLength(1); + expect(files[0].content).toContain('## Skill A'); + expect(files[0].content).toContain('## Skill B'); + }); +}); + +describe('cursor driver', () => { + const driver = getDriver('cursor'); + + it('generates one .mdc file per skill', () => { + const skills = [makeSkill({ slug: 'a' }), makeSkill({ slug: 'b' })]; + const files = driver.generate(skills, '/project'); + expect(files).toHaveLength(2); + expect(files[0].path).toContain('.cursor/rules/a.mdc'); + expect(files[1].path).toContain('.cursor/rules/b.mdc'); + }); + + it('sets alwaysApply true when no conditions', () => { + const files = driver.generate([makeSkill()], '/project'); + expect(files[0].content).toContain('alwaysApply: true'); + }); + + it('sets alwaysApply false and adds globs when conditions present', () => { + const files = driver.generate([makeSkill({ conditions: { file_patterns: ['*.py'] } })], '/project'); + expect(files[0].content).toContain('alwaysApply: false'); + expect(files[0].content).toContain('*.py'); + }); + + it('includes tags in frontmatter', () => { + const files = driver.generate([makeSkill({ tags: ['python', 'api'] })], '/project'); + expect(files[0].content).toContain('python'); + expect(files[0].content).toContain('api'); + }); +}); + +describe('copilot driver', () => { + const driver = getDriver('copilot'); + + it('generates a single copilot-instructions.md', () => { + const files = driver.generate([makeSkill()], '/project'); + expect(files).toHaveLength(1); + expect(files[0].path).toContain('.github/copilot-instructions.md'); + }); + + it('includes all skills with H2 headings', () => { + const files = driver.generate([makeSkill({ name: 'My Skill' })], '/project'); + expect(files[0].content).toContain('## My Skill'); + }); +}); + +describe('windsurf driver', () => { + const driver = getDriver('windsurf'); + + it('generates one .md file per skill', () => { + const files = driver.generate([makeSkill({ slug: 'ws-skill' })], '/project'); + expect(files).toHaveLength(1); + expect(files[0].path).toContain('.windsurf/rules/ws-skill.md'); + }); + + it('uses H1 heading for skill name', () => { + const files = driver.generate([makeSkill({ name: 'Wind Skill' })], '/project'); + expect(files[0].content).toContain('# Wind Skill'); + }); +}); + +describe('cline driver', () => { + const driver = getDriver('cline'); + + it('generates a single .clinerules file', () => { + const files = driver.generate([makeSkill()], '/project'); + expect(files).toHaveLength(1); + expect(files[0].path).toContain('.clinerules'); + }); +}); + +describe('openai driver', () => { + const driver = getDriver('openai'); + + it('generates a single instructions.md', () => { + const files = driver.generate([makeSkill()], '/project'); + expect(files).toHaveLength(1); + expect(files[0].path).toContain('.openai/instructions.md'); + }); +}); + +describe('all single-file drivers produce consistent format', () => { + for (const slug of ['copilot', 'cline', 'openai'] as const) { + it(`${slug} includes H2 heading, body, and separator`, () => { + const driver = getDriver(slug); + const files = driver.generate([makeSkill({ name: 'Fmt Test', body: 'Format body.' })], '/project'); + expect(files[0].content).toContain('## Fmt Test'); + expect(files[0].content).toContain('Format body.'); + expect(files[0].content).toContain('---'); + }); + + it(`${slug} includes gotchas section`, () => { + const driver = getDriver(slug); + const files = driver.generate([makeSkill({ gotchas: 'Edge case here.' })], '/project'); + expect(files[0].content).toContain('### Common Gotchas'); + expect(files[0].content).toContain('Edge case here.'); + }); + } +}); diff --git a/cli/src/drivers/index.ts b/cli/src/drivers/index.ts new file mode 100644 index 0000000..8470636 --- /dev/null +++ b/cli/src/drivers/index.ts @@ -0,0 +1,32 @@ +import type { ProviderDriver } from '../types.js'; +import { claudeDriver } from './claude.js'; +import { cursorDriver } from './cursor.js'; +import { copilotDriver } from './copilot.js'; +import { windsurfDriver } from './windsurf.js'; +import { clineDriver } from './cline.js'; +import { openaiDriver } from './openai.js'; + +const drivers: Record = { + claude: claudeDriver, + cursor: cursorDriver, + copilot: copilotDriver, + windsurf: windsurfDriver, + cline: clineDriver, + openai: openaiDriver, +}; + +export function getDriver(slug: string): ProviderDriver { + const driver = drivers[slug]; + if (!driver) { + throw new Error(`Unknown provider: ${slug}. Valid providers: ${Object.keys(drivers).join(', ')}`); + } + return driver; +} + +export function getAllDrivers(): ProviderDriver[] { + return Object.values(drivers); +} + +export function getDriverSlugs(): string[] { + return Object.keys(drivers); +} diff --git a/cli/src/drivers/openai.ts b/cli/src/drivers/openai.ts new file mode 100644 index 0000000..f44658e --- /dev/null +++ b/cli/src/drivers/openai.ts @@ -0,0 +1,25 @@ +import path from 'node:path'; +import type { ProviderDriver, ResolvedSkill, FileOutput } from '../types.js'; + +export const openaiDriver: ProviderDriver = { + name: 'OpenAI', + slug: 'openai', + + generate(skills: ResolvedSkill[], projectPath: string): FileOutput[] { + let output = ''; + + for (const skill of skills) { + output += `## ${skill.name}\n\n`; + output += `${skill.body}\n\n`; + if (skill.gotchas) { + output += `### Common Gotchas\n\n${skill.gotchas}\n\n`; + } + output += '---\n\n'; + } + + return [{ + path: path.join(projectPath, '.openai', 'instructions.md'), + content: output.trimEnd() + '\n', + }]; + }, +}; diff --git a/cli/src/drivers/windsurf.ts b/cli/src/drivers/windsurf.ts new file mode 100644 index 0000000..3f796ae --- /dev/null +++ b/cli/src/drivers/windsurf.ts @@ -0,0 +1,27 @@ +import path from 'node:path'; +import type { ProviderDriver, ResolvedSkill, FileOutput } from '../types.js'; + +export const windsurfDriver: ProviderDriver = { + name: 'Windsurf', + slug: 'windsurf', + + generate(skills: ResolvedSkill[], projectPath: string): FileOutput[] { + const dir = path.join(projectPath, '.windsurf', 'rules'); + const files: FileOutput[] = []; + + for (const skill of skills) { + let content = `# ${skill.name}\n\n${skill.body}\n`; + + if (skill.gotchas) { + content += `\n## Common Gotchas\n\n${skill.gotchas}\n`; + } + + files.push({ + path: path.join(dir, `${skill.slug}.md`), + content, + }); + } + + return files; + }, +}; diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..d4333da --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,9 @@ +// Public API — for programmatic use +export * from './types.js'; +export { parseContent, renderFile, validateFrontmatter, slugify } from './services/SkillFileParser.js'; +export { resolve as resolveIncludes } from './services/SkillCompositionService.js'; +export { resolve as resolveTemplates, extractVariables, getMissing } from './services/TemplateResolver.js'; +export { lint } from './services/PromptLinter.js'; +export { scanProject, scaffoldProject, readManifest, writeManifest } from './services/ManifestService.js'; +export { sync, preview, resolveSkills } from './services/SyncService.js'; +export { getDriver, getAllDrivers, getDriverSlugs } from './drivers/index.js'; diff --git a/cli/src/services/ManifestService.test.ts b/cli/src/services/ManifestService.test.ts new file mode 100644 index 0000000..40f9ff0 --- /dev/null +++ b/cli/src/services/ManifestService.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { + scanProject, + scaffoldProject, + readManifest, + writeManifest, + writeSkillFile, + hasSkillrDir, + slugify, +} from './ManifestService.js'; +import { renderFile } from './SkillFileParser.js'; +import type { Manifest, SkillFrontmatter } from '../types.js'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skillr-test-')); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('scaffoldProject', () => { + it('creates .skillr/ with manifest.json and skills/ directory', async () => { + await scaffoldProject(tmpDir, 'Test Project', ['claude', 'cursor']); + + const manifest = JSON.parse( + await fs.readFile(path.join(tmpDir, '.skillr', 'manifest.json'), 'utf-8'), + ); + expect(manifest.name).toBe('Test Project'); + expect(manifest.providers).toEqual(['claude', 'cursor']); + expect(manifest.spec_version).toBe(1); + expect(manifest.skills).toEqual([]); + expect(manifest.id).toBeDefined(); + + const stat = await fs.stat(path.join(tmpDir, '.skillr', 'skills')); + expect(stat.isDirectory()).toBe(true); + }); +}); + +describe('hasSkillrDir', () => { + it('returns false when no .skillr/', async () => { + expect(await hasSkillrDir(tmpDir)).toBe(false); + }); + + it('returns true after scaffolding', async () => { + await scaffoldProject(tmpDir, 'Test'); + expect(await hasSkillrDir(tmpDir)).toBe(true); + }); +}); + +describe('readManifest / writeManifest', () => { + it('roundtrips manifest data', async () => { + await scaffoldProject(tmpDir, 'Test'); + const manifest = await readManifest(tmpDir); + manifest.skills = ['skill-a', 'skill-b']; + manifest.synced_at = '2026-01-01T00:00:00Z'; + await writeManifest(tmpDir, manifest); + + const reread = await readManifest(tmpDir); + expect(reread.skills).toEqual(['skill-a', 'skill-b']); + expect(reread.synced_at).toBe('2026-01-01T00:00:00Z'); + }); +}); + +describe('writeSkillFile', () => { + beforeEach(async () => { + await scaffoldProject(tmpDir, 'Test'); + }); + + it('writes flat .md file for simple skills', async () => { + const fm: SkillFrontmatter = { id: 'my-skill', name: 'My Skill' }; + await writeSkillFile(tmpDir, fm, 'Instructions here.'); + + const content = await fs.readFile( + path.join(tmpDir, '.skillr', 'skills', 'my-skill.md'), + 'utf-8', + ); + expect(content).toContain('id: my-skill'); + expect(content).toContain('Instructions here.'); + }); + + it('writes folder format for skills with gotchas', async () => { + const fm: SkillFrontmatter = { + id: 'gotcha-skill', + name: 'Gotcha Skill', + gotchas: 'Watch out for edge cases.', + }; + await writeSkillFile(tmpDir, fm, 'Body.'); + + const skillMd = await fs.readFile( + path.join(tmpDir, '.skillr', 'skills', 'gotcha-skill', 'skill.md'), + 'utf-8', + ); + expect(skillMd).toContain('id: gotcha-skill'); + expect(skillMd).not.toContain('gotchas'); + + const gotchas = await fs.readFile( + path.join(tmpDir, '.skillr', 'skills', 'gotcha-skill', 'gotchas.md'), + 'utf-8', + ); + expect(gotchas).toBe('Watch out for edge cases.'); + }); + + it('writes supplementary files in folder format', async () => { + const fm: SkillFrontmatter = { + id: 'supp-skill', + name: 'Supp Skill', + supplementary_files: [{ path: 'examples/example.json', content: '{"key":"val"}' }], + }; + await writeSkillFile(tmpDir, fm, 'Body.'); + + const supp = await fs.readFile( + path.join(tmpDir, '.skillr', 'skills', 'supp-skill', 'examples', 'example.json'), + 'utf-8', + ); + expect(supp).toBe('{"key":"val"}'); + }); +}); + +describe('scanProject', () => { + beforeEach(async () => { + await scaffoldProject(tmpDir, 'Scan Test', ['claude']); + }); + + it('returns null manifest and empty skills when no .skillr/', async () => { + const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skillr-empty-')); + try { + const result = await scanProject(emptyDir); + expect(result.manifest).toBeNull(); + expect(result.skills).toEqual([]); + } finally { + await fs.rm(emptyDir, { recursive: true, force: true }); + } + }); + + it('discovers flat skill files', async () => { + const skillContent = renderFile({ id: 'flat-skill', name: 'Flat Skill' }, 'Flat body.'); + await fs.writeFile( + path.join(tmpDir, '.skillr', 'skills', 'flat-skill.md'), + skillContent, + ); + + const result = await scanProject(tmpDir); + expect(result.manifest).not.toBeNull(); + expect(result.skills).toHaveLength(1); + expect(result.skills[0].slug).toBe('flat-skill'); + expect(result.skills[0].body).toBe('Flat body.'); + }); + + it('discovers folder-based skills with gotchas', async () => { + const skillDir = path.join(tmpDir, '.skillr', 'skills', 'folder-skill'); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, 'skill.md'), + renderFile({ id: 'folder-skill', name: 'Folder Skill' }, 'Folder body.'), + ); + await fs.writeFile(path.join(skillDir, 'gotchas.md'), 'Gotcha content.'); + + const result = await scanProject(tmpDir); + const folderSkill = result.skills.find((s) => s.slug === 'folder-skill'); + expect(folderSkill).toBeDefined(); + expect(folderSkill!.frontmatter.gotchas).toBe('Gotcha content.'); + }); +}); + +describe('slugify (re-export)', () => { + it('works via ManifestService re-export', () => { + expect(slugify('Test Skill')).toBe('test-skill'); + }); +}); diff --git a/cli/src/services/ManifestService.ts b/cli/src/services/ManifestService.ts new file mode 100644 index 0000000..30ac6c2 --- /dev/null +++ b/cli/src/services/ManifestService.ts @@ -0,0 +1,184 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import fg from 'fast-glob'; +import { v4 as uuidv4 } from 'uuid'; +import { parseContent, parseSkillContent, renderFile, slugify } from './SkillFileParser.js'; +import type { Manifest, ParsedSkill, SkillFrontmatter } from '../types.js'; + +/** + * Scan a project's .skillr/ directory and return manifest + parsed skills. + */ +export async function scanProject(projectPath: string): Promise<{ + manifest: Manifest | null; + skills: ParsedSkill[]; +}> { + const skillrDir = path.join(projectPath, '.skillr'); + let manifest: Manifest | null = null; + const skills: ParsedSkill[] = []; + + // Read manifest + const manifestPath = path.join(skillrDir, 'manifest.json'); + try { + const raw = await fs.readFile(manifestPath, 'utf-8'); + manifest = JSON.parse(raw) as Manifest; + } catch { + // No manifest + } + + const skillsDir = path.join(skillrDir, 'skills'); + + try { + await fs.access(skillsDir); + } catch { + return { manifest, skills }; + } + + // Flat files: *.md + const flatFiles = await fg('*.md', { cwd: skillsDir, absolute: true }); + for (const file of flatFiles) { + const content = await fs.readFile(file, 'utf-8'); + const filename = path.basename(file); + skills.push(parseSkillContent(content, filename)); + } + + // Folder-based: */skill.md + const folderFiles = await fg('*/skill.md', { cwd: skillsDir, absolute: true }); + for (const file of folderFiles) { + const dir = path.dirname(file); + const content = await fs.readFile(file, 'utf-8'); + const slug = path.basename(dir); + const skill = parseSkillContent(content, `${slug}.md`); + + // Read gotchas.md if it exists + const gotchasPath = path.join(dir, 'gotchas.md'); + try { + skill.frontmatter.gotchas = await fs.readFile(gotchasPath, 'utf-8'); + } catch { + // No gotchas file + } + + // Discover supplementary files + const allFiles = await fg('**/*', { cwd: dir, absolute: false }); + const supplementary = []; + for (const f of allFiles) { + if (f === 'skill.md' || f === 'gotchas.md') continue; + const filePath = path.join(dir, f); + const fileContent = await fs.readFile(filePath, 'utf-8'); + supplementary.push({ path: f, content: fileContent }); + } + if (supplementary.length > 0) { + skill.frontmatter.supplementary_files = supplementary; + } + + skills.push(skill); + } + + return { manifest, skills }; +} + +/** + * Scaffold a new .skillr/ directory. + */ +export async function scaffoldProject( + projectPath: string, + name: string, + providers: string[] = [], +): Promise { + const skillrDir = path.join(projectPath, '.skillr'); + await fs.mkdir(path.join(skillrDir, 'skills'), { recursive: true }); + + const manifest: Manifest = { + spec_version: 1, + id: uuidv4(), + name, + description: '', + providers, + skills: [], + created_at: new Date().toISOString(), + synced_at: null, + }; + + await fs.writeFile( + path.join(skillrDir, 'manifest.json'), + JSON.stringify(manifest, null, 2) + '\n', + ); +} + +/** + * Read the manifest from a project. + */ +export async function readManifest(projectPath: string): Promise { + const manifestPath = path.join(projectPath, '.skillr', 'manifest.json'); + const raw = await fs.readFile(manifestPath, 'utf-8'); + return JSON.parse(raw) as Manifest; +} + +/** + * Write a manifest to a project. + */ +export async function writeManifest(projectPath: string, manifest: Manifest): Promise { + const manifestPath = path.join(projectPath, '.skillr', 'manifest.json'); + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); +} + +/** + * Write a skill file to .skillr/skills/. + */ +export async function writeSkillFile( + projectPath: string, + frontmatter: SkillFrontmatter, + body: string, +): Promise { + const slug = frontmatter.id; + const skillsDir = path.join(projectPath, '.skillr', 'skills'); + await fs.mkdir(skillsDir, { recursive: true }); + + const useFolderFormat = !!(frontmatter.gotchas || frontmatter.supplementary_files?.length); + + if (useFolderFormat) { + const skillDir = path.join(skillsDir, slug); + await fs.mkdir(skillDir, { recursive: true }); + + // Remove flat file if upgrading + try { + await fs.unlink(path.join(skillsDir, `${slug}.md`)); + } catch { /* doesn't exist */ } + + // Write skill.md (without gotchas in frontmatter — it goes in gotchas.md) + const fmCopy = { ...frontmatter }; + delete (fmCopy as Record).gotchas; + delete (fmCopy as Record).supplementary_files; + await fs.writeFile(path.join(skillDir, 'skill.md'), renderFile(fmCopy, body)); + + if (frontmatter.gotchas) { + await fs.writeFile(path.join(skillDir, 'gotchas.md'), frontmatter.gotchas); + } + + for (const file of frontmatter.supplementary_files ?? []) { + const filePath = path.join(skillDir, file.path); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, file.content); + } + } else { + // Remove folder if downgrading + try { + await fs.rm(path.join(skillsDir, slug), { recursive: true, force: true }); + } catch { /* doesn't exist */ } + + await fs.writeFile(path.join(skillsDir, `${slug}.md`), renderFile(frontmatter, body)); + } +} + +/** + * Check if .skillr/ exists in the given directory. + */ +export async function hasSkillrDir(projectPath: string): Promise { + try { + await fs.access(path.join(projectPath, '.skillr', 'manifest.json')); + return true; + } catch { + return false; + } +} + +export { slugify }; diff --git a/cli/src/services/PromptLinter.test.ts b/cli/src/services/PromptLinter.test.ts new file mode 100644 index 0000000..8ddf811 --- /dev/null +++ b/cli/src/services/PromptLinter.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from 'vitest'; +import { lint } from './PromptLinter.js'; +import type { ParsedSkill } from '../types.js'; + +function makeSkill(body: string, overrides: Partial = {}): ParsedSkill { + return { + slug: 'test-skill', + body, + frontmatter: { + id: 'test-skill', + name: 'Test Skill', + ...overrides, + }, + }; +} + +// Helper to generate a body of N estimated tokens (~4 chars per token) +function bodyOfTokens(n: number): string { + return 'x'.repeat(n * 4); +} + +describe('lint', () => { + it('returns empty_body warning for empty skill', () => { + const issues = lint(makeSkill('')); + expect(issues).toHaveLength(1); + expect(issues[0].rule).toBe('empty_body'); + expect(issues[0].severity).toBe('warning'); + }); + + it('returns empty_body warning for whitespace-only skill', () => { + const issues = lint(makeSkill(' \n\n ')); + expect(issues).toHaveLength(1); + expect(issues[0].rule).toBe('empty_body'); + }); + + it('detects vague instructions', () => { + const issues = lint(makeSkill('Do your best to complete the task.')); + const vague = issues.find((i) => i.rule === 'vague_instructions'); + expect(vague).toBeDefined(); + expect(vague!.severity).toBe('warning'); + expect(vague!.line).toBeDefined(); + }); + + it('detects weak constraints', () => { + const issues = lint(makeSkill('You should always format output.')); + const weak = issues.find((i) => i.rule === 'weak_constraints'); + expect(weak).toBeDefined(); + expect(weak!.severity).toBe('suggestion'); + }); + + it('detects conflicting directives', () => { + const issues = lint(makeSkill('Be concise in your response.\n\nBe thorough in your analysis.')); + const conflict = issues.find((i) => i.rule === 'conflicting_directives'); + expect(conflict).toBeDefined(); + }); + + it('suggests output format for long skills without one', () => { + const body = bodyOfTokens(250); // >200 tokens, no format section + const issues = lint(makeSkill(body)); + const format = issues.find((i) => i.rule === 'missing_output_format'); + expect(format).toBeDefined(); + }); + + it('does not flag output format when present', () => { + const body = bodyOfTokens(250) + '\n\n## Output Format\nReturn JSON.'; + const issues = lint(makeSkill(body)); + const format = issues.find((i) => i.rule === 'missing_output_format'); + expect(format).toBeUndefined(); + }); + + it('does not flag output format when code block present', () => { + const body = bodyOfTokens(250) + '\n\n```json\n{"key": "val"}\n```'; + const issues = lint(makeSkill(body)); + const format = issues.find((i) => i.rule === 'missing_output_format'); + expect(format).toBeUndefined(); + }); + + it('warns on excessive length', () => { + const body = bodyOfTokens(8500); + const issues = lint(makeSkill(body)); + const excessive = issues.find((i) => i.rule === 'excessive_length'); + expect(excessive).toBeDefined(); + expect(excessive!.severity).toBe('warning'); + }); + + it('detects role confusion', () => { + const issues = lint(makeSkill('You are a user who needs help.')); + const role = issues.find((i) => i.rule === 'role_confusion'); + expect(role).toBeDefined(); + }); + + it('suggests examples for substantial skills', () => { + const body = bodyOfTokens(350); + const issues = lint(makeSkill(body)); + const examples = issues.find((i) => i.rule === 'missing_examples'); + expect(examples).toBeDefined(); + }); + + it('does not flag examples when present', () => { + const body = bodyOfTokens(350) + '\n\nFor example, return JSON.'; + const issues = lint(makeSkill(body)); + const examples = issues.find((i) => i.rule === 'missing_examples'); + expect(examples).toBeUndefined(); + }); + + it('detects duplicate headings', () => { + const body = '## Setup\nDo X.\n\n## Setup\nDo Y.'; + const issues = lint(makeSkill(body)); + const dup = issues.find((i) => i.rule === 'redundancy'); + expect(dup).toBeDefined(); + }); + + it('suggests gotchas for complex skills without them', () => { + const body = bodyOfTokens(550); + const issues = lint(makeSkill(body)); + const gotchas = issues.find((i) => i.rule === 'missing_gotchas'); + expect(gotchas).toBeDefined(); + }); + + it('does not flag gotchas when frontmatter has them', () => { + const body = bodyOfTokens(550); + const issues = lint(makeSkill(body, { gotchas: 'Watch out for X.' })); + const gotchas = issues.find((i) => i.rule === 'missing_gotchas'); + expect(gotchas).toBeUndefined(); + }); + + it('warns on vague description', () => { + const issues = lint(makeSkill('Body.', { description: 'Helps with stuff' })); + const desc = issues.find((i) => i.rule === 'vague_description'); + expect(desc).toBeDefined(); + }); + + it('warns on short description', () => { + const issues = lint(makeSkill('Body.', { description: 'Short' })); + const desc = issues.find((i) => i.rule === 'vague_description'); + expect(desc).toBeDefined(); + }); + + it('does not flag a specific description', () => { + const issues = lint(makeSkill('Body.', { description: 'Validates API responses against the OpenAPI schema and reports mismatches.' })); + const desc = issues.find((i) => i.rule === 'vague_description'); + expect(desc).toBeUndefined(); + }); + + it('suggests skill_type for non-trivial skills', () => { + const body = bodyOfTokens(250); + const issues = lint(makeSkill(body)); + const st = issues.find((i) => i.rule === 'missing_skill_type'); + expect(st).toBeDefined(); + }); + + it('does not flag skill_type when set', () => { + const body = bodyOfTokens(250); + const issues = lint(makeSkill(body, { skill_type: 'capability-uplift' })); + const st = issues.find((i) => i.rule === 'missing_skill_type'); + expect(st).toBeUndefined(); + }); + + it('returns no issues for a well-formed short skill', () => { + const issues = lint(makeSkill('Always return JSON.')); + expect(issues).toEqual([]); + }); +}); diff --git a/cli/src/services/PromptLinter.ts b/cli/src/services/PromptLinter.ts new file mode 100644 index 0000000..a943d76 --- /dev/null +++ b/cli/src/services/PromptLinter.ts @@ -0,0 +1,216 @@ +import type { ParsedSkill, LintIssue } from '../types.js'; + +const VAGUE_PATTERNS = [ + /\bdo your best\b/i, + /\bif possible\b/i, + /\bas needed\b/i, + /\bas appropriate\b/i, + /\btry to\b/i, + /\bfeel free\b/i, + /\bwhen necessary\b/i, + /\bif you can\b/i, + /\bwhen appropriate\b/i, +]; + +const WEAK_CONSTRAINT_PATTERNS = [ + /\byou should\b/i, + /\byou could\b/i, + /\byou might\b/i, + /\bconsider\b/i, + /\bit would be nice\b/i, +]; + +const CONFLICTING_PAIRS: [RegExp, RegExp, string][] = [ + [/\bbe concise\b/i, /\bbe thorough\b/i, 'concise vs. thorough'], + [/\bbe brief\b/i, /\bbe detailed\b/i, 'brief vs. detailed'], + [/\bkeep it short\b/i, /\bexplain in detail\b/i, 'short vs. detailed explanation'], +]; + +const ROLE_CONFUSION_PATTERNS = [ + /\byou are (a|an|the) user\b/i, + /\bas the user\b/i, + /\bpretend to be\b/i, + /\broleplay as\b/i, +]; + +const GENERIC_DESCRIPTION_PATTERNS = [ + /^helps? with/i, + /^does? stuff/i, + /^general purpose/i, + /^a? ?tool for/i, + /^useful for/i, +]; + +/** + * Run all lint rules against a parsed skill. + */ +export function lint(skill: ParsedSkill): LintIssue[] { + const issues: LintIssue[] = []; + const { body } = skill; + const { description, gotchas, skill_type } = skill.frontmatter; + const lines = body.split('\n'); + + // Rule 1: Empty body + if (!body.trim()) { + issues.push({ + rule: 'empty_body', + severity: 'warning', + message: 'Skill body is empty.', + suggestion: 'Add instructions that tell the AI what to do.', + }); + return issues; // No point checking other rules + } + + // Rule 2: Vague instructions + for (const pattern of VAGUE_PATTERNS) { + const lineNum = findLine(lines, pattern); + if (lineNum !== undefined) { + issues.push({ + rule: 'vague_instructions', + severity: 'warning', + message: `Vague instruction found: "${lines[lineNum].trim()}"`, + suggestion: 'Replace with specific, measurable instructions. Use "you must" instead of "try to".', + line: lineNum + 1, + }); + break; + } + } + + // Rule 3: Weak constraints + for (const pattern of WEAK_CONSTRAINT_PATTERNS) { + const lineNum = findLine(lines, pattern); + if (lineNum !== undefined) { + issues.push({ + rule: 'weak_constraints', + severity: 'suggestion', + message: `Weak constraint found: "${lines[lineNum].trim()}"`, + suggestion: 'Use "you must" or "always" instead of "you should" or "consider".', + line: lineNum + 1, + }); + break; + } + } + + // Rule 4: Conflicting directives + for (const [patternA, patternB, label] of CONFLICTING_PAIRS) { + if (patternA.test(body) && patternB.test(body)) { + issues.push({ + rule: 'conflicting_directives', + severity: 'warning', + message: `Conflicting directives detected: ${label}.`, + suggestion: 'Choose one approach and remove the conflicting instruction.', + }); + break; + } + } + + // Rule 5: Missing output format + const hasOutputFormat = /\b(output format|respond with|return as|format:|example output|response format)\b/i.test(body); + const hasCodeBlock = /```/.test(body); + if (!hasOutputFormat && !hasCodeBlock && estimateTokens(body) > 200) { + issues.push({ + rule: 'missing_output_format', + severity: 'suggestion', + message: 'No output format specified.', + suggestion: 'Add an "Output Format" or "Example" section showing the expected response structure.', + }); + } + + // Rule 6: Excessive length + const tokens = estimateTokens(body); + if (tokens > 8000) { + issues.push({ + rule: 'excessive_length', + severity: 'warning', + message: `Skill body is ~${tokens} tokens. This may exceed context limits or dilute key instructions.`, + suggestion: 'Split into smaller skills and use the includes system to compose them.', + }); + } + + // Rule 7: Role confusion + for (const pattern of ROLE_CONFUSION_PATTERNS) { + const lineNum = findLine(lines, pattern); + if (lineNum !== undefined) { + issues.push({ + rule: 'role_confusion', + severity: 'warning', + message: `Potential role confusion: "${lines[lineNum].trim()}"`, + suggestion: 'Ensure clear separation between AI role and user role.', + line: lineNum + 1, + }); + break; + } + } + + // Rule 8: Missing examples + const hasExample = /\b(example|for instance|e\.g\.|such as)\b/i.test(body) || hasCodeBlock; + if (!hasExample && tokens > 300) { + issues.push({ + rule: 'missing_examples', + severity: 'suggestion', + message: 'No examples found in a substantial skill.', + suggestion: 'Add concrete examples showing expected input/output to improve accuracy.', + }); + } + + // Rule 9: Redundancy (repeated headings) + const headings = lines.filter((l) => /^##?\s/.test(l)).map((l) => l.toLowerCase().trim()); + const seen = new Set(); + for (const h of headings) { + if (seen.has(h)) { + issues.push({ + rule: 'redundancy', + severity: 'suggestion', + message: `Duplicate heading: "${h}"`, + suggestion: 'Merge duplicate sections to reduce redundancy.', + }); + break; + } + seen.add(h); + } + + // Rule 10: Missing gotchas + if (tokens > 500 && !gotchas && !/## (gotchas|common gotchas|pitfalls|warnings)/i.test(body)) { + issues.push({ + rule: 'missing_gotchas', + severity: 'suggestion', + message: 'Complex skill without gotchas. Gotcha sections are the highest-signal content in any skill.', + suggestion: 'Add common failure points and edge cases to the gotchas field.', + }); + } + + // Rule 11: Vague description + if (description) { + if (description.length < 20 || GENERIC_DESCRIPTION_PATTERNS.some((p) => p.test(description))) { + issues.push({ + rule: 'vague_description', + severity: 'warning', + message: 'Vague skill description may cause poor agent triggering.', + suggestion: 'Write a specific description that tells the agent exactly when this skill applies.', + }); + } + } + + // Rule 11b: Missing skill type + if (!skill_type && tokens > 200) { + issues.push({ + rule: 'missing_skill_type', + severity: 'suggestion', + message: 'No skill type set (capability-uplift or encoded-preference).', + suggestion: 'Set skill_type to help agents understand how to apply this skill.', + }); + } + + return issues; +} + +function findLine(lines: string[], pattern: RegExp): number | undefined { + for (let i = 0; i < lines.length; i++) { + if (pattern.test(lines[i])) return i; + } + return undefined; +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} diff --git a/cli/src/services/SkillCompositionService.test.ts b/cli/src/services/SkillCompositionService.test.ts new file mode 100644 index 0000000..5a168e4 --- /dev/null +++ b/cli/src/services/SkillCompositionService.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { resolve, validateIncludes } from './SkillCompositionService.js'; +import type { ParsedSkill } from '../types.js'; + +function skill(slug: string, body: string, includes: string[] = []): ParsedSkill { + return { + slug, + body, + frontmatter: { id: slug, name: slug, includes }, + }; +} + +describe('resolve', () => { + it('returns body as-is when no includes', () => { + const s = skill('a', 'Body A'); + const result = resolve(s, new Map()); + expect(result).toBe('Body A'); + }); + + it('prepends included skill bodies', () => { + const base = skill('base', 'Base instructions.'); + const main = skill('main', 'Main instructions.', ['base']); + const map = new Map([['base', base], ['main', main]]); + + const result = resolve(main, map); + expect(result).toContain('Base instructions.'); + expect(result).toContain('Main instructions.'); + // Base should come before main + expect(result.indexOf('Base')).toBeLessThan(result.indexOf('Main')); + }); + + it('resolves nested includes', () => { + const a = skill('a', 'A body.'); + const b = skill('b', 'B body.', ['a']); + const c = skill('c', 'C body.', ['b']); + const map = new Map([['a', a], ['b', b], ['c', c]]); + + const result = resolve(c, map); + expect(result).toContain('A body.'); + expect(result).toContain('B body.'); + expect(result).toContain('C body.'); + }); + + it('handles missing includes with comment', () => { + const main = skill('main', 'Main.', ['nonexistent']); + const result = resolve(main, new Map([['main', main]])); + expect(result).toContain(''); + expect(result).toContain('Main.'); + }); + + it('handles circular includes with comment', () => { + const a = skill('a', 'A.', ['b']); + const b = skill('b', 'B.', ['a']); + const map = new Map([['a', a], ['b', b]]); + + const result = resolve(a, map); + expect(result).toContain(''); + expect(result).toContain('B.'); + expect(result).toContain('A.'); + }); + + it('respects max depth limit', () => { + // Create a chain deeper than 5 + const skills: ParsedSkill[] = []; + for (let i = 0; i <= 7; i++) { + const includes = i > 0 ? [`s${i - 1}`] : []; + skills.push(skill(`s${i}`, `Body ${i}.`, includes)); + } + const map = new Map(skills.map((s) => [s.slug, s])); + + const result = resolve(skills[7], map); + expect(result).toContain(''); + }); +}); + +describe('validateIncludes', () => { + it('returns no errors for valid includes', () => { + const base = skill('base', 'Base.'); + const main = skill('main', 'Main.', ['base']); + const map = new Map([['base', base], ['main', main]]); + + expect(validateIncludes(main, map)).toEqual([]); + }); + + it('reports self-include', () => { + const s = skill('self', 'Body.', ['self']); + const map = new Map([['self', s]]); + + const errors = validateIncludes(s, map); + expect(errors).toContain('Skill cannot include itself: self'); + }); + + it('reports missing include', () => { + const s = skill('main', 'Body.', ['ghost']); + const errors = validateIncludes(s, new Map([['main', s]])); + expect(errors).toContain('Included skill not found: ghost'); + }); + + it('detects circular dependency', () => { + const a = skill('a', 'A.', ['b']); + const b = skill('b', 'B.', ['a']); + const map = new Map([['a', a], ['b', b]]); + + const errors = validateIncludes(a, map); + expect(errors.some((e) => e.includes('Circular dependency'))).toBe(true); + }); + + it('returns no errors when no includes', () => { + const s = skill('solo', 'Solo.'); + expect(validateIncludes(s, new Map())).toEqual([]); + }); +}); diff --git a/cli/src/services/SkillCompositionService.ts b/cli/src/services/SkillCompositionService.ts new file mode 100644 index 0000000..fd2e9c1 --- /dev/null +++ b/cli/src/services/SkillCompositionService.ts @@ -0,0 +1,97 @@ +import type { ParsedSkill } from '../types.js'; + +const MAX_DEPTH = 5; + +/** + * Resolve a skill's full body by prepending included skill bodies. + * Uses filesystem-based skill lookup (no database). + */ +export function resolve( + skill: ParsedSkill, + allSkills: Map, + visited: string[] = [], + depth: number = 0, +): string { + if (depth > MAX_DEPTH) { + return `\n\n${skill.body}`; + } + + const includes = skill.frontmatter.includes ?? []; + + if (includes.length === 0) { + return skill.body; + } + + const currentVisited = [...visited, skill.slug]; + const sections: string[] = []; + + for (const slug of includes) { + if (currentVisited.includes(slug)) { + sections.push(``); + continue; + } + + const included = allSkills.get(slug); + + if (!included) { + sections.push(``); + continue; + } + + sections.push(resolve(included, allSkills, currentVisited, depth + 1)); + } + + sections.push(skill.body); + + return sections.filter(Boolean).join('\n\n'); +} + +/** + * Validate includes for a skill and return errors. + */ +export function validateIncludes( + skill: ParsedSkill, + allSkills: Map, +): string[] { + const errors: string[] = []; + const includes = skill.frontmatter.includes ?? []; + + for (const slug of includes) { + if (slug === skill.slug) { + errors.push(`Skill cannot include itself: ${slug}`); + continue; + } + + if (!allSkills.has(slug)) { + errors.push(`Included skill not found: ${slug}`); + } + } + + if (errors.length === 0) { + detectCycles(skill, [skill.slug], allSkills, errors); + } + + return errors; +} + +function detectCycles( + skill: ParsedSkill, + path: string[], + allSkills: Map, + errors: string[], + depth: number = 0, +): void { + if (depth > MAX_DEPTH) return; + + for (const slug of skill.frontmatter.includes ?? []) { + if (path.includes(slug)) { + errors.push(`Circular dependency detected: ${[...path, slug].join(' -> ')}`); + continue; + } + + const included = allSkills.get(slug); + if (included && (included.frontmatter.includes?.length ?? 0) > 0) { + detectCycles(included, [...path, slug], allSkills, errors, depth + 1); + } + } +} diff --git a/cli/src/services/SkillFileParser.test.ts b/cli/src/services/SkillFileParser.test.ts new file mode 100644 index 0000000..8de24a4 --- /dev/null +++ b/cli/src/services/SkillFileParser.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest'; +import { parseContent, renderFile, validateFrontmatter, slugify, parseSkillContent } from './SkillFileParser.js'; + +describe('parseContent', () => { + it('parses YAML frontmatter and markdown body', () => { + const content = `--- +id: test-skill +name: Test Skill +--- + +This is the body.`; + + const result = parseContent(content); + expect(result.frontmatter.id).toBe('test-skill'); + expect(result.frontmatter.name).toBe('Test Skill'); + expect(result.body).toBe('This is the body.'); + }); + + it('returns empty frontmatter when no delimiters', () => { + const content = 'Just a plain body with no frontmatter.'; + const result = parseContent(content); + expect(result.frontmatter).toEqual({}); + expect(result.body).toBe('Just a plain body with no frontmatter.'); + }); + + it('returns empty frontmatter when closing delimiter is missing', () => { + const content = `--- +id: broken +name: Broken + +This never closes.`; + + const result = parseContent(content); + expect(result.frontmatter).toEqual({}); + }); + + it('handles frontmatter with arrays and nested fields', () => { + const content = `--- +id: complex +name: Complex Skill +tags: [a, b, c] +template_variables: + - name: lang + default: English +--- + +Body here.`; + + const result = parseContent(content); + expect(result.frontmatter.tags).toEqual(['a', 'b', 'c']); + expect(result.frontmatter.template_variables).toEqual([ + { name: 'lang', default: 'English' }, + ]); + expect(result.body).toBe('Body here.'); + }); + + it('handles leading whitespace before frontmatter', () => { + const content = ` +--- +id: spaced +name: Spaced +--- + +Body.`; + + const result = parseContent(content); + expect(result.frontmatter.id).toBe('spaced'); + expect(result.body).toBe('Body.'); + }); + + it('handles empty body after frontmatter', () => { + const content = `--- +id: empty-body +name: Empty Body +---`; + + const result = parseContent(content); + expect(result.frontmatter.id).toBe('empty-body'); + expect(result.body).toBe(''); + }); +}); + +describe('renderFile', () => { + it('renders frontmatter and body into a skill file', () => { + const result = renderFile({ id: 'test', name: 'Test' }, 'Body content.'); + expect(result).toContain('---\n'); + expect(result).toContain('id: test'); + expect(result).toContain('name: Test'); + expect(result).toContain('---\n\nBody content.\n'); + }); + + it('roundtrips through parse and render', () => { + const original = { id: 'roundtrip', name: 'Roundtrip Skill', tags: ['a', 'b'] }; + const body = 'This is the instruction body.'; + const rendered = renderFile(original, body); + const parsed = parseContent(rendered); + expect(parsed.frontmatter.id).toBe('roundtrip'); + expect(parsed.frontmatter.name).toBe('Roundtrip Skill'); + expect(parsed.frontmatter.tags).toEqual(['a', 'b']); + expect(parsed.body).toBe(body); + }); +}); + +describe('validateFrontmatter', () => { + it('returns no errors for valid frontmatter', () => { + expect(validateFrontmatter({ id: 'x', name: 'X' })).toEqual([]); + }); + + it('returns error for missing id', () => { + const errors = validateFrontmatter({ name: 'X' } as any); + expect(errors).toContain('Missing required field: id'); + }); + + it('returns error for missing name', () => { + const errors = validateFrontmatter({ id: 'x' } as any); + expect(errors).toContain('Missing required field: name'); + }); + + it('returns both errors when both missing', () => { + const errors = validateFrontmatter({}); + expect(errors).toHaveLength(2); + }); +}); + +describe('slugify', () => { + it('converts to lowercase kebab-case', () => { + expect(slugify('My Skill Name')).toBe('my-skill-name'); + }); + + it('strips special characters', () => { + expect(slugify('Hello, World! (v2)')).toBe('hello-world-v2'); + }); + + it('trims leading and trailing dashes', () => { + expect(slugify('---test---')).toBe('test'); + }); + + it('handles empty string', () => { + expect(slugify('')).toBe(''); + }); + + it('collapses multiple separators', () => { + expect(slugify('a b___c')).toBe('a-b-c'); + }); +}); + +describe('parseSkillContent', () => { + it('uses filename as slug (without .md)', () => { + const content = `--- +id: my-skill +name: My Skill +--- + +Body.`; + + const result = parseSkillContent(content, 'my-skill.md'); + expect(result.slug).toBe('my-skill'); + expect(result.frontmatter.id).toBe('my-skill'); + expect(result.frontmatter.name).toBe('My Skill'); + expect(result.body).toBe('Body.'); + }); + + it('defaults id and name from filename when frontmatter omits them', () => { + const result = parseSkillContent('Just a body.', 'fallback-skill.md'); + expect(result.slug).toBe('fallback-skill'); + expect(result.frontmatter.id).toBe('fallback-skill'); + expect(result.frontmatter.name).toBe('fallback-skill'); + expect(result.body).toBe('Just a body.'); + }); +}); diff --git a/cli/src/services/SkillFileParser.ts b/cli/src/services/SkillFileParser.ts new file mode 100644 index 0000000..11a863a --- /dev/null +++ b/cli/src/services/SkillFileParser.ts @@ -0,0 +1,78 @@ +import yaml from 'js-yaml'; +import type { SkillFrontmatter, ParsedSkill } from '../types.js'; + +/** + * Parse a skill file (YAML frontmatter + Markdown body) into its components. + */ +export function parseContent(content: string): { frontmatter: Partial; body: string } { + const trimmed = content.trimStart(); + + if (!trimmed.startsWith('---')) { + return { frontmatter: {}, body: trimmed.trim() }; + } + + const endPos = trimmed.indexOf('\n---', 3); + + if (endPos === -1) { + return { frontmatter: {}, body: trimmed.trim() }; + } + + const yamlBlock = trimmed.slice(4, endPos); + const body = trimmed.slice(endPos + 4); + + const frontmatter = (yaml.load(yamlBlock.trim()) as Partial) ?? {}; + + return { frontmatter, body: body.trim() }; +} + +/** + * Render a skill file from frontmatter and body. + */ +export function renderFile(frontmatter: Partial, body: string): string { + const yamlStr = yaml.dump(frontmatter, { lineWidth: -1, quotingType: '"', forceQuotes: false }); + return `---\n${yamlStr}---\n\n${body}\n`; +} + +/** + * Validate frontmatter and return an array of errors (empty = valid). + */ +export function validateFrontmatter(data: Partial): string[] { + const errors: string[] = []; + + if (!data.id) { + errors.push('Missing required field: id'); + } + if (!data.name) { + errors.push('Missing required field: name'); + } + + return errors; +} + +/** + * Generate a slug from a name. + */ +export function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Parse a skill file and return a fully-typed ParsedSkill. + */ +export function parseSkillContent(content: string, filename: string): ParsedSkill { + const { frontmatter, body } = parseContent(content); + const slug = filename.replace(/\.md$/, ''); + + return { + frontmatter: { + id: frontmatter.id ?? slug, + name: frontmatter.name ?? slug, + ...frontmatter, + } as SkillFrontmatter, + body, + slug, + }; +} diff --git a/cli/src/services/SyncService.test.ts b/cli/src/services/SyncService.test.ts new file mode 100644 index 0000000..bb81e13 --- /dev/null +++ b/cli/src/services/SyncService.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { scaffoldProject, writeManifest, readManifest } from './ManifestService.js'; +import { renderFile } from './SkillFileParser.js'; +import { resolveSkills, sync, preview } from './SyncService.js'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skillr-sync-')); + await scaffoldProject(tmpDir, 'Sync Test', ['claude', 'cursor']); + + // Write a test skill + const skillContent = renderFile( + { id: 'greet', name: 'Greeting Skill', tags: ['test'] }, + 'Always greet the user warmly.', + ); + await fs.writeFile(path.join(tmpDir, '.skillr', 'skills', 'greet.md'), skillContent); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('resolveSkills', () => { + it('resolves skills from a project', async () => { + const { skills, manifest } = await resolveSkills(tmpDir); + expect(manifest.name).toBe('Sync Test'); + expect(skills).toHaveLength(1); + expect(skills[0].slug).toBe('greet'); + expect(skills[0].body).toBe('Always greet the user warmly.'); + }); + + it('throws when no manifest exists', async () => { + const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skillr-no-manifest-')); + try { + await expect(resolveSkills(emptyDir)).rejects.toThrow('No .skillr/manifest.json found'); + } finally { + await fs.rm(emptyDir, { recursive: true, force: true }); + } + }); + + it('resolves template variables with defaults', async () => { + const skillContent = renderFile( + { + id: 'tmpl', + name: 'Template Skill', + template_variables: [{ name: 'lang', default: 'English' }], + }, + 'Write in {{lang}}.', + ); + await fs.writeFile(path.join(tmpDir, '.skillr', 'skills', 'tmpl.md'), skillContent); + + const { skills } = await resolveSkills(tmpDir); + const tmpl = skills.find((s) => s.slug === 'tmpl'); + expect(tmpl!.body).toBe('Write in English.'); + }); + + it('overrides defaults with provided variables', async () => { + const skillContent = renderFile( + { + id: 'tmpl2', + name: 'Template Skill 2', + template_variables: [{ name: 'lang', default: 'English' }], + }, + 'Write in {{lang}}.', + ); + await fs.writeFile(path.join(tmpDir, '.skillr', 'skills', 'tmpl2.md'), skillContent); + + const { skills } = await resolveSkills(tmpDir, { lang: 'French' }); + const tmpl = skills.find((s) => s.slug === 'tmpl2'); + expect(tmpl!.body).toBe('Write in French.'); + }); +}); + +describe('sync', () => { + it('writes provider files and updates synced_at', async () => { + const results = await sync(tmpDir); + + // Should sync to both claude and cursor + expect(results).toHaveLength(2); + expect(results.map((r) => r.provider)).toEqual(expect.arrayContaining(['claude', 'cursor'])); + + // Claude file should exist + const claudeFile = await fs.readFile(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8'); + expect(claudeFile).toContain('Greeting Skill'); + + // Cursor file should exist + const cursorFile = await fs.readFile( + path.join(tmpDir, '.cursor', 'rules', 'greet.mdc'), + 'utf-8', + ); + expect(cursorFile).toContain('Always greet the user warmly.'); + + // Manifest should have synced_at updated + const manifest = await readManifest(tmpDir); + expect(manifest.synced_at).not.toBeNull(); + expect(manifest.skills).toContain('greet'); + }); + + it('filters to a single provider', async () => { + const results = await sync(tmpDir, {}, 'claude'); + expect(results).toHaveLength(1); + expect(results[0].provider).toBe('claude'); + }); +}); + +describe('preview', () => { + it('shows files as added when they do not exist', async () => { + const results = await preview(tmpDir); + expect(results.length).toBeGreaterThan(0); + expect(results.every((r) => r.status === 'added')).toBe(true); + }); + + it('shows files as unchanged after sync', async () => { + await sync(tmpDir); + const results = await preview(tmpDir); + expect(results.every((r) => r.status === 'unchanged')).toBe(true); + }); + + it('shows files as modified after content change', async () => { + await sync(tmpDir); + + // Modify the skill + const newContent = renderFile( + { id: 'greet', name: 'Greeting Skill', tags: ['test'] }, + 'Greet the user with a joke.', + ); + await fs.writeFile(path.join(tmpDir, '.skillr', 'skills', 'greet.md'), newContent); + + const results = await preview(tmpDir); + const modified = results.filter((r) => r.status === 'modified'); + expect(modified.length).toBeGreaterThan(0); + }); + + it('filters by provider', async () => { + const results = await preview(tmpDir, {}, 'cursor'); + expect(results.every((r) => r.provider === 'cursor')).toBe(true); + }); +}); diff --git a/cli/src/services/SyncService.ts b/cli/src/services/SyncService.ts new file mode 100644 index 0000000..b5ca59b --- /dev/null +++ b/cli/src/services/SyncService.ts @@ -0,0 +1,158 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { scanProject, readManifest, writeManifest } from './ManifestService.js'; +import { resolve as resolveIncludes } from './SkillCompositionService.js'; +import { resolve as resolveTemplates } from './TemplateResolver.js'; +import { getDriver } from '../drivers/index.js'; +import type { ParsedSkill, ResolvedSkill, FileOutput, Manifest } from '../types.js'; + +export interface SyncResult { + provider: string; + files: FileOutput[]; +} + +/** + * Build resolved skills from a project directory. + */ +export async function resolveSkills( + projectPath: string, + variables: Record = {}, +): Promise<{ skills: ResolvedSkill[]; manifest: Manifest }> { + const { manifest, skills } = await scanProject(projectPath); + + if (!manifest) { + throw new Error('No .skillr/manifest.json found. Run `skillr init` first.'); + } + + // Build lookup map + const skillMap = new Map(); + for (const skill of skills) { + skillMap.set(skill.slug, skill); + } + + // Resolve each skill + const resolved: ResolvedSkill[] = []; + for (const skill of skills) { + // Resolve includes + let body = resolveIncludes(skill, skillMap); + + // Build variable values: defaults + overrides + const vars: Record = {}; + for (const def of skill.frontmatter.template_variables ?? []) { + if (def.default != null) { + vars[def.name] = def.default; + } + } + Object.assign(vars, variables); + + // Resolve templates + if (Object.keys(vars).length > 0) { + body = resolveTemplates(body, vars); + } + + resolved.push({ + slug: skill.slug, + name: skill.frontmatter.name, + description: skill.frontmatter.description ?? null, + body, + category: skill.frontmatter.category ?? 'general', + skill_type: skill.frontmatter.skill_type ?? null, + gotchas: skill.frontmatter.gotchas ?? null, + tags: skill.frontmatter.tags ?? [], + conditions: skill.frontmatter.conditions ?? null, + }); + } + + return { skills: resolved, manifest }; +} + +/** + * Sync skills to all enabled providers. + */ +export async function sync( + projectPath: string, + variables: Record = {}, + providerFilter?: string, +): Promise { + const { skills, manifest } = await resolveSkills(projectPath, variables); + const providers = providerFilter ? [providerFilter] : manifest.providers; + const results: SyncResult[] = []; + + for (const providerSlug of providers) { + const driver = getDriver(providerSlug); + const files = driver.generate(skills, projectPath); + + // Write files + for (const file of files) { + await fs.mkdir(path.dirname(file.path), { recursive: true }); + await fs.writeFile(file.path, file.content); + } + + results.push({ provider: providerSlug, files }); + } + + // Update synced_at + manifest.synced_at = new Date().toISOString(); + manifest.skills = skills.map((s) => s.slug); + await writeManifest(projectPath, manifest); + + return results; +} + +/** + * Generate sync preview (dry run) — returns what would change. + */ +export async function preview( + projectPath: string, + variables: Record = {}, + providerFilter?: string, +): Promise> { + const { skills, manifest } = await resolveSkills(projectPath, variables); + const providers = providerFilter ? [providerFilter] : manifest.providers; + const results: Array<{ + path: string; + provider: string; + current: string | null; + proposed: string; + status: 'added' | 'modified' | 'unchanged'; + }> = []; + + for (const providerSlug of providers) { + const driver = getDriver(providerSlug); + const files = driver.generate(skills, projectPath); + + for (const file of files) { + let current: string | null = null; + try { + current = await fs.readFile(file.path, 'utf-8'); + } catch { + // File doesn't exist + } + + let status: 'added' | 'modified' | 'unchanged'; + if (current === null) { + status = 'added'; + } else if (current === file.content) { + status = 'unchanged'; + } else { + status = 'modified'; + } + + results.push({ + path: file.path, + provider: providerSlug, + current, + proposed: file.content, + status, + }); + } + } + + return results; +} diff --git a/cli/src/services/TemplateResolver.test.ts b/cli/src/services/TemplateResolver.test.ts new file mode 100644 index 0000000..f9664fb --- /dev/null +++ b/cli/src/services/TemplateResolver.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { resolve, extractVariables, getMissing } from './TemplateResolver.js'; + +describe('resolve', () => { + it('replaces known variables', () => { + expect(resolve('Hello {{name}}!', { name: 'World' })).toBe('Hello World!'); + }); + + it('replaces multiple occurrences', () => { + expect(resolve('{{x}} and {{x}}', { x: 'A' })).toBe('A and A'); + }); + + it('replaces multiple different variables', () => { + expect(resolve('{{a}} {{b}}', { a: '1', b: '2' })).toBe('1 2'); + }); + + it('leaves unknown variables as-is', () => { + expect(resolve('Hello {{unknown}}!', {})).toBe('Hello {{unknown}}!'); + }); + + it('handles body with no variables', () => { + expect(resolve('No variables here.', { x: 'unused' })).toBe('No variables here.'); + }); + + it('handles empty body', () => { + expect(resolve('', { x: 'val' })).toBe(''); + }); +}); + +describe('extractVariables', () => { + it('finds all unique variable names', () => { + const vars = extractVariables('{{a}} {{b}} {{a}}'); + expect(vars).toContain('a'); + expect(vars).toContain('b'); + expect(vars).toHaveLength(2); + }); + + it('returns empty array when no variables', () => { + expect(extractVariables('no vars')).toEqual([]); + }); + + it('does not match non-word chars inside braces', () => { + expect(extractVariables('{{foo-bar}}')).toEqual([]); + }); +}); + +describe('getMissing', () => { + it('returns variables with no value', () => { + expect(getMissing('{{a}} {{b}}', { a: 'val' })).toEqual(['b']); + }); + + it('returns empty when all provided', () => { + expect(getMissing('{{a}}', { a: 'val' })).toEqual([]); + }); + + it('returns empty when no variables in body', () => { + expect(getMissing('plain text', {})).toEqual([]); + }); +}); diff --git a/cli/src/services/TemplateResolver.ts b/cli/src/services/TemplateResolver.ts new file mode 100644 index 0000000..64a81ba --- /dev/null +++ b/cli/src/services/TemplateResolver.ts @@ -0,0 +1,29 @@ +/** + * Replace {{variable_name}} placeholders with values. + * Unresolved variables are left as-is. + */ +export function resolve(body: string, variables: Record): string { + return body.replace(/\{\{(\w+)\}\}/g, (match, key: string) => { + return variables[key] ?? match; + }); +} + +/** + * Extract all variable names found in the body. + */ +export function extractVariables(body: string): string[] { + const matches = body.matchAll(/\{\{(\w+)\}\}/g); + const names = new Set(); + for (const match of matches) { + names.add(match[1]); + } + return [...names]; +} + +/** + * Return variable names that have no value provided. + */ +export function getMissing(body: string, variables: Record): string[] { + const found = extractVariables(body); + return found.filter((name) => !(name in variables) || variables[name] == null); +} diff --git a/cli/src/types.ts b/cli/src/types.ts new file mode 100644 index 0000000..cc6de55 --- /dev/null +++ b/cli/src/types.ts @@ -0,0 +1,102 @@ +/** + * Skillr Spec v1 — Core type definitions. + */ + +export interface SkillFrontmatter { + id: string; + name: string; + description?: string | null; + category?: string; + skill_type?: 'capability-uplift' | 'encoded-preference' | null; + model?: string | null; + max_tokens?: number | null; + tags?: string[]; + tools?: Record[]; + includes?: string[]; + template_variables?: TemplateVariableDefinition[]; + gotchas?: string | null; + supplementary_files?: SupplementaryFile[]; + conditions?: SkillConditions | null; + created_at?: string; + updated_at?: string; +} + +export interface TemplateVariableDefinition { + name: string; + description?: string; + default?: string; +} + +export interface SupplementaryFile { + path: string; + content: string; +} + +export interface SkillConditions { + file_patterns?: string[]; + path_prefixes?: string[]; +} + +export interface ParsedSkill { + frontmatter: SkillFrontmatter; + body: string; + slug: string; +} + +export interface ResolvedSkill { + slug: string; + name: string; + description: string | null; + body: string; + category: string; + skill_type: string | null; + gotchas: string | null; + tags: string[]; + conditions: SkillConditions | null; +} + +export interface FileOutput { + path: string; + content: string; +} + +export interface Manifest { + spec_version: number; + id: string; + name: string; + description: string; + providers: string[]; + skills: string[]; + created_at: string; + synced_at: string | null; +} + +export interface LintIssue { + rule: string; + severity: 'warning' | 'suggestion'; + message: string; + suggestion: string; + line?: number; +} + +export interface ProviderDriver { + readonly name: string; + readonly slug: string; + generate(skills: ResolvedSkill[], projectPath: string): FileOutput[]; +} + +export const VALID_PROVIDERS = ['claude', 'cursor', 'copilot', 'windsurf', 'cline', 'openai'] as const; +export type ProviderSlug = typeof VALID_PROVIDERS[number]; + +export const CATEGORIES = [ + 'library-api-reference', + 'product-verification', + 'data-analysis', + 'business-automation', + 'scaffolding-templates', + 'code-quality-review', + 'ci-cd-deployment', + 'incident-runbooks', + 'infrastructure-ops', + 'general', +] as const; diff --git a/cli/src/ui.ts b/cli/src/ui.ts new file mode 100644 index 0000000..3a84ca6 --- /dev/null +++ b/cli/src/ui.ts @@ -0,0 +1,60 @@ +const colors = { + reset: '\x1b[0m', + green: '\x1b[32m', + brightGreen: '\x1b[92m', + blue: '\x1b[34m', + yellow: '\x1b[33m', + red: '\x1b[31m', + gray: '\x1b[90m', + magenta: '\x1b[35m', + dim: '\x1b[2m', + bold: '\x1b[1m', +}; + +const states = { + ready: `${colors.green}(• •)${colors.reset}`, + working: `${colors.blue}(• -)${colors.reset}`, + thinking: `${colors.yellow}(• ~)${colors.reset}`, + error: `${colors.red}(x x)${colors.reset}`, + judging: `${colors.gray}(¬ ¬)${colors.reset}`, + success: `${colors.brightGreen}(^ ^)${colors.reset}`, + confused: `${colors.magenta}(° °)${colors.reset}`, +}; + +export { states, colors }; + +export function ready(msg: string): void { + console.log(`${states.ready} ${msg}`); +} + +export function working(msg: string): void { + console.log(`${states.working} ${msg}`); +} + +export function thinking(msg: string): void { + console.log(`${states.thinking} ${msg}`); +} + +export function error(msg: string): void { + console.log(`${states.error} ${colors.red}${msg}${colors.reset}`); +} + +export function judging(msg: string): void { + console.log(`${states.judging} ${msg}`); +} + +export function success(msg: string): void { + console.log(`${states.success} ${colors.brightGreen}${msg}${colors.reset}`); +} + +export function confused(msg: string): void { + console.log(`${states.confused} ${colors.magenta}${msg}${colors.reset}`); +} + +export function info(msg: string): void { + console.log(` ${colors.dim}${msg}${colors.reset}`); +} + +export function blank(): void { + console.log(); +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..c0b9dec --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts new file mode 100644 index 0000000..6ec74ee --- /dev/null +++ b/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7988c09..54343fc 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -76,7 +76,6 @@ export default defineConfig({ text: 'Sharing', items: [ { text: 'Library', link: '/guide/library' }, - { text: 'Marketplace', link: '/guide/marketplace' }, { text: 'Skills.sh Import', link: '/guide/skills-sh' }, { text: 'Bundle Export/Import', link: '/guide/bundles' }, ], @@ -108,11 +107,19 @@ export default defineConfig({ text: 'Account', items: [ { text: 'Authentication', link: '/guide/authentication' }, - { text: 'Billing & Subscriptions', link: '/guide/billing' }, ], }, ], '/reference/': [ + { + text: 'Specification', + items: [ + { text: 'Skill Format Spec v1', link: '/reference/spec-v1' }, + { text: 'Provider Output Contract', link: '/reference/provider-contract' }, + { text: 'Composition Spec', link: '/reference/composition-spec' }, + { text: 'Template Variable Spec', link: '/reference/template-spec' }, + ], + }, { text: 'Reference', items: [ diff --git a/docs/NESTJS_MIGRATION_PLAN.md b/docs/NESTJS_MIGRATION_PLAN.md index 3995bac..988a01b 100644 --- a/docs/NESTJS_MIGRATION_PLAN.md +++ b/docs/NESTJS_MIGRATION_PLAN.md @@ -23,7 +23,6 @@ | Jobs / Queues | BullMQ (Redis-backed) or in-process for desktop | | Events / Listeners | NestJS EventEmitter2 module | | Session Auth | Passport.js with express-session (or JWT for API-first) | -| Cashier (Stripe) | stripe-node SDK directly | | SSE Streaming | NestJS `@Sse()` decorator (built-in) | | File I/O (Storage) | Node fs/promises + path module | | YAML parsing | js-yaml package | @@ -93,24 +92,12 @@ api/ # NestJS backend (replaces Laravel app/) │ │ ├── library.controller.ts │ │ └── library.service.ts │ │ -│ ├── marketplace/ # Marketplace module -│ │ ├── marketplace.module.ts -│ │ ├── marketplace.controller.ts # list, show, publish, install, vote -│ │ └── marketplace.service.ts -│ │ │ ├── webhooks/ # Webhooks module │ │ ├── webhooks.module.ts │ │ ├── webhooks.controller.ts │ │ ├── webhook-dispatcher.service.ts │ │ └── inbound-webhook.controller.ts # GitHub push events │ │ -│ ├── billing/ # Billing module -│ │ ├── billing.module.ts -│ │ ├── billing.controller.ts # subscribe, cancel, resume, usage, invoices -│ │ ├── billing.service.ts -│ │ ├── stripe-webhook.controller.ts -│ │ └── stripe-connect.service.ts # marketplace payouts -│ │ │ ├── repositories/ # Git repository module │ │ ├── repositories.module.ts │ │ ├── repositories.controller.ts # connect, pull, push, branches @@ -229,8 +216,6 @@ model User { authProvider String @default("email") @map("auth_provider") socialMetadata Json? @map("social_metadata") currentOrganizationId Int? @map("current_organization_id") - stripeConnectId String? @map("stripe_connect_id") - stripeConnectOnboarded Boolean @default(false) @map("stripe_connect_onboarded") emailVerifiedAt DateTime? @map("email_verified_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -238,7 +223,6 @@ model User { organizations OrganizationUser[] currentOrg Organization? @relation("CurrentOrg", fields: [currentOrganizationId], references: [id]) usageRecords UsageRecord[] - payouts MarketplacePayout[] @@map("users") } @@ -249,13 +233,6 @@ model Organization { name String slug String @unique description String? - plan String @default("free") // free, pro, teams - trialEndsAt DateTime? @map("trial_ends_at") - subscriptionEndsAt DateTime? @map("subscription_ends_at") - planLimits Json? @map("plan_limits") - stripeId String? @map("stripe_id") - pmType String? @map("pm_type") - pmLastFour String? @map("pm_last_four") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -465,30 +442,6 @@ model LibrarySkill { @@map("library_skills") } -model MarketplaceSkill { - id Int @id @default(autoincrement()) - uuid String @unique @default(uuid()) - name String - slug String @unique - description String? - category String? - tags Json @default("[]") - frontmatter Json @default("{}") - body String @default("") - author String? - source String? - downloads Int @default(0) - upvotes Int @default(0) - downvotes Int @default(0) - version String @default("1.0.0") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - payouts MarketplacePayout[] - - @@map("marketplace_skills") -} - model AppSetting { id Int @id @default(autoincrement()) key String @unique @@ -557,33 +510,6 @@ model ProjectRepository { @@map("project_repositories") } -model Plan { - id Int @id @default(autoincrement()) - slug String @unique - name String - description String? - priceMonthly Int @default(0) @map("price_monthly") - priceYearly Int @default(0) @map("price_yearly") - stripeMonthlyPriceId String? @map("stripe_monthly_price_id") - stripeYearlyPriceId String? @map("stripe_yearly_price_id") - includedTokensMonthly Int @default(0) @map("included_tokens_monthly") - overagePricePer1kTokens Int @default(0) @map("overage_price_per_1k_tokens") - maxProjects Int @default(3) @map("max_projects") - maxSkillsPerProject Int @default(25) @map("max_skills_per_project") - maxProviders Int @default(2) @map("max_providers") - maxMembers Int @default(1) @map("max_members") - marketplacePublish Boolean @default(false) @map("marketplace_publish") - aiGeneration Boolean @default(false) @map("ai_generation") - webhookAccess Boolean @default(false) @map("webhook_access") - bundleExport Boolean @default(false) @map("bundle_export") - repositoryAccess Boolean @default(false) @map("repository_access") - prioritySupport Boolean @default(false) @map("priority_support") - isActive Boolean @default(true) @map("is_active") - sortOrder Int @default(0) @map("sort_order") - - @@map("plans") -} - model OpenClawConfig { id Int @id @default(autoincrement()) projectId Int @unique @map("project_id") @@ -651,23 +577,6 @@ model UsageRecord { @@map("usage_records") } -model MarketplacePayout { - id Int @id @default(autoincrement()) - userId Int @map("user_id") - marketplaceSkillId Int @map("marketplace_skill_id") - stripeTransferId String? @map("stripe_transfer_id") - amount Int - currency String @default("usd") - status String @default("pending") - paidAt DateTime? @map("paid_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - user User @relation(fields: [userId], references: [id]) - skill MarketplaceSkill @relation(fields: [marketplaceSkillId], references: [id]) - - @@map("marketplace_payouts") -} ``` ## Migration Phases @@ -748,12 +657,11 @@ model MarketplacePayout { ### Phase 3: Ecosystem (Week 5-6) -**Goal:** Library, marketplace, agents, bundles, search, webhooks. +**Goal:** Library, agents, bundles, search, webhooks. 18. **Agents module** — compose, toggle, assign skills 19. **Library module** — browse, import -20. **Marketplace module** — publish, install, vote -21. **Search module** — cross-project full-text search +20. **Search module** — cross-project full-text search 22. **Bundles module** — ZIP/JSON export and import 23. **Webhooks module** — CRUD, delivery, HMAC signing, event dispatch 24. **Skills.sh module** — GitHub discovery and import @@ -763,10 +671,9 @@ model MarketplacePayout { ### Phase 4: Platform (Week 7-8) -**Goal:** Billing, repositories, advanced features, desktop app. +**Goal:** Repositories, advanced features, desktop app. -26. **Billing module** — Stripe subscriptions, usage tracking, Connect -27. **Repositories module** — GitHub/GitLab connect, pull, push +26. **Repositories module** — GitHub/GitLab connect, pull, push 28. **MCP servers** — CRUD 29. **A2A agents** — CRUD 30. **OpenClaw config** — CRUD @@ -819,9 +726,6 @@ simple-git # replaces shell git calls openai # OpenAI SDK @google/generative-ai # Gemini SDK -# Billing -stripe # replaces Laravel Cashier - # Queue (hosted mode) @nestjs/bull bullmq # replaces Laravel queues @@ -897,5 +801,4 @@ Filament is PHP-only and cannot migrate. Options: | Data migration | Build a one-time migration script (MariaDB → SQLite/PostgreSQL via Prisma) | | SSE streaming differences | NestJS has first-class `@Sse()` support — test with existing React SSE consumers | | OAuth complexity | Use well-tested Passport strategies; test in parallel before cutover | -| Stripe integration | stripe-node is the official SDK — actually simpler than Cashier | | Scope creep | Phase-based approach; each phase produces a working system | diff --git a/docs/design/GUARDRAILS.md b/docs/design/GUARDRAILS.md index 71fe09f..c50d2bf 100644 --- a/docs/design/GUARDRAILS.md +++ b/docs/design/GUARDRAILS.md @@ -10,14 +10,14 @@ Skills are prompts that get injected into AI coding assistants. Malicious skills - **Prompt injection** — Override the host AI's safety instructions ("ignore all previous rules") - **Exfiltration instructions** — Tell the AI to leak secrets, env vars, or source code to external URLs - **Vulnerability insertion** — Subtly instruct the AI to write insecure code (weak crypto, SQLi, backdoors) -- **Supply chain attacks** — Marketplace skills that look helpful but contain hidden instructions +- **Supply chain via imports** — Library or bundled skills that look helpful but contain hidden instructions - **Malicious tool config** — MCP servers or A2A agents pointing to attacker-controlled endpoints ## Design Principle **Warn loudly, block rarely, log everything.** -Legitimate users get full freedom with clear visibility. Bad actors get caught at the distribution layer (marketplace) where review is justified. Multiple lightweight layers rather than one heavy gate — each layer catches a different class of abuse while staying out of the way for legitimate use. +Legitimate users get full freedom with clear visibility. Multiple lightweight layers rather than one heavy gate — each layer catches a different class of abuse while staying out of the way for legitimate use. ## Layer 1 — Structural Constraints @@ -56,12 +56,12 @@ Fast, cheap, transparent. Runs on every save. ## Layer 3 — LLM-Based Content Review -Nuanced analysis for marketplace publishing. Catches obfuscated attacks that static patterns miss. +Nuanced analysis for imported skills. Catches obfuscated attacks that static patterns miss. ### When it runs -- **On marketplace publish** (required, blocking above a risk threshold) - **On-demand scan** triggered by user ("Scan this skill for risks") +- **On library/bundle import** (optional, configurable) - **NOT on every save** — too expensive and too slow for the editing loop ### How it works @@ -72,7 +72,6 @@ Nuanced analysis for marketplace publishing. Catches obfuscated attacks that sta - Overall risk score (0-100) - Per-category findings with reasoning - Specific passages flagged with explanations -4. Scores above threshold require human review before marketplace listing goes live ### Classifier prompt design @@ -80,37 +79,9 @@ Nuanced analysis for marketplace publishing. Catches obfuscated attacks that sta - Tested against a corpus of known-malicious and known-benign skills to calibrate thresholds - The classifier model should be different from the model being tested (defense in depth) -**Implementation:** New `SkillGuardrailService` with `analyze(Skill $skill): GuardrailReport`. Called from `MarketplaceController::publish` and exposed via `POST /api/skills/{id}/scan`. +**Implementation:** New `SkillGuardrailService` with `analyze(Skill $skill): GuardrailReport`. Exposed via `POST /api/skills/{id}/scan`. -## Layer 4 — Marketplace Trust & Transparency - -Community-driven defense layer for the distribution surface. - -### Transparency report - -Every published marketplace skill displays: -- What static patterns were detected (if any, with dismissal reasons) -- What tools, URLs, and external services are referenced -- What permissions/capabilities it implies (file access, network, shell) -- Diff view on updates — users see exactly what changed before auto-updating - -### Trust signals - -- Verified publisher badges (linked to Stripe Connect identity) -- Install count and community rating -- Community flagging with review queue -- Publisher history: how many skills published, average ratings, any prior removals - -### Marketplace moderation - -- Skills above LLM risk threshold are held for manual review -- Flagged skills are temporarily unlisted pending review -- Repeat offenders get publishing privileges revoked -- All moderation actions logged in audit trail - -**Implementation:** Add `risk_score`, `review_status`, `transparency_report` columns to `marketplace_skills`. New `MarketplaceModerationService` for review queue management. - -## Layer 5 — Runtime Guardrails +## Layer 4 — Runtime Guardrails Defense in depth for the live test runner and playground. @@ -127,7 +98,6 @@ These are explicit anti-patterns to avoid: - **Don't blacklist keywords.** Blocking "hack", "exploit", "vulnerability" kills every security-focused skill. A pentesting skill is legitimate. - **Don't restrict prompt content structurally.** Skills need to say anything — that's the entire value proposition. Guardrails live around the skill, not inside it. -- **Don't require approval for local/private skills.** Only gate marketplace publishing. Your own skills on your own machine are your business. - **Don't break composability.** Skills that `include:` other skills shouldn't lose their ability to compose just because one layer flagged something. - **Don't create a false sense of security.** No guardrail system is perfect. Transparency and auditability matter more than prevention theater. @@ -136,7 +106,7 @@ These are explicit anti-patterns to avoid: | Priority | Layer | Effort | Impact | |---|---|---|---| | 1 | Static pattern scanner (extend `PromptLinter`) | Low | Catches obvious attacks immediately | -| 2 | Marketplace publish gate (LLM review) | Medium | Secures the highest-risk vector | +| 2 | LLM review on import | Medium | Catches obfuscated attacks in imported skills | | 3 | Transparency UI (skill report card) | Medium | Informed users are the best guardrail | | 4 | Audit log | Medium | Essential for incident response | | 5 | MCP/A2A endpoint validation | Low | Prevents malicious tool configs | @@ -148,7 +118,7 @@ These are explicit anti-patterns to avoid: skill_guardrail_reports ├── id ├── skill_id (FK) -├── triggered_by (enum: save, publish, manual_scan) +├── triggered_by (enum: save, import, manual_scan) ├── static_warnings (JSON: [{pattern, category, severity, passage, dismissed}]) ├── llm_risk_score (int 0-100, nullable) ├── llm_findings (JSON, nullable) @@ -158,16 +128,11 @@ skill_guardrail_reports ├── created_at └── updated_at -marketplace_skills (additions) -├── risk_score (int, nullable) -├── review_status (enum: pending, approved, rejected, flagged) -└── transparency_report (JSON) - audit_log ├── id ├── organization_id (FK) ├── user_id (FK) -├── action (string: skill.created, skill.published, guardrail.dismissed, etc.) +├── action (string: skill.created, skill.imported, guardrail.dismissed, etc.) ├── auditable_type (morph) ├── auditable_id (morph) ├── metadata (JSON) diff --git a/docs/design/OPEN-SOURCE-STRATEGY.md b/docs/design/OPEN-SOURCE-STRATEGY.md deleted file mode 100644 index 7cc7e57..0000000 --- a/docs/design/OPEN-SOURCE-STRATEGY.md +++ /dev/null @@ -1,209 +0,0 @@ -# Open Source & Free Tier Strategy - -> Design doc for Skillr's open source / free tier positioning. -> Created: 2026-03-12 - -## Decision - -**Closed-source product with a generous free tier for open source projects.** - -Not open-core. Not fully open source. A unified codebase with a free plan gated on project license detection. - -## Why Not Open-Core - -| Concern | Detail | -|---|---| -| Codebase fragmentation | Maintaining OSS + proprietary boundary is constant overhead | -| Feature gating complexity | Every feature decision becomes "is this free or paid?" at the code level | -| Community expectations | OSS communities expect governance, RFC processes, contributor docs — real cost | -| Marketplace risk | An open-core marketplace invites forks that strip out fees | -| Revenue dilution | Self-hosted OSS users never convert — they chose OSS to avoid paying | -| Speed | Solo/small team moves faster with one codebase, one build, one deploy target | - -Open-core works for infrastructure (databases, observability) where enterprises need on-prem. Skillr is a developer tool — cloud-first is natural. - -## Why Free-for-Open-Source - -### Strategic benefits - -1. **Credibility without complexity** — Supporting OSS earns goodwill without maintaining a fork -2. **Proven playbook** — GitHub, JetBrains, GitKraken, Snyk, Sentry, Linear all do this -3. **Network effects** — OSS devs write about tools, recommend them in READMEs, create tutorials -4. **Pipeline to paid** — Devs use free tier on side projects, bring Skillr to their company -5. **Marketplace seeding** — OSS users create and share skills, building marketplace inventory -6. **Low cost to serve** — OSS projects are typically smaller, lower usage - -### What the OSS community actually wants - -They don't want to self-host your SaaS. They want: -- Free access for their non-commercial work -- Assurance you won't rug-pull the free tier -- Transparency about what's free vs paid -- A way to contribute back (marketplace skills, not core code) - -## Plan Architecture - -### Tier: Free (Open Source) - -**Eligibility:** Project has an OSI-approved license detected in `LICENSE` or `LICENSE.md` at the repo root, OR the project is hosted on a public GitHub/GitLab repository. - -| Feature | Limit | -|---|---| -| Projects | Up to 5 | -| Skills per project | Up to 25 | -| Provider sync | All 7 providers | -| Skill versions | Last 10 per skill | -| Marketplace | Browse + install free skills | -| Marketplace publishing | Yes (free skills only) | -| Test runner | 50 runs/day | -| Team members | 1 (solo) | -| MCP server configs | Up to 3 per project | -| A2A agent configs | Up to 3 per project | -| Agent Compose | Up to 3 workflows | -| CLI | Full access | -| Skill bundles | Import only (can't create) | - -### Tier: Pro ($12/month) - -For individual developers on private/commercial projects. - -| Feature | Limit | -|---|---| -| Projects | Unlimited | -| Skills per project | Unlimited | -| Provider sync | All 7 providers | -| Skill versions | Unlimited history | -| Marketplace | Full access (free + paid skills) | -| Marketplace publishing | Yes (free + paid skills, 85/15 rev share) | -| Test runner | Unlimited (fair use) | -| Team members | 1 | -| MCP / A2A configs | Unlimited | -| Agent Compose | Unlimited workflows | -| CLI | Full access | -| Skill bundles | Create + share | -| Priority support | Email | - -### Tier: Team ($29/seat/month) - -For teams and organizations. - -| Feature | Limit | -|---|---| -| Everything in Pro | Yes | -| Team members | Unlimited | -| Shared skill library | Cross-project, org-wide | -| Role-based access | Admin, Editor, Viewer | -| SSO / SAML | Yes | -| Audit log | Full history | -| Org-wide provider config | Centralized API keys & defaults | -| Usage analytics | Per-member, per-project | -| Invoice billing | Yes | - -## OSS License Detection - -### How it works - -1. On project creation/scan, read `LICENSE`, `LICENSE.md`, `LICENSE.txt`, or `COPYING` from project root -2. Match content against SPDX license list using simple header detection -3. Alternatively, check `license` field in `package.json`, `composer.json`, `Cargo.toml`, `pyproject.toml` -4. For GitHub-hosted projects, query the GitHub API license endpoint as a fallback -5. Cache the result on the `projects` table; re-check on each scan - -### Supported OSI licenses (non-exhaustive) - -MIT, Apache-2.0, GPL-2.0, GPL-3.0, LGPL-2.1, LGPL-3.0, BSD-2-Clause, BSD-3-Clause, MPL-2.0, ISC, AGPL-3.0, Unlicense, CC0-1.0, Artistic-2.0, Zlib, BSL-1.0 - -### Edge cases - -- **Dual-licensed projects**: If any license is OSI-approved, qualifies as free -- **No license file**: Does not qualify (public repo without a license is not OSS) -- **Custom/proprietary license**: Does not qualify -- **License changes**: Re-evaluated on each project scan; if license removed, grace period of 30 days before downgrade - -## Anti-Abuse Measures - -- Rate limit project creation on free tier (5 projects max, not 5 at a time) -- License file must be present in the actual project directory (not faked in a wrapper repo) -- Periodic re-validation of OSS status on active projects -- Free tier users who publish paid marketplace skills get flagged for review -- GitHub API cross-reference: if project claims OSS but repo is private, doesn't qualify - -## Migration Path - -Users can upgrade/downgrade freely: - -- **Free → Pro**: Instant, all data preserved, limits lifted -- **Pro → Free**: Graceful — excess projects/skills become read-only (not deleted), user chooses which to keep active within free limits -- **Pro → Team**: Instant, invite team members -- **Team → Pro**: Team members lose access, data preserved under the owner - -## What We Communicate - -### Messaging to OSS community - -> "Skillr is free for open source. If your project has an OSI-approved license, you get full access to build, test, and sync AI skills — no credit card, no trial expiry. We believe the tools that shape AI-assisted development should be accessible to everyone building in the open." - -### Messaging to commercial users - -> "For teams and commercial projects, Skillr Pro gives you unlimited skills, full version history, and marketplace access starting at $12/month." - -### What we DON'T say - -- "Open source" when referring to Skillr itself (it's not) -- "Free forever" (free tier terms can evolve, but OSS commitment is durable) -- "Community edition" (implies an open-core split we don't have) - -## Comparison to Alternatives - -| Approach | Example | Pros | Cons | -|---|---|---|---| -| **Free-for-OSS** (ours) | GitHub, JetBrains, Sentry | Simple, proven, earns goodwill | No community code contributions | -| Open-core | GitLab, Supabase | Community contributions, self-host option | Codebase split, governance overhead | -| Fully open source | VS Code, Zed | Maximum trust and adoption | Hard to monetize, forks compete | -| Freemium (no OSS angle) | Most SaaS | Simple business model | No developer community credibility | -| Source-available | Elastic, HashiCorp | Transparency without full OSS | Community backlash, license confusion | - -## Implementation Notes - -### Database changes - -```sql --- Add to projects table -ALTER TABLE projects ADD COLUMN detected_license VARCHAR(50) NULL; -ALTER TABLE projects ADD COLUMN is_oss BOOLEAN DEFAULT FALSE; -ALTER TABLE projects ADD COLUMN license_checked_at TIMESTAMP NULL; - --- Add to organizations/users (depending on auth model) -ALTER TABLE users ADD COLUMN plan ENUM('free', 'pro', 'team') DEFAULT 'free'; -ALTER TABLE users ADD COLUMN plan_expires_at TIMESTAMP NULL; -``` - -### Service changes - -- New `LicenseDetectionService` — reads license files, matches against SPDX list -- Extend `ProjectScanJob` to run license detection on each scan -- New `PlanEnforcementMiddleware` — checks limits on API endpoints -- Extend Stripe integration to manage Pro/Team subscriptions - -### UI changes - -- Plan badge in Filament sidebar and React SPA header -- Upgrade prompts when hitting limits (non-blocking, informational) -- Project settings show detected license and OSS status -- Billing page in Filament settings - -## Revenue Projections (Conservative) - -Assumes marketplace + subscriptions as dual revenue streams: - -| Metric | Year 1 | Year 2 | -|---|---|---| -| Free (OSS) users | 2,000 | 8,000 | -| Pro subscribers | 200 | 1,000 | -| Team seats | 50 | 300 | -| Pro MRR | $2,400 | $12,000 | -| Team MRR | $1,450 | $8,700 | -| Marketplace MRR | $500 | $5,000 | -| **Total MRR** | **$4,350** | **$25,700** | - -The free OSS tier is a marketing channel. Its ROI is measured in awareness, marketplace content, and conversion to Pro — not direct revenue. diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index de7ba44..6d16bbc 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -43,7 +43,7 @@ Skillr supports multi-tenant organizations. Each user can belong to multiple org | Role | Permissions | |---|---| -| **Owner** | Full access, can delete organization, manage billing | +| **Owner** | Full access, can delete organization, manage members | | **Admin** | Manage members, projects, and settings | | **Editor** | Create and edit skills, run syncs | | **Viewer** | Read-only access to projects and skills | diff --git a/docs/guide/billing.md b/docs/guide/billing.md deleted file mode 100644 index 7f4812f..0000000 --- a/docs/guide/billing.md +++ /dev/null @@ -1,84 +0,0 @@ -# Billing & Subscriptions - -Skillr offers tiered pricing plans with Stripe-powered billing. - -## Plans - -| Feature | Free | Pro | Teams | -|---|---|---|---| -| Projects | 5 | Unlimited | Unlimited | -| Skills per project | 25 | Unlimited | Unlimited | -| Provider sync | All 6 providers | All 6 providers | All 6 providers | -| Test runs | Limited | Unlimited | Unlimited | -| Version history | 10 versions | Unlimited | Unlimited | -| Marketplace publish | -- | Yes | Yes | -| Shared library | -- | -- | Yes | -| Role-based access | -- | -- | Yes | -| SSO/SAML | -- | -- | Yes | -| Audit log | -- | -- | Yes | - -### Viewing Plans - -Plans are listed on the billing page and are also available publicly: - -``` -GET /api/billing/plans -``` - -## Subscribing - -Navigate to **Settings > Billing** and select a plan. Skillr uses Stripe Checkout for payment processing. - -### Changing Plans - -Upgrade or downgrade at any time. Changes take effect immediately -- Stripe prorates the charge. - -### Canceling - -Cancel your subscription from the billing page. You retain access to paid features until the end of the current billing period. You can resume a canceled subscription before it expires. - -## Usage Tracking - -The billing page shows your current usage: - -- **Token usage** -- Tokens consumed by test runner and playground -- **Sync operations** -- Number of provider syncs performed -- **API calls** -- Total API requests - -Usage resets monthly. Pro and Teams plans include higher or unlimited quotas. - -## Payment Methods - -Add or update your payment method from the billing page. Skillr supports credit cards via Stripe. - -## Invoices - -View and download past invoices from the billing page. - -## Marketplace Earnings (Stripe Connect) - -If you publish skills to the marketplace, you can connect a Stripe account to receive earnings from paid skill installations. - -1. Click **Connect Stripe** on the billing page -2. Complete the Stripe Connect onboarding -3. View earnings and payout status - -## API - -``` -GET /api/billing/status # Current subscription -GET /api/billing/plans # Available plans (public) -POST /api/billing/subscribe # Subscribe to plan -POST /api/billing/change-plan # Switch plans -POST /api/billing/cancel # Cancel subscription -POST /api/billing/resume # Resume canceled subscription -POST /api/billing/setup-intent # Create Stripe setup intent -PUT /api/billing/payment-method # Update payment method -GET /api/billing/invoices # Invoice history -GET /api/billing/usage # Usage breakdown - -# Marketplace sellers -POST /api/billing/connect # Setup Stripe Connect -GET /api/billing/connect/status # Connect account status -GET /api/billing/earnings # Earnings & payouts -``` diff --git a/docs/guide/library.md b/docs/guide/library.md index 0ff185a..796ca93 100644 --- a/docs/guide/library.md +++ b/docs/guide/library.md @@ -41,10 +41,6 @@ The import process: If the target project already has a skill with the same slug, the import appends a numeric suffix. For example, importing `pest-test-writer` into a project that already has that slug creates `pest-test-writer-1`. -## Library vs. Marketplace - -The library is a local, pre-seeded collection that ships with every Skillr installation. The [Marketplace](./marketplace) is a self-hosted discovery platform for publishing and installing community skills. Library skills are always available offline; marketplace skills require network access to browse and install. - ## Managing Library Skills Library skills are managed through the Filament Admin panel under **Library Skills**. You can: diff --git a/docs/guide/marketplace.md b/docs/guide/marketplace.md deleted file mode 100644 index bb4595a..0000000 --- a/docs/guide/marketplace.md +++ /dev/null @@ -1,74 +0,0 @@ -# Marketplace - -The marketplace is a self-hosted platform for publishing, discovering, and installing community skills. It runs within your Skillr instance and stores everything in the local database. - -## Browsing - -Navigate to **Marketplace** from the sidebar. The page shows: - -- A **category sidebar** for filtering -- A **search bar** with FULLTEXT search across names and descriptions -- **Sort options** -- most popular, newest, highest rated -- **Pagination** for large result sets - -Each skill card displays the name, description, author, download count, and average rating. - -## Publishing a Skill - -To publish a skill from one of your projects to the marketplace: - -1. Open the skill in the Skill Editor -2. The publish flow creates a marketplace entry with: - - Skill name, description, and body - - Category and tags - - Author information - - A snapshot of the current version - -Published skills are visible to anyone with access to your Skillr instance. - -### API - -``` -POST /api/marketplace/publish -``` - -Request body includes the skill ID, category, and optional description override. - -## Installing a Marketplace Skill - -Click **Install** on a marketplace skill card: - -1. Select the target project -2. Confirm the install - -The skill is imported into your project just like a [library import](./library) -- a new skill is created, the file is written to disk, tags are synced, and slug collisions are handled with numeric suffixes. - -Each install increments the skill's download counter. - -``` -POST /api/marketplace/{id}/install -``` - -## Voting - -Rate marketplace skills with upvote or downvote buttons on the skill card. Votes affect the skill's average rating and influence sort order in "highest rated" view. - -``` -POST /api/marketplace/{id}/vote -``` - -Request body: `{ "vote": 1 }` (upvote) or `{ "vote": -1 }` (downvote). - -## Viewing Details - -Click a marketplace skill to see its full details: - -``` -GET /api/marketplace/{id} -``` - -The detail view shows the complete skill body, all metadata, download count, rating, and publication date. - -::: info -The marketplace is self-hosted. If you run multiple Skillr instances, each has its own independent marketplace. There is no central registry. -::: diff --git a/docs/index.md b/docs/index.md index 70930e2..8b327fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,6 +30,6 @@ features: details: Preview exactly what will change in each provider's config files with side-by-side diffs before writing anything to disk. icon: 🔍 - title: Share & Discover - details: Export skill bundles, browse the built-in marketplace, and automate workflows with webhook integrations. + details: Export skill bundles, import from GitHub, browse the built-in library, and automate workflows with webhook integrations. icon: 🌐 --- diff --git a/docs/reference/api.md b/docs/reference/api.md index 177adef..6580fb4 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -410,62 +410,6 @@ POST /api/skills-sh/import --- -## Marketplace - -### Browse Marketplace - -``` -GET /api/marketplace?category=Laravel&q=testing&sort=popular&page=1 -``` - -### Get Marketplace Skill - -``` -GET /api/marketplace/{id} -``` - -### Publish to Marketplace - -``` -POST /api/marketplace/publish -``` - -```json -{ - "skill_id": "uuid", - "category": "Laravel", - "description": "Optional override" -} -``` - -### Install from Marketplace - -``` -POST /api/marketplace/{id}/install -``` - -```json -{ - "project_id": "target-project-uuid" -} -``` - -### Vote - -``` -POST /api/marketplace/{id}/vote -``` - -```json -{ - "vote": 1 -} -``` - -`1` for upvote, `-1` for downvote. - ---- - ## Agents ### List All Agents @@ -1000,80 +944,3 @@ POST /api/auth/logout GET /api/auth/me ``` ---- - -## Billing - -### Subscription Status - -``` -GET /api/billing/status -``` - -### Available Plans - -``` -GET /api/billing/plans -``` - -### Subscribe - -``` -POST /api/billing/subscribe -``` - -```json -{ - "plan": "pro" -} -``` - -### Change Plan - -``` -POST /api/billing/change-plan -``` - -### Cancel Subscription - -``` -POST /api/billing/cancel -``` - -### Resume Subscription - -``` -POST /api/billing/resume -``` - -### Setup Payment Intent - -``` -POST /api/billing/setup-intent -``` - -### Update Payment Method - -``` -PUT /api/billing/payment-method -``` - -### Invoice History - -``` -GET /api/billing/invoices -``` - -### Usage Breakdown - -``` -GET /api/billing/usage -``` - -### Stripe Connect (Marketplace Sellers) - -``` -POST /api/billing/connect -GET /api/billing/connect/status -GET /api/billing/earnings -``` diff --git a/docs/reference/composition-spec.md b/docs/reference/composition-spec.md new file mode 100644 index 0000000..500cc5d --- /dev/null +++ b/docs/reference/composition-spec.md @@ -0,0 +1,126 @@ +# Composition & Include Resolution Specification + +> **Status:** Stable +> **Spec Version:** 1 +> **Last Updated:** 2026-03-20 + +This document specifies how skill includes are resolved during composition. + +## 1. Include Syntax + +Skills declare dependencies via the `includes` frontmatter field: + +```yaml +includes: [base-instructions, coding-standards] +``` + +Each entry is a slug referencing another skill in the same project. + +## 2. Resolution Algorithm + +When a skill is resolved, its includes are processed **in array order**: + +``` +resolve(skill, visited=[], depth=0): + 1. If depth > MAX_DEPTH (5): + Return "\n\n" + skill.body + 2. If skill.includes is empty: + Return skill.body + 3. Add skill.slug to visited + 4. For each slug in skill.includes: + a. If slug is in visited: + Append "" + Continue to next + b. Look up skill by slug in the same project + c. If not found: + Append "" + Continue to next + d. Recursively resolve the included skill: + Append resolve(included_skill, visited, depth + 1) + 5. Append skill.body + 6. Join all sections with "\n\n", filtering empty strings + 7. Return joined result +``` + +## 3. Resolution Order + +Included content is **prepended** before the skill's own body. Given: + +``` +skill A includes [B, C] +``` + +The resolved output is: + +``` +{resolved body of B} + +{resolved body of C} + +{body of A} +``` + +This means base/shared instructions come first, and the skill's specific instructions come last — allowing the skill to override or specialize shared rules. + +## 4. Max Depth + +Implementations MUST enforce a maximum include depth of **5 levels**. When the depth limit is exceeded, implementations MUST: + +1. Stop recursing. +2. Include the current skill's body without further resolution. +3. Insert a comment: ``. + +## 5. Circular Dependency Detection + +Circular dependencies occur when skill A includes B, which includes C, which includes A. + +Implementations MUST detect circular dependencies by tracking visited slugs during resolution. When a circular dependency is detected: + +1. The circular include MUST be skipped. +2. A comment MUST be inserted: ``. +3. Resolution MUST continue with remaining includes (do not abort). + +### 5.1 Validation + +Implementations SHOULD provide a validation function that detects cycles before resolution: + +``` +detectCycles(skill, path=[skill.slug], errors=[]): + For each slug in skill.includes: + If slug is in path: + errors.push("Circular dependency: " + path.join(" -> ") + " -> " + slug) + Continue + Look up included skill + If found and included.includes is non-empty: + detectCycles(included, [...path, slug], errors) +``` + +## 6. Missing Includes + +When a referenced slug does not exist in the project: + +1. The missing include MUST be skipped. +2. A comment MUST be inserted: ``. +3. Implementations SHOULD emit a warning to stderr or the lint output. +4. Resolution MUST continue with remaining includes. + +## 7. Self-Include + +A skill MUST NOT include itself. If `skill.includes` contains the skill's own slug: + +1. Implementations MUST treat it as a circular dependency. +2. The self-include MUST be skipped with the circular include comment. + +## 8. Diamond Dependencies + +If skill A includes B and C, and both B and C include D, then D's body appears twice in the resolved output. This is the expected behavior — implementations MUST NOT deduplicate included content. + +Rationale: Deduplication would require tracking content identity across the resolution tree, adding complexity without clear benefit. Skill authors should structure includes to avoid unintended duplication. + +## 9. Cross-Project Includes + +Includes MUST be resolved within the same project only. Cross-project references are NOT supported. An include slug that does not match any skill in the current project MUST be treated as a missing include (Section 6). + +## 10. Include Resolution Timing + +Include resolution happens at **sync time** and **test time**, not at edit time. The raw `includes` array is stored in frontmatter and resolved on demand. diff --git a/docs/reference/provider-contract.md b/docs/reference/provider-contract.md new file mode 100644 index 0000000..f2030ea --- /dev/null +++ b/docs/reference/provider-contract.md @@ -0,0 +1,207 @@ +# Provider Output Contract + +> **Status:** Stable +> **Spec Version:** 1 +> **Last Updated:** 2026-03-20 + +This document specifies the contract that every provider driver MUST implement. A provider driver transforms resolved skills into the native configuration format for a specific AI tool. + +## 1. Interface + +Every provider driver MUST implement the following interface: + +```typescript +interface ProviderDriver { + /** + * Provider metadata. + */ + readonly name: string; // Human-readable name (e.g., "Claude") + readonly slug: string; // Machine identifier (e.g., "claude") + + /** + * Generate output files from resolved skills. + * + * MUST be a pure function — no side effects, no file I/O, no network calls. + * All file writing is handled by the sync orchestrator. + * + * @param skills - Array of resolved skills (includes expanded, templates substituted) + * @param projectPath - Absolute path to the project root + * @returns Array of file outputs to write + */ + generate(skills: ResolvedSkill[], projectPath: string): FileOutput[]; +} +``` + +## 2. Input: ResolvedSkill + +Each skill passed to `generate()` has already been through include resolution and template variable substitution. The driver receives the final content ready for output. + +```typescript +interface ResolvedSkill { + slug: string; + name: string; + description: string | null; + body: string; // Resolved body (includes expanded, templates substituted) + category: string; + skill_type: string | null; + gotchas: string | null; + tags: string[]; + conditions: { + file_patterns?: string[]; + path_prefixes?: string[]; + } | null; +} +``` + +Drivers MUST NOT perform include resolution or template substitution — that is the orchestrator's responsibility. + +## 3. Output: FileOutput + +```typescript +interface FileOutput { + /** Absolute path where the file should be written. */ + path: string; + /** File content as a UTF-8 string. */ + content: string; +} +``` + +The sync orchestrator handles all file I/O: creating directories, writing files, and cleaning up stale files. Drivers MUST only return the desired file state. + +## 4. Built-in Provider Specifications + +### 4.1 Claude + +| Property | Value | +|---|---| +| Slug | `claude` | +| Output | `.claude/CLAUDE.md` | +| Format | Single file. Each skill as an H2 heading followed by its body. | + +**Output structure:** + +```markdown +# CLAUDE.md + +## {skill.name} + +{skill.body} + +--- + +## {skill.name} + +{skill.body} + +--- +``` + +Skills with `conditions.file_patterns` SHOULD include an "Applies to" note: + +```markdown +> **Applies to:** `*.py, tests/**` +``` + +Skills with gotchas SHOULD append a `### Common Gotchas` subsection. + +### 4.2 Cursor + +| Property | Value | +|---|---| +| Slug | `cursor` | +| Output | `.cursor/rules/{slug}.mdc` (one file per skill) | +| Format | MDC format with YAML frontmatter. | + +**Output structure per file:** + +```markdown +--- +description: {skill.description} +alwaysApply: true +globs: ["*.py"] # only if conditions.file_patterns is set +--- + +{skill.body} +``` + +- `alwaysApply` MUST be `true` when the skill has no conditions, `false` otherwise. +- `globs` MUST be set only when `conditions.file_patterns` is non-empty. +- Tags SHOULD be included in frontmatter if present. + +### 4.3 GitHub Copilot + +| Property | Value | +|---|---| +| Slug | `copilot` | +| Output | `.github/copilot-instructions.md` | +| Format | Single file. Each skill as an H2 heading. Same structure as Claude. | + +### 4.4 Windsurf + +| Property | Value | +|---|---| +| Slug | `windsurf` | +| Output | `.windsurf/rules/{slug}.md` (one file per skill) | +| Format | Plain Markdown, one file per skill. | + +**Output structure per file:** + +```markdown +# {skill.name} + +{skill.body} +``` + +### 4.5 Cline + +| Property | Value | +|---|---| +| Slug | `cline` | +| Output | `.clinerules` | +| Format | Single flat file. Each skill as an H2 heading. | + +**Output structure:** + +```markdown +## {skill.name} + +{skill.body} + +--- + +## {skill.name} + +{skill.body} +``` + +### 4.6 OpenAI + +| Property | Value | +|---|---| +| Slug | `openai` | +| Output | `.openai/instructions.md` | +| Format | Single file. Each skill as an H2 heading. Same structure as Claude. | + +## 5. Stale File Cleanup + +When syncing, the orchestrator MUST: + +1. Call `generate()` to get the proposed file list. +2. Compare against existing provider files on disk. +3. Delete any existing provider files that are NOT in the proposed list (stale files from renamed/deleted skills). + +For multi-file providers (Cursor, Windsurf), this means removing `.mdc` or `.md` files that no longer correspond to a skill. + +For single-file providers (Claude, Copilot, Cline, OpenAI), the file is simply overwritten. + +## 6. Provider Registration + +Built-in providers are registered automatically. Custom providers MAY be registered via `manifest.json`: + +```json +{ + "providers": ["claude", "cursor", "./providers/my-custom-provider.js"] +} +``` + +Paths starting with `./` are resolved relative to the `.skillr/` directory. Custom providers MUST export an object conforming to the `ProviderDriver` interface. diff --git a/docs/reference/spec-v1.md b/docs/reference/spec-v1.md new file mode 100644 index 0000000..1fdb15f --- /dev/null +++ b/docs/reference/spec-v1.md @@ -0,0 +1,225 @@ +
+ +# Skill Format Specification v1 + +> **Status:** Stable +> **Spec Version:** 1 +> **Last Updated:** 2026-03-20 + +This document is the formal specification for the `.skillr/` skill format. Implementations MUST conform to this spec to ensure interoperability across the Skillr CLI, web UI, and third-party tools. + +The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +## 1. Directory Structure + +A Skillr-managed project MUST contain a `.skillr/` directory at the project root with the following structure: + +``` +project-root/ + .skillr/ + manifest.json + skills/ + simple-skill.md # flat format + complex-skill/ # folder format + skill.md + gotchas.md + examples/ + good-output.md +``` + +### 1.1 Manifest + +The file `.skillr/manifest.json` MUST exist and MUST be valid JSON conforming to this schema: + +```json +{ + "spec_version": 1, + "id": "uuid-string", + "name": "project-name", + "description": "", + "providers": ["claude", "cursor", "copilot", "windsurf", "cline", "openai"], + "skills": ["skill-slug-1", "skill-slug-2"], + "created_at": "2026-03-20T10:00:00Z", + "synced_at": "2026-03-20T10:05:00Z" +} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `spec_version` | integer | MUST | Format version. MUST be `1` for this spec. | +| `id` | string (UUID) | MUST | Unique identifier for the project. | +| `name` | string | MUST | Human-readable project name. | +| `description` | string | SHOULD | Short project description. | +| `providers` | string[] | MUST | Provider slugs to sync to. Valid values: `claude`, `cursor`, `copilot`, `windsurf`, `cline`, `openai`. | +| `skills` | string[] | SHOULD | Array of skill slugs present in `skills/`. | +| `created_at` | string (ISO 8601) | SHOULD | Creation timestamp. | +| `synced_at` | string (ISO 8601) \| null | MAY | Last sync timestamp. `null` if never synced. | + +### 1.2 Skills Directory + +The `skills/` directory contains skill definitions in one of two formats: + +- **Flat format:** `{slug}.md` — a single Markdown file with YAML frontmatter. +- **Folder format:** `{slug}/skill.md` — a directory containing a main skill file plus optional supplementary files. + +Implementations MUST support both formats. The folder format SHOULD be used when a skill has gotchas or supplementary files. + +## 2. Skill File Format + +A skill file consists of YAML frontmatter delimited by `---` followed by a Markdown body. + +```markdown +--- +id: my-skill +name: My Skill +description: What this skill does +--- + +The skill body in Markdown. +``` + +### 2.1 Frontmatter + +Frontmatter MUST be valid YAML enclosed between two `---` delimiters. The opening `---` MUST be the first non-whitespace content in the file. + +If the file does not start with `---`, the entire content MUST be treated as the body with an empty frontmatter object. + +If the opening `---` exists but no closing `---` is found, the entire content MUST be treated as the body with an empty frontmatter object. + +### 2.2 Required Fields + +| Field | Type | Description | +|---|---|---| +| `id` | string | Unique identifier within the project. MUST match the filename slug. | +| `name` | string | Human-readable display name. MUST NOT be empty. | + +An implementation MUST reject skill files missing `id` or `name` with a validation error. + +### 2.3 Optional Fields + +| Field | Type | Default | Description | +|---|---|---|---| +| `description` | string | `null` | Short summary of the skill's purpose. | +| `category` | string | `"general"` | Skill category. See [Section 3](#3-categories). | +| `skill_type` | string | `null` | `"capability-uplift"` or `"encoded-preference"`. See [Section 4](#4-skill-types). | +| `model` | string | `null` | Target LLM model (e.g., `claude-sonnet-4-6`). | +| `max_tokens` | integer | `null` | Maximum output tokens for testing. | +| `tags` | string[] | `[]` | Tags for categorization and filtering. | +| `tools` | object[] | `[]` | Tool/function definitions in JSON Schema format. | +| `includes` | string[] | `[]` | Slugs of other skills in the same project. See [Composition Spec](./composition-spec.md). | +| `template_variables` | object[] | `[]` | Template variable definitions. See [Template Spec](./template-spec.md). | +| `gotchas` | string | `null` | Common failure points and edge cases. | +| `supplementary_files` | object[] | `[]` | Additional files in folder-based skills. | +| `conditions` | object | `null` | Conditional activation rules (file patterns, path prefixes). | +| `created_at` | string (ISO 8601) | auto-set | Creation timestamp. | +| `updated_at` | string (ISO 8601) | auto-set | Last modification timestamp. | + +### 2.4 Template Variable Definition + +Each entry in `template_variables` MUST have: + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | MUST | Variable name. MUST match `[a-zA-Z_][a-zA-Z0-9_]*`. | +| `description` | string | SHOULD | What this variable represents. | +| `default` | string | MAY | Default value if no override is provided. | + +### 2.5 Supplementary File Object + +Each entry in `supplementary_files`: + +| Field | Type | Required | Description | +|---|---|---|---| +| `path` | string | MUST | Relative path within the skill folder. | +| `content` | string | MUST | File content. | + +### 2.6 Conditions Object + +| Field | Type | Description | +|---|---|---| +| `file_patterns` | string[] | Glob patterns for file-scoped activation (e.g., `["*.py", "tests/**"]`). | +| `path_prefixes` | string[] | Directory prefixes for path-scoped activation. | + +## 3. Categories + +Valid category values: + +| Value | Description | +|---|---| +| `library-api-reference` | API usage patterns, SDK documentation | +| `product-verification` | Testing, QA, validation | +| `data-analysis` | Data processing, analytics, reporting | +| `business-automation` | Workflow automation, integrations | +| `scaffolding-templates` | Code generation, boilerplates | +| `code-quality-review` | Code review, style enforcement | +| `ci-cd-deployment` | Build pipelines, deployment | +| `incident-runbooks` | On-call procedures, troubleshooting | +| `infrastructure-ops` | Infrastructure management, DevOps | +| `general` | Default catch-all | + +Implementations SHOULD accept any string value for forward compatibility but MAY warn on unrecognized categories. + +## 4. Skill Types + +| Value | Description | +|---|---| +| `capability-uplift` | Teaches the AI domain knowledge it doesn't have. | +| `encoded-preference` | Captures behavioral preferences and style rules. | + +The `skill_type` field is OPTIONAL. If present, it MUST be one of the two values above. + +## 5. Slugs + +Slugs are derived from skill names: + +1. Convert to lowercase +2. Replace spaces and non-alphanumeric characters with hyphens +3. Collapse consecutive hyphens +4. Remove leading and trailing hyphens + +Slugs MUST be unique within a project. The slug MUST match the filename (flat format) or directory name (folder format). + +## 6. Body + +Everything after the closing `---` delimiter is the skill body. The body: + +- MUST be treated as Markdown. +- MAY contain `{{variable}}` template placeholders. See [Template Spec](./template-spec.md). +- MUST be passed verbatim to the model after template resolution. +- SHOULD use Markdown headings, lists, and code blocks for structure. + +## 7. Folder Format + +When a skill uses the folder format, the directory MUST contain `skill.md` as the main skill file. Additional files are OPTIONAL: + +| File | Purpose | +|---|---| +| `skill.md` | Main skill file (frontmatter + body). REQUIRED. | +| `gotchas.md` | Common failure points. Content overwrites the `gotchas` frontmatter field. | +| Any other files | Supplementary files. Collected into `supplementary_files` array. | + +The `skill.md` and `gotchas.md` filenames are reserved. All other files in the directory are treated as supplementary files. + +### 7.1 Progressive Disclosure + +Implementations MAY support 3 levels of skill resolution: + +| Level | Content | Use Case | +|---|---|---| +| 1 | `"{name}: {description}"` (~100 tokens) | Agent skill discovery | +| 2 | Full resolved body with includes | Standard compose/sync | +| 3 | Body + gotchas + supplementary files | Deep context execution | + +## 8. Backward Compatibility + +This spec guarantees: + +- Flat format skills (v0, pre-spec) MUST continue to work with no changes. +- The `spec_version` field in `manifest.json` is REQUIRED for new projects but OPTIONAL for existing projects (absence implies v1). +- New optional fields MUST NOT break existing parsers — unknown fields MUST be ignored. + +## 9. File Encoding + +All skill files MUST be encoded in UTF-8. Line endings SHOULD be LF (`\n`). Implementations MUST handle CRLF gracefully. + +
diff --git a/docs/reference/template-spec.md b/docs/reference/template-spec.md new file mode 100644 index 0000000..5c89d81 --- /dev/null +++ b/docs/reference/template-spec.md @@ -0,0 +1,132 @@ +
+ +# Template Variable Resolution Specification + +> **Status:** Stable +> **Spec Version:** 1 +> **Last Updated:** 2026-03-20 + +This document specifies how `{{variable}}` template placeholders are resolved in skill bodies. + +## 1. Syntax + +Template variables use double-brace syntax: + +``` +{{variable_name}} +``` + +### 1.1 Variable Names + +Variable names MUST match the regex pattern `\w+` (equivalent to `[a-zA-Z0-9_]+`). + +Valid: `{{language}}`, `{{max_retries}}`, `{{API_KEY_NAME}}` + +Invalid: `{{my-var}}` (hyphens not allowed), `{{my var}}` (spaces not allowed) + +### 1.2 Whitespace + +Whitespace inside braces is NOT supported. `{{ language }}` MUST NOT be treated as a template variable — only `{{language}}` (no spaces) is recognized. + +## 2. Resolution Algorithm + +``` +resolve(body, variables): + Replace every occurrence matching /\{\{(\w+)\}\}/ with: + If the captured name exists as a key in variables: + Replace with variables[name] + Else: + Leave the original placeholder unchanged ("{{name}}") +``` + +This is a single-pass regex substitution. Implementations MUST NOT recurse — if a variable's value contains `{{other}}`, that inner placeholder MUST NOT be resolved. + +## 3. Variable Value Sources + +Variables are resolved from these sources, in priority order (highest first): + +| Priority | Source | Description | +|---|---|---| +| 1 | CLI flags | `--var language=Python` | +| 2 | Environment variables | `SKILLR_VAR_LANGUAGE=Python` | +| 3 | Config file | `skillr.config.js` exports `{ variables: { language: "Python" } }` | +| 4 | Frontmatter defaults | `template_variables[].default` in the skill's frontmatter | + +Implementations MUST check sources in priority order and use the first match. + +### 3.1 Frontmatter Defaults + +Default values are defined in the skill's `template_variables` array: + +```yaml +template_variables: + - name: language + description: Output language + default: English + - name: framework + description: Backend framework + default: Laravel +``` + +If no higher-priority source provides a value, the `default` is used. If no default exists, the variable is unresolved. + +## 4. Unresolved Variables + +When a variable has no value from any source: + +1. The placeholder MUST be left as-is in the output (e.g., `{{unknown_var}}` remains `{{unknown_var}}`). +2. Implementations SHOULD emit a warning listing unresolved variables. +3. Resolution MUST NOT fail — unresolved variables are warnings, not errors. + +## 5. Resolution Timing + +Template resolution happens at **sync time** and **test time**, not at edit time. The raw `{{variable}}` placeholders are stored in skill files and resolved on demand. + +This means: +- The skill editor shows raw `{{variable}}` placeholders. +- `skillr sync` and `skillr test` produce output with variables substituted. +- `skillr diff` shows the resolved output (after substitution). + +## 6. Variable Extraction + +Implementations MUST provide a function to extract all variable names from a body: + +``` +extractVariables(body) -> string[]: + Match all /\{\{(\w+)\}\}/ in body + Return unique captured names +``` + +This is used for: +- Displaying which variables a skill expects +- Detecting missing variable definitions +- Linting (warn if body uses `{{var}}` but `template_variables` doesn't define it) + +## 7. Missing Variable Detection + +Implementations SHOULD provide a function to detect variables used in the body but not defined in `template_variables` or any value source: + +``` +getMissing(body, variables) -> string[]: + found = extractVariables(body) + Return found.filter(name => name not in variables) +``` + +## 8. Escaping + +This spec does NOT define an escaping mechanism for literal `{{` in output. If a skill body needs to contain literal `{{variable}}` text (e.g., documenting template syntax), the author should use a code block or alternative notation. + +Rationale: Escaping adds parsing complexity for a rare edge case. Code blocks (`` `{{variable}}` ``) in Markdown already handle the most common case. + +## 9. Scope + +Template resolution applies to: +- Skill bodies (after include resolution) +- Composed agent output (after skill bodies are merged) + +Template resolution does NOT apply to: +- Frontmatter fields (they are metadata, not prompt content) +- File paths +- Manifest fields + +