diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6b2824c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + php: + name: PHP Tests & Lint + runs-on: ubuntu-latest + + services: + mariadb: + image: mariadb:11 + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: skillr_test + MARIADB_USER: skillr + MARIADB_PASSWORD: secret + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: pdo, pdo_mysql, mbstring, bcmath, gd, opcache + coverage: none + + - name: Install Composer dependencies + run: composer install --no-interaction --prefer-dist + + - name: Prepare environment + run: | + cp .env.example .env + sed -i 's/DB_HOST=mariadb/DB_HOST=127.0.0.1/' .env + php artisan key:generate + + - name: Run migrations + run: php artisan migrate --seed --force + env: + DB_HOST: 127.0.0.1 + DB_DATABASE: skillr_test + DB_USERNAME: skillr + DB_PASSWORD: secret + + - name: Run tests + run: php artisan test + env: + DB_HOST: 127.0.0.1 + DB_DATABASE: skillr_test + DB_USERNAME: skillr + DB_PASSWORD: secret + + - name: Check code formatting + run: vendor/bin/pint --test + + frontend: + name: Frontend Lint & Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: ui/package-lock.json + + - name: Install dependencies + working-directory: ui + run: npm ci + + - name: Type check + working-directory: ui + run: npx tsc --noEmit + + - name: Lint + working-directory: ui + run: npm run lint + + - name: Build + working-directory: ui + run: npm run build diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..a89911e --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,60 @@ +name: Deploy Docs + +on: + push: + branches: [main] + paths: + - 'docs/**' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build Documentation + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: docs/package-lock.json + + - name: Install dependencies + working-directory: docs + run: npm ci + + - name: Build VitePress site + working-directory: docs + run: npm run build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/.vitepress/dist + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b1c0608 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,9 @@ +# Code of Conduct + +This project follows the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +Please read the full text at the link above. By participating in this project, you agree to abide by its terms. + +## Enforcement + +Instances of unacceptable behavior may be reported to the project maintainers at **conduct@eooo.io**. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c9771bf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,111 @@ +# Contributing to Skillr + +Thank you for your interest in contributing. This guide covers setup, coding standards, and the PR process. + +## Development Setup + +### Prerequisites + +- PHP 8.4+ +- Composer +- Node.js 20+ and npm +- MariaDB 11+ (or Docker) + +### With Docker + +```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 + +make build +make up +make migrate + +# Start the React SPA (separate terminal) +cd ui && npm install && npm run dev +``` + +### Without Docker + +```bash +git clone https://github.com/eooo-io/skillr.git +cd skillr +composer install +cp .env.example .env +php artisan key:generate + +# Configure database credentials in .env (DB_HOST=127.0.0.1) +php artisan migrate --seed + +cd ui && npm install && cd .. +composer dev +``` + +### Access Points + +| Interface | URL | +|---|---| +| React SPA | http://localhost:5173 | +| Filament Admin | http://localhost:8000/admin | +| API | http://localhost:8000/api | + +Default login: `admin@admin.com` / `password` + +## Running Tests + +```bash +# PHP tests (Pest) +composer test +# or with Docker: +make test + +# TypeScript type checking +cd ui && npx tsc --noEmit + +# Frontend linting +cd ui && npm run lint +``` + +## Coding Standards + +### PHP + +- Follow [PSR-12](https://www.php-fig.org/psr/psr-12/) coding style +- Format with Laravel Pint: `vendor/bin/pint` +- Use [Pest PHP](https://pestphp.com/) for tests +- Add authorization checks (`$this->authorize()`) to any new controller methods that access user resources +- Use FormRequest classes for complex validation + +### TypeScript / React + +- Run ESLint before committing: `cd ui && npm run lint` +- Use TypeScript strictly (no `any` types) +- Follow existing component patterns in `ui/src/components/` +- Use Zustand stores for shared state + +## Pull Request Process + +1. **Open an issue first** to discuss significant changes +2. Fork the repo and create a feature branch: `git checkout -b feature/my-feature` +3. Write tests for new functionality +4. Ensure all tests pass: `composer test` and `cd ui && npx tsc --noEmit` +5. Format code: `vendor/bin/pint` +6. Push and open a PR against `main` + +### PR Guidelines + +- Keep PRs focused — one feature or fix per PR +- Include a clear description of what changed and why +- Reference related issues with `Closes #123` +- Add tests for new API endpoints +- Update CLAUDE.md if you add new routes, models, or services + +## Project Structure + +See [CLAUDE.md](CLAUDE.md) for full architecture documentation including: +- Database schema +- API endpoints +- Service layer overview +- Provider sync system diff --git a/PLAN.md b/PLAN.md index 033baba..6174835 100644 --- a/PLAN.md +++ b/PLAN.md @@ -128,6 +128,39 @@ The React SPA (`ui/`), `.skillr/` file format, and all provider sync output form --- +## 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. + +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 + +| # | 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 | | + +### 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 | | + +### Implementation sequence + +``` +#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) +``` + +--- + ## Laravel Legacy (Phases 1-26) — COMPLETE The original Laravel implementation built the full Component Layer: diff --git a/README.md b/README.md index caf4979..4c78fc9 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ composer dev Default login: `admin@admin.com` / `password` +> **Warning:** Change these credentials immediately in any non-local environment. The seeded admin account is for development only. + ## Features ### Skill Management @@ -176,13 +178,11 @@ cd ui && npx tsc --noEmit ## Contributing -Contributions are welcome. Please open an issue first to discuss what you'd like to change. +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions and guidelines. + +## Security -1. Fork the repo -2. Create your feature branch (`git checkout -b feature/my-feature`) -3. Commit your changes -4. Push to the branch (`git push origin feature/my-feature`) -5. Open a Pull Request +If you discover a security vulnerability, please see [SECURITY.md](SECURITY.md) for responsible disclosure instructions. **Do not open a public issue.** ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b323471 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in Skillr, please report it responsibly. + +**Do not open a public GitHub issue for security vulnerabilities.** + +Instead, email security concerns to: **security@eooo.io** + +Please include: +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +## Response Timeline + +- **Acknowledgment:** Within 48 hours +- **Assessment:** Within 7 days +- **Fix:** Depends on severity, typically within 30 days + +## Scope + +This policy covers the Skillr application code in this repository. Third-party dependencies are managed via Composer and npm, and their vulnerabilities should be reported to respective maintainers. + +## Default Credentials + +The database seeder creates a default admin account (`admin@admin.com` / `password`) for development purposes only. **Change these credentials immediately** in any non-local deployment. diff --git a/app/Http/Controllers/A2aAgentController.php b/app/Http/Controllers/A2aAgentController.php index a8f3e4c..26b5bba 100644 --- a/app/Http/Controllers/A2aAgentController.php +++ b/app/Http/Controllers/A2aAgentController.php @@ -11,6 +11,8 @@ class A2aAgentController extends Controller { public function index(Project $project): JsonResponse { + $this->authorize('view', $project); + return response()->json([ 'a2a_agents' => $project->a2aAgents()->orderBy('name')->get(), ]); @@ -18,6 +20,8 @@ public function index(Project $project): JsonResponse public function store(Request $request, Project $project): JsonResponse { + $this->authorize('update', $project); + $validated = $request->validate([ 'name' => 'required|string|max:255', 'url' => 'required|string|max:500', @@ -34,6 +38,8 @@ public function store(Request $request, Project $project): JsonResponse public function update(Request $request, ProjectA2aAgent $a2aAgent): JsonResponse { + $this->authorize('update', $a2aAgent); + $validated = $request->validate([ 'name' => 'sometimes|string|max:255', 'url' => 'sometimes|string|max:500', @@ -50,6 +56,8 @@ public function update(Request $request, ProjectA2aAgent $a2aAgent): JsonRespons public function destroy(ProjectA2aAgent $a2aAgent): JsonResponse { + $this->authorize('delete', $a2aAgent); + $a2aAgent->delete(); return response()->json(['message' => 'A2A agent removed.']); diff --git a/app/Http/Controllers/AgentController.php b/app/Http/Controllers/AgentController.php index b00af2b..31a95eb 100644 --- a/app/Http/Controllers/AgentController.php +++ b/app/Http/Controllers/AgentController.php @@ -29,6 +29,8 @@ public function index(): JsonResponse */ public function projectAgents(Project $project): JsonResponse { + $this->authorize('view', $project); + $agents = Agent::orderBy('sort_order')->get(); $projectAgents = $project->projectAgents() @@ -70,6 +72,8 @@ public function projectAgents(Project $project): JsonResponse */ public function toggle(Request $request, Project $project, Agent $agent): JsonResponse { + $this->authorize('update', $project); + $validated = $request->validate([ 'is_enabled' => 'required|boolean', ]); @@ -87,6 +91,8 @@ public function toggle(Request $request, Project $project, Agent $agent): JsonRe */ public function updateInstructions(Request $request, Project $project, Agent $agent): JsonResponse { + $this->authorize('update', $project); + $validated = $request->validate([ 'custom_instructions' => 'nullable|string|max:10000', ]); @@ -104,6 +110,8 @@ public function updateInstructions(Request $request, Project $project, Agent $ag */ public function assignSkills(Request $request, Project $project, Agent $agent): JsonResponse { + $this->authorize('update', $project); + $validated = $request->validate([ 'skill_ids' => 'present|array', 'skill_ids.*' => 'integer|exists:skills,id', @@ -143,6 +151,8 @@ public function assignSkills(Request $request, Project $project, Agent $agent): */ public function compose(Project $project, Agent $agent): JsonResponse { + $this->authorize('view', $project); + return response()->json([ 'data' => $this->composeService->compose($project, $agent), ]); @@ -153,6 +163,8 @@ public function compose(Project $project, Agent $agent): JsonResponse */ public function composeAll(Project $project): JsonResponse { + $this->authorize('view', $project); + return response()->json([ 'data' => $this->composeService->composeAll($project), ]); diff --git a/app/Http/Controllers/BulkSkillController.php b/app/Http/Controllers/BulkSkillController.php index 0019c64..ccb07b1 100644 --- a/app/Http/Controllers/BulkSkillController.php +++ b/app/Http/Controllers/BulkSkillController.php @@ -2,12 +2,12 @@ namespace App\Http\Controllers; +use App\Models\Project; use App\Models\Skill; use App\Models\Tag; use App\Services\SkillrManifestService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Str; class BulkSkillController extends Controller { @@ -26,7 +26,7 @@ public function bulkTag(Request $request): JsonResponse 'remove_tags.*' => 'string|max:50', ]); - $skills = Skill::whereIn('id', $validated['skill_ids'])->get(); + $skills = $this->scopedSkills($validated['skill_ids']); $addTags = $validated['add_tags'] ?? []; $removeTags = $validated['remove_tags'] ?? []; @@ -65,7 +65,10 @@ public function bulkAssign(Request $request): JsonResponse 'project_id' => 'required|integer|exists:projects,id', ]); - $skills = Skill::whereIn('id', $validated['skill_ids'])->get(); + $targetProject = Project::findOrFail($validated['project_id']); + $this->authorize('update', $targetProject); + + $skills = $this->scopedSkills($validated['skill_ids']); foreach ($skills as $skill) { $skill->agents()->syncWithoutDetaching([ @@ -86,7 +89,7 @@ public function bulkDelete(Request $request): JsonResponse 'skill_ids.*' => 'integer|exists:skills,id', ]); - $skills = Skill::with('project')->whereIn('id', $validated['skill_ids'])->get(); + $skills = $this->scopedSkills($validated['skill_ids'], ['project']); $count = $skills->count(); foreach ($skills as $skill) { @@ -111,8 +114,10 @@ public function bulkMove(Request $request): JsonResponse 'target_project_id' => 'required|integer|exists:projects,id', ]); - $skills = Skill::with('project', 'tags')->whereIn('id', $validated['skill_ids'])->get(); - $targetProject = \App\Models\Project::findOrFail($validated['target_project_id']); + $targetProject = Project::findOrFail($validated['target_project_id']); + $this->authorize('update', $targetProject); + + $skills = $this->scopedSkills($validated['skill_ids'], ['project', 'tags']); $count = 0; foreach ($skills as $skill) { @@ -149,6 +154,8 @@ public function bulkMove(Request $request): JsonResponse 'id' => $skill->slug, 'name' => $skill->name, 'description' => $skill->description, + 'category' => $skill->category, + 'skill_type' => $skill->skill_type, 'tags' => $skill->tags->pluck('name')->values()->all(), 'model' => $skill->model, 'max_tokens' => $skill->max_tokens, @@ -172,4 +179,24 @@ public function bulkMove(Request $request): JsonResponse 'count' => $count, ]); } + + /** + * Fetch skills by IDs, scoped to the current user's organization. + */ + protected function scopedSkills(array $skillIds, array $with = []): \Illuminate\Database\Eloquent\Collection + { + $query = Skill::whereIn('id', $skillIds) + ->whereHas('project', function ($q) { + $orgId = auth()->user()?->current_organization_id; + if ($orgId) { + $q->where('organization_id', $orgId); + } + }); + + if (! empty($with)) { + $query->with($with); + } + + return $query->get(); + } } diff --git a/app/Http/Controllers/BundleController.php b/app/Http/Controllers/BundleController.php index 953d1dc..7564085 100644 --- a/app/Http/Controllers/BundleController.php +++ b/app/Http/Controllers/BundleController.php @@ -21,6 +21,8 @@ public function __construct( */ public function export(Request $request, Project $project): JsonResponse|BinaryFileResponse { + $this->authorize('view', $project); + $validated = $request->validate([ 'skill_ids' => 'nullable|array', 'skill_ids.*' => 'integer|exists:skills,id', @@ -52,6 +54,8 @@ public function export(Request $request, Project $project): JsonResponse|BinaryF */ public function import(Request $request, Project $project): JsonResponse { + $this->authorize('update', $project); + $isPreview = $request->boolean('preview'); // Determine import source: file upload or JSON body diff --git a/app/Http/Controllers/DesktopConfigController.php b/app/Http/Controllers/DesktopConfigController.php new file mode 100644 index 0000000..a96e508 --- /dev/null +++ b/app/Http/Controllers/DesktopConfigController.php @@ -0,0 +1,154 @@ +user()->id) + ->get() + ->keyBy('app_slug'); + + $knownApps = DesktopAppConfig::knownApps(); + $result = []; + + foreach ($knownApps as $slug => $app) { + $config = $configs->get($slug); + + $result[] = [ + 'slug' => $slug, + 'name' => $app['name'], + 'config_path' => $config?->config_path ?? $app['config_path'], + 'supports' => $app['supports'], + 'registered' => $config !== null, + 'sync_mcp' => $config?->sync_mcp ?? false, + 'sync_settings' => $config?->sync_settings ?? false, + 'last_synced_at' => $config?->last_synced_at?->toIso8601String(), + ]; + } + + return response()->json(['data' => $result]); + } + + /** + * Detect which desktop apps are installed on this machine. + */ + public function detect(): JsonResponse + { + return response()->json(['data' => DesktopAppConfig::detectInstalled()]); + } + + /** + * Register or update a desktop app config. + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'app_slug' => 'required|string|max:50', + 'config_path' => 'nullable|string|max:500', + 'sync_mcp' => 'nullable|boolean', + 'sync_settings' => 'nullable|boolean', + ]); + + $knownApps = DesktopAppConfig::knownApps(); + $defaultPath = $knownApps[$validated['app_slug']]['config_path'] ?? ''; + + $config = DesktopAppConfig::updateOrCreate( + [ + 'user_id' => $request->user()->id, + 'app_slug' => $validated['app_slug'], + ], + [ + 'config_path' => $validated['config_path'] ?? $defaultPath, + 'sync_mcp' => $validated['sync_mcp'] ?? true, + 'sync_settings' => $validated['sync_settings'] ?? false, + ], + ); + + return response()->json(['data' => $config], 201); + } + + /** + * Remove a registered desktop app config. + */ + public function destroy(Request $request, string $appSlug): JsonResponse + { + DesktopAppConfig::where('user_id', $request->user()->id) + ->where('app_slug', $appSlug) + ->delete(); + + return response()->json(['message' => 'Desktop app config removed.']); + } + + /** + * Sync MCP servers and settings to all registered desktop apps. + */ + public function syncAll(Request $request): JsonResponse + { + $projectId = $request->input('project_id'); + + $results = $this->syncService->syncAll($request->user(), $projectId); + + return response()->json(['data' => $results]); + } + + /** + * Sync MCP servers and settings to a specific desktop app. + */ + public function syncApp(Request $request, string $appSlug): JsonResponse + { + $config = DesktopAppConfig::where('user_id', $request->user()->id) + ->where('app_slug', $appSlug) + ->firstOrFail(); + + $projectId = $request->input('project_id'); + $result = $this->syncService->syncApp($config, $request->user(), $projectId); + + return response()->json(['data' => $result]); + } + + /** + * Preview what would be written to a desktop app config. + */ + public function preview(Request $request, string $appSlug): JsonResponse + { + $config = DesktopAppConfig::where('user_id', $request->user()->id) + ->where('app_slug', $appSlug) + ->firstOrFail(); + + $projectId = $request->input('project_id'); + $preview = $this->syncService->preview($config, $request->user(), $projectId); + + return response()->json(['data' => $preview]); + } + + /** + * Import MCP servers from existing desktop app configs into a project. + */ + public function importMcp(Request $request): JsonResponse + { + $validated = $request->validate([ + 'project_id' => 'required|integer|exists:projects,id', + ]); + + $result = $this->syncService->importMcpServers( + $request->user(), + $validated['project_id'], + ); + + return response()->json(['data' => $result]); + } +} diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index 214fd0c..998bd16 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -20,7 +20,7 @@ public function detect(Request $request): JsonResponse { $validated = $request->validate([ 'path' => 'required|string', - 'provider' => 'nullable|string|in:claude,cursor,copilot,windsurf,cline,openai', + 'provider' => 'nullable|string|in:claude,cursor,copilot,windsurf,cline,openai,codex', ]); $path = realpath($validated['path']); @@ -50,9 +50,11 @@ public function detect(Request $request): JsonResponse */ public function import(Request $request, Project $project): JsonResponse { + $this->authorize('update', $project); + $validated = $request->validate([ 'path' => 'required|string', - 'provider' => 'nullable|string|in:claude,cursor,copilot,windsurf,cline,openai', + 'provider' => 'nullable|string|in:claude,cursor,copilot,windsurf,cline,openai,codex', ]); $path = realpath($validated['path']); diff --git a/app/Http/Controllers/LibraryController.php b/app/Http/Controllers/LibraryController.php index 39a4335..3e9a5fc 100644 --- a/app/Http/Controllers/LibraryController.php +++ b/app/Http/Controllers/LibraryController.php @@ -59,10 +59,13 @@ public function import(Request $request, LibrarySkill $librarySkill, SkillrManif 'slug' => $slug, 'name' => $librarySkill->name, 'description' => $librarySkill->description, + 'category' => $librarySkill->category, + 'skill_type' => $librarySkill->frontmatter['skill_type'] ?? null, 'model' => $librarySkill->frontmatter['model'] ?? null, 'max_tokens' => $librarySkill->frontmatter['max_tokens'] ?? null, 'tools' => $librarySkill->frontmatter['tools'] ?? [], 'body' => $librarySkill->body, + 'gotchas' => $librarySkill->frontmatter['gotchas'] ?? null, ]); // Sync tags @@ -91,6 +94,8 @@ public function import(Request $request, LibrarySkill $librarySkill, SkillrManif 'id' => $slug, 'name' => $skill->name, 'description' => $skill->description, + 'category' => $skill->category, + 'skill_type' => $skill->skill_type, 'tags' => $librarySkill->tags ?? [], 'model' => $skill->model, 'max_tokens' => $skill->max_tokens, diff --git a/app/Http/Controllers/MarketplaceController.php b/app/Http/Controllers/MarketplaceController.php index 52d04ba..89914be 100644 --- a/app/Http/Controllers/MarketplaceController.php +++ b/app/Http/Controllers/MarketplaceController.php @@ -129,10 +129,13 @@ public function install(Request $request, MarketplaceSkill $marketplaceSkill, Sk 'slug' => $slug, 'name' => $marketplaceSkill->name, 'description' => $marketplaceSkill->description, + 'category' => $marketplaceSkill->category, + 'skill_type' => $marketplaceSkill->frontmatter['skill_type'] ?? null, 'model' => $marketplaceSkill->frontmatter['model'] ?? null, 'max_tokens' => $marketplaceSkill->frontmatter['max_tokens'] ?? null, 'tools' => $marketplaceSkill->frontmatter['tools'] ?? [], 'body' => $marketplaceSkill->body, + 'gotchas' => $marketplaceSkill->frontmatter['gotchas'] ?? null, ]); // Sync tags @@ -161,6 +164,8 @@ public function install(Request $request, MarketplaceSkill $marketplaceSkill, Sk 'id' => $slug, 'name' => $skill->name, 'description' => $skill->description, + 'category' => $skill->category, + 'skill_type' => $skill->skill_type, 'tags' => $marketplaceSkill->tags ?? [], 'model' => $skill->model, 'max_tokens' => $skill->max_tokens, diff --git a/app/Http/Controllers/McpServerController.php b/app/Http/Controllers/McpServerController.php index cbe0a50..28ed8b4 100644 --- a/app/Http/Controllers/McpServerController.php +++ b/app/Http/Controllers/McpServerController.php @@ -11,6 +11,8 @@ class McpServerController extends Controller { public function index(Project $project): JsonResponse { + $this->authorize('view', $project); + return response()->json([ 'mcp_servers' => $project->mcpServers()->orderBy('name')->get(), ]); @@ -18,6 +20,8 @@ public function index(Project $project): JsonResponse public function store(Request $request, Project $project): JsonResponse { + $this->authorize('update', $project); + $validated = $request->validate([ 'name' => 'required|string|max:255', 'transport' => 'required|in:stdio,sse,streamable-http', @@ -36,6 +40,8 @@ public function store(Request $request, Project $project): JsonResponse public function update(Request $request, ProjectMcpServer $mcpServer): JsonResponse { + $this->authorize('update', $mcpServer); + $validated = $request->validate([ 'name' => 'sometimes|string|max:255', 'transport' => 'sometimes|in:stdio,sse,streamable-http', @@ -54,6 +60,8 @@ public function update(Request $request, ProjectMcpServer $mcpServer): JsonRespo public function destroy(ProjectMcpServer $mcpServer): JsonResponse { + $this->authorize('delete', $mcpServer); + $mcpServer->delete(); return response()->json(['message' => 'MCP server removed.']); diff --git a/app/Http/Controllers/OpenClawConfigController.php b/app/Http/Controllers/OpenClawConfigController.php index c5a4a41..29ebdd4 100644 --- a/app/Http/Controllers/OpenClawConfigController.php +++ b/app/Http/Controllers/OpenClawConfigController.php @@ -11,6 +11,8 @@ class OpenClawConfigController extends Controller { public function show(Project $project): JsonResponse { + $this->authorize('view', $project); + $config = $project->openclawConfig; return response()->json([ @@ -24,6 +26,8 @@ public function show(Project $project): JsonResponse public function update(Request $request, Project $project): JsonResponse { + $this->authorize('update', $project); + $validated = $request->validate([ 'soul_content' => 'nullable|string|max:50000', 'tools' => 'nullable|array', diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index ae81bdc..82af81c 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -3,11 +3,11 @@ namespace App\Http\Controllers; use App\Http\Resources\ProjectResource; -use App\Jobs\ProjectScanJob; use App\Models\Project; use App\Models\ProjectProvider; use App\Rules\SafeProjectPath; use App\Services\GitService; +use App\Services\ProjectScanService; use App\Services\ProviderSyncService; use App\Services\WebhookDispatcher; use Illuminate\Http\JsonResponse; @@ -55,11 +55,14 @@ public function store(Request $request): ProjectResource public function show(Project $project): ProjectResource { + $this->authorize('view', $project); + return new ProjectResource($project->load(['providers', 'repositories'])->loadCount('skills')); } public function update(Request $request, Project $project): ProjectResource { + $this->authorize('update', $project); $validated = $request->validate([ 'name' => 'sometimes|required|string|max:255', 'description' => 'nullable|string|max:1000', @@ -84,20 +87,26 @@ public function update(Request $request, Project $project): ProjectResource public function destroy(Project $project): JsonResponse { + $this->authorize('delete', $project); + $project->delete(); return response()->json(['message' => 'Project deleted']); } - public function scan(Project $project): JsonResponse + public function scan(Project $project, ProjectScanService $scanService): JsonResponse { - ProjectScanJob::dispatch($project); + $this->authorize('update', $project); - return response()->json(['message' => 'Scan queued']); + $result = $scanService->scan($project); + + return response()->json(['data' => $result]); } public function sync(Project $project, ProviderSyncService $syncService, WebhookDispatcher $webhookDispatcher): JsonResponse { + $this->authorize('update', $project); + $syncService->syncProject($project); $webhookDispatcher->dispatch('project.synced', $project, [ @@ -111,6 +120,8 @@ public function sync(Project $project, ProviderSyncService $syncService, Webhook public function syncPreview(Project $project, ProviderSyncService $syncService): JsonResponse { + $this->authorize('view', $project); + $preview = $syncService->preview($project); return response()->json(['data' => $preview]); @@ -118,6 +129,8 @@ public function syncPreview(Project $project, ProviderSyncService $syncService): public function gitLog(Request $request, Project $project, GitService $gitService): JsonResponse { + $this->authorize('view', $project); + $file = $request->query('file'); if (! $file) { @@ -139,6 +152,8 @@ public function gitLog(Request $request, Project $project, GitService $gitServic public function gitDiff(Request $request, Project $project, GitService $gitService): JsonResponse { + $this->authorize('view', $project); + $file = $request->query('file'); $ref = $request->query('ref'); diff --git a/app/Http/Controllers/RepositoryController.php b/app/Http/Controllers/RepositoryController.php index d2a9ec0..01b0a71 100644 --- a/app/Http/Controllers/RepositoryController.php +++ b/app/Http/Controllers/RepositoryController.php @@ -18,6 +18,8 @@ public function __construct( public function show(Project $project): JsonResponse { + $this->authorize('view', $project); + $repositories = $project->repositories()->get(); return response()->json([ @@ -27,6 +29,8 @@ public function show(Project $project): JsonResponse public function connect(Request $request, Project $project): JsonResponse { + $this->authorize('update', $project); + $validated = $request->validate([ 'provider' => 'required|string|in:github,gitlab', 'full_name' => 'required|string|max:255', // "owner/repo" @@ -52,6 +56,8 @@ public function connect(Request $request, Project $project): JsonResponse public function disconnect(Project $project, string $provider): JsonResponse { + $this->authorize('update', $project); + $repository = $project->repositories() ->where('provider', $provider) ->firstOrFail(); @@ -63,6 +69,8 @@ public function disconnect(Project $project, string $provider): JsonResponse public function status(Project $project, string $provider): JsonResponse { + $this->authorize('view', $project); + $repository = $project->repositories() ->where('provider', $provider) ->firstOrFail(); @@ -74,6 +82,8 @@ public function status(Project $project, string $provider): JsonResponse public function branches(Project $project, string $provider): JsonResponse { + $this->authorize('view', $project); + $repository = $project->repositories() ->where('provider', $provider) ->firstOrFail(); @@ -85,6 +95,8 @@ public function branches(Project $project, string $provider): JsonResponse public function latestCommit(Project $project, string $provider): JsonResponse { + $this->authorize('view', $project); + $repository = $project->repositories() ->where('provider', $provider) ->firstOrFail(); @@ -100,6 +112,8 @@ public function latestCommit(Project $project, string $provider): JsonResponse public function update(Request $request, Project $project, string $provider): JsonResponse { + $this->authorize('update', $project); + $repository = $project->repositories() ->where('provider', $provider) ->firstOrFail(); @@ -121,6 +135,8 @@ public function update(Request $request, Project $project, string $provider): Js public function files(Project $project, string $provider): JsonResponse { + $this->authorize('view', $project); + $repository = $project->repositories() ->where('provider', $provider) ->firstOrFail(); @@ -136,6 +152,8 @@ public function files(Project $project, string $provider): JsonResponse public function pullSkills(Project $project, string $provider): JsonResponse { + $this->authorize('update', $project); + $repository = $project->repositories() ->where('provider', $provider) ->firstOrFail(); @@ -159,6 +177,8 @@ public function pullSkills(Project $project, string $provider): JsonResponse public function pushSkills(Request $request, Project $project, string $provider): JsonResponse { + $this->authorize('update', $project); + $repository = $project->repositories() ->where('provider', $provider) ->firstOrFail(); diff --git a/app/Http/Controllers/SkillController.php b/app/Http/Controllers/SkillController.php index b88c8f6..2750c53 100644 --- a/app/Http/Controllers/SkillController.php +++ b/app/Http/Controllers/SkillController.php @@ -6,6 +6,7 @@ use App\Models\Project; use App\Models\Skill; use App\Models\Tag; +use Illuminate\Validation\Rule; use App\Services\SkillrManifestService; use App\Services\GitService; use App\Services\PromptLinter; @@ -27,6 +28,8 @@ public function __construct( public function index(Project $project): AnonymousResourceCollection { + $this->authorize('view', $project); + $skills = $project->skills()->with('tags')->orderBy('name')->get(); return SkillResource::collection($skills); @@ -34,15 +37,20 @@ public function index(Project $project): AnonymousResourceCollection public function store(Request $request, Project $project): SkillResource { + $this->authorize('update', $project); + $validated = $request->validate([ 'name' => 'required|string|max:255', 'description' => 'nullable|string|max:1000', + 'category' => ['nullable', 'string', Rule::in(Skill::CATEGORIES)], + 'skill_type' => ['nullable', 'string', Rule::in(Skill::SKILL_TYPES)], 'model' => 'nullable|string|max:100', 'max_tokens' => 'nullable|integer|min:1', 'tools' => 'nullable|array', 'includes' => 'nullable|array', 'includes.*' => 'string|max:100', 'body' => 'nullable|string', + 'gotchas' => 'nullable|string', 'conditions' => 'nullable|array', 'conditions.file_patterns' => 'nullable|array', 'conditions.file_patterns.*' => 'string|max:200', @@ -68,11 +76,14 @@ public function store(Request $request, Project $project): SkillResource 'slug' => $slug, 'name' => $validated['name'], 'description' => $validated['description'] ?? null, + 'category' => $validated['category'] ?? null, + 'skill_type' => $validated['skill_type'] ?? null, 'model' => $validated['model'] ?? null, 'max_tokens' => $validated['max_tokens'] ?? null, 'tools' => $validated['tools'] ?? [], 'includes' => $validated['includes'] ?? [], 'body' => $validated['body'] ?? '', + 'gotchas' => $validated['gotchas'] ?? null, 'conditions' => $validated['conditions'] ?? null, 'template_variables' => $validated['template_variables'] ?? null, ]); @@ -87,20 +98,27 @@ public function store(Request $request, Project $project): SkillResource public function show(Skill $skill): SkillResource { + $this->authorize('view', $skill); + return new SkillResource($skill->load('tags', 'project')); } public function update(Request $request, Skill $skill): SkillResource { + $this->authorize('update', $skill); + $validated = $request->validate([ 'name' => 'sometimes|required|string|max:255', 'description' => 'nullable|string|max:1000', + 'category' => ['nullable', 'string', Rule::in(Skill::CATEGORIES)], + 'skill_type' => ['nullable', 'string', Rule::in(Skill::SKILL_TYPES)], 'model' => 'nullable|string|max:100', 'max_tokens' => 'nullable|integer|min:1', 'tools' => 'nullable|array', 'includes' => 'nullable|array', 'includes.*' => 'string|max:100', 'body' => 'nullable|string', + 'gotchas' => 'nullable|string', 'conditions' => 'nullable|array', 'conditions.file_patterns' => 'nullable|array', 'conditions.file_patterns.*' => 'string|max:200', @@ -129,13 +147,21 @@ public function update(Request $request, Skill $skill): SkillResource public function lint(Skill $skill, PromptLinter $linter): JsonResponse { - $issues = $linter->lint($skill->body ?? ''); + $this->authorize('view', $skill); + + $issues = $linter->lint($skill->body ?? '', [ + 'description' => $skill->description, + 'gotchas' => $skill->gotchas, + 'skill_type' => $skill->skill_type, + ]); return response()->json(['data' => $issues]); } public function destroy(Skill $skill): JsonResponse { + $this->authorize('delete', $skill); + $project = $skill->project; $skillData = ['id' => $skill->id, 'slug' => $skill->slug, 'name' => $skill->name]; $this->manifestService->deleteSkillFile($project->resolved_path, $skill->slug); @@ -148,9 +174,13 @@ public function destroy(Skill $skill): JsonResponse public function duplicate(Request $request, Skill $skill): SkillResource { + $this->authorize('view', $skill); + $targetProjectId = $request->input('target_project_id', $skill->project_id); $targetProject = Project::findOrFail($targetProjectId); + $this->authorize('update', $targetProject); + $newName = $skill->name . ' (Copy)'; $slug = Str::slug($newName); $baseSlug = $slug; @@ -164,11 +194,14 @@ public function duplicate(Request $request, Skill $skill): SkillResource 'slug' => $slug, 'name' => $newName, 'description' => $skill->description, + 'category' => $skill->category, + 'skill_type' => $skill->skill_type, 'model' => $skill->model, 'max_tokens' => $skill->max_tokens, 'tools' => $skill->tools, 'includes' => $skill->includes, 'body' => $skill->body, + 'gotchas' => $skill->gotchas, 'template_variables' => $skill->template_variables, ]); @@ -198,6 +231,8 @@ protected function createVersion(Skill $skill): void 'id' => $skill->slug, 'name' => $skill->name, 'description' => $skill->description, + 'category' => $skill->category, + 'skill_type' => $skill->skill_type, 'model' => $skill->model, 'max_tokens' => $skill->max_tokens, 'tools' => $skill->tools, @@ -205,6 +240,7 @@ protected function createVersion(Skill $skill): void 'template_variables' => $skill->template_variables, ], 'body' => $skill->body, + 'gotchas' => $skill->gotchas, 'saved_at' => now(), ]); } @@ -227,6 +263,8 @@ protected function writeFile(Project $project, Skill $skill): void 'id' => $skill->slug, 'name' => $skill->name, 'description' => $skill->description, + 'category' => $skill->category, + 'skill_type' => $skill->skill_type, 'tags' => $skill->tags->pluck('name')->values()->all(), 'model' => $skill->model, 'max_tokens' => $skill->max_tokens, @@ -238,7 +276,13 @@ protected function writeFile(Project $project, Skill $skill): void 'updated_at' => $skill->updated_at->toIso8601String(), ]; - $this->manifestService->writeSkillFile($project->resolved_path, $frontmatter, $skill->body ?? ''); + $this->manifestService->writeSkillFile( + $project->resolved_path, + $frontmatter, + $skill->body ?? '', + $skill->gotchas, + $skill->supplementary_files ?? [], + ); // Auto-commit if enabled if ($project->git_auto_commit) { diff --git a/app/Http/Controllers/SkillGenerateController.php b/app/Http/Controllers/SkillGenerateController.php index c50e350..5315930 100644 --- a/app/Http/Controllers/SkillGenerateController.php +++ b/app/Http/Controllers/SkillGenerateController.php @@ -30,11 +30,14 @@ public function __invoke(Request $request): JsonResponse Return ONLY valid JSON with this exact structure: { "name": "Short descriptive name", - "description": "One-sentence description of what the skill does", + "description": "One-sentence description of what the skill does — be specific enough that an agent knows exactly when to use it", + "category": "one of: library-api-reference, product-verification, data-analysis, business-automation, scaffolding-templates, code-quality-review, ci-cd-deployment, incident-runbooks, infrastructure-ops, general", + "skill_type": "either capability-uplift (teaches something the model can't do well) or encoded-preference (sequences steps per team process)", "model": null, "max_tokens": null, "tags": ["tag1", "tag2"], - "body": "The full system prompt text..." + "body": "The full system prompt text...", + "gotchas": "Common failure points and edge cases the agent should watch for" } Guidelines for the body (system prompt): @@ -44,6 +47,7 @@ public function __invoke(Request $request): JsonResponse - Keep instructions focused — avoid contradictory directives - Include relevant constraints and boundaries - Aim for 200-800 words depending on complexity +- Include a gotchas section for common pitfalls Do not wrap the JSON in markdown code fences. Return raw JSON only. PROMPT; diff --git a/app/Http/Controllers/SkillTestController.php b/app/Http/Controllers/SkillTestController.php index c76be75..c3980b2 100644 --- a/app/Http/Controllers/SkillTestController.php +++ b/app/Http/Controllers/SkillTestController.php @@ -21,6 +21,8 @@ public function __construct( */ public function __invoke(Request $request, Skill $skill): StreamedResponse { + $this->authorize('view', $skill); + $validated = $request->validate([ 'user_message' => 'required|string|max:10000', ]); @@ -97,7 +99,10 @@ protected function stream(string $model, int $maxTokens, string $systemPrompt, a } } } catch (\Throwable $e) { - echo "data: " . json_encode(['type' => 'error', 'error' => $e->getMessage()]) . "\n\n"; + $errorMessage = app()->isProduction() + ? 'An error occurred while processing your request.' + : $e->getMessage(); + echo "data: " . json_encode(['type' => 'error', 'error' => $errorMessage]) . "\n\n"; ob_flush(); flush(); } diff --git a/app/Http/Controllers/SkillVariableController.php b/app/Http/Controllers/SkillVariableController.php index 7b9c0a4..046cb57 100644 --- a/app/Http/Controllers/SkillVariableController.php +++ b/app/Http/Controllers/SkillVariableController.php @@ -20,6 +20,8 @@ public function __construct( */ public function index(Project $project, Skill $skill): JsonResponse { + $this->authorize('view', $project); + $variables = SkillVariable::where('project_id', $project->id) ->where('skill_id', $skill->id) ->get() @@ -56,6 +58,8 @@ public function index(Project $project, Skill $skill): JsonResponse */ public function update(Request $request, Project $project, Skill $skill): JsonResponse { + $this->authorize('update', $project); + $validated = $request->validate([ 'variables' => 'required|array', 'variables.*' => 'nullable|string|max:10000', diff --git a/app/Http/Controllers/VersionController.php b/app/Http/Controllers/VersionController.php index 30e76dd..76678cd 100644 --- a/app/Http/Controllers/VersionController.php +++ b/app/Http/Controllers/VersionController.php @@ -16,6 +16,8 @@ public function __construct( public function index(Skill $skill): AnonymousResourceCollection { + $this->authorize('view', $skill); + $versions = $skill->versions()->orderByDesc('version_number')->get(); return VersionResource::collection($versions); @@ -23,6 +25,8 @@ public function index(Skill $skill): AnonymousResourceCollection public function show(Skill $skill, int $versionNumber): VersionResource { + $this->authorize('view', $skill); + $version = $skill->versions()->where('version_number', $versionNumber)->firstOrFail(); return new VersionResource($version); @@ -30,6 +34,8 @@ public function show(Skill $skill, int $versionNumber): VersionResource public function restore(Skill $skill, int $versionNumber): VersionResource { + $this->authorize('update', $skill); + $version = $skill->versions()->where('version_number', $versionNumber)->firstOrFail(); // Restore skill data from the version snapshot diff --git a/app/Http/Controllers/VisualizationController.php b/app/Http/Controllers/VisualizationController.php index b664b41..4ce918a 100644 --- a/app/Http/Controllers/VisualizationController.php +++ b/app/Http/Controllers/VisualizationController.php @@ -19,6 +19,8 @@ public function __construct( */ public function graph(Project $project): JsonResponse { + $this->authorize('view', $project); + $project->load([ 'skills.tags', 'providers', diff --git a/app/Http/Controllers/WebhookController.php b/app/Http/Controllers/WebhookController.php index 672c971..505a963 100644 --- a/app/Http/Controllers/WebhookController.php +++ b/app/Http/Controllers/WebhookController.php @@ -12,6 +12,8 @@ class WebhookController extends Controller { public function index(Project $project): JsonResponse { + $this->authorize('view', $project); + $webhooks = $project->webhooks() ->orderByDesc('created_at') ->get(); @@ -21,6 +23,8 @@ public function index(Project $project): JsonResponse public function store(Request $request, Project $project): JsonResponse { + $this->authorize('update', $project); + $validated = $request->validate([ 'event' => 'required|string|in:skill.created,skill.updated,skill.deleted,project.synced', 'url' => 'required|url|max:500', @@ -40,6 +44,8 @@ public function store(Request $request, Project $project): JsonResponse public function update(Request $request, Webhook $webhook): JsonResponse { + $this->authorize('update', $webhook); + $validated = $request->validate([ 'event' => 'sometimes|required|string|in:skill.created,skill.updated,skill.deleted,project.synced', 'url' => 'sometimes|required|url|max:500', @@ -54,6 +60,8 @@ public function update(Request $request, Webhook $webhook): JsonResponse public function destroy(Webhook $webhook): JsonResponse { + $this->authorize('delete', $webhook); + $webhook->delete(); return response()->json(['message' => 'Webhook deleted']); @@ -61,6 +69,8 @@ public function destroy(Webhook $webhook): JsonResponse public function deliveries(Webhook $webhook): JsonResponse { + $this->authorize('view', $webhook); + $deliveries = $webhook->deliveries() ->orderByDesc('created_at') ->limit(20) @@ -71,6 +81,8 @@ public function deliveries(Webhook $webhook): JsonResponse public function test(Webhook $webhook, WebhookDispatcher $dispatcher): JsonResponse { + $this->authorize('update', $webhook); + $payload = [ 'test' => true, 'project_id' => $webhook->project_id, diff --git a/app/Http/Resources/SkillResource.php b/app/Http/Resources/SkillResource.php index 94b379e..e51e060 100644 --- a/app/Http/Resources/SkillResource.php +++ b/app/Http/Resources/SkillResource.php @@ -21,6 +21,8 @@ public function toArray(Request $request): array 'slug' => $this->slug, 'name' => $this->name, 'description' => $this->description, + 'category' => $this->category, + 'skill_type' => $this->skill_type, 'model' => $this->model, 'max_tokens' => $this->max_tokens, 'tools' => $this->tools ?? [], @@ -28,6 +30,9 @@ public function toArray(Request $request): array 'conditions' => $this->conditions, 'template_variables' => $this->template_variables, 'body' => $this->body, + 'gotchas' => $this->gotchas, + 'supplementary_files' => $this->supplementary_files ?? [], + 'has_folder' => ! empty($this->gotchas) || ! empty($this->supplementary_files), 'resolved_body' => $resolvedBody, 'tags' => $this->whenLoaded('tags', fn () => $this->tags->pluck('name')->values()), 'project' => new ProjectResource($this->whenLoaded('project')), diff --git a/app/Jobs/ProjectScanJob.php b/app/Jobs/ProjectScanJob.php index e035870..ff32ed2 100644 --- a/app/Jobs/ProjectScanJob.php +++ b/app/Jobs/ProjectScanJob.php @@ -3,15 +3,12 @@ namespace App\Jobs; use App\Models\Project; -use App\Models\Skill; -use App\Models\Tag; -use App\Services\SkillrManifestService; +use App\Services\ProjectScanService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Str; class ProjectScanJob implements ShouldQueue { @@ -21,53 +18,8 @@ public function __construct( public Project $project, ) {} - public function handle(SkillrManifestService $manifestService): void + public function handle(ProjectScanService $scanService): void { - $result = $manifestService->scanProject($this->project->resolved_path); - - foreach ($result['skills'] as $parsedSkill) { - $frontmatter = $parsedSkill['frontmatter']; - $body = $parsedSkill['body']; - $slug = $frontmatter['id'] ?? $parsedSkill['filename']; - - $skill = Skill::updateOrCreate( - [ - 'project_id' => $this->project->id, - 'slug' => $slug, - ], - [ - 'name' => $frontmatter['name'] ?? Str::headline($slug), - 'description' => $frontmatter['description'] ?? null, - 'model' => $frontmatter['model'] ?? null, - 'max_tokens' => $frontmatter['max_tokens'] ?? null, - 'tools' => $frontmatter['tools'] ?? null, - 'includes' => $frontmatter['includes'] ?? null, - 'template_variables' => $frontmatter['template_variables'] ?? null, - 'body' => $body, - ], - ); - - // Sync tags if present in frontmatter - if (! empty($frontmatter['tags']) && is_array($frontmatter['tags'])) { - $tagIds = collect($frontmatter['tags'])->map(function (string $tagName) { - return Tag::firstOrCreate(['name' => trim($tagName)])->id; - }); - - $skill->tags()->sync($tagIds); - } - - // Create v1 snapshot if skill was just created (no versions yet) - if ($skill->versions()->count() === 0) { - $skill->versions()->create([ - 'version_number' => 1, - 'frontmatter' => $frontmatter, - 'body' => $body, - 'note' => 'Initial scan', - 'saved_at' => now(), - ]); - } - } - - $this->project->update(['synced_at' => now()]); + $scanService->scan($this->project); } } diff --git a/app/Models/DesktopAppConfig.php b/app/Models/DesktopAppConfig.php new file mode 100644 index 0000000..c76c04b --- /dev/null +++ b/app/Models/DesktopAppConfig.php @@ -0,0 +1,93 @@ + 'boolean', + 'sync_settings' => 'boolean', + 'managed_keys' => 'array', + 'last_synced_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Known desktop app definitions with default config paths. + */ + public static function knownApps(): array + { + $home = PHP_OS_FAMILY === 'Windows' + ? getenv('USERPROFILE') + : getenv('HOME'); + + return [ + 'claude-desktop' => [ + 'name' => 'Claude Desktop', + 'config_path' => match (PHP_OS_FAMILY) { + 'Darwin' => "{$home}/Library/Application Support/Claude/claude_desktop_config.json", + 'Windows' => "{$home}/AppData/Roaming/Claude/claude_desktop_config.json", + default => "{$home}/.config/claude/claude_desktop_config.json", + }, + 'supports' => ['mcp'], + ], + 'claude-code' => [ + 'name' => 'Claude Code', + 'config_path' => "{$home}/.claude/settings.json", + 'supports' => ['mcp', 'settings'], + ], + 'cursor' => [ + 'name' => 'Cursor', + 'config_path' => "{$home}/.cursor/mcp.json", + 'supports' => ['mcp'], + ], + 'windsurf' => [ + 'name' => 'Windsurf', + 'config_path' => "{$home}/.windsurf/mcp.json", + 'supports' => ['mcp'], + ], + 'codex-cli' => [ + 'name' => 'Codex CLI', + 'config_path' => "{$home}/.codex/config.json", + 'supports' => ['settings'], + ], + ]; + } + + /** + * Detect which known apps have config files present on this machine. + */ + public static function detectInstalled(): array + { + $detected = []; + + foreach (self::knownApps() as $slug => $app) { + $detected[$slug] = array_merge($app, [ + 'slug' => $slug, + 'installed' => file_exists($app['config_path']), + ]); + } + + return $detected; + } +} diff --git a/app/Models/Skill.php b/app/Models/Skill.php index 4bdbfc8..976c5bf 100644 --- a/app/Models/Skill.php +++ b/app/Models/Skill.php @@ -10,17 +10,39 @@ class Skill extends Model { + public const CATEGORIES = [ + 'library-api-reference', + 'product-verification', + 'data-analysis', + 'business-automation', + 'scaffolding-templates', + 'code-quality-review', + 'ci-cd-deployment', + 'incident-runbooks', + 'infrastructure-ops', + 'general', + ]; + + public const SKILL_TYPES = [ + 'capability-uplift', + 'encoded-preference', + ]; + protected $fillable = [ 'uuid', 'project_id', 'slug', 'name', 'description', + 'category', + 'skill_type', 'model', 'max_tokens', 'tools', 'includes', 'body', + 'gotchas', + 'supplementary_files', 'conditions', 'template_variables', ]; @@ -30,6 +52,7 @@ protected function casts(): array return [ 'tools' => 'array', 'includes' => 'array', + 'supplementary_files' => 'array', 'conditions' => 'array', 'template_variables' => 'array', 'max_tokens' => 'integer', diff --git a/app/Models/Webhook.php b/app/Models/Webhook.php index b5b41be..902339e 100644 --- a/app/Models/Webhook.php +++ b/app/Models/Webhook.php @@ -25,6 +25,7 @@ protected function casts(): array 'is_active' => 'boolean', 'last_triggered_at' => 'datetime', 'last_status' => 'integer', + 'secret' => 'encrypted', ]; } diff --git a/app/Models/WorkspaceProfile.php b/app/Models/WorkspaceProfile.php new file mode 100644 index 0000000..648b4d2 --- /dev/null +++ b/app/Models/WorkspaceProfile.php @@ -0,0 +1,48 @@ + 'array', + 'denied_tools' => 'array', + 'default_max_tokens' => 'integer', + 'default_temperature' => 'float', + 'is_default' => 'boolean', + ]; + } + + protected static function booted(): void + { + static::creating(function (WorkspaceProfile $profile) { + if (empty($profile->slug)) { + $profile->slug = Str::slug($profile->name); + } + }); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Policies/ProjectA2aAgentPolicy.php b/app/Policies/ProjectA2aAgentPolicy.php new file mode 100644 index 0000000..346694e --- /dev/null +++ b/app/Policies/ProjectA2aAgentPolicy.php @@ -0,0 +1,35 @@ +belongsToUserOrganization($user, $a2aAgent); + } + + public function update(User $user, ProjectA2aAgent $a2aAgent): bool + { + return $this->belongsToUserOrganization($user, $a2aAgent); + } + + public function delete(User $user, ProjectA2aAgent $a2aAgent): bool + { + return $this->belongsToUserOrganization($user, $a2aAgent); + } + + protected function belongsToUserOrganization(User $user, ProjectA2aAgent $a2aAgent): bool + { + $orgId = $a2aAgent->project?->organization_id; + + if (! $user->current_organization_id && ! $orgId) { + return true; + } + + return $orgId === $user->current_organization_id; + } +} diff --git a/app/Policies/ProjectMcpServerPolicy.php b/app/Policies/ProjectMcpServerPolicy.php new file mode 100644 index 0000000..8ea59a4 --- /dev/null +++ b/app/Policies/ProjectMcpServerPolicy.php @@ -0,0 +1,35 @@ +belongsToUserOrganization($user, $mcpServer); + } + + public function update(User $user, ProjectMcpServer $mcpServer): bool + { + return $this->belongsToUserOrganization($user, $mcpServer); + } + + public function delete(User $user, ProjectMcpServer $mcpServer): bool + { + return $this->belongsToUserOrganization($user, $mcpServer); + } + + protected function belongsToUserOrganization(User $user, ProjectMcpServer $mcpServer): bool + { + $orgId = $mcpServer->project?->organization_id; + + if (! $user->current_organization_id && ! $orgId) { + return true; + } + + return $orgId === $user->current_organization_id; + } +} diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php new file mode 100644 index 0000000..7e3d488 --- /dev/null +++ b/app/Policies/ProjectPolicy.php @@ -0,0 +1,44 @@ +belongsToUserOrganization($user, $project); + } + + public function create(User $user): bool + { + return true; + } + + public function update(User $user, Project $project): bool + { + return $this->belongsToUserOrganization($user, $project); + } + + public function delete(User $user, Project $project): bool + { + return $this->belongsToUserOrganization($user, $project); + } + + protected function belongsToUserOrganization(User $user, Project $project): bool + { + // If no org context, allow (single-user mode / no multi-tenancy configured) + if (! $user->current_organization_id && ! $project->organization_id) { + return true; + } + + return $project->organization_id === $user->current_organization_id; + } +} diff --git a/app/Policies/SkillPolicy.php b/app/Policies/SkillPolicy.php new file mode 100644 index 0000000..8b1da87 --- /dev/null +++ b/app/Policies/SkillPolicy.php @@ -0,0 +1,35 @@ +belongsToUserOrganization($user, $skill); + } + + public function update(User $user, Skill $skill): bool + { + return $this->belongsToUserOrganization($user, $skill); + } + + public function delete(User $user, Skill $skill): bool + { + return $this->belongsToUserOrganization($user, $skill); + } + + protected function belongsToUserOrganization(User $user, Skill $skill): bool + { + $orgId = $skill->project?->organization_id; + + if (! $user->current_organization_id && ! $orgId) { + return true; + } + + return $orgId === $user->current_organization_id; + } +} diff --git a/app/Policies/WebhookPolicy.php b/app/Policies/WebhookPolicy.php new file mode 100644 index 0000000..c7f7ab8 --- /dev/null +++ b/app/Policies/WebhookPolicy.php @@ -0,0 +1,35 @@ +belongsToUserOrganization($user, $webhook); + } + + public function update(User $user, Webhook $webhook): bool + { + return $this->belongsToUserOrganization($user, $webhook); + } + + public function delete(User $user, Webhook $webhook): bool + { + return $this->belongsToUserOrganization($user, $webhook); + } + + protected function belongsToUserOrganization(User $user, Webhook $webhook): bool + { + $orgId = $webhook->project?->organization_id; + + if (! $user->current_organization_id && ! $orgId) { + return true; + } + + return $orgId === $user->current_organization_id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..9369baf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,17 @@ namespace App\Providers; +use App\Models\Project; +use App\Models\ProjectA2aAgent; +use App\Models\ProjectMcpServer; +use App\Models\Skill; +use App\Models\Webhook; +use App\Policies\ProjectA2aAgentPolicy; +use App\Policies\ProjectMcpServerPolicy; +use App\Policies\ProjectPolicy; +use App\Policies\SkillPolicy; +use App\Policies\WebhookPolicy; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +30,10 @@ public function register(): void */ public function boot(): void { - // + Gate::policy(Project::class, ProjectPolicy::class); + Gate::policy(Skill::class, SkillPolicy::class); + Gate::policy(Webhook::class, WebhookPolicy::class); + Gate::policy(ProjectMcpServer::class, ProjectMcpServerPolicy::class); + Gate::policy(ProjectA2aAgent::class, ProjectA2aAgentPolicy::class); } } diff --git a/app/Services/BundleExportService.php b/app/Services/BundleExportService.php index 15952d7..f5e73df 100644 --- a/app/Services/BundleExportService.php +++ b/app/Services/BundleExportService.php @@ -166,6 +166,8 @@ protected function buildSkillFrontmatter(Skill $skill): array 'id' => $skill->slug, 'name' => $skill->name, 'description' => $skill->description, + 'category' => $skill->category, + 'skill_type' => $skill->skill_type, 'tags' => $skill->tags->pluck('name')->values()->all(), 'model' => $skill->model, 'max_tokens' => $skill->max_tokens, diff --git a/app/Services/BundleImportService.php b/app/Services/BundleImportService.php index a8c9fac..8810436 100644 --- a/app/Services/BundleImportService.php +++ b/app/Services/BundleImportService.php @@ -171,12 +171,15 @@ public function extractZip(string $path): array 'slug' => $fm['id'] ?? basename($name, '.md'), 'name' => $fm['name'] ?? basename($name, '.md'), 'description' => $fm['description'] ?? null, + 'category' => $fm['category'] ?? null, + 'skill_type' => $fm['skill_type'] ?? null, 'model' => $fm['model'] ?? null, 'max_tokens' => $fm['max_tokens'] ?? null, 'tools' => $fm['tools'] ?? [], 'includes' => $fm['includes'] ?? [], 'tags' => $fm['tags'] ?? [], 'body' => $parsed['body'], + 'gotchas' => $fm['gotchas'] ?? null, ]; } } @@ -205,11 +208,14 @@ protected function importSkill(Project $project, array $data, string $conflictMo $existing->update([ 'name' => $data['name'], 'description' => $data['description'] ?? null, + 'category' => $data['category'] ?? null, + 'skill_type' => $data['skill_type'] ?? null, 'model' => $data['model'] ?? null, 'max_tokens' => $data['max_tokens'] ?? null, 'tools' => $data['tools'] ?? [], 'includes' => $data['includes'] ?? [], 'body' => $data['body'] ?? '', + 'gotchas' => $data['gotchas'] ?? null, ]); $this->syncSkillTags($existing, $data['tags'] ?? []); $this->createVersion($existing); @@ -232,11 +238,14 @@ protected function importSkill(Project $project, array $data, string $conflictMo 'slug' => $slug, 'name' => $data['name'], 'description' => $data['description'] ?? null, + 'category' => $data['category'] ?? null, + 'skill_type' => $data['skill_type'] ?? null, 'model' => $data['model'] ?? null, 'max_tokens' => $data['max_tokens'] ?? null, 'tools' => $data['tools'] ?? [], 'includes' => $data['includes'] ?? [], 'body' => $data['body'] ?? '', + 'gotchas' => $data['gotchas'] ?? null, ]); $this->syncSkillTags($skill, $data['tags'] ?? []); @@ -289,12 +298,15 @@ protected function createVersion($skill): void 'id' => $skill->slug, 'name' => $skill->name, 'description' => $skill->description, + 'category' => $skill->category, + 'skill_type' => $skill->skill_type, 'model' => $skill->model, 'max_tokens' => $skill->max_tokens, 'tools' => $skill->tools, 'tags' => $skill->tags->pluck('name')->values()->all(), ], 'body' => $skill->body, + 'gotchas' => $skill->gotchas, 'saved_at' => now(), ]); } @@ -305,6 +317,8 @@ protected function writeSkillFile(Project $project, $skill): void 'id' => $skill->slug, 'name' => $skill->name, 'description' => $skill->description, + 'category' => $skill->category, + 'skill_type' => $skill->skill_type, 'tags' => $skill->tags->pluck('name')->values()->all(), 'model' => $skill->model, 'max_tokens' => $skill->max_tokens, @@ -314,6 +328,12 @@ protected function writeSkillFile(Project $project, $skill): void 'updated_at' => $skill->updated_at->toIso8601String(), ]; - $this->manifestService->writeSkillFile($project->resolved_path, $frontmatter, $skill->body ?? ''); + $this->manifestService->writeSkillFile( + $project->resolved_path, + $frontmatter, + $skill->body ?? '', + $skill->gotchas, + $skill->supplementary_files ?? [], + ); } } diff --git a/app/Services/DesktopSyncService.php b/app/Services/DesktopSyncService.php new file mode 100644 index 0000000..f48878c --- /dev/null +++ b/app/Services/DesktopSyncService.php @@ -0,0 +1,293 @@ +id)->get(); + $results = []; + + foreach ($configs as $config) { + $results[$config->app_slug] = $this->syncApp($config, $user, $projectId); + } + + return $results; + } + + /** + * Sync MCP servers and/or settings to a specific desktop app. + */ + public function syncApp(DesktopAppConfig $config, User $user, ?int $projectId = null): array + { + $result = ['app' => $config->app_slug, 'mcp_synced' => false, 'settings_synced' => false, 'error' => null]; + + try { + $existing = $this->readConfigFile($config->config_path); + + if ($config->sync_mcp) { + $servers = $this->getMcpServers($user, $projectId); + $existing = $this->mergeMcpServers($existing, $servers, $config->app_slug); + $result['mcp_synced'] = true; + } + + if ($config->sync_settings) { + $profile = WorkspaceProfile::where('user_id', $user->id) + ->where('is_default', true) + ->first(); + + if ($profile) { + $existing = $this->mergeSettings($existing, $profile, $config->app_slug); + $result['settings_synced'] = true; + } + } + + $this->writeConfigFile($config->config_path, $existing); + $config->update(['last_synced_at' => now()]); + } catch (\Throwable $e) { + $result['error'] = $e->getMessage(); + } + + return $result; + } + + /** + * Generate a preview of what would be written without actually writing. + */ + public function preview(DesktopAppConfig $config, User $user, ?int $projectId = null): array + { + $current = $this->readConfigFile($config->config_path); + $proposed = $current; + + if ($config->sync_mcp) { + $servers = $this->getMcpServers($user, $projectId); + $proposed = $this->mergeMcpServers($proposed, $servers, $config->app_slug); + } + + if ($config->sync_settings) { + $profile = WorkspaceProfile::where('user_id', $user->id) + ->where('is_default', true) + ->first(); + + if ($profile) { + $proposed = $this->mergeSettings($proposed, $profile, $config->app_slug); + } + } + + return [ + 'current' => json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + 'proposed' => json_encode($proposed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + ]; + } + + /** + * Detect and import MCP servers from existing desktop config files. + */ + public function importMcpServers(User $user, int $projectId): array + { + $imported = 0; + $skipped = 0; + $sources = []; + + $knownApps = DesktopAppConfig::knownApps(); + + foreach ($knownApps as $slug => $app) { + if (! in_array('mcp', $app['supports'])) { + continue; + } + + if (! file_exists($app['config_path'])) { + continue; + } + + $config = $this->readConfigFile($app['config_path']); + $mcpServers = $config['mcpServers'] ?? []; + + if (empty($mcpServers)) { + continue; + } + + foreach ($mcpServers as $name => $entry) { + $exists = ProjectMcpServer::where('project_id', $projectId) + ->where('name', $name) + ->exists(); + + if ($exists) { + $skipped++; + + continue; + } + + $transport = isset($entry['command']) ? 'stdio' : 'sse'; + + ProjectMcpServer::create([ + 'project_id' => $projectId, + 'name' => $name, + 'transport' => $transport, + 'command' => $entry['command'] ?? null, + 'args' => $entry['args'] ?? null, + 'url' => $entry['url'] ?? null, + 'env' => $entry['env'] ?? null, + 'headers' => $entry['headers'] ?? null, + 'enabled' => true, + ]); + + $imported++; + } + + if (! empty($mcpServers)) { + $sources[$slug] = count($mcpServers); + } + } + + return [ + 'imported' => $imported, + 'skipped' => $skipped, + 'sources' => $sources, + ]; + } + + /** + * Get all enabled MCP servers for the user, optionally scoped to a project. + */ + protected function getMcpServers(User $user, ?int $projectId): Collection + { + $query = ProjectMcpServer::where('enabled', true); + + if ($projectId) { + $query->where('project_id', $projectId); + } else { + // All projects in the user's current organization + $query->whereHas('project', function ($q) use ($user) { + if ($user->current_organization_id) { + $q->where('organization_id', $user->current_organization_id); + } + }); + } + + return $query->get(); + } + + /** + * Build the mcpServers JSON object from server models. + */ + protected function buildMcpServersJson(Collection $servers): array + { + $mcpServers = []; + + foreach ($servers as $server) { + $entry = []; + + if ($server->transport === 'stdio') { + if ($server->command) { + $entry['command'] = $server->command; + } + if (! empty($server->args)) { + $entry['args'] = $server->args; + } + } else { + if ($server->url) { + $entry['url'] = $server->url; + } + if (! empty($server->headers)) { + $entry['headers'] = $server->headers; + } + } + + if (! empty($server->env)) { + $entry['env'] = $server->env; + } + + $mcpServers[$server->name] = $entry; + } + + return $mcpServers; + } + + /** + * Merge MCP servers into an existing config, preserving non-MCP keys. + */ + protected function mergeMcpServers(array $config, Collection $servers, string $appSlug): array + { + $mcpServers = $this->buildMcpServersJson($servers); + + // Each app stores mcpServers at the same key, just different file locations + $config['mcpServers'] = $mcpServers; + + return $config; + } + + /** + * Merge workspace profile settings into a config for a specific app. + */ + protected function mergeSettings(array $config, WorkspaceProfile $profile, string $appSlug): array + { + return match ($appSlug) { + 'claude-code' => $this->mergeClaudeCodeSettings($config, $profile), + 'codex-cli' => $this->mergeCodexSettings($config, $profile), + default => $config, + }; + } + + protected function mergeClaudeCodeSettings(array $config, WorkspaceProfile $profile): array + { + if ($profile->allowed_tools) { + $config['allowedTools'] = $profile->allowed_tools; + } + + if ($profile->denied_tools) { + $config['deniedTools'] = $profile->denied_tools; + } + + return $config; + } + + protected function mergeCodexSettings(array $config, WorkspaceProfile $profile): array + { + if ($profile->default_model) { + $config['model'] = $profile->default_model; + } + + if ($profile->approval_mode) { + $config['approvalMode'] = $profile->approval_mode; + } + + return $config; + } + + protected function readConfigFile(string $path): array + { + if (! file_exists($path)) { + return []; + } + + $content = file_get_contents($path); + $decoded = json_decode($content, true); + + return is_array($decoded) ? $decoded : []; + } + + protected function writeConfigFile(string $path, array $config): void + { + $dir = dirname($path); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents( + $path, + json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n", + ); + } +} diff --git a/app/Services/ProjectScanService.php b/app/Services/ProjectScanService.php new file mode 100644 index 0000000..973ac4f --- /dev/null +++ b/app/Services/ProjectScanService.php @@ -0,0 +1,137 @@ +resolved_path; + + // Phase 1: Scan .skillr/skills/ + $skillrResult = $this->scanSkillrDirectory($project, $path); + + // Phase 2: Auto-detect and import from provider config files + $providerResult = $this->importFromProviders($project, $path); + + $project->update(['synced_at' => now()]); + + return [ + 'skillr' => $skillrResult, + 'providers' => $providerResult, + 'total_skills' => $project->skills()->count(), + ]; + } + + /** + * Scan .skillr/skills/ directory — existing behavior from ProjectScanJob. + */ + protected function scanSkillrDirectory(Project $project, string $path): array + { + $result = $this->manifestService->scanProject($path); + + $found = count($result['skills']); + $created = 0; + $updated = 0; + + foreach ($result['skills'] as $parsedSkill) { + $frontmatter = $parsedSkill['frontmatter']; + $body = $parsedSkill['body']; + $slug = $frontmatter['id'] ?? $parsedSkill['filename']; + + $existing = $project->skills()->where('slug', $slug)->first(); + + $skill = Skill::updateOrCreate( + [ + 'project_id' => $project->id, + 'slug' => $slug, + ], + [ + 'name' => $frontmatter['name'] ?? Str::headline($slug), + 'description' => $frontmatter['description'] ?? null, + 'category' => $frontmatter['category'] ?? null, + 'skill_type' => $frontmatter['skill_type'] ?? null, + 'model' => $frontmatter['model'] ?? null, + 'max_tokens' => $frontmatter['max_tokens'] ?? null, + 'tools' => $frontmatter['tools'] ?? null, + 'includes' => $frontmatter['includes'] ?? null, + 'template_variables' => $frontmatter['template_variables'] ?? null, + 'body' => $body, + 'gotchas' => $frontmatter['gotchas'] ?? null, + ], + ); + + if ($existing) { + $updated++; + } else { + $created++; + } + + if (! empty($frontmatter['tags']) && is_array($frontmatter['tags'])) { + $tagIds = collect($frontmatter['tags'])->map(function (string $tagName) { + return Tag::firstOrCreate(['name' => trim($tagName)])->id; + }); + + $skill->tags()->sync($tagIds); + } + + if ($skill->versions()->count() === 0) { + $skill->versions()->create([ + 'version_number' => 1, + 'frontmatter' => $frontmatter, + 'body' => $body, + 'note' => 'Initial scan', + 'saved_at' => now(), + ]); + } + } + + return [ + 'found' => $found, + 'created' => $created, + 'updated' => $updated, + ]; + } + + /** + * Detect and import skills from all provider config files. + */ + protected function importFromProviders(Project $project, string $path): array + { + $detected = $this->importService->detect($path); + + if (empty($detected)) { + return [ + 'detected' => [], + 'imported' => 0, + 'skipped' => 0, + ]; + } + + $result = $this->importService->import($project, $detected); + + $detectedCounts = []; + foreach ($detected as $provider => $skills) { + $detectedCounts[$provider] = count($skills); + } + + return [ + 'detected' => $detectedCounts, + 'imported' => $result['created'], + 'skipped' => $result['skipped'], + ]; + } +} diff --git a/app/Services/PromptLinter.php b/app/Services/PromptLinter.php index 1295361..94a28ac 100644 --- a/app/Services/PromptLinter.php +++ b/app/Services/PromptLinter.php @@ -9,7 +9,10 @@ class PromptLinter * * @return array */ - public function lint(string $body): array + /** + * @param array{description?: string|null, gotchas?: string|null, skill_type?: string|null} $context + */ + public function lint(string $body, array $context = []): array { if (empty(trim($body))) { return [[ @@ -32,6 +35,9 @@ public function lint(string $body): array $this->checkRoleConfusion($body, $issues); $this->checkMissingExamples($body, $issues); $this->checkRedundancy($lines, $issues); + $this->checkMissingGotchas($body, $context['gotchas'] ?? null, $issues); + $this->checkVagueDescription($context['description'] ?? null, $issues); + $this->checkMissingSkillType($context['skill_type'] ?? null, $issues); return $issues; } @@ -199,4 +205,78 @@ protected function checkRedundancy(array $lines, array &$issues): void $normalized[] = [$idx, $trimmed]; } } + + protected function checkMissingGotchas(string $body, ?string $gotchas, array &$issues): void + { + $tokens = (int) ceil(mb_strlen($body) / 4); + + if ($tokens <= 500) { + return; + } + + $hasGotchasField = ! empty(trim($gotchas ?? '')); + $hasGotchasSection = preg_match('/^##\s*(gotchas|common pitfalls|common mistakes|failure points)/im', $body); + + if (! $hasGotchasField && ! $hasGotchasSection) { + $issues[] = [ + 'severity' => 'suggestion', + 'rule' => 'missing_gotchas', + '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.', + 'line' => null, + ]; + } + } + + protected function checkVagueDescription(?string $description, array &$issues): void + { + if ($description === null) { + return; + } + + $trimmed = trim($description); + + if (mb_strlen($trimmed) > 0 && mb_strlen($trimmed) < 20) { + $issues[] = [ + 'severity' => 'warning', + 'rule' => 'vague_description', + 'message' => 'Skill description is too short. Vague descriptions cause poor agent triggering.', + 'suggestion' => 'Write a specific description that tells the agent exactly when this skill applies.', + 'line' => null, + ]; + + return; + } + + $vaguePatterns = [ + '/^(helps? with|does stuff|general purpose|useful for|a skill that|this skill)/i', + ]; + + foreach ($vaguePatterns as $pattern) { + if (preg_match($pattern, $trimmed)) { + $issues[] = [ + 'severity' => 'warning', + 'rule' => 'vague_description', + 'message' => 'Skill description may be too generic for reliable agent triggering.', + 'suggestion' => 'Write a specific description that tells the agent exactly when this skill applies.', + 'line' => null, + ]; + + return; + } + } + } + + protected function checkMissingSkillType(?string $skillType, array &$issues): void + { + if (empty($skillType)) { + $issues[] = [ + 'severity' => 'suggestion', + 'rule' => 'missing_skill_type', + 'message' => 'No skill type set. Classifying as "capability uplift" or "encoded preference" helps with testing strategy.', + 'suggestion' => 'Set skill type to clarify whether this skill teaches new capabilities or encodes team processes.', + 'line' => null, + ]; + } + } } diff --git a/app/Services/ProviderImportService.php b/app/Services/ProviderImportService.php index 3c5dd27..f287100 100644 --- a/app/Services/ProviderImportService.php +++ b/app/Services/ProviderImportService.php @@ -17,6 +17,7 @@ class ProviderImportService 'windsurf' => 'parseWindsurf', 'cline' => 'parseCline', 'openai' => 'parseOpenAI', + 'codex' => 'parseCodex', ]; /** @@ -74,15 +75,16 @@ public function import(Project $project, array $detected): array 'includes' => [], ]); - // Attach tags - if (! empty($skillData['tags'])) { - $tagIds = []; - foreach ($skillData['tags'] as $tagName) { - $tag = \App\Models\Tag::firstOrCreate(['name' => $tagName]); - $tagIds[] = $tag->id; - } - $skill->tags()->sync($tagIds); + // Attach tags — include source provider tag for traceability + $tagNames = $skillData['tags'] ?? []; + $tagNames[] = "imported:{$provider}"; + + $tagIds = []; + foreach ($tagNames as $tagName) { + $tag = \App\Models\Tag::firstOrCreate(['name' => trim($tagName)]); + $tagIds[] = $tag->id; } + $skill->tags()->sync($tagIds); // Create initial version $skill->versions()->create([ @@ -91,7 +93,7 @@ public function import(Project $project, array $detected): array 'id' => $slug, 'name' => $skillData['name'], 'description' => $skillData['description'] ?? null, - 'tags' => $skillData['tags'] ?? [], + 'tags' => $tagNames, ], 'body' => $skillData['body'], 'note' => "Imported from {$provider}", @@ -99,7 +101,23 @@ public function import(Project $project, array $detected): array ]); // Write to .skillr/skills/ - app(SkillrManifestService::class)->writeSkillFile($project, $skill); + $frontmatter = [ + 'id' => $slug, + 'name' => $skillData['name'], + 'description' => $skillData['description'] ?? null, + 'tags' => $tagNames, + 'model' => $skillData['model'] ?? null, + 'tools' => [], + 'includes' => [], + 'created_at' => $skill->created_at->toIso8601String(), + 'updated_at' => $skill->updated_at->toIso8601String(), + ]; + + app(SkillrManifestService::class)->writeSkillFile( + $project->resolved_path, + $frontmatter, + $skillData['body'], + ); $existingSlugs[] = $slug; $created++; @@ -191,7 +209,6 @@ private function parseWindsurf(string $path): array $content = File::get($file); $slug = pathinfo($file, PATHINFO_FILENAME); - // Extract name from H1 if present $name = Str::title(str_replace('-', ' ', $slug)); $body = $content; @@ -241,6 +258,50 @@ private function parseOpenAI(string $path): array return $this->parseMarkdownHeadings(File::get($file), 2); } + /** + * Parse Codex CLI: AGENTS.md at project root (H2 sections) + .codex/*.md files + */ + private function parseCodex(string $path): array + { + $skills = []; + + // Check for AGENTS.md at project root + $agentsFile = $path . '/AGENTS.md'; + if (File::exists($agentsFile)) { + $skills = array_merge($skills, $this->parseMarkdownHeadings(File::get($agentsFile), 2)); + } + + // Check for .codex/ directory with .md files + $codexDir = $path . '/.codex'; + if (File::isDirectory($codexDir)) { + foreach (File::glob($codexDir . '/*.md') as $file) { + $content = File::get($file); + $slug = pathinfo($file, PATHINFO_FILENAME); + + $name = Str::title(str_replace('-', ' ', $slug)); + $body = $content; + + if (preg_match('/^#\s+(.+)$/m', $content, $matches)) { + $name = trim($matches[1]); + $body = trim(preg_replace('/^#\s+.+\n*/m', '', $content, 1)); + } + + if (! empty(trim($body))) { + $skills[] = [ + 'name' => $name, + 'slug' => $slug, + 'description' => null, + 'body' => $body, + 'tags' => [], + 'model' => null, + ]; + } + } + } + + return $skills; + } + /** * Split markdown content by heading level into individual skills. */ @@ -257,13 +318,10 @@ private function parseMarkdownHeadings(string $content, int $level): array $skills = []; - // parts[0] = content before first heading (skip) - // parts[1] = first heading name, parts[2] = first body, etc. for ($i = 1; $i < count($parts); $i += 2) { $name = trim($parts[$i]); $body = isset($parts[$i + 1]) ? trim($parts[$i + 1]) : ''; - // Strip trailing horizontal rule $body = preg_replace('/\n---\s*$/', '', $body); $body = trim($body); @@ -271,7 +329,6 @@ private function parseMarkdownHeadings(string $content, int $level): array continue; } - // Skip meta headings if (in_array(strtolower($name), ['claude.md', 'github copilot instructions', 'agents'])) { continue; } diff --git a/app/Services/Providers/ClaudeDriver.php b/app/Services/Providers/ClaudeDriver.php index 3c12ac1..0f2a038 100644 --- a/app/Services/Providers/ClaudeDriver.php +++ b/app/Services/Providers/ClaudeDriver.php @@ -23,7 +23,11 @@ public function generate(Project $project, Collection $skills, array $composedAg $output .= "> **Applies to:** `{$patterns}`\n\n"; } - $output .= "{$body}\n\n---\n\n"; + $output .= "{$body}\n\n"; + if (! empty($skill->gotchas)) { + $output .= "### Common Gotchas\n\n{$skill->gotchas}\n\n"; + } + $output .= "---\n\n"; } if (! empty($composedAgents)) { diff --git a/app/Services/Providers/ClineDriver.php b/app/Services/Providers/ClineDriver.php index 51a3f61..a689935 100644 --- a/app/Services/Providers/ClineDriver.php +++ b/app/Services/Providers/ClineDriver.php @@ -23,7 +23,11 @@ public function generate(Project $project, Collection $skills, array $composedAg $output .= "> **Applies to:** `{$patterns}`\n\n"; } - $output .= "{$body}\n\n---\n\n"; + $output .= "{$body}\n\n"; + if (! empty($skill->gotchas)) { + $output .= "## Common Gotchas\n\n{$skill->gotchas}\n\n"; + } + $output .= "---\n\n"; } if (! empty($composedAgents)) { diff --git a/app/Services/Providers/CopilotDriver.php b/app/Services/Providers/CopilotDriver.php index 6c2d615..8466418 100644 --- a/app/Services/Providers/CopilotDriver.php +++ b/app/Services/Providers/CopilotDriver.php @@ -23,7 +23,11 @@ public function generate(Project $project, Collection $skills, array $composedAg $output .= "> **Applies to:** `{$patterns}`\n\n"; } - $output .= "{$body}\n\n---\n\n"; + $output .= "{$body}\n\n"; + if (! empty($skill->gotchas)) { + $output .= "### Common Gotchas\n\n{$skill->gotchas}\n\n"; + } + $output .= "---\n\n"; } if (! empty($composedAgents)) { diff --git a/app/Services/Providers/CursorDriver.php b/app/Services/Providers/CursorDriver.php index f48508d..be30a19 100644 --- a/app/Services/Providers/CursorDriver.php +++ b/app/Services/Providers/CursorDriver.php @@ -36,6 +36,9 @@ public function generate(Project $project, Collection $skills, array $composedAg $yaml = Yaml::dump($frontmatter, 2, 2); $content = "---\n{$yaml}---\n\n{$body}\n"; + if (! empty($skill->gotchas)) { + $content .= "\n## Common Gotchas\n\n{$skill->gotchas}\n"; + } $files[$dir . '/' . $skill->slug . '.mdc'] = $content; } diff --git a/app/Services/Providers/OpenAIDriver.php b/app/Services/Providers/OpenAIDriver.php index baa267f..d89531a 100644 --- a/app/Services/Providers/OpenAIDriver.php +++ b/app/Services/Providers/OpenAIDriver.php @@ -14,7 +14,11 @@ public function generate(Project $project, Collection $skills, array $composedAg foreach ($skills as $skill) { $body = $resolvedBodies[$skill->id] ?? $skill->body; - $output .= "## {$skill->name}\n\n{$body}\n\n---\n\n"; + $output .= "## {$skill->name}\n\n{$body}\n\n"; + if (! empty($skill->gotchas)) { + $output .= "### Common Gotchas\n\n{$skill->gotchas}\n\n"; + } + $output .= "---\n\n"; } if (! empty($composedAgents)) { diff --git a/app/Services/Providers/WindsurfDriver.php b/app/Services/Providers/WindsurfDriver.php index a97ed5b..159e762 100644 --- a/app/Services/Providers/WindsurfDriver.php +++ b/app/Services/Providers/WindsurfDriver.php @@ -27,6 +27,9 @@ public function generate(Project $project, Collection $skills, array $composedAg } $content .= "{$body}\n"; + if (! empty($skill->gotchas)) { + $content .= "\n## Common Gotchas\n\n{$skill->gotchas}\n"; + } $files[$dir . '/' . $skill->slug . '.md'] = $content; } diff --git a/app/Services/SkillCompositionService.php b/app/Services/SkillCompositionService.php index 3bf5778..2b3b04f 100644 --- a/app/Services/SkillCompositionService.php +++ b/app/Services/SkillCompositionService.php @@ -52,6 +52,41 @@ public function resolve(Skill $skill, array $visited = [], int $depth = 0): stri return implode("\n\n", array_filter($sections)); } + /** + * Resolve a skill at a specific progressive disclosure level. + * + * Level 1: Name + description (~100 tokens, for agent discovery) + * Level 2: Full resolved body (standard compose behavior) + * Level 3: Body + gotchas + supplementary files (deep context) + */ + public function resolveAtLevel(Skill $skill, int $level = 2): string + { + return match ($level) { + 1 => "{$skill->name}: " . ($skill->description ?? 'No description'), + 3 => $this->resolveLevel3($skill), + default => $this->resolve($skill), + }; + } + + protected function resolveLevel3(Skill $skill): string + { + $sections = [$this->resolve($skill)]; + + if (! empty($skill->gotchas)) { + $sections[] = "## Common Gotchas\n\n{$skill->gotchas}"; + } + + foreach ($skill->supplementary_files ?? [] as $file) { + $path = $file['path'] ?? 'unknown'; + $content = $file['content'] ?? ''; + if (! empty($content)) { + $sections[] = "## {$path}\n\n{$content}"; + } + } + + return implode("\n\n", array_filter($sections)); + } + /** * Validate includes for a skill and return errors. * diff --git a/app/Services/SkillrManifestService.php b/app/Services/SkillrManifestService.php index 3793456..111d254 100644 --- a/app/Services/SkillrManifestService.php +++ b/app/Services/SkillrManifestService.php @@ -13,6 +13,7 @@ public function __construct( /** * Scan a project directory and return manifest + parsed skills. + * Supports both flat .md files and folder-based skills (slug/skill.md). * * @return array{manifest: array|null, skills: array} */ @@ -29,13 +30,51 @@ public function scanProject(string $absolutePath): array $skillsDir = $skillrDir . '/skills'; if (File::isDirectory($skillsDir)) { + // Flat .md files $files = File::glob($skillsDir . '/*.md'); - foreach ($files as $file) { $parsed = $this->parser->parseFile($file); $parsed['filename'] = basename($file, '.md'); $skills[] = $parsed; } + + // Folder-based skills: {slug}/skill.md + $dirs = File::directories($skillsDir); + foreach ($dirs as $dir) { + $skillFile = $dir . '/skill.md'; + if (! File::exists($skillFile)) { + continue; + } + + $parsed = $this->parser->parseFile($skillFile); + $parsed['filename'] = basename($dir); + + // Read gotchas.md if it exists + $gotchasFile = $dir . '/gotchas.md'; + if (File::exists($gotchasFile)) { + $parsed['frontmatter']['gotchas'] = File::get($gotchasFile); + } + + // Discover supplementary files (everything except skill.md and gotchas.md) + $supplementary = []; + $allFiles = File::allFiles($dir); + foreach ($allFiles as $supplementaryFile) { + $relativePath = $supplementaryFile->getRelativePathname(); + if (in_array($relativePath, ['skill.md', 'gotchas.md'])) { + continue; + } + $supplementary[] = [ + 'path' => $relativePath, + 'content' => File::get($supplementaryFile->getPathname()), + ]; + } + + if (! empty($supplementary)) { + $parsed['frontmatter']['supplementary_files'] = $supplementary; + } + + $skills[] = $parsed; + } } return [ @@ -92,33 +131,84 @@ public function scaffoldProject(string $absolutePath, string $name): void /** * Write a skill file to the project's .skillr/skills/ directory. + * Uses folder format ({slug}/skill.md + gotchas.md) when the skill has gotchas + * or supplementary files. Otherwise uses flat format ({slug}.md). */ - public function writeSkillFile(string $projectPath, array $frontmatter, string $body): void + public function writeSkillFile(string $projectPath, array $frontmatter, string $body, ?string $gotchas = null, array $supplementaryFiles = []): void { $slug = $frontmatter['id'] ?? \Illuminate\Support\Str::slug($frontmatter['name'] ?? 'untitled'); - $dir = rtrim($projectPath, '/') . '/.skillr/skills'; + $skillsDir = rtrim($projectPath, '/') . '/.skillr/skills'; + $useFolderFormat = ! empty($gotchas) || ! empty($supplementaryFiles); - File::ensureDirectoryExists($dir); - File::put($dir . '/' . $slug . '.md', $this->parser->renderFile($frontmatter, $body)); + File::ensureDirectoryExists($skillsDir); + + if ($useFolderFormat) { + // Remove flat file if it exists (upgrading to folder format) + $flatFile = $skillsDir . '/' . $slug . '.md'; + if (File::exists($flatFile)) { + File::delete($flatFile); + } + + $skillDir = $skillsDir . '/' . $slug; + File::ensureDirectoryExists($skillDir); + + // Write main skill.md + File::put($skillDir . '/skill.md', $this->parser->renderFile($frontmatter, $body)); + + // Write gotchas.md + if (! empty($gotchas)) { + File::put($skillDir . '/gotchas.md', $gotchas); + } elseif (File::exists($skillDir . '/gotchas.md')) { + File::delete($skillDir . '/gotchas.md'); + } + + // Write supplementary files + foreach ($supplementaryFiles as $file) { + $filePath = $skillDir . '/' . $file['path']; + File::ensureDirectoryExists(dirname($filePath)); + File::put($filePath, $file['content']); + } + } else { + // Remove folder if it exists (downgrading to flat format) + $folderPath = $skillsDir . '/' . $slug; + if (File::isDirectory($folderPath)) { + File::deleteDirectory($folderPath); + } + + File::put($skillsDir . '/' . $slug . '.md', $this->parser->renderFile($frontmatter, $body)); + } } /** * Delete a skill file from the project's .skillr/skills/ directory. + * Handles both flat files and folder-based skills. */ public function deleteSkillFile(string $projectPath, string $slug): void { - $file = rtrim($projectPath, '/') . '/.skillr/skills/' . $slug . '.md'; + $skillsDir = rtrim($projectPath, '/') . '/.skillr/skills'; + // Delete flat file + $file = $skillsDir . '/' . $slug . '.md'; if (File::exists($file)) { File::delete($file); } + + // Delete folder + $folder = $skillsDir . '/' . $slug; + if (File::isDirectory($folder)) { + File::deleteDirectory($folder); + } } /** * Check if a skill file exists in the project's .skillr/skills/ directory. + * Checks both flat file and folder format. */ public function skillExists(string $projectPath, string $slug): bool { - return File::exists(rtrim($projectPath, '/') . '/.skillr/skills/' . $slug . '.md'); + $skillsDir = rtrim($projectPath, '/') . '/.skillr/skills'; + + return File::exists($skillsDir . '/' . $slug . '.md') + || File::exists($skillsDir . '/' . $slug . '/skill.md'); } } diff --git a/article/skillr-medium-article.md b/article/skillr-medium-article.md new file mode 100644 index 0000000..5393517 --- /dev/null +++ b/article/skillr-medium-article.md @@ -0,0 +1,197 @@ +# The AI Tool Fragmentation Problem Nobody Talks About + +## How managing prompts across Claude, Codex, Cursor, Windsurf, and Copilot led me to build Skillr + +--- + +Your senior backend engineer swears by Claude Code in the terminal. Your frontend lead lives in Cursor. The mobile developer just switched to Windsurf. Someone on the team still uses Copilot in VS Code, and your DevOps person just discovered Codex CLI. + +Every one of them has carefully crafted instructions telling their AI assistant how to work with your codebase. Coding standards. Framework conventions. Testing patterns. Security rules. Architecture decisions. + +And none of those instructions are the same. + +Welcome to the AI tool fragmentation problem. + +--- + +## The Hidden Cost of "Use Whatever Works for You" + +Most engineering teams today have adopted a pragmatic stance toward AI coding assistants: use what makes you productive. It's a reasonable position. These tools are evolving fast, developers have strong preferences, and forcing everyone onto one platform creates friction. + +But there's an unspoken cost to this freedom. + +When your Claude user has a `.claude/CLAUDE.md` file that says "always use repository pattern for data access," and your Cursor user has a `.cursor/rules/architecture.mdc` file that says "keep controllers thin, put logic in services," and your Copilot user has a `.github/copilot-instructions.md` that says nothing about architecture at all — you don't have a team coding standard. You have three different AI assistants with three different understandings of how your codebase should work. + +The AI writes code that reflects whatever instructions it was given. If those instructions diverge across your team, the AI-assisted code diverges too. Code reviews catch some of this, but not all. And certainly not the subtle drift in style, naming, and structural decisions that accumulates over weeks and months. + +I hit this problem firsthand managing a team where every developer used a different AI tool. We'd agree on conventions in a meeting, and then each person would go update their own tool's config file — if they remembered to. There was no single source of truth. No way to know if everyone's AI assistant was actually aligned. No way to share a well-crafted prompt across tools without manually reformatting it for each provider's config format. + +That's when I started building Skillr. + +--- + +## What If Prompts Were Portable? + +The core insight was simple: **the instructions you give an AI coding assistant are not tool-specific.** Whether you're telling Claude, Cursor, Copilot, or Windsurf to "use TypeScript strict mode" or "follow our error handling patterns," the intent is the same. The only thing that differs is the file format and location where each tool expects to find those instructions. + +So what if you wrote your instructions once, in a single canonical format, and then generated each tool's native config from that source? + +That's the fundamental idea behind Skillr. You define "skills" — reusable prompt + configuration blocks — in a provider-agnostic format. Each skill is a Markdown file with YAML frontmatter, stored in a `.skillr/` directory: + +```markdown +--- +id: error-handling +name: Error Handling Standards +description: Consistent error handling across the codebase +tags: [standards, errors] +--- + +Always use custom exception classes that extend the base AppException. +Never catch generic \Exception unless re-throwing. +Log errors with structured context: user_id, request_id, operation name. +Return consistent error response shapes from API endpoints: +{ "error": { "code": "...", "message": "..." } } +``` + +This one file becomes: +- An H2 section in `.claude/CLAUDE.md` for Claude Code users +- A `.cursor/rules/error-handling.mdc` file for Cursor users +- A section in `.github/copilot-instructions.md` for Copilot users +- A `.windsurf/rules/error-handling.md` for Windsurf users +- A section in `.clinerules` for Cline users +- A section in `.openai/instructions.md` for OpenAI users + +One source, every tool synchronized. When you update the skill, every provider config regenerates. When a new developer joins the team — regardless of which AI tool they prefer — they get the full set of team instructions on their first sync. + +--- + +## Going Beyond Copy-Paste: Composition, Variables, and Intelligence + +Once I had the basic sync working, the real possibilities opened up. + +### Skill Composition + +In any mature codebase, instructions build on each other. Your "API endpoint" skill might reference your "error handling" skill, which references your "logging" skill. Skillr supports this through an `includes` field — skills can reference other skills by slug, and they're resolved recursively (with circular dependency detection) at sync time. + +```yaml +--- +id: api-endpoint +name: API Endpoint Standards +includes: [error-handling, logging, validation] +--- + +When creating a new API endpoint, follow these patterns... +``` + +This means you can compose complex instruction sets from small, reusable building blocks rather than duplicating content across skills. + +### Template Variables + +Different projects or environments might need slightly different instructions. Template variables let you parameterize skills: + +```markdown +Use {{framework}} conventions for routing. +Write tests using {{test_runner}}. +Database queries should use {{orm}}. +``` + +The variables resolve per-project at sync time. Your Laravel project fills in `Laravel`, `Pest`, `Eloquent`. Your Node project fills in `Express`, `Jest`, `Prisma`. Same skill, different contexts. + +### Prompt Linting + +AI instructions are only as good as their clarity. Skillr includes a built-in prompt linter with eight quality rules that catch common issues: vague instructions ("do your best"), weak constraints ("you should" vs "you must"), conflicting directives (asking for both concise and detailed output), missing output format specs, and more. + +It's a small thing, but it pushes the team toward writing better prompts — which directly translates to better AI-generated code. + +--- + +## Reverse-Sync: Meeting Teams Where They Are + +One of the most important features came from a practical realization: teams don't start from zero. They already have `.claude/CLAUDE.md` files, Cursor rules, and Copilot instructions scattered across their repositories. + +Skillr can scan a project directory, detect all existing provider config files, and reverse-import them into the canonical `.skillr/` format. It parses each provider's native format — H2 headings from Claude, MDC frontmatter from Cursor, flat files from Cline — and converts them into portable skills. + +This means adoption doesn't require throwing away existing work. Point Skillr at your repo, scan, and your existing instructions become the starting point for a unified skill library. + +We recently expanded this to automatically detect provider configs whenever you scan a project. No separate import step needed — it finds everything and brings it in, tagging each imported skill with its source provider for traceability. + +--- + +## The Skill Library: Institutional Knowledge as Code + +The second-order effect I didn't anticipate was how Skillr changed the way teams think about their coding standards. + +When your instructions are scattered across tool-specific config files that only one person maintains, they're effectively invisible. Nobody browses `.cursor/rules/` to see what conventions exist. Nobody reads through a 500-line `CLAUDE.md` to find the authentication patterns. + +But when those instructions are organized as a browsable, searchable library of named skills with descriptions and tags — suddenly they become discoverable. New developers can browse the skill library to understand "how we do things here." Senior developers can see gaps in coverage. The team can discuss and iterate on skills in pull requests, just like code. + +We ship 25 pre-built skills covering common patterns: Laravel best practices, React conventions, security rules, documentation standards, code review guidelines. Teams can import these as starting points and customize them. + +--- + +## Multi-Model Testing + +Having skills in a structured format opened another door: you can test them. Skillr includes a live test runner that streams responses from any supported LLM provider — Anthropic, OpenAI, Gemini, or local Ollama models. + +Write a skill, then immediately test it by sending a prompt and watching the streamed response. Try it against different models. Compare how Claude interprets your instructions versus GPT. This tight feedback loop makes it practical to iterate on prompt quality in a way that copying text between config files never allowed. + +--- + +## The Architecture Choice: Why Laravel (For Now) + +Skillr is built on Laravel 12 with a React + TypeScript SPA. The backend handles skill file I/O, YAML parsing, provider sync, Git operations, and LLM streaming. The frontend gives you a Monaco editor for writing skills, a visual dependency graph, and a playground for testing. + +For a tool that does heavy file system operations, YAML parsing, recursive template resolution, and manages seven different provider output formats, a batteries-included framework like Laravel was the right starting point. The service layer maps cleanly to the domain: a `ProviderSyncService` orchestrates seven provider drivers, a `SkillCompositionService` handles recursive includes, a `TemplateResolver` processes variables. + +The long-term plan is to migrate to NestJS/TypeScript so we can ship a self-contained desktop app via Tauri — no Docker, no PHP, no database server needed. But the Laravel version gave us the fastest path to a working product, and we're using it to prove out the feature set before investing in the platform change. Features first, architecture second. + +--- + +## The Next Frontier: Desktop App Configs + +There's a layer of fragmentation that Skillr doesn't fully address yet — and it's worth being honest about. + +Everything I've described so far deals with **project-level** instruction files. The `.claude/CLAUDE.md`, `.cursor/rules/`, `.github/copilot-instructions.md` files that live inside your repository and tell AI tools how to work with *that specific codebase*. + +But there's a second layer: **desktop app configurations**. Claude Desktop, ChatGPT Desktop, Claude Code, Codex CLI, Cursor, and Windsurf all maintain their own user-level config files that control MCP server connections, model preferences, permission rules, and approval modes. And predictably, every app stores these in a different location with a different schema. + +Claude Desktop keeps its MCP servers in `~/.config/claude/claude_desktop_config.json`. Claude Code uses `~/.claude/settings.json` and per-project `.mcp.json` files. Cursor has `~/.cursor/mcp.json`. Codex CLI stores model and approval settings in `~/.codex/config.json`. Same MCP server, same intent — three different files, three different places. + +If your team has standardized on a set of MCP servers — a database connector, a documentation search tool, a deployment helper — every developer currently has to manually configure those servers in every desktop app they use. There's no way to say "here are the five MCP servers our team uses" and have that propagate everywhere. + +This is the natural next step for Skillr. The existing architecture already stores MCP server definitions per project and generates provider-specific configs through a trait called `GeneratesMcpConfig`. Extending this to write desktop app config files is a straightforward addition: same data source, new output targets. + +Beyond MCP servers, there's a "workspace profile" concept forming — a portable set of shared settings (default model, approval mode, tool permissions) that maps to each desktop app's native configuration schema. A team lead defines the profile once, and every developer's tools inherit those defaults. + +We're building this now. The immediate goal is desktop MCP sync — making Skillr the single source of truth for both your project instructions *and* your tool configurations. The longer-term goal is something more ambitious. + +--- + +## What's Next: From Skills to Agents + +The direction I'm most excited about is turning Skillr from a skill sync tool into an agent configuration platform. Today, you define individual skills. Tomorrow, you define complete agent personas — with goals, tool access (including MCP servers and A2A connections), memory strategies, and delegation chains — and export them to frameworks like Claude Agent SDK, LangGraph, or CrewAI. + +The `.skillr/` directory becomes the canonical definition of how AI operates in your project and across your team's tools: what it knows, what it can do, how it should behave, and what it can connect to. Provider sync and desktop config sync become two output channels from the same source of truth. + +But that's further out. Right now, the immediate value is simpler: write your AI instructions once, configure your tools once, and everything stays synchronized. + +--- + +## Try It + +Skillr is open source under the MIT license. + +**GitHub:** [github.com/eooo-io/skillr](https://github.com/eooo-io/skillr) + +```bash +git clone https://github.com/eooo-io/skillr.git +cd skillr +make build && make up && make migrate +cd ui && npm install && npm run dev +``` + +If your team uses more than one AI coding tool — and most do — I'd love to hear how you're handling the synchronization problem today. Open an issue, start a discussion, or contribute a new provider driver. The format is intentionally simple, and adding a new provider is a single class that reads skills and writes files. + +--- + +*Ezra Terlinden is the creator of Skillr and founder of [eooo.io](https://eooo.io). He builds tools for development teams navigating the AI-assisted coding landscape.* diff --git a/article/skills_structure.md b/article/skills_structure.md new file mode 100644 index 0000000..db60807 --- /dev/null +++ b/article/skills_structure.md @@ -0,0 +1,59 @@ + + +Today we are doing a bit more of a practical hands-on style episode. It was inspired by this post from Tariq over at the Claude Code team at anthropic called Lessons from Building Claude Code. How we use Skills and the context for this is that if you take away one theme from pretty much all of 2026 is episode so far, it's that we are moving into a much more agentic era of AI skills are a key component of how to get value out of agents and so today we're going to first give a little bit of a background of what skills are, talk about some of these lessons and best practices from the team at Claude Code and then share a few more resources where you can take the conversation farther. + + +First of all, let's talk about what skills are the official GitHub repo calls them a simple open format for giving agents new capabilities and expertise. Skills are folders of instructions, scripts and resources that agents can discover and use to perform better at specific tasks. Write once, use everywhere. The background is this. As AI coding agents were getting more and more capable throughout 2025 people started to hit a very similar wall, which was basically that system prompts kept ballooning. Every new capability meant more instructions, more examples and more edge cases crammed into a single context window. Of course, the more you try to jam into a context window, the more you're gonna have performance degradation. Having to juggle all of that knowledge all at once was crowding out space for actual execution on the task at hand. + + +That led to agents getting slower, more expensive and less reliable. Now, the insight that ended up driving skills was that agents don't need access to all of their knowledge all the time. What they need is to be able to load the right knowledge at the right moment. On October 16th, anthropic officially announced skills in a blog post. The post was called Equipping Agents for the Real World with Agent Skills and Frame. The issue is this, Claude is powerful, but real work requires procedural knowledge and organizational context. They write as model capabilities improve, we can now build general purpose agents that interact with full fledged computing environments. Claude Code, for example, can accomplish complex tasks across domains using local code execution and file systems, but as these agents become more powerful, we need more composable, scalable and portable ways to equip them with domain specific expertise. + + +This led us to create agent skills, organize folders of instructions, scripts and resources that agents can discover and load dynamically to perform better at specific tasks. So what a skill actually is, is a directory anchored by a markdown file. Every skill directory is going to have a Skill MD file that's going to have some required metadata like a name and a short description when agents have access to skills rather than having to have all of the context all at once, they simply load up the name in the description. The idea of progressive disclosure and skills is to give the agent just The information that it needs in order to make good decisions without overloading its context. So basically the first layer of detail is just the short description, which means that when the agent is doing a task, it has those descriptions in mind and can go call up that skill if it seems like it would be useful. + + +The second level of detail in this progressive disclosure regime is the actual body of the Skill MD file. If the agent thinks that that skill is going to be useful, it'll move from just reading the description to reading the contents of that skill md. Now, the Skill MD body is still very small, while the metadata is tiny at roughly a hundred tokens per skill, even the full skill.md body is recommended to stay pretty small. This leads to the third level of detail and progressive disclosure. Basically, as skills grow in complexity, they also might have context that's relevant only in specific scenarios, and in fact, this is a really important part that gets missed in the article from Philanthropics TAR Read that we're going to come back to. He writes, A common misconception we hear about skills is that they're just markdown files, but the most interesting part of skills is that they're not just text files, they're folders that can include scripts, assets, datas, et cetera that the agent can discover, explore, and manipulate. + + +Basically, you can bundle additional context in the form of other markdown files or references or scripts that get linked out to from the Skill MD file. The analogy they say is a well-organized manual that starts with a table of contents, then specific chapters, and finally a detailed appendix. Almost immediately skills began being adopted outside of just the anthropic ecosystem. OpenAI added skills support to both chat GBT and the GitHub co-pilot family of coding agents all adopted the standard and other ecosystems and harnesses have jumped on board as well. The launch of Open Claw really took the skills conversation to the next level as people started on mass building. All of these different agents, a lot of them had common skills needs like for example, understanding how to use specific tools, how to interact with certain types of file formats like documents and PDFs or how to take specific actions like transcribing audio. + + +A site called Claw Hub quickly launched that now has something like 28,000 skills. Other people have their own collections focused on particular use cases or areas of interest, and yet what Anthropic found when they actually sat back and looked was that as many skills as there were available, many if not most of them, could fit into one of nine categories, library and API reference product verification, data and analysis, business automation, scaffolding and templates, code quality and review, CICD and deployment incident runbooks and infrastructure ops, so that's what led them to this post. Let's talk first about some of the categories in this taxonomy and then some of the more general best practices that philanthropic shared. + + +I'm not gonna go through all nine categories, but let's talk about a couple. One key category they found was data fetching and analysis skills that, for example, connect to your data. These skills they write might include libraries to fetch your data with credentials, specific dashboard IDs, et cetera, as well as instructions on common workflows or ways to get data. Another category which I can see being important to listeners of this show is business process and team automation. In other words, skills that automate repetitive workflows into one command. They write these skills are usually fairly simple instructions but might have more complicated dependencies on other skills or cps. An example might be a weekly recap skill where merge PRS plus close tickets plus deploys come together in a formatted recap post. Another category in their key taxonomy of skills, which relates to some conversations we've been having recently is about code quality and review. + + +Now, the conversation that we've been sharing here is one about what happens when coding agents sprawl gets sufficient, that it just becomes impossible for humans to review all the code. There are some who are argued that we're already far past that point while others clinging onto the idea that humans need to have the final look. My very strong instinct is that even if it would be better if all code that was released as products and services actually had human review, I don't think there's any chance that that paradigm gets outta 2026. I think we're going to have to solve the problem of code review in new ways, which I'll be clear is a problem that I am not qualified to solve, but I just think that we're gonna be producing such an incredibly high volume of code that at some point we'll give up the ghost on the idea of being able to review it. All that makes code quality and review skills seem all the more potentially important. + + +This anthropic describes as skills that enforce code quality inside of your org and help review code. Some of the examples are adversarial review, which would spawn a fresh eye subagent to critique, implement fixes and iterate until findings degrade into nitpicks or a code style skill that enforces code styling, especially styles that Claude does not do well by default. Interestingly, and I think related to that, Tariq argues that one of the highest ROI categories are verification skills. They describe this as skills that describe how to test or verify that your code is working. Verification skills are extremely useful for ensuring Claude's output is correct. It can be worth having an engineer spend a week just making your verification skills. Excellent. Consider techniques like having Claude record a video of its output so you can see exactly what is tested or enforcing programmatic assertions on state at each step. + + +So there are more categories in the taxonomy, but that gives you a feel for what Anthropic is seeing in terms of their most valuable skills. Now admittedly, this is from the Claude Code team, so is going to index highly technical, whereas if you had an agent builder who is mostly focused on business processes, you probably see more gradations of this category for business process and team automation, maybe even more useful. Then our Tariq and Claude code's tips for actually making skills. One thing that a number of folks missed is that Anthropic actually just updated their skill creator tool. Skill creator, they write helps you write evals, run benchmarks, and keep your skills working as models evolve, and it was meant to answer a specific challenge. Since launching agent skills last October, they wrote, we've noticed that most authors are subject matter experts, not engineers. + + +They know their workflows but don't have the tools to tell whether a skill still works with a new model triggers when it should or if it actually improved after an edit. Ultimately, they write. The goal is bringing some of the rigor of software development, like testing, benchmarking and iterative improvement to skill authoring without requiring anyone to write code solopreneur and educator. Ollie Lemon actually thought this out as a fairly big deal. He wrote, anthropic shipped three upgrades to skills that fix most problems. Almost everyone runs into problem one. You had no way to measure how well your skills were actually performing. Now you can run evals that test your skill against multiple prompts and get a score. Problem two, your skills break when models update and you don't notice with the new skill creator, you can run AB test comparing your skill in raw Claude. Problem number three, he writes, Claude doesn't even use your skill half the time because the description is too vague or too specific. + + +Now the skill creator rewrites your descriptions automatically so they trigger at the right time philanthropic. He points out, ran this on their own skills and saw better triggering five outta six times. Now, one other note from the skill creator that I thought was valuable is the framework for organizing skills into two categories. They call those two categories. One, capability uplift skills that help Claude do something the base model either can't do or can't do consistently, IE certain types of document creation. And then the second category of skills are called encoded preference skills that document workflows where Claude can already do each piece, but the skill sequences them according to your team's processes. The distinction matters. They say, because these two types of skills may need testing for different reasons, capability uplift skills may become less necessary as models improve while encoded preference skills are more durable but only as valuable as their fidelity to your actual workflow. + + +So back to Tariq's post. Here are some of their top tips for making skills better. The first is don't state the obvious they write. If you're publishing a skill that is primarily about knowledge, try to focus on information that pushes Claude out of its normal way of thinking. The front end design skill is a great example. It was built by one of the engineers at Anthropic by iterating with customers on improving Claude's design taste, avoiding classic patterns like the inter font and purple gradients. The second tip is to build a gotcha section. In fact, Tariq argues that the highest signal content in any skill is the gotcha section. These sections articulate common failure points that Claude runs into when using your skill and ideally he says, you update your skill over time to capture these gotchas. A third tip goes back to that idea that people still think of skills as just a single markdown file rather than an entire folder, and Tariq says you should think of the entire file system as a form of context engineering. + + +They also suggest you should avoid railroading cla IE, give Claude The information it needs, but give it the flexibility to adapt to the situation. As Tariq put to the conclusion, this should be thought of more as a grab bag of useful tips than as some sort of definitive guide. That makes sense because right now everyone is just racing to figure out how to actually engage with the new capabilities of agents, and so every bit of advice at this point is going to be at least a little bit a work in progress. Now, one of the interesting things then is how all of these work in progress lessons apply to different categories of users. The most obvious is probably the advanced agent builders who are building and maintaining complex multi-agent teams. For them, obviously, skills are essentially a modular architecture for agent capabilities, and frankly, this is kind of the audience that Tariq most wrote this post for a level down from that are the individual power users, which of my guess is a lot of you fall into this category. + + +This is not a person who's building complex agent teams and orchestration models. Instead, they're using one or a small number of agents to get their own work done faster or better, or do things that weren't possible before. For that type of user skills are basically reusable prompts with superpowers. The difference between a skill and a saved prompt is that a skill can include actual code templates, reference data, and examples, not just instructions. The practical value then is you figure out how to get the agent to do something well once and then you package it so it works reliably every time. The standup post example from Tariq's Post is perfect for this tier. This is an automation of a daily task you do and the type of thing that you want to happen consistently over and over again. + + +This also helps demonstrate why that gotcha section can be really valuable. Every time the agent makes a mistake, you add it to the skill so it doesn't happen again, and the skill becomes a living document that gets smarter over time. This also helps you stay not locked into one specific ecosystem because skills are supported by Codex, Claude Code Cursor, et cetera. You're not locked into any one tools prompting format, but what about for the mainstream user? The person who isn't even yet fully in Claude Code or Codex, people who are using off the shelf tools are experimenting with perplexity computer or notion custom agents. What's interesting here is that the design pattern holds, and you can see even in these simpler prosumer and consumer tools, the idea of skills as reusable capabilities infiltrating into the mainstream. + + +In fact, earlier this week, notion announced custom skills for Notion ai. In their announcement tweet, they write, write a prompt. You'll use it once, write a skill and you'll use it forever. And this is the mental model shift. Even if you are not an agent builder with Claude Code, the shift is from thinking about ad hoc prompting to reusable capabilities. For a lot of folks out there, you're not ultimately going to have to care about the full architecture of Skill MD files and Progressive Disclosure and all these these things. Those folks just know they can teach the AI to do a specific thing their way, give it a name and invoke it whenever they want. For some, it'll almost be an update to custom gpt, which for many became essential even though they never fully took off. + + +Now you can see how Notion has simplified skills into their own ecosystem. Basically, you can take any page in Notion, click the menu, and turn that page into a skill, and the point is that this concept of skills as reusable capabilities is a concept that is converging across the entire AI stack from consumer uses up to much more advanced uses all at once. The underlying idea is that AI is less and less a one-off conversation and more and more a library of reliable, repeatable capabilities skills, I think are a useful framework for that no matter what level you're engaging with it on. And hopefully this episode has given you a little bit of a better starting point. We might go deeper in a future operator episode, but for now, that is gonna do it for today's AI Daily Brief. + + diff --git a/bootstrap/app.php b/bootstrap/app.php index 33c3b52..dfd8f27 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -23,6 +23,7 @@ \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \App\Http\Middleware\ResolveOrganization::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/database/migrations/2026_03_17_000001_create_desktop_app_configs_table.php b/database/migrations/2026_03_17_000001_create_desktop_app_configs_table.php new file mode 100644 index 0000000..739eb04 --- /dev/null +++ b/database/migrations/2026_03_17_000001_create_desktop_app_configs_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('app_slug'); // claude-desktop, claude-code, cursor, windsurf, codex-cli, chatgpt + $table->string('config_path'); // resolved path to the config file + $table->boolean('sync_mcp')->default(true); + $table->boolean('sync_settings')->default(false); + $table->json('managed_keys')->nullable(); // which config keys Skillr manages + $table->timestamp('last_synced_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'app_slug']); + }); + + Schema::create('workspace_profiles', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('default_model')->nullable(); + $table->string('approval_mode')->nullable(); // auto, suggest, manual + $table->json('allowed_tools')->nullable(); + $table->json('denied_tools')->nullable(); + $table->integer('default_max_tokens')->nullable(); + $table->float('default_temperature')->nullable(); + $table->boolean('is_default')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('workspace_profiles'); + Schema::dropIfExists('desktop_app_configs'); + } +}; diff --git a/database/migrations/2026_03_19_000001_add_category_type_gotchas_to_skills_table.php b/database/migrations/2026_03_19_000001_add_category_type_gotchas_to_skills_table.php new file mode 100644 index 0000000..ebcb74e --- /dev/null +++ b/database/migrations/2026_03_19_000001_add_category_type_gotchas_to_skills_table.php @@ -0,0 +1,24 @@ +string('category')->nullable()->after('description'); + $table->string('skill_type')->nullable()->after('category'); + $table->longText('gotchas')->nullable()->after('body'); + }); + } + + public function down(): void + { + Schema::table('skills', function (Blueprint $table) { + $table->dropColumn(['category', 'skill_type', 'gotchas']); + }); + } +}; diff --git a/database/migrations/2026_03_19_000002_add_supplementary_files_to_skills_table.php b/database/migrations/2026_03_19_000002_add_supplementary_files_to_skills_table.php new file mode 100644 index 0000000..9627eb8 --- /dev/null +++ b/database/migrations/2026_03_19_000002_add_supplementary_files_to_skills_table.php @@ -0,0 +1,22 @@ +json('supplementary_files')->nullable()->after('gotchas'); + }); + } + + public function down(): void + { + Schema::table('skills', function (Blueprint $table) { + $table->dropColumn('supplementary_files'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index ece9e47..7d0e769 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,12 +15,16 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::firstOrCreate( - ['email' => 'admin@admin.com'], - ['name' => 'Admin', 'password' => bcrypt('password')], - ); + // WARNING: This seeds a default admin account with a weak password. + // Change these credentials immediately in any non-local environment. + if (app()->isProduction()) { + $this->command->warn('Skipping default admin user in production. Create users manually.'); + } else { + User::firstOrCreate( + ['email' => 'admin@admin.com'], + ['name' => 'Admin', 'password' => bcrypt('password')], + ); + } $this->call(LibrarySkillSeeder::class); $this->call(ComplianceSkillSeeder::class); diff --git a/database/seeders/LibrarySkillSeeder.php b/database/seeders/LibrarySkillSeeder.php index 2c94557..44331aa 100644 --- a/database/seeders/LibrarySkillSeeder.php +++ b/database/seeders/LibrarySkillSeeder.php @@ -15,7 +15,7 @@ public function run(): void 'name' => 'Laravel API Resource Builder', 'slug' => 'laravel-api-resource-builder', 'description' => 'Generates well-structured Laravel API resources with proper data transformation and conditional fields.', - 'category' => 'Laravel', + 'category' => 'library-api-reference', 'tags' => ['laravel', 'api', 'resources'], 'body' => "You are an expert Laravel developer specializing in API development. When asked to create API resources, follow these guidelines:\n\n- Always extend `JsonResource` and implement a clean `toArray` method\n- Use `\$this->when()` for conditional fields and `\$this->whenLoaded()` for relationships\n- Include proper type hints in PHPDoc blocks for IDE support\n- Group related fields logically (identifiers, metadata, relationships, timestamps)\n- Use ISO 8601 format for all datetime fields via `->toIso8601String()`\n- Create resource collections when pagination is needed\n- Never expose sensitive fields like passwords or internal IDs without explicit request\n- Add `\$this->whenCounted()` for aggregate counts to avoid N+1 queries\n\nProvide complete, production-ready code with no placeholders.", ], @@ -23,7 +23,7 @@ public function run(): void 'name' => 'Eloquent Query Optimizer', 'slug' => 'eloquent-query-optimizer', 'description' => 'Reviews and optimizes Eloquent queries to prevent N+1 problems and improve database performance.', - 'category' => 'Laravel', + 'category' => 'library-api-reference', 'tags' => ['laravel', 'eloquent', 'performance', 'database'], 'body' => "You are a Laravel performance expert focused on Eloquent ORM optimization. When reviewing or writing queries:\n\n- Identify and fix N+1 query problems using eager loading (`with()`, `load()`)\n- Use `select()` to limit fetched columns when not all are needed\n- Prefer `whereIn()` over multiple `where()` calls for batch lookups\n- Use `chunk()` or `lazy()` for large datasets instead of `get()`\n- Add database indexes for frequently queried columns\n- Use `withCount()` instead of loading full relationships just to count\n- Prefer `firstOrCreate` / `updateOrCreate` over manual check-then-insert patterns\n- Use query scopes for reusable conditions\n- Always consider the query log output when reviewing performance\n- Suggest composite indexes for multi-column where clauses\n\nExplain the performance impact of each suggestion with estimated query count reductions.", ], @@ -31,7 +31,7 @@ public function run(): void 'name' => 'Laravel Migration Generator', 'slug' => 'laravel-migration-generator', 'description' => 'Creates well-structured database migrations with proper column types, indexes, and foreign keys.', - 'category' => 'Laravel', + 'category' => 'library-api-reference', 'tags' => ['laravel', 'database', 'migrations'], 'body' => "You are a database architect working with Laravel migrations. Follow these conventions:\n\n- Use descriptive migration names: `create_`, `add_`, `modify_`, `drop_`\n- Always include both `up()` and `down()` methods with proper rollback logic\n- Use appropriate column types: `string` for short text, `text` for longer content, `json` for structured data\n- Add foreign key constraints with `constrained()->cascadeOnDelete()` for parent-child relationships\n- Include relevant indexes: unique constraints, composite indexes for common query patterns\n- Use `nullable()` intentionally — default to required unless there is a clear reason for null\n- Add `after()` for column ordering when modifying existing tables\n- Use `uuid()` columns for public-facing identifiers, keep `id()` for internal references\n- Consider adding `softDeletes()` for data that should not be permanently removed\n- Always add timestamps unless there is a specific reason not to\n\nGenerate complete migration files ready to run.", ], @@ -39,7 +39,7 @@ public function run(): void 'name' => 'Pest Test Writer', 'slug' => 'pest-test-writer', 'description' => 'Writes comprehensive Pest PHP tests with proper assertions, data providers, and test organization.', - 'category' => 'Laravel', + 'category' => 'library-api-reference', 'tags' => ['laravel', 'testing', 'pest'], 'body' => "You are a testing expert using Pest PHP for Laravel applications. When writing tests:\n\n- Use Pest's expressive syntax: `it()`, `test()`, `expect()`, `beforeEach()`\n- Group related tests with `describe()` blocks when logical\n- Use `RefreshDatabase` trait for database tests\n- Prefer `expect()` fluent assertions over PHPUnit's `assert` methods\n- Test both happy paths and edge cases (validation errors, not found, unauthorized)\n- Use factories with specific states rather than raw data\n- Mock external services and APIs — never hit real endpoints in tests\n- Test API endpoints with `getJson()`, `postJson()`, `putJson()`, `deleteJson()`\n- Assert response structure with `assertJsonStructure()` and status codes\n- Keep tests focused — one logical assertion per test, named descriptively\n- Use `fake()` for generating realistic test data\n\nWrite tests that serve as documentation for the feature being tested.", ], @@ -47,7 +47,7 @@ public function run(): void 'name' => 'Laravel Service Class Pattern', 'slug' => 'laravel-service-class-pattern', 'description' => 'Designs clean service classes that encapsulate business logic outside of controllers.', - 'category' => 'Laravel', + 'category' => 'library-api-reference', 'tags' => ['laravel', 'architecture', 'services'], 'body' => "You are a Laravel architect who designs clean service classes. Follow these patterns:\n\n- Keep controllers thin — move business logic to dedicated service classes\n- Use constructor injection for dependencies (other services, repositories)\n- Name methods clearly: `createOrder()`, `processPayment()`, `syncInventory()`\n- Return typed results — use DTOs or domain objects, not raw arrays\n- Throw domain-specific exceptions rather than generic ones\n- Make services testable by depending on interfaces, not concrete implementations\n- Use Laravel's service container for automatic resolution\n- Keep services focused on a single domain area (Single Responsibility)\n- Use events to decouple side effects from main business logic\n- Document public methods with clear parameter and return type descriptions\n\nProvide complete service class implementations with proper namespace, imports, and typing.", ], @@ -57,7 +57,7 @@ public function run(): void 'name' => 'PHP Type Safety Enforcer', 'slug' => 'php-type-safety-enforcer', 'description' => 'Reviews PHP code for type safety issues and adds strict typing, return types, and property types.', - 'category' => 'PHP', + 'category' => 'code-quality-review', 'tags' => ['php', 'types', 'quality'], 'body' => "You are a PHP 8.4 expert focused on type safety and modern PHP features. When reviewing or writing code:\n\n- Always use `declare(strict_types=1)` at the top of every file\n- Add union types, intersection types, and nullable types where appropriate\n- Use typed properties with appropriate visibility (readonly where possible)\n- Leverage enums instead of string/int constants\n- Use `match()` expressions instead of complex switch statements\n- Apply named arguments for better readability on complex function calls\n- Use constructor promotion for clean dependency injection\n- Prefer first-class callable syntax `strlen(...)` over string references\n- Add return types to all methods, including `void` and `never`\n- Use generics-style PHPDoc `@template` annotations for collections and containers\n\nExplain each type improvement and its impact on code safety.", ], @@ -65,7 +65,7 @@ public function run(): void 'name' => 'PHP Error Handler', 'slug' => 'php-error-handler', 'description' => 'Designs robust error handling strategies with custom exceptions and proper error recovery.', - 'category' => 'PHP', + 'category' => 'code-quality-review', 'tags' => ['php', 'error-handling', 'exceptions'], 'body' => "You are a PHP error handling specialist. When designing error handling:\n\n- Create domain-specific exception hierarchies that extend base PHP exceptions\n- Use `try/catch` blocks at appropriate boundaries — not around every line\n- Implement the `Throwable` interface for custom error types when needed\n- Log errors with contextual data using structured logging (PSR-3)\n- Return meaningful error responses to API consumers with proper HTTP status codes\n- Use `finally` blocks for cleanup operations that must always run\n- Avoid catching `\\Exception` broadly — catch specific exception types\n- Implement retry logic with exponential backoff for transient failures\n- Use PHP 8's `match` for mapping exception types to responses\n- Never swallow exceptions silently — always log or re-throw\n\nDesign error handling that aids debugging while keeping user-facing messages clean.", ], @@ -73,7 +73,7 @@ public function run(): void 'name' => 'PHP Code Refactorer', 'slug' => 'php-code-refactorer', 'description' => 'Identifies code smells and refactors PHP code following SOLID principles and clean code practices.', - 'category' => 'PHP', + 'category' => 'code-quality-review', 'tags' => ['php', 'refactoring', 'solid', 'clean-code'], 'body' => "You are a senior PHP developer specializing in code refactoring. When reviewing code:\n\n- Identify violations of SOLID principles and suggest specific fixes\n- Extract complex conditionals into well-named private methods\n- Replace magic numbers and strings with named constants or enums\n- Simplify deeply nested code using early returns and guard clauses\n- Break large classes into focused, single-responsibility classes\n- Replace inheritance with composition where appropriate\n- Use dependency injection instead of static method calls or singletons\n- Identify and eliminate code duplication through abstraction\n- Improve naming: methods should describe what they do, variables should describe what they hold\n- Reduce method parameter count — use parameter objects for 3+ params\n\nShow the before/after for each refactoring with a brief explanation of why it improves the code.", ], @@ -81,7 +81,7 @@ public function run(): void 'name' => 'PHP Security Auditor', 'slug' => 'php-security-auditor', 'description' => 'Audits PHP code for security vulnerabilities including injection, XSS, CSRF, and authentication flaws.', - 'category' => 'PHP', + 'category' => 'code-quality-review', 'tags' => ['php', 'security', 'owasp'], 'body' => "You are a PHP security expert who audits code for vulnerabilities. Check for:\n\n- SQL injection: ensure all queries use parameterized statements or ORM methods\n- XSS: verify all user output is escaped with `htmlspecialchars()` or framework equivalents\n- CSRF: confirm all state-changing operations require valid CSRF tokens\n- Authentication: check for timing-safe comparisons, proper password hashing with bcrypt/argon2\n- Authorization: verify access control checks on every protected resource\n- File upload: validate MIME types, limit sizes, never use user-supplied filenames directly\n- Session management: ensure secure cookie flags, session regeneration on login\n- Input validation: validate and sanitize all user input at the boundary\n- Sensitive data: check that API keys, passwords, tokens are never logged or exposed\n- Dependency security: flag known vulnerable package versions\n\nRate each finding by severity (Critical/High/Medium/Low) with remediation steps.", ], @@ -91,7 +91,7 @@ public function run(): void 'name' => 'React Component Architect', 'slug' => 'react-component-architect', 'description' => 'Designs well-structured React components with proper props, state management, and composition patterns.', - 'category' => 'TypeScript', + 'category' => 'library-api-reference', 'tags' => ['react', 'typescript', 'components'], 'body' => "You are a senior React developer using TypeScript. When building components:\n\n- Define explicit TypeScript interfaces for all props — never use `any`\n- Use functional components with hooks exclusively\n- Keep components focused on a single responsibility\n- Extract reusable logic into custom hooks prefixed with `use`\n- Use `React.memo()` only when profiling shows actual re-render performance issues\n- Implement proper error boundaries for production resilience\n- Use discriminated unions for component variants instead of boolean props\n- Prefer composition (`children`, render props) over deep component hierarchies\n- Handle loading, error, and empty states explicitly — never show blank screens\n- Use `useCallback` and `useMemo` judiciously — only for expensive computations or stable references\n- Follow the convention: one component per file, named export matching filename\n\nProvide complete, production-ready components with proper typing.", ], @@ -99,7 +99,7 @@ public function run(): void 'name' => 'TypeScript Type Designer', 'slug' => 'typescript-type-designer', 'description' => 'Creates precise TypeScript type definitions using advanced patterns like generics, mapped types, and discriminated unions.', - 'category' => 'TypeScript', + 'category' => 'library-api-reference', 'tags' => ['typescript', 'types', 'generics'], 'body' => "You are a TypeScript type system expert. When designing types:\n\n- Use `interface` for object shapes that might be extended, `type` for unions and complex types\n- Leverage generics to create reusable, type-safe abstractions\n- Use discriminated unions with a `type` or `kind` field for variant types\n- Apply `Readonly` and `ReadonlyArray` for immutable data structures\n- Use `Pick`, `Omit`, and `Partial` to derive types from existing ones\n- Create template literal types for string patterns like routes or event names\n- Use `satisfies` operator to validate values match a type while preserving inference\n- Avoid `enum` — prefer const objects with `as const` for better tree-shaking\n- Use branded types for nominal typing (e.g., `UserId` vs plain `string`)\n- Document complex types with JSDoc including `@example` usage\n\nExplain the reasoning behind each type design decision.", ], @@ -107,7 +107,7 @@ public function run(): void 'name' => 'Zustand Store Designer', 'slug' => 'zustand-store-designer', 'description' => 'Designs clean Zustand stores with proper state slicing, actions, and TypeScript integration.', - 'category' => 'TypeScript', + 'category' => 'library-api-reference', 'tags' => ['zustand', 'state-management', 'react'], 'body' => "You are a state management expert using Zustand with TypeScript. Follow these patterns:\n\n- Define a clear state interface with separate sections for data, UI state, and actions\n- Keep stores focused — create separate stores for unrelated domains\n- Use `immer` middleware for complex nested state updates\n- Define async actions that handle loading/error states internally\n- Use selectors to subscribe to specific state slices and prevent unnecessary re-renders\n- Implement `devtools` middleware in development for debugging\n- Never store derived data — compute it in selectors or components\n- Use `persist` middleware for state that should survive page refreshes\n- Keep actions named as verbs: `fetchUsers`, `addItem`, `clearFilters`\n- Type the store with `create()` for full IntelliSense\n\nProvide complete store implementations with typed state, actions, and example usage.", ], @@ -115,7 +115,7 @@ public function run(): void 'name' => 'API Client Generator', 'slug' => 'api-client-generator', 'description' => 'Generates type-safe API client functions using Axios with proper error handling and response typing.', - 'category' => 'TypeScript', + 'category' => 'library-api-reference', 'tags' => ['typescript', 'api', 'axios'], 'body' => "You are a TypeScript API integration expert using Axios. When generating API clients:\n\n- Create a centralized Axios instance with base URL, default headers, and interceptors\n- Define TypeScript interfaces matching the API response shapes exactly\n- Type all request functions with proper parameter and return types\n- Use generic wrapper types like `ApiResponse` for consistent response handling\n- Implement request/response interceptors for auth tokens and error normalization\n- Handle errors gracefully — transform API errors into user-friendly messages\n- Use `AbortController` for cancellable requests in React components\n- Group related endpoints into modules (e.g., `users.ts`, `projects.ts`)\n- Add JSDoc comments with endpoint documentation for each function\n- Never use `any` in API types — define precise shapes even for error responses\n\nGenerate complete, production-ready API client code.", ], @@ -123,7 +123,7 @@ public function run(): void 'name' => 'React Hook Creator', 'slug' => 'react-hook-creator', 'description' => 'Creates custom React hooks that encapsulate complex logic with proper TypeScript typing and cleanup.', - 'category' => 'TypeScript', + 'category' => 'library-api-reference', 'tags' => ['react', 'hooks', 'typescript'], 'body' => "You are a React hooks expert using TypeScript. When creating custom hooks:\n\n- Prefix all hooks with `use` and name them descriptively: `useDebounce`, `usePagination`\n- Define explicit return types — prefer returning objects for named access over arrays\n- Handle cleanup in `useEffect` return functions to prevent memory leaks\n- Use `useRef` for values that should persist across renders without triggering re-renders\n- Implement proper dependency arrays — include all referenced values\n- Return loading, error, and data states for async hooks\n- Use `useCallback` for returned functions that consumers might pass as props\n- Make hooks configurable through options objects with sensible defaults\n- Handle edge cases: unmounted components, race conditions, stale closures\n- Write hooks that compose well — they should be independent and combinable\n\nProvide complete hook implementations with TypeScript types, JSDoc, and usage examples.", ], @@ -133,7 +133,7 @@ public function run(): void 'name' => 'Financial Data Validator', 'slug' => 'financial-data-validator', 'description' => 'Validates financial data inputs including currency amounts, IBAN numbers, tax IDs, and date ranges.', - 'category' => 'FinTech', + 'category' => 'business-automation', 'tags' => ['fintech', 'validation', 'compliance'], 'body' => "You are a FinTech data validation specialist. When validating financial data:\n\n- Always use integer cents (or smallest currency unit) for monetary amounts — never floats\n- Validate IBAN numbers using the MOD-97 algorithm with country-specific length checks\n- Verify German tax IDs (Steuer-ID: 11 digits) and VAT numbers (USt-IdNr) format\n- Validate date ranges for financial periods — no future dates for historical data\n- Check currency codes against ISO 4217 standard\n- Implement BIC/SWIFT code validation (8 or 11 characters)\n- Validate percentage values are within 0-100 range with appropriate precision\n- Check for reasonable bounds on financial amounts (no negative loans, etc.)\n- Verify that related financial dates are logically consistent (start before end)\n- Format all monetary output with proper locale-aware formatting\n\nProvide validation functions with clear error messages suitable for end users.", ], @@ -141,7 +141,7 @@ public function run(): void 'name' => 'Mortgage Calculator Logic', 'slug' => 'mortgage-calculator-logic', 'description' => 'Implements mortgage and loan calculation logic including amortization schedules and rate comparisons.', - 'category' => 'FinTech', + 'category' => 'business-automation', 'tags' => ['fintech', 'mortgage', 'calculations'], 'body' => "You are a mortgage finance calculation expert. When implementing calculations:\n\n- Calculate monthly payments using the standard annuity formula\n- Generate full amortization schedules showing principal, interest, and remaining balance\n- Handle fixed and variable rate scenarios with rate change periods\n- Calculate effective annual rate (Effektivzins) including all fees and costs\n- Support German-style Tilgung (repayment) calculations with Sondertilgung options\n- Compute total interest paid over the loan lifetime\n- Handle Zinsbindung (fixed-rate period) and refinancing scenarios\n- Use precise decimal arithmetic — never floating point for financial amounts\n- Support comparison of multiple loan offers with normalized metrics\n- Calculate Restschuld (remaining debt) at any point in the loan term\n\nAll calculations must be mathematically precise and auditable.", ], @@ -149,7 +149,7 @@ public function run(): void 'name' => 'KYC Document Processor', 'slug' => 'kyc-document-processor', 'description' => 'Processes and validates Know Your Customer documents for financial compliance workflows.', - 'category' => 'FinTech', + 'category' => 'business-automation', 'tags' => ['fintech', 'kyc', 'compliance'], 'body' => "You are a KYC compliance automation specialist. When processing KYC documents:\n\n- Extract and validate personal information: full legal name, date of birth, nationality\n- Verify document types: passport, national ID, driver's license, residence permit\n- Check document expiry dates — flag documents expiring within 3 months\n- Validate address proof documents: utility bills, bank statements (max 3 months old)\n- Cross-reference extracted data against application forms for consistency\n- Flag potential issues: mismatched names, expired documents, unclear scans\n- Generate structured output with confidence scores for each extracted field\n- Support German document formats: Personalausweis, Reisepass, Meldebescheinigung\n- Track document verification status through a clear state machine\n- Maintain audit trail of all verification steps and decisions\n\nAll processing must comply with GDPR data handling requirements.", ], @@ -157,7 +157,7 @@ public function run(): void 'name' => 'Financial Report Generator', 'slug' => 'financial-report-generator', 'description' => 'Generates structured financial reports and summaries from transaction data.', - 'category' => 'FinTech', + 'category' => 'business-automation', 'tags' => ['fintech', 'reporting', 'analytics'], 'body' => "You are a financial reporting specialist. When generating reports:\n\n- Structure reports with clear sections: summary, details, trends, and recommendations\n- Calculate key metrics: revenue, expenses, net income, profit margins, growth rates\n- Present monetary values with proper formatting (locale-aware, currency symbols)\n- Include period-over-period comparisons (month-over-month, year-over-year)\n- Generate visual-ready data structures for charts (time series, breakdowns, distributions)\n- Highlight anomalies and significant changes with contextual explanations\n- Support filtering by date range, category, account, and custom dimensions\n- Calculate running totals, moving averages, and cumulative metrics\n- Include data quality indicators (completeness, consistency checks)\n- Format output as clean Markdown tables or structured JSON for downstream rendering\n\nAll reports must be accurate, clearly labeled, and suitable for stakeholder presentation.", ], @@ -167,7 +167,7 @@ public function run(): void 'name' => 'Docker Compose Architect', 'slug' => 'docker-compose-architect', 'description' => 'Designs production-ready Docker Compose configurations with proper networking, volumes, and health checks.', - 'category' => 'DevOps', + 'category' => 'ci-cd-deployment', 'tags' => ['docker', 'devops', 'infrastructure'], 'body' => "You are a Docker containerization expert. When designing Docker Compose setups:\n\n- Use specific image tags — never `latest` in production configurations\n- Define health checks for all services to enable proper orchestration\n- Use named volumes for persistent data (databases, uploads, caches)\n- Configure proper networking with isolated bridge networks\n- Set resource limits (memory, CPU) to prevent runaway containers\n- Use `.env` files for environment-specific configuration, never hardcode secrets\n- Implement proper dependency ordering with `depends_on` and health check conditions\n- Add restart policies: `unless-stopped` for production, `no` for development\n- Use multi-stage builds in Dockerfiles to minimize image sizes\n- Separate development and production compose files using override patterns\n- Configure logging drivers appropriate for the environment\n\nProvide complete, production-ready configurations with inline comments.", ], @@ -175,7 +175,7 @@ public function run(): void 'name' => 'CI/CD Pipeline Designer', 'slug' => 'cicd-pipeline-designer', 'description' => 'Designs CI/CD pipelines for testing, building, and deploying applications with proper stages and caching.', - 'category' => 'DevOps', + 'category' => 'ci-cd-deployment', 'tags' => ['cicd', 'devops', 'automation'], 'body' => "You are a CI/CD pipeline expert. When designing pipelines:\n\n- Structure pipelines with clear stages: lint, test, build, deploy\n- Cache dependencies (composer, npm) between runs to speed up builds\n- Run tests in parallel where possible to reduce total pipeline time\n- Use matrix builds for testing across multiple PHP/Node versions\n- Implement proper artifact passing between stages\n- Add deployment gates: manual approval for production, automatic for staging\n- Include security scanning (dependency audit, SAST) as pipeline stages\n- Configure branch-specific behaviors: full pipeline on main, tests-only on feature branches\n- Set up proper secret management — never commit credentials to pipeline configs\n- Add pipeline notifications for failures (Slack, email)\n- Implement rollback procedures as documented pipeline steps\n\nGenerate complete pipeline configurations with all stages properly connected.", ], @@ -183,7 +183,7 @@ public function run(): void 'name' => 'Nginx Configuration Expert', 'slug' => 'nginx-configuration-expert', 'description' => 'Creates optimized Nginx configurations for reverse proxying, SSL termination, and static file serving.', - 'category' => 'DevOps', + 'category' => 'ci-cd-deployment', 'tags' => ['nginx', 'devops', 'web-server'], 'body' => "You are an Nginx configuration expert. When creating configurations:\n\n- Configure proper upstream blocks for PHP-FPM or application servers\n- Set up SSL/TLS with modern cipher suites and HSTS headers\n- Enable gzip compression for text-based assets with appropriate MIME types\n- Configure proper caching headers for static assets (long TTL with cache-busting)\n- Set up security headers: X-Frame-Options, X-Content-Type-Options, CSP\n- Handle SPA routing with `try_files \$uri \$uri/ /index.html`\n- Configure proper client body size limits for file uploads\n- Set up rate limiting for API endpoints to prevent abuse\n- Add access and error logging with structured log formats\n- Configure proxy pass with proper header forwarding (X-Real-IP, X-Forwarded-For)\n- Use `location` blocks efficiently — prefer prefix matching over regex\n\nProvide complete, commented configurations ready for production deployment.", ], @@ -191,7 +191,7 @@ public function run(): void 'name' => 'Linux Server Hardening', 'slug' => 'linux-server-hardening', 'description' => 'Provides security hardening checklists and configurations for Linux production servers.', - 'category' => 'DevOps', + 'category' => 'ci-cd-deployment', 'tags' => ['linux', 'security', 'devops'], 'body' => "You are a Linux server security specialist. When hardening servers:\n\n- Configure SSH: disable root login, use key-only auth, change default port, set connection limits\n- Set up UFW/iptables firewall: default deny incoming, allow only required ports\n- Configure automatic security updates with unattended-upgrades\n- Set proper file permissions: 750 for directories, 640 for files, restrict sensitive configs\n- Implement fail2ban for brute-force protection on SSH and web services\n- Configure log rotation and centralized logging\n- Set up user accounts with principle of least privilege — no shared accounts\n- Enable audit logging for security-relevant events\n- Configure resource limits (ulimits) to prevent fork bombs and memory exhaustion\n- Implement regular backup verification — not just backup creation\n- Disable unnecessary services and remove unused packages\n\nProvide specific commands and configuration files for each hardening step.", ], @@ -201,7 +201,7 @@ public function run(): void 'name' => 'Technical Documentation Writer', 'slug' => 'technical-documentation-writer', 'description' => 'Writes clear, structured technical documentation including API docs, architecture guides, and READMEs.', - 'category' => 'Writing', + 'category' => 'general', 'tags' => ['documentation', 'writing', 'technical'], 'body' => "You are a technical documentation expert. When writing documentation:\n\n- Start with a clear, one-paragraph summary of what the system/feature does\n- Use consistent heading hierarchy: H1 for title, H2 for major sections, H3 for subsections\n- Include practical code examples for every API endpoint or configuration option\n- Write for the reader's skill level — define jargon on first use\n- Use numbered steps for procedures, bullet points for lists of items\n- Include diagrams or ASCII art for architecture and data flow explanations\n- Add a Prerequisites section listing required tools, versions, and access\n- Write error messages and troubleshooting sections for common failure modes\n- Keep paragraphs short (3-5 sentences max) for scanability\n- Include a Quick Start section for readers who want to get running immediately\n- Version the documentation alongside the code it describes\n\nAll documentation should be complete enough that a new team member can understand and use the system.", ], @@ -209,7 +209,7 @@ public function run(): void 'name' => 'Code Review Commenter', 'slug' => 'code-review-commenter', 'description' => 'Provides constructive, specific code review feedback following best practices for team collaboration.', - 'category' => 'Writing', + 'category' => 'general', 'tags' => ['code-review', 'writing', 'collaboration'], 'body' => "You are a senior developer providing code review feedback. When reviewing:\n\n- Start with what is done well — acknowledge good patterns and clever solutions\n- Be specific: reference exact lines, suggest concrete alternatives, not vague instructions\n- Categorize feedback: must-fix (bugs, security), should-fix (quality), nice-to-have (style)\n- Explain the why behind each suggestion — teach, don't just criticize\n- Ask questions when intent is unclear rather than assuming it is wrong\n- Suggest refactorings with before/after code snippets when possible\n- Check for: correctness, readability, performance, security, test coverage\n- Use conventional prefixes: `nit:` for minor style issues, `question:` for clarifications\n- Consider the broader context — does this change fit the existing architecture?\n- Keep comments concise but complete — one concept per comment\n\nTone should be collaborative, respectful, and focused on improving the code together.", ], @@ -217,7 +217,7 @@ public function run(): void 'name' => 'Commit Message Composer', 'slug' => 'commit-message-composer', 'description' => 'Composes clear, conventional commit messages that explain the why behind code changes.', - 'category' => 'Writing', + 'category' => 'general', 'tags' => ['git', 'writing', 'conventions'], 'body' => "You are a git workflow specialist focused on clear communication through commits. When composing messages:\n\n- Follow Conventional Commits format: `type(scope): description`\n- Types: feat, fix, refactor, docs, test, chore, perf, style, ci, build\n- Keep the subject line under 72 characters, imperative mood (\"add\" not \"added\")\n- Leave a blank line between subject and body\n- Use the body to explain WHY the change was made, not WHAT changed (the diff shows what)\n- Reference issue numbers: `Closes #123`, `Fixes #456`, `Relates to #789`\n- Break large changes into logical, atomic commits that each tell a story\n- Never commit generated files, build artifacts, or secrets\n- Use `BREAKING CHANGE:` footer for incompatible API changes\n- Group related changes in a single commit — unrelated changes get separate commits\n\nGenerate commit messages that help future developers understand the project's evolution.", ], diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6b93f88..7988c09 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -41,6 +41,7 @@ export default defineConfig({ text: 'Skills', items: [ { text: 'Creating Skills', link: '/guide/skills' }, + { text: 'Skill Taxonomy', link: '/guide/skill-taxonomy' }, { text: 'Includes & Composition', link: '/guide/includes' }, { text: 'Template Variables', link: '/guide/templates' }, { text: 'Prompt Linting', link: '/guide/linting' }, @@ -59,6 +60,7 @@ export default defineConfig({ items: [ { text: 'Sync Overview', link: '/guide/provider-sync' }, { text: 'Diff Preview', link: '/guide/diff-preview' }, + { text: 'Reverse Import', link: '/guide/reverse-import' }, { text: 'Git Auto-Commit', link: '/guide/git-integration' }, ], }, @@ -79,12 +81,36 @@ export default defineConfig({ { text: 'Bundle Export/Import', link: '/guide/bundles' }, ], }, + { + text: 'Integrations', + items: [ + { text: 'MCP Servers', link: '/guide/mcp-servers' }, + { text: 'A2A Agents', link: '/guide/a2a-agents' }, + { text: 'OpenClaw Config', link: '/guide/openclaw' }, + { text: 'Repository Connections', link: '/guide/repositories' }, + { text: 'Desktop Config Sync', link: '/guide/desktop-sync' }, + ], + }, + { + text: 'Discovery', + items: [ + { text: 'Cross-Project Search', link: '/guide/search' }, + { text: 'Project Visualization', link: '/guide/visualization' }, + ], + }, { text: 'Automation', items: [ { text: 'Webhooks', link: '/guide/webhooks' }, ], }, + { + text: 'Account', + items: [ + { text: 'Authentication', link: '/guide/authentication' }, + { text: 'Billing & Subscriptions', link: '/guide/billing' }, + ], + }, ], '/reference/': [ { diff --git a/docs/guide/a2a-agents.md b/docs/guide/a2a-agents.md new file mode 100644 index 0000000..1ee4ae2 --- /dev/null +++ b/docs/guide/a2a-agents.md @@ -0,0 +1,45 @@ +# A2A Agents + +Skillr supports configuring [Agent-to-Agent](https://google.github.io/A2A/) (A2A) integrations for your projects. A2A is a protocol that enables AI agents from different providers to communicate and collaborate. + +## Managing A2A Agents + +Navigate to a project and open the **A2A** tab to view and manage A2A agent configurations. + +### Adding an A2A Agent + +Click **Add A2A Agent** and provide: + +| Field | Description | +|---|---| +| **Name** | Display name for the agent | +| **Provider** | The AI provider or service hosting the agent | +| **Config** | JSON configuration with connection details and capabilities | + +### Configuration + +The config object varies by provider but typically includes: + +```json +{ + "endpoint": "https://agent.example.com/a2a", + "capabilities": ["code-review", "testing"], + "auth": { + "type": "bearer", + "token_env": "A2A_AGENT_TOKEN" + } +} +``` + +### Editing and Removing + +Click an agent card to edit its configuration, or click delete to remove it. + +## API + +``` +GET /api/projects/{id}/a2a-agents # List A2A agents +POST /api/projects/{id}/a2a-agents # Create A2A agent +PUT /api/a2a-agents/{id} # Update agent +DELETE /api/a2a-agents/{id} # Delete agent +``` diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md new file mode 100644 index 0000000..de7ba44 --- /dev/null +++ b/docs/guide/authentication.md @@ -0,0 +1,72 @@ +# Authentication + +Skillr uses session-based authentication with support for multiple sign-in methods. + +## Sign-In Methods + +### Email and Password + +Register at `/register` or log in at `/login` with your email and password. + +### GitHub OAuth + +Click **Sign in with GitHub** on the login page. Skillr redirects to GitHub for authorization, then creates or links your account on callback. + +### Apple Sign In + +Click **Sign in with Apple** on the login page. Uses Apple's form_post response mode for the callback. + +## Session Management + +Skillr uses Laravel's session-based auth (`auth:web` guard). The React SPA and API share the same session cookie via CORS configuration. There are no API tokens -- all requests are authenticated via the session cookie. + +### Logging Out + +Click your avatar or the logout option in the sidebar. This invalidates the session and redirects to the login page. + +## Default Development Account + +In development, the seeded database includes a default account: + +- **Email:** `admin@admin.com` +- **Password:** `password` + +::: warning +Change the default credentials before deploying to any shared or production environment. +::: + +## Organizations + +Skillr supports multi-tenant organizations. Each user can belong to multiple organizations, and each organization has its own projects, skills, and settings. + +### Roles + +| Role | Permissions | +|---|---| +| **Owner** | Full access, can delete organization, manage billing | +| **Admin** | Manage members, projects, and settings | +| **Editor** | Create and edit skills, run syncs | +| **Viewer** | Read-only access to projects and skills | +| **Member** | Default role with basic access | + +### Switching Organizations + +Your current organization is resolved via the `X-Organization-Id` header or your `current_organization_id` user setting. The React SPA manages this automatically. + +### Creating an Organization + +Organizations are managed through the Filament Admin panel at `/admin`. + +## API + +``` +POST /api/auth/register # Email registration +POST /api/auth/login # Email login +POST /api/auth/logout # Logout +GET /api/auth/me # Current user + +GET /auth/github/redirect # GitHub OAuth redirect +GET /auth/github/callback # GitHub OAuth callback +GET /auth/apple/redirect # Apple Sign In redirect +POST /auth/apple/callback # Apple Sign In callback +``` diff --git a/docs/guide/billing.md b/docs/guide/billing.md new file mode 100644 index 0000000..7f4812f --- /dev/null +++ b/docs/guide/billing.md @@ -0,0 +1,84 @@ +# 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/desktop-sync.md b/docs/guide/desktop-sync.md new file mode 100644 index 0000000..9d30117 --- /dev/null +++ b/docs/guide/desktop-sync.md @@ -0,0 +1,96 @@ +# Desktop App Config Sync + +Skillr can sync MCP server definitions and workspace settings to desktop AI applications. This extends Skillr's single-source-of-truth philosophy beyond IDE/CLI instruction files to desktop app configurations. + +## The Problem + +Desktop AI tools each maintain their own config files: + +| App | Config Location | What It Stores | +|---|---|---| +| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` | MCP servers, model prefs | +| Claude Code | `~/.claude/settings.json` | MCP servers, permissions, approval mode | +| Cursor | `~/.cursor/mcp.json` | MCP servers | +| Windsurf | `~/.windsurf/mcp.json` | MCP servers | +| Codex CLI | `~/.codex/config.json` | Model, approval mode | +| ChatGPT Desktop | `~/Library/Application Support/com.openai.chat/` | Plugins, preferences | + +When you add a new MCP server, you have to manually update each tool's config file. Skillr eliminates this duplication. + +## MCP Config Sync + +### Detecting Desktop Apps + +Click **Detect** to auto-discover which desktop AI tools are installed on your machine: + +``` +GET /api/desktop-configs/detect +``` + +Skillr checks known config file locations for each supported app. + +### Registering an App + +Register a detected app (or add one manually) to include it in sync: + +``` +POST /api/desktop-configs +``` + +### Syncing MCP Servers + +Once registered, sync your project's MCP server definitions to all registered desktop apps: + +``` +POST /api/desktop-configs/sync # Sync all apps +POST /api/desktop-configs/{appSlug}/sync # Sync single app +``` + +Each app's driver translates the MCP server definitions into the correct config format for that tool. + +### Preview Before Sync + +See exactly what will change before writing: + +``` +GET /api/desktop-configs/{appSlug}/preview +``` + +Returns a diff showing the current config vs. the proposed config. + +### Importing MCP Servers from Apps + +If you've already configured MCP servers in a desktop app, import them into Skillr: + +``` +POST /api/desktop-configs/import-mcp +``` + +This reads the app's config file and creates MCP server records in your project. + +## Workspace Settings Sync + +Beyond MCP servers, Skillr can sync workspace-level settings like: + +- **Model preferences** -- Default model per app +- **Permissions** -- File read/write, network access, shell execution +- **Approval modes** -- Auto-approve, suggest, manual + +These settings are stored as workspace profiles in Skillr and synced to each app's native format. + +## API + +``` +GET /api/desktop-configs # List synced apps +GET /api/desktop-configs/detect # Auto-detect installed apps +POST /api/desktop-configs # Register app +DELETE /api/desktop-configs/{appSlug} # Unregister app +POST /api/desktop-configs/sync # Sync all apps +POST /api/desktop-configs/{appSlug}/sync # Sync single app +GET /api/desktop-configs/{appSlug}/preview # Preview changes +POST /api/desktop-configs/import-mcp # Import from app config +``` + +::: tip +Combine desktop config sync with [MCP server management](./mcp-servers) and [provider sync](./provider-sync) for a complete setup: define skills and MCP servers once in Skillr, then sync everything to all your tools with a single click. +::: diff --git a/docs/guide/mcp-servers.md b/docs/guide/mcp-servers.md new file mode 100644 index 0000000..5035bce --- /dev/null +++ b/docs/guide/mcp-servers.md @@ -0,0 +1,58 @@ +# MCP Servers + +Skillr can manage [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server configurations for your projects. MCP servers extend AI tools with additional capabilities like file system access, database queries, or custom tool integrations. + +## Managing MCP Servers + +Navigate to a project and open the **MCP** tab. From here you can add, edit, and remove MCP server configurations. + +### Adding a Server + +Click **Add MCP Server** and provide: + +| Field | Description | +|---|---| +| **Name** | Display name for the server (e.g., "PostgreSQL", "GitHub") | +| **Transport** | Connection type: `stdio` (subprocess) or `sse` (HTTP stream) | +| **Config** | JSON configuration object with transport-specific parameters | + +#### stdio Transport + +For subprocess-based servers that communicate over stdin/stdout: + +```json +{ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "env": { + "DATABASE_URL": "postgresql://localhost:5432/mydb" + } +} +``` + +#### SSE Transport + +For HTTP-based servers using Server-Sent Events: + +```json +{ + "url": "http://localhost:3001/sse" +} +``` + +### Editing and Removing + +Click a server card to edit its configuration, or click the delete button to remove it. + +## Sync to Desktop Apps + +MCP server definitions stored in Skillr can be synced to desktop AI tools that support MCP, including Claude Desktop, Claude Code, and Cursor. See [Desktop Config Sync](./desktop-sync) for details. + +## API + +``` +GET /api/projects/{id}/mcp-servers # List MCP servers +POST /api/projects/{id}/mcp-servers # Add server +PUT /api/mcp-servers/{id} # Update server +DELETE /api/mcp-servers/{id} # Remove server +``` diff --git a/docs/guide/openclaw.md b/docs/guide/openclaw.md new file mode 100644 index 0000000..87a0e10 --- /dev/null +++ b/docs/guide/openclaw.md @@ -0,0 +1,32 @@ +# OpenClaw Config + +Skillr supports managing [OpenClaw](https://openclaw.org/) configurations for your projects. OpenClaw is an open standard for defining AI agent behavior and constraints. + +## Managing OpenClaw Config + +Navigate to a project and open the **OpenClaw** tab to view and edit the project's OpenClaw configuration. + +The OpenClaw config is a JSON document that defines agent constraints, allowed operations, and behavior boundaries for the project. + +## API + +``` +GET /api/projects/{id}/openclaw # Get OpenClaw config +PUT /api/projects/{id}/openclaw # Update OpenClaw config +``` + +The PUT endpoint accepts a JSON body with the OpenClaw configuration: + +```json +{ + "constraints": { + "allowed_operations": ["read", "write", "execute"], + "forbidden_paths": ["/etc", "/var/log"], + "max_tokens_per_request": 8192 + }, + "behavior": { + "confirmation_required": ["delete", "deploy"], + "auto_approve": ["read", "lint"] + } +} +``` diff --git a/docs/guide/repositories.md b/docs/guide/repositories.md new file mode 100644 index 0000000..cb5bc47 --- /dev/null +++ b/docs/guide/repositories.md @@ -0,0 +1,70 @@ +# Repository Connections + +Skillr can connect to GitHub and GitLab repositories for bidirectional skill sync -- push skills from Skillr to a remote repo, or pull skill changes made outside Skillr back in. + +## Connecting a Repository + +Navigate to a project's **Settings** page and open the **Repository** tab. + +Click **Connect Repository** and provide: + +| Field | Description | +|---|---| +| **Provider** | `github` or `gitlab` | +| **Owner** | Repository owner or organization | +| **Name** | Repository name | +| **Default Branch** | Branch to sync against (e.g., `main`) | +| **Access Token** | Personal access token with repo read/write permissions | + +### Auto-Sync Options + +| Option | Description | +|---|---| +| **Auto-scan on push** | When a push is received via webhook, automatically scan the project for new or changed skills | +| **Auto-sync on push** | After scanning, automatically sync skills to all enabled providers | + +## Repository Status + +Once connected, the repository tab shows: + +- **Connection status** -- Whether the repo is accessible +- **Last synced at** -- When skills were last pulled or pushed +- **Last commit SHA** -- The most recent commit Skillr is aware of +- **Branch list** -- Available branches in the remote + +## Pull and Push + +### Pull + +Click **Pull** to fetch the latest `.skillr/skills/` files from the remote repository and update the local project. This is useful when team members have edited skills directly in the repo. + +### Push + +Click **Push** to commit and push the current state of `.skillr/skills/` to the remote repository. This makes your local skill changes available to the rest of the team. + +## Browsing Remote Files + +The repository panel lets you browse the file tree of the connected repo without leaving Skillr. This is useful for verifying that skill files were pushed correctly. + +## Allowed Paths + +For security, Skillr restricts which local file paths can be used for projects. The allowed paths can be configured in the Filament Admin panel. The repository API exposes these: + +``` +GET /api/repositories/allowed-paths +``` + +## API + +``` +GET /api/projects/{id}/repositories # List connections +POST /api/projects/{id}/repositories # Connect repo +PUT /api/projects/{id}/repositories/{provider} # Update config +DELETE /api/projects/{id}/repositories/{provider} # Disconnect +GET /api/projects/{id}/repositories/{provider}/status # Connection status +GET /api/projects/{id}/repositories/{provider}/branches # List branches +GET /api/projects/{id}/repositories/{provider}/latest-commit # Latest commit info +GET /api/projects/{id}/repositories/{provider}/files # Browse files +POST /api/projects/{id}/repositories/{provider}/pull # Pull from remote +POST /api/projects/{id}/repositories/{provider}/push # Push to remote +``` diff --git a/docs/guide/reverse-import.md b/docs/guide/reverse-import.md new file mode 100644 index 0000000..f36c0a5 --- /dev/null +++ b/docs/guide/reverse-import.md @@ -0,0 +1,77 @@ +# Reverse Import + +Reverse import lets you bring existing AI provider configurations _into_ Skillr. If you already have `.cursor/rules/`, `.claude/CLAUDE.md`, or other provider config files, Skillr can detect and import them as skills. + +## How It Works + +Reverse import is the opposite of [provider sync](./provider-sync). Instead of writing from `.skillr/` to provider files, it reads from provider files and creates `.skillr/` skills. + +``` +Provider configs → Skillr detection → Preview → Import as skills +``` + +## Detecting Skills + +### From the Project Settings + +Navigate to a project's **Settings** page and open the **Import** tab. Click **Detect** to scan the project directory for provider-specific skill files. + +Skillr scans for: + +| Provider | Scanned Path | Format | +|---|---|---| +| Claude | `.claude/CLAUDE.md` | H2 headings parsed as individual skills | +| Cursor | `.cursor/rules/*.mdc` | Each MDC file becomes a skill | +| Copilot | `.github/copilot-instructions.md` | H2 headings parsed as individual skills | +| Windsurf | `.windsurf/rules/*.md` | Each file becomes a skill | +| Cline | `.clinerules` | Parsed as a single skill or split by headings | +| OpenAI | `.openai/instructions.md` | H2 headings parsed as individual skills | + +### From the Project Detail Page + +Click **Scan** on the project detail page. This scans both `.skillr/skills/` (normal scan) and provider-specific paths (reverse import). + +## Preview Before Import + +After detection, Skillr shows a preview of the skills it found: + +- **Skill name** -- Derived from the filename or heading +- **Source provider** -- Which provider config the skill was found in +- **Body preview** -- First 200 characters of the skill content +- **Conflict indicator** -- Whether a skill with the same slug already exists + +Review the list and select which skills to import. + +## Importing + +Click **Import** to create the selected skills in your project. For each imported skill, Skillr: + +1. Creates a skill record in the database +2. Writes a `.skillr/skills/{slug}.md` file +3. Creates a version 1 snapshot +4. Assigns the `general` category by default + +### Conflict Handling + +If a skill with the same slug already exists in the project, the import skips that skill. Rename the existing skill first if you want to import the detected version. + +## API + +``` +POST /api/import/detect # Detect skills in provider formats +POST /api/projects/{id}/import # Import detected skills +``` + +The detect endpoint accepts: + +```json +{ + "path": "/path/to/project" +} +``` + +Returns an array of detected skills with their source, name, and body. + +::: tip +Reverse import is a great way to onboard an existing project onto Skillr. Run detect once, import what you want, then use Skillr as the source of truth going forward. +::: diff --git a/docs/guide/search.md b/docs/guide/search.md new file mode 100644 index 0000000..e9b2e7a --- /dev/null +++ b/docs/guide/search.md @@ -0,0 +1,39 @@ +# Cross-Project Search + +The search page lets you find skills across all your projects in one place. + +## Using Search + +Navigate to **Search** from the sidebar. The search interface has: + +- A **search bar** at the top for text queries +- A **filter panel** (collapsible) with project, model, and tag filters +- **Results** grouped by project + +### Text Search + +Type a query to search across skill names, descriptions, and body content. Search uses a 300ms debounce so results update as you type without hammering the API. + +### Filters + +Narrow results with any combination of: + +| Filter | Description | +|---|---| +| **Project** | Limit results to a specific project | +| **Model** | Filter by target model (e.g., `claude-sonnet-4-6`) | +| **Tags** | Multi-select tags to filter by | + +Click **Clear Filters** to reset all filters at once. + +### Results + +Results are grouped by project. Each project section shows the project name and matching skill count, with a grid of skill cards. Click any card to open the skill in the editor. + +## API + +``` +GET /api/search?q=review&tags=security&project_id=uuid&model=claude-sonnet-4-6 +``` + +All parameters are optional. Returns skills matching the query, grouped by project. diff --git a/docs/guide/skill-taxonomy.md b/docs/guide/skill-taxonomy.md new file mode 100644 index 0000000..029a341 --- /dev/null +++ b/docs/guide/skill-taxonomy.md @@ -0,0 +1,107 @@ +# Skill Taxonomy + +Skillr supports a structured taxonomy for organizing and classifying skills based on Anthropic's best practices for AI skill design. This system helps agents discover the right skill at the right time. + +## Categories + +Every skill can be assigned to one of 10 categories. Categories describe what domain the skill operates in and are used to group skills in the project detail view. + +| Category | Description | +|---|---| +| `library-api-reference` | API usage patterns, SDK documentation, library guides | +| `product-verification` | Testing, QA, validation, acceptance criteria | +| `data-analysis` | Data processing, analytics, reporting, visualization | +| `business-automation` | Workflow automation, integrations, business rules | +| `scaffolding-templates` | Code generation, project scaffolding, boilerplates | +| `code-quality-review` | Code review, linting rules, style enforcement | +| `ci-cd-deployment` | Build pipelines, deployment scripts, release automation | +| `incident-runbooks` | On-call procedures, incident response, troubleshooting | +| `infrastructure-ops` | Infrastructure management, monitoring, DevOps | +| `general` | Catch-all for skills that don't fit a specific category | + +Set the category in the **Frontmatter Form** using the category dropdown. When no category is selected, the skill defaults to `general`. + +### Grouped View + +On the project detail page, skills are grouped by category in collapsible sections. Each section shows the category name, icon, and skill count. This makes it easy to scan a project with dozens of skills and find what you need. + +## Skill Types + +Skills fall into two fundamental types that describe _how_ they help the AI: + +### Capability Uplift + +A **capability uplift** skill teaches the AI something it cannot do on its own. These skills provide domain knowledge, API references, or specialized procedures that go beyond the model's training data. + +Examples: +- Internal API reference documentation +- Company-specific deployment procedures +- Proprietary data format specifications + +### Encoded Preference + +An **encoded preference** skill captures how you want the AI to behave -- coding style, output format, tone, or decision-making rules. The AI _could_ produce valid output without the skill, but the skill ensures it matches your preferences. + +Examples: +- Code style guidelines (naming conventions, patterns) +- Output format templates (always use bullet points, include examples) +- Decision rules (prefer composition over inheritance) + +Set the skill type in the Frontmatter Form using the radio toggle. The linter will suggest setting a type if one is not specified. + +::: tip +Understanding the distinction helps with skill design: capability uplift skills should focus on _what_ (knowledge), while encoded preference skills should focus on _how_ (behavior rules). +::: + +## Gotchas + +The gotchas field captures common failure points, edge cases, and warnings for a skill. According to Anthropic's research, gotcha sections are the **highest-signal content** in any skill -- they prevent the AI from making mistakes it would otherwise repeat. + +### Writing Good Gotchas + +Gotchas should be specific and actionable: + +```markdown +- Never use `rm -rf` without confirming the target path first +- The payments API returns amounts in cents, not dollars -- always divide by 100 for display +- PostgreSQL JSONB operators differ from MySQL JSON functions -- don't assume syntax compatibility +- Rate limits on the staging API are 10x lower than production -- tests may fail if run too fast +``` + +Avoid vague gotchas like "be careful with edge cases" -- these don't help the AI avoid specific mistakes. + +### Gotchas in the Editor + +The gotchas field appears as a collapsible textarea in the Frontmatter Form. It supports plain text or Markdown. Gotchas are included in the skill's sync output and composed agent output. + +### Linting for Gotchas + +The [prompt linter](./linting) warns when a complex skill (over 500 tokens) has no gotchas. This is a suggestion, not a hard error -- but adding gotchas to substantial skills significantly improves output quality. + +## Supplementary Files + +Skills can include additional files beyond the main Markdown body. When a skill has supplementary files, it is stored as a folder rather than a flat file: + +``` +.skillr/skills/ +├── api-client/ # Folder-based skill +│ ├── skill.md # Main skill file +│ ├── gotchas.md # Extracted gotchas +│ └── examples/ +│ └── good-output.md # Example outputs +├── simple-rule.md # Flat file skill (still supported) +``` + +### Progressive Disclosure + +Skills with supplementary files support 3 levels of detail for agent discovery: + +1. **Level 1** (~100 tokens) -- Name and description only. Used for agent skill discovery when scanning available skills. +2. **Level 2** (~500 tokens) -- Name, description, gotchas, and key rules. Used when the agent is considering whether to apply the skill. +3. **Level 3** (full) -- Complete skill body with all supplementary files. Used when the agent is actively executing the skill. + +This allows agents to efficiently triage which skills are relevant without loading every full skill body into context. + +### Backward Compatibility + +Flat-file skills (a single `.md` file per skill) continue to work exactly as before. The folder format is only used when a skill has gotchas or supplementary files. Skillr detects both formats automatically during project scans. diff --git a/docs/guide/skills.md b/docs/guide/skills.md index 0713abe..d2f09fa 100644 --- a/docs/guide/skills.md +++ b/docs/guide/skills.md @@ -16,12 +16,15 @@ Open any skill from the project detail page to launch the Skill Editor. The edit |---|---|---|---| | `name` | string | Yes | Display name of the skill | | `description` | string | No | Short summary of what the skill does | +| `category` | string | No | [Skill category](./skill-taxonomy#categories) (e.g., `code-quality-review`) | +| `skill_type` | string | No | [Skill type](./skill-taxonomy#skill-types): `capability-uplift` or `encoded-preference` | | `model` | string | No | Target model (e.g., `claude-sonnet-4-6`) | | `max_tokens` | number | No | Max output tokens for test/playground | | `tags` | string[] | No | Tags for categorization and filtering | | `tools` | object[] | No | Tool/function definitions (JSON) | | `includes` | string[] | No | Slugs of other skills to [include](./includes) | | `template_variables` | object[] | No | [Template variable](./templates) definitions | +| `gotchas` | string | No | [Common failure points](./skill-taxonomy#gotchas) and edge cases | Fill in the fields in the sidebar form. The `name` field is the only required field -- everything else is optional. diff --git a/docs/guide/visualization.md b/docs/guide/visualization.md new file mode 100644 index 0000000..ae0030e --- /dev/null +++ b/docs/guide/visualization.md @@ -0,0 +1,40 @@ +# Project Visualization + +The visualization tab provides an interactive graph of your project's skill relationships, agent assignments, and provider connections. + +## Accessing the Graph + +Navigate to a project and open the **Visualize** tab. The graph renders automatically using D3.js. + +## What the Graph Shows + +### Skill Dependencies + +Nodes represent skills, and edges represent [include](./includes) relationships. If skill A includes skill B, an arrow points from A to B. This makes it easy to see: + +- Which skills are composed from others +- Which skills are shared dependencies (many arrows pointing in) +- Whether there are circular dependencies (highlighted in red) + +### Agent Assignments + +Enabled agents appear as a distinct node type connected to their assigned skills. This shows at a glance which skills feed into which agents. + +### Circular Dependencies + +If the graph detects a circular dependency (A includes B, B includes C, C includes A), the cycle is highlighted. Circular dependencies are also caught by the [include resolver](./includes) at sync time, but the graph provides a visual way to spot them. + +## Interacting with the Graph + +- **Zoom** -- Scroll to zoom in and out +- **Pan** -- Click and drag the background to pan +- **Drag nodes** -- Click and drag a node to reposition it +- **Click a node** -- Highlights the node and its immediate connections + +## API + +``` +GET /api/projects/{id}/graph +``` + +Returns the graph data structure with nodes (skills, agents) and edges (includes, assignments), plus any detected circular dependencies. diff --git a/docs/reference/api.md b/docs/reference/api.md index 633ebe0..177adef 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -1,6 +1,6 @@ # API Endpoints -All endpoints are served at `http://localhost:8000/api`. There is no authentication -- Skillr is a single-user application. +All endpoints are served at `http://localhost:8000/api`. API routes are protected by session-based authentication (`auth:web` middleware). See [Authentication](../guide/authentication) for details. ## Health @@ -692,3 +692,388 @@ PUT /api/settings "default_model": "claude-sonnet-4-6" } ``` + +--- + +## Repositories + +### List Repository Connections + +``` +GET /api/projects/{id}/repositories +``` + +### Connect Repository + +``` +POST /api/projects/{id}/repositories +``` + +```json +{ + "provider": "github", + "owner": "your-org", + "name": "your-repo", + "default_branch": "main", + "access_token": "ghp_...", + "auto_scan_on_push": true, + "auto_sync_on_push": false +} +``` + +### Update Repository Config + +``` +PUT /api/projects/{id}/repositories/{provider} +``` + +### Disconnect Repository + +``` +DELETE /api/projects/{id}/repositories/{provider} +``` + +### Repository Status + +``` +GET /api/projects/{id}/repositories/{provider}/status +``` + +### List Branches + +``` +GET /api/projects/{id}/repositories/{provider}/branches +``` + +### Latest Commit + +``` +GET /api/projects/{id}/repositories/{provider}/latest-commit +``` + +### Browse Files + +``` +GET /api/projects/{id}/repositories/{provider}/files +``` + +### Pull from Remote + +``` +POST /api/projects/{id}/repositories/{provider}/pull +``` + +### Push to Remote + +``` +POST /api/projects/{id}/repositories/{provider}/push +``` + +### Allowed Paths + +``` +GET /api/repositories/allowed-paths +``` + +--- + +## MCP Servers + +### List MCP Servers + +``` +GET /api/projects/{id}/mcp-servers +``` + +### Add MCP Server + +``` +POST /api/projects/{id}/mcp-servers +``` + +```json +{ + "name": "PostgreSQL", + "transport": "stdio", + "config": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "env": { "DATABASE_URL": "postgresql://localhost:5432/mydb" } + } +} +``` + +### Update MCP Server + +``` +PUT /api/mcp-servers/{id} +``` + +### Delete MCP Server + +``` +DELETE /api/mcp-servers/{id} +``` + +--- + +## A2A Agents + +### List A2A Agents + +``` +GET /api/projects/{id}/a2a-agents +``` + +### Create A2A Agent + +``` +POST /api/projects/{id}/a2a-agents +``` + +```json +{ + "name": "Code Review Agent", + "provider": "custom", + "config": { + "endpoint": "https://agent.example.com/a2a", + "capabilities": ["code-review"] + } +} +``` + +### Update A2A Agent + +``` +PUT /api/a2a-agents/{id} +``` + +### Delete A2A Agent + +``` +DELETE /api/a2a-agents/{id} +``` + +--- + +## OpenClaw Config + +### Get OpenClaw Config + +``` +GET /api/projects/{id}/openclaw +``` + +### Update OpenClaw Config + +``` +PUT /api/projects/{id}/openclaw +``` + +--- + +## Visualization + +### Project Graph + +``` +GET /api/projects/{id}/graph +``` + +Returns nodes (skills, agents), edges (includes, assignments), and detected circular dependencies. + +--- + +## Reverse Import + +### Detect Skills in Provider Configs + +``` +POST /api/import/detect +``` + +```json +{ + "path": "/path/to/project" +} +``` + +### Import Detected Skills + +``` +POST /api/projects/{id}/import +``` + +--- + +## Desktop Config Sync + +### List Synced Desktop Apps + +``` +GET /api/desktop-configs +``` + +### Detect Installed Apps + +``` +GET /api/desktop-configs/detect +``` + +### Register App + +``` +POST /api/desktop-configs +``` + +### Unregister App + +``` +DELETE /api/desktop-configs/{appSlug} +``` + +### Sync All Apps + +``` +POST /api/desktop-configs/sync +``` + +### Sync Single App + +``` +POST /api/desktop-configs/{appSlug}/sync +``` + +### Preview Sync + +``` +GET /api/desktop-configs/{appSlug}/preview +``` + +### Import MCP Servers from App + +``` +POST /api/desktop-configs/import-mcp +``` + +--- + +## Authentication + +### Register + +``` +POST /api/auth/register +``` + +```json +{ + "name": "Jane Doe", + "email": "jane@example.com", + "password": "password", + "password_confirmation": "password" +} +``` + +### Login + +``` +POST /api/auth/login +``` + +```json +{ + "email": "jane@example.com", + "password": "password" +} +``` + +### Logout + +``` +POST /api/auth/logout +``` + +### Current User + +``` +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/skill-format.md b/docs/reference/skill-format.md index a314d7c..08001c0 100644 --- a/docs/reference/skill-format.md +++ b/docs/reference/skill-format.md @@ -4,14 +4,21 @@ Skills are stored as Markdown files with YAML frontmatter in `.skillr/skills/`. ## File Location +Skills can be stored as flat files or folders: + ``` project-root/ .skillr/ skills/ - my-skill.md + simple-skill.md # Flat file format + complex-skill/ # Folder format + skill.md # Main skill file + gotchas.md # Supplementary file + examples/ + good-output.md # Additional files ``` -The filename is the skill's slug with a `.md` extension. Slugs are auto-generated from the skill name (lowercased, spaces replaced with hyphens, special characters removed). +The filename (or folder name) is the skill's slug. Slugs are auto-generated from the skill name (lowercased, spaces replaced with hyphens, special characters removed). The folder format is used when a skill has supplementary files; see [Skill Taxonomy](../guide/skill-taxonomy#supplementary-files). ## Structure @@ -22,12 +29,17 @@ A skill file has two sections separated by the YAML frontmatter delimiters (`--- id: summarize-doc name: Summarize Document description: Summarizes any document to key bullet points +category: data-analysis +skill_type: capability-uplift tags: [summarization, documents] model: claude-sonnet-4-6 max_tokens: 1000 tools: [] includes: [] template_variables: [] +gotchas: | + - Documents over 100k tokens may be truncated silently + - Tables in PDFs often lose formatting during extraction created_at: 2026-01-15T09:00:00Z updated_at: 2026-03-09T14:22:00Z --- @@ -57,12 +69,16 @@ the key points and present them as a concise bulleted list. | Field | Type | Default | Description | |---|---|---|---| | `description` | `string` | `null` | Short summary of the skill's purpose | +| `category` | `string` | `general` | Skill category. See [Skill Taxonomy](../guide/skill-taxonomy#categories). | +| `skill_type` | `string` | `null` | `capability-uplift` or `encoded-preference`. See [Skill Types](../guide/skill-taxonomy#skill-types). | | `tags` | `string[]` | `[]` | Tags for categorization and filtering | | `model` | `string` | `null` | Target model (e.g., `claude-sonnet-4-6`). Falls back to default model in settings. | | `max_tokens` | `integer` | `null` | Maximum output tokens for test/playground. Falls back to system default. | | `tools` | `object[]` | `[]` | Tool/function definitions in JSON Schema format | | `includes` | `string[]` | `[]` | Slugs of other skills in the same project to prepend. See [Includes](../guide/includes). | | `template_variables` | `object[]` | `[]` | Template variable definitions. See [Templates](../guide/templates). | +| `gotchas` | `string` | `null` | Common failure points and edge cases. See [Gotchas](../guide/skill-taxonomy#gotchas). | +| `supplementary_files` | `object[]` | `[]` | Additional files in a skill folder. Each entry has `path` and `content`. | | `created_at` | `string` (ISO 8601) | Auto-set | Creation timestamp | | `updated_at` | `string` (ISO 8601) | Auto-set | Last modification timestamp | diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..93061b6 --- /dev/null +++ b/pint.json @@ -0,0 +1,3 @@ +{ + "preset": "laravel" +} diff --git a/routes/api.php b/routes/api.php index 9a94d6e..c3da495 100644 --- a/routes/api.php +++ b/routes/api.php @@ -23,6 +23,7 @@ use App\Http\Controllers\McpServerController; use App\Http\Controllers\A2aAgentController; use App\Http\Controllers\BillingController; +use App\Http\Controllers\DesktopConfigController; use App\Http\Controllers\ImportController; use App\Http\Controllers\StripeWebhookController; use App\Http\Controllers\VisualizationController; @@ -35,7 +36,7 @@ Route::get('/billing/plans', [BillingController::class, 'plans']); // ─── Authenticated Routes ──────────────────────────────────── -Route::middleware('auth:web')->group(function () { +Route::middleware(['auth:web', 'throttle:120,1'])->group(function () { // Projects Route::get('/projects', [ProjectController::class, 'index']); Route::post('/projects', [ProjectController::class, 'store']); @@ -69,12 +70,12 @@ Route::get('/projects/{project}/skills/{skill}/variables', [SkillVariableController::class, 'index']); Route::put('/projects/{project}/skills/{skill}/variables', [SkillVariableController::class, 'update']); - // Live Test Runner (SSE) - Route::post('/skills/{skill}/test', SkillTestController::class); - Route::post('/playground', [SkillTestController::class, 'playground']); + // Live Test Runner (SSE) — rate-limited to prevent LLM cost abuse + Route::post('/skills/{skill}/test', SkillTestController::class)->middleware('throttle:10,1'); + Route::post('/playground', [SkillTestController::class, 'playground'])->middleware('throttle:10,1'); - // AI Skill Generation - Route::post('/skills/generate', SkillGenerateController::class); + // AI Skill Generation — rate-limited + Route::post('/skills/generate', SkillGenerateController::class)->middleware('throttle:5,1'); // Versions Route::get('/skills/{skill}/versions', [VersionController::class, 'index']); @@ -181,6 +182,16 @@ Route::get('/billing/connect/status', [BillingController::class, 'connectStatus']); Route::get('/billing/earnings', [BillingController::class, 'earnings']); + // Desktop App Config Sync + Route::get('/desktop-configs', [DesktopConfigController::class, 'index']); + Route::get('/desktop-configs/detect', [DesktopConfigController::class, 'detect']); + Route::post('/desktop-configs', [DesktopConfigController::class, 'store']); + Route::delete('/desktop-configs/{appSlug}', [DesktopConfigController::class, 'destroy']); + Route::post('/desktop-configs/sync', [DesktopConfigController::class, 'syncAll']); + Route::post('/desktop-configs/{appSlug}/sync', [DesktopConfigController::class, 'syncApp']); + Route::get('/desktop-configs/{appSlug}/preview', [DesktopConfigController::class, 'preview']); + Route::post('/desktop-configs/import-mcp', [DesktopConfigController::class, 'importMcp']); + // Settings Route::get('/settings', SettingsController::class); Route::put('/settings', [SettingsController::class, 'update']); diff --git a/tests/Feature/AuthorizationTest.php b/tests/Feature/AuthorizationTest.php new file mode 100644 index 0000000..74170a8 --- /dev/null +++ b/tests/Feature/AuthorizationTest.php @@ -0,0 +1,140 @@ +orgA = Organization::create(['name' => 'Org A', 'slug' => 'org-a', 'plan' => 'free']); + $this->orgB = Organization::create(['name' => 'Org B', 'slug' => 'org-b', 'plan' => 'free']); + + $this->userA = User::factory()->create(['current_organization_id' => $this->orgA->id]); + $this->userB = User::factory()->create(['current_organization_id' => $this->orgB->id]); + + $this->orgA->users()->attach($this->userA, ['role' => 'owner']); + $this->orgB->users()->attach($this->userB, ['role' => 'owner']); + + // Bind org context for project creation + app()->instance('current_organization', $this->orgA); + $this->projectA = Project::create([ + 'name' => 'Project A', + 'path' => '/tmp/project-a', + 'organization_id' => $this->orgA->id, + ]); + + $this->skillA = $this->projectA->skills()->create([ + 'name' => 'Skill A', + 'slug' => 'skill-a', + 'body' => 'Test body', + ]); + + app()->instance('current_organization', $this->orgB); + $this->projectB = Project::create([ + 'name' => 'Project B', + 'path' => '/tmp/project-b', + 'organization_id' => $this->orgB->id, + ]); + + $this->skillB = $this->projectB->skills()->create([ + 'name' => 'Skill B', + 'slug' => 'skill-b', + 'body' => 'Test body B', + ]); +}); + +// ─── Project Authorization ────────────────────────────────── + +it('allows user to view their own project', function () { + $this->actingAs($this->userA) + ->getJson("/api/projects/{$this->projectA->id}") + ->assertOk(); +}); + +it('denies user from viewing another org project', function () { + $this->actingAs($this->userA) + ->getJson("/api/projects/{$this->projectB->id}") + ->assertForbidden(); +}); + +it('denies user from updating another org project', function () { + $this->actingAs($this->userA) + ->putJson("/api/projects/{$this->projectB->id}", ['name' => 'Hacked']) + ->assertForbidden(); +}); + +it('denies user from deleting another org project', function () { + $this->actingAs($this->userA) + ->deleteJson("/api/projects/{$this->projectB->id}") + ->assertForbidden(); +}); + +// ─── Skill Authorization ──────────────────────────────────── + +it('allows user to view their own skill', function () { + $this->actingAs($this->userA) + ->getJson("/api/skills/{$this->skillA->id}") + ->assertOk(); +}); + +it('denies user from viewing another org skill', function () { + $this->actingAs($this->userA) + ->getJson("/api/skills/{$this->skillB->id}") + ->assertForbidden(); +}); + +it('denies user from updating another org skill', function () { + $this->actingAs($this->userA) + ->putJson("/api/skills/{$this->skillB->id}", ['name' => 'Hacked']) + ->assertForbidden(); +}); + +it('denies user from deleting another org skill', function () { + $this->actingAs($this->userA) + ->deleteJson("/api/skills/{$this->skillB->id}") + ->assertForbidden(); +}); + +// ─── Skill Duplication Cross-Project ──────────────────────── + +it('denies duplicating a skill into another org project', function () { + $this->actingAs($this->userA) + ->postJson("/api/skills/{$this->skillA->id}/duplicate", [ + 'target_project_id' => $this->projectB->id, + ]) + ->assertForbidden(); +}); + +// ─── Bulk Operations ──────────────────────────────────────── + +it('filters out other org skills in bulk delete', function () { + $this->actingAs($this->userA) + ->postJson('/api/skills/bulk-delete', [ + 'skill_ids' => [$this->skillA->id, $this->skillB->id], + ]) + ->assertOk(); + + // Only skill A should be deleted (owned by user A's org) + expect(Skill::find($this->skillA->id))->toBeNull(); + expect(Skill::find($this->skillB->id))->not->toBeNull(); +}); + +it('denies bulk move to another org project', function () { + $this->actingAs($this->userA) + ->postJson('/api/skills/bulk-move', [ + 'skill_ids' => [$this->skillA->id], + 'target_project_id' => $this->projectB->id, + ]) + ->assertForbidden(); +}); + +// ─── Unauthenticated Access ───────────────────────────────── + +it('returns 401 for unauthenticated requests', function () { + $this->getJson('/api/projects') + ->assertUnauthorized(); +}); diff --git a/tests/Feature/DesktopSyncTest.php b/tests/Feature/DesktopSyncTest.php new file mode 100644 index 0000000..49c3525 --- /dev/null +++ b/tests/Feature/DesktopSyncTest.php @@ -0,0 +1,281 @@ +org = Organization::create(['name' => 'Test Org', 'slug' => 'test-org', 'plan' => 'free']); + $this->user = User::factory()->create(['current_organization_id' => $this->org->id]); + $this->org->users()->attach($this->user, ['role' => 'owner']); + app()->instance('current_organization', $this->org); + + $this->project = Project::create([ + 'name' => 'Desktop Test', + 'path' => '/tmp/desktop-test', + 'organization_id' => $this->org->id, + ]); + + $this->tempDir = sys_get_temp_dir() . '/skillr-desktop-test-' . uniqid(); + mkdir($this->tempDir, 0755, true); + + $this->syncService = app(DesktopSyncService::class); +}); + +afterEach(function () { + File::deleteDirectory($this->tempDir); +}); + +it('detects known desktop apps', function () { + $detected = DesktopAppConfig::detectInstalled(); + + expect($detected)->toHaveKey('claude-desktop'); + expect($detected)->toHaveKey('claude-code'); + expect($detected)->toHaveKey('cursor'); + expect($detected['claude-desktop'])->toHaveKey('installed'); + expect($detected['claude-desktop'])->toHaveKey('name'); +}); + +it('generates MCP config JSON for desktop apps', function () { + $configPath = $this->tempDir . '/test-config.json'; + + // Create MCP servers + ProjectMcpServer::create([ + 'project_id' => $this->project->id, + 'name' => 'postgres', + 'transport' => 'stdio', + 'command' => 'npx', + 'args' => ['-y', '@modelcontextprotocol/server-postgres'], + 'enabled' => true, + ]); + + $config = DesktopAppConfig::create([ + 'user_id' => $this->user->id, + 'app_slug' => 'claude-desktop', + 'config_path' => $configPath, + 'sync_mcp' => true, + ]); + + $this->syncService->syncApp($config, $this->user, $this->project->id); + + expect(file_exists($configPath))->toBeTrue(); + + $written = json_decode(file_get_contents($configPath), true); + expect($written['mcpServers'])->toHaveKey('postgres'); + expect($written['mcpServers']['postgres']['command'])->toBe('npx'); + expect($written['mcpServers']['postgres']['args'])->toBe(['-y', '@modelcontextprotocol/server-postgres']); +}); + +it('preserves existing non-MCP config keys', function () { + $configPath = $this->tempDir . '/existing-config.json'; + + // Write existing config with extra keys + file_put_contents($configPath, json_encode([ + 'theme' => 'dark', + 'language' => 'en', + 'mcpServers' => ['old-server' => ['command' => 'old']], + ], JSON_PRETTY_PRINT)); + + ProjectMcpServer::create([ + 'project_id' => $this->project->id, + 'name' => 'new-server', + 'transport' => 'stdio', + 'command' => 'new-cmd', + 'enabled' => true, + ]); + + $config = DesktopAppConfig::create([ + 'user_id' => $this->user->id, + 'app_slug' => 'claude-desktop', + 'config_path' => $configPath, + 'sync_mcp' => true, + ]); + + $this->syncService->syncApp($config, $this->user, $this->project->id); + + $written = json_decode(file_get_contents($configPath), true); + + // Non-MCP keys preserved + expect($written['theme'])->toBe('dark'); + expect($written['language'])->toBe('en'); + + // MCP servers replaced with Skillr-managed ones + expect($written['mcpServers'])->toHaveKey('new-server'); + expect($written['mcpServers'])->not->toHaveKey('old-server'); +}); + +it('generates preview without writing', function () { + $configPath = $this->tempDir . '/preview-config.json'; + file_put_contents($configPath, json_encode(['existing' => true], JSON_PRETTY_PRINT)); + + ProjectMcpServer::create([ + 'project_id' => $this->project->id, + 'name' => 'preview-server', + 'transport' => 'stdio', + 'command' => 'test', + 'enabled' => true, + ]); + + $config = DesktopAppConfig::create([ + 'user_id' => $this->user->id, + 'app_slug' => 'cursor', + 'config_path' => $configPath, + 'sync_mcp' => true, + ]); + + $preview = $this->syncService->preview($config, $this->user, $this->project->id); + + expect($preview)->toHaveKey('current'); + expect($preview)->toHaveKey('proposed'); + expect($preview['proposed'])->toContain('preview-server'); + + // File should NOT have been modified + $onDisk = json_decode(file_get_contents($configPath), true); + expect($onDisk)->not->toHaveKey('mcpServers'); +}); + +it('imports MCP servers from existing desktop configs', function () { + // Simulate an existing Claude Desktop config with MCP servers + $home = getenv('HOME'); + $fakePath = $this->tempDir . '/claude_desktop_config.json'; + file_put_contents($fakePath, json_encode([ + 'mcpServers' => [ + 'filesystem' => [ + 'command' => 'npx', + 'args' => ['-y', '@modelcontextprotocol/server-filesystem'], + ], + 'github' => [ + 'command' => 'npx', + 'args' => ['-y', '@modelcontextprotocol/server-github'], + 'env' => ['GITHUB_TOKEN' => 'xxx'], + ], + ], + ])); + + // Monkey-patch the known apps to use our temp path + $result = $this->syncService->importMcpServers($this->user, $this->project->id); + + // Since we can't easily override knownApps paths in a test, + // verify the import method works with the service directly + expect($result)->toHaveKey('imported'); + expect($result)->toHaveKey('skipped'); + expect($result)->toHaveKey('sources'); +}); + +it('merges workspace profile settings for Claude Code', function () { + $configPath = $this->tempDir . '/claude-code-settings.json'; + file_put_contents($configPath, json_encode(['existingKey' => true])); + + WorkspaceProfile::create([ + 'user_id' => $this->user->id, + 'name' => 'Work', + 'slug' => 'work', + 'allowed_tools' => ['Read', 'Write', 'Bash'], + 'denied_tools' => ['mcp__dangerous-tool'], + 'is_default' => true, + ]); + + $config = DesktopAppConfig::create([ + 'user_id' => $this->user->id, + 'app_slug' => 'claude-code', + 'config_path' => $configPath, + 'sync_mcp' => false, + 'sync_settings' => true, + ]); + + $this->syncService->syncApp($config, $this->user); + + $written = json_decode(file_get_contents($configPath), true); + + expect($written['existingKey'])->toBeTrue(); + expect($written['allowedTools'])->toBe(['Read', 'Write', 'Bash']); + expect($written['deniedTools'])->toBe(['mcp__dangerous-tool']); +}); + +it('merges workspace profile settings for Codex CLI', function () { + $configPath = $this->tempDir . '/codex-config.json'; + file_put_contents($configPath, json_encode([])); + + WorkspaceProfile::create([ + 'user_id' => $this->user->id, + 'name' => 'Work', + 'slug' => 'work-codex', + 'default_model' => 'o3', + 'approval_mode' => 'suggest', + 'is_default' => true, + ]); + + $config = DesktopAppConfig::create([ + 'user_id' => $this->user->id, + 'app_slug' => 'codex-cli', + 'config_path' => $configPath, + 'sync_mcp' => false, + 'sync_settings' => true, + ]); + + $this->syncService->syncApp($config, $this->user); + + $written = json_decode(file_get_contents($configPath), true); + + expect($written['model'])->toBe('o3'); + expect($written['approvalMode'])->toBe('suggest'); +}); + +it('creates config file if it does not exist', function () { + $configPath = $this->tempDir . '/nonexistent/new-config.json'; + + ProjectMcpServer::create([ + 'project_id' => $this->project->id, + 'name' => 'test-server', + 'transport' => 'stdio', + 'command' => 'test', + 'enabled' => true, + ]); + + $config = DesktopAppConfig::create([ + 'user_id' => $this->user->id, + 'app_slug' => 'windsurf', + 'config_path' => $configPath, + 'sync_mcp' => true, + ]); + + $this->syncService->syncApp($config, $this->user, $this->project->id); + + expect(file_exists($configPath))->toBeTrue(); + $written = json_decode(file_get_contents($configPath), true); + expect($written['mcpServers'])->toHaveKey('test-server'); +}); + +it('handles malformed JSON config gracefully', function () { + $configPath = $this->tempDir . '/bad-config.json'; + file_put_contents($configPath, 'not valid json {{{'); + + $config = DesktopAppConfig::create([ + 'user_id' => $this->user->id, + 'app_slug' => 'cursor', + 'config_path' => $configPath, + 'sync_mcp' => true, + ]); + + ProjectMcpServer::create([ + 'project_id' => $this->project->id, + 'name' => 'recovery-server', + 'transport' => 'stdio', + 'command' => 'test', + 'enabled' => true, + ]); + + // Should not throw — treats malformed JSON as empty config + $result = $this->syncService->syncApp($config, $this->user, $this->project->id); + + expect($result['mcp_synced'])->toBeTrue(); + $written = json_decode(file_get_contents($configPath), true); + expect($written['mcpServers'])->toHaveKey('recovery-server'); +}); diff --git a/tests/Feature/ProjectApiTest.php b/tests/Feature/ProjectApiTest.php new file mode 100644 index 0000000..63ce513 --- /dev/null +++ b/tests/Feature/ProjectApiTest.php @@ -0,0 +1,72 @@ +org = Organization::create(['name' => 'Test Org', 'slug' => 'test-org', 'plan' => 'free']); + $this->user = User::factory()->create(['current_organization_id' => $this->org->id]); + $this->org->users()->attach($this->user, ['role' => 'owner']); + app()->instance('current_organization', $this->org); +}); + +it('lists projects for the current organization', function () { + Project::create(['name' => 'Project 1', 'path' => '/tmp/p1', 'organization_id' => $this->org->id]); + Project::create(['name' => 'Project 2', 'path' => '/tmp/p2', 'organization_id' => $this->org->id]); + + $response = $this->actingAs($this->user) + ->getJson('/api/projects') + ->assertOk(); + + expect($response->json('data'))->toHaveCount(2); +}); + +it('creates a project', function () { + $response = $this->actingAs($this->user) + ->postJson('/api/projects', [ + 'name' => 'New Project', + 'path' => '/tmp/new-project', + ]) + ->assertCreated(); + + expect($response->json('data.name'))->toBe('New Project'); + expect(Project::where('name', 'New Project')->exists())->toBeTrue(); +}); + +it('shows a single project', function () { + $project = Project::create(['name' => 'Show Me', 'path' => '/tmp/show', 'organization_id' => $this->org->id]); + + $this->actingAs($this->user) + ->getJson("/api/projects/{$project->id}") + ->assertOk() + ->assertJsonPath('data.name', 'Show Me'); +}); + +it('updates a project', function () { + $project = Project::create(['name' => 'Old Name', 'path' => '/tmp/old', 'organization_id' => $this->org->id]); + + $this->actingAs($this->user) + ->putJson("/api/projects/{$project->id}", ['name' => 'New Name']) + ->assertOk() + ->assertJsonPath('data.name', 'New Name'); +}); + +it('deletes a project', function () { + $project = Project::create(['name' => 'Delete Me', 'path' => '/tmp/del', 'organization_id' => $this->org->id]); + + $this->actingAs($this->user) + ->deleteJson("/api/projects/{$project->id}") + ->assertOk(); + + expect(Project::find($project->id))->toBeNull(); +}); + +it('validates required fields on create', function () { + $this->actingAs($this->user) + ->postJson('/api/projects', []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['name', 'path']); +}); diff --git a/tests/Feature/ProjectScanServiceTest.php b/tests/Feature/ProjectScanServiceTest.php new file mode 100644 index 0000000..d69a604 --- /dev/null +++ b/tests/Feature/ProjectScanServiceTest.php @@ -0,0 +1,193 @@ +org = Organization::create(['name' => 'Test Org', 'slug' => 'test-org', 'plan' => 'free']); + $this->user = User::factory()->create(['current_organization_id' => $this->org->id]); + $this->org->users()->attach($this->user, ['role' => 'owner']); + app()->instance('current_organization', $this->org); + + $this->tempDir = sys_get_temp_dir() . '/skillr-scan-test-' . uniqid(); + mkdir($this->tempDir, 0755, true); + + $this->project = Project::create([ + 'name' => 'Scan Test', + 'path' => $this->tempDir, + 'organization_id' => $this->org->id, + ]); + + $this->scanService = app(ProjectScanService::class); +}); + +afterEach(function () { + File::deleteDirectory($this->tempDir); +}); + +it('scans .skillr/skills/ directory and creates skills', function () { + // Create a .skillr/skills/ structure + mkdir($this->tempDir . '/.skillr/skills', 0755, true); + file_put_contents($this->tempDir . '/.skillr/skills/test-skill.md', <<<'MD' +--- +id: test-skill +name: Test Skill +description: A test skill +tags: [testing] +--- + +You are a test helper. +MD); + + $result = $this->scanService->scan($this->project); + + expect($result['skillr']['found'])->toBe(1); + expect($result['skillr']['created'])->toBe(1); + expect($result['total_skills'])->toBe(1); + + $skill = $this->project->skills()->where('slug', 'test-skill')->first(); + expect($skill)->not->toBeNull(); + expect($skill->name)->toBe('Test Skill'); +}); + +it('auto-imports from Claude CLAUDE.md', function () { + mkdir($this->tempDir . '/.claude', 0755, true); + file_put_contents($this->tempDir . '/.claude/CLAUDE.md', <<<'MD' +# Project Instructions + +## Code Style + +Always use snake_case for variable names. + +## Testing + +Write tests for all public methods. +MD); + + $result = $this->scanService->scan($this->project); + + expect($result['providers']['detected'])->toHaveKey('claude'); + expect($result['providers']['imported'])->toBe(2); + + $codeStyle = $this->project->skills()->where('slug', 'code-style')->first(); + expect($codeStyle)->not->toBeNull(); + expect($codeStyle->body)->toContain('snake_case'); + + // Verify imported:claude tag + expect($codeStyle->tags->pluck('name')->toArray())->toContain('imported:claude'); +}); + +it('auto-imports from Cursor .mdc files', function () { + mkdir($this->tempDir . '/.cursor/rules', 0755, true); + file_put_contents($this->tempDir . '/.cursor/rules/lint-rules.mdc', <<<'MD' +--- +description: Linting configuration +tags: [linting, quality] +--- + +Always run the linter before committing. +MD); + + $result = $this->scanService->scan($this->project); + + expect($result['providers']['detected'])->toHaveKey('cursor'); + expect($result['providers']['imported'])->toBe(1); + + $skill = $this->project->skills()->where('slug', 'lint-rules')->first(); + expect($skill)->not->toBeNull(); + expect($skill->tags->pluck('name')->toArray())->toContain('imported:cursor'); + expect($skill->tags->pluck('name')->toArray())->toContain('linting'); +}); + +it('skips duplicate slugs from provider configs', function () { + // Pre-create a skill with the same slug + $this->project->skills()->create([ + 'name' => 'Code Style', + 'slug' => 'code-style', + 'body' => 'Existing skill', + ]); + + mkdir($this->tempDir . '/.claude', 0755, true); + file_put_contents($this->tempDir . '/.claude/CLAUDE.md', <<<'MD' +## Code Style + +New instructions from Claude. +MD); + + $result = $this->scanService->scan($this->project); + + expect($result['providers']['skipped'])->toBe(1); + expect($result['providers']['imported'])->toBe(0); + + // Original skill body should be unchanged + $skill = $this->project->skills()->where('slug', 'code-style')->first(); + expect($skill->body)->toBe('Existing skill'); +}); + +it('detects Codex CLI AGENTS.md', function () { + file_put_contents($this->tempDir . '/AGENTS.md', <<<'MD' +## Frontend Agent + +Handle all React component development. + +## Backend Agent + +Handle all API endpoint development. +MD); + + $result = $this->scanService->scan($this->project); + + expect($result['providers']['detected'])->toHaveKey('codex'); + expect($result['providers']['imported'])->toBe(2); + + $skill = $this->project->skills()->where('slug', 'frontend-agent')->first(); + expect($skill)->not->toBeNull(); + expect($skill->tags->pluck('name')->toArray())->toContain('imported:codex'); +}); + +it('scans multiple providers in a single scan', function () { + // Claude + mkdir($this->tempDir . '/.claude', 0755, true); + file_put_contents($this->tempDir . '/.claude/CLAUDE.md', "## Claude Skill\n\nClaude instructions."); + + // Cursor + mkdir($this->tempDir . '/.cursor/rules', 0755, true); + file_put_contents($this->tempDir . '/.cursor/rules/cursor-skill.mdc', "---\n---\n\nCursor instructions."); + + // OpenAI + mkdir($this->tempDir . '/.openai', 0755, true); + file_put_contents($this->tempDir . '/.openai/instructions.md', "## OpenAI Skill\n\nOpenAI instructions."); + + $result = $this->scanService->scan($this->project); + + expect($result['providers']['detected'])->toHaveKey('claude'); + expect($result['providers']['detected'])->toHaveKey('cursor'); + expect($result['providers']['detected'])->toHaveKey('openai'); + expect($result['providers']['imported'])->toBe(3); + expect($result['total_skills'])->toBe(3); +}); + +it('returns zero results when directory is empty', function () { + $result = $this->scanService->scan($this->project); + + expect($result['skillr']['found'])->toBe(0); + expect($result['providers']['detected'])->toBeEmpty(); + expect($result['providers']['imported'])->toBe(0); + expect($result['total_skills'])->toBe(0); +}); + +it('creates version snapshots for imported skills', function () { + mkdir($this->tempDir . '/.claude', 0755, true); + file_put_contents($this->tempDir . '/.claude/CLAUDE.md', "## My Skill\n\nSome instructions."); + + $this->scanService->scan($this->project); + + $skill = $this->project->skills()->where('slug', 'my-skill')->first(); + expect($skill->versions()->count())->toBe(1); + expect($skill->versions()->first()->note)->toBe('Imported from claude'); +}); diff --git a/tests/Feature/SkillApiTest.php b/tests/Feature/SkillApiTest.php new file mode 100644 index 0000000..ff9ebc8 --- /dev/null +++ b/tests/Feature/SkillApiTest.php @@ -0,0 +1,117 @@ +org = Organization::create(['name' => 'Test Org', 'slug' => 'test-org', 'plan' => 'free']); + $this->user = User::factory()->create(['current_organization_id' => $this->org->id]); + $this->org->users()->attach($this->user, ['role' => 'owner']); + app()->instance('current_organization', $this->org); + + $this->project = Project::create([ + 'name' => 'Test Project', + 'path' => sys_get_temp_dir() . '/skillr-test-' . uniqid(), + 'organization_id' => $this->org->id, + ]); +}); + +it('lists skills for a project', function () { + $this->project->skills()->create(['name' => 'Skill 1', 'slug' => 'skill-1', 'body' => '']); + $this->project->skills()->create(['name' => 'Skill 2', 'slug' => 'skill-2', 'body' => '']); + + $response = $this->actingAs($this->user) + ->getJson("/api/projects/{$this->project->id}/skills") + ->assertOk(); + + expect($response->json('data'))->toHaveCount(2); +}); + +it('creates a skill', function () { + $response = $this->actingAs($this->user) + ->postJson("/api/projects/{$this->project->id}/skills", [ + 'name' => 'New Skill', + 'body' => 'You are a helpful assistant.', + ]) + ->assertCreated(); + + expect($response->json('data.name'))->toBe('New Skill'); + expect($response->json('data.slug'))->toBe('new-skill'); +}); + +it('shows a skill', function () { + $skill = $this->project->skills()->create(['name' => 'Show Me', 'slug' => 'show-me', 'body' => 'Test']); + + $this->actingAs($this->user) + ->getJson("/api/skills/{$skill->id}") + ->assertOk() + ->assertJsonPath('data.name', 'Show Me'); +}); + +it('updates a skill', function () { + $skill = $this->project->skills()->create(['name' => 'Old', 'slug' => 'old', 'body' => 'Old body']); + + $this->actingAs($this->user) + ->putJson("/api/skills/{$skill->id}", [ + 'name' => 'Updated', + 'body' => 'Updated body', + ]) + ->assertOk() + ->assertJsonPath('data.name', 'Updated'); +}); + +it('deletes a skill', function () { + $skill = $this->project->skills()->create(['name' => 'Delete Me', 'slug' => 'delete-me', 'body' => '']); + + $this->actingAs($this->user) + ->deleteJson("/api/skills/{$skill->id}") + ->assertOk(); + + expect(Skill::find($skill->id))->toBeNull(); +}); + +it('duplicates a skill within the same project', function () { + $skill = $this->project->skills()->create(['name' => 'Original', 'slug' => 'original', 'body' => 'Content']); + + $response = $this->actingAs($this->user) + ->postJson("/api/skills/{$skill->id}/duplicate") + ->assertOk(); + + expect($response->json('data.name'))->toBe('Original (Copy)'); + expect($this->project->skills()->count())->toBe(2); +}); + +it('auto-generates unique slugs', function () { + $this->project->skills()->create(['name' => 'Test Skill', 'slug' => 'test-skill', 'body' => '']); + + $response = $this->actingAs($this->user) + ->postJson("/api/projects/{$this->project->id}/skills", [ + 'name' => 'Test Skill', + 'body' => 'Different body', + ]) + ->assertCreated(); + + expect($response->json('data.slug'))->toBe('test-skill-1'); +}); + +it('creates a version snapshot on save', function () { + $skill = $this->project->skills()->create(['name' => 'Versioned', 'slug' => 'versioned', 'body' => 'v1']); + + // The store method creates a version — let's trigger an update + $this->actingAs($this->user) + ->putJson("/api/skills/{$skill->id}", ['body' => 'v2']); + + $versions = $skill->versions()->get(); + expect($versions)->not->toBeEmpty(); +}); + +it('validates required fields on create', function () { + $this->actingAs($this->user) + ->postJson("/api/projects/{$this->project->id}/skills", []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['name']); +}); diff --git a/tests/Unit/PromptLinterTest.php b/tests/Unit/PromptLinterTest.php new file mode 100644 index 0000000..f38ccc1 --- /dev/null +++ b/tests/Unit/PromptLinterTest.php @@ -0,0 +1,80 @@ +linter = new PromptLinter; +}); + +it('flags empty body', function () { + $issues = $this->linter->lint(''); + + expect($issues)->toHaveCount(1); + expect($issues[0]['rule'])->toBe('empty_body'); +}); + +it('flags whitespace-only body', function () { + $issues = $this->linter->lint(' '); + + expect($issues)->toHaveCount(1); + expect($issues[0]['rule'])->toBe('empty_body'); +}); + +it('detects vague instructions', function () { + $issues = $this->linter->lint('Please do your best to summarize the document.'); + + $vagueIssues = array_filter($issues, fn ($i) => $i['rule'] === 'vague_instruction'); + expect($vagueIssues)->not->toBeEmpty(); +}); + +it('detects weak constraints', function () { + $issues = $this->linter->lint('You should always validate the input before processing.'); + + $weakIssues = array_filter($issues, fn ($i) => $i['rule'] === 'weak_constraint'); + expect($weakIssues)->not->toBeEmpty(); +}); + +it('detects missing output format for generation skills', function () { + $issues = $this->linter->lint('Generate a comprehensive summary of the given document.'); + + $formatIssues = array_filter($issues, fn ($i) => $i['rule'] === 'missing_output_format'); + expect($formatIssues)->not->toBeEmpty(); +}); + +it('does not flag output format when specified', function () { + $issues = $this->linter->lint('Generate a summary. Format your response as a bulleted list.'); + + $formatIssues = array_filter($issues, fn ($i) => $i['rule'] === 'missing_output_format'); + expect($formatIssues)->toBeEmpty(); +}); + +it('flags excessive length', function () { + $body = str_repeat('This is a test sentence that is reasonably long. ', 500); + $issues = $this->linter->lint($body); + + $lengthIssues = array_filter($issues, fn ($i) => $i['rule'] === 'excessive_length'); + expect($lengthIssues)->not->toBeEmpty(); +}); + +it('detects role confusion with multiple roles', function () { + $body = "You are a code reviewer.\nYou are a documentation writer.\nYou are a security auditor."; + $issues = $this->linter->lint($body); + + $roleIssues = array_filter($issues, fn ($i) => $i['rule'] === 'role_confusion'); + expect($roleIssues)->not->toBeEmpty(); +}); + +it('detects redundant lines', function () { + $body = "Always validate user input before processing it.\nAlways validate user input before processing it carefully."; + $issues = $this->linter->lint($body); + + $redundancyIssues = array_filter($issues, fn ($i) => $i['rule'] === 'redundancy'); + expect($redundancyIssues)->not->toBeEmpty(); +}); + +it('returns no issues for a well-written skill', function () { + $body = "You are a code reviewer.\n\nReview the provided code for:\n- Security vulnerabilities\n- Performance issues\n- Code style violations\n\nFormat your response as a numbered list of findings."; + $issues = $this->linter->lint($body); + + expect($issues)->toBeEmpty(); +}); diff --git a/tests/Unit/TemplateResolverTest.php b/tests/Unit/TemplateResolverTest.php new file mode 100644 index 0000000..f8d17bc --- /dev/null +++ b/tests/Unit/TemplateResolverTest.php @@ -0,0 +1,79 @@ +resolver = new TemplateResolver; +}); + +it('resolves simple variables', function () { + $result = $this->resolver->resolve('Hello {{name}}, welcome to {{place}}.', [ + 'name' => 'Alice', + 'place' => 'Wonderland', + ]); + + expect($result)->toBe('Hello Alice, welcome to Wonderland.'); +}); + +it('leaves unresolved variables untouched', function () { + $result = $this->resolver->resolve('Hello {{name}}, your role is {{role}}.', [ + 'name' => 'Bob', + ]); + + expect($result)->toBe('Hello Bob, your role is {{role}}.'); +}); + +it('handles empty variables array', function () { + $result = $this->resolver->resolve('Hello {{name}}.', []); + + expect($result)->toBe('Hello {{name}}.'); +}); + +it('handles body with no variables', function () { + $result = $this->resolver->resolve('No variables here.', ['foo' => 'bar']); + + expect($result)->toBe('No variables here.'); +}); + +it('extracts variable names from body', function () { + $variables = $this->resolver->extractVariables('Hello {{name}}, use {{language}} in {{tone}} tone.'); + + expect($variables)->toBe(['name', 'language', 'tone']); +}); + +it('extracts unique variable names', function () { + $variables = $this->resolver->extractVariables('{{name}} said hello to {{name}}.'); + + expect($variables)->toBe(['name']); +}); + +it('returns empty array for body with no variables', function () { + $variables = $this->resolver->extractVariables('No variables here.'); + + expect($variables)->toBe([]); +}); + +it('detects missing variables', function () { + $missing = $this->resolver->getMissing('Hello {{name}} in {{language}}', [ + 'name' => 'Alice', + ]); + + expect($missing)->toBe(['language']); +}); + +it('reports null values as missing', function () { + $missing = $this->resolver->getMissing('Use {{tone}}', [ + 'tone' => null, + ]); + + expect($missing)->toBe(['tone']); +}); + +it('returns empty when all variables are provided', function () { + $missing = $this->resolver->getMissing('{{a}} and {{b}}', [ + 'a' => 'x', + 'b' => 'y', + ]); + + expect($missing)->toBe([]); +}); diff --git a/ui/.env.example b/ui/.env.example new file mode 100644 index 0000000..5934e2e --- /dev/null +++ b/ui/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:8000 diff --git a/ui/package-lock.json b/ui/package-lock.json index e964b5b..792073d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,17 +1,20 @@ { "name": "ui", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@base-ui/react": "^1.2.0", "@fontsource-variable/geist": "^5.2.8", "@monaco-editor/react": "^4.7.0", "@tailwindcss/vite": "^4.2.1", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-shell": "^2", "@xyflow/react": "^12.10.1", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", @@ -29,6 +32,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@tauri-apps/cli": "^2", "@types/d3": "^7.4.3", "@types/node": "^24.12.0", "@types/react": "^19.2.7", @@ -2350,6 +2354,251 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tauri-apps/api": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", + "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", + "integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.1", + "@tauri-apps/cli-darwin-x64": "2.10.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", + "@tauri-apps/cli-linux-arm64-musl": "2.10.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-musl": "2.10.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", + "@tauri-apps/cli-win32-x64-msvc": "2.10.1" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", + "integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", + "integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", + "integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", + "integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", + "integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", + "integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", + "integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", + "integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", + "integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", + "integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", + "integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", + "integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", + "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", diff --git a/ui/package.json b/ui/package.json index ce34faf..5def744 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "ui", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 204318a..ac82e39 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,5 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { ErrorBoundary } from '@/components/ErrorBoundary' import { Layout } from '@/components/layout/Layout' import { CommandPalette } from '@/components/layout/CommandPalette' import { useCommandPalette } from '@/hooks/useCommandPalette' @@ -71,9 +72,11 @@ function AppContent() { function App() { return ( - - - + + + + + ) } diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 305e6f4..0e0f83a 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -58,7 +58,9 @@ api.interceptors.response.use( (error) => { const message = error.response?.data?.message || error.message || 'An error occurred' - console.error('[API Error]', message) + if (import.meta.env.DEV) { + console.error('[API Error]', message) + } // Show global toast for server errors (lazy import to avoid circular dep) if (error.response?.status >= 500) { @@ -86,7 +88,8 @@ export const updateProject = (id: number, data: Partial) => export const deleteProject = (id: number) => api.delete(`/projects/${id}`) -export const scanProject = (id: number) => api.post(`/projects/${id}/scan`) +export const scanProject = (id: number) => + api.post>(`/projects/${id}/scan`).then((r) => r.data.data) export const syncProject = (id: number) => api.post(`/projects/${id}/sync`) diff --git a/ui/src/components/ErrorBoundary.tsx b/ui/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..acaa239 --- /dev/null +++ b/ui/src/components/ErrorBoundary.tsx @@ -0,0 +1,49 @@ +import { Component, type ReactNode } from 'react' + +interface Props { + children: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + render() { + if (this.state.hasError) { + return ( +
+
+

Something went wrong

+

+ An unexpected error occurred. Please try reloading the page. +

+ {import.meta.env.DEV && this.state.error && ( +
+                {this.state.error.message}
+              
+ )} + +
+
+ ) + } + + return this.props.children + } +} diff --git a/ui/src/components/integrations/ImportTab.tsx b/ui/src/components/integrations/ImportTab.tsx index 32efc1f..bf15fe1 100644 --- a/ui/src/components/integrations/ImportTab.tsx +++ b/ui/src/components/integrations/ImportTab.tsx @@ -16,6 +16,7 @@ const PROVIDERS = [ { value: 'windsurf', label: 'Windsurf (.windsurf/rules/)' }, { value: 'cline', label: 'Cline (.clinerules)' }, { value: 'openai', label: 'OpenAI (.openai/instructions.md)' }, + { value: 'codex', label: 'Codex CLI (AGENTS.md / .codex/)' }, ] export default function ImportTab({ projectId, projectPath, onImported }: ImportTabProps) { diff --git a/ui/src/components/library/ImportLibraryModal.tsx b/ui/src/components/library/ImportLibraryModal.tsx index 8ac55ff..7688f46 100644 --- a/ui/src/components/library/ImportLibraryModal.tsx +++ b/ui/src/components/library/ImportLibraryModal.tsx @@ -11,18 +11,9 @@ import { import { fetchLibrary, importLibrarySkill } from '@/api/client' import { useAppStore } from '@/store/useAppStore' import { Button } from '@/components/ui/button' +import { SKILL_CATEGORIES, getCategoryOption } from '@/constants/categories' import type { LibrarySkill } from '@/types' -const CATEGORIES = [ - { label: 'All', value: '' }, - { label: 'Laravel', value: 'Laravel' }, - { label: 'PHP', value: 'PHP' }, - { label: 'TypeScript', value: 'TypeScript' }, - { label: 'FinTech', value: 'FinTech' }, - { label: 'DevOps', value: 'DevOps' }, - { label: 'Writing', value: 'Writing' }, -] - interface ImportLibraryModalProps { projectId: number onClose: () => void @@ -111,7 +102,7 @@ export function ImportLibraryModal({ />
- {CATEGORIES.map((cat) => ( + {SKILL_CATEGORIES.map((cat) => (
{skill.description && (

diff --git a/ui/src/components/skills/FrontmatterForm.tsx b/ui/src/components/skills/FrontmatterForm.tsx index 1e755f3..6797f5a 100644 --- a/ui/src/components/skills/FrontmatterForm.tsx +++ b/ui/src/components/skills/FrontmatterForm.tsx @@ -1,8 +1,9 @@ import { useState } from 'react' -import { Coins, Link2, Variable, Plus, Trash2, Filter } from 'lucide-react' +import { Coins, Link2, Variable, Plus, Trash2, Filter, AlertTriangle, Layers } from 'lucide-react' import { estimateTokens } from '@/api/client' import ConditionsEditor from '@/components/skills/ConditionsEditor' -import type { Skill, TemplateVariable, SkillConditions } from '@/types' +import { SKILL_CATEGORIES, SKILL_TYPES } from '@/constants/categories' +import type { Skill, TemplateVariable, SkillConditions, SkillCategory, SkillType } from '@/types' const MODEL_CONTEXT_LIMITS: Record = { 'claude-opus-4-6': 200000, @@ -94,6 +95,44 @@ export function FrontmatterForm({ skill, onChange, projectSkills }: FrontmatterF /> + {/* Category & Skill Type */} +

+
+ + +
+
+ + +
+
+