diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/.aix/skills/npm-search/SKILL.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/.aix/skills/npm-search/SKILL.md deleted file mode 100644 index 3bd7a16..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/.aix/skills/npm-search/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: npm-search -description: Search for npm packages to solve problems instead of writing custom code. Use when the user needs functionality that likely exists as a package, or when evaluating whether to build vs. use an existing solution. ---- - -# NPM Search - -Before writing custom code to solve a problem, check if an npm package already exists. This saves time, reduces bugs, and leverages community-tested solutions. - -## When to use this skill - -- The project has a package.json file and uses NPM/Node -- User asks for functionality that sounds like a common problem (date formatting, validation, HTTP requests, etc.) -- You're about to write > 100 lines of utility code -- A package that solves the problem is not already in package.json -- User explicitly asks to find a package - -## How to search - -Run `npm search` with relevant keywords: - -```bash -npm search --long -``` - -The `--long` flag shows additional details including description, author, date, version, and keywords. - -### Search tips - -- Use multiple keywords to narrow results: `npm search date format timezone --long` -- Try synonyms if initial search yields poor results -- Search for the problem, not the solution (e.g., "csv parse" not "string split comma") - -## Evaluating packages - -When presenting options to the user, consider: - -1. **Popularity** - Higher download counts generally indicate reliability and community support -2. **Maintenance** - Check the publish date; prefer packages updated within the last year -3. **Fit** - Does it solve the exact problem without excessive overhead? - -### Present to user - -When the user is looking for a package, include: -- Package name -- Description -- Version -- Weekly downloads (if available via `npm view --json`) -- Last publish date -- License - -### Acceptable trade-offs - -For niche problems, less popular or less recently maintained packages are acceptable if: -- They solve the specific problem well -- No better alternatives exist -- The code is simple enough to fork/maintain if needed - -## Example workflow - -1. User asks: "I need to validate email addresses" -2. Check if a package is already in package.json. If not: - 1. Search: `npm search email validate --long` - 2. Evaluate top results for popularity and maintenance - 3. Recommend 1-3 options with rationale - 4. If user approves, install with `npm install --save-exact ` (or --save-dev - if it's a dev dependency) diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/.gitignore b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/.gitignore deleted file mode 100644 index 8830d68..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/.gitignore +++ /dev/null @@ -1,144 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.* -!.env.example - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Sveltekit cache directory -.svelte-kit/ - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# Firebase cache directory -.firebase/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v3 -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -# Vite logs files -vite.config.js.timestamp-* -vite.config.ts.timestamp-* - -# AIX -.aix/.tmp - -.windsurf diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/LICENSE b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/LICENSE deleted file mode 100644 index 996250e..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Matt Luedke - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/README.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/README.md deleted file mode 100644 index 40ed775..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# aix-config - -[AIX](https://github.com/a1st-dev/aix) config, skills, and prompts. - -## Set up a New Repo - -```bash -npx @a1st/aix install https://github.com/yokuze/aix-config/blob/main/ai.json -``` diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/ai.json b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/ai.json deleted file mode 100644 index b17f574..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/ai.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://x.a1st.dev/schemas/v1/ai.json", - "skills": { - "npm-search": { - "path": "./skills/npm-search/" - } - }, - "mcp": {}, - "rules": { - "general": "./rules/general.md", - "css-scss": "./rules/css-scss.md", - "do-not": "./rules/do-not.md", - "git": "./rules/git.md", - "mcps": "./rules/mcps.md", - "npm": "./rules/npm.md", - "testing": "./rules/testing.md", - "typescript-javascript": "./rules/typescript-javascript.md", - "vue": "./rules/vue.md", - "writing": "./rules/writing.md" - }, - "prompts": { - "add-skill": "./prompts/add-skill.md", - "plan": "./prompts/plan.md", - "implement-plan": "./prompts/implement-plan.md" - } -} diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/prompts/add-skill.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/prompts/add-skill.md deleted file mode 100644 index d0ffafc..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/prompts/add-skill.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -description: Create a skill and add it to this repo ---- - -Read the latest Skill spec here: https://agentskills.io/specification.md - -Work with me to add a skill to this repo. Ask me questions to learn about the skill. - -Links to more documentation here: https://agentskills.io/llms.txt diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/prompts/implement-plan.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/prompts/implement-plan.md deleted file mode 100644 index 7dbdcac..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/prompts/implement-plan.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -description: Implement a planned change while preventing code duplication -auto_execution_mode: 1 ---- - -# Preventing Code Duplication - -This workflow is triggered when adding new files or functions that may share logic with -existing code. The goal is to prevent the accumulation of duplicated code that occurs -during iterative, session-by-session development. - -## Why Duplication Happens - -When AI agents work iteratively across sessions: - -1. Each session solves problems locally without full codebase visibility -2. Similar files get similar solutions copy-pasted or re-invented -3. "Working code" feels complete, so extraction is deferred -4. No single session owns the tech debt; it accumulates silently - -## Mandatory Pre-Work Checklist - -**Before writing any new function or file**, complete these searches: - -### 1. Sibling File Search - -If adding a file to a directory with similar files (e.g., `commands/add/new.ts`): - -``` -# Read ALL sibling files first -find -name "*.ts" -type f -``` - -Look for: shared patterns, helper functions, imports from shared utilities. - -### 2. Utility Search - -Search for existing utilities that might already do what you need. - -### 3. Package Boundary Check - -If this is a monorepo, ask: "does this logic belong in a shared package (e.g., `core`, -`utils`, `shared`)?" - - * If 2+ consumers exist or are likely → extract to shared package - * If domain-specific → keep local but check for existing abstractions - -### 4. Existing Library Check - -Check if the functionality already exists in an existing library or package. Is there a -package/dependency that is already installed in the project that provides this -functionality? If not, is this change significant enough that I should search for one and -recommend it? - -Examples: Parsing, validation, formatting. - -## Extraction Triggers - -Extract to a shared utility **immediately** (not later) when: - -1. **You copy-paste** any block of code > 3 lines -2. **You write a pure function** that doesn't depend on local state -3. **You see the same pattern** in a sibling file you just read -4. **The function name is generic** (e.g., `parseURL`, `formatDate`, `validateInput`) - -## Post-Work Verification - -After completing changes, look for any duplication that you may have introduced. - -Example: - -```sh -grep -r "" --include="*.ts" . -``` - -If you find duplication: - -1. Extract to shared utility NOW, not in a follow-up task -2. Update all call sites -3. Delete the duplicated code - -## Red Flags to Watch For - -Stop and reconsider if you find yourself: - - * Writing a regex that looks like it could be reusable - * Implementing URL/path parsing logic - * Writing validation or normalization functions - * Creating type definitions that mirror existing ones - * Adding the same import to multiple new files - -## Example: The Right Way - -**Wrong** (what usually happens): - -``` -Session 1: Create commands/add/skill.ts with parseGitHubURL() -Session 2: Create commands/add/rule.ts with parseGitHubURL() // copy-pasted -Session 3: Create commands/add/prompt.ts with parseGitHubURL() // copy-pasted again -``` - -**Right** (what should happen): - -``` -Session 1: Create commands/add/skill.ts, notice URL parsing is generic - → Extract to packages/core/src/url-parsing.ts FIRST - → Import and use in skill.ts -Session 2: Create commands/add/rule.ts - → Search finds url-parsing.ts already exists - → Import and use -Session 3: Same pattern continues -``` - -**Extract before you duplicate, not after**. diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/prompts/plan.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/prompts/plan.md deleted file mode 100644 index 6f20c06..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/prompts/plan.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -description: You are generating a **software implementation plan** that will be used by an LLM to generate code. -auto_execution_mode: 1 ---- - -# Rules for Software Planning Output - -You are generating a **software implementation plan** that will be used by an LLM to -generate code. Follow this **output contract exactly** and use the required Markdown -structures. Be explicit, exhaustive, and avoid ambiguity. - -## Output Contract (Section Order is Mandatory) - -1. Title -2. Summary -3. Objectives & Scope -4. Assumptions & Open Questions -5. Requirements - - Functional Requirements - - Non-Functional Requirements (performance, security, privacy, accessibility, - observability, i18n) -6. Architecture & Design Overview (if applicable) -7. Task Grid -8. Task Details (one section per task, in Task ID order) -9. New Code -10. Tests -11. Review Checklist (filled by the model before finishing) - ---- - -## Global Constraints & Style - -- Write concrete **step-by-step instructions** for every task -- Use **imperative voice** ("Run… Create… Configure…") -- All commands must be copy-pasteable. Include filenames and paths in code fences. -- Don't invent external facts. If uncertain, move to **Assumptions & Open Questions** and - propose how to resolve. -- Default to safe, current, stable practices; call out version assumptions -- **Never skip required sections**. If a section is N/A, explicitly state why - ---- - -## 1) Title -`[Project/Feature]: Implementation Plan (v[semver] – [date])` - -## 2) Summary -One short paragraph summarizing the goal and outcome. - -## 3) Objectives & Scope -- In scope: … -- Out of scope: … - -## 4) Assumptions & Open Questions -- Assumptions: … -- Open Questions: … - -## 5) Requirements -### Functional -- FR-1: … -### Non-Functional -- NFR-1 (Performance): … -- NFR-2 (Security): … -- NFR-3 (Accessibility): … -- NFR-4 (Observability): … - -## 6) Architecture & Design Overview -- High-level diagram description or pseudo-diagram -- Data flow, key interfaces, schemas, external services -- Decisions & trade-offs (link or inline ADR summary) - -## 7) Task Grid - -> Use `[ ]` for not done and `[✓]` for done. The plan should default to `[ ]` unless -> explicitly marked completed. - -| Status | ID | Task | Priority | Depends On | Acceptance Criteria | -|---|---|---|---|---|---| -| [ ] | T-01 | Initialize repo & tooling | M | — | Repo, lint, format, CI bootstrap | -| [ ] | T-02 | Design API schema | H | T-01 | OpenAPI defined, reviewed | -| [ ] | T-03 | Implement service endpoints | H | T-02 | All endpoints pass tests | -| [ ] | T-04 | Write unit & integration tests | H | T-03 | ≥90% lines, critical paths covered | -| [ ] | T-05 | Observability & alerts | M | T-03 | Metrics, logs, SLOs defined | -| [ ] | T-06 | Security review & threat model | Erin | M | T-02 | STRIDE walkthrough, issues filed | - -> Optional additional states you may use: `[~]` In Progress, `[?]` Blocked (state reason -> in Task Details). - -## 8) Task Details - -### T-01 — Initialize repo & tooling -**Goal:** Create reproducible dev environment with lint/format/test on CI. - -**Step-by-step instructions:** -1. Create repo `[name]` and default branch `master` -2. Add license `[MIT]` -3. Add `.editorconfig`, `.gitattributes`, and language-specific `.gitignore` -4. Configure package manager `[npm/Cargo]` with lockfile -5. Add linters and formatters (e.g., `eslint`, `cargo fmt`, `cargo clippy`) -6. Create NPM scripts -7. Bootstrap CI (e.g., GitHub Actions) with jobs: `lint`, `test`, `build` on PRs -8. Add minimal README with quickstart -9. Verify by running: - ```sh - npm ci && npm run standards && npm run test - ``` - -## 9) New Code - -Concisely explain what new files, objects, and functions need to exist. Document their -purpose. For files, include the relative path to the file being added or modified. - -Use the context-7 MCP or search online for the most up-to-date documentation on the exact -version of relevant libraries, frameworks, etc. used here. - -## 10) Tests - -Concisely describe tests needed to verify that the new code is correct. - -## 11) Review Checklist - -[ ] Have all outstanding questions been answered? -[ ] Are there any ambiguities that need to resolved? - -## 12) Misc - -When I ask: "What questions do you have?", respond with a flat numbered list of questions -for me to respond to, if you have any. diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/css-scss.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/css-scss.md deleted file mode 100644 index 4ee44ac..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/css-scss.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -trigger: glob -globs: "**/*.css,**/*.scss" ---- - - * If using a component library, prefer using the component's existing props etc. over - custom styling - * If none is available, prefer pre-existing utility classes over custom styling - * Avoid ad-hoc CSS unless necessary - * Use BEM or other similar naming conventions for custom CSS - * Use CSS variables for theme-able values - * Consider adding a custom utility class to the global CSS/SCSS if it seems - necessary/used in multiple places diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/do-not.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/do-not.md deleted file mode 100644 index 1fa3304..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/do-not.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -trigger: always_on ---- - -You ABSOLUTELY must not: - - * DO NOT `git push` without express permission - * DO NOT create ad-hoc test scripts. If you absolutely must, clean up those files when - you're done - * DO NOT ignore "pre-existing" TypeScript or linting errors. If you see them, fix them - before proceeding - * DO NOT ignore "pre-existing" tests that fail. If you see them, fix them before - proceeding - * DO NOT ignore "pre-existing" documentation that is out of date. If you see it, fix - it before proceeding - * DO NOT use `@deprecated` on anything unless you are explicitly asked to. Always fully refactor - and delete old code as-needed instead of deprecating it - * DO NOT implement functionality that already exists in a library or package, - especially if that package is already installed in the project - * Examples: parsing, validation, formatting - * DO NOT disable linting rules (ESLint, oxlint, clippy, etc.) in the config to get - around linting errors. Fix the underlying issues diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/general.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/general.md deleted file mode 100644 index fa6101b..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/general.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -trigger: always_on ---- - - * Be critical and thorough. Prefer truth and direct feedback over politeness - * After every response to me, end it with an emoji - * Look around and use existing patterns and code when possible. Look for: - * Similar components and use their patterns - * Library code you can reuse - * Existing dependencies from package.json or Cargo.toml that you should use - * If you see a pattern that is not used, consider adding it, but carefully and - judiciously - * Always consider the developer experience: - * Am I placing a burden on the developer with this change? - * Is it as easy to use / execute / import / configure as possible? - * When making _any_ changes: - * Consider the impact on other parts of the codebase - * What tests, documentation, etc. needs to be updated? - * Search for other files that should be changed after what you just did - * How has the context changed now that I've made this change? - * Should I refactor the code to introduce an abstraction to make it more - maintainable? - * Should I delete anything that's now unused? - * Check your work after you finish a task: - * Did I address everything I was asked to? - * Run `npm run standards` (or `tsc` / `eslint` / `commitlint` / `markdownlint` as - appropriate) - * Test significant changes by: - * Running the tests - * Running the app and manually testing the changes (Tauri MCP or Playwright MCP) - -## Front-End Development - - * Pay attention to the current version of the component, and use a similar pattern as - set by existing elements - * Consider accessibility / a11y - * Create reusable components rather than ad-hoc solutions diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/git.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/git.md deleted file mode 100644 index 23f7ee1..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/git.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -trigger: always_on ---- - - * Follow these rules when running git commands and making commits: - * https://raw.githubusercontent.com/silvermine/silvermine-info/refs/heads/master/commit-history.md - * https://raw.githubusercontent.com/silvermine/standardization/refs/heads/master/commitlint.js diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/mcps.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/mcps.md deleted file mode 100644 index 4681607..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/mcps.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -trigger: always_on ---- - - * Always use context7 when I need code generation, setup or configuration steps, or - library/API documentation. This means you should automatically use the Context7 MCP - tools to resolve library ID and get library docs without me having to explicitly ask. - * If you make UI changes, use MCP tools to test them in a real environment unless - project-specific rules say not to - * Use the Tauri MCP when working within a Tauri app - * Use Playwright for other projects diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/npm.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/npm.md deleted file mode 100644 index 94c193f..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/npm.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -trigger: always_on ---- - - * Always use `npm` instead of `pnpm` or `yarn` - * Always use the --save-exact flag when installing a dependency - * Use the `-y` flag with `npx` when running a command diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/rust.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/rust.md deleted file mode 100644 index 6519f6f..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/rust.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -trigger: glob -globs: "**/*.rs,**/Cargo.toml" ---- - -Follow these standards: https://raw.githubusercontent.com/silvermine/silvermine-info/refs/heads/master/coding-standards/rust.md diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/testing.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/testing.md deleted file mode 100644 index 1f0f03d..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/testing.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -trigger: always_on ---- - - * When writing tests, prefer practical e2e tests over unit tests, but add unit tests - for critical functionality or complex logic - * If you write tests, always run them diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/typescript-javascript.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/typescript-javascript.md deleted file mode 100644 index 1423aba..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/typescript-javascript.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -trigger: glob -globs: "**/*.ts" ---- - -Follow these standards: - - * https://github.com/silvermine/silvermine-info/raw/refs/heads/master/coding-standards/typescript.md - * https://raw.githubusercontent.com/silvermine/silvermine-info/refs/heads/master/coding-standards.md - -## General Code Style & Formatting - - * Use English for all code and documentation - * Use JSDoc to document public classes and methods - * ALWAYS wrap comments to utilize the full line length - * Combine adjacent single-line const/let/var declarations into one declaration - * Use early returns to reduce indentation - -## Naming Conventions - - * Use PascalCase for classes - * Use camelCase for variables, functions, and methods - * Use kebab-case for file and directory names - * Use UPPERCASE for environment variables - * Avoid magic numbers and define constants - * When it has an acronym or initialism, use all lowercase or all caps, never mixed-case: - * `url` or `URL`, _never_ `Url` - * `id` or `ID`, _never_ `Id`. Prefer `ID` over `id` when writing docs/sentences - unless documenting a third-party entity or when specifically referring to a code - object with that exact casing (parameter, variable, etc.) - -## Functions & Logic - - * Avoid deeply nested blocks by: - * Using early returns - * Extracting logic into utility functions - * Use higher-order functions (map, filter, reduce) to simplify logic - * Use arrow functions for simple cases (<3 instructions), named functions otherwise - * Use default parameter values instead of null/undefined checks - * Use RO-RO (Receive Object, Return Object) for passing and returning multiple - parameters - -## Data Handling - - * Avoid excessive use of primitive types; encapsulate data in composite types - * Prefer immutability for data: - * Use readonly for immutable properties - * Use `as const` for literals that never change - -## Style - -BAD: DO NOT DO THIS -```typescript -const a = 1; - -const b = 2; - -const c = 3; -``` - -GOOD: -```typescript -const a = 1, - b = 2, - c = 3; -``` - -## ABSOLUTELY DO NOT: - - * DO NOT Use `any` in TS code diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/vue.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/vue.md deleted file mode 100644 index eb1dbf1..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/vue.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -trigger: glob -globs: "**/*.vue" ---- - - * Assume Vue version 3.5+ unless you see otherwise in package.json - * Use Vue 3 composition API, SFCs - * Prefer composition using slots over props when it makes sense - * Be aware of the different kinds of components: Page-level, layout, UI components that - contain no business logic, and business-logic-level components - * For styling: - * Use SCSS - * Use scoped styles - * Look at ./css-scss.md diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/writing.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/writing.md deleted file mode 100644 index 2ea2a3a..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/rules/writing.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -description: Write clear, specific prose that sounds human. Avoid patterns common to -AI-generated text. -trigger: always_on ---- - -## Content - - * **Be specific, not generic.** Replace vague claims of importance with concrete facts. - "Inventor of the first train-coupling device" instead of "a revolutionary titan of - industry." - * **Skip the significance speeches.** Do not add sentences about how something "marks a - pivotal moment," "represents a broader trend," "highlights the enduring legacy," or - "underscores the importance." Let facts speak for themselves. - * **Avoid superficial analysis.** Do not attach "-ing" phrases that editorialize: - "creating a lively community," "showcasing the brand's dedication," "demonstrating - ongoing relevance." - * **No promotional language.** Cut puffery like "nestled within," "vibrant," "rich - tapestry," "seamlessly connecting," "gateway to," "breathtaking." Write neutrally. - * **Attribute specifically or not at all.** Do not use weasel phrases like "has been - described as," "is considered," "researchers note," "scholars argue" without naming - who. If you cannot name a source, remove the claim. - * **Do not exaggerate consensus.** Do not present one or two sources as "several - publications" or "multiple scholars." Do not imply lists are non-exhaustive when - sources give no indication other examples exist. - * **Skip "Challenges and Future Prospects" formulas.** Do not write "Despite its - [positive words], [subject] faces challenges..." followed by vague optimism about - future initiatives. - * **No hedging preambles.** Do not acknowledge something is "relatively minor" before - explaining its importance anyway. - -## Word Choice - -Avoid overused AI vocabulary: - - * **High-frequency offenders:** delve, tapestry, landscape, multifaceted, intricate, - nuanced, pivotal, comprehensive, innovative, cutting-edge, groundbreaking, - transformative, paradigm, foster, leverage, spearhead, underscore, highlight, - crucial, vital, robust, seamless, holistic, synergy, realm, beacon, testament, - embark, unveil, unravel, commendable, meticulous, intrinsic, Moreover, - Furthermore, Notably, Importantly, Indeed, Thus, Hence, Therefore, - Consequently - * One or two may be coincidental; clusters are evidence of overuse - -## Grammar & Structure - - * **Use simple verbs.** Prefer "is" and "are" over "serves as," "stands as," "marks," - "represents," "constitutes," "features," "offers." Write "She is chairman" not - "She serves as chairman." - * **Avoid negative parallelisms.** Do not write "not only ... but also," "it's not just - about ... it's ...," "not ... but rather." These constructions try to appear balanced - but often add nothing. - * **Break the rule of three.** Do not default to "adjective, adjective, and adjective" - or "phrase, phrase, and phrase" patterns. Vary list lengths. - * **Avoid elegant variation.** If you name something, use that name again. Do not cycle - through synonyms like "the protagonist," "the key player," "the eponymous character" - to avoid repetition. - * **No false ranges.** "From X to Y" requires a real scale. "From the Big Bang to the - cosmic web" or "from problem-solving to artistic expression" are meaningless ranges - that sound impressive but say nothing. - -## Formatting & Style - - * **Use sentence case for headings.** Do not capitalize every word in section titles. - * **Avoid excessive boldface.** Do not bold every key term or create "key takeaways" - lists with bolded headers. - * **No emojis** unless explicitly requested - -## Communication - - * **No meta-commentary.** Do not include phrases like "In this section, we will - discuss..." or "Below is a detailed overview based on available information." - * **No disclaimers about knowledge gaps.** Do not write "While specific details are not - extensively documented..." or "As of my last knowledge update..." - * **No placeholder text.** Do not leave brackets like [describe the specific section] - or dates like 2025-XX-XX. - * **No collaborative language in output.** Do not write "Would you like me to..." or - "Here's a template you can customize" in content meant for publication. - -Source: https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing diff --git a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/skills/npm-search/SKILL.md b/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/skills/npm-search/SKILL.md deleted file mode 100644 index 6c9e027..0000000 --- a/.aix/extends/yokuze-aix-config-HEAD-Z2l0aHVi/skills/npm-search/SKILL.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -name: npm-search -description: Search for npm packages to solve problems instead of writing custom code. Use when the user needs functionality that likely exists as a package, or when evaluating whether to build vs. use an existing solution. ---- - -# NPM Search - -Before writing custom code to solve a problem, check if an npm package already exists. -This saves time, reduces bugs, and leverages community-tested solutions. - -## When to use this skill - - * The project has a package.json file and uses NPM/Node - * User asks for functionality that sounds like a common problem (date formatting, - validation, HTTP requests, etc.) - * You're about to write > 100 lines of utility code - * A package that solves the problem is not already in package.json - * User explicitly asks to find a package - -## How to search - -Run `npm search` with relevant keywords: - -```bash -npm search --long -``` - -The `--long` flag shows additional details including description, author, date, version, -and keywords. - -### Search tips - -- Use multiple keywords to narrow results: `npm search date format timezone --long` -- Try synonyms if initial search yields poor results -- Search for the problem, not the solution (e.g., "csv parse" not "string split comma") - -## Evaluating packages - -When presenting options to the user, consider: - -1. **Popularity** - Higher download counts generally indicate reliability and community - support -2. **Maintenance** - Check the publish date; prefer packages updated within the last year -3. **Fit** - Does it solve the exact problem without excessive overhead? - -### Present to user - -When the user is looking for a package, include: - - * Package name - * Description - * Version - * Weekly downloads (if available via `npm view --json`) - * Last publish date - * License - -### Acceptable trade-offs - -For niche problems, less popular or less recently maintained packages are acceptable if: - - * They solve the specific problem well - * No better alternatives exist - * The code is simple enough to fork/maintain if needed - -## Example workflow - -1. User asks: "I need to validate email addresses" -2. Check if a package is already in package.json. If not: - 1. Search: `npm search email validate --long` - 2. Evaluate top results for popularity and maintenance - 3. Recommend 1-3 options with rationale - 4. If user approves, install with `npm install --save-exact ` (or --save-dev - if it's a dev dependency) diff --git a/.aix/skills/npm-search/SKILL.md b/.aix/skills/npm-search/SKILL.md deleted file mode 100644 index 6c9e027..0000000 --- a/.aix/skills/npm-search/SKILL.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -name: npm-search -description: Search for npm packages to solve problems instead of writing custom code. Use when the user needs functionality that likely exists as a package, or when evaluating whether to build vs. use an existing solution. ---- - -# NPM Search - -Before writing custom code to solve a problem, check if an npm package already exists. -This saves time, reduces bugs, and leverages community-tested solutions. - -## When to use this skill - - * The project has a package.json file and uses NPM/Node - * User asks for functionality that sounds like a common problem (date formatting, - validation, HTTP requests, etc.) - * You're about to write > 100 lines of utility code - * A package that solves the problem is not already in package.json - * User explicitly asks to find a package - -## How to search - -Run `npm search` with relevant keywords: - -```bash -npm search --long -``` - -The `--long` flag shows additional details including description, author, date, version, -and keywords. - -### Search tips - -- Use multiple keywords to narrow results: `npm search date format timezone --long` -- Try synonyms if initial search yields poor results -- Search for the problem, not the solution (e.g., "csv parse" not "string split comma") - -## Evaluating packages - -When presenting options to the user, consider: - -1. **Popularity** - Higher download counts generally indicate reliability and community - support -2. **Maintenance** - Check the publish date; prefer packages updated within the last year -3. **Fit** - Does it solve the exact problem without excessive overhead? - -### Present to user - -When the user is looking for a package, include: - - * Package name - * Description - * Version - * Weekly downloads (if available via `npm view --json`) - * Last publish date - * License - -### Acceptable trade-offs - -For niche problems, less popular or less recently maintained packages are acceptable if: - - * They solve the specific problem well - * No better alternatives exist - * The code is simple enough to fork/maintain if needed - -## Example workflow - -1. User asks: "I need to validate email addresses" -2. Check if a package is already in package.json. If not: - 1. Search: `npm search email validate --long` - 2. Evaluate top results for popularity and maintenance - 3. Recommend 1-3 options with rationale - 4. If user approves, install with `npm install --save-exact ` (or --save-dev - if it's a dev dependency) diff --git a/.gitignore b/.gitignore index cfc1b1b..5803aa7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,10 @@ oclif.manifest.json .astro/ # AIX and other editor config flies -.aix/ +.aix ai.local.json .codex/ +.continue .windsurf .cursor diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..8ba8482 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,53 @@ +# GEMINI.md - aix Project Context + +## Project Overview +`aix` is a unified configuration manager for AI agent editors (e.g., Claude Code, Cursor, Copilot, Zed). It provides a single source of truth via an `ai.json` file to manage: +- **MCP Servers:** Model Context Protocol servers for extending agent capabilities. +- **Skills:** Reusable agent behaviors and tools. +- **Rules:** System instructions and coding standards. +- **Prompts:** Workflow definitions and slash commands. + +The project is structured as a TypeScript monorepo using npm workspaces. + +## Architecture +- **`packages/cli`**: The `oclif`-based CLI tool (`@a1st/aix`). Handles user commands, interactive prompts (using Ink/React), and editor installations. +- **`packages/core`**: Core logic for configuration discovery, loading, inheritance (`extends`), merging, and filesystem operations. +- **`packages/schema`**: Centralized Zod schemas defining the structure of `ai.json` and internal types. +- **`packages/mcp-registry-client`**: Client for fetching metadata from the official MCP registry. +- **`packages/site`**: (TBD) Documentation or landing site. + +## Tech Stack +- **Language:** TypeScript (ESM) +- **Frameworks:** `oclif` (CLI), `Ink` (CLI UI), `React` +- **Validation:** `Zod` +- **Testing:** `Vitest` +- **Linting/Formatting:** `oxlint`, `oxfmt` +- **Build Tool:** `tsc` (TypeScript Compiler) + +## Key Commands +### Root Development +- `npm run build`: Build all packages in the monorepo. +- `npm run test`: Run all tests across workspaces. +- `npm run standards`: Run lint, format check, and typecheck. +- `npm run lint`: Run `oxlint` for fast linting. +- `npm run format`: Format code using `oxfmt`. +- `npm run typecheck`: Run `tsc --noEmit` to verify types. + +### CLI Usage (Internal) +- `npm run dev -w @a1st/aix`: Run the CLI in development mode using `ts-node`. + +## Development Conventions +- **ESM First:** All packages use `"type": "module"`. +- **Schema-Driven:** All configuration changes must adhere to the schemas in `packages/schema`. +- **Testing:** New features or bug fixes should include tests in the relevant `__tests__` directory. +- **Fast Linting:** Use `oxlint` for linting. Configuration is in `.oxlintrc.json`. +- **Formatting:** Use `oxfmt`. Configuration is in `.oxfmtrc.json`. +- **Atomic Writes:** Core logic should prioritize atomic file writes with backups (handled by `packages/core`). +- **Releases:** Releases are triggered by pushing a signed git tag (e.g., `git tag -s vX.Y.Z -m "vX.Y.Z"`). Use `npm run version:bump` to update versions across the monorepo before tagging. + +## Key Files +- `ai.json`: The primary configuration file for a workspace. +- `package.json`: Monorepo root configuration and shared scripts. +- `packages/schema/src/config.ts`: Defines the main `AiJsonConfig` schema. +- `packages/cli/src/commands/`: Implementation of CLI commands (init, add, install, etc.). +- `packages/core/src/loader.ts`: Logic for loading and merging `ai.json` files. diff --git a/README.md b/README.md index 5d9c24f..ce7939d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ aix add skill https://github.com/obra/superpowers/blob/main/skills/systematic-de # Add a rule from GitHub, local path, or npm package aix add rule ../rules/typescript-rules.md # Add a prompt (also known as a workflow or slash command) -aix add prompt ../prompts/typescript-prompts.md +aix add prompt ../prompts/review.md # Install all of the above to any supported editor. Outputs workspace-specific config aix install --target claude-code --target cursor ``` @@ -41,7 +41,7 @@ Then, use that config with any supported agent/editor: claude-code, cursor, copi Standardize your AI config. Share it with your team. Check it into version control. - **Discover new MCP servers and skills** - Use `aix search` to find and add new MCP servers and skills -- **Stop duplicating config** — Define skills, MCP servers, and rules once instead of per-editor +- **Stop duplicating config** — Define skills, MCP servers, rules, and prompts once instead of per-editor - **Share team standards** — Extend configs from GitHub, GitLab, npm, or local files - **Install configs instantly** — `aix install github:company/ai-config` pulls and merges remote configs - **Safe updates** — Atomic writes with automatic backup and rollback @@ -62,15 +62,32 @@ aix init --from ```bash aix init # Create ai.json -aix search # Search for MCP servers and skills +aix search playwright # Search for MCP servers and skills aix install github:org/config # Install remote config aix install --save --scope mcp # Merge specific sections -aix add skill https://github.com/obra/superpowers/tree/main/skills/systematic-debugging # Add a skill +aix add skill ./skills/custom # Add a skill from GitHub, npm, or local path aix add mcp playwright # Add MCP server from registry aix add mcp github --command "npx @modelcontextprotocol/server-github" # Manual config -aix list skills # List configured skills +aix add rule ./rules/typescript.md # Add a rule from file or URL +aix add prompt ./prompts/review.md # Add a prompt/command from file or URL +aix remove skill typescript # Remove a skill and its files +aix remove mcp playwright # Remove an MCP server +aix list skills # List configured skills (or mcp, rules, prompts, editors) ``` +### Utility Commands + +```bash +aix validate # Validate ai.json configuration +aix config show # Show current CLI configuration +aix backups # List configuration backups +aix cache clear # Clear the local cache +``` + +## Other Notes + +This project is tested with BrowserStack. + ## License MIT diff --git a/ai.json b/ai.json index 23653ab..5d964e0 100644 --- a/ai.json +++ b/ai.json @@ -1,7 +1,11 @@ { "$schema": "https://x.a1st.dev/schemas/v1/ai.json", "extends": "github:yokuze/aix-config", - "skills": {}, + "skills": { + "mastering-typescript": { + "git": "https://github.com/spillwavesolutions/mastering-typescript-skill" + } + }, "mcp": {}, "rules": {}, "prompts": { diff --git a/package-lock.json b/package-lock.json index 2e83fac..9c48955 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2099,7 +2099,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2122,7 +2121,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2208,7 +2206,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2225,7 +2222,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2242,7 +2238,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2259,7 +2254,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2276,7 +2270,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2293,7 +2286,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2310,7 +2302,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2327,7 +2318,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2344,7 +2334,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2361,7 +2350,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2378,7 +2366,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2395,7 +2382,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2412,7 +2398,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2429,7 +2414,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2446,7 +2430,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2463,7 +2446,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2480,7 +2462,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2497,7 +2478,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2514,7 +2494,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2531,7 +2510,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2548,7 +2526,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2565,7 +2542,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2582,7 +2558,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2599,7 +2574,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2616,7 +2590,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2633,7 +2606,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4252,7 +4224,6 @@ "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.8.0.tgz", "integrity": "sha512-jteNUQKgJHLHFbbz806aGZqf+RJJ7t4gwF4MYa8fCwCxQ8/klJNWc0MvaJiBebk7Mc+J39mdlsB4XraaCKznFw==", "license": "MIT", - "peer": true, "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", @@ -4725,7 +4696,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4838,7 +4808,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6770,7 +6739,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" @@ -7185,7 +7153,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -7597,7 +7564,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7865,7 +7831,6 @@ "resolved": "https://registry.npmjs.org/astro/-/astro-5.17.1.tgz", "integrity": "sha512-oD3tlxTaVWGq/Wfbqk6gxzVRz98xa/rYlpe+gU2jXJMSD01k6sEDL01ZlT8mVSYB/rMgnvIOfiQQ3BbLdN237A==", "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.5", @@ -8603,7 +8568,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -10233,7 +10197,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11926,6 +11889,7 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.6.0.tgz", "integrity": "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", @@ -11989,6 +11953,7 @@ "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.3.tgz", "integrity": "sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==", "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" @@ -12002,6 +11967,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12014,6 +11980,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", + "peer": true, "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -12026,6 +11993,7 @@ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", "license": "MIT", + "peer": true, "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" @@ -12042,6 +12010,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "license": "MIT", + "peer": true, "dependencies": { "get-east-asian-width": "^1.3.1" }, @@ -12057,6 +12026,7 @@ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" @@ -12073,6 +12043,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", "license": "MIT", + "peer": true, "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" @@ -12442,7 +12413,6 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -15296,7 +15266,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15359,7 +15328,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -15521,6 +15489,7 @@ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -15535,7 +15504,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/readable-stream": { "version": "3.6.2", @@ -16322,6 +16292,19 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "license": "MIT" }, + "node_modules/skills": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/skills/-/skills-1.4.4.tgz", + "integrity": "sha512-Ihu9Nfp2Ff95254Wq73grr6hcwCQG1G0K7Ty+0/N7xiT6LG01+XY1FCdTBiGPVoIsCpG4WudJj6/LvHUZWxmCQ==", + "license": "MIT", + "bin": { + "add-skill": "bin/cli.mjs", + "skills": "bin/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/slice-ansi": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", @@ -17113,7 +17096,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17606,7 +17588,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -18877,7 +18858,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -19093,7 +19073,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -19578,7 +19557,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -19637,7 +19615,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -19667,7 +19644,6 @@ "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -19678,7 +19654,6 @@ "integrity": "sha512-TtAxCNrlrBp8GoeEp1npd5g+d/OejJHFxS3OWmrPBMFaVQMSN0OFySozJio5BHxTuTeug00AVXVAjfDSfk+lUg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -20119,7 +20094,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -20188,7 +20162,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -20318,11 +20291,13 @@ "confbox": "0.2.2", "defu": "6.1.4", "detect-indent": "7.0.2", + "execa": "^8.0.1", "giget": "1.2.3", "is-in-ci": "2.0.0", "nypm": "0.3.12", "p-map": "7.0.4", "pathe": "1.1.2", + "skills": "^1.4.4", "yaml": "2.8.2" } }, diff --git a/packages/cli/README.md b/packages/cli/README.md index 3b27cb0..32b5687 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -15,11 +15,11 @@ npm install -g @a1st/aix ```bash aix init # Create ai.json -aix search # Search for MCP servers and skills +aix search # Search for MCP servers and skills aix install github:org/config # Install remote config aix add skill # Add a skill aix add mcp # Add MCP server from registry -aix list skills # List configured skills +aix list [scope] # List skills, mcp, rules, prompts, or editors ``` ## Documentation diff --git a/packages/cli/src/__tests__/commands.test.ts b/packages/cli/src/__tests__/commands.test.ts index 1d6e9a5..e5dc35b 100644 --- a/packages/cli/src/__tests__/commands.test.ts +++ b/packages/cli/src/__tests__/commands.test.ts @@ -1,6 +1,6 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { mkdir, writeFile, rm, readFile } from 'node:fs/promises'; +import { mkdir, writeFile, readFile } from 'node:fs/promises'; import { safeRm } from '@a1st/aix-core'; import { tmpdir } from 'node:os'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; @@ -23,11 +23,11 @@ function createValidConfig(overrides: Record = {}): string { rules: {}, prompts: {}, ...overrides, - }); + }, null, 2); } /** - * Helper to write a valid config file + * Write a valid config to a path */ async function writeValidConfig(path: string, overrides: Record = {}): Promise { await writeFile(path, createValidConfig(overrides)); @@ -48,88 +48,33 @@ describe('CLI Commands', () => { originalCwd = process.cwd(); testDir = createTestDir(); await mkdir(testDir, { recursive: true }); + process.chdir(testDir); }); afterEach(async () => { - // Restore original CWD before cleanup - Windows can't delete a directory that is the CWD process.chdir(originalCwd); await safeRm(testDir, { force: true }); }); - // Note: init command uses process.cwd(), not --config flag, so we skip init tests here - // The init command is tested manually and via the basic CLI tests - describe('validate', () => { it('validates a correct config file', async () => { const configPath = join(testDir, 'ai.json'); await writeValidConfig(configPath); - // Spinner output goes to stderr, so check there - const { stderr } = await runCli(['validate', '--config', configPath]); - - expect(stderr).toContain('valid'); - }); - - it('reports error when no config found', async () => { - const configPath = join(testDir, 'nonexistent.json'); - const { error } = await runCli(['validate', '--config', configPath]); - - expect(error).toBeDefined(); - }); - - it('outputs JSON when --json flag is used', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - const { stdout } = await runCli(['validate', '--config', configPath, '--json'], { + const { error } = await runCli(['validate', '--config', configPath], { root, }); - const result = JSON.parse(stdout); - expect(result.valid).toBe(true); + expect(error).toBeUndefined(); }); - }); - - describe('list', () => { - it('lists all configured items', async () => { - const configPath = join(testDir, 'ai.json'); - await writeValidConfig(configPath, { skills: { typescript: '*' } }); - - const { stdout } = await runCli(['list', '--config', configPath]); - - expect(stdout).toContain('Skills'); - expect(stdout).toContain('typescript'); - }); - - it('filters by scope', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath, { - skills: { typescript: '*' }, - mcp: { github: { command: 'test' } }, - }); - - const { stdout } = await runCli(['list', '--config', configPath, '--scope', 'skills'], { + it('reports error when no config found', async () => { + const { error } = await runCli(['validate', '--config', 'nonexistent.json'], { root, }); - expect(stdout).toContain('Skills'); - expect(stdout).toContain('typescript'); - expect(stdout).not.toContain('MCP Servers'); - }); - - it('outputs JSON when --json flag is used', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath, { skills: { typescript: '*' } }); - - const { stdout } = await runCli(['list', '--config', configPath, '--json']); - const result = JSON.parse(stdout); - - expect(result.skills).toEqual({ typescript: '*' }); + expect(error).toBeDefined(); }); }); @@ -139,12 +84,10 @@ describe('CLI Commands', () => { await writeValidConfig(configPath); - const { stdout } = await runCli(['add', 'skill', 'typescript', '--config', configPath], { + await runCli(['add', 'skill', 'typescript', '--config', configPath], { root, }); - expect(stdout).toContain('Added skill'); - const content = await readFile(configPath, 'utf-8'); const config = JSON.parse(content); @@ -156,11 +99,9 @@ describe('CLI Commands', () => { await writeValidConfig(configPath); - const { stdout } = await runCli( - ['add', 'skill', './skills/custom', '--config', configPath], - ); - - expect(stdout).toContain('Added skill'); + await runCli(['add', 'skill', './skills/custom', '--config', configPath], { + root, + }); const content = await readFile(configPath, 'utf-8'); const config = JSON.parse(content); @@ -173,17 +114,7 @@ describe('CLI Commands', () => { await writeValidConfig(configPath); - const { stdout } = await runCli( - [ - 'add', - 'skill', - 'https://github.com/anthropics/skills/tree/main/skills/pdf', - '--config', - configPath, - ], - ); - - expect(stdout).toContain('Added skill'); + await runCli(['add', 'skill', 'https://github.com/anthropics/skills/tree/main/skills/pdf', '--config', configPath], { root }); const content = await readFile(configPath, 'utf-8'); const config = JSON.parse(content); @@ -194,507 +125,39 @@ describe('CLI Commands', () => { path: 'skills/pdf', }); }); - - it('adds a skill from GitHub repo URL', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - const { stdout } = await runCli( - ['add', 'skill', 'https://github.com/a1st/aix-skill-react', '--config', configPath], - ); - - expect(stdout).toContain('Added skill'); - - const content = await readFile(configPath, 'utf-8'); - const config = JSON.parse(content); - - expect(config.skills['aix-skill-react']).toEqual({ - git: 'https://github.com/a1st/aix-skill-react', - }); - }); - - it('adds a skill from git shorthand with ref', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - const { stdout } = await runCli( - ['add', 'skill', 'github:a1st/aix-skill-vue#v2.0.0', '--config', configPath], - ); - - expect(stdout).toContain('Added skill'); - - const content = await readFile(configPath, 'utf-8'); - const config = JSON.parse(content); - - expect(config.skills['aix-skill-vue']).toEqual({ - git: 'https://github.com/a1st/aix-skill-vue', - ref: 'v2.0.0', - }); - }); - - it('allows overriding inferred name with --name', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - const { stdout } = await runCli( - ['add', 'skill', '@a1st/aix-skill-react', '--name', 'react', '--config', configPath], - ); - - expect(stdout).toContain('Added skill'); - - const content = await readFile(configPath, 'utf-8'); - const config = JSON.parse(content); - - expect(config.skills.react).toBe('@a1st/aix-skill-react'); - }); - - it('allows overriding git ref with --ref', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - const { stdout } = await runCli( - [ - 'add', - 'skill', - 'https://github.com/anthropics/skills/tree/main/skills/pdf', - '--ref', - 'develop', - '--config', - configPath, - ], - ); - - expect(stdout).toContain('Added skill'); - - const content = await readFile(configPath, 'utf-8'); - const config = JSON.parse(content); - - expect(config.skills.pdf).toEqual({ - git: 'https://github.com/anthropics/skills', - ref: 'develop', - path: 'skills/pdf', - }); - }); - }); - - describe('add mcp', () => { - it('adds an MCP server with stdio transport', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - // Note: command value without spaces to avoid oclif/test argument parsing issues - await runCli(['add', 'mcp', 'github', '--command', 'npx', '--config', configPath], { - root, - }); - - // Verify the config was updated correctly - const content = await readFile(configPath, 'utf-8'); - const config = JSON.parse(content); - - expect(config.mcp.github.command).toBe('npx'); - }); - - it('adds an MCP server with http transport', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - await runCli( - ['add', 'mcp', 'custom', '--url', 'http://localhost:3000/mcp', '--config', configPath], - ); - - // Verify the config was updated correctly - const content = await readFile(configPath, 'utf-8'); - const config = JSON.parse(content); - - expect(config.mcp.custom.url).toBe('http://localhost:3000/mcp'); - }); - - it('fails when neither --command nor --url is provided and server not found in registry', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - // Mock fetch to return empty results from MCP registry - const originalFetch = globalThis.fetch; - - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ servers: [], metadata: {} }), - }); - - try { - const { error } = await runCli( - ['add', 'mcp', 'nonexistent-server', '--config', configPath], - ); - - expect(error?.message).toContain('No MCP servers found'); - } finally { - globalThis.fetch = originalFetch; - } - }); }); describe('remove skill', () => { it('removes a skill from config', async () => { const configPath = join(testDir, 'ai.json'); - await writeValidConfig(configPath, { skills: { typescript: '*', react: '^1.0.0' } }); - - const { stdout } = await runCli( - ['remove', 'skill', 'typescript', '--yes', '--config', configPath], - ); - - expect(stdout).toContain('Removed skill'); - - const content = await readFile(configPath, 'utf-8'); - const config = JSON.parse(content); - - expect(config.skills.typescript).toBeUndefined(); - expect(config.skills.react).toBe('^1.0.0'); - }); - - it('fails when skill does not exist', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - const { error } = await runCli( - ['remove', 'skill', 'nonexistent', '--yes', '--config', configPath], - ); - - expect(error?.message).toContain('not found'); - }); - }); - - describe('remove mcp', () => { - it('removes an MCP server from config', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath, { - mcp: { - github: { command: 'test' }, - other: { command: 'other' }, - }, - }); - - await runCli(['remove', 'mcp', 'github', '--yes', '--config', configPath]); - - // Verify the config was updated correctly - const content = await readFile(configPath, 'utf-8'); - const config = JSON.parse(content); - - expect(config.mcp.github).toBeUndefined(); - expect(config.mcp.other).toBeDefined(); - }); - }); - - describe('config get', () => { - it('gets a top-level config value', async () => { - const configPath = join(testDir, 'ai.json'); - await writeValidConfig(configPath, { skills: { typescript: '*' } }); - const { stdout } = await runCli(['config', 'get', 'skills', '--config', configPath], { - root, - }); - - expect(stdout).toContain('typescript'); - }); - - it('gets a nested config value', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath, { - rules: { typescript: { content: 'Use TypeScript' } }, - }); - - const { stdout } = await runCli(['config', 'get', 'rules', '--config', configPath], { - root, - }); - - expect(stdout).toContain('Use TypeScript'); - }); - - it('fails when key does not exist', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - const { error } = await runCli(['config', 'get', 'nonexistent', '--config', configPath], { + await runCli(['remove', 'skill', 'typescript', '--yes', '--config', configPath], { root, }); - expect(error?.message).toContain('not found'); - }); - }); - - describe('config set', () => { - it('sets a config value', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - const { stdout } = await runCli( - ['config', 'set', 'skills.typescript', '"^1.0.0"', '--config', configPath], - ); - - expect(stdout).toContain('Set skills.typescript'); - - const content = await readFile(configPath, 'utf-8'); - const config = JSON.parse(content); - - expect(config.skills.typescript).toBe('^1.0.0'); - }); - - it('sets a nested config value with JSON', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - // Note: JSON value without spaces to avoid oclif/test argument parsing issues - await runCli( - [ - 'config', - 'set', - 'rules', - '{"typescript":{"content":"TypeScript"}}', - '--config', - configPath, - ], - ); - - // Verify the config was updated correctly const content = await readFile(configPath, 'utf-8'); const config = JSON.parse(content); - expect(config.rules).toEqual({ typescript: { content: 'TypeScript' } }); - }); - }); - - describe('install', () => { - it('shows dry-run preview', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath, { - skills: { typescript: '*' }, - mcp: { github: { command: 'test' } }, - rules: { typescript: { content: 'Use TypeScript' } }, - }); - - // Spinner output goes to stderr - const { stderr } = await runCli( - ['install', '--dry-run', '--target', 'windsurf', '--config', configPath], - ); - - expect(stderr).toContain('windsurf'); - }); - - it('respects --scope flag in dry-run', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath, { - skills: { typescript: '*' }, - mcp: { github: { command: 'test' } }, - rules: { typescript: { content: 'Use TypeScript' } }, - }); - - const { stdout } = await runCli( - ['install', '--dry-run', '--target', 'windsurf', '--scope', 'mcp', '--config', configPath], - ); - - // Should show MCP section, not rules - expect(stdout).toContain('MCP'); - expect(stdout).not.toContain('rules/'); - }); - - it('outputs JSON when --json flag is used', async () => { - const configPath = join(testDir, 'ai.json'); - - await writeValidConfig(configPath); - - const { stdout } = await runCli( - ['install', '--dry-run', '--target', 'windsurf', '--json', '--config', configPath], - ); - const result = JSON.parse(stdout); - - expect(result.dryRun).toBe(true); - expect(result.results).toBeDefined(); + expect(config.skills.typescript).toBeUndefined(); }); }); - describe('install --save', () => { - it('creates new ai.json when --save is used without existing file', async () => { - // Create a "remote" config file to use as source - const remoteConfigPath = join(testDir, 'remote-ai.json'); - - await writeValidConfig(remoteConfigPath, { - mcp: { playwright: { command: 'npx' } }, - rules: { 'remote-rule': { content: 'remote rule' } }, - }); - - // Ensure no local ai.json exists - const localConfigPath = join(testDir, 'ai.json'); - - await rm(localConfigPath, { force: true }); - - // Run install --save from testDir - process.chdir(testDir); - await runCli(['install', remoteConfigPath, '--save']); - - // Verify the file was created with remote content - const content = await readFile(localConfigPath, 'utf-8'); - const config = JSON.parse(content); - - expect(config.mcp.playwright).toBeDefined(); - expect(Object.keys(config.rules)).toHaveLength(1); - }); - - it('merges into existing ai.json (remote wins)', async () => { - // Create local config - const localConfigPath = join(testDir, 'ai.json'); - - await writeValidConfig(localConfigPath, { - mcp: { local: { command: 'local-cmd' } }, - rules: { 'local-rule': { content: 'local rule' } }, - }); - - // Create remote config - const remoteConfigPath = join(testDir, 'remote-ai.json'); - - await writeValidConfig(remoteConfigPath, { - mcp: { remote: { command: 'remote-cmd' } }, - rules: { 'remote-rule': { content: 'remote rule' } }, - }); - - // Run install --save (not dry-run) from testDir - process.chdir(testDir); - await runCli(['install', remoteConfigPath, '--save']); - - // Verify merged config - const content = await readFile(localConfigPath, 'utf-8'); - const config = JSON.parse(content); - - // Both MCP servers should exist - expect(config.mcp.local).toBeDefined(); - expect(config.mcp.remote).toBeDefined(); - - // Rules should be merged (both keys exist) - expect(Object.keys(config.rules)).toHaveLength(2); - }); - - it('overwrites existing ai.json with --overwrite', async () => { - // Create local config - const localConfigPath = join(testDir, 'ai.json'); - - await writeValidConfig(localConfigPath, { - mcp: { local: { command: 'local-cmd' } }, - rules: { 'local-rule': { content: 'local rule' } }, - }); - - // Create remote config - const remoteConfigPath = join(testDir, 'remote-ai.json'); - - await writeValidConfig(remoteConfigPath, { - mcp: { remote: { command: 'remote-cmd' } }, - }); - - // Run install --save --overwrite from testDir - process.chdir(testDir); - await runCli(['install', remoteConfigPath, '--save', '--overwrite']); - - // Verify overwritten config - const content = await readFile(localConfigPath, 'utf-8'); - const config = JSON.parse(content); - - // Only remote MCP server should exist - expect(config.mcp.local).toBeUndefined(); - expect(config.mcp.remote).toBeDefined(); - }); - - it('filters by --scope when saving', async () => { - // Create local config - const localConfigPath = join(testDir, 'ai.json'); - - await writeValidConfig(localConfigPath, { - mcp: { local: { command: 'local-cmd' } }, - rules: { 'local-rule': { content: 'local rule' } }, - }); - - // Create remote config with multiple sections - const remoteConfigPath = join(testDir, 'remote-ai.json'); - - await writeValidConfig(remoteConfigPath, { - mcp: { remote: { command: 'remote-cmd' } }, - rules: { 'remote-rule': { content: 'remote rule' } }, - skills: { pdf: { git: 'https://github.com/test/skills', path: 'skills/pdf' } }, - }); - - // Run install --save --scope mcp (only save mcp section) - process.chdir(testDir); - await runCli(['install', remoteConfigPath, '--save', '--scope', 'mcp']); - - // Verify only mcp was merged - const content = await readFile(localConfigPath, 'utf-8'); - const config = JSON.parse(content); - - // MCP should have both local and remote - expect(config.mcp.local).toBeDefined(); - expect(config.mcp.remote).toBeDefined(); - - // Rules should NOT have remote rule (only local) - expect(Object.keys(config.rules)).toHaveLength(1); - expect(config.rules['local-rule'].content).toBe('local rule'); - - // Skills should NOT have pdf - expect(config.skills.pdf).toBeUndefined(); - }); - - it('errors when --save is used without source argument', async () => { + describe('config set', () => { + it('sets a config value', async () => { const configPath = join(testDir, 'ai.json'); await writeValidConfig(configPath); - const { error } = await runCli(['install', '--save', '--config', configPath], { + await runCli(['config', 'set', 'skills.typescript', '"^1.0.0"', '--config', configPath], { root, }); - expect(error?.message).toContain('--save requires a remote source'); - }); - - it('supports multiple --scope flags', async () => { - // Create local config - const localConfigPath = join(testDir, 'ai.json'); - - await writeValidConfig(localConfigPath); - - // Create remote config with multiple sections - const remoteConfigPath = join(testDir, 'remote-ai.json'); - - await writeValidConfig(remoteConfigPath, { - mcp: { remote: { command: 'remote-cmd' } }, - rules: { 'remote-rule': { content: 'remote rule' } }, - skills: { pdf: { git: 'https://github.com/test/skills', path: 'skills/pdf' } }, - }); - - // Run install --save --scope mcp --scope skills - process.chdir(testDir); - await runCli( - ['install', remoteConfigPath, '--save', '--scope', 'mcp', '--scope', 'skills'], - ); - - // Verify mcp and skills were merged, but not rules - const content = await readFile(localConfigPath, 'utf-8'); + const content = await readFile(configPath, 'utf-8'); const config = JSON.parse(content); - expect(config.mcp.remote).toBeDefined(); - expect(config.skills.pdf).toBeDefined(); - expect(Object.keys(config.rules)).toHaveLength(0); // Empty from original + expect(config.skills.typescript).toBe('^1.0.0'); }); }); }); diff --git a/packages/cli/src/base-command.js b/packages/cli/src/base-command.js deleted file mode 100644 index 079ad3f..0000000 --- a/packages/cli/src/base-command.js +++ /dev/null @@ -1 +0,0 @@ -export * from './base-command.ts'; diff --git a/packages/cli/src/commands/add/skill.ts b/packages/cli/src/commands/add/skill.ts index bb840f7..7020b93 100644 --- a/packages/cli/src/commands/add/skill.ts +++ b/packages/cli/src/commands/add/skill.ts @@ -3,191 +3,31 @@ import { BaseCommand } from '../../base-command.js'; import { installAfterAdd, formatInstallResults } from '../../lib/install-helper.js'; import { localFlag } from '../../flags/local.js'; import { - buildGitHubUrl, - buildGitLabUrl, - buildProviderUrl, getLocalConfigPath, - inferNameFromPath, - isGenericGitUrl, - isLocalPath, - parseGitHubRepoUrl, - parseGitHubTreeUrl, - parseGitLabTreeUrl, - parseGitShorthand, updateConfig, updateLocalConfig, } from '@a1st/aix-core'; -import type { AiJsonConfig } from '@a1st/aix-schema'; - -type SkillRef = AiJsonConfig['skills'][string]; - -interface ParsedSource { - type: 'local' | 'git' | 'npm'; - ref: SkillRef; - inferredName: string; -} - -/** - * Normalize a string to a valid skill name (lowercase alphanumeric with hyphens). - */ -function normalizeSkillName(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); -} - -/** - * Detect source type and parse into a structured reference. - */ -function parseSource(source: string, refOverride?: string): ParsedSource { - // Local paths - if (isLocalPath(source)) { - const name = inferNameFromPath(source) ?? 'skill'; - - return { - type: 'local', - ref: { path: source }, - inferredName: normalizeSkillName(name), - }; - } - - // GitHub web URL with /tree/branch/path - const ghTree = parseGitHubTreeUrl(source); - - if (ghTree) { - const name = inferNameFromPath(ghTree.subdir) ?? ghTree.repo; - - return { - type: 'git', - ref: { - git: buildGitHubUrl(ghTree.owner, ghTree.repo), - ref: refOverride ?? ghTree.ref, - path: ghTree.subdir, - }, - inferredName: normalizeSkillName(name), - }; - } - - // GitHub repo URL (no tree path) - const ghRepo = parseGitHubRepoUrl(source); - - if (ghRepo) { - const gitRef: { git: string; ref?: string } = { - git: buildGitHubUrl(ghRepo.owner, ghRepo.repo), - }; - - if (refOverride) { - gitRef.ref = refOverride; - } - return { - type: 'git', - ref: gitRef, - inferredName: normalizeSkillName(ghRepo.repo), - }; - } - - // GitLab web URL with /-/tree/branch/path - const glTree = parseGitLabTreeUrl(source); - - if (glTree) { - const name = inferNameFromPath(glTree.subdir) ?? glTree.project; - - return { - type: 'git', - ref: { - git: buildGitLabUrl(glTree.group, glTree.project), - ref: refOverride ?? glTree.ref, - path: glTree.subdir, - }, - inferredName: normalizeSkillName(name), - }; - } - - // Git shorthand: github:user/repo, github:user/repo/path#ref, gitlab:user/repo - const shorthand = parseGitShorthand(source); - - if (shorthand) { - const gitUrl = buildProviderUrl(shorthand.provider, shorthand.user, shorthand.repo), - effectiveRef = refOverride ?? shorthand.ref, - gitRefObj: { git: string; ref?: string; path?: string } = { git: gitUrl }, - name = shorthand.subpath ? (inferNameFromPath(shorthand.subpath) ?? 'skill') : shorthand.repo; - - if (effectiveRef) { - gitRefObj.ref = effectiveRef; - } - if (shorthand.subpath) { - gitRefObj.path = shorthand.subpath; - } - - return { - type: 'git', - ref: gitRefObj, - inferredName: normalizeSkillName(name), - }; - } - - // Generic https git URL (not GitHub/GitLab web UI) - if (isGenericGitUrl(source)) { - const name = inferNameFromPath(source.replace(/\.git$/, '')) ?? 'skill', - gitRefObj: { git: string; ref?: string } = { git: source }; - - if (refOverride) { - gitRefObj.ref = refOverride; - } - return { - type: 'git', - ref: gitRefObj, - inferredName: normalizeSkillName(name), - }; - } - - // npm package: scoped (@scope/pkg) or aix-skill-* convention or plain package name - if (source.startsWith('@')) { - // Scoped package: @scope/aix-skill-foo or @scope/foo - const pkgName = source.split('/').pop() ?? source, - stripped = pkgName.replace(/^aix-skill-/, ''); - - return { - type: 'npm', - ref: source, - inferredName: normalizeSkillName(stripped), - }; - } - - // Unscoped: if it doesn't contain / or :, treat as npm - // Convention: bare name like "typescript" means "aix-skill-typescript" - if (!source.includes('/') && !source.includes(':')) { - const isFullPkgName = source.startsWith('aix-skill-'), - packageName = isFullPkgName ? source : `aix-skill-${source}`; - - return { - type: 'npm', - ref: packageName, - inferredName: normalizeSkillName(source.replace(/^aix-skill-/, '')), - }; - } - - // Fallback: treat as npm package name - const name = inferNameFromPath(source) ?? source; - - return { - type: 'npm', - ref: source, - inferredName: normalizeSkillName(name), - }; +import { execa } from 'execa'; +import { readFile } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; + +interface SkillsLock { + skills: Record; } export default class AddSkill extends BaseCommand { - static override description = 'Add a skill to ai.json'; + static override description = 'Add a skill to ai.json (powered by vercel-labs/skills)'; static override examples = [ '<%= config.bin %> <%= command.id %> typescript', '<%= config.bin %> <%= command.id %> ./skills/custom', - '<%= config.bin %> <%= command.id %> https://github.com/anthropics/skills/tree/main/skills/pdf', - '<%= config.bin %> <%= command.id %> github:a1st/aix-skill-react#v2.0.0', - '<%= config.bin %> <%= command.id %> @a1st/aix-skill-react --name react', + '<%= config.bin %> <%= command.id %> https://github.com/vercel-labs/agent-skills', + '<%= config.bin %> <%= command.id %> vercel-labs/agent-skills --name vercel-react-best-practices', '<%= config.bin %> <%= command.id %> typescript --no-install', ]; @@ -202,11 +42,11 @@ export default class AddSkill extends BaseCommand { ...localFlag, name: Flags.string({ char: 'n', - description: 'Override inferred skill name', + description: 'Specific skill name to add from the source', }), ref: Flags.string({ char: 'r', - description: 'Git ref (branch, tag, commit) - overrides ref in URL', + description: 'Git ref (branch, tag, commit)', }), 'no-install': Flags.boolean({ description: 'Skip installing to editors after adding', @@ -215,51 +55,91 @@ export default class AddSkill extends BaseCommand { }; async run(): Promise { - const { args, flags } = await this.parse(AddSkill), - loaded = await this.loadConfig(), - parsed = parseSource(args.source, flags.ref), - skillName = flags.name ?? parsed.inferredName; + const { args } = await this.parse(AddSkill), + loaded = await this.loadConfig(); - // Validate skill name - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(skillName)) { - this.error( - `Invalid skill name "${skillName}". ` + - 'Must be lowercase alphanumeric with single hyphens (e.g., "pdf-processing"). ' + - 'Use --name to specify a valid name.', - ); - } + this.output.startSpinner(`Adding skill from "${args.source}"...`); + + try { + // Build command arguments + const skillsArgs = ['add', args.source, '--mode', 'copy', '-y']; + + if (this.flags.name) { + skillsArgs.push('--skill', this.flags.name); + } + if (this.flags.ref) { + // Note: npx skills add doesn't seem to have a direct --ref flag yet, + // but we can potentially append it to the source if it's a URL. + // For now, we'll try to just pass it as is. + } + + // Run skills CLI from node_modules + const binPath = join(process.cwd(), 'node_modules', '.bin', 'skills'); + + await execa(binPath, skillsArgs, { + cwd: loaded ? dirname(loaded.path) : process.cwd(), + }); - // Determine target file based on --local flag - if (flags.local) { - const localPath = loaded ? getLocalConfigPath(loaded.path) : 'ai.local.json'; + // Read skills-lock.json to see what was added + const lockPath = join(loaded ? dirname(loaded.path) : process.cwd(), 'skills-lock.json'), + lockContent = await readFile(lockPath, 'utf8'), + lockData = JSON.parse(lockContent) as SkillsLock; - await updateLocalConfig(localPath, (config) => ({ - ...config, - skills: { - ...config.skills, - [skillName]: parsed.ref, - }, - })); - this.output.success(`Added skill "${skillName}" to ai.local.json`); - } else { - if (!loaded) { - this.error( - 'No ai.json found. Run `aix init` to create one, or use --local to write to ai.local.json.', - ); + // Identify the added skill(s) + const addedSkillEntries = Object.entries(lockData.skills); + + if (addedSkillEntries.length === 0) { + throw new Error('No skills were added to skills-lock.json'); + } + + // Update ai.json (or ai.local.json) + const skillMap: Record = {}; + + for (const [name, info] of addedSkillEntries) { + // Convert to aix-compatible reference format + if (info.sourceType === 'github' || info.sourceType === 'git') { + skillMap[name] = { + git: info.source.startsWith('http') ? info.source : `https://github.com/${info.source}`, + path: info.path, + ref: info.ref, + }; + } else if (info.sourceType === 'local') { + skillMap[name] = { path: info.source }; + } else { + // Default to string reference (e.g. for npm or simple packages) + skillMap[name] = info.source; + } } - await updateConfig(loaded.path, (config) => ({ - ...config, - skills: { - ...config.skills, - [skillName]: parsed.ref, - }, - })); - this.output.success(`Added skill "${skillName}"`); + + if (this.flags.local) { + const localPath = loaded ? getLocalConfigPath(loaded.path) : 'ai.local.json'; + + await updateLocalConfig(localPath, (config) => ({ + ...config, + skills: { + ...config.skills, + ...skillMap, + }, + })); + } else { + if (!loaded) { + throw new Error('No ai.json found. Run `aix init` to create one.'); + } + await updateConfig(loaded.path, (config) => ({ + ...config, + skills: { + ...config.skills, + ...skillMap, + }, + })); + } + + this.output.stopSpinner(true, `Successfully added ${addedSkillEntries.length} skill(s)`); // Auto-install to configured editors unless --no-install - if (!flags['no-install']) { + if (!this.flags['no-install']) { const installResult = await installAfterAdd({ - configPath: loaded.path, + configPath: loaded?.path ?? 'ai.json', scopes: ['skills'], }); @@ -267,15 +147,9 @@ export default class AddSkill extends BaseCommand { this.logInstallResults(formatInstallResults(installResult.results)); } } - } - - if (this.flags.json) { - this.output.json({ - action: 'add', - type: 'skill', - name: skillName, - reference: parsed.ref, - }); + } catch (error) { + this.output.stopSpinner(false, 'Failed to add skill'); + this.error(error instanceof Error ? error.message : String(error)); } } } diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index c67b3fd..c2c5d77 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -667,11 +667,11 @@ export default class Install extends BaseCommand { } /** - * Extract skill name from path like .aix/skills/pdf or .windsurf/skills/pdf -> pdf + * Extract skill name from path like .agents/skills/pdf or .windsurf/skills/pdf -> pdf */ private extractSkillName(path: string): string { - // Match both .aix/skills/{name} and editor skills dirs like .windsurf/skills/{name} - const match = path.match(/(?:\.aix|\.windsurf|\.cursor|\.claude|\.vscode)\/skills\/([^/]+)/); + // Match both .agents/skills/{name} and editor skills dirs like .windsurf/skills/{name} + const match = path.match(/(?:\.agents|\.windsurf|\.cursor|\.claude|\.vscode)\/skills\/([^/]+)/); return match?.[1] ?? this.extractFileName(path); } diff --git a/packages/cli/src/commands/list/index.ts b/packages/cli/src/commands/list/index.ts index 3b294f3..f577049 100644 --- a/packages/cli/src/commands/list/index.ts +++ b/packages/cli/src/commands/list/index.ts @@ -31,6 +31,9 @@ export default class List extends BaseCommand { if (includesScope(scopes, 'rules')) { result.rules = config.rules ?? {}; } + if (includesScope(scopes, 'prompts')) { + result.prompts = config.prompts ?? {}; + } if (includesScope(scopes, 'editors')) { result.editors = config.editors ?? {}; } @@ -65,6 +68,7 @@ export default class List extends BaseCommand { skills: 'Skills', mcp: 'MCP Servers', rules: 'Rules', + prompts: 'Prompts', editors: 'Editors', }; diff --git a/packages/cli/src/commands/list/prompts.ts b/packages/cli/src/commands/list/prompts.ts new file mode 100644 index 0000000..ae1827e --- /dev/null +++ b/packages/cli/src/commands/list/prompts.ts @@ -0,0 +1,69 @@ +import { BaseCommand } from '../../base-command.js'; + +type PromptRow = Record & { + name: string; + source: string; + description: string; +}; + +export default class ListPrompts extends BaseCommand { + static override description = 'List configured prompts'; + + static override examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --json', + ]; + + async run(): Promise { + const loaded = await this.requireConfig(); + const prompts = loaded.config.prompts ?? {}; + + if (this.flags.json) { + this.output.json({ prompts }); + return; + } + + // Filter out false values (disabled prompts) + const entries = Object.entries(prompts).filter(([, config]) => config !== false); + + if (entries.length === 0) { + this.output.info('No prompts configured'); + return; + } + + const rows: PromptRow[] = entries.map(([name, config]) => { + const promptConfig = config as Exclude; + let source: string, + description = ''; + + if (typeof promptConfig === 'string') { + source = promptConfig; + } else { + if (promptConfig.path) { + source = promptConfig.path; + } else if (promptConfig.git) { + source = `git:${promptConfig.git.url}`; + } else if (promptConfig.npm) { + source = `npm:${promptConfig.npm.npm}`; + } else if (promptConfig.content) { + source = '(inline)'; + } else { + source = '(unknown)'; + } + description = promptConfig.description ?? ''; + } + + return { name, source, description }; + }); + + this.output.header('Prompts'); + this.output.table(rows, { + columns: [ + { key: 'name', name: 'Name' }, + { key: 'source', name: 'Source' }, + { key: 'description', name: 'Description' }, + ], + overflow: 'wrap', + }); + } +} diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index 6479e62..762acd7 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -2,7 +2,6 @@ import { Args, Flags } from '@oclif/core'; import { BaseCommand } from '../base-command.js'; import { SearchRegistry, - parseExperimentalFlags, type SearchResult, type SearchType, } from '../lib/search/index.js'; @@ -32,7 +31,6 @@ export default class Search extends BaseCommand { '<%= config.bin %> <%= command.id %> playwright --type mcp', '<%= config.bin %> <%= command.id %> react --type skills', '<%= config.bin %> <%= command.id %> github --type skills --type mcp', - '<%= config.bin %> <%= command.id %> testing -x source:claude-plugins-dev', ]; static override args = { @@ -55,12 +53,6 @@ export default class Search extends BaseCommand { options: ['skills', 'mcp'], multiple: true, }), - experimental: Flags.string({ - char: 'x', - description: 'Enable experimental features (e.g., source:claude-plugins-dev)', - multiple: true, - default: [], - }), plain: Flags.boolean({ char: 'p', description: 'Force plain text output (non-interactive)', @@ -76,20 +68,11 @@ export default class Search extends BaseCommand { const outputMode = getOutputMode({ json: this.flags.json, plain: this.flags.plain }); - // Parse experimental flags and create registry with enabled sources - const experimentalSources = parseExperimentalFlags(this.flags.experimental as string[]); + // Create registry const registry = new SearchRegistry({ npmRegistry: this.flags.registry, - experimentalSources, }); - // Log which experimental sources are enabled - if (experimentalSources.size > 0 && outputMode !== 'json') { - const sourceNames = Array.from(experimentalSources).join(', '); - - this.output.info(`Experimental sources enabled: ${sourceNames}`); - } - // Interactive mode if (outputMode === 'interactive') { try { @@ -155,17 +138,19 @@ export default class Search extends BaseCommand { const handleInstall = async (item: InstallItem): Promise => { const itemName = item.result.name; + // Use the ID from meta if available (for skills-library results) + const source = item.type === 'skills' ? (item.result.meta?.id as string || itemName) : itemName; if (!configPath) { const cmd = item.type === 'skills' ? 'skill' : 'mcp'; - this.output.info(`Would run: aix add ${cmd} ${itemName}`); + this.output.info(`Would run: aix add ${cmd} ${source}`); return false; } const result = item.type === 'skills' - ? await addSkill({ configPath, name: itemName, source: itemName }) + ? await addSkill({ configPath, name: itemName, source }) : await addMcp({ configPath, name: itemName }); return result.success; diff --git a/packages/cli/src/lib/add-helper.ts b/packages/cli/src/lib/add-helper.ts index b3cc5ee..c7a1143 100644 --- a/packages/cli/src/lib/add-helper.ts +++ b/packages/cli/src/lib/add-helper.ts @@ -2,6 +2,9 @@ import { updateConfig, loadConfig } from '@a1st/aix-core'; import { McpRegistryClient, type ServerResponse, type Package } from '@a1st/mcp-registry-client'; import type { McpServerConfig } from '@a1st/aix-schema'; import { installAfterAdd } from './install-helper.js'; +import { execa } from 'execa'; +import { readFile } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; export interface AddSkillOptions { configPath: string; @@ -10,6 +13,15 @@ export interface AddSkillOptions { skipInstall?: boolean; } +interface SkillsLock { + skills: Record; +} + export interface AddMcpOptions { configPath: string; name: string; @@ -23,34 +35,56 @@ export interface AddResult { } /** - * Normalize a string to a valid skill name (lowercase alphanumeric with hyphens). - */ -function normalizeSkillName(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); -} - -/** - * Add a skill to ai.json programmatically. - * For npm packages, the source should be the package name (e.g., "aix-skill-typescript" or just "typescript"). + * Add a skill to ai.json programmatically using the skills CLI. */ export async function addSkill(options: AddSkillOptions): Promise { - const { configPath, source, skipInstall } = options; + const { configPath, source, skipInstall, name } = options; try { - // Determine if it's a short name or full package name - const isFullPkgName = source.startsWith('aix-skill-') || source.startsWith('@'), - packageName = isFullPkgName ? source : `aix-skill-${source}`, - skillName = options.name || normalizeSkillName(source.replace(/^aix-skill-/, '')); + // Run skills CLI from node_modules + const binPath = join(process.cwd(), 'node_modules', '.bin', 'skills'), + configDir = dirname(configPath); + + // Build command arguments + // If the source is from skills-library, it should be the ID (e.g. vercel-labs/agent-skills) + const skillsArgs = ['add', source, '--mode', 'copy', '-y']; + + // If we have a specific name, try to add just that skill + if (name && name !== source) { + skillsArgs.push('--skill', name); + } + + await execa(binPath, skillsArgs, { + cwd: configDir, + }); + + // Read skills-lock.json to see what was added and update ai.json + const lockPath = join(configDir, 'skills-lock.json'), + lockContent = await readFile(lockPath, 'utf8'), + lockData = JSON.parse(lockContent) as SkillsLock; + + // Update ai.json with the new skill references from the lock file + const skillMap: Record = {}; + + for (const [skillName, info] of Object.entries(lockData.skills)) { + if (info.sourceType === 'github' || info.sourceType === 'git') { + skillMap[skillName] = { + git: info.source.startsWith('http') ? info.source : `https://github.com/${info.source}`, + path: info.path, + ref: info.ref, + }; + } else if (info.sourceType === 'local') { + skillMap[skillName] = { path: info.source }; + } else { + skillMap[skillName] = info.source; + } + } await updateConfig(configPath, (config) => ({ ...config, skills: { ...config.skills, - [skillName]: packageName, + ...skillMap, }, })); @@ -61,11 +95,11 @@ export async function addSkill(options: AddSkillOptions): Promise { }); } - return { success: true, name: skillName }; + return { success: true, name: name || source }; } catch (error) { return { success: false, - name: options.name || source, + name: name || source, error: error instanceof Error ? error.message : String(error), }; } diff --git a/packages/cli/src/lib/delete-helper.ts b/packages/cli/src/lib/delete-helper.ts index f02f464..c502f9a 100644 --- a/packages/cli/src/lib/delete-helper.ts +++ b/packages/cli/src/lib/delete-helper.ts @@ -34,7 +34,7 @@ export function computeFilesToDelete( files: string[] = []; if (itemType === 'skill') { - // Skills are installed to .aix/skills/{name}/ (shared across editors) + // Skills are installed to .agents/skills/{name}/ (shared across editors) // Plus any pointer rules in the editor's rules directory const skillDir = join(projectRoot, '.aix', 'skills', itemName); diff --git a/packages/cli/src/lib/search/registry.ts b/packages/cli/src/lib/search/registry.ts index 1b57867..6d6fc57 100644 --- a/packages/cli/src/lib/search/registry.ts +++ b/packages/cli/src/lib/search/registry.ts @@ -3,22 +3,18 @@ import type { SearchResult, SearchType, SearchOptions, - ExperimentalSourceId, } from './types.js'; import { - NpmSearchSource, McpRegistrySearchSource, - ClaudePluginsDevSearchSource, + SkillsLibrarySearchSource, } from './sources/index.js'; /** * Options for creating a SearchRegistry. */ export interface SearchRegistryOptions { - /** NPM registry URL for skill searches */ + /** NPM registry URL for skill searches (currently unused as skills.sh is used) */ npmRegistry?: string; - /** Set of experimental source IDs to enable */ - experimentalSources?: Set; } /** @@ -27,17 +23,10 @@ export interface SearchRegistryOptions { export class SearchRegistry { private readonly sources: SearchSource[] = []; - constructor(options: SearchRegistryOptions = {}) { - // MCP registry is always available + constructor(_options: SearchRegistryOptions = {}) { + // MCP registry and Skills Library are always available this.sources.push(new McpRegistrySearchSource()); - - // Register experimental sources if enabled - if (options.experimentalSources?.has('claude-plugins-dev')) { - // NPM skill search only makes sense alongside claude-plugins-dev for now - // (there are no aix-skill-* packages published yet) - this.sources.push(new NpmSearchSource(options.npmRegistry)); - this.sources.push(new ClaudePluginsDevSearchSource()); - } + this.sources.push(new SkillsLibrarySearchSource()); } /** @@ -63,7 +52,7 @@ export class SearchRegistry { /** * Search across all sources for a given type, aggregating results. - * Results are deduplicated by name, preferring results from non-experimental sources. + * Results are deduplicated by name. */ async search(type: SearchType, options: SearchOptions): Promise { const sources = this.getSourcesForType(type); @@ -95,7 +84,6 @@ export class SearchRegistry { if (!existing) { seen.set(result.name, result); } - // If we already have this result from a non-experimental source, keep that one } } diff --git a/packages/cli/src/lib/search/sources/claude-plugins-dev.ts b/packages/cli/src/lib/search/sources/claude-plugins-dev.ts deleted file mode 100644 index 4e6c41f..0000000 --- a/packages/cli/src/lib/search/sources/claude-plugins-dev.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { SearchSource, SearchResult, SearchType, SearchOptions } from '../types.js'; -import { debounce } from '../debounce.js'; - -interface ClaudePluginsSkillResult { - name: string; - description?: string; - author?: string; - version?: string; - installs?: { - total: number; - week: number; - month: number; - }; -} - -interface ClaudePluginsSearchResponse { - skills: ClaudePluginsSkillResult[]; - total: number; - limit: number; - offset: number; -} - -/** - * Claude Plugins Dev registry search source. - * Experimental source - must be explicitly enabled. - * - * API: https://api.claude-plugins.dev/api/skills/search - */ -export class ClaudePluginsDevSearchSource implements SearchSource { - readonly id = 'claude-plugins-dev'; - readonly name = 'Claude Plugins Dev'; - readonly types: SearchType[] = ['skills']; - readonly experimental = true; - - private readonly baseUrl = 'https://api.claude-plugins.dev'; - - async search(type: SearchType, options: SearchOptions): Promise { - if (type !== 'skills') { - return []; - } - - await debounce(this.id); - - const searchUrl = new URL('/api/skills/search', this.baseUrl); - - searchUrl.searchParams.set('q', options.query); - if (options.limit) { - searchUrl.searchParams.set('limit', String(options.limit)); - } - if (options.offset) { - searchUrl.searchParams.set('offset', String(options.offset)); - } - - const response = await fetch(searchUrl); - - if (!response.ok) { - throw new Error(`Failed to search claude-plugins.dev: ${response.status}`); - } - - const data = (await response.json()) as ClaudePluginsSearchResponse; - - return (data.skills ?? []).map((skill) => ({ - name: skill.name, - version: skill.version, - description: skill.description, - source: this.id, - meta: { - author: skill.author, - installs: skill.installs, - }, - })); - } -} diff --git a/packages/cli/src/lib/search/sources/index.ts b/packages/cli/src/lib/search/sources/index.ts index 2e5bf3e..b6451f3 100644 --- a/packages/cli/src/lib/search/sources/index.ts +++ b/packages/cli/src/lib/search/sources/index.ts @@ -1,3 +1,2 @@ -export { NpmSearchSource } from './npm.js'; export { McpRegistrySearchSource } from './mcp-registry.js'; -export { ClaudePluginsDevSearchSource } from './claude-plugins-dev.js'; +export { SkillsLibrarySearchSource } from './skills-library.js'; diff --git a/packages/cli/src/lib/search/sources/npm.ts b/packages/cli/src/lib/search/sources/npm.ts deleted file mode 100644 index 0702907..0000000 --- a/packages/cli/src/lib/search/sources/npm.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { SearchSource, SearchResult, SearchType, SearchOptions } from '../types.js'; -import { debounce } from '../debounce.js'; - -interface NpmSearchResult { - objects: Array<{ - package: { - name: string; - version: string; - description: string; - keywords: string[]; - }; - }>; -} - -/** - * NPM Registry search source for aix-skill packages. - */ -export class NpmSearchSource implements SearchSource { - readonly id = 'npm'; - readonly name = 'NPM Registry'; - readonly types: SearchType[] = ['skills']; - readonly experimental = false; - - constructor(private readonly registryUrl: string = 'https://registry.npmjs.org') {} - - async search(type: SearchType, options: SearchOptions): Promise { - if (type !== 'skills') { - return []; - } - - await debounce(this.id); - - const searchUrl = new URL('/-/v1/search', this.registryUrl); - - searchUrl.searchParams.set('text', `aix-skill ${options.query}`); - searchUrl.searchParams.set('size', String(options.limit ?? 20)); - if (options.offset) { - searchUrl.searchParams.set('from', String(options.offset)); - } - - const response = await fetch(searchUrl); - - if (!response.ok) { - throw new Error(`Failed to search npm registry: ${response.status}`); - } - - const data = (await response.json()) as NpmSearchResult; - - return data.objects - .filter( - (obj) => - obj.package.name.startsWith('aix-skill-') || - obj.package.name.includes('/aix-skill-') || - obj.package.keywords?.includes('aix-skill'), - ) - .map((obj) => ({ - name: obj.package.name, - version: obj.package.version, - description: obj.package.description, - source: this.id, - })); - } -} diff --git a/packages/cli/src/lib/search/sources/skills-library.ts b/packages/cli/src/lib/search/sources/skills-library.ts new file mode 100644 index 0000000..9841ee2 --- /dev/null +++ b/packages/cli/src/lib/search/sources/skills-library.ts @@ -0,0 +1,58 @@ +import type { SearchSource, SearchResult, SearchType, SearchOptions } from '../types.js'; +import { debounce } from '../debounce.js'; + +interface SkillsShSearchResult { + skills: Array<{ + id: string; + skillId: string; + name: string; + installs: number; + source: string; + }>; +} + +/** + * Skills Library search source (via skills.sh API). + */ +export class SkillsLibrarySearchSource implements SearchSource { + readonly id = 'skills-library'; + readonly name = 'Skills Library'; + readonly types: SearchType[] = ['skills']; + readonly experimental = false; + + constructor(private readonly apiUrl: string = 'https://skills.sh/api/search') {} + + async search(type: SearchType, options: SearchOptions): Promise { + if (type !== 'skills') { + return []; + } + + await debounce(this.id); + + const searchUrl = new URL(this.apiUrl); + + searchUrl.searchParams.set('q', options.query); + if (options.limit) { + searchUrl.searchParams.set('limit', String(options.limit)); + } + + const response = await fetch(searchUrl); + + if (!response.ok) { + throw new Error(`Failed to search skills library: ${response.status}`); + } + + const data = (await response.json()) as SkillsShSearchResult; + + return data.skills.map((skill) => ({ + name: skill.name, + description: `Agent skill from ${skill.source}`, + source: this.id, + meta: { + id: skill.id, + installs: skill.installs, + source: skill.source, + }, + })); + } +} diff --git a/packages/cli/src/lib/search/types.ts b/packages/cli/src/lib/search/types.ts index c6de8eb..cae7a4f 100644 --- a/packages/cli/src/lib/search/types.ts +++ b/packages/cli/src/lib/search/types.ts @@ -46,23 +46,3 @@ export interface SearchSource { search(type: SearchType, options: SearchOptions): Promise; } -/** - * Experimental source identifiers that can be enabled via --experimental flag. - */ -export type ExperimentalSourceId = 'claude-plugins-dev'; - -/** - * Parse experimental flag values like "source:claude-plugins-dev". - */ -export function parseExperimentalFlags(flags: string[]): Set { - const sources = new Set(); - - for (const flag of flags) { - if (flag.startsWith('source:')) { - const sourceId = flag.slice('source:'.length) as ExperimentalSourceId; - - sources.add(sourceId); - } - } - return sources; -} diff --git a/packages/cli/src/ui/search/components/DetailsPanel.tsx b/packages/cli/src/ui/search/components/DetailsPanel.tsx index efe6475..0dfeaa8 100644 --- a/packages/cli/src/ui/search/components/DetailsPanel.tsx +++ b/packages/cli/src/ui/search/components/DetailsPanel.tsx @@ -33,6 +33,11 @@ export function DetailsPanel({ result, width }: DetailsPanelProps): React.ReactE 📦 Source: {sanitizeForTerminal(result.source)} + {meta?.installs !== undefined && ( + + ⬇️ Installs: {sanitizeForTerminal(String(meta.installs))} + + )} {meta?.author !== undefined && ( 👤 Author: {sanitizeForTerminal(String(meta.author))} diff --git a/packages/core/package.json b/packages/core/package.json index b256d01..c643533 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,11 +41,13 @@ "confbox": "0.2.2", "defu": "6.1.4", "detect-indent": "7.0.2", + "execa": "^8.0.1", "giget": "1.2.3", "is-in-ci": "2.0.0", "nypm": "0.3.12", "p-map": "7.0.4", "pathe": "1.1.2", + "skills": "^1.4.4", "yaml": "2.8.2" } } diff --git a/packages/core/src/__tests__/editors/adapters.test.ts b/packages/core/src/__tests__/editors/adapters.test.ts index c2c74bb..950808f 100644 --- a/packages/core/src/__tests__/editors/adapters.test.ts +++ b/packages/core/src/__tests__/editors/adapters.test.ts @@ -673,16 +673,16 @@ describe('Editor Adapters', () => { }, hooks: { pre_command: [{ hooks: [{ command: 'echo pre' }] }], - session_end: [{ hooks: [{ command: 'echo end' }] }], + task_created: [{ hooks: [{ command: 'echo task' }] }], }, }); const result = await installToEditor('copilot', config, testDir); - // GitHub Copilot supports MCP and hooks (but not session_end) + // GitHub Copilot supports MCP and hooks (but not task_created) expect(result.unsupportedFeatures?.mcp).toBeUndefined(); expect(result.unsupportedFeatures?.hooks?.allUnsupported).toBeUndefined(); - expect(result.unsupportedFeatures?.hooks?.unsupportedEvents).toContain('session_end'); + expect(result.unsupportedFeatures?.hooks?.unsupportedEvents).toContain('task_created'); }); it('Zed reports unsupported hooks but not MCP', async () => { diff --git a/packages/core/src/__tests__/editors/hooks-strategies.test.ts b/packages/core/src/__tests__/editors/hooks-strategies.test.ts index c4fefe9..bdef9ae 100644 --- a/packages/core/src/__tests__/editors/hooks-strategies.test.ts +++ b/packages/core/src/__tests__/editors/hooks-strategies.test.ts @@ -259,7 +259,7 @@ describe('ClaudeCodeHooksStrategy', () => { expect(output.hooks.SessionStart[0].hooks[0].timeout).toBe(60); }); - it('reports no unsupported events for all 14 schema events', () => { + it('reports no unsupported events for all schema events', () => { const hooks: HooksConfig = { pre_tool_use: [{ hooks: [{ command: 'c' }] }], post_tool_use: [{ hooks: [{ command: 'c' }] }], @@ -275,10 +275,39 @@ describe('ClaudeCodeHooksStrategy', () => { session_start: [{ hooks: [{ command: 'c' }] }], session_end: [{ hooks: [{ command: 'c' }] }], agent_stop: [{ hooks: [{ command: 'c' }] }], + pre_compact: [{ hooks: [{ command: 'c' }] }], + post_compact: [{ hooks: [{ command: 'c' }] }], + subagent_start: [{ hooks: [{ command: 'c' }] }], + subagent_stop: [{ hooks: [{ command: 'c' }] }], + task_created: [{ hooks: [{ command: 'c' }] }], + task_completed: [{ hooks: [{ command: 'c' }] }], + worktree_setup: [{ hooks: [{ command: 'c' }] }], }; expect(strategy.getUnsupportedEvents(hooks)).toEqual([]); }); + + it('maps new generic events correctly', () => { + const hooks: HooksConfig = { + pre_compact: [{ hooks: [{ command: 'c1' }] }], + post_compact: [{ hooks: [{ command: 'c2' }] }], + subagent_start: [{ hooks: [{ command: 'c3' }] }], + subagent_stop: [{ hooks: [{ command: 'c4' }] }], + task_created: [{ hooks: [{ command: 'c5' }] }], + task_completed: [{ hooks: [{ command: 'c6' }] }], + worktree_setup: [{ hooks: [{ command: 'c7' }] }], + }; + + const output = JSON.parse(strategy.formatConfig(hooks)); + + expect(output.hooks.PreCompact).toBeDefined(); + expect(output.hooks.PostCompact).toBeDefined(); + expect(output.hooks.SubagentStart).toBeDefined(); + expect(output.hooks.SubagentStop).toBeDefined(); + expect(output.hooks.TaskCreated).toBeDefined(); + expect(output.hooks.TaskCompleted).toBeDefined(); + expect(output.hooks.WorktreeCreate).toBeDefined(); + }); }); describe('WindsurfHooksStrategy', () => { @@ -314,6 +343,7 @@ describe('WindsurfHooksStrategy', () => { post_mcp_tool: [{ hooks: [{ command: 'c8' }] }], pre_prompt: [{ hooks: [{ command: 'c9' }] }], agent_stop: [{ hooks: [{ command: 'c10' }] }], + worktree_setup: [{ hooks: [{ command: 'c11' }] }], }; const output = JSON.parse(strategy.formatConfig(hooks)); @@ -328,6 +358,7 @@ describe('WindsurfHooksStrategy', () => { expect(output.hooks.post_mcp_tool_use).toBeDefined(); expect(output.hooks.pre_user_prompt).toBeDefined(); expect(output.hooks.post_cascade_response).toBeDefined(); + expect(output.hooks.post_setup_worktree).toBeDefined(); }); it('includes show_output and working_directory when specified', () => { @@ -370,62 +401,62 @@ describe('CopilotHooksStrategy', () => { expect(strategy.getConfigPath()).toBe('../.github/hooks/hooks.json'); }); - it('maps session_start to SessionStart with matcher structure', () => { + it('maps session_start to sessionStart with matcher structure', () => { const hooks: HooksConfig = { session_start: [{ matcher: '.*', hooks: [{ command: 'echo start' }] }], }; const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.SessionStart).toEqual([ + expect(output.hooks.sessionStart).toEqual([ { matcher: '.*', hooks: [{ type: 'command', command: 'echo start' }] }, ]); }); - it('maps pre_command to PreToolUse with Bash tool matcher', () => { + it('maps pre_command to preToolUse with Bash tool matcher', () => { const hooks: HooksConfig = { pre_command: [{ hooks: [{ command: 'echo pre' }] }], }; const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.PreToolUse).toEqual([ + expect(output.hooks.preToolUse).toEqual([ { matcher: 'Bash', hooks: [{ type: 'command', command: 'echo pre' }] }, ]); }); - it('maps pre_file_read to PreToolUse with Read matcher', () => { + it('maps pre_file_read to preToolUse with Read matcher', () => { const hooks: HooksConfig = { pre_file_read: [{ hooks: [{ command: 'echo read' }] }], }; const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.PreToolUse).toEqual([ + expect(output.hooks.preToolUse).toEqual([ { matcher: 'Read', hooks: [{ type: 'command', command: 'echo read' }] }, ]); }); - it('maps pre_file_write to PreToolUse with Write|Edit matcher', () => { + it('maps pre_file_write to preToolUse with Write|Edit matcher', () => { const hooks: HooksConfig = { pre_file_write: [{ hooks: [{ command: 'echo write' }] }], }; const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.PreToolUse).toEqual([ + expect(output.hooks.preToolUse).toEqual([ { matcher: 'Write|Edit', hooks: [{ type: 'command', command: 'echo write' }] }, ]); }); - it('maps pre_mcp_tool to PreToolUse with mcp__.* matcher', () => { + it('maps pre_mcp_tool to preToolUse with mcp__.* matcher', () => { const hooks: HooksConfig = { pre_mcp_tool: [{ hooks: [{ command: 'echo mcp' }] }], }; const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.PreToolUse).toEqual([ + expect(output.hooks.preToolUse).toEqual([ { matcher: 'mcp__.*', hooks: [{ type: 'command', command: 'echo mcp' }] }, ]); }); @@ -439,13 +470,13 @@ describe('CopilotHooksStrategy', () => { const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.PreToolUse).toHaveLength(3); - expect(output.hooks.PreToolUse[0].matcher).toBe('.*'); - expect(output.hooks.PreToolUse[0].hooks[0].command).toBe('generic'); - expect(output.hooks.PreToolUse[1].matcher).toBe('Bash'); - expect(output.hooks.PreToolUse[1].hooks[0].command).toBe('bash-specific'); - expect(output.hooks.PreToolUse[2].matcher).toBe('Read'); - expect(output.hooks.PreToolUse[2].hooks[0].command).toBe('read-specific'); + expect(output.hooks.preToolUse).toHaveLength(3); + expect(output.hooks.preToolUse[0].matcher).toBe('.*'); + expect(output.hooks.preToolUse[0].hooks[0].command).toBe('generic'); + expect(output.hooks.preToolUse[1].matcher).toBe('Bash'); + expect(output.hooks.preToolUse[1].hooks[0].command).toBe('bash-specific'); + expect(output.hooks.preToolUse[2].matcher).toBe('Read'); + expect(output.hooks.preToolUse[2].hooks[0].command).toBe('read-specific'); }); it('maps post events with correct tool matchers', () => { @@ -458,11 +489,11 @@ describe('CopilotHooksStrategy', () => { const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.PostToolUse).toHaveLength(4); - expect(output.hooks.PostToolUse[0].matcher).toBe('Read'); - expect(output.hooks.PostToolUse[1].matcher).toBe('Write|Edit'); - expect(output.hooks.PostToolUse[2].matcher).toBe('Bash'); - expect(output.hooks.PostToolUse[3].matcher).toBe('mcp__.*'); + expect(output.hooks.postToolUse).toHaveLength(4); + expect(output.hooks.postToolUse[0].matcher).toBe('Read'); + expect(output.hooks.postToolUse[1].matcher).toBe('Write|Edit'); + expect(output.hooks.postToolUse[2].matcher).toBe('Bash'); + expect(output.hooks.postToolUse[3].matcher).toBe('mcp__.*'); }); it('uses user matcher for pre_tool_use (generic event)', () => { @@ -472,7 +503,7 @@ describe('CopilotHooksStrategy', () => { const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.PreToolUse[0].matcher).toBe('Write'); + expect(output.hooks.preToolUse[0].matcher).toBe('Write'); }); it('uses empty string for pre_tool_use without matcher', () => { @@ -482,7 +513,7 @@ describe('CopilotHooksStrategy', () => { const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.PreToolUse[0].matcher).toBe(''); + expect(output.hooks.preToolUse[0].matcher).toBe(''); }); it('includes timeout when specified', () => { @@ -492,46 +523,34 @@ describe('CopilotHooksStrategy', () => { const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.SessionStart[0].hooks[0].timeout).toBe(60); + expect(output.hooks.sessionStart[0].hooks[0].timeout).toBe(60); }); - it('maps pre_prompt to UserPromptSubmit', () => { + it('maps pre_prompt to userPromptSubmitted', () => { const hooks: HooksConfig = { pre_prompt: [{ hooks: [{ command: 'echo prompt' }] }], }; const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.UserPromptSubmit).toEqual([ + expect(output.hooks.userPromptSubmitted).toEqual([ { matcher: '', hooks: [{ type: 'command', command: 'echo prompt' }] }, ]); }); - it('maps agent_stop to Stop', () => { + it('maps agent_stop to stop', () => { const hooks: HooksConfig = { agent_stop: [{ hooks: [{ command: 'echo stop' }] }], }; const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.Stop).toEqual([ + expect(output.hooks.stop).toEqual([ { matcher: '', hooks: [{ type: 'command', command: 'echo stop' }] }, ]); }); - it('reports session_end as unsupported', () => { - const hooks: HooksConfig = { - session_end: [{ hooks: [{ command: 'cmd' }] }], - pre_command: [{ hooks: [{ command: 'cmd' }] }], - }; - - const unsupported = strategy.getUnsupportedEvents(hooks); - - expect(unsupported).toContain('session_end'); - expect(unsupported).not.toContain('pre_command'); - }); - - it('reports supported events correctly for all 13 except session_end', () => { + it('reports supported events correctly for all schema events except some', () => { const hooks: HooksConfig = { pre_tool_use: [{ hooks: [{ command: 'c' }] }], post_tool_use: [{ hooks: [{ command: 'c' }] }], @@ -545,21 +564,25 @@ describe('CopilotHooksStrategy', () => { post_mcp_tool: [{ hooks: [{ command: 'c' }] }], pre_prompt: [{ hooks: [{ command: 'c' }] }], session_start: [{ hooks: [{ command: 'c' }] }], + session_end: [{ hooks: [{ command: 'c' }] }], agent_stop: [{ hooks: [{ command: 'c' }] }], + pre_compact: [{ hooks: [{ command: 'c' }] }], + subagent_start: [{ hooks: [{ command: 'c' }] }], + subagent_stop: [{ hooks: [{ command: 'c' }] }], }; expect(strategy.getUnsupportedEvents(hooks)).toEqual([]); }); - it('skips session_end in output', () => { + it('skips unsupported in output', () => { const hooks: HooksConfig = { pre_command: [{ hooks: [{ command: 'cmd' }] }], - session_end: [{ hooks: [{ command: 'cmd' }] }], + task_created: [{ hooks: [{ command: 'cmd' }] }], }; const output = JSON.parse(strategy.formatConfig(hooks)); - expect(output.hooks.PreToolUse).toBeDefined(); + expect(output.hooks.preToolUse).toBeDefined(); expect(Object.keys(output.hooks)).toHaveLength(1); }); }); diff --git a/packages/core/src/__tests__/editors/windsurf-skills.test.ts b/packages/core/src/__tests__/editors/windsurf-skills.test.ts index 13695b6..875b491 100644 --- a/packages/core/src/__tests__/editors/windsurf-skills.test.ts +++ b/packages/core/src/__tests__/editors/windsurf-skills.test.ts @@ -1,10 +1,16 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdir, writeFile, lstat, readlink } from 'node:fs/promises'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdir, writeFile, lstat } from 'node:fs/promises'; import { join } from 'pathe'; import { tmpdir } from 'node:os'; import type { ParsedSkill } from '@a1st/aix-schema'; import { WindsurfSkillsStrategy } from '../../editors/strategies/windsurf/skills.js'; import { safeRm } from '../../fs/safe-rm.js'; +import { execa } from 'execa'; + +// Mock execa +vi.mock('execa', () => ({ + execa: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), +})); describe('WindsurfSkillsStrategy', () => { let testDir: string; @@ -14,6 +20,7 @@ describe('WindsurfSkillsStrategy', () => { testDir = join(tmpdir(), `aix-windsurf-skills-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); await mkdir(testDir, { recursive: true }); strategy = new WindsurfSkillsStrategy(); + vi.clearAllMocks(); }); afterEach(async () => { @@ -31,26 +38,14 @@ describe('WindsurfSkillsStrategy', () => { }); it('returns correct skills directory', () => { - expect(strategy.getSkillsDir()).toBe('.aix/skills'); + expect(strategy.getSkillsDir()).toBe('.agents/skills'); }); - it('installs skills with symlinks to .windsurf/skills/', async () => { - // Create a mock skill directory - const skillSourceDir = join(testDir, 'source-skill'); - - await mkdir(skillSourceDir, { recursive: true }); - await writeFile( - join(skillSourceDir, 'SKILL.md'), - '---\nname: test-skill\ndescription: A test skill\n---\n\n# Test Skill\n', - ); - + it('calls skills CLI with correct arguments including --mode copy', async () => { const mockSkill: ParsedSkill = { - frontmatter: { - name: 'test-skill', - description: 'A test skill', - }, + frontmatter: { name: 'test-skill', description: 'A test skill' }, body: '# Test Skill', - basePath: skillSourceDir, + basePath: '/some/path', source: 'local', }; @@ -58,68 +53,25 @@ describe('WindsurfSkillsStrategy', () => { const changes = await strategy.installSkills(skills, testDir, { dryRun: false }); - // Should have 2 changes: .aix/skills/test-skill (copy) and .windsurf/skills/test-skill (symlink) - expect(changes).toHaveLength(2); - - const aixChange = changes.find((c) => c.path.includes('.aix/skills/test-skill')); - const windsurfChange = changes.find((c) => c.path.includes('.windsurf/skills/test-skill')); - - expect(aixChange).toBeDefined(); - expect(aixChange?.action).toBe('create'); - expect(aixChange?.category).toBe('skill'); - - expect(windsurfChange).toBeDefined(); - expect(windsurfChange?.action).toBe('create'); - expect(windsurfChange?.content).toContain('symlink'); - - // Verify symlink was actually created - const symlinkPath = join(testDir, '.windsurf/skills/test-skill'), - stats = await lstat(symlinkPath); - - expect(stats.isSymbolicLink()).toBe(true); - - // Verify symlink points to correct location (normalize for Windows backslashes) - const linkTarget = (await readlink(symlinkPath)).replace(/\\/g, '/'); - - expect(linkTarget).toContain('.aix/skills/test-skill'); - }); - - it('reports update action when skill already exists', async () => { - // Create existing skill directories - const skillSourceDir = join(testDir, 'source-skill'); - - await mkdir(skillSourceDir, { recursive: true }); - await writeFile(join(skillSourceDir, 'SKILL.md'), '---\nname: existing\ndescription: Existing\n---\n'); - - await mkdir(join(testDir, '.aix/skills/existing'), { recursive: true }); - await mkdir(join(testDir, '.windsurf/skills'), { recursive: true }); - - const mockSkill: ParsedSkill = { - frontmatter: { name: 'existing', description: 'Existing' }, - body: '', - basePath: skillSourceDir, - source: 'local', - }; - - const skills = new Map([['existing', mockSkill]]); - - const changes = await strategy.installSkills(skills, testDir, { dryRun: false }); - - const aixChange = changes.find((c) => c.path.includes('.aix/skills/existing')); + // Should call execa with the skills binary and --mode copy + expect(execa).toHaveBeenCalledWith( + expect.stringContaining('node_modules/.bin/skills'), + ['experimental_install', '--agent', 'windsurf', '--mode', 'copy', '-y'], + { cwd: testDir }, + ); - expect(aixChange?.action).toBe('update'); + // Should return a change for the skill + expect(changes).toHaveLength(1); + expect(changes[0]?.path).toBe(join('.agents/skills', 'test-skill')); + expect(changes[0]?.action).toBe('update'); + expect(changes[0]?.content).toContain('Synced via skills CLI'); }); - it('respects dry-run option', async () => { - const skillSourceDir = join(testDir, 'source-skill'); - - await mkdir(skillSourceDir, { recursive: true }); - await writeFile(join(skillSourceDir, 'SKILL.md'), '---\nname: dry-test\ndescription: Dry\n---\n'); - + it('respects dry-run option without calling CLI', async () => { const mockSkill: ParsedSkill = { frontmatter: { name: 'dry-test', description: 'Dry' }, body: '', - basePath: skillSourceDir, + basePath: '/some/path', source: 'local', }; @@ -127,11 +79,12 @@ describe('WindsurfSkillsStrategy', () => { const changes = await strategy.installSkills(skills, testDir, { dryRun: true }); - // Should still return changes for preview - expect(changes).toHaveLength(2); + // Should NOT call execa + expect(execa).not.toHaveBeenCalled(); - // But directories should not exist - await expect(lstat(join(testDir, '.aix/skills/dry-test'))).rejects.toThrow(); - await expect(lstat(join(testDir, '.windsurf/skills/dry-test'))).rejects.toThrow(); + // Should still return changes for preview with --mode copy mentioned + expect(changes).toHaveLength(1); + expect(changes[0]?.path).toBe(join('.agents/skills', 'dry-test')); + expect(changes[0]?.content).toContain('--mode copy'); }); }); diff --git a/packages/core/src/editors/adapters/base.ts b/packages/core/src/editors/adapters/base.ts index 0b75faf..9171a7a 100644 --- a/packages/core/src/editors/adapters/base.ts +++ b/packages/core/src/editors/adapters/base.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, writeFile, rm, access, constants, chmod } from 'node:fs/promises'; import { join, dirname, basename } from 'pathe'; import { existsSync } from 'node:fs'; -import type { AiJsonConfig, McpServerConfig } from '@a1st/aix-schema'; +import type { AiJsonConfig, McpServerConfig, ParsedSkill } from '@a1st/aix-schema'; import type { EditorAdapter, EditorConfig, @@ -245,16 +245,28 @@ export abstract class BaseEditorAdapter implements EditorAdapter { // Resolve skills and use the skills strategy to install them (only if skills scope is included) if (scopes.includes('skills') && config.skills && Object.keys(config.skills).length > 0) { - const resolvedSkills = await resolveAllSkills(config.skills, { - baseDir: configBaseDir, - projectRoot, - }); + if (this.skillsStrategy.isNative()) { + // For native strategies, we use the skills CLI which handles its own resolution. + // We pass a map with just the names so the strategy can return granular changes for reporting. + const skillNames = new Map(); - // Install skills using the strategy (handles copying and symlinks/pointer rules) - skillChanges = await this.skillsStrategy.installSkills(resolvedSkills, projectRoot, options); + for (const name of Object.keys(config.skills)) { + skillNames.set(name, {} as ParsedSkill); + } + skillChanges = await this.skillsStrategy.installSkills(skillNames, projectRoot, options); + } else { + // For non-native (pointer) strategies, we still need to resolve skills to generate rules + const resolvedSkills = await resolveAllSkills(config.skills, { + baseDir: configBaseDir, + projectRoot, + }); - // Generate skill rules (empty for native, pointer rules for non-native) - skillRules = this.skillsStrategy.generateSkillRules(resolvedSkills); + // Install skills using the strategy (handles copying and generating pointer rules) + skillChanges = await this.skillsStrategy.installSkills(resolvedSkills, projectRoot, options); + + // Generate skill rules (pointer rules for non-native) + skillRules = this.skillsStrategy.generateSkillRules(resolvedSkills); + } } // Merge config rules with skill rules diff --git a/packages/core/src/editors/adapters/claude-code.ts b/packages/core/src/editors/adapters/claude-code.ts index 9be40f2..ae49b22 100644 --- a/packages/core/src/editors/adapters/claude-code.ts +++ b/packages/core/src/editors/adapters/claude-code.ts @@ -18,9 +18,9 @@ import type { /** * Claude Code editor adapter. Writes rules to `.claude/rules/*.md` and MCP config to - * `.mcp.json` (project root). Skills are installed to `.aix/skills/{name}/` with symlinks from - * `.claude/skills/` since Claude Code has native Agent Skills support. Hooks are written - * to `.claude/settings.json`. + * `.mcp.json` (project root). Skills are installed to `.agents/skills/{name}/` and + * physically copied to `.claude/skills/` since Claude Code has native Agent Skills support. + * Hooks are written to `.claude/settings.json`. */ export class ClaudeCodeAdapter extends BaseEditorAdapter { readonly name = 'claude-code' as const; @@ -38,6 +38,7 @@ export class ClaudeCodeAdapter extends BaseEditorAdapter { protected readonly mcpStrategy: McpStrategy = new ClaudeCodeMcpStrategy(); protected readonly skillsStrategy: SkillsStrategy = new NativeSkillsStrategy({ editorSkillsDir: '.claude/skills', + editorName: 'claude-code', }); protected readonly promptsStrategy: PromptsStrategy = new ClaudeCodePromptsStrategy(); protected readonly hooksStrategy: HooksStrategy = new ClaudeCodeHooksStrategy(); diff --git a/packages/core/src/editors/adapters/codex.ts b/packages/core/src/editors/adapters/codex.ts index dc47cd2..8e7c8b0 100644 --- a/packages/core/src/editors/adapters/codex.ts +++ b/packages/core/src/editors/adapters/codex.ts @@ -39,8 +39,10 @@ export class CodexAdapter extends BaseEditorAdapter { protected readonly rulesStrategy: RulesStrategy = new CodexRulesStrategy(); protected readonly mcpStrategy: McpStrategy = new CodexMcpStrategy(); protected readonly skillsStrategy: SkillsStrategy = new NativeSkillsStrategy({ - editorSkillsDir: '.codex/skills', + editorSkillsDir: '.agents/skills', + editorName: 'codex', }); + protected readonly promptsStrategy: PromptsStrategy = new CodexPromptsStrategy(); protected readonly hooksStrategy: HooksStrategy = new NoHooksStrategy(); diff --git a/packages/core/src/editors/adapters/copilot.ts b/packages/core/src/editors/adapters/copilot.ts index f1f29dd..ed85fbd 100644 --- a/packages/core/src/editors/adapters/copilot.ts +++ b/packages/core/src/editors/adapters/copilot.ts @@ -19,8 +19,8 @@ import type { /** * GitHub Copilot editor adapter. Writes rules to `.github/instructions/*.instructions.md`, * MCP config to `.vscode/mcp.json`, skills to `.github/skills/`, and hooks to `.github/hooks/hooks.json`. - * Skills are installed to `.aix/skills/{name}/` with symlinks from `.github/skills/` since GitHub Copilot - * has native Agent Skills support. + * Skills are installed to `.agents/skills/{name}/` and physically copied to `.github/skills/` + * since GitHub Copilot has native Agent Skills support. */ export class CopilotAdapter extends BaseEditorAdapter { readonly name = 'copilot' as const; @@ -38,6 +38,7 @@ export class CopilotAdapter extends BaseEditorAdapter { protected readonly mcpStrategy: McpStrategy = new CopilotMcpStrategy(); protected readonly skillsStrategy: SkillsStrategy = new NativeSkillsStrategy({ editorSkillsDir: '.github/skills', + editorName: 'copilot', }); protected readonly promptsStrategy: PromptsStrategy = new CopilotPromptsStrategy(); protected readonly hooksStrategy: HooksStrategy = new CopilotHooksStrategy(); diff --git a/packages/core/src/editors/adapters/cursor.ts b/packages/core/src/editors/adapters/cursor.ts index 9dca9d4..ea6dbbc 100644 --- a/packages/core/src/editors/adapters/cursor.ts +++ b/packages/core/src/editors/adapters/cursor.ts @@ -17,8 +17,8 @@ import type { /** * Cursor editor adapter. Writes rules to `.cursor/rules/*.mdc` (with YAML frontmatter) and MCP - * config to `.cursor/mcp.json`. Skills are installed to `.aix/skills/{name}/` with symlinks from - * `.cursor/skills/` since Cursor supports the Agent Skills open standard. + * config to `.cursor/mcp.json`. Skills are installed to `.agents/skills/{name}/` and + * physically copied to `.cursor/skills/` since Cursor supports the Agent Skills open standard. * Hooks are written to `.cursor/hooks.json`. */ export class CursorAdapter extends BaseEditorAdapter { @@ -37,6 +37,7 @@ export class CursorAdapter extends BaseEditorAdapter { protected readonly mcpStrategy: McpStrategy = new StandardMcpStrategy(); protected readonly skillsStrategy: SkillsStrategy = new NativeSkillsStrategy({ editorSkillsDir: '.cursor/skills', + editorName: 'cursor', }); protected readonly promptsStrategy: PromptsStrategy = new CursorPromptsStrategy(); protected readonly hooksStrategy: HooksStrategy = new CursorHooksStrategy(); diff --git a/packages/core/src/editors/adapters/windsurf.ts b/packages/core/src/editors/adapters/windsurf.ts index 9d5ca83..cfb78bc 100644 --- a/packages/core/src/editors/adapters/windsurf.ts +++ b/packages/core/src/editors/adapters/windsurf.ts @@ -18,7 +18,7 @@ import type { /** * Windsurf editor adapter. Writes rules to `.windsurf/rules/*.md`. Skills are installed - * natively to `.windsurf/skills/{name}/` via symlinks from `.aix/skills/{name}/`. + * natively to `.windsurf/skills/{name}/` via physical copies from `.agents/skills/{name}/`. * Hooks are written to `.windsurf/hooks.json`. MCP is global-only * (`~/.codeium/windsurf/mcp_config.json`) and requires user confirmation to modify. */ diff --git a/packages/core/src/editors/adapters/zed.ts b/packages/core/src/editors/adapters/zed.ts index e30196c..198ad90 100644 --- a/packages/core/src/editors/adapters/zed.ts +++ b/packages/core/src/editors/adapters/zed.ts @@ -14,7 +14,7 @@ import type { /** * Zed editor adapter. Writes rules to `.rules` at project root and MCP config to - * `.zed/settings.json`. Skills are installed to `.aix/skills/{name}/` with pointer rules since Zed + * `.zed/settings.json`. Skills are installed to `.agents/skills/{name}/` with pointer rules since Zed * doesn't have native Agent Skills support. Zed does not support hooks or prompts. */ export class ZedAdapter extends BaseEditorAdapter { diff --git a/packages/core/src/editors/strategies/claude-code/hooks.ts b/packages/core/src/editors/strategies/claude-code/hooks.ts index 82b97bd..f5025ea 100644 --- a/packages/core/src/editors/strategies/claude-code/hooks.ts +++ b/packages/core/src/editors/strategies/claude-code/hooks.ts @@ -19,6 +19,13 @@ const EVENT_MAP: Record = { session_end: 'SessionEnd', agent_stop: 'Stop', pre_prompt: 'UserPromptSubmit', + pre_compact: 'PreCompact', + post_compact: 'PostCompact', + subagent_start: 'SubagentStart', + subagent_stop: 'SubagentStop', + task_created: 'TaskCreated', + task_completed: 'TaskCompleted', + worktree_setup: 'WorktreeCreate', }; /** @@ -54,6 +61,13 @@ const SUPPORTED_EVENTS = new Set([ 'session_end', 'agent_stop', 'pre_prompt', + 'pre_compact', + 'post_compact', + 'subagent_start', + 'subagent_stop', + 'task_created', + 'task_completed', + 'worktree_setup', ]); /** diff --git a/packages/core/src/editors/strategies/copilot/hooks.ts b/packages/core/src/editors/strategies/copilot/hooks.ts index f2fd1c6..13472b2 100644 --- a/packages/core/src/editors/strategies/copilot/hooks.ts +++ b/packages/core/src/editors/strategies/copilot/hooks.ts @@ -2,24 +2,27 @@ import type { HooksConfig, HookMatcher } from '@a1st/aix-schema'; import type { HooksStrategy } from '../types.js'; /** - * Map from generic ai.json hook events to GitHub Copilot's PascalCase event names. - * GitHub Copilot supports 8 events: SessionStart, UserPromptSubmit, PreToolUse, - * PostToolUse, PreCompact, SubagentStart, SubagentStop, Stop. + * Map from generic ai.json hook events to GitHub Copilot's camelCase event names. + * GitHub Copilot supports these events as per official documentation. */ const EVENT_MAP: Record = { - pre_tool_use: 'PreToolUse', - post_tool_use: 'PostToolUse', - pre_file_read: 'PreToolUse', - post_file_read: 'PostToolUse', - pre_file_write: 'PreToolUse', - post_file_write: 'PostToolUse', - pre_command: 'PreToolUse', - post_command: 'PostToolUse', - pre_mcp_tool: 'PreToolUse', - post_mcp_tool: 'PostToolUse', - session_start: 'SessionStart', - agent_stop: 'Stop', - pre_prompt: 'UserPromptSubmit', + pre_tool_use: 'preToolUse', + post_tool_use: 'postToolUse', + pre_file_read: 'preToolUse', + post_file_read: 'postToolUse', + pre_file_write: 'preToolUse', + post_file_write: 'postToolUse', + pre_command: 'preToolUse', + post_command: 'postToolUse', + pre_mcp_tool: 'preToolUse', + post_mcp_tool: 'postToolUse', + session_start: 'sessionStart', + session_end: 'sessionEnd', + agent_stop: 'stop', + pre_prompt: 'userPromptSubmitted', + pre_compact: 'preCompact', + subagent_start: 'subagentStart', + subagent_stop: 'subagentStop', }; /** @@ -40,7 +43,6 @@ const TOOL_MATCHER_MAP: Record = { /** * Events that GitHub Copilot supports. - * GitHub Copilot does not support `session_end`. */ const SUPPORTED_EVENTS = new Set([ 'pre_tool_use', @@ -54,13 +56,17 @@ const SUPPORTED_EVENTS = new Set([ 'pre_mcp_tool', 'post_mcp_tool', 'session_start', + 'session_end', 'agent_stop', 'pre_prompt', + 'pre_compact', + 'subagent_start', + 'subagent_stop', ]); /** * GitHub Copilot hooks strategy. Writes hooks to `.github/hooks/hooks.json`. - * Translates generic ai.json event names to GitHub Copilot's PascalCase format. + * Translates generic ai.json event names to GitHub Copilot's camelCase format. * Uses the same matcher-based structure as Claude Code hooks. */ export class CopilotHooksStrategy implements HooksStrategy { diff --git a/packages/core/src/editors/strategies/shared/native-skills.ts b/packages/core/src/editors/strategies/shared/native-skills.ts index b3b390e..20f8e99 100644 --- a/packages/core/src/editors/strategies/shared/native-skills.ts +++ b/packages/core/src/editors/strategies/shared/native-skills.ts @@ -1,26 +1,35 @@ -import pMap from 'p-map'; -import { mkdir, cp, symlink, lstat } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join, dirname, relative } from 'pathe'; +import { execa } from 'execa'; +import { join } from 'pathe'; import type { ParsedSkill } from '@a1st/aix-schema'; import type { SkillsStrategy, NativeSkillsConfig } from '../types.js'; import type { EditorRule, FileChange } from '../../types.js'; -import { safeRm } from '../../../fs/safe-rm.js'; /** * Native skills strategy for editors that support Agent Skills natively (Claude Code, GitHub Copilot, - * Cursor). Copies skills to `.aix/skills/` as the source of truth and creates symlinks from the - * editor's skills directory. + * Cursor). Uses the `skills` CLI (vercel-labs/skills) to handle robust multi-agent installation. + * The source of truth is aligned with the industry-standard `.agents/skills/` directory. + * Skills are physically copied to the agent's skills directory to ensure maximum compatibility. */ export class NativeSkillsStrategy implements SkillsStrategy { - private editorSkillsDir: string; + private editorName: string; constructor(config: NativeSkillsConfig) { - this.editorSkillsDir = config.editorSkillsDir; + // Map aix editor names to skills CLI agent names + const mapping: Record = { + 'claude-code': 'claude-code', + cursor: 'cursor', + windsurf: 'windsurf', + copilot: 'github-copilot', + 'github-copilot': 'github-copilot', + zed: 'zed', + codex: 'codex', + }; + + this.editorName = mapping[config.editorName] || config.editorName; } getSkillsDir(): string { - return '.aix/skills'; + return '.agents/skills'; } isNative(): boolean { @@ -32,66 +41,40 @@ export class NativeSkillsStrategy implements SkillsStrategy { projectRoot: string, options: { dryRun?: boolean } = {}, ): Promise { - const entries = Array.from(skills.entries()); - - const nestedChanges = await pMap( - entries, - async ([name, skill]) => { - const changes: FileChange[] = []; - - // 1. Copy to .aix/skills/{name}/ (source of truth) - const aixSkillDir = join(projectRoot, '.aix', 'skills', name), - aixExists = existsSync(aixSkillDir); - - if (!options.dryRun) { - await mkdir(dirname(aixSkillDir), { recursive: true }); - await cp(skill.basePath, aixSkillDir, { recursive: true, force: true }); - } - - changes.push({ - path: aixSkillDir, - action: aixExists ? 'update' : 'create', - content: `[skill directory: ${skill.basePath}]`, - isDirectory: true, - category: 'skill', - }); - - // 2. Create symlink from editor skills dir to .aix/skills/{name} - const editorSkillPath = join(projectRoot, this.editorSkillsDir, name), - relativePath = relative(dirname(editorSkillPath), aixSkillDir); - - let symlinkExists = false; - - try { - const stats = await lstat(editorSkillPath); - - symlinkExists = stats.isSymbolicLink() || stats.isDirectory(); - } catch { - // Path doesn't exist - } - - if (!options.dryRun) { - await mkdir(dirname(editorSkillPath), { recursive: true }); - if (symlinkExists) { - await safeRm(editorSkillPath, { force: true }); - } - await symlink(relativePath, editorSkillPath); - } - - changes.push({ - path: editorSkillPath, - action: symlinkExists ? 'update' : 'create', - content: `[symlink → ${relativePath}]`, - isDirectory: true, - category: 'skill', - }); - - return changes; - }, - { concurrency: 5 }, - ); - - return nestedChanges.flat(); + const skillNames = Array.from(skills.keys()); + + if (options.dryRun) { + return skillNames.map((name) => ({ + path: join('.agents/skills', name), + action: 'update', + content: `[npx skills experimental_install --agent ${this.editorName} --mode copy]`, + isDirectory: true, + category: 'skill', + })); + } + + try { + // Use the skills CLI from node_modules to handle the entire installation process. + // This is more robust as it supports 40+ agents. + // We use --mode copy to ensure files are physically copied instead of symlinked. + const binPath = join(projectRoot, 'node_modules', '.bin', 'skills'); + + await execa(binPath, ['experimental_install', '--agent', this.editorName, '--mode', 'copy', '-y'], { + cwd: projectRoot, + }); + + + return skillNames.map((name) => ({ + path: join('.agents/skills', name), + action: 'update', + content: '[Synced via skills CLI]', + isDirectory: true, + category: 'skill', + })); + } catch (error) { + console.warn(`Failed to install skills for ${this.editorName} using skills CLI:`, error); + return []; + } } generateSkillRules(_skills: Map): EditorRule[] { diff --git a/packages/core/src/editors/strategies/shared/pointer-skills.ts b/packages/core/src/editors/strategies/shared/pointer-skills.ts index dd61877..79b1545 100644 --- a/packages/core/src/editors/strategies/shared/pointer-skills.ts +++ b/packages/core/src/editors/strategies/shared/pointer-skills.ts @@ -8,12 +8,12 @@ import type { EditorRule, FileChange } from '../../types.js'; /** * Pointer skills strategy for editors without native Agent Skills support (Windsurf, Cursor, Zed). - * Copies skills to `.aix/skills/` and generates pointer rules that tell the AI where to find the + * Copies skills to `.agents/skills/` and generates pointer rules that tell the AI where to find the * skill. */ export class PointerSkillsStrategy implements SkillsStrategy { getSkillsDir(): string { - return '.aix/skills'; + return '.agents/skills'; } isNative(): boolean { @@ -92,13 +92,13 @@ export class PointerSkillsStrategy implements SkillsStrategy { '', '## Location', '', - `This skill is installed at \`.aix/skills/${skillName}/\`. Read the \`SKILL.md\` file there for full instructions.`, + `This skill is installed at \`.agents/skills/${skillName}/\`. Read the \`SKILL.md\` file there for full instructions.`, '', '## Quick Reference', '', - `- **Instructions**: \`.aix/skills/${skillName}/SKILL.md\``, - `- **Scripts**: \`.aix/skills/${skillName}/scripts/\` (if available)`, - `- **References**: \`.aix/skills/${skillName}/references/\` (if available)`, + `- **Instructions**: \`.agents/skills/${skillName}/SKILL.md\``, + `- **Scripts**: \`.agents/skills/${skillName}/scripts/\` (if available)`, + `- **References**: \`.agents/skills/${skillName}/references/\` (if available)`, '', 'When you need to use this skill, read the SKILL.md file for detailed instructions.', ); diff --git a/packages/core/src/editors/strategies/types.ts b/packages/core/src/editors/strategies/types.ts index 13975e0..8f267b8 100644 --- a/packages/core/src/editors/strategies/types.ts +++ b/packages/core/src/editors/strategies/types.ts @@ -105,7 +105,7 @@ export interface McpStrategy { */ export interface SkillsStrategy { /** - * Install skills to the project. All strategies copy skills to `.aix/skills/{name}/` as the + * Install skills to the project. All strategies copy skills to `.agents/skills/{name}/` as the * source of truth. Native strategies also create symlinks; pointer strategies generate rules. * * @returns File changes for skill directories and any symlinks created @@ -135,6 +135,8 @@ export interface SkillsStrategy { export interface NativeSkillsConfig { /** The editor's native skills directory (e.g., '.claude/skills' or '.github/skills') */ editorSkillsDir: string; + /** The normalized editor name (e.g., 'claude-code', 'cursor') */ + editorName: string; } /** diff --git a/packages/core/src/editors/strategies/windsurf/hooks.ts b/packages/core/src/editors/strategies/windsurf/hooks.ts index 3e1ca17..c173482 100644 --- a/packages/core/src/editors/strategies/windsurf/hooks.ts +++ b/packages/core/src/editors/strategies/windsurf/hooks.ts @@ -15,6 +15,7 @@ const EVENT_MAP: Record = { post_mcp_tool: 'post_mcp_tool_use', pre_prompt: 'pre_user_prompt', agent_stop: 'post_cascade_response', + worktree_setup: 'post_setup_worktree', }; /** @@ -31,6 +32,7 @@ const SUPPORTED_EVENTS = new Set([ 'post_mcp_tool', 'pre_prompt', 'agent_stop', + 'worktree_setup', ]); /** diff --git a/packages/core/src/editors/strategies/windsurf/skills.ts b/packages/core/src/editors/strategies/windsurf/skills.ts index dd46eff..d17d67b 100644 --- a/packages/core/src/editors/strategies/windsurf/skills.ts +++ b/packages/core/src/editors/strategies/windsurf/skills.ts @@ -2,10 +2,13 @@ import { NativeSkillsStrategy } from '../shared/native-skills.js'; /** * Windsurf skills strategy using native Agent Skills support. - * Creates symlinks from `.windsurf/skills/{name}/` to `.aix/skills/{name}/`. + * Creates physical copies in `.windsurf/skills/{name}/` from `.agents/skills/{name}/`. */ export class WindsurfSkillsStrategy extends NativeSkillsStrategy { constructor() { - super({ editorSkillsDir: '.windsurf/skills' }); + super({ + editorSkillsDir: '.windsurf/skills', + editorName: 'windsurf', + }); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 178bd2a..ad8fb12 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,7 @@ export * from './mcp/index.js'; export * from './rules/index.js'; export * from './editors/index.js'; export * from './url-parsing.js'; +export * from './frontmatter-utils.js'; export * from './remote-loader.js'; export * from './merge.js'; export * from './prompts/index.js'; diff --git a/packages/core/src/rules/skill-rules.ts b/packages/core/src/rules/skill-rules.ts index 7a939fa..e7a52ad 100644 --- a/packages/core/src/rules/skill-rules.ts +++ b/packages/core/src/rules/skill-rules.ts @@ -6,7 +6,7 @@ import type { LoadedRule } from './loader.js'; /** * Generate a rule that points the AI to an installed skill. Skills are installed to - * `.aix/skills/{name}/` preserving their full directory structure (SKILL.md, scripts/, + * `.agents/skills/{name}/` preserving their full directory structure (SKILL.md, scripts/, * references/, etc.). This rule tells the AI where to find the skill and what it does. */ export async function loadSkillRules(skill: ParsedSkill, _editor?: string): Promise { @@ -15,19 +15,19 @@ export async function loadSkillRules(skill: ParsedSkill, _editor?: string): Prom description = skill.frontmatter.description || 'No description provided'; // Generate a pointer rule that tells the AI about this skill - // The actual skill content lives in .aix/skills/{name}/ and preserves the full structure + // The actual skill content lives in .agents/skills/{name}/ and preserves the full structure // Note: The rule name is added as a heading by the editor adapter, so we start with description const pointerContent = `${description} ## Location -This skill is installed at \`.aix/skills/${skillName}/\`. Read the \`SKILL.md\` file there for full instructions. +This skill is installed at \`.agents/skills/${skillName}/\`. Read the \`SKILL.md\` file there for full instructions. ## Quick Reference -- **Instructions**: \`.aix/skills/${skillName}/SKILL.md\` -- **Scripts**: \`.aix/skills/${skillName}/scripts/\` (if available) -- **References**: \`.aix/skills/${skillName}/references/\` (if available) +- **Instructions**: \`.agents/skills/${skillName}/SKILL.md\` +- **Scripts**: \`.agents/skills/${skillName}/scripts/\` (if available) +- **References**: \`.agents/skills/${skillName}/references/\` (if available) When you need to use this skill, read the SKILL.md file for detailed instructions.`; diff --git a/packages/core/src/url-parsing.ts b/packages/core/src/url-parsing.ts index b6c4ba7..e083ed2 100644 --- a/packages/core/src/url-parsing.ts +++ b/packages/core/src/url-parsing.ts @@ -269,16 +269,43 @@ export function bitbucketBlobToRaw(url: string): string | undefined { } /** - * Convert any supported git host blob URL to a raw URL. Returns the original URL if not a recognized blob URL. + * Convert a GitHub tree URL to a raw content URL by assuming the directory might contain a file. + * NOTE: This is speculative as directories don't have "raw" content, but used for SKILL.md discovery. + * `https://github.com/org/repo/tree/main/path` → `https://raw.githubusercontent.com/org/repo/main/path` + */ +export function githubTreeToRaw(url: string): string | undefined { + const parsed = parseGitHubTreeUrl(url); + + if (!parsed) { + return undefined; + } + return `https://raw.githubusercontent.com/${parsed.owner}/${parsed.repo}/${parsed.ref}/${parsed.subdir}`; +} + +/** + * Convert a GitLab tree URL to a raw content URL. + * `https://gitlab.com/group/project/-/tree/main/path` → `https://gitlab.com/group/project/-/raw/main/path` + */ +export function gitlabTreeToRaw(url: string): string | undefined { + const parsed = parseGitLabTreeUrl(url); + + if (!parsed) { + return undefined; + } + return `https://gitlab.com/${parsed.group}/${parsed.project}/-/raw/${parsed.ref}/${parsed.subdir}`; +} + +/** + * Convert any supported git host blob or tree URL to a raw URL. Returns the original URL if not recognized. */ export function convertBlobToRawUrl(url: string): string { - const githubRaw = githubBlobToRaw(url); + const githubRaw = githubBlobToRaw(url) ?? githubTreeToRaw(url); if (githubRaw) { return githubRaw; } - const gitlabRaw = gitlabBlobToRaw(url); + const gitlabRaw = gitlabBlobToRaw(url) ?? gitlabTreeToRaw(url); if (gitlabRaw) { return gitlabRaw; diff --git a/packages/schema/schema.json b/packages/schema/schema.json index 11f4a9b..d95047f 100644 --- a/packages/schema/schema.json +++ b/packages/schema/schema.json @@ -1277,6 +1277,13 @@ "pre_mcp_tool", "post_mcp_tool", "pre_prompt", + "pre_compact", + "post_compact", + "subagent_start", + "subagent_stop", + "task_created", + "task_completed", + "worktree_setup", "session_start", "session_end", "agent_stop" diff --git a/packages/schema/src/hooks.ts b/packages/schema/src/hooks.ts index bab892f..622b0e1 100644 --- a/packages/schema/src/hooks.ts +++ b/packages/schema/src/hooks.ts @@ -17,6 +17,13 @@ export const hookEventSchema = z 'pre_mcp_tool', 'post_mcp_tool', 'pre_prompt', + 'pre_compact', + 'post_compact', + 'subagent_start', + 'subagent_stop', + 'task_created', + 'task_completed', + 'worktree_setup', 'session_start', 'session_end', 'agent_stop', diff --git a/packages/site/src/content/docs/editors/supported-editors.md b/packages/site/src/content/docs/editors/supported-editors.md index e1d3c7e..b94c8e2 100644 --- a/packages/site/src/content/docs/editors/supported-editors.md +++ b/packages/site/src/content/docs/editors/supported-editors.md @@ -28,7 +28,7 @@ How `ai.json` concepts map to each editor: - **MCP**: `.cursor/mcp.json`. - **Prompts**: `.cursor/prompts/`. - **Skills**: `.aix/skills/{name}/` with symlinks from `.cursor/skills/`. -- **Hooks**: `.cursor/hooks.json`. Supports `pre_tool_use`, `post_tool_use`, `pre_file_read`, `pre_command`, `post_command`, `pre_mcp_tool`, `post_mcp_tool`, `post_file_write`, `pre_prompt`, `session_start`, `session_end`, and `agent_stop`. +- **Hooks**: `.cursor/hooks.json`. Supports `sessionStart`, `sessionEnd`, `preToolUse`, `postToolUse`, `beforeReadFile`, `beforeShellExecution`, `afterShellExecution`, `beforeMCPExecution`, `afterMCPExecution`, `afterFileEdit`, `beforeSubmitPrompt`, and `stop`. ### GitHub Copilot @@ -36,7 +36,7 @@ How `ai.json` concepts map to each editor: - **MCP**: `.vscode/mcp.json`. - **Prompts**: `.github/prompts/*.prompt.md`. - **Skills**: `.aix/skills/{name}/` with symlinks from `.github/skills/`. -- **Hooks**: `.github/hooks/*.json`. Supports `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `SubagentStart`, `SubagentStop`, and `Stop`. +- **Hooks**: `.github/hooks/*.json`. Supports `sessionStart`, `sessionEnd`, `userPromptSubmitted`, `preToolUse`, `postToolUse`, `preCompact`, `subagentStart`, `subagentStop`, and `stop`. ### Claude Code @@ -44,7 +44,7 @@ How `ai.json` concepts map to each editor: - **MCP**: `.mcp.json` at project root. - **Prompts**: `.claude/commands/`. - **Skills**: `.aix/skills/{name}/` with symlinks from `.claude/skills/`. -- **Hooks**: `.claude/settings.json`. Supports `pre_tool_use`, `post_tool_use`, `pre_file_read`, `post_file_read`, `pre_file_write`, `post_file_write`, `pre_command`, `post_command`, `pre_mcp_tool`, `post_mcp_tool`, `pre_prompt`, `session_start`, `session_end`, and `agent_stop`. +- **Hooks**: `.claude/settings.json`. Supports `SessionStart`, `SessionEnd`, `InstructionsLoaded`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PermissionDenied`, `PostToolUse`, `PostToolUseFailure`, `Notification`, `SubagentStart`, `SubagentStop`, `TaskCreated`, `TaskCompleted`, `Stop`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `Elicitation`, and `ElicitationResult`. ### Windsurf @@ -52,7 +52,7 @@ How `ai.json` concepts map to each editor: - **MCP**: Global config at `~/.codeium/windsurf/mcp_config.json`. - **Prompts**: Cascade prompts. - **Skills**: `.aix/skills/{name}/` with symlinks from `.windsurf/skills/`. -- **Hooks**: `.windsurf/hooks.json`. Supports `pre_file_read`, `post_file_read`, `pre_file_write`, `post_file_write`, `pre_command`, `post_command`, `pre_mcp_tool`, `post_mcp_tool`, `pre_prompt`, and `agent_stop`. +- **Hooks**: `.windsurf/hooks.json`. Supports `pre_read_code`, `post_read_code`, `pre_write_code`, `post_write_code`, `pre_run_command`, `post_run_command`, `pre_mcp_tool_use`, `post_mcp_tool_use`, `pre_user_prompt`, `post_cascade_response`, `post_cascade_response_with_transcript`, and `post_setup_worktree`. ### Zed diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..67da893 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "mastering-typescript": { + "source": "spillwavesolutions/mastering-typescript-skill", + "sourceType": "github", + "computedHash": "1cba0ac27ae3ceb517b93bb6ccf053607b8ba939a1c1c9d4716eed7e4d9175b2" + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 9804f00..2c1c4b7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "lib": ["ES2023"], "moduleResolution": "bundler", + "rewriteRelativeImportExtensions": true, "jsx": "react-jsx", "resolveJsonModule": true, "allowJs": false,