[
  {
    "path": ".github/ISSUE_TEMPLATE/agent-request.yml",
    "content": "name: Agent Request\ndescription: Request support for a new coding agent\ntitle: \"[Agent]: \"\nlabels: [\"enhancement\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for requesting a new agent! Please provide the details below.\n\n  - type: input\n    id: agent-name\n    attributes:\n      label: Agent Name\n      description: The name of the coding agent\n      placeholder: e.g., Cursor, Claude Code\n    validations:\n      required: true\n\n  - type: input\n    id: agent-url\n    attributes:\n      label: Skills Documentation URL\n      description: Link to the agent's skills documentation\n      placeholder: https://example.com/docs/skills\n    validations:\n      required: true\n\n  - type: input\n    id: skills-dir\n    attributes:\n      label: Project Skills Directory\n      description: Where skills are stored at the project level\n      placeholder: e.g., .cursor/skills\n    validations:\n      required: true\n\n  - type: input\n    id: global-skills-dir\n    attributes:\n      label: Global Skills Directory\n      description: Where skills are stored at the user/global level\n      placeholder: e.g., ~/.cursor/skills\n    validations:\n      required: true\n\n  - type: input\n    id: detection-path\n    attributes:\n      label: Detection Path\n      description: Path to check if the agent is installed (usually a config directory)\n      placeholder: e.g., ~/.cursor\n    validations:\n      required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.yml",
    "content": "name: Bug Report\ndescription: Report a bug or issue\ntitle: \"[Bug]: \"\nlabels: [\"bug\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for reporting a bug! Please provide as much detail as possible.\n\n  - type: textarea\n    id: description\n    attributes:\n      label: Description\n      description: A clear description of the bug\n      placeholder: What happened?\n    validations:\n      required: true\n\n  - type: textarea\n    id: steps\n    attributes:\n      label: Steps to Reproduce\n      description: How can we reproduce this issue?\n      placeholder: |\n        1. Run `npx add-skill ...`\n        2. Select ...\n        3. See error\n    validations:\n      required: true\n\n  - type: textarea\n    id: expected\n    attributes:\n      label: Expected Behavior\n      description: What did you expect to happen?\n    validations:\n      required: true\n\n  - type: textarea\n    id: actual\n    attributes:\n      label: Actual Behavior\n      description: What actually happened?\n    validations:\n      required: true\n\n  - type: input\n    id: version\n    attributes:\n      label: Version\n      description: What version of add-skill are you using?\n      placeholder: e.g., 1.0.8\n    validations:\n      required: false\n\n  - type: input\n    id: node-version\n    attributes:\n      label: Node.js Version\n      description: What version of Node.js are you using?\n      placeholder: e.g., 20.10.0\n    validations:\n      required: false\n\n  - type: dropdown\n    id: os\n    attributes:\n      label: Operating System\n      options:\n        - macOS\n        - Windows\n        - Linux\n        - Other\n    validations:\n      required: false\n\n  - type: textarea\n    id: logs\n    attributes:\n      label: Logs / Error Output\n      description: Paste any relevant error messages or logs\n      render: shell\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: true\ncontact_links:\n  - name: Documentation\n    url: https://github.com/vercel-labs/add-skill#readme\n    about: Check the README for usage instructions\n  - name: Agent Skills Specification\n    url: https://agentskills.io/home\n    about: Learn about the Agent Skills specification\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-request.yml",
    "content": "name: Feature Request\ndescription: Suggest a new feature or improvement\ntitle: \"[Feature]: \"\nlabels: [\"enhancement\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for suggesting a feature! Please describe your idea below.\n\n  - type: textarea\n    id: problem\n    attributes:\n      label: Problem\n      description: What problem does this feature solve?\n      placeholder: I'm always frustrated when...\n    validations:\n      required: true\n\n  - type: textarea\n    id: solution\n    attributes:\n      label: Proposed Solution\n      description: How would you like this to work?\n      placeholder: Describe your ideal solution\n    validations:\n      required: true\n\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: Alternatives Considered\n      description: Have you considered any alternative solutions or workarounds?\n    validations:\n      required: false\n\n  - type: textarea\n    id: context\n    attributes:\n      label: Additional Context\n      description: Any other context, screenshots, or examples\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/RELEASE_TEMPLATE.md",
    "content": "## Changelog\n\n${CHANGELOG}\n\n## Contributors\n\n${CONTRIBUTORS}\n"
  },
  {
    "path": ".github/workflows/agents.yml",
    "content": "name: Agents CI\n\non:\n  pull_request:\n    paths:\n      - \"src/agents.ts\"\n  push:\n    branches: [main]\n    paths:\n      - \"src/agents.ts\"\n\nconcurrency:\n  group: agents-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  validate-agents:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - name: Enable Corepack\n        run: corepack enable\n\n      - name: Enable Corepack\n        run: corepack enable\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: lts/*\n          cache: pnpm\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Validate agents\n        run: node scripts/validate-agents.ts\n\n  sync-agents:\n    needs: validate-agents\n    if: github.event_name == 'push' && github.ref == 'refs/heads/main'\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - name: Enable Corepack\n        run: corepack enable\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: lts/*\n          cache: pnpm\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Sync agents\n        run: pnpm exec node scripts/sync-agents.ts\n\n      - name: Commit changes\n        run: |\n          git config --local user.email \"github-actions[bot]@users.noreply.github.com\"\n          git config --local user.name \"github-actions[bot]\"\n          git diff --quiet README.md package.json || (git add README.md package.json && git commit -m \"chore: update README and package.json with latest agents\")\n\n      - name: Push changes\n        run: git push\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  pull_request:\n    paths-ignore:\n      - \"**/*.md\"\n  push:\n    branches: [main]\n    paths-ignore:\n      - \"**/*.md\"\n\njobs:\n  checks:\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest]\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - name: Checkout repo\n        uses: actions/checkout@v6\n\n      - name: Enable Corepack\n        run: corepack enable\n\n      - name: Setup Node\n        uses: actions/setup-node@v6\n        with:\n          node-version: lts/*\n          cache: pnpm\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Type check\n        run: pnpm build\n\n      - name: Prettier\n        if: matrix.os == 'ubuntu-latest'\n        run: pnpm format:check\n\n      - name: Tests\n        run: pnpm test\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish\n\non:\n  push:\n    branches: [main]\n    tags:\n      - 'v*'\n    paths-ignore:\n      - '**/*.md'\n  workflow_dispatch:\n    inputs:\n      bump:\n        description: 'Version bump type'\n        required: true\n        type: choice\n        options:\n          - patch\n          - minor\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      id-token: write\n\n    steps:\n      - name: Checkout repo\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n          token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Enable Corepack\n        run: corepack enable\n\n      - name: Setup Node\n        uses: actions/setup-node@v6\n        with:\n          node-version: lts/*\n          cache: pnpm\n          registry-url: 'https://registry.npmjs.org'\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile\n\n      - name: Build\n        run: pnpm build\n\n      - name: Determine version bump type\n        id: version\n        run: |\n          # If tag push, skip version bump (already versioned)\n          if [[ \"${{ github.ref }}\" == refs/tags/v* ]]; then\n            echo \"bump=skip\" >> $GITHUB_OUTPUT\n            echo \"Tag push - skipping version bump, will publish directly\"\n            exit 0\n          fi\n\n          # If manually triggered, use the input value\n          if [ \"${{ github.event_name }}\" = \"workflow_dispatch\" ]; then\n            echo \"bump=${{ inputs.bump }}\" >> $GITHUB_OUTPUT\n            echo \"Manual trigger - will bump ${{ inputs.bump }} version\"\n            exit 0\n          fi\n\n          # Get the latest version tag\n          LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo \"\")\n\n          if [ -z \"$LATEST_TAG\" ]; then\n            # No tags found, use all commits\n            COMMITS=$(git log --format=%s)\n          else\n            # Get commits since the latest tag\n            COMMITS=$(git log ${LATEST_TAG}..HEAD --format=%s)\n          fi\n\n          echo \"Commits since last tag:\"\n          echo \"$COMMITS\"\n\n          # Check for explicit version bump markers\n          if echo \"$COMMITS\" | grep -q \"\\[minor\\]\"; then\n            echo \"bump=minor\" >> $GITHUB_OUTPUT\n            echo \"Found [minor] - will bump minor version\"\n          elif echo \"$COMMITS\" | grep -q \"\\[patch\\]\"; then\n            echo \"bump=patch\" >> $GITHUB_OUTPUT\n            echo \"Found [patch] - will bump patch version\"\n          else\n            echo \"bump=none\" >> $GITHUB_OUTPUT\n            echo \"No version marker found - skipping release\"\n          fi\n\n      - name: Configure git\n        if: steps.version.outputs.bump != 'none' && steps.version.outputs.bump != 'skip'\n        run: |\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n\n      - name: Reset generated files\n        if: steps.version.outputs.bump != 'none' && steps.version.outputs.bump != 'skip'\n        run: |\n          # Reset any changes to generated files (e.g., ThirdPartyNoticeText.txt)\n          # that may differ between local and CI builds\n          git checkout -- ThirdPartyNoticeText.txt || true\n\n      - name: Bump version\n        if: steps.version.outputs.bump != 'none' && steps.version.outputs.bump != 'skip'\n        run: |\n          npm version ${{ steps.version.outputs.bump }} -m \"v%s\"\n\n      - name: Push changes\n        if: steps.version.outputs.bump != 'none' && steps.version.outputs.bump != 'skip'\n        run: |\n          git push\n          git push --tags\n\n      - name: Publish to npm\n        id: publish\n        if: steps.version.outputs.bump != 'none'\n        run: npm publish --provenance --access public\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n\n      - name: Create GitHub Release\n        if: steps.publish.outcome == 'success'\n        run: |\n          VERSION=$(node -p \"require('./package.json').version\")\n\n          # Get changelog from merged PRs since last tag\n          LATEST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo \"\")\n          if [ -z \"$LATEST_TAG\" ]; then\n            SINCE=\"\"\n          else\n            SINCE=$(git log -1 --format=%cI ${LATEST_TAG})\n          fi\n\n          # Fetch merged PRs and format as changelog\n          if [ -z \"$SINCE\" ]; then\n            PRS=$(gh pr list --state merged --json number,title,author --limit 100)\n          else\n            PRS=$(gh pr list --state merged --search \"merged:>=${SINCE}\" --json number,title,author --limit 100)\n          fi\n\n          CHANGELOG=$(echo \"$PRS\" | jq -r '.[] | \"- \\(.title) (#\\(.number))\"')\n          CONTRIBUTORS=$(echo \"$PRS\" | jq -r '.[].author.login' | sort -u | sed 's/^/@/' | paste -sd ', ' -)\n\n          # Generate release notes from template\n          export VERSION CHANGELOG CONTRIBUTORS\n          envsubst < .github/RELEASE_TEMPLATE.md > release-notes.md\n\n          gh release create \"v${VERSION}\" --notes-file release-notes.md\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# dependencies (bun install)\nnode_modules\n\n# output\nout\ndist\n*.tgz\n\n# code coverage\ncoverage\n*.lcov\n\n# logs\nlogs\n_.log\nreport.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json\n\n# dotenv environment variable files\n.env\n.env.development.local\n.env.test.local\n.env.production.local\n.env.local\n\n# caches\n.eslintcache\n.cache\n*.tsbuildinfo\n\n# IntelliJ based IDEs\n.idea\n\n# Finder (MacOS) folder config\n.DS_Store\n\n*.log\npackage-lock.json\n.codebuddy/\n.agent/\n.agents/\n.claude/\n.cursor/\n.gemini/\n.github/skills/\n.opencode/\n.qoder/\n.qwen/\n.trae/\n.vscode/\n\nbin/_chunks/\nbin/cli-wrapper.mjs\nbin/cli.d.mts\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "pnpm lint-staged\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"trailingComma\": \"es5\",\n  \"printWidth\": 100,\n  \"tabWidth\": 2\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\nThis file provides guidance to AI coding agents working on the `skills` CLI codebase.\n\n## Project Overview\n\n`skills` is the CLI for the open agent skills ecosystem.\n\n## Commands\n\n| Command                       | Description                                         |\n| ----------------------------- | --------------------------------------------------- |\n| `skills`                      | Show banner with available commands                 |\n| `skills add <pkg>`            | Install skills from git repos, URLs, or local paths |\n| `skills experimental_install` | Restore skills from skills-lock.json                |\n| `skills experimental_sync`    | Sync skills from node_modules into agent dirs       |\n| `skills list`                 | List installed skills (alias: `ls`)                 |\n| `skills check`                | Check for available skill updates                   |\n| `skills update`               | Update all skills to latest versions                |\n| `skills init [name]`          | Create a new SKILL.md template                      |\n\nAliases: `skills a` works for `add`. `skills i`, `skills install` (no args) restore from `skills-lock.json`. `skills ls` works for `list`. `skills experimental_install` restores from `skills-lock.json`. `skills experimental_sync` crawls `node_modules` for skills.\n\n## Architecture\n\n```\nsrc/\n├── cli.ts           # Main entry point, command routing, init/check/update\n├── cli.test.ts      # CLI tests\n├── add.ts           # Core add command logic\n├── add-prompt.test.ts # Add prompt behavior tests\n├── add.test.ts      # Add command tests\n├── constants.ts      # Shared constants\n├── find.ts           # Find/search command\n├── list.ts          # List installed skills command\n├── list.test.ts     # List command tests\n├── remove.ts         # Remove command implementation\n├── remove.test.ts    # Remove command tests\n├── agents.ts        # Agent definitions and detection\n├── installer.ts     # Skill installation logic (symlink/copy) + listInstalledSkills\n├── skills.ts        # Skill discovery and parsing\n├── skill-lock.ts    # Global lock file management (~/.agents/.skill-lock.json)\n├── local-lock.ts    # Local lock file management (skills-lock.json, checked in)\n├── sync.ts          # Sync command - crawl node_modules for skills\n├── source-parser.ts # Parse git URLs, GitHub shorthand, local paths\n├── git.ts           # Git clone operations\n├── telemetry.ts     # Anonymous usage tracking\n├── types.ts         # TypeScript types\n├── mintlify.ts      # Mintlify skill fetching (legacy)\n├── plugin-manifest.ts # Plugin manifest discovery support\n├── prompts/         # Interactive prompt helpers\n│   └── search-multiselect.ts\n├── providers/       # Remote skill providers (GitHub, HuggingFace, Mintlify)\n│   ├── index.ts\n│   ├── registry.ts\n│   ├── types.ts\n│   ├── huggingface.ts\n│   ├── mintlify.ts\n│   └── wellknown.ts\n├── init.test.ts     # Init command tests\n└── test-utils.ts    # Test utilities\n\ntests/\n├── cross-platform-paths.test.ts # Path normalization across platforms\n├── full-depth-discovery.test.ts # --full-depth skill discovery tests\n├── openclaw-paths.test.ts       # OpenClaw-specific path tests\n├── plugin-manifest-discovery.test.ts # Plugin manifest skill discovery\n├── sanitize-name.test.ts     # Tests for sanitizeName (path traversal prevention)\n├── skill-matching.test.ts    # Tests for filterSkills (multi-word skill name matching)\n├── source-parser.test.ts     # Tests for URL/path parsing\n├── installer-symlink.test.ts # Tests for symlink installation\n├── list-installed.test.ts    # Tests for listing installed skills\n├── skill-path.test.ts        # Tests for skill path handling\n├── wellknown-provider.test.ts # Tests for well-known provider\n├── xdg-config-paths.test.ts   # XDG global path handling tests\n└── dist.test.ts               # Tests for built distribution\n```\n\n## Update Checking System\n\n### How `skills check` and `skills update` Work\n\n1. Read `~/.agents/.skill-lock.json` for installed skills\n2. Filter to GitHub-backed skills that have both `skillFolderHash` and `skillPath`\n3. For each skill, call `fetchSkillFolderHash(source, skillPath, token)`. Optional auth token is sourced from `GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token` to improve rate limits.\n4. `fetchSkillFolderHash` calls GitHub Trees API directly (`/git/trees/<branch>?recursive=1` for `main`, then `master` fallback)\n5. Compare latest folder tree SHA with lock file `skillFolderHash`; mismatch means update available\n6. `skills update` reinstalls changed skills by invoking the current CLI entrypoint directly (`node <repo>/bin/cli.mjs add <source-tree-url> -g -y`) to avoid nested npm exec/npx behavior\n\n### Lock File Compatibility\n\nThe lock file format is v3. Key field: `skillFolderHash` (GitHub tree SHA for the skill folder).\n\nIf reading an older lock file version, it's wiped. Users must reinstall skills to populate the new format.\n\n## Key Integration Points\n\n| Feature                    | Implementation                                                |\n| -------------------------- | ------------------------------------------------------------- |\n| `skills add`               | `src/add.ts` - full implementation                            |\n| `skills experimental_sync` | `src/sync.ts` - crawl node_modules                            |\n| `skills check`             | `src/cli.ts` + `fetchSkillFolderHash` in `src/skill-lock.ts`  |\n| `skills update`            | `src/cli.ts` direct hash compare + reinstall via `skills add` |\n\n## Development\n\n```bash\n# Install dependencies\npnpm install\n\n# Build\npnpm build\n\n# Test locally\npnpm dev add vercel-labs/agent-skills --list\npnpm dev experimental_sync\npnpm dev check\npnpm dev update\npnpm dev init my-skill\n\n# Run all tests\npnpm test\n\n# Run specific test file(s)\npnpm test tests/sanitize-name.test.ts\npnpm test tests/skill-matching.test.ts tests/source-parser.test.ts\n\n# Type check\npnpm type-check\n\n# Format code\npnpm format\n\n# Check formatting\npnpm format:check\n\n# Validate and sync agent metadata/docs\npnpm run -C scripts validate-agents.ts\npnpm run -C scripts sync-agents.ts\n```\n\n## Code Style\n\nThis project uses Prettier for code formatting. **Always run `pnpm format` before committing changes** to ensure consistent formatting.\n\n```bash\n# Format all files\npnpm format\n\n# Check formatting without fixing\npnpm format:check\n```\n\nCI will fail if code is not properly formatted.\n\n## Publishing\n\n```bash\n# 1. Bump version in package.json\n# 2. Build\npnpm build\n# 3. Publish\nnpm publish\n```\n\n## Adding a New Agent\n\n1. Add the agent definition to `src/agents.ts`\n2. Run `pnpm run -C scripts validate-agents.ts` to validate\n3. Run `pnpm run -C scripts sync-agents.ts` to update README.md and package keywords\n"
  },
  {
    "path": "README.md",
    "content": "# skills\n\nThe CLI for the open agent skills ecosystem.\n\n<!-- agent-list:start -->\nSupports **OpenCode**, **Claude Code**, **Codex**, **Cursor**, and [39 more](#available-agents).\n<!-- agent-list:end -->\n\n## Install a Skill\n\n```bash\nnpx skills add vercel-labs/agent-skills\n```\n\n### Source Formats\n\n```bash\n# GitHub shorthand (owner/repo)\nnpx skills add vercel-labs/agent-skills\n\n# Full GitHub URL\nnpx skills add https://github.com/vercel-labs/agent-skills\n\n# Direct path to a skill in a repo\nnpx skills add https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines\n\n# GitLab URL\nnpx skills add https://gitlab.com/org/repo\n\n# Any git URL\nnpx skills add git@github.com:vercel-labs/agent-skills.git\n\n# Local path\nnpx skills add ./my-local-skills\n```\n\n### Options\n\n| Option                    | Description                                                                                                                                        |\n| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `-g, --global`            | Install to user directory instead of project                                                                                                       |\n| `-a, --agent <agents...>` | <!-- agent-names:start -->Target specific agents (e.g., `claude-code`, `codex`). See [Available Agents](#available-agents)<!-- agent-names:end --> |\n| `-s, --skill <skills...>` | Install specific skills by name (use `'*'` for all skills)                                                                                         |\n| `-l, --list`              | List available skills without installing                                                                                                           |\n| `--copy`                  | Copy files instead of symlinking to agent directories                                                                                              |\n| `-y, --yes`               | Skip all confirmation prompts                                                                                                                      |\n| `--all`                   | Install all skills to all agents without prompts                                                                                                   |\n\n### Examples\n\n```bash\n# List skills in a repository\nnpx skills add vercel-labs/agent-skills --list\n\n# Install specific skills\nnpx skills add vercel-labs/agent-skills --skill frontend-design --skill skill-creator\n\n# Install a skill with spaces in the name (must be quoted)\nnpx skills add owner/repo --skill \"Convex Best Practices\"\n\n# Install to specific agents\nnpx skills add vercel-labs/agent-skills -a claude-code -a opencode\n\n# Non-interactive installation (CI/CD friendly)\nnpx skills add vercel-labs/agent-skills --skill frontend-design -g -a claude-code -y\n\n# Install all skills from a repo to all agents\nnpx skills add vercel-labs/agent-skills --all\n\n# Install all skills to specific agents\nnpx skills add vercel-labs/agent-skills --skill '*' -a claude-code\n\n# Install specific skills to all agents\nnpx skills add vercel-labs/agent-skills --agent '*' --skill frontend-design\n```\n\n### Installation Scope\n\n| Scope       | Flag      | Location            | Use Case                                      |\n| ----------- | --------- | ------------------- | --------------------------------------------- |\n| **Project** | (default) | `./<agent>/skills/` | Committed with your project, shared with team |\n| **Global**  | `-g`      | `~/<agent>/skills/` | Available across all projects                 |\n\n### Installation Methods\n\nWhen installing interactively, you can choose:\n\n| Method                    | Description                                                                                 |\n| ------------------------- | ------------------------------------------------------------------------------------------- |\n| **Symlink** (Recommended) | Creates symlinks from each agent to a canonical copy. Single source of truth, easy updates. |\n| **Copy**                  | Creates independent copies for each agent. Use when symlinks aren't supported.              |\n\n## Other Commands\n\n| Command                      | Description                                    |\n| ---------------------------- | ---------------------------------------------- |\n| `npx skills list`            | List installed skills (alias: `ls`)            |\n| `npx skills find [query]`    | Search for skills interactively or by keyword  |\n| `npx skills remove [skills]` | Remove installed skills from agents            |\n| `npx skills check`           | Check for available skill updates              |\n| `npx skills update`          | Update all installed skills to latest versions |\n| `npx skills init [name]`     | Create a new SKILL.md template                 |\n\n### `skills list`\n\nList all installed skills. Similar to `npm ls`.\n\n```bash\n# List all installed skills (project and global)\nnpx skills list\n\n# List only global skills\nnpx skills ls -g\n\n# Filter by specific agents\nnpx skills ls -a claude-code -a cursor\n```\n\n### `skills find`\n\nSearch for skills interactively or by keyword.\n\n```bash\n# Interactive search (fzf-style)\nnpx skills find\n\n# Search by keyword\nnpx skills find typescript\n```\n\n### `skills check` / `skills update`\n\n```bash\n# Check if any installed skills have updates\nnpx skills check\n\n# Update all skills to latest versions\nnpx skills update\n```\n\n### `skills init`\n\n```bash\n# Create SKILL.md in current directory\nnpx skills init\n\n# Create a new skill in a subdirectory\nnpx skills init my-skill\n```\n\n### `skills remove`\n\nRemove installed skills from agents.\n\n```bash\n# Remove interactively (select from installed skills)\nnpx skills remove\n\n# Remove specific skill by name\nnpx skills remove web-design-guidelines\n\n# Remove multiple skills\nnpx skills remove frontend-design web-design-guidelines\n\n# Remove from global scope\nnpx skills remove --global web-design-guidelines\n\n# Remove from specific agents only\nnpx skills remove --agent claude-code cursor my-skill\n\n# Remove all installed skills without confirmation\nnpx skills remove --all\n\n# Remove all skills from a specific agent\nnpx skills remove --skill '*' -a cursor\n\n# Remove a specific skill from all agents\nnpx skills remove my-skill --agent '*'\n\n# Use 'rm' alias\nnpx skills rm my-skill\n```\n\n| Option         | Description                                      |\n| -------------- | ------------------------------------------------ |\n| `-g, --global` | Remove from global scope (~/) instead of project |\n| `-a, --agent`  | Remove from specific agents (use `'*'` for all)  |\n| `-s, --skill`  | Specify skills to remove (use `'*'` for all)     |\n| `-y, --yes`    | Skip confirmation prompts                        |\n| `--all`        | Shorthand for `--skill '*' --agent '*' -y`       |\n\n## What are Agent Skills?\n\nAgent skills are reusable instruction sets that extend your coding agent's capabilities. They're defined in `SKILL.md`\nfiles with YAML frontmatter containing a `name` and `description`.\n\nSkills let agents perform specialized tasks like:\n\n- Generating release notes from git history\n- Creating PRs following your team's conventions\n- Integrating with external tools (Linear, Notion, etc.)\n\nDiscover skills at **[skills.sh](https://skills.sh)**\n\n## Supported Agents\n\nSkills can be installed to any of these agents:\n\n<!-- supported-agents:start -->\n| Agent | `--agent` | Project Path | Global Path |\n|-------|-----------|--------------|-------------|\n| Amp, Kimi Code CLI, Replit, Universal | `amp`, `kimi-cli`, `replit`, `universal` | `.agents/skills/` | `~/.config/agents/skills/` |\n| Antigravity | `antigravity` | `.agents/skills/` | `~/.gemini/antigravity/skills/` |\n| Augment | `augment` | `.augment/skills/` | `~/.augment/skills/` |\n| Claude Code | `claude-code` | `.claude/skills/` | `~/.claude/skills/` |\n| OpenClaw | `openclaw` | `skills/` | `~/.openclaw/skills/` |\n| Cline, Warp | `cline`, `warp` | `.agents/skills/` | `~/.agents/skills/` |\n| CodeBuddy | `codebuddy` | `.codebuddy/skills/` | `~/.codebuddy/skills/` |\n| Codex | `codex` | `.agents/skills/` | `~/.codex/skills/` |\n| Command Code | `command-code` | `.commandcode/skills/` | `~/.commandcode/skills/` |\n| Continue | `continue` | `.continue/skills/` | `~/.continue/skills/` |\n| Cortex Code | `cortex` | `.cortex/skills/` | `~/.snowflake/cortex/skills/` |\n| Crush | `crush` | `.crush/skills/` | `~/.config/crush/skills/` |\n| Cursor | `cursor` | `.agents/skills/` | `~/.cursor/skills/` |\n| Deep Agents | `deepagents` | `.agents/skills/` | `~/.deepagents/agent/skills/` |\n| Droid | `droid` | `.factory/skills/` | `~/.factory/skills/` |\n| Gemini CLI | `gemini-cli` | `.agents/skills/` | `~/.gemini/skills/` |\n| GitHub Copilot | `github-copilot` | `.agents/skills/` | `~/.copilot/skills/` |\n| Goose | `goose` | `.goose/skills/` | `~/.config/goose/skills/` |\n| Junie | `junie` | `.junie/skills/` | `~/.junie/skills/` |\n| iFlow CLI | `iflow-cli` | `.iflow/skills/` | `~/.iflow/skills/` |\n| Kilo Code | `kilo` | `.kilocode/skills/` | `~/.kilocode/skills/` |\n| Kiro CLI | `kiro-cli` | `.kiro/skills/` | `~/.kiro/skills/` |\n| Kode | `kode` | `.kode/skills/` | `~/.kode/skills/` |\n| MCPJam | `mcpjam` | `.mcpjam/skills/` | `~/.mcpjam/skills/` |\n| Mistral Vibe | `mistral-vibe` | `.vibe/skills/` | `~/.vibe/skills/` |\n| Mux | `mux` | `.mux/skills/` | `~/.mux/skills/` |\n| OpenCode | `opencode` | `.agents/skills/` | `~/.config/opencode/skills/` |\n| OpenHands | `openhands` | `.openhands/skills/` | `~/.openhands/skills/` |\n| Pi | `pi` | `.pi/skills/` | `~/.pi/agent/skills/` |\n| Qoder | `qoder` | `.qoder/skills/` | `~/.qoder/skills/` |\n| Qwen Code | `qwen-code` | `.qwen/skills/` | `~/.qwen/skills/` |\n| Roo Code | `roo` | `.roo/skills/` | `~/.roo/skills/` |\n| Trae | `trae` | `.trae/skills/` | `~/.trae/skills/` |\n| Trae CN | `trae-cn` | `.trae/skills/` | `~/.trae-cn/skills/` |\n| Windsurf | `windsurf` | `.windsurf/skills/` | `~/.codeium/windsurf/skills/` |\n| Zencoder | `zencoder` | `.zencoder/skills/` | `~/.zencoder/skills/` |\n| Neovate | `neovate` | `.neovate/skills/` | `~/.neovate/skills/` |\n| Pochi | `pochi` | `.pochi/skills/` | `~/.pochi/skills/` |\n| AdaL | `adal` | `.adal/skills/` | `~/.adal/skills/` |\n<!-- supported-agents:end -->\n\n> [!NOTE]\n> **Kiro CLI users:** After installing skills, manually add them to your custom agent's `resources` in\n> `.kiro/agents/<agent>.json`:\n>\n> ```json\n> {\n>   \"resources\": [\"skill://.kiro/skills/**/SKILL.md\"]\n> }\n> ```\n\nThe CLI automatically detects which coding agents you have installed. If none are detected, you'll be prompted to select\nwhich agents to install to.\n\n## Creating Skills\n\nSkills are directories containing a `SKILL.md` file with YAML frontmatter:\n\n```markdown\n---\nname: my-skill\ndescription: What this skill does and when to use it\n---\n\n# My Skill\n\nInstructions for the agent to follow when this skill is activated.\n\n## When to Use\n\nDescribe the scenarios where this skill should be used.\n\n## Steps\n\n1. First, do this\n2. Then, do that\n```\n\n### Required Fields\n\n- `name`: Unique identifier (lowercase, hyphens allowed)\n- `description`: Brief explanation of what the skill does\n\n### Optional Fields\n\n- `metadata.internal`: Set to `true` to hide the skill from normal discovery. Internal skills are only visible and\n  installable when `INSTALL_INTERNAL_SKILLS=1` is set. Useful for work-in-progress skills or skills meant only for\n  internal tooling.\n\n```markdown\n---\nname: my-internal-skill\ndescription: An internal skill not shown by default\nmetadata:\n  internal: true\n---\n```\n\n### Skill Discovery\n\nThe CLI searches for skills in these locations within a repository:\n\n<!-- skill-discovery:start -->\n- Root directory (if it contains `SKILL.md`)\n- `skills/`\n- `skills/.curated/`\n- `skills/.experimental/`\n- `skills/.system/`\n- `.agents/skills/`\n- `.augment/skills/`\n- `.claude/skills/`\n- `./skills/`\n- `.codebuddy/skills/`\n- `.commandcode/skills/`\n- `.continue/skills/`\n- `.cortex/skills/`\n- `.crush/skills/`\n- `.factory/skills/`\n- `.goose/skills/`\n- `.junie/skills/`\n- `.iflow/skills/`\n- `.kilocode/skills/`\n- `.kiro/skills/`\n- `.kode/skills/`\n- `.mcpjam/skills/`\n- `.vibe/skills/`\n- `.mux/skills/`\n- `.openhands/skills/`\n- `.pi/skills/`\n- `.qoder/skills/`\n- `.qwen/skills/`\n- `.roo/skills/`\n- `.trae/skills/`\n- `.windsurf/skills/`\n- `.zencoder/skills/`\n- `.neovate/skills/`\n- `.pochi/skills/`\n- `.adal/skills/`\n<!-- skill-discovery:end -->\n\n### Plugin Manifest Discovery\n\nIf `.claude-plugin/marketplace.json` or `.claude-plugin/plugin.json` exists, skills declared in those files are also discovered:\n\n```json\n// .claude-plugin/marketplace.json\n{\n  \"metadata\": { \"pluginRoot\": \"./plugins\" },\n  \"plugins\": [\n    {\n      \"name\": \"my-plugin\",\n      \"source\": \"my-plugin\",\n      \"skills\": [\"./skills/review\", \"./skills/test\"]\n    }\n  ]\n}\n```\n\nThis enables compatibility with the [Claude Code plugin marketplace](https://code.claude.com/docs/en/plugin-marketplaces) ecosystem.\n\nIf no skills are found in standard locations, a recursive search is performed.\n\n## Compatibility\n\nSkills are generally compatible across agents since they follow a\nshared [Agent Skills specification](https://agentskills.io). However, some features may be agent-specific:\n\n| Feature         | OpenCode | OpenHands | Claude Code | Cline | CodeBuddy | Codex | Command Code | Kiro CLI | Cursor | Antigravity | Roo Code | Github Copilot | Amp | OpenClaw | Neovate | Pi  | Qoder | Zencoder |\n| --------------- | -------- | --------- | ----------- | ----- | --------- | ----- | ------------ | -------- | ------ | ----------- | -------- | -------------- | --- | -------- | ------- | --- | ----- | -------- |\n| Basic skills    | Yes      | Yes       | Yes         | Yes   | Yes       | Yes   | Yes          | Yes      | Yes    | Yes         | Yes      | Yes            | Yes | Yes      | Yes     | Yes | Yes   | Yes      |\n| `allowed-tools` | Yes      | Yes       | Yes         | Yes   | Yes       | Yes   | Yes          | No       | Yes    | Yes         | Yes      | Yes            | Yes | Yes      | Yes     | Yes | Yes   | No       |\n| `context: fork` | No       | No        | Yes         | No    | No        | No    | No           | No       | No     | No          | No       | No             | No  | No       | No      | No  | No    | No       |\n| Hooks           | No       | No        | Yes         | Yes   | No        | No    | No           | No       | No     | No          | No       | No             | No  | No       | No      | No  | No    | No       |\n\n## Troubleshooting\n\n### \"No skills found\"\n\nEnsure the repository contains valid `SKILL.md` files with both `name` and `description` in the frontmatter.\n\n### Skill not loading in agent\n\n- Verify the skill was installed to the correct path\n- Check the agent's documentation for skill loading requirements\n- Ensure the `SKILL.md` frontmatter is valid YAML\n\n### Permission errors\n\nEnsure you have write access to the target directory.\n\n## Environment Variables\n\n| Variable                  | Description                                                                |\n| ------------------------- | -------------------------------------------------------------------------- |\n| `INSTALL_INTERNAL_SKILLS` | Set to `1` or `true` to show and install skills marked as `internal: true` |\n| `DISABLE_TELEMETRY`       | Set to disable anonymous usage telemetry                                   |\n| `DO_NOT_TRACK`            | Alternative way to disable telemetry                                       |\n\n```bash\n# Install internal skills\nINSTALL_INTERNAL_SKILLS=1 npx skills add vercel-labs/agent-skills --list\n```\n\n## Telemetry\n\nThis CLI collects anonymous usage data to help improve the tool. No personal information is collected.\n\nTelemetry is automatically disabled in CI environments.\n\n## Related Links\n\n- [Agent Skills Specification](https://agentskills.io)\n- [Skills Directory](https://skills.sh)\n- [Amp Skills Documentation](https://ampcode.com/manual#agent-skills)\n- [Antigravity Skills Documentation](https://antigravity.google/docs/skills)\n- [Factory AI / Droid Skills Documentation](https://docs.factory.ai/cli/configuration/skills)\n- [Claude Code Skills Documentation](https://code.claude.com/docs/en/skills)\n- [OpenClaw Skills Documentation](https://docs.openclaw.ai/tools/skills)\n- [Cline Skills Documentation](https://docs.cline.bot/features/skills)\n- [CodeBuddy Skills Documentation](https://www.codebuddy.ai/docs/ide/Features/Skills)\n- [Codex Skills Documentation](https://developers.openai.com/codex/skills)\n- [Command Code Skills Documentation](https://commandcode.ai/docs/skills)\n- [Crush Skills Documentation](https://github.com/charmbracelet/crush?tab=readme-ov-file#agent-skills)\n- [Cursor Skills Documentation](https://cursor.com/docs/context/skills)\n- [Gemini CLI Skills Documentation](https://geminicli.com/docs/cli/skills/)\n- [GitHub Copilot Agent Skills](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills)\n- [iFlow CLI Skills Documentation](https://platform.iflow.cn/en/cli/examples/skill)\n- [Kimi Code CLI Skills Documentation](https://moonshotai.github.io/kimi-cli/en/customization/skills.html)\n- [Kiro CLI Skills Documentation](https://kiro.dev/docs/cli/custom-agents/configuration-reference/#skill-resources)\n- [Kode Skills Documentation](https://github.com/shareAI-lab/kode/blob/main/docs/skills.md)\n- [OpenCode Skills Documentation](https://opencode.ai/docs/skills)\n- [Qwen Code Skills Documentation](https://qwenlm.github.io/qwen-code-docs/en/users/features/skills/)\n- [OpenHands Skills Documentation](https://docs.openhands.ai/modules/usage/how-to/using-skills)\n- [Pi Skills Documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md)\n- [Qoder Skills Documentation](https://docs.qoder.com/cli/Skills)\n- [Replit Skills Documentation](https://docs.replit.com/replitai/skills)\n- [Roo Code Skills Documentation](https://docs.roocode.com/features/skills)\n- [Trae Skills Documentation](https://docs.trae.ai/ide/skills)\n- [Vercel Agent Skills Repository](https://github.com/vercel-labs/agent-skills)\n\n## License\n\nMIT\n"
  },
  {
    "path": "ThirdPartyNoticeText.txt",
    "content": "/*!----------------- Skills CLI ThirdPartyNotices -------------------------------------------------------\n\nThe Skills CLI incorporates third party material from the projects listed below.\nThe original copyright notice and the license under which this material was received\nare set forth below. These licenses and notices are provided for informational purposes only.\n\n---------------------------------------------\nThird Party Code Components\n--------------------------------------------\n\n================================================================================\nPackage: @clack/core@0.4.1\nLicense: MIT\nRepository: https://github.com/natemoo-re/clack\n--------------------------------------------------------------------------------\n\nMIT License\n\nCopyright (c) Nate Moore\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n\n\n================================================================================\nPackage: @clack/prompts@0.11.0\nLicense: MIT\nRepository: https://github.com/bombshell-dev/clack\n--------------------------------------------------------------------------------\n\nMIT License\n\nCopyright (c) Nate Moore\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n\n\n================================================================================\nPackage: gray-matter@4.0.3\nLicense: MIT\nRepository: https://github.com/jonschlinkert/gray-matter\n--------------------------------------------------------------------------------\n\nThe MIT License (MIT)\n\nCopyright (c) 2014-2018, Jon Schlinkert.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n\n================================================================================\nPackage: picocolors@1.1.1\nLicense: ISC\nRepository: https://github.com/alexeyraspopov/picocolors\n--------------------------------------------------------------------------------\n\nISC License\n\nCopyright (c) 2021-2024 Oleksii Raspopov, Kostiantyn Denysov, Anton Verinov\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\nOR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n\n\n================================================================================\nPackage: simple-git@3.30.0\nLicense: MIT\nRepository: https://github.com/steveukx/git-js\n--------------------------------------------------------------------------------\n\nMIT License\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n\n================================================================================\nPackage: sisteransi@1.0.5\nLicense: MIT\nRepository: https://github.com/terkelg/sisteransi\n--------------------------------------------------------------------------------\n\nMIT License\n\nCopyright (c) 2018 Terkel Gjervig Nielsen\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n\n================================================================================\nPackage: xdg-basedir@5.1.0\nLicense: MIT\nRepository: https://github.com/sindresorhus/xdg-basedir\n--------------------------------------------------------------------------------\n\nMIT License\n\nCopyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n\n\n================================================================================\n*/"
  },
  {
    "path": "bin/cli.mjs",
    "content": "#!/usr/bin/env node\n\nimport module from 'node:module';\n\n// https://nodejs.org/api/module.html#module-compile-cache\nif (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {\n  try {\n    module.enableCompileCache();\n  } catch {\n    // Ignore errors\n  }\n}\n\nawait import('../dist/cli.mjs');\n"
  },
  {
    "path": "build.config.mjs",
    "content": "import { defineBuildConfig } from 'obuild/config';\n\n// https://github.com/unjs/obuild\nexport default defineBuildConfig({\n  entries: [{ type: 'bundle', input: './src/cli.ts' }],\n});\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"skills\",\n  \"version\": \"1.4.5\",\n  \"description\": \"The open agent skills ecosystem\",\n  \"type\": \"module\",\n  \"bin\": {\n    \"skills\": \"./bin/cli.mjs\",\n    \"add-skill\": \"./bin/cli.mjs\"\n  },\n  \"files\": [\n    \"dist\",\n    \"bin\",\n    \"README.md\",\n    \"ThirdPartyNoticeText.txt\"\n  ],\n  \"scripts\": {\n    \"build\": \"node scripts/generate-licenses.ts && obuild\",\n    \"generate-licenses\": \"node scripts/generate-licenses.ts\",\n    \"dev\": \"node src/cli.ts\",\n    \"exec:test\": \"node scripts/execute-tests.ts\",\n    \"prepublishOnly\": \"npm run build\",\n    \"format\": \"prettier --write \\\"src/**/*.ts\\\" \\\"scripts/**/*.ts\\\"\",\n    \"format:check\": \"prettier --check \\\"src/**/*.ts\\\" \\\"scripts/**/*.ts\\\"\",\n    \"prepare\": \"husky\",\n    \"test\": \"vitest\",\n    \"type-check\": \"tsc --noEmit\",\n    \"publish:snapshot\": \"npm version prerelease --preid=snapshot --no-git-tag-version && npm publish --tag snapshot\"\n  },\n  \"lint-staged\": {\n    \"src/**/*.ts\": \"prettier --write\",\n    \"scripts/**/*.ts\": \"prettier --write\",\n    \"tests/**/*.ts\": \"prettier --write\"\n  },\n  \"keywords\": [\n    \"cli\",\n    \"agent-skills\",\n    \"skills\",\n    \"ai-agents\",\n    \"amp\",\n    \"antigravity\",\n    \"augment\",\n    \"claude-code\",\n    \"openclaw\",\n    \"cline\",\n    \"codebuddy\",\n    \"codex\",\n    \"command-code\",\n    \"continue\",\n    \"cortex\",\n    \"crush\",\n    \"cursor\",\n    \"deepagents\",\n    \"droid\",\n    \"gemini-cli\",\n    \"github-copilot\",\n    \"goose\",\n    \"junie\",\n    \"iflow-cli\",\n    \"kilo\",\n    \"kimi-cli\",\n    \"kiro-cli\",\n    \"kode\",\n    \"mcpjam\",\n    \"mistral-vibe\",\n    \"mux\",\n    \"opencode\",\n    \"openhands\",\n    \"pi\",\n    \"qoder\",\n    \"qwen-code\",\n    \"replit\",\n    \"roo\",\n    \"trae\",\n    \"trae-cn\",\n    \"warp\",\n    \"windsurf\",\n    \"zencoder\",\n    \"neovate\",\n    \"pochi\",\n    \"adal\",\n    \"universal\"\n  ],\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/vercel-labs/skills.git\"\n  },\n  \"homepage\": \"https://github.com/vercel-labs/skills#readme\",\n  \"bugs\": {\n    \"url\": \"https://github.com/vercel-labs/skills/issues\"\n  },\n  \"author\": \"\",\n  \"license\": \"MIT\",\n  \"devDependencies\": {\n    \"@clack/prompts\": \"^0.11.0\",\n    \"@types/bun\": \"latest\",\n    \"@types/node\": \"^22.10.0\",\n    \"gray-matter\": \"^4.0.3\",\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.7\",\n    \"obuild\": \"^0.4.22\",\n    \"picocolors\": \"^1.1.1\",\n    \"prettier\": \"^3.8.1\",\n    \"simple-git\": \"^3.27.0\",\n    \"typescript\": \"^5.9.3\",\n    \"vitest\": \"^4.0.17\",\n    \"xdg-basedir\": \"^5.1.0\"\n  },\n  \"engines\": {\n    \"node\": \">=18\"\n  },\n  \"packageManager\": \"pnpm@10.17.1\"\n}\n"
  },
  {
    "path": "scripts/execute-tests.ts",
    "content": "#!/usr/bin/env node\n\nimport { spawn } from 'node:child_process';\nimport { readdir } from 'node:fs/promises';\nimport path from 'node:path';\nimport process from 'node:process';\nimport { fileURLToPath } from 'node:url';\n\ntype RunOptions = {\n  rootDir: string;\n  testsDir: string;\n  filter?: RegExp;\n  listOnly: boolean;\n};\n\nfunction parseArgs(argv: string[], rootDir: string): RunOptions {\n  const testsDir = path.join(rootDir, 'tests');\n  let filter: RegExp | undefined;\n  let listOnly = false;\n\n  for (let i = 0; i < argv.length; i++) {\n    const arg = argv[i];\n    if (arg === '--list' || arg === '-l') {\n      listOnly = true;\n      continue;\n    }\n    if (arg === '--filter' || arg === '-f') {\n      const pattern = argv[i + 1];\n      if (!pattern) throw new Error('Missing value for --filter');\n      filter = new RegExp(pattern);\n      i++;\n      continue;\n    }\n    if (arg === '--help' || arg === '-h') {\n      console.log(\n        `Usage: node scripts/execute-tests.ts [options]\\n\\nOptions:\\n  -l, --list              List discovered test files and exit\\n  -f, --filter <regex>    Only run tests whose path matches regex\\n  -h, --help              Show help\\n`\n      );\n      process.exit(0);\n    }\n    throw new Error(`Unknown argument: ${arg}`);\n  }\n\n  return { rootDir, testsDir, filter, listOnly };\n}\n\nasync function findTestFiles(dir: string): Promise<string[]> {\n  const entries = await readdir(dir, { withFileTypes: true });\n  const files: string[] = [];\n\n  for (const entry of entries) {\n    const fullPath = path.join(dir, entry.name);\n    if (entry.isDirectory()) {\n      files.push(...(await findTestFiles(fullPath)));\n      continue;\n    }\n    if (entry.isFile() && entry.name.endsWith('.test.ts')) {\n      files.push(fullPath);\n    }\n  }\n\n  return files.sort((a, b) => a.localeCompare(b));\n}\n\nasync function runOneTest(rootDir: string, testFile: string): Promise<number> {\n  return await new Promise((resolve, reject) => {\n    const child = spawn('node', [testFile], {\n      cwd: rootDir,\n      stdio: 'inherit',\n    });\n\n    child.on('error', reject);\n    child.on('exit', (code) => resolve(code ?? 1));\n  });\n}\n\nasync function main(): Promise<void> {\n  const scriptDir = path.dirname(fileURLToPath(import.meta.url));\n  const rootDir = path.resolve(scriptDir, '..');\n  const opts = parseArgs(process.argv.slice(2), rootDir);\n\n  let testFiles: string[];\n  try {\n    testFiles = await findTestFiles(opts.testsDir);\n  } catch (err) {\n    const msg = err instanceof Error ? err.message : String(err);\n    process.exit(1);\n  }\n\n  if (opts.filter) {\n    testFiles = testFiles.filter((f) => opts.filter!.test(f));\n  }\n\n  if (testFiles.length === 0) {\n    process.exit(1);\n  }\n\n  if (opts.listOnly) {\n    for (const file of testFiles) console.log(path.relative(opts.rootDir, file));\n    return;\n  }\n\n  let failed = 0;\n  for (const testFile of testFiles) {\n    console.log(`\\n— Running ${path.relative(opts.rootDir, testFile)} —`);\n    const exitCode = await runOneTest(opts.rootDir, testFile);\n    if (exitCode !== 0) failed++;\n  }\n\n  if (failed > 0) {\n    process.exit(1);\n  }\n\n  console.log(`\\nAll ${testFiles.length} test file(s) passed.`);\n}\n\nawait main();\n"
  },
  {
    "path": "scripts/generate-licenses.ts",
    "content": "#!/usr/bin/env node\n/**\n * Generates ThirdPartyNoticeText.txt for bundled dependencies.\n * Run during build to ensure license compliance.\n */\n\nimport { execSync } from 'child_process';\nimport { writeFileSync, readFileSync, existsSync } from 'fs';\nimport { join } from 'path';\n\n// Dependencies that get bundled into the CLI\nconst BUNDLED_PACKAGES = [\n  '@clack/prompts',\n  '@clack/core',\n  'picocolors',\n  'gray-matter',\n  'simple-git',\n  'xdg-basedir',\n  'sisteransi',\n  'is-unicode-supported',\n];\n\ninterface LicenseInfo {\n  licenses: string;\n  repository?: string;\n  publisher?: string;\n  licenseFile?: string;\n}\n\nfunction getLicenseText(pkgPath: string): string {\n  const possibleFiles = ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'license', 'license.md'];\n  for (const file of possibleFiles) {\n    const filePath = join(pkgPath, file);\n    if (existsSync(filePath)) {\n      return readFileSync(filePath, 'utf-8').trim();\n    }\n  }\n  return '';\n}\n\nfunction main() {\n  console.log('Generating ThirdPartyNoticeText.txt...');\n\n  // Get license info from license-checker\n  const output = execSync('npx license-checker --json', { encoding: 'utf-8' });\n  const allLicenses: Record<string, LicenseInfo> = JSON.parse(output);\n\n  const lines: string[] = [\n    '/*!----------------- Skills CLI ThirdPartyNotices -------------------------------------------------------',\n    '',\n    'The Skills CLI incorporates third party material from the projects listed below.',\n    'The original copyright notice and the license under which this material was received',\n    'are set forth below. These licenses and notices are provided for informational purposes only.',\n    '',\n    '---------------------------------------------',\n    'Third Party Code Components',\n    '--------------------------------------------',\n    '',\n  ];\n\n  for (const [pkgNameVersion, info] of Object.entries(allLicenses)) {\n    // Extract package name (remove version)\n    const pkgName = pkgNameVersion.replace(/@[\\d.]+(-.*)?$/, '').replace(/^(.+)@.*$/, '$1');\n\n    // Check if this is a bundled package\n    const isBundled = BUNDLED_PACKAGES.some(\n      (bundled) => pkgName === bundled || pkgNameVersion.startsWith(bundled + '@')\n    );\n\n    if (!isBundled) continue;\n\n    // Get the actual license text from the package\n    const pkgPath = join(process.cwd(), 'node_modules', pkgName);\n    const licenseText = getLicenseText(pkgPath);\n\n    lines.push('='.repeat(80));\n    lines.push(`Package: ${pkgNameVersion}`);\n    lines.push(`License: ${info.licenses}`);\n    if (info.repository) {\n      lines.push(`Repository: ${info.repository}`);\n    }\n    lines.push('-'.repeat(80));\n    lines.push('');\n    if (licenseText) {\n      lines.push(licenseText);\n    } else {\n      // Fallback to generic MIT/ISC text\n      if (info.licenses === 'MIT') {\n        lines.push('MIT License');\n        lines.push('');\n        lines.push('Permission is hereby granted, free of charge, to any person obtaining a copy');\n        lines.push('of this software and associated documentation files (the \"Software\"), to deal');\n        lines.push('in the Software without restriction, including without limitation the rights');\n        lines.push('to use, copy, modify, merge, publish, distribute, sublicense, and/or sell');\n        lines.push('copies of the Software, and to permit persons to whom the Software is');\n        lines.push('furnished to do so, subject to the following conditions:');\n        lines.push('');\n        lines.push(\n          'The above copyright notice and this permission notice shall be included in all'\n        );\n        lines.push('copies or substantial portions of the Software.');\n        lines.push('');\n        lines.push('THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR');\n        lines.push('IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,');\n        lines.push('FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE');\n        lines.push('AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER');\n        lines.push('LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,');\n        lines.push('OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE');\n        lines.push('SOFTWARE.');\n      } else if (info.licenses === 'ISC') {\n        lines.push('ISC License');\n        lines.push('');\n        lines.push('Permission to use, copy, modify, and/or distribute this software for any');\n        lines.push('purpose with or without fee is hereby granted, provided that the above');\n        lines.push('copyright notice and this permission notice appear in all copies.');\n        lines.push('');\n        lines.push('THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES');\n        lines.push('WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF');\n        lines.push('MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR');\n        lines.push('ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES');\n        lines.push('WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN');\n        lines.push('ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF');\n        lines.push('OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.');\n      }\n    }\n    lines.push('');\n    lines.push('');\n  }\n\n  lines.push('='.repeat(80));\n  lines.push('*/');\n\n  const content = lines.join('\\n');\n  writeFileSync('ThirdPartyNoticeText.txt', content);\n  console.log('Generated ThirdPartyNoticeText.txt');\n}\n\nmain();\n"
  },
  {
    "path": "scripts/sync-agents.ts",
    "content": "#!/usr/bin/env node\n\nimport { readFileSync, writeFileSync } from 'fs';\nimport { join } from 'path';\nimport { homedir } from 'os';\nimport { agents } from '../src/agents.ts';\n\nconst ROOT = join(import.meta.dirname, '..');\nconst README_PATH = join(ROOT, 'README.md');\nconst PACKAGE_PATH = join(ROOT, 'package.json');\n\nfunction generateAgentList(): string {\n  const agentList = Object.values(agents);\n  const count = agentList.length;\n  return `Supports **OpenCode**, **Claude Code**, **Codex**, **Cursor**, and [${count - 4} more](#available-agents).`;\n}\n\nfunction generateAgentNames(): string {\n  return 'Target specific agents (e.g., `claude-code`, `codex`). See [Available Agents](#available-agents)';\n}\n\nfunction generateAvailableAgentsTable(): string {\n  // Group agents by their paths\n  const pathGroups = new Map<\n    string,\n    {\n      keys: string[];\n      displayNames: string[];\n      skillsDir: string;\n      globalSkillsDir: string | undefined;\n    }\n  >();\n\n  for (const [key, a] of Object.entries(agents)) {\n    const pathKey = `${a.skillsDir}|${a.globalSkillsDir}`;\n    if (!pathGroups.has(pathKey)) {\n      pathGroups.set(pathKey, {\n        keys: [],\n        displayNames: [],\n        skillsDir: a.skillsDir,\n        globalSkillsDir: a.globalSkillsDir,\n      });\n    }\n    const group = pathGroups.get(pathKey)!;\n    group.keys.push(key);\n    group.displayNames.push(a.displayName);\n  }\n\n  const rows = Array.from(pathGroups.values()).map((group) => {\n    const globalPath = group.globalSkillsDir\n      ? `\\`${group.globalSkillsDir.replace(homedir(), '~')}/\\``\n      : 'N/A (project-only)';\n    const names = group.displayNames.join(', ');\n    const keys = group.keys.map((k) => `\\`${k}\\``).join(', ');\n    return `| ${names} | ${keys} | \\`${group.skillsDir}/\\` | ${globalPath} |`;\n  });\n  return [\n    '| Agent | `--agent` | Project Path | Global Path |',\n    '|-------|-----------|--------------|-------------|',\n    ...rows,\n  ].join('\\n');\n}\n\nfunction generateSkillDiscoveryPaths(): string {\n  const standardPaths = [\n    '- Root directory (if it contains `SKILL.md`)',\n    '- `skills/`',\n    '- `skills/.curated/`',\n    '- `skills/.experimental/`',\n    '- `skills/.system/`',\n  ];\n\n  const agentPaths = [...new Set(Object.values(agents).map((a) => a.skillsDir))].map(\n    (p) => `- \\`.${p.startsWith('.') ? p.slice(1) : '/' + p}/\\``\n  );\n\n  return [...standardPaths, ...agentPaths].join('\\n');\n}\n\nfunction generateKeywords(): string[] {\n  const baseKeywords = ['cli', 'agent-skills', 'skills', 'ai-agents'];\n  const agentKeywords = Object.keys(agents);\n  return [...baseKeywords, ...agentKeywords];\n}\n\nfunction replaceSection(\n  content: string,\n  marker: string,\n  replacement: string,\n  inline = false\n): string {\n  const regex = new RegExp(`(<!-- ${marker}:start -->)[\\\\s\\\\S]*?(<!-- ${marker}:end -->)`, 'g');\n  if (inline) {\n    return content.replace(regex, `$1${replacement}$2`);\n  }\n  return content.replace(regex, `$1\\n${replacement}\\n$2`);\n}\n\nfunction main() {\n  let readme = readFileSync(README_PATH, 'utf-8');\n\n  readme = replaceSection(readme, 'agent-list', generateAgentList());\n  readme = replaceSection(readme, 'agent-names', generateAgentNames(), true);\n  readme = replaceSection(readme, 'supported-agents', generateAvailableAgentsTable());\n  readme = replaceSection(readme, 'skill-discovery', generateSkillDiscoveryPaths());\n\n  writeFileSync(README_PATH, readme);\n  console.log('README.md updated');\n\n  const pkg = JSON.parse(readFileSync(PACKAGE_PATH, 'utf-8'));\n  pkg.keywords = generateKeywords();\n  writeFileSync(PACKAGE_PATH, JSON.stringify(pkg, null, 2) + '\\n');\n  console.log('package.json updated');\n}\n\nmain();\n"
  },
  {
    "path": "scripts/validate-agents.ts",
    "content": "#!/usr/bin/env node\n\nimport { homedir } from 'os';\nimport { agents } from '../src/agents.ts';\n\nlet hasErrors = false;\n\nfunction error(message: string) {\n  console.error(message);\n  hasErrors = true;\n}\n\n/**\n * Checks for duplicate `displayName` values among the agents.\n *\n * Iterates through the `agents` object, collecting all `displayName` values (case-insensitive)\n * and mapping them to their corresponding agent keys. If any `displayName` is associated with\n * more than one agent, an error is reported listing the duplicate names and their keys.\n *\n * @throws Will call the `error` function if duplicate display names are found.\n */\n\nfunction checkDuplicateDisplayNames() {\n  const displayNames = new Map<string, string[]>();\n\n  for (const [key, config] of Object.entries(agents)) {\n    const name = config.displayName.toLowerCase();\n    if (!displayNames.has(name)) {\n      displayNames.set(name, []);\n    }\n    displayNames.get(name)!.push(key);\n  }\n\n  for (const [name, keys] of displayNames) {\n    if (keys.length > 1) {\n      error(`Duplicate displayName \"${name}\" found in agents: ${keys.join(', ')}`);\n    }\n  }\n}\n\n/**\n * Checks for duplicate `skillsDir` and `globalSkillsDir` values among agents.\n *\n * Iterates through the `agents` object, collecting all `skillsDir` and normalized `globalSkillsDir`\n * paths. If any directory is associated with more than one agent, an error is reported listing the\n * conflicting agents.\n *\n * @remarks\n * - The `globalSkillsDir` path is normalized by replacing the user's home directory with `~`.\n * - Errors are reported using the `error` function.\n *\n * @throws Will call `error` if duplicate directories are found.\n */\n\nfunction checkDuplicateSkillsDirs() {\n  const skillsDirs = new Map<string, string[]>();\n  const globalSkillsDirs = new Map<string, string[]>();\n\n  for (const [key, config] of Object.entries(agents)) {\n    if (!skillsDirs.has(config.skillsDir)) {\n      skillsDirs.set(config.skillsDir, []);\n    }\n    skillsDirs.get(config.skillsDir)!.push(key);\n\n    const globalPath = config.globalSkillsDir?.replace(homedir(), '~');\n    if (globalPath) {\n      if (!globalSkillsDirs.has(globalPath)) {\n        globalSkillsDirs.set(globalPath, []);\n      }\n      globalSkillsDirs.get(globalPath)!.push(key);\n    }\n  }\n\n  for (const [dir, keys] of skillsDirs) {\n    if (keys.length > 1) {\n      error(`Duplicate skillsDir \"${dir}\" found in agents: ${keys.join(', ')}`);\n    }\n  }\n\n  for (const [dir, keys] of globalSkillsDirs) {\n    if (keys.length > 1) {\n      error(`Duplicate globalSkillsDir \"${dir}\" found in agents: ${keys.join(', ')}`);\n    }\n  }\n}\n\nconsole.log('Validating agents...\\n');\n\ncheckDuplicateDisplayNames();\n// It's fine to have duplicate skills dirs\n// checkDuplicateSkillsDirs();\n\nif (hasErrors) {\n  console.log('\\nValidation failed.');\n  process.exit(1);\n} else {\n  console.log('All agents valid.');\n}\n"
  },
  {
    "path": "skills/find-skills/SKILL.md",
    "content": "---\nname: find-skills\ndescription: Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\", \"is there a skill that can...\", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.\n---\n\n# Find Skills\n\nThis skill helps you discover and install skills from the open agent skills ecosystem.\n\n## When to Use This Skill\n\nUse this skill when the user:\n\n- Asks \"how do I do X\" where X might be a common task with an existing skill\n- Says \"find a skill for X\" or \"is there a skill for X\"\n- Asks \"can you do X\" where X is a specialized capability\n- Expresses interest in extending agent capabilities\n- Wants to search for tools, templates, or workflows\n- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)\n\n## What is the Skills CLI?\n\nThe Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.\n\n**Key commands:**\n\n- `npx skills find [query]` - Search for skills interactively or by keyword\n- `npx skills add <package>` - Install a skill from GitHub or other sources\n- `npx skills check` - Check for skill updates\n- `npx skills update` - Update all installed skills\n\n**Browse skills at:** https://skills.sh/\n\n## How to Help Users Find Skills\n\n### Step 1: Understand What They Need\n\nWhen a user asks for help with something, identify:\n\n1. The domain (e.g., React, testing, design, deployment)\n2. The specific task (e.g., writing tests, creating animations, reviewing PRs)\n3. Whether this is a common enough task that a skill likely exists\n\n### Step 2: Check the Leaderboard First\n\nBefore running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.\n\nFor example, top skills for web development include:\n- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)\n- `anthropics/skills` — Frontend design, document processing (100K+ installs)\n\n### Step 3: Search for Skills\n\nIf the leaderboard doesn't cover the user's need, run the find command:\n\n```bash\nnpx skills find [query]\n```\n\nFor example:\n\n- User asks \"how do I make my React app faster?\" → `npx skills find react performance`\n- User asks \"can you help me with PR reviews?\" → `npx skills find pr review`\n- User asks \"I need to create a changelog\" → `npx skills find changelog`\n\n### Step 4: Verify Quality Before Recommending\n\n**Do not recommend a skill based solely on search results.** Always verify:\n\n1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.\n2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.\n3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.\n\n### Step 5: Present Options to the User\n\nWhen you find relevant skills, present them to the user with:\n\n1. The skill name and what it does\n2. The install count and source\n3. The install command they can run\n4. A link to learn more at skills.sh\n\nExample response:\n\n```\nI found a skill that might help! The \"react-best-practices\" skill provides\nReact and Next.js performance optimization guidelines from Vercel Engineering.\n(185K installs)\n\nTo install it:\nnpx skills add vercel-labs/agent-skills@react-best-practices\n\nLearn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices\n```\n\n### Step 6: Offer to Install\n\nIf the user wants to proceed, you can install the skill for them:\n\n```bash\nnpx skills add <owner/repo@skill> -g -y\n```\n\nThe `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.\n\n## Common Skill Categories\n\nWhen searching, consider these common categories:\n\n| Category        | Example Queries                          |\n| --------------- | ---------------------------------------- |\n| Web Development | react, nextjs, typescript, css, tailwind |\n| Testing         | testing, jest, playwright, e2e           |\n| DevOps          | deploy, docker, kubernetes, ci-cd        |\n| Documentation   | docs, readme, changelog, api-docs        |\n| Code Quality    | review, lint, refactor, best-practices   |\n| Design          | ui, ux, design-system, accessibility     |\n| Productivity    | workflow, automation, git                |\n\n## Tips for Effective Searches\n\n1. **Use specific keywords**: \"react testing\" is better than just \"testing\"\n2. **Try alternative terms**: If \"deploy\" doesn't work, try \"deployment\" or \"ci-cd\"\n3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`\n\n## When No Skills Are Found\n\nIf no relevant skills exist:\n\n1. Acknowledge that no existing skill was found\n2. Offer to help with the task directly using your general capabilities\n3. Suggest the user could create their own skill with `npx skills init`\n\nExample:\n\n```\nI searched for skills related to \"xyz\" but didn't find any matches.\nI can still help you with this task directly! Would you like me to proceed?\n\nIf this is something you do often, you could create your own skill:\nnpx skills init my-xyz-skill\n```\n"
  },
  {
    "path": "src/add-prompt.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { promptForAgents } from './add.js';\nimport * as skillLock from './skill-lock.js';\nimport * as searchMultiselectModule from './prompts/search-multiselect.js';\n\n// Mock dependencies\nvi.mock('./skill-lock.js');\nvi.mock('./prompts/search-multiselect.js');\nvi.mock('./telemetry.js', () => ({\n  setVersion: vi.fn(),\n  track: vi.fn(),\n}));\nvi.mock('../package.json', () => ({\n  default: { version: '1.0.0' },\n}));\n\ndescribe('promptForAgents', () => {\n  // Cast to any to avoid AgentType validation in tests\n  const choices: any[] = [\n    { value: 'opencode', label: 'OpenCode' },\n    { value: 'cursor', label: 'Cursor' },\n    { value: 'claude-code', label: 'Claude Code' },\n  ];\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it('should use default agents (claude-code, opencode, codex) when no history exists', async () => {\n    vi.mocked(skillLock.getLastSelectedAgents).mockResolvedValue(undefined);\n    vi.mocked(searchMultiselectModule.searchMultiselect).mockResolvedValue(['opencode']);\n\n    await promptForAgents('Select agents', choices);\n\n    // Should default to claude-code, opencode, codex (filtered by available choices)\n    expect(searchMultiselectModule.searchMultiselect).toHaveBeenCalledWith(\n      expect.objectContaining({\n        initialSelected: ['claude-code', 'opencode'],\n      })\n    );\n  });\n\n  it('should use last selected agents when history exists', async () => {\n    vi.mocked(skillLock.getLastSelectedAgents).mockResolvedValue(['cursor']);\n    vi.mocked(searchMultiselectModule.searchMultiselect).mockResolvedValue(['cursor']);\n\n    await promptForAgents('Select agents', choices);\n\n    expect(searchMultiselectModule.searchMultiselect).toHaveBeenCalledWith(\n      expect.objectContaining({\n        initialSelected: ['cursor'],\n      })\n    );\n  });\n\n  it('should filter out invalid agents from history', async () => {\n    vi.mocked(skillLock.getLastSelectedAgents).mockResolvedValue(['cursor', 'invalid-agent']);\n    vi.mocked(searchMultiselectModule.searchMultiselect).mockResolvedValue(['cursor']);\n\n    await promptForAgents('Select agents', choices);\n\n    expect(searchMultiselectModule.searchMultiselect).toHaveBeenCalledWith(\n      expect.objectContaining({\n        initialSelected: ['cursor'],\n      })\n    );\n  });\n\n  it('should use default agents if all history agents are invalid', async () => {\n    vi.mocked(skillLock.getLastSelectedAgents).mockResolvedValue(['invalid-agent']);\n    vi.mocked(searchMultiselectModule.searchMultiselect).mockResolvedValue(['opencode']);\n\n    await promptForAgents('Select agents', choices);\n\n    // When history is invalid, should fall back to defaults (claude-code, opencode, codex)\n    // filtered by available choices\n    expect(searchMultiselectModule.searchMultiselect).toHaveBeenCalledWith(\n      expect.objectContaining({\n        initialSelected: ['claude-code', 'opencode'],\n      })\n    );\n  });\n\n  it('should save selected agents if not cancelled', async () => {\n    vi.mocked(skillLock.getLastSelectedAgents).mockResolvedValue(undefined);\n    vi.mocked(searchMultiselectModule.searchMultiselect).mockResolvedValue(['opencode']);\n\n    await promptForAgents('Select agents', choices);\n\n    expect(skillLock.saveSelectedAgents).toHaveBeenCalledWith(['opencode']);\n  });\n\n  it('should not save agents if cancelled', async () => {\n    vi.mocked(skillLock.getLastSelectedAgents).mockResolvedValue(undefined);\n    vi.mocked(searchMultiselectModule.searchMultiselect).mockResolvedValue(\n      searchMultiselectModule.cancelSymbol\n    );\n\n    await promptForAgents('Select agents', choices);\n\n    expect(skillLock.saveSelectedAgents).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "src/add.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { existsSync, rmSync, mkdirSync, writeFileSync } from 'fs';\nimport { join } from 'path';\nimport { tmpdir } from 'os';\nimport { runCli } from './test-utils.ts';\nimport { shouldInstallInternalSkills } from './skills.ts';\nimport { parseAddOptions } from './add.ts';\n\ndescribe('add command', () => {\n  let testDir: string;\n\n  beforeEach(() => {\n    testDir = join(tmpdir(), `skills-add-test-${Date.now()}`);\n    mkdirSync(testDir, { recursive: true });\n  });\n\n  afterEach(() => {\n    if (existsSync(testDir)) {\n      rmSync(testDir, { recursive: true, force: true });\n    }\n  });\n\n  it('should show error when no source provided', () => {\n    const result = runCli(['add'], testDir);\n    expect(result.stdout).toContain('ERROR');\n    expect(result.stdout).toContain('Missing required argument: source');\n    expect(result.exitCode).toBe(1);\n  });\n\n  it('should show error for non-existent local path', () => {\n    const result = runCli(['add', './non-existent-path', '-y'], testDir);\n    expect(result.stdout).toContain('Local path does not exist');\n    expect(result.exitCode).toBe(1);\n  });\n\n  it('should list skills from local path with --list flag', () => {\n    // Create a test skill\n    const skillDir = join(testDir, 'test-skill');\n    mkdirSync(skillDir, { recursive: true });\n    writeFileSync(\n      join(skillDir, 'SKILL.md'),\n      `---\nname: test-skill\ndescription: A test skill for testing\n---\n\n# Test Skill\n\nThis is a test skill.\n`\n    );\n\n    const result = runCli(['add', testDir, '--list'], testDir);\n    expect(result.stdout).toContain('test-skill');\n    expect(result.stdout).toContain('A test skill for testing');\n    expect(result.exitCode).toBe(0);\n  });\n\n  it('should show no skills found for empty directory', () => {\n    const result = runCli(['add', testDir, '-y'], testDir);\n    expect(result.stdout).toContain('No skills found');\n    expect(result.stdout).toContain('No valid skills found');\n    expect(result.exitCode).toBe(1);\n  });\n\n  it('should install skill from local path with -y flag', () => {\n    // Create a test skill\n    const skillDir = join(testDir, 'skills', 'my-skill');\n    mkdirSync(skillDir, { recursive: true });\n    writeFileSync(\n      join(skillDir, 'SKILL.md'),\n      `---\nname: my-skill\ndescription: My test skill\n---\n\n# My Skill\n\nInstructions here.\n`\n    );\n\n    // Create a target directory to install to\n    const targetDir = join(testDir, 'project');\n    mkdirSync(targetDir, { recursive: true });\n\n    const result = runCli(['add', testDir, '-y', '-g', '--agent', 'claude-code'], targetDir);\n    expect(result.stdout).toContain('my-skill');\n    expect(result.stdout).toContain('Done!');\n    expect(result.exitCode).toBe(0);\n  });\n\n  it('should filter skills by name with --skill flag', () => {\n    // Create multiple test skills\n    const skill1Dir = join(testDir, 'skills', 'skill-one');\n    const skill2Dir = join(testDir, 'skills', 'skill-two');\n    mkdirSync(skill1Dir, { recursive: true });\n    mkdirSync(skill2Dir, { recursive: true });\n\n    writeFileSync(\n      join(skill1Dir, 'SKILL.md'),\n      `---\nname: skill-one\ndescription: First skill\n---\n# Skill One\n`\n    );\n\n    writeFileSync(\n      join(skill2Dir, 'SKILL.md'),\n      `---\nname: skill-two\ndescription: Second skill\n---\n# Skill Two\n`\n    );\n\n    const result = runCli(['add', testDir, '--list', '--skill', 'skill-one'], testDir);\n    // With --list, it should show only the filtered skill info\n    expect(result.stdout).toContain('skill-one');\n  });\n\n  it('should show error for invalid agent name', () => {\n    // Create a test skill\n    const skillDir = join(testDir, 'test-skill');\n    mkdirSync(skillDir, { recursive: true });\n    writeFileSync(\n      join(skillDir, 'SKILL.md'),\n      `---\nname: test-skill\ndescription: Test\n---\n# Test\n`\n    );\n\n    const result = runCli(['add', testDir, '-y', '--agent', 'invalid-agent'], testDir);\n    expect(result.stdout).toContain('Invalid agents');\n    expect(result.exitCode).toBe(1);\n  });\n\n  it('should support add command aliases (a, i, install)', () => {\n    // Test that aliases work (just check they show missing source error)\n    const resultA = runCli(['a'], testDir);\n    const resultI = runCli(['i'], testDir);\n    const resultInstall = runCli(['install'], testDir);\n\n    // All should show the same \"missing source\" error\n    expect(resultA.stdout).toContain('Missing required argument: source');\n    expect(resultI.stdout).toContain('Missing required argument: source');\n    expect(resultInstall.stdout).toContain('Missing required argument: source');\n  });\n\n  it('should restore from lock file with experimental_install', () => {\n    const result = runCli(['experimental_install'], testDir);\n    expect(result.stdout).toContain('No project skills found in skills-lock.json');\n  });\n\n  describe('internal skills', () => {\n    it('should skip internal skills by default', () => {\n      // Create an internal skill\n      const skillDir = join(testDir, 'internal-skill');\n      mkdirSync(skillDir, { recursive: true });\n      writeFileSync(\n        join(skillDir, 'SKILL.md'),\n        `---\nname: internal-skill\ndescription: An internal skill\nmetadata:\n  internal: true\n---\n\n# Internal Skill\n\nThis is an internal skill.\n`\n      );\n\n      const result = runCli(['add', testDir, '--list'], testDir);\n      expect(result.stdout).not.toContain('internal-skill');\n    });\n\n    it('should show internal skills when INSTALL_INTERNAL_SKILLS=1', () => {\n      // Create an internal skill\n      const skillDir = join(testDir, 'internal-skill');\n      mkdirSync(skillDir, { recursive: true });\n      writeFileSync(\n        join(skillDir, 'SKILL.md'),\n        `---\nname: internal-skill\ndescription: An internal skill\nmetadata:\n  internal: true\n---\n\n# Internal Skill\n\nThis is an internal skill.\n`\n      );\n\n      const result = runCli(['add', testDir, '--list'], testDir, {\n        INSTALL_INTERNAL_SKILLS: '1',\n      });\n      expect(result.stdout).toContain('internal-skill');\n      expect(result.stdout).toContain('An internal skill');\n    });\n\n    it('should show internal skills when INSTALL_INTERNAL_SKILLS=true', () => {\n      // Create an internal skill\n      const skillDir = join(testDir, 'internal-skill');\n      mkdirSync(skillDir, { recursive: true });\n      writeFileSync(\n        join(skillDir, 'SKILL.md'),\n        `---\nname: internal-skill\ndescription: An internal skill\nmetadata:\n  internal: true\n---\n\n# Internal Skill\n\nThis is an internal skill.\n`\n      );\n\n      const result = runCli(['add', testDir, '--list'], testDir, {\n        INSTALL_INTERNAL_SKILLS: 'true',\n      });\n      expect(result.stdout).toContain('internal-skill');\n    });\n\n    it('should show non-internal skills alongside internal when env var is set', () => {\n      // Create both internal and non-internal skills\n      const internalDir = join(testDir, 'skills', 'internal-skill');\n      const publicDir = join(testDir, 'skills', 'public-skill');\n      mkdirSync(internalDir, { recursive: true });\n      mkdirSync(publicDir, { recursive: true });\n\n      writeFileSync(\n        join(internalDir, 'SKILL.md'),\n        `---\nname: internal-skill\ndescription: An internal skill\nmetadata:\n  internal: true\n---\n# Internal Skill\n`\n      );\n\n      writeFileSync(\n        join(publicDir, 'SKILL.md'),\n        `---\nname: public-skill\ndescription: A public skill\n---\n# Public Skill\n`\n      );\n\n      // Without env var - only public skill visible\n      const resultWithout = runCli(['add', testDir, '--list'], testDir);\n      expect(resultWithout.stdout).toContain('public-skill');\n      expect(resultWithout.stdout).not.toContain('internal-skill');\n\n      // With env var - both visible\n      const resultWith = runCli(['add', testDir, '--list'], testDir, {\n        INSTALL_INTERNAL_SKILLS: '1',\n      });\n      expect(resultWith.stdout).toContain('public-skill');\n      expect(resultWith.stdout).toContain('internal-skill');\n    });\n\n    it('should not treat metadata.internal: false as internal', () => {\n      const skillDir = join(testDir, 'not-internal-skill');\n      mkdirSync(skillDir, { recursive: true });\n      writeFileSync(\n        join(skillDir, 'SKILL.md'),\n        `---\nname: not-internal-skill\ndescription: Explicitly not internal\nmetadata:\n  internal: false\n---\n# Not Internal\n`\n      );\n\n      const result = runCli(['add', testDir, '--list'], testDir);\n      expect(result.stdout).toContain('not-internal-skill');\n    });\n  });\n});\n\ndescribe('shouldInstallInternalSkills', () => {\n  const originalEnv = process.env;\n\n  beforeEach(() => {\n    vi.resetModules();\n    process.env = { ...originalEnv };\n  });\n\n  afterEach(() => {\n    process.env = originalEnv;\n  });\n\n  it('should return false when INSTALL_INTERNAL_SKILLS is not set', () => {\n    delete process.env.INSTALL_INTERNAL_SKILLS;\n    expect(shouldInstallInternalSkills()).toBe(false);\n  });\n\n  it('should return true when INSTALL_INTERNAL_SKILLS=1', () => {\n    process.env.INSTALL_INTERNAL_SKILLS = '1';\n    expect(shouldInstallInternalSkills()).toBe(true);\n  });\n\n  it('should return true when INSTALL_INTERNAL_SKILLS=true', () => {\n    process.env.INSTALL_INTERNAL_SKILLS = 'true';\n    expect(shouldInstallInternalSkills()).toBe(true);\n  });\n\n  it('should return false for other values', () => {\n    process.env.INSTALL_INTERNAL_SKILLS = '0';\n    expect(shouldInstallInternalSkills()).toBe(false);\n\n    process.env.INSTALL_INTERNAL_SKILLS = 'false';\n    expect(shouldInstallInternalSkills()).toBe(false);\n\n    process.env.INSTALL_INTERNAL_SKILLS = 'yes';\n    expect(shouldInstallInternalSkills()).toBe(false);\n  });\n});\n\ndescribe('parseAddOptions', () => {\n  it('should parse --all flag', () => {\n    const result = parseAddOptions(['source', '--all']);\n    expect(result.source).toEqual(['source']);\n    expect(result.options.all).toBe(true);\n  });\n\n  it('should parse --skill with wildcard', () => {\n    const result = parseAddOptions(['source', '--skill', '*']);\n    expect(result.source).toEqual(['source']);\n    expect(result.options.skill).toEqual(['*']);\n  });\n\n  it('should parse --agent with wildcard', () => {\n    const result = parseAddOptions(['source', '--agent', '*']);\n    expect(result.source).toEqual(['source']);\n    expect(result.options.agent).toEqual(['*']);\n  });\n\n  it('should parse --skill wildcard with specific agents', () => {\n    const result = parseAddOptions(['source', '--skill', '*', '--agent', 'claude-code']);\n    expect(result.source).toEqual(['source']);\n    expect(result.options.skill).toEqual(['*']);\n    expect(result.options.agent).toEqual(['claude-code']);\n  });\n\n  it('should parse --agent wildcard with specific skills', () => {\n    const result = parseAddOptions(['source', '--agent', '*', '--skill', 'my-skill']);\n    expect(result.source).toEqual(['source']);\n    expect(result.options.agent).toEqual(['*']);\n    expect(result.options.skill).toEqual(['my-skill']);\n  });\n\n  it('should parse combined flags with wildcards', () => {\n    const result = parseAddOptions(['source', '-g', '--skill', '*', '-y']);\n    expect(result.source).toEqual(['source']);\n    expect(result.options.global).toBe(true);\n    expect(result.options.skill).toEqual(['*']);\n    expect(result.options.yes).toBe(true);\n  });\n\n  it('should parse --full-depth flag', () => {\n    const result = parseAddOptions(['source', '--full-depth']);\n    expect(result.source).toEqual(['source']);\n    expect(result.options.fullDepth).toBe(true);\n  });\n\n  it('should parse --full-depth with other flags', () => {\n    const result = parseAddOptions(['source', '--full-depth', '--list', '-g']);\n    expect(result.source).toEqual(['source']);\n    expect(result.options.fullDepth).toBe(true);\n    expect(result.options.list).toBe(true);\n    expect(result.options.global).toBe(true);\n  });\n});\n\ndescribe('find-skills prompt with -y flag', () => {\n  let testDir: string;\n\n  beforeEach(() => {\n    testDir = join(tmpdir(), `skills-yes-flag-test-${Date.now()}`);\n    mkdirSync(testDir, { recursive: true });\n  });\n\n  afterEach(() => {\n    if (existsSync(testDir)) {\n      rmSync(testDir, { recursive: true, force: true });\n    }\n  });\n\n  it('should skip find-skills prompt when -y flag is passed', () => {\n    // Create a test skill\n    const skillDir = join(testDir, 'test-skill');\n    mkdirSync(skillDir, { recursive: true });\n    writeFileSync(\n      join(skillDir, 'SKILL.md'),\n      `---\nname: yes-flag-test-skill\ndescription: A test skill for -y flag testing\n---\n\n# Yes Flag Test Skill\n\nThis is a test skill for -y flag mode testing.\n`\n    );\n\n    // Run with -y flag - should complete without hanging\n    const result = runCli(['add', testDir, '-g', '-y', '--skill', 'yes-flag-test-skill'], testDir);\n\n    // Should not contain the find-skills prompt\n    expect(result.stdout).not.toContain('Install the find-skills skill');\n    expect(result.stdout).not.toContain(\"One-time prompt - you won't be asked again\");\n    // Should complete successfully\n    expect(result.exitCode).toBe(0);\n  });\n});\n"
  },
  {
    "path": "src/add.ts",
    "content": "import * as p from '@clack/prompts';\nimport pc from 'picocolors';\nimport { existsSync } from 'fs';\nimport { homedir } from 'os';\nimport { sep } from 'path';\nimport { parseSource, getOwnerRepo, parseOwnerRepo, isRepoPrivate } from './source-parser.ts';\nimport { searchMultiselect } from './prompts/search-multiselect.ts';\n\n// Helper to check if a value is a cancel symbol (works with both clack and our custom prompts)\nconst isCancelled = (value: unknown): value is symbol => typeof value === 'symbol';\n\n/**\n * Check if a source identifier (owner/repo format) represents a private GitHub repo.\n * Returns true if private, false if public, null if unable to determine or not a GitHub repo.\n */\nasync function isSourcePrivate(source: string): Promise<boolean | null> {\n  const ownerRepo = parseOwnerRepo(source);\n  if (!ownerRepo) {\n    // Not in owner/repo format, assume not private (could be other providers)\n    return false;\n  }\n  return isRepoPrivate(ownerRepo.owner, ownerRepo.repo);\n}\nimport { cloneRepo, cleanupTempDir, GitCloneError } from './git.ts';\nimport { discoverSkills, getSkillDisplayName, filterSkills } from './skills.ts';\nimport {\n  installSkillForAgent,\n  isSkillInstalled,\n  getCanonicalPath,\n  installWellKnownSkillForAgent,\n  type InstallMode,\n} from './installer.ts';\nimport {\n  detectInstalledAgents,\n  agents,\n  getUniversalAgents,\n  getNonUniversalAgents,\n  isUniversalAgent,\n} from './agents.ts';\nimport {\n  track,\n  setVersion,\n  fetchAuditData,\n  type AuditResponse,\n  type PartnerAudit,\n} from './telemetry.ts';\nimport { wellKnownProvider, type WellKnownSkill } from './providers/index.ts';\nimport {\n  addSkillToLock,\n  fetchSkillFolderHash,\n  getGitHubToken,\n  isPromptDismissed,\n  dismissPrompt,\n  getLastSelectedAgents,\n  saveSelectedAgents,\n} from './skill-lock.ts';\nimport { addSkillToLocalLock, computeSkillFolderHash } from './local-lock.ts';\nimport type { Skill, AgentType } from './types.ts';\nimport packageJson from '../package.json' with { type: 'json' };\nexport function initTelemetry(version: string): void {\n  setVersion(version);\n}\n\n// ─── Security Advisory ───\n\nfunction riskLabel(risk: string): string {\n  switch (risk) {\n    case 'critical':\n      return pc.red(pc.bold('Critical Risk'));\n    case 'high':\n      return pc.red('High Risk');\n    case 'medium':\n      return pc.yellow('Med Risk');\n    case 'low':\n      return pc.green('Low Risk');\n    case 'safe':\n      return pc.green('Safe');\n    default:\n      return pc.dim('--');\n  }\n}\n\nfunction socketLabel(audit: PartnerAudit | undefined): string {\n  if (!audit) return pc.dim('--');\n  const count = audit.alerts ?? 0;\n  return count > 0 ? pc.red(`${count} alert${count !== 1 ? 's' : ''}`) : pc.green('0 alerts');\n}\n\n/** Pad a string to a given visible width (ignoring ANSI escape codes). */\nfunction padEnd(str: string, width: number): string {\n  // Strip ANSI codes to measure visible length\n  const visible = str.replace(/\\x1b\\[[0-9;]*m/g, '');\n  const pad = Math.max(0, width - visible.length);\n  return str + ' '.repeat(pad);\n}\n\n/**\n * Render a compact security table showing partner audit results.\n * Returns the lines to display, or empty array if no data.\n */\nfunction buildSecurityLines(\n  auditData: AuditResponse | null,\n  skills: Array<{ slug: string; displayName: string }>,\n  source: string\n): string[] {\n  if (!auditData) return [];\n\n  // Check if we have any audit data at all\n  const hasAny = skills.some((s) => {\n    const data = auditData[s.slug];\n    return data && Object.keys(data).length > 0;\n  });\n  if (!hasAny) return [];\n\n  // Compute column width for skill names\n  const nameWidth = Math.min(Math.max(...skills.map((s) => s.displayName.length)), 36);\n\n  // Header\n  const lines: string[] = [];\n  const header =\n    padEnd('', nameWidth + 2) +\n    padEnd(pc.dim('Gen'), 18) +\n    padEnd(pc.dim('Socket'), 18) +\n    pc.dim('Snyk');\n  lines.push(header);\n\n  // Rows\n  for (const skill of skills) {\n    const data = auditData[skill.slug];\n    const name =\n      skill.displayName.length > nameWidth\n        ? skill.displayName.slice(0, nameWidth - 1) + '\\u2026'\n        : skill.displayName;\n\n    const ath = data?.ath ? riskLabel(data.ath.risk) : pc.dim('--');\n    const socket = data?.socket ? socketLabel(data.socket) : pc.dim('--');\n    const snyk = data?.snyk ? riskLabel(data.snyk.risk) : pc.dim('--');\n\n    lines.push(padEnd(pc.cyan(name), nameWidth + 2) + padEnd(ath, 18) + padEnd(socket, 18) + snyk);\n  }\n\n  // Footer link\n  lines.push('');\n  lines.push(`${pc.dim('Details:')} ${pc.dim(`https://skills.sh/${source}`)}`);\n\n  return lines;\n}\n\n/**\n * Shortens a path for display: replaces homedir with ~ and cwd with .\n * Handles both Unix and Windows path separators.\n */\nfunction shortenPath(fullPath: string, cwd: string): string {\n  const home = homedir();\n  // Ensure we match complete path segments by checking for separator after the prefix\n  if (fullPath === home || fullPath.startsWith(home + sep)) {\n    return '~' + fullPath.slice(home.length);\n  }\n  if (fullPath === cwd || fullPath.startsWith(cwd + sep)) {\n    return '.' + fullPath.slice(cwd.length);\n  }\n  return fullPath;\n}\n\n/**\n * Formats a list of items, truncating if too many\n */\nfunction formatList(items: string[], maxShow: number = 5): string {\n  if (items.length <= maxShow) {\n    return items.join(', ');\n  }\n  const shown = items.slice(0, maxShow);\n  const remaining = items.length - maxShow;\n  return `${shown.join(', ')} +${remaining} more`;\n}\n\n/**\n * Splits agents into universal and non-universal (symlinked) groups.\n * Returns display names for each group.\n */\nfunction splitAgentsByType(agentTypes: AgentType[]): {\n  universal: string[];\n  symlinked: string[];\n} {\n  const universal: string[] = [];\n  const symlinked: string[] = [];\n\n  for (const a of agentTypes) {\n    if (isUniversalAgent(a)) {\n      universal.push(agents[a].displayName);\n    } else {\n      symlinked.push(agents[a].displayName);\n    }\n  }\n\n  return { universal, symlinked };\n}\n\n/**\n * Builds summary lines showing universal vs symlinked agents\n */\nfunction buildAgentSummaryLines(targetAgents: AgentType[], installMode: InstallMode): string[] {\n  const lines: string[] = [];\n  const { universal, symlinked } = splitAgentsByType(targetAgents);\n\n  if (installMode === 'symlink') {\n    if (universal.length > 0) {\n      lines.push(`  ${pc.green('universal:')} ${formatList(universal)}`);\n    }\n    if (symlinked.length > 0) {\n      lines.push(`  ${pc.dim('symlink →')} ${formatList(symlinked)}`);\n    }\n  } else {\n    // Copy mode - all agents get copies\n    const allNames = targetAgents.map((a) => agents[a].displayName);\n    lines.push(`  ${pc.dim('copy →')} ${formatList(allNames)}`);\n  }\n\n  return lines;\n}\n\n/**\n * Ensures universal agents are always included in the target agents list.\n * Used when -y flag is passed or when auto-selecting agents.\n */\nfunction ensureUniversalAgents(targetAgents: AgentType[]): AgentType[] {\n  const universalAgents = getUniversalAgents();\n  const result = [...targetAgents];\n\n  for (const ua of universalAgents) {\n    if (!result.includes(ua)) {\n      result.push(ua);\n    }\n  }\n\n  return result;\n}\n\n/**\n * Builds result lines from installation results, splitting by universal vs symlinked\n */\nfunction buildResultLines(\n  results: Array<{\n    agent: string;\n    symlinkFailed?: boolean;\n  }>,\n  targetAgents: AgentType[]\n): string[] {\n  const lines: string[] = [];\n\n  // Split target agents by type\n  const { universal, symlinked: symlinkAgents } = splitAgentsByType(targetAgents);\n\n  // For symlink results, also track which ones actually succeeded vs failed\n  const successfulSymlinks = results\n    .filter((r) => !r.symlinkFailed && !universal.includes(r.agent))\n    .map((r) => r.agent);\n  const failedSymlinks = results.filter((r) => r.symlinkFailed).map((r) => r.agent);\n\n  if (universal.length > 0) {\n    lines.push(`  ${pc.green('universal:')} ${formatList(universal)}`);\n  }\n  if (successfulSymlinks.length > 0) {\n    lines.push(`  ${pc.dim('symlinked:')} ${formatList(successfulSymlinks)}`);\n  }\n  if (failedSymlinks.length > 0) {\n    lines.push(`  ${pc.yellow('copied:')} ${formatList(failedSymlinks)}`);\n  }\n\n  return lines;\n}\n\n/**\n * Wrapper around p.multiselect that adds a hint for keyboard usage.\n * Accepts options with required labels (matching our usage pattern).\n */\nfunction multiselect<Value>(opts: {\n  message: string;\n  options: Array<{ value: Value; label: string; hint?: string }>;\n  initialValues?: Value[];\n  required?: boolean;\n}) {\n  return p.multiselect({\n    ...opts,\n    // Cast is safe: our options always have labels, which satisfies p.Option requirements\n    options: opts.options as p.Option<Value>[],\n    message: `${opts.message} ${pc.dim('(space to toggle)')}`,\n  }) as Promise<Value[] | symbol>;\n}\n\n/**\n * Prompts the user to select agents using interactive search.\n * Pre-selects the last used agents if available.\n * Saves the selection for future use.\n */\nexport async function promptForAgents(\n  message: string,\n  choices: Array<{ value: AgentType; label: string; hint?: string }>\n): Promise<AgentType[] | symbol> {\n  // Get last selected agents to pre-select\n  let lastSelected: string[] | undefined;\n  try {\n    lastSelected = await getLastSelectedAgents();\n  } catch {\n    // Silently ignore errors reading lock file\n  }\n\n  const validAgents = choices.map((c) => c.value);\n\n  // Default agents to pre-select when no valid history exists\n  const defaultAgents: AgentType[] = ['claude-code', 'opencode', 'codex'];\n  const defaultValues = defaultAgents.filter((a) => validAgents.includes(a));\n\n  let initialValues: AgentType[] = [];\n\n  if (lastSelected && lastSelected.length > 0) {\n    // Filter stored agents against currently valid agents\n    initialValues = lastSelected.filter((a) => validAgents.includes(a as AgentType)) as AgentType[];\n  }\n\n  // If no valid selection from history, use defaults\n  if (initialValues.length === 0) {\n    initialValues = defaultValues;\n  }\n\n  const selected = await searchMultiselect({\n    message,\n    items: choices,\n    initialSelected: initialValues,\n    required: true,\n  });\n\n  if (!isCancelled(selected)) {\n    // Save selection for next time\n    try {\n      await saveSelectedAgents(selected as string[]);\n    } catch {\n      // Silently ignore errors writing lock file\n    }\n  }\n\n  return selected as AgentType[] | symbol;\n}\n\n/**\n * Interactive agent selection using fuzzy search.\n * Shows universal agents as locked (always selected), and other agents as selectable.\n */\nasync function selectAgentsInteractive(options: {\n  global?: boolean;\n}): Promise<AgentType[] | symbol> {\n  // Filter out agents that don't support global installation when --global is used\n  const supportsGlobalFilter = (a: AgentType) => !options.global || agents[a].globalSkillsDir;\n\n  const universalAgents = getUniversalAgents().filter(supportsGlobalFilter);\n  const otherAgents = getNonUniversalAgents().filter(supportsGlobalFilter);\n\n  // Universal agents shown as locked section\n  const universalSection = {\n    title: 'Universal (.agents/skills)',\n    items: universalAgents.map((a) => ({\n      value: a,\n      label: agents[a].displayName,\n    })),\n  };\n\n  // Other agents are selectable with their skillsDir as hint\n  const otherChoices = otherAgents.map((a) => ({\n    value: a,\n    label: agents[a].displayName,\n    hint: options.global ? agents[a].globalSkillsDir! : agents[a].skillsDir,\n  }));\n\n  // Get last selected agents (filter to only non-universal ones for initial selection)\n  let lastSelected: string[] | undefined;\n  try {\n    lastSelected = await getLastSelectedAgents();\n  } catch {\n    // Silently ignore errors\n  }\n\n  const initialSelected = lastSelected\n    ? (lastSelected.filter(\n        (a) => otherAgents.includes(a as AgentType) && !universalAgents.includes(a as AgentType)\n      ) as AgentType[])\n    : [];\n\n  const selected = await searchMultiselect({\n    message: 'Which agents do you want to install to?',\n    items: otherChoices,\n    initialSelected,\n    lockedSection: universalSection,\n  });\n\n  if (!isCancelled(selected)) {\n    // Save selection (all agents including universal)\n    try {\n      await saveSelectedAgents(selected as string[]);\n    } catch {\n      // Silently ignore errors\n    }\n  }\n\n  return selected as AgentType[] | symbol;\n}\n\nconst version = packageJson.version;\nsetVersion(version);\n\nexport interface AddOptions {\n  global?: boolean;\n  agent?: string[];\n  yes?: boolean;\n  skill?: string[];\n  list?: boolean;\n  all?: boolean;\n  fullDepth?: boolean;\n  copy?: boolean;\n}\n\n/**\n * Handle skills from a well-known endpoint (RFC 8615).\n * Discovers skills from /.well-known/skills/index.json\n */\nasync function handleWellKnownSkills(\n  source: string,\n  url: string,\n  options: AddOptions,\n  spinner: ReturnType<typeof p.spinner>\n): Promise<void> {\n  spinner.start('Discovering skills from well-known endpoint...');\n\n  // Fetch all skills from the well-known endpoint\n  const skills = await wellKnownProvider.fetchAllSkills(url);\n\n  if (skills.length === 0) {\n    spinner.stop(pc.red('No skills found'));\n    p.outro(\n      pc.red(\n        'No skills found at this URL. Make sure the server has a /.well-known/skills/index.json file.'\n      )\n    );\n    process.exit(1);\n  }\n\n  spinner.stop(`Found ${pc.green(skills.length)} skill${skills.length > 1 ? 's' : ''}`);\n\n  // Log discovered skills\n  for (const skill of skills) {\n    p.log.info(`Skill: ${pc.cyan(skill.installName)}`);\n    p.log.message(pc.dim(skill.description));\n    if (skill.files.size > 1) {\n      p.log.message(pc.dim(`  Files: ${Array.from(skill.files.keys()).join(', ')}`));\n    }\n  }\n\n  if (options.list) {\n    console.log();\n    p.log.step(pc.bold('Available Skills'));\n    for (const skill of skills) {\n      p.log.message(`  ${pc.cyan(skill.installName)}`);\n      p.log.message(`    ${pc.dim(skill.description)}`);\n      if (skill.files.size > 1) {\n        p.log.message(`    ${pc.dim(`Files: ${skill.files.size}`)}`);\n      }\n    }\n    console.log();\n    p.outro('Run without --list to install');\n    process.exit(0);\n  }\n\n  // Filter skills if --skill option is provided\n  let selectedSkills: WellKnownSkill[];\n\n  if (options.skill?.includes('*')) {\n    // --skill '*' selects all skills\n    selectedSkills = skills;\n    p.log.info(`Installing all ${skills.length} skills`);\n  } else if (options.skill && options.skill.length > 0) {\n    selectedSkills = skills.filter((s) =>\n      options.skill!.some(\n        (name) =>\n          s.installName.toLowerCase() === name.toLowerCase() ||\n          s.name.toLowerCase() === name.toLowerCase()\n      )\n    );\n\n    if (selectedSkills.length === 0) {\n      p.log.error(`No matching skills found for: ${options.skill.join(', ')}`);\n      p.log.info('Available skills:');\n      for (const s of skills) {\n        p.log.message(`  - ${s.installName}`);\n      }\n      process.exit(1);\n    }\n  } else if (skills.length === 1) {\n    selectedSkills = skills;\n    const firstSkill = skills[0]!;\n    p.log.info(`Skill: ${pc.cyan(firstSkill.installName)}`);\n  } else if (options.yes) {\n    selectedSkills = skills;\n    p.log.info(`Installing all ${skills.length} skills`);\n  } else {\n    // Prompt user to select skills\n    const skillChoices = skills.map((s) => ({\n      value: s,\n      label: s.installName,\n      hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description,\n    }));\n\n    const selected = await multiselect({\n      message: 'Select skills to install',\n      options: skillChoices,\n      required: true,\n    });\n\n    if (p.isCancel(selected)) {\n      p.cancel('Installation cancelled');\n      process.exit(0);\n    }\n\n    selectedSkills = selected as WellKnownSkill[];\n  }\n\n  // Detect agents\n  let targetAgents: AgentType[];\n  const validAgents = Object.keys(agents);\n\n  if (options.agent?.includes('*')) {\n    // --agent '*' selects all agents\n    targetAgents = validAgents as AgentType[];\n    p.log.info(`Installing to all ${targetAgents.length} agents`);\n  } else if (options.agent && options.agent.length > 0) {\n    const invalidAgents = options.agent.filter((a) => !validAgents.includes(a));\n\n    if (invalidAgents.length > 0) {\n      p.log.error(`Invalid agents: ${invalidAgents.join(', ')}`);\n      p.log.info(`Valid agents: ${validAgents.join(', ')}`);\n      process.exit(1);\n    }\n\n    targetAgents = options.agent as AgentType[];\n  } else {\n    spinner.start('Loading agents...');\n    const installedAgents = await detectInstalledAgents();\n    const totalAgents = Object.keys(agents).length;\n    spinner.stop(`${totalAgents} agents`);\n\n    if (installedAgents.length === 0) {\n      if (options.yes) {\n        targetAgents = validAgents as AgentType[];\n        p.log.info('Installing to all agents');\n      } else {\n        p.log.info('Select agents to install skills to');\n\n        const allAgentChoices = Object.entries(agents).map(([key, config]) => ({\n          value: key as AgentType,\n          label: config.displayName,\n        }));\n\n        // Use helper to prompt with search\n        const selected = await promptForAgents(\n          'Which agents do you want to install to?',\n          allAgentChoices\n        );\n\n        if (p.isCancel(selected)) {\n          p.cancel('Installation cancelled');\n          process.exit(0);\n        }\n\n        targetAgents = selected as AgentType[];\n      }\n    } else if (installedAgents.length === 1 || options.yes) {\n      // Auto-select detected agents + ensure universal agents are included\n      targetAgents = ensureUniversalAgents(installedAgents);\n      if (installedAgents.length === 1) {\n        const firstAgent = installedAgents[0]!;\n        p.log.info(`Installing to: ${pc.cyan(agents[firstAgent].displayName)}`);\n      } else {\n        p.log.info(\n          `Installing to: ${installedAgents.map((a) => pc.cyan(agents[a].displayName)).join(', ')}`\n        );\n      }\n    } else {\n      const selected = await selectAgentsInteractive({ global: options.global });\n\n      if (p.isCancel(selected)) {\n        p.cancel('Installation cancelled');\n        process.exit(0);\n      }\n\n      targetAgents = selected as AgentType[];\n    }\n  }\n\n  let installGlobally = options.global ?? false;\n\n  // Check if any selected agents support global installation\n  const supportsGlobal = targetAgents.some((a) => agents[a].globalSkillsDir !== undefined);\n\n  if (options.global === undefined && !options.yes && supportsGlobal) {\n    const scope = await p.select({\n      message: 'Installation scope',\n      options: [\n        {\n          value: false,\n          label: 'Project',\n          hint: 'Install in current directory (committed with your project)',\n        },\n        {\n          value: true,\n          label: 'Global',\n          hint: 'Install in home directory (available across all projects)',\n        },\n      ],\n    });\n\n    if (p.isCancel(scope)) {\n      p.cancel('Installation cancelled');\n      process.exit(0);\n    }\n\n    installGlobally = scope as boolean;\n  }\n\n  // Determine install mode (symlink vs copy)\n  let installMode: InstallMode = options.copy ? 'copy' : 'symlink';\n\n  // Only prompt for install mode when there are multiple unique target directories.\n  // When all selected agents share the same skillsDir, symlink vs copy is meaningless.\n  const uniqueDirs = new Set(targetAgents.map((a) => agents[a].skillsDir));\n\n  if (!options.copy && !options.yes && uniqueDirs.size > 1) {\n    const modeChoice = await p.select({\n      message: 'Installation method',\n      options: [\n        {\n          value: 'symlink',\n          label: 'Symlink (Recommended)',\n          hint: 'Single source of truth, easy updates',\n        },\n        { value: 'copy', label: 'Copy to all agents', hint: 'Independent copies for each agent' },\n      ],\n    });\n\n    if (p.isCancel(modeChoice)) {\n      p.cancel('Installation cancelled');\n      process.exit(0);\n    }\n\n    installMode = modeChoice as InstallMode;\n  } else if (uniqueDirs.size <= 1) {\n    // Single target directory — default to copy (no symlink needed)\n    installMode = 'copy';\n  }\n\n  const cwd = process.cwd();\n\n  // Build installation summary\n  const summaryLines: string[] = [];\n  const agentNames = targetAgents.map((a) => agents[a].displayName);\n\n  // Check if any skill will be overwritten (parallel)\n  const overwriteChecks = await Promise.all(\n    selectedSkills.flatMap((skill) =>\n      targetAgents.map(async (agent) => ({\n        skillName: skill.installName,\n        agent,\n        installed: await isSkillInstalled(skill.installName, agent, { global: installGlobally }),\n      }))\n    )\n  );\n  const overwriteStatus = new Map<string, Map<string, boolean>>();\n  for (const { skillName, agent, installed } of overwriteChecks) {\n    if (!overwriteStatus.has(skillName)) {\n      overwriteStatus.set(skillName, new Map());\n    }\n    overwriteStatus.get(skillName)!.set(agent, installed);\n  }\n\n  for (const skill of selectedSkills) {\n    if (summaryLines.length > 0) summaryLines.push('');\n\n    const canonicalPath = getCanonicalPath(skill.installName, { global: installGlobally });\n    const shortCanonical = shortenPath(canonicalPath, cwd);\n    summaryLines.push(`${pc.cyan(shortCanonical)}`);\n    summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode));\n    if (skill.files.size > 1) {\n      summaryLines.push(`  ${pc.dim('files:')} ${skill.files.size}`);\n    }\n\n    const skillOverwrites = overwriteStatus.get(skill.installName);\n    const overwriteAgents = targetAgents\n      .filter((a) => skillOverwrites?.get(a))\n      .map((a) => agents[a].displayName);\n\n    if (overwriteAgents.length > 0) {\n      summaryLines.push(`  ${pc.yellow('overwrites:')} ${formatList(overwriteAgents)}`);\n    }\n  }\n\n  console.log();\n  p.note(summaryLines.join('\\n'), 'Installation Summary');\n\n  if (!options.yes) {\n    const confirmed = await p.confirm({ message: 'Proceed with installation?' });\n\n    if (p.isCancel(confirmed) || !confirmed) {\n      p.cancel('Installation cancelled');\n      process.exit(0);\n    }\n  }\n\n  spinner.start('Installing skills...');\n\n  const results: {\n    skill: string;\n    agent: string;\n    success: boolean;\n    path: string;\n    canonicalPath?: string;\n    mode: InstallMode;\n    symlinkFailed?: boolean;\n    error?: string;\n  }[] = [];\n\n  for (const skill of selectedSkills) {\n    for (const agent of targetAgents) {\n      const result = await installWellKnownSkillForAgent(skill, agent, {\n        global: installGlobally,\n        mode: installMode,\n      });\n      results.push({\n        skill: skill.installName,\n        agent: agents[agent].displayName,\n        ...result,\n      });\n    }\n  }\n\n  spinner.stop('Installation complete');\n\n  console.log();\n  const successful = results.filter((r) => r.success);\n  const failed = results.filter((r) => !r.success);\n\n  // Track installation\n  const sourceIdentifier = wellKnownProvider.getSourceIdentifier(url);\n\n  // Build skillFiles map: { skillName: sourceUrl }\n  const skillFiles: Record<string, string> = {};\n  for (const skill of selectedSkills) {\n    skillFiles[skill.installName] = skill.sourceUrl;\n  }\n\n  // Skip telemetry for private GitHub repos\n  const isPrivate = await isSourcePrivate(sourceIdentifier);\n  if (isPrivate !== true) {\n    // Only send telemetry if repo is public (isPrivate === false) or we can't determine (null for non-GitHub sources)\n    track({\n      event: 'install',\n      source: sourceIdentifier,\n      skills: selectedSkills.map((s) => s.installName).join(','),\n      agents: targetAgents.join(','),\n      ...(installGlobally && { global: '1' }),\n      skillFiles: JSON.stringify(skillFiles),\n      sourceType: 'well-known',\n    });\n  }\n\n  // Add to skill lock file for update tracking (only for global installs)\n  if (successful.length > 0 && installGlobally) {\n    const successfulSkillNames = new Set(successful.map((r) => r.skill));\n    for (const skill of selectedSkills) {\n      if (successfulSkillNames.has(skill.installName)) {\n        try {\n          await addSkillToLock(skill.installName, {\n            source: sourceIdentifier,\n            sourceType: 'well-known',\n            sourceUrl: skill.sourceUrl,\n            skillFolderHash: '', // Well-known skills don't have a folder hash\n          });\n        } catch {\n          // Don't fail installation if lock file update fails\n        }\n      }\n    }\n  }\n\n  // Add to local lock file for project-scoped installs\n  if (successful.length > 0 && !installGlobally) {\n    const successfulSkillNames = new Set(successful.map((r) => r.skill));\n    for (const skill of selectedSkills) {\n      if (successfulSkillNames.has(skill.installName)) {\n        try {\n          const matchingResult = successful.find((r) => r.skill === skill.installName);\n          const installDir = matchingResult?.canonicalPath || matchingResult?.path;\n          if (installDir) {\n            const computedHash = await computeSkillFolderHash(installDir);\n            await addSkillToLocalLock(\n              skill.installName,\n              {\n                source: sourceIdentifier,\n                sourceType: 'well-known',\n                computedHash,\n              },\n              cwd\n            );\n          }\n        } catch {\n          // Don't fail installation if lock file update fails\n        }\n      }\n    }\n  }\n\n  if (successful.length > 0) {\n    const bySkill = new Map<string, typeof results>();\n    for (const r of successful) {\n      const skillResults = bySkill.get(r.skill) || [];\n      skillResults.push(r);\n      bySkill.set(r.skill, skillResults);\n    }\n\n    const skillCount = bySkill.size;\n    const symlinkFailures = successful.filter((r) => r.mode === 'symlink' && r.symlinkFailed);\n    const copiedAgents = symlinkFailures.map((r) => r.agent);\n    const resultLines: string[] = [];\n\n    for (const [skillName, skillResults] of bySkill) {\n      const firstResult = skillResults[0]!;\n\n      if (firstResult.mode === 'copy') {\n        // Copy mode: show skill name and list all agent paths\n        resultLines.push(`${pc.green('✓')} ${skillName} ${pc.dim('(copied)')}`);\n        for (const r of skillResults) {\n          const shortPath = shortenPath(r.path, cwd);\n          resultLines.push(`  ${pc.dim('→')} ${shortPath}`);\n        }\n      } else {\n        // Symlink mode: show canonical path and universal/symlinked agents\n        if (firstResult.canonicalPath) {\n          const shortPath = shortenPath(firstResult.canonicalPath, cwd);\n          resultLines.push(`${pc.green('✓')} ${shortPath}`);\n        } else {\n          resultLines.push(`${pc.green('✓')} ${skillName}`);\n        }\n        resultLines.push(...buildResultLines(skillResults, targetAgents));\n      }\n    }\n\n    const title = pc.green(`Installed ${skillCount} skill${skillCount !== 1 ? 's' : ''}`);\n    p.note(resultLines.join('\\n'), title);\n\n    // Show symlink failure warning (only for symlink mode)\n    if (symlinkFailures.length > 0) {\n      p.log.warn(pc.yellow(`Symlinks failed for: ${formatList(copiedAgents)}`));\n      p.log.message(\n        pc.dim(\n          '  Files were copied instead. On Windows, enable Developer Mode for symlink support.'\n        )\n      );\n    }\n  }\n\n  if (failed.length > 0) {\n    console.log();\n    p.log.error(pc.red(`Failed to install ${failed.length}`));\n    for (const r of failed) {\n      p.log.message(`  ${pc.red('✗')} ${r.skill} → ${r.agent}: ${pc.dim(r.error)}`);\n    }\n  }\n\n  console.log();\n  p.outro(\n    pc.green('Done!') + pc.dim('  Review skills before use; they run with full agent permissions.')\n  );\n\n  // Prompt for find-skills after successful install\n  await promptForFindSkills(options, targetAgents);\n}\n\nexport async function runAdd(args: string[], options: AddOptions = {}): Promise<void> {\n  const source = args[0];\n  let installTipShown = false;\n\n  const showInstallTip = (): void => {\n    if (installTipShown) return;\n    p.log.message(\n      pc.dim('Tip: use the --yes (-y) and --global (-g) flags to install without prompts.')\n    );\n    installTipShown = true;\n  };\n\n  if (!source) {\n    console.log();\n    console.log(\n      pc.bgRed(pc.white(pc.bold(' ERROR '))) + ' ' + pc.red('Missing required argument: source')\n    );\n    console.log();\n    console.log(pc.dim('  Usage:'));\n    console.log(`    ${pc.cyan('npx skills add')} ${pc.yellow('<source>')} ${pc.dim('[options]')}`);\n    console.log();\n    console.log(pc.dim('  Example:'));\n    console.log(`    ${pc.cyan('npx skills add')} ${pc.yellow('vercel-labs/agent-skills')}`);\n    console.log();\n    process.exit(1);\n  }\n\n  // --all implies --skill '*' and --agent '*' and -y\n  if (options.all) {\n    options.skill = ['*'];\n    options.agent = ['*'];\n    options.yes = true;\n  }\n\n  console.log();\n  p.intro(pc.bgCyan(pc.black(' skills ')));\n\n  if (!process.stdin.isTTY) {\n    showInstallTip();\n  }\n\n  let tempDir: string | null = null;\n\n  try {\n    const spinner = p.spinner();\n\n    spinner.start('Parsing source...');\n    const parsed = parseSource(source);\n    spinner.stop(\n      `Source: ${parsed.type === 'local' ? parsed.localPath! : parsed.url}${parsed.ref ? ` @ ${pc.yellow(parsed.ref)}` : ''}${parsed.subpath ? ` (${parsed.subpath})` : ''}${parsed.skillFilter ? ` ${pc.dim('@')}${pc.cyan(parsed.skillFilter)}` : ''}`\n    );\n\n    // Handle well-known skills from arbitrary URLs\n    if (parsed.type === 'well-known') {\n      await handleWellKnownSkills(source, parsed.url, options, spinner);\n      return;\n    }\n\n    let skillsDir: string;\n\n    if (parsed.type === 'local') {\n      // Use local path directly, no cloning needed\n      spinner.start('Validating local path...');\n      if (!existsSync(parsed.localPath!)) {\n        spinner.stop(pc.red('Path not found'));\n        p.outro(pc.red(`Local path does not exist: ${parsed.localPath}`));\n        process.exit(1);\n      }\n      skillsDir = parsed.localPath!;\n      spinner.stop('Local path validated');\n    } else {\n      // Clone repository for remote sources\n      spinner.start('Cloning repository...');\n      tempDir = await cloneRepo(parsed.url, parsed.ref);\n      skillsDir = tempDir;\n      spinner.stop('Repository cloned');\n    }\n\n    // If skillFilter is present from @skill syntax (e.g., owner/repo@skill-name),\n    // merge it into options.skill\n    if (parsed.skillFilter) {\n      options.skill = options.skill || [];\n      if (!options.skill.includes(parsed.skillFilter)) {\n        options.skill.push(parsed.skillFilter);\n      }\n    }\n\n    // Include internal skills when a specific skill is explicitly requested\n    // (via --skill or @skill syntax)\n    const includeInternal = !!(options.skill && options.skill.length > 0);\n\n    spinner.start('Discovering skills...');\n    const skills = await discoverSkills(skillsDir, parsed.subpath, {\n      includeInternal,\n      fullDepth: options.fullDepth,\n    });\n\n    if (skills.length === 0) {\n      spinner.stop(pc.red('No skills found'));\n      p.outro(\n        pc.red('No valid skills found. Skills require a SKILL.md with name and description.')\n      );\n      await cleanup(tempDir);\n      process.exit(1);\n    }\n\n    spinner.stop(`Found ${pc.green(skills.length)} skill${skills.length > 1 ? 's' : ''}`);\n\n    if (options.list) {\n      console.log();\n      p.log.step(pc.bold('Available Skills'));\n\n      // Group available skills by plugin for list output\n      const groupedSkills: Record<string, Skill[]> = {};\n      const ungroupedSkills: Skill[] = [];\n\n      for (const skill of skills) {\n        if (skill.pluginName) {\n          const group = skill.pluginName;\n          if (!groupedSkills[group]) groupedSkills[group] = [];\n          groupedSkills[group].push(skill);\n        } else {\n          ungroupedSkills.push(skill);\n        }\n      }\n\n      // Print groups\n      const sortedGroups = Object.keys(groupedSkills).sort();\n      for (const group of sortedGroups) {\n        // Convert kebab-case to Title Case for display header\n        const title = group\n          .split('-')\n          .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n          .join(' ');\n\n        console.log(pc.bold(title));\n        for (const skill of groupedSkills[group]!) {\n          p.log.message(`  ${pc.cyan(getSkillDisplayName(skill))}`);\n          p.log.message(`    ${pc.dim(skill.description)}`);\n        }\n        console.log();\n      }\n\n      // Print ungrouped\n      if (ungroupedSkills.length > 0) {\n        if (sortedGroups.length > 0) console.log(pc.bold('General'));\n        for (const skill of ungroupedSkills) {\n          p.log.message(`  ${pc.cyan(getSkillDisplayName(skill))}`);\n          p.log.message(`    ${pc.dim(skill.description)}`);\n        }\n      }\n\n      console.log();\n      p.outro('Use --skill <name> to install specific skills');\n      await cleanup(tempDir);\n      process.exit(0);\n    }\n\n    let selectedSkills: Skill[];\n\n    if (options.skill?.includes('*')) {\n      // --skill '*' selects all skills\n      selectedSkills = skills;\n      p.log.info(`Installing all ${skills.length} skills`);\n    } else if (options.skill && options.skill.length > 0) {\n      selectedSkills = filterSkills(skills, options.skill);\n\n      if (selectedSkills.length === 0) {\n        p.log.error(`No matching skills found for: ${options.skill.join(', ')}`);\n        p.log.info('Available skills:');\n        for (const s of skills) {\n          p.log.message(`  - ${getSkillDisplayName(s)}`);\n        }\n        await cleanup(tempDir);\n        process.exit(1);\n      }\n\n      p.log.info(\n        `Selected ${selectedSkills.length} skill${selectedSkills.length !== 1 ? 's' : ''}: ${selectedSkills.map((s) => pc.cyan(getSkillDisplayName(s))).join(', ')}`\n      );\n    } else if (skills.length === 1) {\n      selectedSkills = skills;\n      const firstSkill = skills[0]!;\n      p.log.info(`Skill: ${pc.cyan(getSkillDisplayName(firstSkill))}`);\n      p.log.message(pc.dim(firstSkill.description));\n    } else if (options.yes) {\n      selectedSkills = skills;\n      p.log.info(`Installing all ${skills.length} skills`);\n    } else {\n      // Sort skills by plugin name first, then by skill name\n      const sortedSkills = [...skills].sort((a, b) => {\n        if (a.pluginName && !b.pluginName) return -1;\n        if (!a.pluginName && b.pluginName) return 1;\n        if (a.pluginName && b.pluginName && a.pluginName !== b.pluginName) {\n          return a.pluginName.localeCompare(b.pluginName);\n        }\n        return getSkillDisplayName(a).localeCompare(getSkillDisplayName(b));\n      });\n\n      // Check if any skills have plugin grouping\n      const hasGroups = sortedSkills.some((s) => s.pluginName);\n\n      let selected: Skill[] | symbol;\n\n      if (hasGroups) {\n        // Build grouped options for groupMultiselect\n        const kebabToTitle = (s: string) =>\n          s\n            .split('-')\n            .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n            .join(' ');\n\n        const grouped: Record<string, p.Option<Skill>[]> = {};\n        for (const s of sortedSkills) {\n          const groupName = s.pluginName ? kebabToTitle(s.pluginName) : 'Other';\n          if (!grouped[groupName]) grouped[groupName] = [];\n          grouped[groupName]!.push({\n            value: s,\n            label: getSkillDisplayName(s),\n            hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description,\n          });\n        }\n\n        selected = await p.groupMultiselect({\n          message: `Select skills to install ${pc.dim('(space to toggle)')}`,\n          options: grouped,\n          required: true,\n        });\n      } else {\n        const skillChoices = sortedSkills.map((s) => ({\n          value: s,\n          label: getSkillDisplayName(s),\n          hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description,\n        }));\n\n        selected = await multiselect({\n          message: 'Select skills to install',\n          options: skillChoices,\n          required: true,\n        });\n      }\n\n      if (p.isCancel(selected)) {\n        p.cancel('Installation cancelled');\n        await cleanup(tempDir);\n        process.exit(0);\n      }\n\n      selectedSkills = selected as Skill[];\n    }\n\n    // Kick off security audit fetch early (non-blocking) so it runs\n    // in parallel with agent selection, scope, and mode prompts.\n    const ownerRepoForAudit = getOwnerRepo(parsed);\n    const auditPromise = ownerRepoForAudit\n      ? fetchAuditData(\n          ownerRepoForAudit,\n          selectedSkills.map((s) => getSkillDisplayName(s))\n        )\n      : Promise.resolve(null);\n\n    let targetAgents: AgentType[];\n    const validAgents = Object.keys(agents);\n\n    if (options.agent?.includes('*')) {\n      // --agent '*' selects all agents\n      targetAgents = validAgents as AgentType[];\n      p.log.info(`Installing to all ${targetAgents.length} agents`);\n    } else if (options.agent && options.agent.length > 0) {\n      const invalidAgents = options.agent.filter((a) => !validAgents.includes(a));\n\n      if (invalidAgents.length > 0) {\n        p.log.error(`Invalid agents: ${invalidAgents.join(', ')}`);\n        p.log.info(`Valid agents: ${validAgents.join(', ')}`);\n        await cleanup(tempDir);\n        process.exit(1);\n      }\n\n      targetAgents = options.agent as AgentType[];\n    } else {\n      spinner.start('Loading agents...');\n      const installedAgents = await detectInstalledAgents();\n      const totalAgents = Object.keys(agents).length;\n      spinner.stop(`${totalAgents} agents`);\n\n      if (installedAgents.length === 0) {\n        if (options.yes) {\n          targetAgents = validAgents as AgentType[];\n          p.log.info('Installing to all agents');\n        } else {\n          p.log.info('Select agents to install skills to');\n\n          const allAgentChoices = Object.entries(agents).map(([key, config]) => ({\n            value: key as AgentType,\n            label: config.displayName,\n          }));\n\n          // Use helper to prompt with search\n          const selected = await promptForAgents(\n            'Which agents do you want to install to?',\n            allAgentChoices\n          );\n\n          if (p.isCancel(selected)) {\n            p.cancel('Installation cancelled');\n            await cleanup(tempDir);\n            process.exit(0);\n          }\n\n          targetAgents = selected as AgentType[];\n        }\n      } else if (installedAgents.length === 1 || options.yes) {\n        // Auto-select detected agents + ensure universal agents are included\n        targetAgents = ensureUniversalAgents(installedAgents);\n        if (installedAgents.length === 1) {\n          const firstAgent = installedAgents[0]!;\n          p.log.info(`Installing to: ${pc.cyan(agents[firstAgent].displayName)}`);\n        } else {\n          p.log.info(\n            `Installing to: ${installedAgents.map((a) => pc.cyan(agents[a].displayName)).join(', ')}`\n          );\n        }\n      } else {\n        const selected = await selectAgentsInteractive({ global: options.global });\n\n        if (p.isCancel(selected)) {\n          p.cancel('Installation cancelled');\n          await cleanup(tempDir);\n          process.exit(0);\n        }\n\n        targetAgents = selected as AgentType[];\n      }\n    }\n\n    let installGlobally = options.global ?? false;\n\n    // Check if any selected agents support global installation\n    const supportsGlobal = targetAgents.some((a) => agents[a].globalSkillsDir !== undefined);\n\n    if (options.global === undefined && !options.yes && supportsGlobal) {\n      const scope = await p.select({\n        message: 'Installation scope',\n        options: [\n          {\n            value: false,\n            label: 'Project',\n            hint: 'Install in current directory (committed with your project)',\n          },\n          {\n            value: true,\n            label: 'Global',\n            hint: 'Install in home directory (available across all projects)',\n          },\n        ],\n      });\n\n      if (p.isCancel(scope)) {\n        p.cancel('Installation cancelled');\n        await cleanup(tempDir);\n        process.exit(0);\n      }\n\n      installGlobally = scope as boolean;\n    }\n\n    // Determine install mode (symlink vs copy)\n    let installMode: InstallMode = options.copy ? 'copy' : 'symlink';\n\n    // Only prompt for install mode when there are multiple unique target directories.\n    // When all selected agents share the same skillsDir, symlink vs copy is meaningless.\n    const uniqueDirs = new Set(targetAgents.map((a) => agents[a].skillsDir));\n\n    if (!options.copy && !options.yes && uniqueDirs.size > 1) {\n      const modeChoice = await p.select({\n        message: 'Installation method',\n        options: [\n          {\n            value: 'symlink',\n            label: 'Symlink (Recommended)',\n            hint: 'Single source of truth, easy updates',\n          },\n          { value: 'copy', label: 'Copy to all agents', hint: 'Independent copies for each agent' },\n        ],\n      });\n\n      if (p.isCancel(modeChoice)) {\n        p.cancel('Installation cancelled');\n        await cleanup(tempDir);\n        process.exit(0);\n      }\n\n      installMode = modeChoice as InstallMode;\n    } else if (uniqueDirs.size <= 1) {\n      // Single target directory — default to copy (no symlink needed)\n      installMode = 'copy';\n    }\n\n    const cwd = process.cwd();\n\n    // Build installation summary\n    const summaryLines: string[] = [];\n    const agentNames = targetAgents.map((a) => agents[a].displayName);\n\n    // Check if any skill will be overwritten (parallel)\n    const overwriteChecks = await Promise.all(\n      selectedSkills.flatMap((skill) =>\n        targetAgents.map(async (agent) => ({\n          skillName: skill.name,\n          agent,\n          installed: await isSkillInstalled(skill.name, agent, { global: installGlobally }),\n        }))\n      )\n    );\n    const overwriteStatus = new Map<string, Map<string, boolean>>();\n    for (const { skillName, agent, installed } of overwriteChecks) {\n      if (!overwriteStatus.has(skillName)) {\n        overwriteStatus.set(skillName, new Map());\n      }\n      overwriteStatus.get(skillName)!.set(agent, installed);\n    }\n\n    // Group selected skills for summary\n    const groupedSummary: Record<string, Skill[]> = {};\n    const ungroupedSummary: Skill[] = [];\n\n    for (const skill of selectedSkills) {\n      if (skill.pluginName) {\n        const group = skill.pluginName;\n        if (!groupedSummary[group]) groupedSummary[group] = [];\n        groupedSummary[group].push(skill);\n      } else {\n        ungroupedSummary.push(skill);\n      }\n    }\n\n    // Helper to print summary lines for a list of skills\n    const printSkillSummary = (skills: Skill[]) => {\n      for (const skill of skills) {\n        if (summaryLines.length > 0) summaryLines.push('');\n\n        const canonicalPath = getCanonicalPath(skill.name, { global: installGlobally });\n        const shortCanonical = shortenPath(canonicalPath, cwd);\n        summaryLines.push(`${pc.cyan(shortCanonical)}`);\n        summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode));\n\n        const skillOverwrites = overwriteStatus.get(skill.name);\n        const overwriteAgents = targetAgents\n          .filter((a) => skillOverwrites?.get(a))\n          .map((a) => agents[a].displayName);\n\n        if (overwriteAgents.length > 0) {\n          summaryLines.push(`  ${pc.yellow('overwrites:')} ${formatList(overwriteAgents)}`);\n        }\n      }\n    };\n\n    // Build grouped summary\n    const sortedGroups = Object.keys(groupedSummary).sort();\n\n    for (const group of sortedGroups) {\n      const title = group\n        .split('-')\n        .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n        .join(' ');\n\n      summaryLines.push('');\n      summaryLines.push(pc.bold(title));\n      printSkillSummary(groupedSummary[group]!);\n    }\n\n    if (ungroupedSummary.length > 0) {\n      if (sortedGroups.length > 0) {\n        summaryLines.push('');\n        summaryLines.push(pc.bold('General'));\n      }\n      printSkillSummary(ungroupedSummary);\n    }\n\n    console.log();\n    p.note(summaryLines.join('\\n'), 'Installation Summary');\n\n    // Await and display security audit results (started earlier in parallel)\n    // Wrapped in try/catch so a failed audit fetch never blocks installation.\n    try {\n      const auditData = await auditPromise;\n      if (auditData && ownerRepoForAudit) {\n        const securityLines = buildSecurityLines(\n          auditData,\n          selectedSkills.map((s) => ({\n            slug: getSkillDisplayName(s),\n            displayName: getSkillDisplayName(s),\n          })),\n          ownerRepoForAudit\n        );\n        if (securityLines.length > 0) {\n          p.note(securityLines.join('\\n'), 'Security Risk Assessments');\n        }\n      }\n    } catch {\n      // Silently skip — security info is advisory only\n    }\n\n    if (!options.yes) {\n      const confirmed = await p.confirm({ message: 'Proceed with installation?' });\n\n      if (p.isCancel(confirmed) || !confirmed) {\n        p.cancel('Installation cancelled');\n        await cleanup(tempDir);\n        process.exit(0);\n      }\n    }\n\n    spinner.start('Installing skills...');\n\n    const results: {\n      skill: string;\n      agent: string;\n      success: boolean;\n      path: string;\n      canonicalPath?: string;\n      mode: InstallMode;\n      symlinkFailed?: boolean;\n      error?: string;\n      pluginName?: string;\n    }[] = [];\n\n    for (const skill of selectedSkills) {\n      for (const agent of targetAgents) {\n        const result = await installSkillForAgent(skill, agent, {\n          global: installGlobally,\n          mode: installMode,\n        });\n        results.push({\n          skill: getSkillDisplayName(skill),\n          agent: agents[agent].displayName,\n          pluginName: skill.pluginName,\n          ...result,\n        });\n      }\n    }\n\n    spinner.stop('Installation complete');\n\n    console.log();\n    const successful = results.filter((r) => r.success);\n    const failed = results.filter((r) => !r.success);\n\n    // Track installation result\n    // Build skillFiles map: { skillName: relative path to SKILL.md from repo root }\n    const skillFiles: Record<string, string> = {};\n    for (const skill of selectedSkills) {\n      // skill.path is absolute, compute relative from tempDir (repo root)\n      let relativePath: string;\n      if (tempDir && skill.path === tempDir) {\n        // Skill is at root level of repo\n        relativePath = 'SKILL.md';\n      } else if (tempDir && skill.path.startsWith(tempDir + sep)) {\n        // Compute path relative to repo root (tempDir), not search path\n        // Use forward slashes for telemetry (URL-style paths)\n        relativePath =\n          skill.path\n            .slice(tempDir.length + 1)\n            .split(sep)\n            .join('/') + '/SKILL.md';\n      } else {\n        // Local path - skip telemetry for local installs\n        continue;\n      }\n      skillFiles[skill.name] = relativePath;\n    }\n\n    // Normalize source to owner/repo format for telemetry\n    const normalizedSource = getOwnerRepo(parsed);\n\n    // Preserve SSH URLs in lock files instead of normalizing to owner/repo shorthand.\n    // When normalizedSource is used, parseSource() later resolves it to HTTPS,\n    // breaking restore for private repos that require SSH authentication.\n    const isSSH = parsed.url.startsWith('git@');\n    const lockSource = isSSH ? parsed.url : normalizedSource;\n\n    // Only track if we have a valid remote source and it's not a private repo\n    if (normalizedSource) {\n      const ownerRepo = parseOwnerRepo(normalizedSource);\n      if (ownerRepo) {\n        // Check if repo is private - skip telemetry for private repos\n        const isPrivate = await isRepoPrivate(ownerRepo.owner, ownerRepo.repo);\n        // Only send telemetry if repo is public (isPrivate === false)\n        // If we can't determine (null), err on the side of caution and skip telemetry\n        if (isPrivate === false) {\n          track({\n            event: 'install',\n            source: normalizedSource,\n            skills: selectedSkills.map((s) => s.name).join(','),\n            agents: targetAgents.join(','),\n            ...(installGlobally && { global: '1' }),\n            skillFiles: JSON.stringify(skillFiles),\n          });\n        }\n      } else {\n        // If we can't parse owner/repo, still send telemetry (for non-GitHub sources)\n        track({\n          event: 'install',\n          source: normalizedSource,\n          skills: selectedSkills.map((s) => s.name).join(','),\n          agents: targetAgents.join(','),\n          ...(installGlobally && { global: '1' }),\n          skillFiles: JSON.stringify(skillFiles),\n        });\n      }\n    }\n\n    // Add to skill lock file for update tracking (only for global installs)\n    if (successful.length > 0 && installGlobally && normalizedSource) {\n      const successfulSkillNames = new Set(successful.map((r) => r.skill));\n      for (const skill of selectedSkills) {\n        const skillDisplayName = getSkillDisplayName(skill);\n        if (successfulSkillNames.has(skillDisplayName)) {\n          try {\n            // Fetch the folder hash from GitHub Trees API\n            let skillFolderHash = '';\n            const skillPathValue = skillFiles[skill.name];\n            if (parsed.type === 'github' && skillPathValue) {\n              const token = getGitHubToken();\n              const hash = await fetchSkillFolderHash(normalizedSource, skillPathValue, token);\n              if (hash) skillFolderHash = hash;\n            }\n\n            await addSkillToLock(skill.name, {\n              source: lockSource || normalizedSource,\n              sourceType: parsed.type,\n              sourceUrl: parsed.url,\n              skillPath: skillPathValue,\n              skillFolderHash,\n              pluginName: skill.pluginName,\n            });\n          } catch {\n            // Don't fail installation if lock file update fails\n          }\n        }\n      }\n    }\n\n    // Add to local lock file for project-scoped installs\n    if (successful.length > 0 && !installGlobally) {\n      const successfulSkillNames = new Set(successful.map((r) => r.skill));\n      for (const skill of selectedSkills) {\n        const skillDisplayName = getSkillDisplayName(skill);\n        if (successfulSkillNames.has(skillDisplayName)) {\n          try {\n            const computedHash = await computeSkillFolderHash(skill.path);\n            await addSkillToLocalLock(\n              skill.name,\n              {\n                source: lockSource || parsed.url,\n                sourceType: parsed.type,\n                computedHash,\n              },\n              cwd\n            );\n          } catch {\n            // Don't fail installation if lock file update fails\n          }\n        }\n      }\n    }\n\n    if (successful.length > 0) {\n      const bySkill = new Map<string, typeof results>();\n\n      // Group results by plugin name\n      const groupedResults: Record<string, typeof results> = {};\n      const ungroupedResults: typeof results = [];\n\n      for (const r of successful) {\n        const skillResults = bySkill.get(r.skill) || [];\n        skillResults.push(r);\n        bySkill.set(r.skill, skillResults);\n\n        // We only need to group once per skill (take the first result for that skill)\n        if (skillResults.length === 1) {\n          if (r.pluginName) {\n            const group = r.pluginName;\n            if (!groupedResults[group]) groupedResults[group] = [];\n            // We'll store just one entry per skill here to drive the loop\n            groupedResults[group].push(r);\n          } else {\n            ungroupedResults.push(r);\n          }\n        }\n      }\n\n      const skillCount = bySkill.size;\n      const symlinkFailures = successful.filter((r) => r.mode === 'symlink' && r.symlinkFailed);\n      const copiedAgents = symlinkFailures.map((r) => r.agent);\n      const resultLines: string[] = [];\n\n      const printSkillResults = (entries: typeof results) => {\n        for (const entry of entries) {\n          const skillResults = bySkill.get(entry.skill) || [];\n          const firstResult = skillResults[0]!;\n\n          if (firstResult.mode === 'copy') {\n            // Copy mode: show skill name and list all agent paths\n            resultLines.push(`${pc.green('✓')} ${entry.skill} ${pc.dim('(copied)')}`);\n            for (const r of skillResults) {\n              const shortPath = shortenPath(r.path, cwd);\n              resultLines.push(`  ${pc.dim('→')} ${shortPath}`);\n            }\n          } else {\n            // Symlink mode: show canonical path and universal/symlinked agents\n            if (firstResult.canonicalPath) {\n              const shortPath = shortenPath(firstResult.canonicalPath, cwd);\n              resultLines.push(`${pc.green('✓')} ${shortPath}`);\n            } else {\n              resultLines.push(`${pc.green('✓')} ${entry.skill}`);\n            }\n            resultLines.push(...buildResultLines(skillResults, targetAgents));\n          }\n        }\n      };\n\n      // Print grouped results\n      const sortedResultGroups = Object.keys(groupedResults).sort();\n\n      for (const group of sortedResultGroups) {\n        const title = group\n          .split('-')\n          .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n          .join(' ');\n\n        resultLines.push('');\n        resultLines.push(pc.bold(title));\n        printSkillResults(groupedResults[group]!);\n      }\n\n      if (ungroupedResults.length > 0) {\n        if (sortedResultGroups.length > 0) {\n          resultLines.push('');\n          resultLines.push(pc.bold('General'));\n        }\n        printSkillResults(ungroupedResults);\n      }\n\n      const title = pc.green(`Installed ${skillCount} skill${skillCount !== 1 ? 's' : ''}`);\n      p.note(resultLines.join('\\n'), title);\n\n      // Show symlink failure warning (only for symlink mode)\n      if (symlinkFailures.length > 0) {\n        p.log.warn(pc.yellow(`Symlinks failed for: ${formatList(copiedAgents)}`));\n        p.log.message(\n          pc.dim(\n            '  Files were copied instead. On Windows, enable Developer Mode for symlink support.'\n          )\n        );\n      }\n    }\n\n    if (failed.length > 0) {\n      console.log();\n      p.log.error(pc.red(`Failed to install ${failed.length}`));\n      for (const r of failed) {\n        p.log.message(`  ${pc.red('✗')} ${r.skill} → ${r.agent}: ${pc.dim(r.error)}`);\n      }\n    }\n\n    console.log();\n    p.outro(\n      pc.green('Done!') +\n        pc.dim('  Review skills before use; they run with full agent permissions.')\n    );\n\n    // Prompt for find-skills after successful install\n    await promptForFindSkills(options, targetAgents);\n  } catch (error) {\n    if (error instanceof GitCloneError) {\n      p.log.error(pc.red('Failed to clone repository'));\n      // Print each line of the error message separately for better formatting\n      for (const line of error.message.split('\\n')) {\n        p.log.message(pc.dim(line));\n      }\n    } else {\n      p.log.error(error instanceof Error ? error.message : 'Unknown error occurred');\n    }\n    showInstallTip();\n    p.outro(pc.red('Installation failed'));\n    process.exit(1);\n  } finally {\n    await cleanup(tempDir);\n  }\n}\n\n// Cleanup helper\nasync function cleanup(tempDir: string | null) {\n  if (tempDir) {\n    try {\n      await cleanupTempDir(tempDir);\n    } catch {\n      // Ignore cleanup errors\n    }\n  }\n}\n\n/**\n * Prompt user to install the find-skills skill after their first installation.\n */\nasync function promptForFindSkills(\n  options?: AddOptions,\n  targetAgents?: AgentType[]\n): Promise<void> {\n  // Skip if already dismissed or not in interactive mode\n  if (!process.stdin.isTTY) return;\n  if (options?.yes) return;\n\n  try {\n    const dismissed = await isPromptDismissed('findSkillsPrompt');\n    if (dismissed) return;\n\n    // Check if find-skills is already installed\n    const findSkillsInstalled = await isSkillInstalled('find-skills', 'claude-code', {\n      global: true,\n    });\n    if (findSkillsInstalled) {\n      // Mark as dismissed so we don't check again\n      await dismissPrompt('findSkillsPrompt');\n      return;\n    }\n\n    console.log();\n    p.log.message(pc.dim(\"One-time prompt - you won't be asked again if you dismiss.\"));\n    const install = await p.confirm({\n      message: `Install the ${pc.cyan('find-skills')} skill? It helps your agent discover and suggest skills.`,\n    });\n\n    if (p.isCancel(install)) {\n      await dismissPrompt('findSkillsPrompt');\n      return;\n    }\n\n    if (install) {\n      // Install find-skills to the same agents the user selected, excluding replit\n      await dismissPrompt('findSkillsPrompt');\n\n      // Filter out replit from target agents\n      const findSkillsAgents = targetAgents?.filter((a) => a !== 'replit');\n\n      // Skip if no valid agents remain after filtering\n      if (!findSkillsAgents || findSkillsAgents.length === 0) {\n        return;\n      }\n\n      console.log();\n      p.log.step('Installing find-skills skill...');\n\n      try {\n        // Call runAdd directly\n        await runAdd(['vercel-labs/skills'], {\n          skill: ['find-skills'],\n          global: true,\n          yes: true,\n          agent: findSkillsAgents,\n        });\n      } catch {\n        p.log.warn('Failed to install find-skills. You can try again with:');\n        p.log.message(pc.dim('  npx skills add vercel-labs/skills@find-skills -g -y --all'));\n      }\n    } else {\n      // User declined - dismiss the prompt\n      await dismissPrompt('findSkillsPrompt');\n      p.log.message(\n        pc.dim('You can install it later with: npx skills add vercel-labs/skills@find-skills')\n      );\n    }\n  } catch {\n    // Don't fail the main installation if prompt fails\n  }\n}\n\n// Parse command line options from args array\nexport function parseAddOptions(args: string[]): { source: string[]; options: AddOptions } {\n  const options: AddOptions = {};\n  const source: string[] = [];\n\n  for (let i = 0; i < args.length; i++) {\n    const arg = args[i];\n\n    if (arg === '-g' || arg === '--global') {\n      options.global = true;\n    } else if (arg === '-y' || arg === '--yes') {\n      options.yes = true;\n    } else if (arg === '-l' || arg === '--list') {\n      options.list = true;\n    } else if (arg === '--all') {\n      options.all = true;\n    } else if (arg === '-a' || arg === '--agent') {\n      options.agent = options.agent || [];\n      i++;\n      let nextArg = args[i];\n      while (i < args.length && nextArg && !nextArg.startsWith('-')) {\n        options.agent.push(nextArg);\n        i++;\n        nextArg = args[i];\n      }\n      i--; // Back up one since the loop will increment\n    } else if (arg === '-s' || arg === '--skill') {\n      options.skill = options.skill || [];\n      i++;\n      let nextArg = args[i];\n      while (i < args.length && nextArg && !nextArg.startsWith('-')) {\n        options.skill.push(nextArg);\n        i++;\n        nextArg = args[i];\n      }\n      i--; // Back up one since the loop will increment\n    } else if (arg === '--full-depth') {\n      options.fullDepth = true;\n    } else if (arg === '--copy') {\n      options.copy = true;\n    } else if (arg && !arg.startsWith('-')) {\n      source.push(arg);\n    }\n  }\n\n  return { source, options };\n}\n"
  },
  {
    "path": "src/agents.ts",
    "content": "import { homedir } from 'os';\nimport { join } from 'path';\nimport { existsSync } from 'fs';\nimport { xdgConfig } from 'xdg-basedir';\nimport type { AgentConfig, AgentType } from './types.ts';\n\nconst home = homedir();\n// Use xdg-basedir (not env-paths) to match OpenCode/Amp/Goose behavior on all platforms.\nconst configHome = xdgConfig ?? join(home, '.config');\nconst codexHome = process.env.CODEX_HOME?.trim() || join(home, '.codex');\nconst claudeHome = process.env.CLAUDE_CONFIG_DIR?.trim() || join(home, '.claude');\n\nexport function getOpenClawGlobalSkillsDir(\n  homeDir = home,\n  pathExists: (path: string) => boolean = existsSync\n) {\n  if (pathExists(join(homeDir, '.openclaw'))) {\n    return join(homeDir, '.openclaw/skills');\n  }\n  if (pathExists(join(homeDir, '.clawdbot'))) {\n    return join(homeDir, '.clawdbot/skills');\n  }\n  if (pathExists(join(homeDir, '.moltbot'))) {\n    return join(homeDir, '.moltbot/skills');\n  }\n  return join(homeDir, '.openclaw/skills');\n}\n\nexport const agents: Record<AgentType, AgentConfig> = {\n  amp: {\n    name: 'amp',\n    displayName: 'Amp',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(configHome, 'agents/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(configHome, 'amp'));\n    },\n  },\n  antigravity: {\n    name: 'antigravity',\n    displayName: 'Antigravity',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(home, '.gemini/antigravity/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.gemini/antigravity'));\n    },\n  },\n  augment: {\n    name: 'augment',\n    displayName: 'Augment',\n    skillsDir: '.augment/skills',\n    globalSkillsDir: join(home, '.augment/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.augment'));\n    },\n  },\n  'claude-code': {\n    name: 'claude-code',\n    displayName: 'Claude Code',\n    skillsDir: '.claude/skills',\n    globalSkillsDir: join(claudeHome, 'skills'),\n    detectInstalled: async () => {\n      return existsSync(claudeHome);\n    },\n  },\n  openclaw: {\n    name: 'openclaw',\n    displayName: 'OpenClaw',\n    skillsDir: 'skills',\n    globalSkillsDir: getOpenClawGlobalSkillsDir(),\n    detectInstalled: async () => {\n      return (\n        existsSync(join(home, '.openclaw')) ||\n        existsSync(join(home, '.clawdbot')) ||\n        existsSync(join(home, '.moltbot'))\n      );\n    },\n  },\n  cline: {\n    name: 'cline',\n    displayName: 'Cline',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(home, '.agents', 'skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.cline'));\n    },\n  },\n  codebuddy: {\n    name: 'codebuddy',\n    displayName: 'CodeBuddy',\n    skillsDir: '.codebuddy/skills',\n    globalSkillsDir: join(home, '.codebuddy/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(process.cwd(), '.codebuddy')) || existsSync(join(home, '.codebuddy'));\n    },\n  },\n  codex: {\n    name: 'codex',\n    displayName: 'Codex',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(codexHome, 'skills'),\n    detectInstalled: async () => {\n      return existsSync(codexHome) || existsSync('/etc/codex');\n    },\n  },\n  'command-code': {\n    name: 'command-code',\n    displayName: 'Command Code',\n    skillsDir: '.commandcode/skills',\n    globalSkillsDir: join(home, '.commandcode/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.commandcode'));\n    },\n  },\n  continue: {\n    name: 'continue',\n    displayName: 'Continue',\n    skillsDir: '.continue/skills',\n    globalSkillsDir: join(home, '.continue/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(process.cwd(), '.continue')) || existsSync(join(home, '.continue'));\n    },\n  },\n  cortex: {\n    name: 'cortex',\n    displayName: 'Cortex Code',\n    skillsDir: '.cortex/skills',\n    globalSkillsDir: join(home, '.snowflake/cortex/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.snowflake/cortex'));\n    },\n  },\n  crush: {\n    name: 'crush',\n    displayName: 'Crush',\n    skillsDir: '.crush/skills',\n    globalSkillsDir: join(home, '.config/crush/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.config/crush'));\n    },\n  },\n  cursor: {\n    name: 'cursor',\n    displayName: 'Cursor',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(home, '.cursor/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.cursor'));\n    },\n  },\n  deepagents: {\n    name: 'deepagents',\n    displayName: 'Deep Agents',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(home, '.deepagents/agent/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.deepagents'));\n    },\n  },\n  droid: {\n    name: 'droid',\n    displayName: 'Droid',\n    skillsDir: '.factory/skills',\n    globalSkillsDir: join(home, '.factory/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.factory'));\n    },\n  },\n  'gemini-cli': {\n    name: 'gemini-cli',\n    displayName: 'Gemini CLI',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(home, '.gemini/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.gemini'));\n    },\n  },\n  'github-copilot': {\n    name: 'github-copilot',\n    displayName: 'GitHub Copilot',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(home, '.copilot/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.copilot'));\n    },\n  },\n  goose: {\n    name: 'goose',\n    displayName: 'Goose',\n    skillsDir: '.goose/skills',\n    globalSkillsDir: join(configHome, 'goose/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(configHome, 'goose'));\n    },\n  },\n  junie: {\n    name: 'junie',\n    displayName: 'Junie',\n    skillsDir: '.junie/skills',\n    globalSkillsDir: join(home, '.junie/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.junie'));\n    },\n  },\n  'iflow-cli': {\n    name: 'iflow-cli',\n    displayName: 'iFlow CLI',\n    skillsDir: '.iflow/skills',\n    globalSkillsDir: join(home, '.iflow/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.iflow'));\n    },\n  },\n  kilo: {\n    name: 'kilo',\n    displayName: 'Kilo Code',\n    skillsDir: '.kilocode/skills',\n    globalSkillsDir: join(home, '.kilocode/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.kilocode'));\n    },\n  },\n  'kimi-cli': {\n    name: 'kimi-cli',\n    displayName: 'Kimi Code CLI',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(home, '.config/agents/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.kimi'));\n    },\n  },\n  'kiro-cli': {\n    name: 'kiro-cli',\n    displayName: 'Kiro CLI',\n    skillsDir: '.kiro/skills',\n    globalSkillsDir: join(home, '.kiro/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.kiro'));\n    },\n  },\n  kode: {\n    name: 'kode',\n    displayName: 'Kode',\n    skillsDir: '.kode/skills',\n    globalSkillsDir: join(home, '.kode/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.kode'));\n    },\n  },\n  mcpjam: {\n    name: 'mcpjam',\n    displayName: 'MCPJam',\n    skillsDir: '.mcpjam/skills',\n    globalSkillsDir: join(home, '.mcpjam/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.mcpjam'));\n    },\n  },\n  'mistral-vibe': {\n    name: 'mistral-vibe',\n    displayName: 'Mistral Vibe',\n    skillsDir: '.vibe/skills',\n    globalSkillsDir: join(home, '.vibe/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.vibe'));\n    },\n  },\n  mux: {\n    name: 'mux',\n    displayName: 'Mux',\n    skillsDir: '.mux/skills',\n    globalSkillsDir: join(home, '.mux/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.mux'));\n    },\n  },\n  opencode: {\n    name: 'opencode',\n    displayName: 'OpenCode',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(configHome, 'opencode/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(configHome, 'opencode'));\n    },\n  },\n  openhands: {\n    name: 'openhands',\n    displayName: 'OpenHands',\n    skillsDir: '.openhands/skills',\n    globalSkillsDir: join(home, '.openhands/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.openhands'));\n    },\n  },\n  pi: {\n    name: 'pi',\n    displayName: 'Pi',\n    skillsDir: '.pi/skills',\n    globalSkillsDir: join(home, '.pi/agent/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.pi/agent'));\n    },\n  },\n  qoder: {\n    name: 'qoder',\n    displayName: 'Qoder',\n    skillsDir: '.qoder/skills',\n    globalSkillsDir: join(home, '.qoder/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.qoder'));\n    },\n  },\n  'qwen-code': {\n    name: 'qwen-code',\n    displayName: 'Qwen Code',\n    skillsDir: '.qwen/skills',\n    globalSkillsDir: join(home, '.qwen/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.qwen'));\n    },\n  },\n  replit: {\n    name: 'replit',\n    displayName: 'Replit',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(configHome, 'agents/skills'),\n    showInUniversalList: false,\n    detectInstalled: async () => {\n      return existsSync(join(process.cwd(), '.replit'));\n    },\n  },\n  roo: {\n    name: 'roo',\n    displayName: 'Roo Code',\n    skillsDir: '.roo/skills',\n    globalSkillsDir: join(home, '.roo/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.roo'));\n    },\n  },\n  trae: {\n    name: 'trae',\n    displayName: 'Trae',\n    skillsDir: '.trae/skills',\n    globalSkillsDir: join(home, '.trae/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.trae'));\n    },\n  },\n  'trae-cn': {\n    name: 'trae-cn',\n    displayName: 'Trae CN',\n    skillsDir: '.trae/skills',\n    globalSkillsDir: join(home, '.trae-cn/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.trae-cn'));\n    },\n  },\n  warp: {\n    name: 'warp',\n    displayName: 'Warp',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(home, '.agents/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.warp'));\n    },\n  },\n  windsurf: {\n    name: 'windsurf',\n    displayName: 'Windsurf',\n    skillsDir: '.windsurf/skills',\n    globalSkillsDir: join(home, '.codeium/windsurf/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.codeium/windsurf'));\n    },\n  },\n  zencoder: {\n    name: 'zencoder',\n    displayName: 'Zencoder',\n    skillsDir: '.zencoder/skills',\n    globalSkillsDir: join(home, '.zencoder/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.zencoder'));\n    },\n  },\n  neovate: {\n    name: 'neovate',\n    displayName: 'Neovate',\n    skillsDir: '.neovate/skills',\n    globalSkillsDir: join(home, '.neovate/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.neovate'));\n    },\n  },\n  pochi: {\n    name: 'pochi',\n    displayName: 'Pochi',\n    skillsDir: '.pochi/skills',\n    globalSkillsDir: join(home, '.pochi/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.pochi'));\n    },\n  },\n  adal: {\n    name: 'adal',\n    displayName: 'AdaL',\n    skillsDir: '.adal/skills',\n    globalSkillsDir: join(home, '.adal/skills'),\n    detectInstalled: async () => {\n      return existsSync(join(home, '.adal'));\n    },\n  },\n  universal: {\n    name: 'universal',\n    displayName: 'Universal',\n    skillsDir: '.agents/skills',\n    globalSkillsDir: join(configHome, 'agents/skills'),\n    showInUniversalList: false,\n    detectInstalled: async () => false,\n  },\n};\n\nexport async function detectInstalledAgents(): Promise<AgentType[]> {\n  const results = await Promise.all(\n    Object.entries(agents).map(async ([type, config]) => ({\n      type: type as AgentType,\n      installed: await config.detectInstalled(),\n    }))\n  );\n  return results.filter((r) => r.installed).map((r) => r.type);\n}\n\nexport function getAgentConfig(type: AgentType): AgentConfig {\n  return agents[type];\n}\n\n/**\n * Returns agents that use the universal .agents/skills directory.\n * These agents share a common skill location and don't need symlinks.\n * Agents with showInUniversalList: false are excluded.\n */\nexport function getUniversalAgents(): AgentType[] {\n  return (Object.entries(agents) as [AgentType, AgentConfig][])\n    .filter(\n      ([_, config]) => config.skillsDir === '.agents/skills' && config.showInUniversalList !== false\n    )\n    .map(([type]) => type);\n}\n\n/**\n * Returns agents that use agent-specific skill directories (not universal).\n * These agents need symlinks from the canonical .agents/skills location.\n */\nexport function getNonUniversalAgents(): AgentType[] {\n  return (Object.entries(agents) as [AgentType, AgentConfig][])\n    .filter(([_, config]) => config.skillsDir !== '.agents/skills')\n    .map(([type]) => type);\n}\n\n/**\n * Check if an agent uses the universal .agents/skills directory.\n */\nexport function isUniversalAgent(type: AgentType): boolean {\n  return agents[type].skillsDir === '.agents/skills';\n}\n"
  },
  {
    "path": "src/cli.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { readFileSync } from 'fs';\nimport { join } from 'path';\nimport { runCliOutput, stripLogo, hasLogo } from './test-utils.ts';\n\ndescribe('skills CLI', () => {\n  describe('--help', () => {\n    it('should display help message', () => {\n      const output = runCliOutput(['--help']);\n      expect(output).toContain('Usage: skills <command> [options]');\n      expect(output).toContain('Manage Skills:');\n      expect(output).toContain('init [name]');\n      expect(output).toContain('add <package>');\n      expect(output).toContain('check');\n      expect(output).toContain('update');\n      expect(output).toContain('Add Options:');\n      expect(output).toContain('-g, --global');\n      expect(output).toContain('-a, --agent');\n      expect(output).toContain('-s, --skill');\n      expect(output).toContain('-l, --list');\n      expect(output).toContain('-y, --yes');\n      expect(output).toContain('--all');\n    });\n\n    it('should show same output for -h alias', () => {\n      const helpOutput = runCliOutput(['--help']);\n      const hOutput = runCliOutput(['-h']);\n      expect(hOutput).toBe(helpOutput);\n    });\n  });\n\n  describe('--version', () => {\n    it('should display version number', () => {\n      const output = runCliOutput(['--version']);\n      expect(output.trim()).toMatch(/^\\d+\\.\\d+\\.\\d+$/);\n    });\n\n    it('should match package.json version', () => {\n      const output = runCliOutput(['--version']);\n      const pkg = JSON.parse(\n        readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf-8')\n      );\n      expect(output.trim()).toBe(pkg.version);\n    });\n  });\n\n  describe('no arguments', () => {\n    it('should display banner', () => {\n      const output = stripLogo(runCliOutput([]));\n      expect(output).toContain('The open agent skills ecosystem');\n      expect(output).toContain('npx skills add');\n      expect(output).toContain('npx skills check');\n      expect(output).toContain('npx skills update');\n      expect(output).toContain('npx skills init');\n      expect(output).toContain('skills.sh');\n    });\n  });\n\n  describe('unknown command', () => {\n    it('should show error for unknown command', () => {\n      const output = runCliOutput(['unknown-command']);\n      expect(output).toMatchInlineSnapshot(`\n        \"Unknown command: unknown-command\n        Run skills --help for usage.\n        \"\n      `);\n    });\n  });\n\n  describe('logo display', () => {\n    it('should not display logo for list command', () => {\n      const output = runCliOutput(['list']);\n      expect(hasLogo(output)).toBe(false);\n    });\n\n    it('should not display logo for check command', () => {\n      // Note: check command makes GitHub API calls, so we just verify initial output\n      const output = runCliOutput(['check']);\n      expect(hasLogo(output)).toBe(false);\n    }, 60000);\n\n    it('should not display logo for update command', () => {\n      // Note: update command makes GitHub API calls, so we just verify initial output\n      const output = runCliOutput(['update']);\n      expect(hasLogo(output)).toBe(false);\n    }, 60000);\n  });\n});\n"
  },
  {
    "path": "src/cli.ts",
    "content": "#!/usr/bin/env node\n\nimport { spawnSync } from 'child_process';\nimport { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';\nimport { basename, join, dirname } from 'path';\nimport { homedir } from 'os';\nimport { fileURLToPath } from 'url';\nimport { runAdd, parseAddOptions, initTelemetry } from './add.ts';\nimport { runFind } from './find.ts';\nimport { runInstallFromLock } from './install.ts';\nimport { runList } from './list.ts';\nimport { removeCommand, parseRemoveOptions } from './remove.ts';\nimport { runSync, parseSyncOptions } from './sync.ts';\nimport { track } from './telemetry.ts';\nimport { fetchSkillFolderHash, getGitHubToken } from './skill-lock.ts';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n\nfunction getVersion(): string {\n  try {\n    const pkgPath = join(__dirname, '..', 'package.json');\n    const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));\n    return pkg.version;\n  } catch {\n    return '0.0.0';\n  }\n}\n\nconst VERSION = getVersion();\ninitTelemetry(VERSION);\n\nconst RESET = '\\x1b[0m';\nconst BOLD = '\\x1b[1m';\n// 256-color grays - visible on both light and dark backgrounds\nconst DIM = '\\x1b[38;5;102m'; // darker gray for secondary text\nconst TEXT = '\\x1b[38;5;145m'; // lighter gray for primary text\n\nconst LOGO_LINES = [\n  '███████╗██╗  ██╗██╗██╗     ██╗     ███████╗',\n  '██╔════╝██║ ██╔╝██║██║     ██║     ██╔════╝',\n  '███████╗█████╔╝ ██║██║     ██║     ███████╗',\n  '╚════██║██╔═██╗ ██║██║     ██║     ╚════██║',\n  '███████║██║  ██╗██║███████╗███████╗███████║',\n  '╚══════╝╚═╝  ╚═╝╚═╝╚══════╝╚══════╝╚══════╝',\n];\n\n// 256-color middle grays - visible on both light and dark backgrounds\nconst GRAYS = [\n  '\\x1b[38;5;250m', // lighter gray\n  '\\x1b[38;5;248m',\n  '\\x1b[38;5;245m', // mid gray\n  '\\x1b[38;5;243m',\n  '\\x1b[38;5;240m',\n  '\\x1b[38;5;238m', // darker gray\n];\n\nfunction showLogo(): void {\n  console.log();\n  LOGO_LINES.forEach((line, i) => {\n    console.log(`${GRAYS[i]}${line}${RESET}`);\n  });\n}\n\nfunction showBanner(): void {\n  showLogo();\n  console.log();\n  console.log(`${DIM}The open agent skills ecosystem${RESET}`);\n  console.log();\n  console.log(\n    `  ${DIM}$${RESET} ${TEXT}npx skills add ${DIM}<package>${RESET}        ${DIM}Add a new skill${RESET}`\n  );\n  console.log(\n    `  ${DIM}$${RESET} ${TEXT}npx skills remove${RESET}               ${DIM}Remove installed skills${RESET}`\n  );\n  console.log(\n    `  ${DIM}$${RESET} ${TEXT}npx skills list${RESET}                 ${DIM}List installed skills${RESET}`\n  );\n  console.log(\n    `  ${DIM}$${RESET} ${TEXT}npx skills find ${DIM}[query]${RESET}         ${DIM}Search for skills${RESET}`\n  );\n  console.log();\n  console.log(\n    `  ${DIM}$${RESET} ${TEXT}npx skills check${RESET}                ${DIM}Check for updates${RESET}`\n  );\n  console.log(\n    `  ${DIM}$${RESET} ${TEXT}npx skills update${RESET}               ${DIM}Update all skills${RESET}`\n  );\n  console.log();\n  console.log(\n    `  ${DIM}$${RESET} ${TEXT}npx skills experimental_install${RESET} ${DIM}Restore from skills-lock.json${RESET}`\n  );\n  console.log(\n    `  ${DIM}$${RESET} ${TEXT}npx skills init ${DIM}[name]${RESET}          ${DIM}Create a new skill${RESET}`\n  );\n  console.log(\n    `  ${DIM}$${RESET} ${TEXT}npx skills experimental_sync${RESET}    ${DIM}Sync skills from node_modules${RESET}`\n  );\n  console.log();\n  console.log(`${DIM}try:${RESET} npx skills add vercel-labs/agent-skills`);\n  console.log();\n  console.log(`Discover more skills at ${TEXT}https://skills.sh/${RESET}`);\n  console.log();\n}\n\nfunction showHelp(): void {\n  console.log(`\n${BOLD}Usage:${RESET} skills <command> [options]\n\n${BOLD}Manage Skills:${RESET}\n  add <package>        Add a skill package (alias: a)\n                       e.g. vercel-labs/agent-skills\n                            https://github.com/vercel-labs/agent-skills\n  remove [skills]      Remove installed skills\n  list, ls             List installed skills\n  find [query]         Search for skills interactively\n\n${BOLD}Updates:${RESET}\n  check                Check for available skill updates\n  update               Update all skills to latest versions\n\n${BOLD}Project:${RESET}\n  experimental_install Restore skills from skills-lock.json\n  init [name]          Initialize a skill (creates <name>/SKILL.md or ./SKILL.md)\n  experimental_sync    Sync skills from node_modules into agent directories\n\n${BOLD}Add Options:${RESET}\n  -g, --global           Install skill globally (user-level) instead of project-level\n  -a, --agent <agents>   Specify agents to install to (use '*' for all agents)\n  -s, --skill <skills>   Specify skill names to install (use '*' for all skills)\n  -l, --list             List available skills in the repository without installing\n  -y, --yes              Skip confirmation prompts\n  --copy                 Copy files instead of symlinking to agent directories\n  --all                  Shorthand for --skill '*' --agent '*' -y\n  --full-depth           Search all subdirectories even when a root SKILL.md exists\n\n${BOLD}Remove Options:${RESET}\n  -g, --global           Remove from global scope\n  -a, --agent <agents>   Remove from specific agents (use '*' for all agents)\n  -s, --skill <skills>   Specify skills to remove (use '*' for all skills)\n  -y, --yes              Skip confirmation prompts\n  --all                  Shorthand for --skill '*' --agent '*' -y\n  \n${BOLD}Experimental Sync Options:${RESET}\n  -a, --agent <agents>   Specify agents to install to (use '*' for all agents)\n  -y, --yes              Skip confirmation prompts\n\n${BOLD}List Options:${RESET}\n  -g, --global           List global skills (default: project)\n  -a, --agent <agents>   Filter by specific agents\n  --json                 Output as JSON (machine-readable, no ANSI codes)\n\n${BOLD}Options:${RESET}\n  --help, -h        Show this help message\n  --version, -v     Show version number\n\n${BOLD}Examples:${RESET}\n  ${DIM}$${RESET} skills add vercel-labs/agent-skills\n  ${DIM}$${RESET} skills add vercel-labs/agent-skills -g\n  ${DIM}$${RESET} skills add vercel-labs/agent-skills --agent claude-code cursor\n  ${DIM}$${RESET} skills add vercel-labs/agent-skills --skill pr-review commit\n  ${DIM}$${RESET} skills remove                        ${DIM}# interactive remove${RESET}\n  ${DIM}$${RESET} skills remove web-design             ${DIM}# remove by name${RESET}\n  ${DIM}$${RESET} skills rm --global frontend-design\n  ${DIM}$${RESET} skills list                          ${DIM}# list project skills${RESET}\n  ${DIM}$${RESET} skills ls -g                         ${DIM}# list global skills${RESET}\n  ${DIM}$${RESET} skills ls -a claude-code             ${DIM}# filter by agent${RESET}\n  ${DIM}$${RESET} skills ls --json                      ${DIM}# JSON output${RESET}\n  ${DIM}$${RESET} skills find                          ${DIM}# interactive search${RESET}\n  ${DIM}$${RESET} skills find typescript               ${DIM}# search by keyword${RESET}\n  ${DIM}$${RESET} skills check\n  ${DIM}$${RESET} skills update\n  ${DIM}$${RESET} skills experimental_install            ${DIM}# restore from skills-lock.json${RESET}\n  ${DIM}$${RESET} skills init my-skill\n  ${DIM}$${RESET} skills experimental_sync              ${DIM}# sync from node_modules${RESET}\n  ${DIM}$${RESET} skills experimental_sync -y           ${DIM}# sync without prompts${RESET}\n\nDiscover more skills at ${TEXT}https://skills.sh/${RESET}\n`);\n}\n\nfunction showRemoveHelp(): void {\n  console.log(`\n${BOLD}Usage:${RESET} skills remove [skills...] [options]\n\n${BOLD}Description:${RESET}\n  Remove installed skills from agents. If no skill names are provided,\n  an interactive selection menu will be shown.\n\n${BOLD}Arguments:${RESET}\n  skills            Optional skill names to remove (space-separated)\n\n${BOLD}Options:${RESET}\n  -g, --global       Remove from global scope (~/) instead of project scope\n  -a, --agent        Remove from specific agents (use '*' for all agents)\n  -s, --skill        Specify skills to remove (use '*' for all skills)\n  -y, --yes          Skip confirmation prompts\n  --all              Shorthand for --skill '*' --agent '*' -y\n\n${BOLD}Examples:${RESET}\n  ${DIM}$${RESET} skills remove                           ${DIM}# interactive selection${RESET}\n  ${DIM}$${RESET} skills remove my-skill                   ${DIM}# remove specific skill${RESET}\n  ${DIM}$${RESET} skills remove skill1 skill2 -y           ${DIM}# remove multiple skills${RESET}\n  ${DIM}$${RESET} skills remove --global my-skill          ${DIM}# remove from global scope${RESET}\n  ${DIM}$${RESET} skills rm --agent claude-code my-skill   ${DIM}# remove from specific agent${RESET}\n  ${DIM}$${RESET} skills remove --all                      ${DIM}# remove all skills${RESET}\n  ${DIM}$${RESET} skills remove --skill '*' -a cursor      ${DIM}# remove all skills from cursor${RESET}\n\nDiscover more skills at ${TEXT}https://skills.sh/${RESET}\n`);\n}\n\nfunction runInit(args: string[]): void {\n  const cwd = process.cwd();\n  const skillName = args[0] || basename(cwd);\n  const hasName = args[0] !== undefined;\n\n  const skillDir = hasName ? join(cwd, skillName) : cwd;\n  const skillFile = join(skillDir, 'SKILL.md');\n  const displayPath = hasName ? `${skillName}/SKILL.md` : 'SKILL.md';\n\n  if (existsSync(skillFile)) {\n    console.log(`${TEXT}Skill already exists at ${DIM}${displayPath}${RESET}`);\n    return;\n  }\n\n  if (hasName) {\n    mkdirSync(skillDir, { recursive: true });\n  }\n\n  const skillContent = `---\nname: ${skillName}\ndescription: A brief description of what this skill does\n---\n\n# ${skillName}\n\nInstructions for the agent to follow when this skill is activated.\n\n## When to use\n\nDescribe when this skill should be used.\n\n## Instructions\n\n1. First step\n2. Second step\n3. Additional steps as needed\n`;\n\n  writeFileSync(skillFile, skillContent);\n\n  console.log(`${TEXT}Initialized skill: ${DIM}${skillName}${RESET}`);\n  console.log();\n  console.log(`${DIM}Created:${RESET}`);\n  console.log(`  ${displayPath}`);\n  console.log();\n  console.log(`${DIM}Next steps:${RESET}`);\n  console.log(`  1. Edit ${TEXT}${displayPath}${RESET} to define your skill instructions`);\n  console.log(\n    `  2. Update the ${TEXT}name${RESET} and ${TEXT}description${RESET} in the frontmatter`\n  );\n  console.log();\n  console.log(`${DIM}Publishing:${RESET}`);\n  console.log(\n    `  ${DIM}GitHub:${RESET}  Push to a repo, then ${TEXT}npx skills add <owner>/<repo>${RESET}`\n  );\n  console.log(\n    `  ${DIM}URL:${RESET}     Host the file, then ${TEXT}npx skills add https://example.com/${displayPath}${RESET}`\n  );\n  console.log();\n  console.log(`Browse existing skills for inspiration at ${TEXT}https://skills.sh/${RESET}`);\n  console.log();\n}\n\n// ============================================\n// Check and Update Commands\n// ============================================\n\nconst AGENTS_DIR = '.agents';\nconst LOCK_FILE = '.skill-lock.json';\nconst CURRENT_LOCK_VERSION = 3; // Bumped from 2 to 3 for folder hash support\n\ninterface SkillLockEntry {\n  source: string;\n  sourceType: string;\n  sourceUrl: string;\n  skillPath?: string;\n  /** GitHub tree SHA for the entire skill folder (v3) */\n  skillFolderHash: string;\n  installedAt: string;\n  updatedAt: string;\n}\n\ninterface SkillLockFile {\n  version: number;\n  skills: Record<string, SkillLockEntry>;\n}\n\nfunction getSkillLockPath(): string {\n  const xdgStateHome = process.env.XDG_STATE_HOME;\n  if (xdgStateHome) {\n    return join(xdgStateHome, 'skills', LOCK_FILE);\n  }\n  return join(homedir(), AGENTS_DIR, LOCK_FILE);\n}\n\nfunction readSkillLock(): SkillLockFile {\n  const lockPath = getSkillLockPath();\n  try {\n    const content = readFileSync(lockPath, 'utf-8');\n    const parsed = JSON.parse(content) as SkillLockFile;\n    if (typeof parsed.version !== 'number' || !parsed.skills) {\n      return { version: CURRENT_LOCK_VERSION, skills: {} };\n    }\n    // If old version, wipe and start fresh (backwards incompatible change)\n    // v3 adds skillFolderHash - we want fresh installs to populate it\n    if (parsed.version < CURRENT_LOCK_VERSION) {\n      return { version: CURRENT_LOCK_VERSION, skills: {} };\n    }\n    return parsed;\n  } catch {\n    return { version: CURRENT_LOCK_VERSION, skills: {} };\n  }\n}\n\ninterface SkippedSkill {\n  name: string;\n  reason: string;\n  sourceUrl: string;\n}\n\n/**\n * Determine why a skill cannot be checked for updates automatically.\n */\nfunction getSkipReason(entry: SkillLockEntry): string {\n  if (entry.sourceType === 'local') {\n    return 'Local path';\n  }\n  if (entry.sourceType === 'git') {\n    return 'Git URL (hash tracking not supported)';\n  }\n  if (!entry.skillFolderHash) {\n    return 'No version hash available';\n  }\n  if (!entry.skillPath) {\n    return 'No skill path recorded';\n  }\n  return 'No version tracking';\n}\n\n/**\n * Print a list of skills that cannot be checked automatically,\n * with the reason and a manual update command for each.\n */\nfunction printSkippedSkills(skipped: SkippedSkill[]): void {\n  if (skipped.length === 0) return;\n  console.log();\n  console.log(`${DIM}${skipped.length} skill(s) cannot be checked automatically:${RESET}`);\n  for (const skill of skipped) {\n    console.log(`  ${TEXT}•${RESET} ${skill.name} ${DIM}(${skill.reason})${RESET}`);\n    console.log(`    ${DIM}To update: ${TEXT}npx skills add ${skill.sourceUrl} -g -y${RESET}`);\n  }\n}\n\nasync function runCheck(args: string[] = []): Promise<void> {\n  console.log(`${TEXT}Checking for skill updates...${RESET}`);\n  console.log();\n\n  const lock = readSkillLock();\n  const skillNames = Object.keys(lock.skills);\n\n  if (skillNames.length === 0) {\n    console.log(`${DIM}No skills tracked in lock file.${RESET}`);\n    console.log(`${DIM}Install skills with${RESET} ${TEXT}npx skills add <package>${RESET}`);\n    return;\n  }\n\n  // Get GitHub token from user's environment for higher rate limits\n  const token = getGitHubToken();\n\n  // Group skills by source (owner/repo) to batch GitHub API calls\n  const skillsBySource = new Map<string, Array<{ name: string; entry: SkillLockEntry }>>();\n  const skipped: SkippedSkill[] = [];\n\n  for (const skillName of skillNames) {\n    const entry = lock.skills[skillName];\n    if (!entry) continue;\n\n    // Only check skills with folder hash and skill path\n    if (!entry.skillFolderHash || !entry.skillPath) {\n      skipped.push({ name: skillName, reason: getSkipReason(entry), sourceUrl: entry.sourceUrl });\n      continue;\n    }\n\n    const existing = skillsBySource.get(entry.source) || [];\n    existing.push({ name: skillName, entry });\n    skillsBySource.set(entry.source, existing);\n  }\n\n  const totalSkills = skillNames.length - skipped.length;\n  if (totalSkills === 0) {\n    console.log(`${DIM}No GitHub skills to check.${RESET}`);\n    printSkippedSkills(skipped);\n    return;\n  }\n\n  console.log(`${DIM}Checking ${totalSkills} skill(s) for updates...${RESET}`);\n\n  const updates: Array<{ name: string; source: string }> = [];\n  const errors: Array<{ name: string; source: string; error: string }> = [];\n\n  // Check each source (one API call per repo)\n  for (const [source, skills] of skillsBySource) {\n    for (const { name, entry } of skills) {\n      try {\n        const latestHash = await fetchSkillFolderHash(source, entry.skillPath!, token);\n\n        if (!latestHash) {\n          errors.push({ name, source, error: 'Could not fetch from GitHub' });\n          continue;\n        }\n\n        if (latestHash !== entry.skillFolderHash) {\n          updates.push({ name, source });\n        }\n      } catch (err) {\n        errors.push({\n          name,\n          source,\n          error: err instanceof Error ? err.message : 'Unknown error',\n        });\n      }\n    }\n  }\n\n  console.log();\n\n  if (updates.length === 0) {\n    console.log(`${TEXT}✓ All skills are up to date${RESET}`);\n  } else {\n    console.log(`${TEXT}${updates.length} update(s) available:${RESET}`);\n    console.log();\n    for (const update of updates) {\n      console.log(`  ${TEXT}↑${RESET} ${update.name}`);\n      console.log(`    ${DIM}source: ${update.source}${RESET}`);\n    }\n    console.log();\n    console.log(\n      `${DIM}Run${RESET} ${TEXT}npx skills update${RESET} ${DIM}to update all skills${RESET}`\n    );\n  }\n\n  if (errors.length > 0) {\n    console.log();\n    console.log(`${DIM}Could not check ${errors.length} skill(s) (may need reinstall)${RESET}`);\n    console.log();\n    for (const error of errors) {\n      console.log(`  ${DIM}✗${RESET} ${error.name}`);\n      console.log(`    ${DIM}source: ${error.source}${RESET}`);\n    }\n  }\n\n  printSkippedSkills(skipped);\n\n  // Track telemetry\n  track({\n    event: 'check',\n    skillCount: String(totalSkills),\n    updatesAvailable: String(updates.length),\n  });\n\n  console.log();\n}\n\nasync function runUpdate(): Promise<void> {\n  console.log(`${TEXT}Checking for skill updates...${RESET}`);\n  console.log();\n\n  const lock = readSkillLock();\n  const skillNames = Object.keys(lock.skills);\n\n  if (skillNames.length === 0) {\n    console.log(`${DIM}No skills tracked in lock file.${RESET}`);\n    console.log(`${DIM}Install skills with${RESET} ${TEXT}npx skills add <package>${RESET}`);\n    return;\n  }\n\n  // Get GitHub token from user's environment for higher rate limits\n  const token = getGitHubToken();\n\n  // Find skills that need updates by checking GitHub directly\n  const updates: Array<{ name: string; source: string; entry: SkillLockEntry }> = [];\n  const skipped: SkippedSkill[] = [];\n\n  for (const skillName of skillNames) {\n    const entry = lock.skills[skillName];\n    if (!entry) continue;\n\n    // Only check skills with folder hash and skill path\n    if (!entry.skillFolderHash || !entry.skillPath) {\n      skipped.push({ name: skillName, reason: getSkipReason(entry), sourceUrl: entry.sourceUrl });\n      continue;\n    }\n\n    try {\n      const latestHash = await fetchSkillFolderHash(entry.source, entry.skillPath, token);\n\n      if (latestHash && latestHash !== entry.skillFolderHash) {\n        updates.push({ name: skillName, source: entry.source, entry });\n      }\n    } catch {\n      // Skip skills that fail to check\n    }\n  }\n\n  const checkedCount = skillNames.length - skipped.length;\n\n  if (checkedCount === 0) {\n    console.log(`${DIM}No skills to check.${RESET}`);\n    printSkippedSkills(skipped);\n    return;\n  }\n\n  if (updates.length === 0) {\n    console.log(`${TEXT}✓ All skills are up to date${RESET}`);\n    console.log();\n    return;\n  }\n\n  console.log(`${TEXT}Found ${updates.length} update(s)${RESET}`);\n  console.log();\n\n  // Reinstall each skill that has an update\n  let successCount = 0;\n  let failCount = 0;\n\n  for (const update of updates) {\n    console.log(`${TEXT}Updating ${update.name}...${RESET}`);\n\n    // Build the URL with subpath to target the specific skill directory\n    // e.g., https://github.com/owner/repo/tree/main/skills/my-skill\n    let installUrl = update.entry.sourceUrl;\n    if (update.entry.skillPath) {\n      // Extract the skill folder path (remove /SKILL.md suffix)\n      let skillFolder = update.entry.skillPath;\n      if (skillFolder.endsWith('/SKILL.md')) {\n        skillFolder = skillFolder.slice(0, -9);\n      } else if (skillFolder.endsWith('SKILL.md')) {\n        skillFolder = skillFolder.slice(0, -8);\n      }\n      if (skillFolder.endsWith('/')) {\n        skillFolder = skillFolder.slice(0, -1);\n      }\n\n      // Convert git URL to tree URL with path\n      // https://github.com/owner/repo.git -> https://github.com/owner/repo/tree/main/path\n      installUrl = update.entry.sourceUrl.replace(/\\.git$/, '').replace(/\\/$/, '');\n      installUrl = `${installUrl}/tree/main/${skillFolder}`;\n    }\n\n    // Reinstall using the current CLI entrypoint directly (avoid nested npm exec/npx)\n    const cliEntry = join(__dirname, '..', 'bin', 'cli.mjs');\n    if (!existsSync(cliEntry)) {\n      failCount++;\n      console.log(\n        `  ${DIM}✗ Failed to update ${update.name}: CLI entrypoint not found at ${cliEntry}${RESET}`\n      );\n      continue;\n    }\n    const result = spawnSync(process.execPath, [cliEntry, 'add', installUrl, '-g', '-y'], {\n      stdio: ['inherit', 'pipe', 'pipe'],\n      encoding: 'utf-8',\n      shell: process.platform === 'win32',\n    });\n\n    if (result.status === 0) {\n      successCount++;\n      console.log(`  ${TEXT}✓${RESET} Updated ${update.name}`);\n    } else {\n      failCount++;\n      console.log(`  ${DIM}✗ Failed to update ${update.name}${RESET}`);\n    }\n  }\n\n  console.log();\n  if (successCount > 0) {\n    console.log(`${TEXT}✓ Updated ${successCount} skill(s)${RESET}`);\n  }\n  if (failCount > 0) {\n    console.log(`${DIM}Failed to update ${failCount} skill(s)${RESET}`);\n  }\n\n  // Track telemetry\n  track({\n    event: 'update',\n    skillCount: String(updates.length),\n    successCount: String(successCount),\n    failCount: String(failCount),\n  });\n\n  console.log();\n}\n\n// ============================================\n// Main\n// ============================================\n\nasync function main(): Promise<void> {\n  const args = process.argv.slice(2);\n\n  if (args.length === 0) {\n    showBanner();\n    return;\n  }\n\n  const command = args[0];\n  const restArgs = args.slice(1);\n\n  switch (command) {\n    case 'find':\n    case 'search':\n    case 'f':\n    case 's':\n      showLogo();\n      console.log();\n      await runFind(restArgs);\n      break;\n    case 'init':\n      showLogo();\n      console.log();\n      runInit(restArgs);\n      break;\n    case 'experimental_install': {\n      showLogo();\n      await runInstallFromLock(restArgs);\n      break;\n    }\n    case 'i':\n    case 'install':\n    case 'a':\n    case 'add': {\n      showLogo();\n      const { source: addSource, options: addOpts } = parseAddOptions(restArgs);\n      await runAdd(addSource, addOpts);\n      break;\n    }\n    case 'remove':\n    case 'rm':\n    case 'r':\n      // Check for --help or -h flag\n      if (restArgs.includes('--help') || restArgs.includes('-h')) {\n        showRemoveHelp();\n        break;\n      }\n      const { skills, options: removeOptions } = parseRemoveOptions(restArgs);\n      await removeCommand(skills, removeOptions);\n      break;\n    case 'experimental_sync': {\n      showLogo();\n      const { options: syncOptions } = parseSyncOptions(restArgs);\n      await runSync(restArgs, syncOptions);\n      break;\n    }\n    case 'list':\n    case 'ls':\n      await runList(restArgs);\n      break;\n    case 'check':\n      runCheck(restArgs);\n      break;\n    case 'update':\n    case 'upgrade':\n      runUpdate();\n      break;\n    case '--help':\n    case '-h':\n      showHelp();\n      break;\n    case '--version':\n    case '-v':\n      console.log(VERSION);\n      break;\n\n    default:\n      console.log(`Unknown command: ${command}`);\n      console.log(`Run ${BOLD}skills --help${RESET} for usage.`);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "src/constants.ts",
    "content": "export const AGENTS_DIR = '.agents';\nexport const SKILLS_SUBDIR = 'skills';\nexport const UNIVERSAL_SKILLS_DIR = '.agents/skills';\n"
  },
  {
    "path": "src/find.ts",
    "content": "import * as readline from 'readline';\nimport { runAdd, parseAddOptions } from './add.ts';\nimport { track } from './telemetry.ts';\nimport { isRepoPrivate } from './source-parser.ts';\n\nconst RESET = '\\x1b[0m';\nconst BOLD = '\\x1b[1m';\nconst DIM = '\\x1b[38;5;102m';\nconst TEXT = '\\x1b[38;5;145m';\nconst CYAN = '\\x1b[36m';\nconst MAGENTA = '\\x1b[35m';\nconst YELLOW = '\\x1b[33m';\n\n// API endpoint for skills search\nconst SEARCH_API_BASE = process.env.SKILLS_API_URL || 'https://skills.sh';\n\nfunction formatInstalls(count: number): string {\n  if (!count || count <= 0) return '';\n  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\\.0$/, '')}M installs`;\n  if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\\.0$/, '')}K installs`;\n  return `${count} install${count === 1 ? '' : 's'}`;\n}\n\nexport interface SearchSkill {\n  name: string;\n  slug: string;\n  source: string;\n  installs: number;\n}\n\n// Search via API\nexport async function searchSkillsAPI(query: string): Promise<SearchSkill[]> {\n  try {\n    const url = `${SEARCH_API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=10`;\n    const res = await fetch(url);\n\n    if (!res.ok) return [];\n\n    const data = (await res.json()) as {\n      skills: Array<{\n        id: string;\n        name: string;\n        installs: number;\n        source: string;\n      }>;\n    };\n\n    return data.skills\n      .map((skill) => ({\n        name: skill.name,\n        slug: skill.id,\n        source: skill.source || '',\n        installs: skill.installs,\n      }))\n      .sort((a, b) => (b.installs || 0) - (a.installs || 0));\n  } catch {\n    return [];\n  }\n}\n\n// ANSI escape codes for terminal control\nconst HIDE_CURSOR = '\\x1b[?25l';\nconst SHOW_CURSOR = '\\x1b[?25h';\nconst CLEAR_DOWN = '\\x1b[J';\nconst MOVE_UP = (n: number) => `\\x1b[${n}A`;\nconst MOVE_TO_COL = (n: number) => `\\x1b[${n}G`;\n\n// Custom fzf-style search prompt using raw readline\nasync function runSearchPrompt(initialQuery = ''): Promise<SearchSkill | null> {\n  let results: SearchSkill[] = [];\n  let selectedIndex = 0;\n  let query = initialQuery;\n  let loading = false;\n  let debounceTimer: ReturnType<typeof setTimeout> | null = null;\n  let lastRenderedLines = 0;\n\n  // Enable raw mode for keypress events\n  if (process.stdin.isTTY) {\n    process.stdin.setRawMode(true);\n  }\n\n  // Setup readline for keypress events but don't let it echo\n  readline.emitKeypressEvents(process.stdin);\n\n  // Resume stdin to start receiving events\n  process.stdin.resume();\n\n  // Hide cursor during selection\n  process.stdout.write(HIDE_CURSOR);\n\n  function render(): void {\n    // Move cursor up to overwrite previous render\n    if (lastRenderedLines > 0) {\n      process.stdout.write(MOVE_UP(lastRenderedLines) + MOVE_TO_COL(1));\n    }\n\n    // Clear from cursor to end of screen (removes ghost trails)\n    process.stdout.write(CLEAR_DOWN);\n\n    const lines: string[] = [];\n\n    // Search input line with cursor\n    const cursor = `${BOLD}_${RESET}`;\n    lines.push(`${TEXT}Search skills:${RESET} ${query}${cursor}`);\n    lines.push('');\n\n    // Results - keep showing existing results while loading new ones\n    if (!query || query.length < 2) {\n      lines.push(`${DIM}Start typing to search (min 2 chars)${RESET}`);\n    } else if (results.length === 0 && loading) {\n      lines.push(`${DIM}Searching...${RESET}`);\n    } else if (results.length === 0) {\n      lines.push(`${DIM}No skills found${RESET}`);\n    } else {\n      const maxVisible = 8;\n      const visible = results.slice(0, maxVisible);\n\n      for (let i = 0; i < visible.length; i++) {\n        const skill = visible[i]!;\n        const isSelected = i === selectedIndex;\n        const arrow = isSelected ? `${BOLD}>${RESET}` : ' ';\n        const name = isSelected ? `${BOLD}${skill.name}${RESET}` : `${TEXT}${skill.name}${RESET}`;\n        const source = skill.source ? ` ${DIM}${skill.source}${RESET}` : '';\n        const installs = formatInstalls(skill.installs);\n        const installsBadge = installs ? ` ${CYAN}${installs}${RESET}` : '';\n        const loadingIndicator = loading && i === 0 ? ` ${DIM}...${RESET}` : '';\n\n        lines.push(`  ${arrow} ${name}${source}${installsBadge}${loadingIndicator}`);\n      }\n    }\n\n    lines.push('');\n    lines.push(`${DIM}up/down navigate | enter select | esc cancel${RESET}`);\n\n    // Write each line\n    for (const line of lines) {\n      process.stdout.write(line + '\\n');\n    }\n\n    lastRenderedLines = lines.length;\n  }\n\n  function triggerSearch(q: string): void {\n    // Always clear any pending debounce timer\n    if (debounceTimer) {\n      clearTimeout(debounceTimer);\n      debounceTimer = null;\n    }\n\n    // Always reset loading state when starting a new search\n    loading = false;\n\n    if (!q || q.length < 2) {\n      results = [];\n      selectedIndex = 0;\n      render();\n      return;\n    }\n\n    // Use API search for all queries (debounced)\n    loading = true;\n    render();\n\n    // Adaptive debounce: shorter queries = longer wait (user still typing)\n    // 2 chars: 250ms, 3 chars: 200ms, 4 chars: 150ms, 5+ chars: 150ms\n    const debounceMs = Math.max(150, 350 - q.length * 50);\n\n    debounceTimer = setTimeout(async () => {\n      try {\n        results = await searchSkillsAPI(q);\n        selectedIndex = 0;\n      } catch {\n        results = [];\n      } finally {\n        loading = false;\n        debounceTimer = null;\n        render();\n      }\n    }, debounceMs);\n  }\n\n  // Trigger initial search if there's a query, then render\n  if (initialQuery) {\n    triggerSearch(initialQuery);\n  }\n  render();\n\n  return new Promise((resolve) => {\n    function cleanup(): void {\n      process.stdin.removeListener('keypress', handleKeypress);\n      if (process.stdin.isTTY) {\n        process.stdin.setRawMode(false);\n      }\n      process.stdout.write(SHOW_CURSOR);\n      // Pause stdin to fully release it for child processes\n      process.stdin.pause();\n    }\n\n    function handleKeypress(_ch: string | undefined, key: readline.Key): void {\n      if (!key) return;\n\n      if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {\n        // Cancel\n        cleanup();\n        resolve(null);\n        return;\n      }\n\n      if (key.name === 'return') {\n        // Submit\n        cleanup();\n        resolve(results[selectedIndex] || null);\n        return;\n      }\n\n      if (key.name === 'up') {\n        selectedIndex = Math.max(0, selectedIndex - 1);\n        render();\n        return;\n      }\n\n      if (key.name === 'down') {\n        selectedIndex = Math.min(Math.max(0, results.length - 1), selectedIndex + 1);\n        render();\n        return;\n      }\n\n      if (key.name === 'backspace') {\n        if (query.length > 0) {\n          query = query.slice(0, -1);\n          triggerSearch(query);\n        }\n        return;\n      }\n\n      // Regular character input\n      if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) {\n        const char = key.sequence;\n        if (char >= ' ' && char <= '~') {\n          query += char;\n          triggerSearch(query);\n        }\n      }\n    }\n\n    process.stdin.on('keypress', handleKeypress);\n  });\n}\n\n// Parse owner/repo from a package string (for the find command)\nfunction getOwnerRepoFromString(pkg: string): { owner: string; repo: string } | null {\n  // Handle owner/repo or owner/repo@skill\n  const atIndex = pkg.lastIndexOf('@');\n  const repoPath = atIndex > 0 ? pkg.slice(0, atIndex) : pkg;\n  const match = repoPath.match(/^([^/]+)\\/([^/]+)$/);\n  if (match) {\n    return { owner: match[1]!, repo: match[2]! };\n  }\n  return null;\n}\n\nasync function isRepoPublic(owner: string, repo: string): Promise<boolean> {\n  const isPrivate = await isRepoPrivate(owner, repo);\n  // Return true only if we know it's public (isPrivate === false)\n  // Return false if private or unable to determine\n  return isPrivate === false;\n}\n\nexport async function runFind(args: string[]): Promise<void> {\n  const query = args.join(' ');\n  const isNonInteractive = !process.stdin.isTTY;\n  const agentTip = `${DIM}Tip: if running in a coding agent, follow these steps:${RESET}\n${DIM}  1) npx skills find [query]${RESET}\n${DIM}  2) npx skills add <owner/repo@skill>${RESET}`;\n\n  // Non-interactive mode: just print results and exit\n  if (query) {\n    const results = await searchSkillsAPI(query);\n\n    // Track telemetry for non-interactive search\n    track({\n      event: 'find',\n      query,\n      resultCount: String(results.length),\n    });\n\n    if (results.length === 0) {\n      console.log(`${DIM}No skills found for \"${query}\"${RESET}`);\n      return;\n    }\n\n    console.log(`${DIM}Install with${RESET} npx skills add <owner/repo@skill>`);\n    console.log();\n\n    for (const skill of results.slice(0, 6)) {\n      const pkg = skill.source || skill.slug;\n      const installs = formatInstalls(skill.installs);\n      console.log(\n        `${TEXT}${pkg}@${skill.name}${RESET}${installs ? ` ${CYAN}${installs}${RESET}` : ''}`\n      );\n      console.log(`${DIM}└ https://skills.sh/${skill.slug}${RESET}`);\n      console.log();\n    }\n    return;\n  }\n\n  // Interactive mode - show tip only if running non-interactively (likely in a coding agent)\n  if (isNonInteractive) {\n    console.log(agentTip);\n    console.log();\n  }\n  const selected = await runSearchPrompt();\n\n  // Track telemetry for interactive search\n  track({\n    event: 'find',\n    query: '',\n    resultCount: selected ? '1' : '0',\n    interactive: '1',\n  });\n\n  if (!selected) {\n    console.log(`${DIM}Search cancelled${RESET}`);\n    console.log();\n    return;\n  }\n\n  // Use source (owner/repo) and skill name for installation\n  const pkg = selected.source || selected.slug;\n  const skillName = selected.name;\n\n  console.log();\n  console.log(`${TEXT}Installing ${BOLD}${skillName}${RESET} from ${DIM}${pkg}${RESET}...`);\n  console.log();\n\n  // Run add directly since we're in the same CLI\n  const { source, options } = parseAddOptions([pkg, '--skill', skillName]);\n  await runAdd(source, options);\n\n  console.log();\n\n  const info = getOwnerRepoFromString(pkg);\n  if (info && (await isRepoPublic(info.owner, info.repo))) {\n    console.log(\n      `${DIM}View the skill at${RESET} ${TEXT}https://skills.sh/${selected.slug}${RESET}`\n    );\n  } else {\n    console.log(`${DIM}Discover more skills at${RESET} ${TEXT}https://skills.sh${RESET}`);\n  }\n\n  console.log();\n}\n"
  },
  {
    "path": "src/git.ts",
    "content": "import simpleGit from 'simple-git';\nimport { join, normalize, resolve, sep } from 'path';\nimport { mkdtemp, rm } from 'fs/promises';\nimport { tmpdir } from 'os';\n\nconst CLONE_TIMEOUT_MS = 60000; // 60 seconds\n\nexport class GitCloneError extends Error {\n  readonly url: string;\n  readonly isTimeout: boolean;\n  readonly isAuthError: boolean;\n\n  constructor(message: string, url: string, isTimeout = false, isAuthError = false) {\n    super(message);\n    this.name = 'GitCloneError';\n    this.url = url;\n    this.isTimeout = isTimeout;\n    this.isAuthError = isAuthError;\n  }\n}\n\nexport async function cloneRepo(url: string, ref?: string): Promise<string> {\n  const tempDir = await mkdtemp(join(tmpdir(), 'skills-'));\n  const git = simpleGit({\n    timeout: { block: CLONE_TIMEOUT_MS },\n    env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },\n  });\n  const cloneOptions = ref ? ['--depth', '1', '--branch', ref] : ['--depth', '1'];\n\n  try {\n    await git.clone(url, tempDir, cloneOptions);\n    return tempDir;\n  } catch (error) {\n    // Clean up temp dir on failure\n    await rm(tempDir, { recursive: true, force: true }).catch(() => {});\n\n    const errorMessage = error instanceof Error ? error.message : String(error);\n    const isTimeout = errorMessage.includes('block timeout') || errorMessage.includes('timed out');\n    const isAuthError =\n      errorMessage.includes('Authentication failed') ||\n      errorMessage.includes('could not read Username') ||\n      errorMessage.includes('Permission denied') ||\n      errorMessage.includes('Repository not found');\n\n    if (isTimeout) {\n      throw new GitCloneError(\n        `Clone timed out after 60s. This often happens with private repos that require authentication.\\n` +\n          `  Ensure you have access and your SSH keys or credentials are configured:\\n` +\n          `  - For SSH: ssh-add -l (to check loaded keys)\\n` +\n          `  - For HTTPS: gh auth status (if using GitHub CLI)`,\n        url,\n        true,\n        false\n      );\n    }\n\n    if (isAuthError) {\n      throw new GitCloneError(\n        `Authentication failed for ${url}.\\n` +\n          `  - For private repos, ensure you have access\\n` +\n          `  - For SSH: Check your keys with 'ssh -T git@github.com'\\n` +\n          `  - For HTTPS: Run 'gh auth login' or configure git credentials`,\n        url,\n        false,\n        true\n      );\n    }\n\n    throw new GitCloneError(`Failed to clone ${url}: ${errorMessage}`, url, false, false);\n  }\n}\n\nexport async function cleanupTempDir(dir: string): Promise<void> {\n  // Validate that the directory path is within tmpdir to prevent deletion of arbitrary paths\n  const normalizedDir = normalize(resolve(dir));\n  const normalizedTmpDir = normalize(resolve(tmpdir()));\n\n  if (!normalizedDir.startsWith(normalizedTmpDir + sep) && normalizedDir !== normalizedTmpDir) {\n    throw new Error('Attempted to clean up directory outside of temp directory');\n  }\n\n  await rm(dir, { recursive: true, force: true });\n}\n"
  },
  {
    "path": "src/init.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { existsSync, rmSync, readFileSync, mkdirSync } from 'fs';\nimport { join } from 'path';\nimport { tmpdir } from 'os';\nimport { runCliOutput, stripLogo } from './test-utils.ts';\n\ndescribe('init command', () => {\n  let testDir: string;\n\n  beforeEach(() => {\n    testDir = join(tmpdir(), `skills-test-${Date.now()}`);\n    mkdirSync(testDir, { recursive: true });\n  });\n\n  afterEach(() => {\n    if (existsSync(testDir)) {\n      rmSync(testDir, { recursive: true, force: true });\n    }\n  });\n\n  it('should initialize a skill and create SKILL.md', () => {\n    const output = stripLogo(runCliOutput(['init', 'my-test-skill'], testDir));\n    expect(output).toMatchInlineSnapshot(`\n      \"Initialized skill: my-test-skill\n\n      Created:\n        my-test-skill/SKILL.md\n\n      Next steps:\n        1. Edit my-test-skill/SKILL.md to define your skill instructions\n        2. Update the name and description in the frontmatter\n\n      Publishing:\n        GitHub:  Push to a repo, then npx skills add <owner>/<repo>\n        URL:     Host the file, then npx skills add https://example.com/my-test-skill/SKILL.md\n\n      Browse existing skills for inspiration at https://skills.sh/\n\n      \"\n    `);\n\n    const skillPath = join(testDir, 'my-test-skill', 'SKILL.md');\n    expect(existsSync(skillPath)).toBe(true);\n\n    const content = readFileSync(skillPath, 'utf-8');\n    expect(content).toMatchInlineSnapshot(`\n      \"---\n      name: my-test-skill\n      description: A brief description of what this skill does\n      ---\n\n      # my-test-skill\n\n      Instructions for the agent to follow when this skill is activated.\n\n      ## When to use\n\n      Describe when this skill should be used.\n\n      ## Instructions\n\n      1. First step\n      2. Second step\n      3. Additional steps as needed\n      \"\n    `);\n  });\n\n  it('should allow multiple skills in same directory', () => {\n    runCliOutput(['init', 'hydration-fix'], testDir);\n    runCliOutput(['init', 'waterfall-data-fetching'], testDir);\n\n    expect(existsSync(join(testDir, 'hydration-fix', 'SKILL.md'))).toBe(true);\n    expect(existsSync(join(testDir, 'waterfall-data-fetching', 'SKILL.md'))).toBe(true);\n  });\n\n  it('should init SKILL.md in cwd when no name provided', () => {\n    const output = stripLogo(runCliOutput(['init'], testDir));\n\n    expect(output).toContain('Initialized skill:');\n    expect(output).toContain('Created:\\n  SKILL.md'); // directly in cwd, not in a subfolder\n    expect(output).toContain('Publishing:');\n    expect(output).toContain('GitHub:');\n    expect(output).toContain('npx skills add <owner>/<repo>');\n    expect(output).toContain('URL:');\n    expect(output).toContain('npx skills add https://example.com/SKILL.md');\n    expect(existsSync(join(testDir, 'SKILL.md'))).toBe(true);\n  });\n\n  it('should show publishing hints with skill path', () => {\n    const output = stripLogo(runCliOutput(['init', 'my-skill'], testDir));\n\n    expect(output).toContain('Publishing:');\n    expect(output).toContain('GitHub:  Push to a repo, then npx skills add <owner>/<repo>');\n    expect(output).toContain(\n      'URL:     Host the file, then npx skills add https://example.com/my-skill/SKILL.md'\n    );\n  });\n\n  it('should show error if skill already exists', () => {\n    runCliOutput(['init', 'existing-skill'], testDir);\n    const output = stripLogo(runCliOutput(['init', 'existing-skill'], testDir));\n    expect(output).toMatchInlineSnapshot(`\n      \"Skill already exists at existing-skill/SKILL.md\n      \"\n    `);\n  });\n});\n"
  },
  {
    "path": "src/install.ts",
    "content": "import * as p from '@clack/prompts';\nimport pc from 'picocolors';\nimport { readLocalLock } from './local-lock.ts';\nimport { runAdd } from './add.ts';\nimport { runSync, parseSyncOptions } from './sync.ts';\nimport { getUniversalAgents } from './agents.ts';\n\n/**\n * Install all skills from the local skills-lock.json.\n * Groups skills by source and calls `runAdd` for each group.\n *\n * Only installs to .agents/skills/ (universal agents) -- the canonical\n * project-level location. Does not install to agent-specific directories.\n *\n * node_modules skills are handled via experimental_sync.\n */\nexport async function runInstallFromLock(args: string[]): Promise<void> {\n  const cwd = process.cwd();\n  const lock = await readLocalLock(cwd);\n  const skillEntries = Object.entries(lock.skills);\n\n  if (skillEntries.length === 0) {\n    p.log.warn('No project skills found in skills-lock.json');\n    p.log.info(\n      `Add project-level skills with ${pc.cyan('npx skills add <package>')} (without ${pc.cyan('-g')})`\n    );\n    return;\n  }\n\n  // Only install to .agents/skills/ (universal agents)\n  const universalAgentNames = getUniversalAgents();\n\n  // Separate node_modules skills from remote skills\n  const nodeModuleSkills: string[] = [];\n  const bySource = new Map<string, { sourceType: string; skills: string[] }>();\n\n  for (const [skillName, entry] of skillEntries) {\n    if (entry.sourceType === 'node_modules') {\n      nodeModuleSkills.push(skillName);\n      continue;\n    }\n\n    const existing = bySource.get(entry.source);\n    if (existing) {\n      existing.skills.push(skillName);\n    } else {\n      bySource.set(entry.source, {\n        sourceType: entry.sourceType,\n        skills: [skillName],\n      });\n    }\n  }\n\n  const remoteCount = skillEntries.length - nodeModuleSkills.length;\n  if (remoteCount > 0) {\n    p.log.info(\n      `Restoring ${pc.cyan(String(remoteCount))} skill${remoteCount !== 1 ? 's' : ''} from skills-lock.json into ${pc.dim('.agents/skills/')}`\n    );\n  }\n\n  // Install remote skills grouped by source\n  for (const [source, { skills }] of bySource) {\n    try {\n      await runAdd([source], {\n        skill: skills,\n        agent: universalAgentNames,\n        yes: true,\n      });\n    } catch (error) {\n      p.log.error(\n        `Failed to install from ${pc.cyan(source)}: ${error instanceof Error ? error.message : 'Unknown error'}`\n      );\n    }\n  }\n\n  // Handle node_modules skills via sync\n  if (nodeModuleSkills.length > 0) {\n    p.log.info(\n      `${pc.cyan(String(nodeModuleSkills.length))} skill${nodeModuleSkills.length !== 1 ? 's' : ''} from node_modules`\n    );\n    try {\n      const { options: syncOptions } = parseSyncOptions(args);\n      await runSync(args, { ...syncOptions, yes: true, agent: universalAgentNames });\n    } catch (error) {\n      p.log.error(\n        `Failed to sync node_modules skills: ${error instanceof Error ? error.message : 'Unknown error'}`\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "src/installer.ts",
    "content": "import {\n  mkdir,\n  cp,\n  access,\n  readdir,\n  symlink,\n  lstat,\n  rm,\n  readlink,\n  writeFile,\n  stat,\n  realpath,\n} from 'fs/promises';\nimport { existsSync } from 'fs';\nimport { join, basename, normalize, resolve, sep, relative, dirname } from 'path';\nimport { homedir, platform } from 'os';\nimport type { Skill, AgentType, RemoteSkill } from './types.ts';\nimport type { WellKnownSkill } from './providers/wellknown.ts';\nimport { agents, detectInstalledAgents, isUniversalAgent } from './agents.ts';\nimport { AGENTS_DIR, SKILLS_SUBDIR } from './constants.ts';\nimport { parseSkillMd } from './skills.ts';\n\nexport type InstallMode = 'symlink' | 'copy';\n\ninterface InstallResult {\n  success: boolean;\n  path: string;\n  canonicalPath?: string;\n  mode: InstallMode;\n  symlinkFailed?: boolean;\n  error?: string;\n}\n\n/**\n * Sanitizes a filename/directory name to prevent path traversal attacks\n * and ensures it follows kebab-case convention\n * @param name - The name to sanitize\n * @returns Sanitized name safe for use in file paths\n */\nexport function sanitizeName(name: string): string {\n  const sanitized = name\n    .toLowerCase()\n    // Replace any sequence of characters that are NOT lowercase letters (a-z),\n    // digits (0-9), dots (.), or underscores (_) with a single hyphen.\n    // This converts spaces, special chars, and path traversal attempts (../) into hyphens.\n    .replace(/[^a-z0-9._]+/g, '-')\n    // Remove leading/trailing dots and hyphens to prevent hidden files (.) and\n    // ensure clean directory names. The pattern matches:\n    // - ^[.\\-]+ : one or more dots or hyphens at the start\n    // - [.\\-]+$ : one or more dots or hyphens at the end\n    .replace(/^[.\\-]+|[.\\-]+$/g, '');\n\n  // Limit to 255 chars (common filesystem limit), fallback to 'unnamed-skill' if empty\n  return sanitized.substring(0, 255) || 'unnamed-skill';\n}\n\n/**\n * Validates that a path is within an expected base directory\n * @param basePath - The expected base directory\n * @param targetPath - The path to validate\n * @returns true if targetPath is within basePath\n */\nfunction isPathSafe(basePath: string, targetPath: string): boolean {\n  const normalizedBase = normalize(resolve(basePath));\n  const normalizedTarget = normalize(resolve(targetPath));\n\n  return normalizedTarget.startsWith(normalizedBase + sep) || normalizedTarget === normalizedBase;\n}\n\nexport function getCanonicalSkillsDir(global: boolean, cwd?: string): string {\n  const baseDir = global ? homedir() : cwd || process.cwd();\n  return join(baseDir, AGENTS_DIR, SKILLS_SUBDIR);\n}\n\n/**\n * Gets the base directory for an agent's skills, respecting universal agents.\n * Universal agents always use the canonical directory, which prevents\n * redundant symlinks and double-listing of skills.\n */\nexport function getAgentBaseDir(agentType: AgentType, global: boolean, cwd?: string): string {\n  if (isUniversalAgent(agentType)) {\n    return getCanonicalSkillsDir(global, cwd);\n  }\n\n  const agent = agents[agentType];\n  const baseDir = global ? homedir() : cwd || process.cwd();\n\n  if (global) {\n    if (agent.globalSkillsDir === undefined) {\n      // This should be caught by callers checking support\n      return join(baseDir, agent.skillsDir);\n    }\n    return agent.globalSkillsDir;\n  }\n\n  return join(baseDir, agent.skillsDir);\n}\n\nfunction resolveSymlinkTarget(linkPath: string, linkTarget: string): string {\n  return resolve(dirname(linkPath), linkTarget);\n}\n\n/**\n * Cleans and recreates a directory for skill installation.\n *\n * This ensures:\n * 1. Renamed/deleted files from previous installs are removed\n * 2. Symlinks (including self-referential ones causing ELOOP) are handled\n *    when canonical and agent paths resolve to the same location\n */\nasync function cleanAndCreateDirectory(path: string): Promise<void> {\n  try {\n    await rm(path, { recursive: true, force: true });\n  } catch {\n    // Ignore cleanup errors - mkdir will fail if there's a real problem\n  }\n  await mkdir(path, { recursive: true });\n}\n\n/**\n * Resolve a path's parent directory through symlinks, keeping the final component.\n * This handles the case where a parent directory (e.g., ~/.claude/skills) is a symlink\n * to another location (e.g., ~/.agents/skills). In that case, computing relative paths\n * from the symlink path produces broken symlinks.\n *\n * Returns the real path of the parent + the original basename.\n * If realpath fails (parent doesn't exist), returns the original resolved path.\n */\nasync function resolveParentSymlinks(path: string): Promise<string> {\n  const resolved = resolve(path);\n  const dir = dirname(resolved);\n  const base = basename(resolved);\n  try {\n    const realDir = await realpath(dir);\n    return join(realDir, base);\n  } catch {\n    return resolved;\n  }\n}\n\n/**\n * Creates a symlink, handling cross-platform differences\n * Returns true if symlink was created, false if fallback to copy is needed\n */\nasync function createSymlink(target: string, linkPath: string): Promise<boolean> {\n  try {\n    const resolvedTarget = resolve(target);\n    const resolvedLinkPath = resolve(linkPath);\n\n    // Use realpath to handle cases where parent directories are symlinked.\n    // This prevents deleting the canonical directory if the agent directory\n    // is a symlink to the canonical location.\n    const [realTarget, realLinkPath] = await Promise.all([\n      realpath(resolvedTarget).catch(() => resolvedTarget),\n      realpath(resolvedLinkPath).catch(() => resolvedLinkPath),\n    ]);\n\n    if (realTarget === realLinkPath) {\n      return true;\n    }\n\n    // Also check with symlinks resolved in parent directories.\n    // This handles cases where e.g. ~/.claude/skills is a symlink to ~/.agents/skills,\n    // so ~/.claude/skills/<skill> and ~/.agents/skills/<skill> are physically the same.\n    const realTargetWithParents = await resolveParentSymlinks(target);\n    const realLinkPathWithParents = await resolveParentSymlinks(linkPath);\n\n    if (realTargetWithParents === realLinkPathWithParents) {\n      return true;\n    }\n\n    try {\n      const stats = await lstat(linkPath);\n      if (stats.isSymbolicLink()) {\n        const existingTarget = await readlink(linkPath);\n        if (resolveSymlinkTarget(linkPath, existingTarget) === resolvedTarget) {\n          return true;\n        }\n        await rm(linkPath);\n      } else {\n        await rm(linkPath, { recursive: true });\n      }\n    } catch (err: unknown) {\n      // ELOOP = circular symlink, ENOENT = doesn't exist\n      // For ELOOP, try to remove the broken symlink\n      if (err && typeof err === 'object' && 'code' in err && err.code === 'ELOOP') {\n        try {\n          await rm(linkPath, { force: true });\n        } catch {\n          // If we can't remove it, symlink creation will fail and trigger copy fallback\n        }\n      }\n      // For ENOENT or other errors, continue to symlink creation\n    }\n\n    const linkDir = dirname(linkPath);\n    await mkdir(linkDir, { recursive: true });\n\n    // Use the real (symlink-resolved) parent directory for computing the relative path.\n    // This ensures the symlink target is correct even when the link's parent dir is a symlink.\n    const realLinkDir = await resolveParentSymlinks(linkDir);\n    const relativePath = relative(realLinkDir, target);\n    const symlinkType = platform() === 'win32' ? 'junction' : undefined;\n\n    await symlink(relativePath, linkPath, symlinkType);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nexport async function installSkillForAgent(\n  skill: Skill,\n  agentType: AgentType,\n  options: { global?: boolean; cwd?: string; mode?: InstallMode } = {}\n): Promise<InstallResult> {\n  const agent = agents[agentType];\n  const isGlobal = options.global ?? false;\n  const cwd = options.cwd || process.cwd();\n\n  // Check if agent supports global installation\n  if (isGlobal && agent.globalSkillsDir === undefined) {\n    return {\n      success: false,\n      path: '',\n      mode: options.mode ?? 'symlink',\n      error: `${agent.displayName} does not support global skill installation`,\n    };\n  }\n\n  // Sanitize skill name to prevent directory traversal\n  const rawSkillName = skill.name || basename(skill.path);\n  const skillName = sanitizeName(rawSkillName);\n\n  // Canonical location: .agents/skills/<skill-name>\n  const canonicalBase = getCanonicalSkillsDir(isGlobal, cwd);\n  const canonicalDir = join(canonicalBase, skillName);\n\n  // Agent-specific location (for symlink)\n  const agentBase = getAgentBaseDir(agentType, isGlobal, cwd);\n  const agentDir = join(agentBase, skillName);\n\n  const installMode = options.mode ?? 'symlink';\n\n  // Validate paths\n  if (!isPathSafe(canonicalBase, canonicalDir)) {\n    return {\n      success: false,\n      path: agentDir,\n      mode: installMode,\n      error: 'Invalid skill name: potential path traversal detected',\n    };\n  }\n\n  if (!isPathSafe(agentBase, agentDir)) {\n    return {\n      success: false,\n      path: agentDir,\n      mode: installMode,\n      error: 'Invalid skill name: potential path traversal detected',\n    };\n  }\n\n  try {\n    // For copy mode, skip canonical directory and copy directly to agent location\n    if (installMode === 'copy') {\n      await cleanAndCreateDirectory(agentDir);\n      await copyDirectory(skill.path, agentDir);\n\n      return {\n        success: true,\n        path: agentDir,\n        mode: 'copy',\n      };\n    }\n\n    // Symlink mode: copy to canonical location and symlink to agent location\n    await cleanAndCreateDirectory(canonicalDir);\n    await copyDirectory(skill.path, canonicalDir);\n\n    // For universal agents with global install, the skill is already in the canonical\n    // ~/.agents/skills directory. Skip creating a symlink to the agent-specific global dir\n    // (e.g. ~/.copilot/skills) to avoid duplicates.\n    if (isGlobal && isUniversalAgent(agentType)) {\n      return {\n        success: true,\n        path: canonicalDir,\n        canonicalPath: canonicalDir,\n        mode: 'symlink',\n      };\n    }\n\n    const symlinkCreated = await createSymlink(canonicalDir, agentDir);\n\n    if (!symlinkCreated) {\n      // Symlink failed, fall back to copy\n      await cleanAndCreateDirectory(agentDir);\n      await copyDirectory(skill.path, agentDir);\n\n      return {\n        success: true,\n        path: agentDir,\n        canonicalPath: canonicalDir,\n        mode: 'symlink',\n        symlinkFailed: true,\n      };\n    }\n\n    return {\n      success: true,\n      path: agentDir,\n      canonicalPath: canonicalDir,\n      mode: 'symlink',\n    };\n  } catch (error) {\n    return {\n      success: false,\n      path: agentDir,\n      mode: installMode,\n      error: error instanceof Error ? error.message : 'Unknown error',\n    };\n  }\n}\n\nconst EXCLUDE_FILES = new Set(['metadata.json']);\nconst EXCLUDE_DIRS = new Set(['.git', '__pycache__', '__pypackages__']);\n\nconst isExcluded = (name: string, isDirectory: boolean = false): boolean => {\n  if (EXCLUDE_FILES.has(name)) return true;\n  if (name.startsWith('.')) return true;\n  if (isDirectory && EXCLUDE_DIRS.has(name)) return true;\n  return false;\n};\n\nasync function copyDirectory(src: string, dest: string): Promise<void> {\n  await mkdir(dest, { recursive: true });\n\n  const entries = await readdir(src, { withFileTypes: true });\n\n  // Copy files and directories in parallel\n  await Promise.all(\n    entries\n      .filter((entry) => !isExcluded(entry.name, entry.isDirectory()))\n      .map(async (entry) => {\n        const srcPath = join(src, entry.name);\n        const destPath = join(dest, entry.name);\n\n        if (entry.isDirectory()) {\n          await copyDirectory(srcPath, destPath);\n        } else {\n          try {\n            await cp(srcPath, destPath, {\n              // If the file is a symlink to elsewhere in a remote skill, it may not\n              // resolve correctly once it has been copied to the local location.\n              // `dereference: true` tells Node to copy the file instead of copying\n              // the symlink. `recursive: true` handles symlinks pointing to directories.\n              dereference: true,\n              recursive: true,\n            });\n          } catch (err: unknown) {\n            // Skip broken symlinks (e.g., pointing to absolute paths on another machine)\n            // instead of aborting the entire install.\n            if (\n              err instanceof Error &&\n              'code' in err &&\n              (err as NodeJS.ErrnoException).code === 'ENOENT' &&\n              entry.isSymbolicLink()\n            ) {\n              console.warn(`Skipping broken symlink: ${srcPath}`);\n            } else {\n              throw err;\n            }\n          }\n        }\n      })\n  );\n}\n\nexport async function isSkillInstalled(\n  skillName: string,\n  agentType: AgentType,\n  options: { global?: boolean; cwd?: string } = {}\n): Promise<boolean> {\n  const agent = agents[agentType];\n  const sanitized = sanitizeName(skillName);\n\n  // Agent doesn't support global installation\n  if (options.global && agent.globalSkillsDir === undefined) {\n    return false;\n  }\n\n  const targetBase = options.global\n    ? agent.globalSkillsDir!\n    : join(options.cwd || process.cwd(), agent.skillsDir);\n\n  const skillDir = join(targetBase, sanitized);\n\n  if (!isPathSafe(targetBase, skillDir)) {\n    return false;\n  }\n\n  try {\n    await access(skillDir);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nexport function getInstallPath(\n  skillName: string,\n  agentType: AgentType,\n  options: { global?: boolean; cwd?: string } = {}\n): string {\n  const agent = agents[agentType];\n  const cwd = options.cwd || process.cwd();\n  const sanitized = sanitizeName(skillName);\n\n  const targetBase = getAgentBaseDir(agentType, options.global ?? false, options.cwd);\n  const installPath = join(targetBase, sanitized);\n\n  if (!isPathSafe(targetBase, installPath)) {\n    throw new Error('Invalid skill name: potential path traversal detected');\n  }\n\n  return installPath;\n}\n\n/**\n * Gets the canonical .agents/skills/<skill> path\n */\nexport function getCanonicalPath(\n  skillName: string,\n  options: { global?: boolean; cwd?: string } = {}\n): string {\n  const sanitized = sanitizeName(skillName);\n  const canonicalBase = getCanonicalSkillsDir(options.global ?? false, options.cwd);\n  const canonicalPath = join(canonicalBase, sanitized);\n\n  if (!isPathSafe(canonicalBase, canonicalPath)) {\n    throw new Error('Invalid skill name: potential path traversal detected');\n  }\n\n  return canonicalPath;\n}\n\n/**\n * Install a remote skill from any host provider.\n * The skill directory name is derived from the installName field.\n * Supports symlink mode (writes to canonical location and symlinks to agent dirs)\n * or copy mode (writes directly to each agent dir).\n */\nexport async function installRemoteSkillForAgent(\n  skill: RemoteSkill,\n  agentType: AgentType,\n  options: { global?: boolean; cwd?: string; mode?: InstallMode } = {}\n): Promise<InstallResult> {\n  const agent = agents[agentType];\n  const isGlobal = options.global ?? false;\n  const cwd = options.cwd || process.cwd();\n  const installMode = options.mode ?? 'symlink';\n\n  // Check if agent supports global installation\n  if (isGlobal && agent.globalSkillsDir === undefined) {\n    return {\n      success: false,\n      path: '',\n      mode: installMode,\n      error: `${agent.displayName} does not support global skill installation`,\n    };\n  }\n\n  // Use installName as the skill directory name\n  const skillName = sanitizeName(skill.installName);\n\n  // Canonical location: .agents/skills/<skill-name>\n  const canonicalBase = getCanonicalSkillsDir(isGlobal, cwd);\n  const canonicalDir = join(canonicalBase, skillName);\n\n  // Agent-specific location (for symlink)\n  const agentBase = getAgentBaseDir(agentType, isGlobal, cwd);\n  const agentDir = join(agentBase, skillName);\n\n  // Validate paths\n  if (!isPathSafe(canonicalBase, canonicalDir)) {\n    return {\n      success: false,\n      path: agentDir,\n      mode: installMode,\n      error: 'Invalid skill name: potential path traversal detected',\n    };\n  }\n\n  if (!isPathSafe(agentBase, agentDir)) {\n    return {\n      success: false,\n      path: agentDir,\n      mode: installMode,\n      error: 'Invalid skill name: potential path traversal detected',\n    };\n  }\n\n  try {\n    // For copy mode, write directly to agent location\n    if (installMode === 'copy') {\n      await cleanAndCreateDirectory(agentDir);\n      const skillMdPath = join(agentDir, 'SKILL.md');\n      await writeFile(skillMdPath, skill.content, 'utf-8');\n\n      return {\n        success: true,\n        path: agentDir,\n        mode: 'copy',\n      };\n    }\n\n    // Symlink mode: write to canonical location and symlink to agent location\n    await cleanAndCreateDirectory(canonicalDir);\n    const skillMdPath = join(canonicalDir, 'SKILL.md');\n    await writeFile(skillMdPath, skill.content, 'utf-8');\n\n    // For universal agents with global install, skip creating agent-specific symlink\n    if (isGlobal && isUniversalAgent(agentType)) {\n      return {\n        success: true,\n        path: canonicalDir,\n        canonicalPath: canonicalDir,\n        mode: 'symlink',\n      };\n    }\n\n    const symlinkCreated = await createSymlink(canonicalDir, agentDir);\n\n    if (!symlinkCreated) {\n      // Symlink failed, fall back to copy\n      await cleanAndCreateDirectory(agentDir);\n      const agentSkillMdPath = join(agentDir, 'SKILL.md');\n      await writeFile(agentSkillMdPath, skill.content, 'utf-8');\n\n      return {\n        success: true,\n        path: agentDir,\n        canonicalPath: canonicalDir,\n        mode: 'symlink',\n        symlinkFailed: true,\n      };\n    }\n\n    return {\n      success: true,\n      path: agentDir,\n      canonicalPath: canonicalDir,\n      mode: 'symlink',\n    };\n  } catch (error) {\n    return {\n      success: false,\n      path: agentDir,\n      mode: installMode,\n      error: error instanceof Error ? error.message : 'Unknown error',\n    };\n  }\n}\n\n/**\n * Install a well-known skill with multiple files.\n * The skill directory name is derived from the installName field.\n * All files from the skill's files map are written to the installation directory.\n * Supports symlink mode (writes to canonical location and symlinks to agent dirs)\n * or copy mode (writes directly to each agent dir).\n */\nexport async function installWellKnownSkillForAgent(\n  skill: WellKnownSkill,\n  agentType: AgentType,\n  options: { global?: boolean; cwd?: string; mode?: InstallMode } = {}\n): Promise<InstallResult> {\n  const agent = agents[agentType];\n  const isGlobal = options.global ?? false;\n  const cwd = options.cwd || process.cwd();\n  const installMode = options.mode ?? 'symlink';\n\n  // Check if agent supports global installation\n  if (isGlobal && agent.globalSkillsDir === undefined) {\n    return {\n      success: false,\n      path: '',\n      mode: installMode,\n      error: `${agent.displayName} does not support global skill installation`,\n    };\n  }\n\n  // Use installName as the skill directory name\n  const skillName = sanitizeName(skill.installName);\n\n  // Canonical location: .agents/skills/<skill-name>\n  const canonicalBase = getCanonicalSkillsDir(isGlobal, cwd);\n  const canonicalDir = join(canonicalBase, skillName);\n\n  // Agent-specific location (for symlink)\n  const agentBase = getAgentBaseDir(agentType, isGlobal, cwd);\n  const agentDir = join(agentBase, skillName);\n\n  // Validate paths\n  if (!isPathSafe(canonicalBase, canonicalDir)) {\n    return {\n      success: false,\n      path: agentDir,\n      mode: installMode,\n      error: 'Invalid skill name: potential path traversal detected',\n    };\n  }\n\n  if (!isPathSafe(agentBase, agentDir)) {\n    return {\n      success: false,\n      path: agentDir,\n      mode: installMode,\n      error: 'Invalid skill name: potential path traversal detected',\n    };\n  }\n\n  /**\n   * Write all skill files to a directory (assumes directory already exists)\n   */\n  async function writeSkillFiles(targetDir: string): Promise<void> {\n    for (const [filePath, content] of skill.files) {\n      // Validate file path doesn't escape the target directory\n      const fullPath = join(targetDir, filePath);\n      if (!isPathSafe(targetDir, fullPath)) {\n        continue; // Skip files that would escape the directory\n      }\n\n      // Create parent directories if needed\n      const parentDir = dirname(fullPath);\n      if (parentDir !== targetDir) {\n        await mkdir(parentDir, { recursive: true });\n      }\n\n      await writeFile(fullPath, content, 'utf-8');\n    }\n  }\n\n  try {\n    // For copy mode, write directly to agent location\n    if (installMode === 'copy') {\n      await cleanAndCreateDirectory(agentDir);\n      await writeSkillFiles(agentDir);\n\n      return {\n        success: true,\n        path: agentDir,\n        mode: 'copy',\n      };\n    }\n\n    // Symlink mode: write to canonical location and symlink to agent location\n    await cleanAndCreateDirectory(canonicalDir);\n    await writeSkillFiles(canonicalDir);\n\n    // For universal agents with global install, skip creating agent-specific symlink\n    if (isGlobal && isUniversalAgent(agentType)) {\n      return {\n        success: true,\n        path: canonicalDir,\n        canonicalPath: canonicalDir,\n        mode: 'symlink',\n      };\n    }\n\n    const symlinkCreated = await createSymlink(canonicalDir, agentDir);\n\n    if (!symlinkCreated) {\n      // Symlink failed, fall back to copy\n      await cleanAndCreateDirectory(agentDir);\n      await writeSkillFiles(agentDir);\n\n      return {\n        success: true,\n        path: agentDir,\n        canonicalPath: canonicalDir,\n        mode: 'symlink',\n        symlinkFailed: true,\n      };\n    }\n\n    return {\n      success: true,\n      path: agentDir,\n      canonicalPath: canonicalDir,\n      mode: 'symlink',\n    };\n  } catch (error) {\n    return {\n      success: false,\n      path: agentDir,\n      mode: installMode,\n      error: error instanceof Error ? error.message : 'Unknown error',\n    };\n  }\n}\n\nexport interface InstalledSkill {\n  name: string;\n  description: string;\n  path: string;\n  canonicalPath: string;\n  scope: 'project' | 'global';\n  agents: AgentType[];\n}\n\n/**\n * Lists all installed skills from canonical locations\n * @param options - Options for listing skills\n * @returns Array of installed skills with metadata\n */\nexport async function listInstalledSkills(\n  options: {\n    global?: boolean;\n    cwd?: string;\n    agentFilter?: AgentType[];\n  } = {}\n): Promise<InstalledSkill[]> {\n  const cwd = options.cwd || process.cwd();\n  // Use a Map to deduplicate skills by scope:name\n  const skillsMap: Map<string, InstalledSkill> = new Map();\n  const scopes: Array<{ global: boolean; path: string; agentType?: AgentType }> = [];\n\n  // Detect which agents are actually installed\n  const detectedAgents = await detectInstalledAgents();\n  const agentFilter = options.agentFilter;\n  const agentsToCheck = agentFilter\n    ? detectedAgents.filter((a) => agentFilter.includes(a))\n    : detectedAgents;\n\n  // Determine which scopes to scan\n  const scopeTypes: Array<{ global: boolean }> = [];\n  if (options.global === undefined) {\n    scopeTypes.push({ global: false }, { global: true });\n  } else {\n    scopeTypes.push({ global: options.global });\n  }\n\n  // Build list of directories to scan: canonical + each installed agent's directory\n  //\n  // Scanning workflow:\n  //\n  //   detectInstalledAgents()\n  //            │\n  //            ▼\n  //   for each scope (project / global)\n  //            │\n  //            ├──▶ scan canonical dir ──▶ .agents/skills, ~/.agents/skills\n  //            │\n  //            ├──▶ scan each installed agent's dir ──▶ .cursor/skills, .claude/skills, ...\n  //            │\n  //            ▼\n  //   deduplicate by skill name\n  //\n  // Trade-off: More readdir() calls, but most non-existent dirs fail fast.\n  // Skills in agent-specific dirs skip the expensive \"check all agents\" loop.\n  //\n  for (const { global: isGlobal } of scopeTypes) {\n    // Add canonical directory\n    scopes.push({ global: isGlobal, path: getCanonicalSkillsDir(isGlobal, cwd) });\n\n    // Add each installed agent's skills directory\n    for (const agentType of agentsToCheck) {\n      const agent = agents[agentType];\n      if (isGlobal && agent.globalSkillsDir === undefined) {\n        continue;\n      }\n      const agentDir = isGlobal ? agent.globalSkillsDir! : join(cwd, agent.skillsDir);\n      // Avoid duplicate paths\n      if (!scopes.some((s) => s.path === agentDir && s.global === isGlobal)) {\n        scopes.push({ global: isGlobal, path: agentDir, agentType });\n      }\n    }\n\n    // Also scan skill directories for agents NOT in agentsToCheck, in case\n    // skills were installed with `--agent <name>` but the agent is no longer\n    // detected (e.g. ~/.openclaw was removed).  Only add dirs that actually\n    // exist on disk to avoid unnecessary readdir errors.\n    const allAgentTypes = Object.keys(agents) as AgentType[];\n    for (const agentType of allAgentTypes) {\n      if (agentsToCheck.includes(agentType)) continue;\n      const agent = agents[agentType];\n      if (isGlobal && agent.globalSkillsDir === undefined) continue;\n      const agentDir = isGlobal ? agent.globalSkillsDir! : join(cwd, agent.skillsDir);\n      if (scopes.some((s) => s.path === agentDir && s.global === isGlobal)) continue;\n      if (existsSync(agentDir)) {\n        scopes.push({ global: isGlobal, path: agentDir, agentType });\n      }\n    }\n  }\n\n  for (const scope of scopes) {\n    try {\n      const entries = await readdir(scope.path, { withFileTypes: true });\n\n      for (const entry of entries) {\n        if (!entry.isDirectory()) {\n          continue;\n        }\n\n        const skillDir = join(scope.path, entry.name);\n        const skillMdPath = join(skillDir, 'SKILL.md');\n\n        // Check if SKILL.md exists\n        try {\n          await stat(skillMdPath);\n        } catch {\n          // SKILL.md doesn't exist, skip this directory\n          continue;\n        }\n\n        // Parse the skill\n        const skill = await parseSkillMd(skillMdPath);\n        if (!skill) {\n          continue;\n        }\n\n        const scopeKey = scope.global ? 'global' : 'project';\n        const skillKey = `${scopeKey}:${skill.name}`;\n\n        // If scanning an agent-specific directory, attribute directly to that agent\n        if (scope.agentType) {\n          if (skillsMap.has(skillKey)) {\n            const existing = skillsMap.get(skillKey)!;\n            if (!existing.agents.includes(scope.agentType)) {\n              existing.agents.push(scope.agentType);\n            }\n          } else {\n            skillsMap.set(skillKey, {\n              name: skill.name,\n              description: skill.description,\n              path: skillDir,\n              canonicalPath: skillDir,\n              scope: scopeKey,\n              agents: [scope.agentType],\n            });\n          }\n          continue;\n        }\n\n        // For canonical directory, check which agents have this skill\n        const sanitizedSkillName = sanitizeName(skill.name);\n        const installedAgents: AgentType[] = [];\n\n        for (const agentType of agentsToCheck) {\n          const agent = agents[agentType];\n\n          if (scope.global && agent.globalSkillsDir === undefined) {\n            continue;\n          }\n\n          const agentBase = scope.global ? agent.globalSkillsDir! : join(cwd, agent.skillsDir);\n          let found = false;\n\n          // Try exact directory name matches\n          const possibleNames = Array.from(\n            new Set([\n              entry.name,\n              sanitizedSkillName,\n              skill.name\n                .toLowerCase()\n                .replace(/\\s+/g, '-')\n                .replace(/[\\/\\\\:\\0]/g, ''),\n            ])\n          );\n\n          for (const possibleName of possibleNames) {\n            const agentSkillDir = join(agentBase, possibleName);\n            if (!isPathSafe(agentBase, agentSkillDir)) continue;\n\n            try {\n              await access(agentSkillDir);\n              found = true;\n              break;\n            } catch {\n              // Try next name\n            }\n          }\n\n          // Fallback: scan all directories and check SKILL.md files\n          // Handles cases where directory names don't match (e.g., \"git-review\" vs \"Git Review Before Commit\")\n          if (!found) {\n            try {\n              const agentEntries = await readdir(agentBase, { withFileTypes: true });\n              for (const agentEntry of agentEntries) {\n                if (!agentEntry.isDirectory()) continue;\n\n                const candidateDir = join(agentBase, agentEntry.name);\n                if (!isPathSafe(agentBase, candidateDir)) continue;\n\n                try {\n                  const candidateSkillMd = join(candidateDir, 'SKILL.md');\n                  await stat(candidateSkillMd);\n                  const candidateSkill = await parseSkillMd(candidateSkillMd);\n                  if (candidateSkill && candidateSkill.name === skill.name) {\n                    found = true;\n                    break;\n                  }\n                } catch {\n                  // Not a valid skill directory\n                }\n              }\n            } catch {\n              // Agent base directory doesn't exist\n            }\n          }\n\n          if (found) {\n            installedAgents.push(agentType);\n          }\n        }\n\n        if (skillsMap.has(skillKey)) {\n          // Merge agents\n          const existing = skillsMap.get(skillKey)!;\n          for (const agent of installedAgents) {\n            if (!existing.agents.includes(agent)) {\n              existing.agents.push(agent);\n            }\n          }\n        } else {\n          skillsMap.set(skillKey, {\n            name: skill.name,\n            description: skill.description,\n            path: skillDir,\n            canonicalPath: skillDir,\n            scope: scopeKey,\n            agents: installedAgents,\n          });\n        }\n      }\n    } catch {\n      // Directory doesn't exist, skip\n    }\n  }\n\n  return Array.from(skillsMap.values());\n}\n"
  },
  {
    "path": "src/list.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { existsSync, rmSync, mkdirSync, writeFileSync } from 'fs';\nimport { join } from 'path';\nimport { tmpdir, homedir } from 'os';\nimport { runCli } from './test-utils.ts';\nimport { parseListOptions } from './list.ts';\n\ndescribe('list command', () => {\n  let testDir: string;\n\n  beforeEach(() => {\n    testDir = join(tmpdir(), `skills-list-test-${Date.now()}`);\n    mkdirSync(testDir, { recursive: true });\n  });\n\n  afterEach(() => {\n    if (existsSync(testDir)) {\n      rmSync(testDir, { recursive: true, force: true });\n    }\n  });\n\n  describe('parseListOptions', () => {\n    it('should parse empty args', () => {\n      const options = parseListOptions([]);\n      expect(options).toEqual({});\n    });\n\n    it('should parse -g flag', () => {\n      const options = parseListOptions(['-g']);\n      expect(options.global).toBe(true);\n    });\n\n    it('should parse --global flag', () => {\n      const options = parseListOptions(['--global']);\n      expect(options.global).toBe(true);\n    });\n\n    it('should parse -a flag with single agent', () => {\n      const options = parseListOptions(['-a', 'claude-code']);\n      expect(options.agent).toEqual(['claude-code']);\n    });\n\n    it('should parse --agent flag with single agent', () => {\n      const options = parseListOptions(['--agent', 'cursor']);\n      expect(options.agent).toEqual(['cursor']);\n    });\n\n    it('should parse -a flag with multiple agents', () => {\n      const options = parseListOptions(['-a', 'claude-code', 'cursor', 'codex']);\n      expect(options.agent).toEqual(['claude-code', 'cursor', 'codex']);\n    });\n\n    it('should parse combined flags', () => {\n      const options = parseListOptions(['-g', '-a', 'claude-code', 'cursor']);\n      expect(options.global).toBe(true);\n      expect(options.agent).toEqual(['claude-code', 'cursor']);\n    });\n\n    it('should parse --json flag', () => {\n      const options = parseListOptions(['--json']);\n      expect(options.json).toBe(true);\n    });\n\n    it('should parse combined --json and -g flags', () => {\n      const options = parseListOptions(['-g', '--json']);\n      expect(options.global).toBe(true);\n      expect(options.json).toBe(true);\n    });\n\n    it('should stop collecting agents at next flag', () => {\n      const options = parseListOptions(['-a', 'claude-code', '-g']);\n      expect(options.agent).toEqual(['claude-code']);\n      expect(options.global).toBe(true);\n    });\n  });\n\n  describe('CLI integration', () => {\n    it('should run list command', () => {\n      const result = runCli(['list'], testDir);\n      // Empty project dir shows \"No project skills found\"\n      expect(result.stdout).toContain('No project skills found');\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should run ls alias', () => {\n      const result = runCli(['ls'], testDir);\n      expect(result.stdout).toContain('No project skills found');\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should output empty JSON array when no skills', () => {\n      const result = runCli(['list', '--json'], testDir);\n      expect(result.exitCode).toBe(0);\n      const parsed = JSON.parse(result.stdout.trim());\n      expect(parsed).toEqual([]);\n    });\n\n    it('should output valid JSON with --json flag', () => {\n      const skillDir = join(testDir, '.agents', 'skills', 'json-skill');\n      mkdirSync(skillDir, { recursive: true });\n      writeFileSync(\n        join(skillDir, 'SKILL.md'),\n        `---\nname: json-skill\ndescription: A skill for JSON testing\n---\n\n# JSON Skill\n`\n      );\n\n      const result = runCli(['list', '--json'], testDir);\n      expect(result.exitCode).toBe(0);\n      const parsed = JSON.parse(result.stdout.trim());\n      expect(Array.isArray(parsed)).toBe(true);\n      expect(parsed.length).toBe(1);\n      expect(parsed[0].name).toBe('json-skill');\n      expect(parsed[0].path).toContain('json-skill');\n      expect(parsed[0].scope).toBe('project');\n      expect(Array.isArray(parsed[0].agents)).toBe(true);\n      // No ANSI codes in JSON output\n      expect(result.stdout).not.toMatch(/\\x1b\\[/);\n    });\n\n    it('should output multiple skills as JSON array', () => {\n      const skill1Dir = join(testDir, '.agents', 'skills', 'skill-alpha');\n      const skill2Dir = join(testDir, '.agents', 'skills', 'skill-beta');\n      mkdirSync(skill1Dir, { recursive: true });\n      mkdirSync(skill2Dir, { recursive: true });\n\n      writeFileSync(\n        join(skill1Dir, 'SKILL.md'),\n        `---\\nname: skill-alpha\\ndescription: Alpha\\n---\\n# Alpha\\n`\n      );\n      writeFileSync(\n        join(skill2Dir, 'SKILL.md'),\n        `---\\nname: skill-beta\\ndescription: Beta\\n---\\n# Beta\\n`\n      );\n\n      const result = runCli(['list', '--json'], testDir);\n      expect(result.exitCode).toBe(0);\n      const parsed = JSON.parse(result.stdout.trim());\n      expect(parsed.length).toBe(2);\n      const names = parsed.map((s: any) => s.name);\n      expect(names).toContain('skill-alpha');\n      expect(names).toContain('skill-beta');\n    });\n\n    it('should show message when no project skills found', () => {\n      const result = runCli(['list'], testDir);\n      expect(result.stdout).toContain('No project skills found');\n      expect(result.stdout).toContain('Try listing global skills with -g');\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should list project skills', () => {\n      // Create a skill in the canonical location\n      const skillDir = join(testDir, '.agents', 'skills', 'test-skill');\n      mkdirSync(skillDir, { recursive: true });\n      writeFileSync(\n        join(skillDir, 'SKILL.md'),\n        `---\nname: test-skill\ndescription: A test skill for listing\n---\n\n# Test Skill\n\nThis is a test skill.\n`\n      );\n\n      const result = runCli(['list'], testDir);\n      expect(result.stdout).toContain('test-skill');\n      expect(result.stdout).toContain('Project Skills');\n      // Description should not be shown\n      expect(result.stdout).not.toContain('A test skill for listing');\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should list multiple skills', () => {\n      // Create multiple skills\n      const skill1Dir = join(testDir, '.agents', 'skills', 'skill-one');\n      const skill2Dir = join(testDir, '.agents', 'skills', 'skill-two');\n      mkdirSync(skill1Dir, { recursive: true });\n      mkdirSync(skill2Dir, { recursive: true });\n\n      writeFileSync(\n        join(skill1Dir, 'SKILL.md'),\n        `---\nname: skill-one\ndescription: First skill\n---\n# Skill One\n`\n      );\n\n      writeFileSync(\n        join(skill2Dir, 'SKILL.md'),\n        `---\nname: skill-two\ndescription: Second skill\n---\n# Skill Two\n`\n      );\n\n      const result = runCli(['list'], testDir);\n      expect(result.stdout).toContain('skill-one');\n      expect(result.stdout).toContain('skill-two');\n      expect(result.stdout).toContain('Project Skills');\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should respect -g flag for global only', () => {\n      // Create a project skill (should not be shown with -g)\n      const skillDir = join(testDir, '.agents', 'skills', 'project-skill');\n      mkdirSync(skillDir, { recursive: true });\n      writeFileSync(\n        join(skillDir, 'SKILL.md'),\n        `---\nname: project-skill\ndescription: A project skill\n---\n# Project Skill\n`\n      );\n\n      const result = runCli(['list', '-g'], testDir);\n      // Should not show project skill when -g is specified\n      expect(result.stdout).not.toContain('project-skill');\n      expect(result.stdout).toContain('Global Skills');\n    });\n\n    it('should show error for invalid agent filter', () => {\n      const result = runCli(['list', '-a', 'invalid-agent'], testDir);\n      expect(result.stdout).toContain('Invalid agents');\n      expect(result.stdout).toContain('invalid-agent');\n      expect(result.exitCode).toBe(1);\n    });\n\n    it('should filter by valid agent', () => {\n      // Create a skill\n      const skillDir = join(testDir, '.agents', 'skills', 'test-skill');\n      mkdirSync(skillDir, { recursive: true });\n      writeFileSync(\n        join(skillDir, 'SKILL.md'),\n        `---\nname: test-skill\ndescription: A test skill\n---\n# Test Skill\n`\n      );\n\n      const result = runCli(['list', '-a', 'claude-code'], testDir);\n      expect(result.stdout).toContain('test-skill');\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should ignore directories without SKILL.md', () => {\n      // Create a valid skill\n      const validDir = join(testDir, '.agents', 'skills', 'valid-skill');\n      mkdirSync(validDir, { recursive: true });\n      writeFileSync(\n        join(validDir, 'SKILL.md'),\n        `---\nname: valid-skill\ndescription: Valid skill\n---\n# Valid\n`\n      );\n\n      // Create an invalid directory (no SKILL.md)\n      const invalidDir = join(testDir, '.agents', 'skills', 'invalid-skill');\n      mkdirSync(invalidDir, { recursive: true });\n      writeFileSync(join(invalidDir, 'README.md'), '# Not a skill');\n\n      const result = runCli(['list'], testDir);\n      expect(result.stdout).toContain('valid-skill');\n      expect(result.stdout).not.toContain('invalid-skill');\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should handle SKILL.md with missing frontmatter', () => {\n      // Create a valid skill\n      const validDir = join(testDir, '.agents', 'skills', 'valid-skill');\n      mkdirSync(validDir, { recursive: true });\n      writeFileSync(\n        join(validDir, 'SKILL.md'),\n        `---\nname: valid-skill\ndescription: Valid skill\n---\n# Valid\n`\n      );\n\n      // Create a skill with invalid SKILL.md (no frontmatter)\n      const invalidDir = join(testDir, '.agents', 'skills', 'invalid-skill');\n      mkdirSync(invalidDir, { recursive: true });\n      writeFileSync(join(invalidDir, 'SKILL.md'), '# Invalid\\nNo frontmatter here');\n\n      const result = runCli(['list'], testDir);\n      expect(result.stdout).toContain('valid-skill');\n      expect(result.stdout).not.toContain('invalid-skill');\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should show skill path', () => {\n      const skillDir = join(testDir, '.agents', 'skills', 'test-skill');\n      mkdirSync(skillDir, { recursive: true });\n      writeFileSync(\n        join(skillDir, 'SKILL.md'),\n        `---\nname: test-skill\ndescription: A test skill\n---\n# Test Skill\n`\n      );\n\n      const result = runCli(['list'], testDir);\n      // Path is shown inline with skill name (handles both Unix / and Windows \\)\n      expect(result.stdout).toMatch(/\\.agents[/\\\\]skills[/\\\\]test-skill/);\n    });\n  });\n\n  describe('help output', () => {\n    it('should include list command in help', () => {\n      const result = runCli(['--help']);\n      expect(result.stdout).toContain('list, ls');\n      expect(result.stdout).toContain('List installed skills');\n    });\n\n    it('should include list options in help', () => {\n      const result = runCli(['--help']);\n      expect(result.stdout).toContain('List Options:');\n      expect(result.stdout).toContain('-g, --global');\n      expect(result.stdout).toContain('-a, --agent');\n    });\n\n    it('should include list examples in help', () => {\n      const result = runCli(['--help']);\n      expect(result.stdout).toContain('skills list');\n      expect(result.stdout).toContain('skills ls -g');\n      expect(result.stdout).toContain('skills ls -a claude-code');\n    });\n  });\n\n  describe('banner', () => {\n    it('should include list command in banner', () => {\n      const result = runCli([]);\n      expect(result.stdout).toContain('npx skills list');\n      expect(result.stdout).toContain('List installed skills');\n    });\n  });\n});\n"
  },
  {
    "path": "src/list.ts",
    "content": "import { homedir } from 'os';\nimport type { AgentType } from './types.ts';\nimport { agents } from './agents.ts';\nimport { listInstalledSkills, type InstalledSkill } from './installer.ts';\nimport { getAllLockedSkills } from './skill-lock.ts';\n\nconst RESET = '\\x1b[0m';\nconst BOLD = '\\x1b[1m';\nconst DIM = '\\x1b[38;5;102m';\nconst TEXT = '\\x1b[38;5;145m';\nconst CYAN = '\\x1b[36m';\nconst YELLOW = '\\x1b[33m';\n\ninterface ListOptions {\n  global?: boolean;\n  agent?: string[];\n  json?: boolean;\n}\n\n/**\n * Shortens a path for display: replaces homedir with ~ and cwd with .\n */\nfunction shortenPath(fullPath: string, cwd: string): string {\n  const home = homedir();\n  if (fullPath.startsWith(home)) {\n    return fullPath.replace(home, '~');\n  }\n  if (fullPath.startsWith(cwd)) {\n    return '.' + fullPath.slice(cwd.length);\n  }\n  return fullPath;\n}\n\n/**\n * Formats a list of items, truncating if too many\n */\nfunction formatList(items: string[], maxShow: number = 5): string {\n  if (items.length <= maxShow) {\n    return items.join(', ');\n  }\n  const shown = items.slice(0, maxShow);\n  const remaining = items.length - maxShow;\n  return `${shown.join(', ')} +${remaining} more`;\n}\n\nexport function parseListOptions(args: string[]): ListOptions {\n  const options: ListOptions = {};\n\n  for (let i = 0; i < args.length; i++) {\n    const arg = args[i];\n    if (arg === '-g' || arg === '--global') {\n      options.global = true;\n    } else if (arg === '--json') {\n      options.json = true;\n    } else if (arg === '-a' || arg === '--agent') {\n      options.agent = options.agent || [];\n      // Collect all following arguments until next flag\n      while (i + 1 < args.length && !args[i + 1]!.startsWith('-')) {\n        options.agent.push(args[++i]!);\n      }\n    }\n  }\n\n  return options;\n}\n\nexport async function runList(args: string[]): Promise<void> {\n  const options = parseListOptions(args);\n\n  // Default to project only (local), use -g for global\n  const scope = options.global === true ? true : false;\n\n  // Validate agent filter if provided\n  let agentFilter: AgentType[] | undefined;\n  if (options.agent && options.agent.length > 0) {\n    const validAgents = Object.keys(agents);\n    const invalidAgents = options.agent.filter((a) => !validAgents.includes(a));\n\n    if (invalidAgents.length > 0) {\n      console.log(`${YELLOW}Invalid agents: ${invalidAgents.join(', ')}${RESET}`);\n      console.log(`${DIM}Valid agents: ${validAgents.join(', ')}${RESET}`);\n      process.exit(1);\n    }\n\n    agentFilter = options.agent as AgentType[];\n  }\n\n  const installedSkills = await listInstalledSkills({\n    global: scope,\n    agentFilter,\n  });\n\n  // JSON output mode: structured, no ANSI, untruncated agent lists\n  if (options.json) {\n    const jsonOutput = installedSkills.map((skill) => ({\n      name: skill.name,\n      path: skill.canonicalPath,\n      scope: skill.scope,\n      agents: skill.agents.map((a) => agents[a].displayName),\n    }));\n    console.log(JSON.stringify(jsonOutput, null, 2));\n    return;\n  }\n\n  // Fetch lock entries to get plugin grouping info\n  const lockedSkills = await getAllLockedSkills();\n\n  const cwd = process.cwd();\n  const scopeLabel = scope ? 'Global' : 'Project';\n\n  if (installedSkills.length === 0) {\n    if (options.json) {\n      console.log('[]');\n      return;\n    }\n    console.log(`${DIM}No ${scopeLabel.toLowerCase()} skills found.${RESET}`);\n    if (scope) {\n      console.log(`${DIM}Try listing project skills without -g${RESET}`);\n    } else {\n      console.log(`${DIM}Try listing global skills with -g${RESET}`);\n    }\n    return;\n  }\n\n  function printSkill(skill: InstalledSkill, indent: boolean = false): void {\n    const prefix = indent ? '  ' : '';\n    const shortPath = shortenPath(skill.canonicalPath, cwd);\n    const agentNames = skill.agents.map((a) => agents[a].displayName);\n    const agentInfo =\n      skill.agents.length > 0 ? formatList(agentNames) : `${YELLOW}not linked${RESET}`;\n    console.log(`${prefix}${CYAN}${skill.name}${RESET} ${DIM}${shortPath}${RESET}`);\n    console.log(`${prefix}  ${DIM}Agents:${RESET} ${agentInfo}`);\n  }\n\n  console.log(`${BOLD}${scopeLabel} Skills${RESET}`);\n  console.log();\n\n  // Group skills by plugin\n  const groupedSkills: Record<string, InstalledSkill[]> = {};\n  const ungroupedSkills: InstalledSkill[] = [];\n\n  for (const skill of installedSkills) {\n    const lockEntry = lockedSkills[skill.name];\n    if (lockEntry?.pluginName) {\n      const group = lockEntry.pluginName;\n      if (!groupedSkills[group]) {\n        groupedSkills[group] = [];\n      }\n      groupedSkills[group].push(skill);\n    } else {\n      ungroupedSkills.push(skill);\n    }\n  }\n\n  const hasGroups = Object.keys(groupedSkills).length > 0;\n\n  if (hasGroups) {\n    // Print groups sorted alphabetically\n    const sortedGroups = Object.keys(groupedSkills).sort();\n    for (const group of sortedGroups) {\n      // Convert kebab-case to Title Case for display header\n      const title = group\n        .split('-')\n        .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n        .join(' ');\n\n      console.log(`${BOLD}${title}${RESET}`);\n      const skills = groupedSkills[group];\n      if (skills) {\n        for (const skill of skills) {\n          printSkill(skill, true);\n        }\n      }\n      console.log();\n    }\n\n    // Print ungrouped skills if any exist\n    if (ungroupedSkills.length > 0) {\n      console.log(`${BOLD}General${RESET}`);\n      for (const skill of ungroupedSkills) {\n        printSkill(skill, true);\n      }\n      console.log();\n    }\n  } else {\n    // No groups, print flat list as before\n    for (const skill of installedSkills) {\n      printSkill(skill);\n    }\n    console.log();\n  }\n}\n"
  },
  {
    "path": "src/local-lock.ts",
    "content": "import { readFile, writeFile, readdir, stat } from 'fs/promises';\nimport { join, relative } from 'path';\nimport { createHash } from 'crypto';\n\nconst LOCAL_LOCK_FILE = 'skills-lock.json';\nconst CURRENT_VERSION = 1;\n\n/**\n * Represents a single skill entry in the local (project) lock file.\n *\n * Intentionally minimal and timestamp-free to minimize merge conflicts.\n * Two branches adding different skills produce non-overlapping JSON keys\n * that git can auto-merge cleanly.\n */\nexport interface LocalSkillLockEntry {\n  /** Where the skill came from: npm package name, owner/repo, local path, etc. */\n  source: string;\n  /** The provider/source type (e.g., \"github\", \"node_modules\", \"local\") */\n  sourceType: string;\n  /**\n   * SHA-256 hash computed from all files in the skill folder.\n   * Unlike the global lock which uses GitHub tree SHA, the local lock\n   * computes the hash from actual file contents on disk.\n   */\n  computedHash: string;\n}\n\n/**\n * The structure of the local (project-scoped) skill lock file.\n * This file is meant to be checked into version control.\n *\n * Skills are sorted alphabetically by name when written to produce\n * deterministic output and minimize merge conflicts.\n */\nexport interface LocalSkillLockFile {\n  /** Schema version for future migrations */\n  version: number;\n  /** Map of skill name to its lock entry (sorted alphabetically) */\n  skills: Record<string, LocalSkillLockEntry>;\n}\n\n/**\n * Get the path to the local skill lock file for a project.\n */\nexport function getLocalLockPath(cwd?: string): string {\n  return join(cwd || process.cwd(), LOCAL_LOCK_FILE);\n}\n\n/**\n * Read the local skill lock file.\n * Returns an empty lock file structure if the file doesn't exist\n * or is corrupted (e.g., merge conflict markers).\n */\nexport async function readLocalLock(cwd?: string): Promise<LocalSkillLockFile> {\n  const lockPath = getLocalLockPath(cwd);\n\n  try {\n    const content = await readFile(lockPath, 'utf-8');\n    const parsed = JSON.parse(content) as LocalSkillLockFile;\n\n    if (typeof parsed.version !== 'number' || !parsed.skills) {\n      return createEmptyLocalLock();\n    }\n\n    if (parsed.version < CURRENT_VERSION) {\n      return createEmptyLocalLock();\n    }\n\n    return parsed;\n  } catch {\n    return createEmptyLocalLock();\n  }\n}\n\n/**\n * Write the local skill lock file.\n * Skills are sorted alphabetically by name for deterministic output.\n */\nexport async function writeLocalLock(lock: LocalSkillLockFile, cwd?: string): Promise<void> {\n  const lockPath = getLocalLockPath(cwd);\n\n  // Sort skills alphabetically for deterministic output / clean diffs\n  const sortedSkills: Record<string, LocalSkillLockEntry> = {};\n  for (const key of Object.keys(lock.skills).sort()) {\n    sortedSkills[key] = lock.skills[key]!;\n  }\n\n  const sorted: LocalSkillLockFile = { version: lock.version, skills: sortedSkills };\n  const content = JSON.stringify(sorted, null, 2) + '\\n';\n  await writeFile(lockPath, content, 'utf-8');\n}\n\n/**\n * Compute a SHA-256 hash from all files in a skill directory.\n * Reads all files recursively, sorts them by relative path for determinism,\n * and produces a single hash from their concatenated contents.\n */\nexport async function computeSkillFolderHash(skillDir: string): Promise<string> {\n  const files: Array<{ relativePath: string; content: Buffer }> = [];\n  await collectFiles(skillDir, skillDir, files);\n\n  // Sort by relative path for deterministic hashing\n  files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));\n\n  const hash = createHash('sha256');\n  for (const file of files) {\n    // Include the path in the hash so renames are detected\n    hash.update(file.relativePath);\n    hash.update(file.content);\n  }\n\n  return hash.digest('hex');\n}\n\nasync function collectFiles(\n  baseDir: string,\n  currentDir: string,\n  results: Array<{ relativePath: string; content: Buffer }>\n): Promise<void> {\n  const entries = await readdir(currentDir, { withFileTypes: true });\n\n  await Promise.all(\n    entries.map(async (entry) => {\n      const fullPath = join(currentDir, entry.name);\n\n      if (entry.isDirectory()) {\n        // Skip .git and node_modules within skill dirs\n        if (entry.name === '.git' || entry.name === 'node_modules') return;\n        await collectFiles(baseDir, fullPath, results);\n      } else if (entry.isFile()) {\n        const content = await readFile(fullPath);\n        const relativePath = relative(baseDir, fullPath).split('\\\\').join('/');\n        results.push({ relativePath, content });\n      }\n    })\n  );\n}\n\n/**\n * Add or update a skill entry in the local lock file.\n */\nexport async function addSkillToLocalLock(\n  skillName: string,\n  entry: LocalSkillLockEntry,\n  cwd?: string\n): Promise<void> {\n  const lock = await readLocalLock(cwd);\n  lock.skills[skillName] = entry;\n  await writeLocalLock(lock, cwd);\n}\n\n/**\n * Remove a skill from the local lock file.\n */\nexport async function removeSkillFromLocalLock(skillName: string, cwd?: string): Promise<boolean> {\n  const lock = await readLocalLock(cwd);\n\n  if (!(skillName in lock.skills)) {\n    return false;\n  }\n\n  delete lock.skills[skillName];\n  await writeLocalLock(lock, cwd);\n  return true;\n}\n\nfunction createEmptyLocalLock(): LocalSkillLockFile {\n  return {\n    version: CURRENT_VERSION,\n    skills: {},\n  };\n}\n"
  },
  {
    "path": "src/plugin-manifest.ts",
    "content": "import { readFile } from 'fs/promises';\nimport { join, dirname, resolve, normalize, sep } from 'path';\n\n/**\n * Check if a path is contained within a base directory.\n * Prevents path traversal attacks via `..` segments or absolute paths.\n */\nfunction isContainedIn(targetPath: string, basePath: string): boolean {\n  const normalizedBase = normalize(resolve(basePath));\n  const normalizedTarget = normalize(resolve(targetPath));\n  return normalizedTarget.startsWith(normalizedBase + sep) || normalizedTarget === normalizedBase;\n}\n\n/**\n * Validate that a relative path follows Claude Code conventions.\n * Paths must start with './' per the plugin manifest spec.\n */\nfunction isValidRelativePath(path: string): boolean {\n  return path.startsWith('./');\n}\n\n/**\n * Plugin manifest types\n */\ninterface PluginManifestEntry {\n  source?: string | { source: string; repo?: string };\n  skills?: string[];\n  /** Optional name for grouping skills (e.g., \"document-skills\") */\n  name?: string;\n}\n\ninterface MarketplaceManifest {\n  metadata?: { pluginRoot?: string };\n  plugins?: PluginManifestEntry[];\n}\n\ninterface PluginManifest {\n  skills?: string[];\n  name?: string;\n}\n\n/**\n * Extract skill search directories from plugin manifests.\n * Handles both marketplace.json (multi-plugin) and plugin.json (single plugin).\n * Only resolves local paths - remote sources are skipped.\n *\n * Returns directories that CONTAIN skills (to be searched for child SKILL.md files).\n * For explicit skill paths in manifests, adds the parent directory so the\n * existing discovery loop finds them.\n */\nexport async function getPluginSkillPaths(basePath: string): Promise<string[]> {\n  const searchDirs: string[] = [];\n\n  // Helper: add skill paths for a plugin at a given base path\n  // Only adds paths that are contained within basePath (security: prevents traversal)\n  const addPluginSkillPaths = (pluginBase: string, skills?: string[]) => {\n    // Validate pluginBase itself is contained\n    if (!isContainedIn(pluginBase, basePath)) return;\n\n    if (skills && skills.length > 0) {\n      // Plugin explicitly declares skill paths - add parent dirs so existing loop finds them\n      for (const skillPath of skills) {\n        // Validate skill path starts with './' (per Claude Code convention)\n        if (!isValidRelativePath(skillPath)) continue;\n\n        const skillDir = dirname(join(pluginBase, skillPath));\n        if (isContainedIn(skillDir, basePath)) {\n          searchDirs.push(skillDir);\n        }\n      }\n    }\n    // Always add conventional skills/ directory for discovery\n    // (deduplication happens via seenNames in discoverSkills)\n    searchDirs.push(join(pluginBase, 'skills'));\n  };\n\n  // Try marketplace.json (multi-plugin catalog)\n  try {\n    const content = await readFile(join(basePath, '.claude-plugin/marketplace.json'), 'utf-8');\n    const manifest: MarketplaceManifest = JSON.parse(content);\n    const pluginRoot = manifest.metadata?.pluginRoot;\n\n    // Validate pluginRoot starts with './' if provided (per Claude Code convention)\n    const validPluginRoot = pluginRoot === undefined || isValidRelativePath(pluginRoot);\n\n    if (validPluginRoot) {\n      for (const plugin of manifest.plugins ?? []) {\n        // Skip remote sources (object with source/repo) - only handle local string paths\n        if (typeof plugin.source !== 'string' && plugin.source !== undefined) continue;\n\n        // Validate source starts with './' if provided (per Claude Code convention)\n        if (plugin.source !== undefined && !isValidRelativePath(plugin.source)) continue;\n\n        const pluginBase = join(basePath, pluginRoot ?? '', plugin.source ?? '');\n        addPluginSkillPaths(pluginBase, plugin.skills);\n      }\n    }\n  } catch {\n    // File doesn't exist or invalid JSON\n  }\n\n  // Try plugin.json (single plugin at root)\n  try {\n    const content = await readFile(join(basePath, '.claude-plugin/plugin.json'), 'utf-8');\n    const manifest: PluginManifest = JSON.parse(content);\n    addPluginSkillPaths(basePath, manifest.skills);\n  } catch {\n    // File doesn't exist or invalid JSON\n  }\n\n  return searchDirs;\n}\n\n/**\n * Get a map of skill directory paths to plugin names from plugin manifests.\n * This allows grouping skills by their parent plugin.\n *\n * Returns Map<AbsolutePath, PluginName>\n */\nexport async function getPluginGroupings(basePath: string): Promise<Map<string, string>> {\n  const groupings = new Map<string, string>();\n\n  // Try marketplace.json (multi-plugin catalog)\n  try {\n    const content = await readFile(join(basePath, '.claude-plugin/marketplace.json'), 'utf-8');\n    const manifest: MarketplaceManifest = JSON.parse(content);\n    const pluginRoot = manifest.metadata?.pluginRoot;\n\n    // Validate pluginRoot starts with './' if provided (per Claude Code convention)\n    const validPluginRoot = pluginRoot === undefined || isValidRelativePath(pluginRoot);\n\n    if (validPluginRoot) {\n      for (const plugin of manifest.plugins ?? []) {\n        if (!plugin.name) continue;\n\n        // Skip remote sources (object with source/repo) - only handle local string paths\n        if (typeof plugin.source !== 'string' && plugin.source !== undefined) continue;\n\n        // Validate source starts with './' if provided (per Claude Code convention)\n        if (plugin.source !== undefined && !isValidRelativePath(plugin.source)) continue;\n\n        const pluginBase = join(basePath, pluginRoot ?? '', plugin.source ?? '');\n\n        // Validate pluginBase itself is contained\n        if (!isContainedIn(pluginBase, basePath)) continue;\n\n        if (plugin.skills && plugin.skills.length > 0) {\n          for (const skillPath of plugin.skills) {\n            // Validate skill path starts with './' (per Claude Code convention)\n            if (!isValidRelativePath(skillPath)) continue;\n\n            const skillDir = join(pluginBase, skillPath);\n            if (isContainedIn(skillDir, basePath)) {\n              // Store absolute path as key for reliable matching\n              groupings.set(resolve(skillDir), plugin.name);\n            }\n          }\n        }\n      }\n    }\n  } catch {\n    // File doesn't exist or invalid JSON\n  }\n\n  // Try plugin.json (single plugin at root)\n  try {\n    const content = await readFile(join(basePath, '.claude-plugin/plugin.json'), 'utf-8');\n    const manifest: PluginManifest = JSON.parse(content);\n    if (manifest.name && manifest.skills && manifest.skills.length > 0) {\n      for (const skillPath of manifest.skills) {\n        if (!isValidRelativePath(skillPath)) continue;\n        const skillDir = join(basePath, skillPath);\n        if (isContainedIn(skillDir, basePath)) {\n          groupings.set(resolve(skillDir), manifest.name);\n        }\n      }\n    }\n  } catch {\n    // File doesn't exist or invalid JSON\n  }\n\n  return groupings;\n}\n"
  },
  {
    "path": "src/prompts/search-multiselect.ts",
    "content": "import * as readline from 'readline';\nimport { Writable } from 'stream';\nimport pc from 'picocolors';\n\n// Silent writable stream to prevent readline from echoing input\nconst silentOutput = new Writable({\n  write(_chunk, _encoding, callback) {\n    callback();\n  },\n});\n\nexport interface SearchItem<T> {\n  value: T;\n  label: string;\n  hint?: string;\n}\n\nexport interface LockedSection<T> {\n  title: string;\n  items: SearchItem<T>[];\n}\n\nexport interface SearchMultiselectOptions<T> {\n  message: string;\n  items: SearchItem<T>[];\n  maxVisible?: number;\n  initialSelected?: T[];\n  /** If true, require at least one item to be selected before submitting */\n  required?: boolean;\n  /** Locked section shown above the searchable list - items are always selected and can't be toggled */\n  lockedSection?: LockedSection<T>;\n}\n\nconst S_STEP_ACTIVE = pc.green('◆');\nconst S_STEP_CANCEL = pc.red('■');\nconst S_STEP_SUBMIT = pc.green('◇');\nconst S_RADIO_ACTIVE = pc.green('●');\nconst S_RADIO_INACTIVE = pc.dim('○');\nconst S_CHECKBOX_LOCKED = pc.green('✓');\nconst S_BULLET = pc.green('•');\nconst S_BAR = pc.dim('│');\nconst S_BAR_H = pc.dim('─');\n\nexport const cancelSymbol = Symbol('cancel');\n\n/**\n * Interactive search multiselect prompt.\n * Allows users to filter a long list by typing and select multiple items.\n * Optionally supports a \"locked\" section that displays always-selected items.\n */\nexport async function searchMultiselect<T>(\n  options: SearchMultiselectOptions<T>\n): Promise<T[] | symbol> {\n  const {\n    message,\n    items,\n    maxVisible = 8,\n    initialSelected = [],\n    required = false,\n    lockedSection,\n  } = options;\n\n  return new Promise((resolve) => {\n    const rl = readline.createInterface({\n      input: process.stdin,\n      output: silentOutput,\n      terminal: false,\n    });\n\n    // Enable raw mode for keypress detection\n    if (process.stdin.isTTY) {\n      process.stdin.setRawMode(true);\n    }\n    readline.emitKeypressEvents(process.stdin, rl);\n\n    let query = '';\n    let cursor = 0;\n    const selected = new Set<T>(initialSelected);\n    let lastRenderHeight = 0;\n\n    // Locked items are always included in the result\n    const lockedValues = lockedSection ? lockedSection.items.map((i) => i.value) : [];\n\n    const filter = (item: SearchItem<T>, q: string): boolean => {\n      if (!q) return true;\n      const lowerQ = q.toLowerCase();\n      return (\n        item.label.toLowerCase().includes(lowerQ) ||\n        String(item.value).toLowerCase().includes(lowerQ)\n      );\n    };\n\n    const getFiltered = (): SearchItem<T>[] => {\n      return items.filter((item) => filter(item, query));\n    };\n\n    const clearRender = (): void => {\n      if (lastRenderHeight > 0) {\n        // Move up and clear each line\n        process.stdout.write(`\\x1b[${lastRenderHeight}A`);\n        for (let i = 0; i < lastRenderHeight; i++) {\n          process.stdout.write('\\x1b[2K\\x1b[1B');\n        }\n        process.stdout.write(`\\x1b[${lastRenderHeight}A`);\n      }\n    };\n\n    const render = (state: 'active' | 'submit' | 'cancel' = 'active'): void => {\n      clearRender();\n\n      const lines: string[] = [];\n      const filtered = getFiltered();\n\n      // Header\n      const icon =\n        state === 'active' ? S_STEP_ACTIVE : state === 'cancel' ? S_STEP_CANCEL : S_STEP_SUBMIT;\n      lines.push(`${icon}  ${pc.bold(message)}`);\n\n      if (state === 'active') {\n        // Locked section (universal agents)\n        if (lockedSection && lockedSection.items.length > 0) {\n          lines.push(`${S_BAR}`);\n          const lockedTitle = `${pc.bold(lockedSection.title)} ${pc.dim('── always included')}`;\n          lines.push(`${S_BAR}  ${S_BAR_H}${S_BAR_H} ${lockedTitle} ${S_BAR_H.repeat(12)}`);\n          for (const item of lockedSection.items) {\n            lines.push(`${S_BAR}    ${S_BULLET} ${pc.bold(item.label)}`);\n          }\n          lines.push(`${S_BAR}`);\n          lines.push(\n            `${S_BAR}  ${S_BAR_H}${S_BAR_H} ${pc.bold('Additional agents')} ${S_BAR_H.repeat(29)}`\n          );\n        }\n\n        // Search input\n        const searchLine = `${S_BAR}  ${pc.dim('Search:')} ${query}${pc.inverse(' ')}`;\n        lines.push(searchLine);\n\n        // Hint\n        lines.push(`${S_BAR}  ${pc.dim('↑↓ move, space select, enter confirm')}`);\n        lines.push(`${S_BAR}`);\n\n        // Items\n        const visibleStart = Math.max(\n          0,\n          Math.min(cursor - Math.floor(maxVisible / 2), filtered.length - maxVisible)\n        );\n        const visibleEnd = Math.min(filtered.length, visibleStart + maxVisible);\n        const visibleItems = filtered.slice(visibleStart, visibleEnd);\n\n        if (filtered.length === 0) {\n          lines.push(`${S_BAR}  ${pc.dim('No matches found')}`);\n        } else {\n          for (let i = 0; i < visibleItems.length; i++) {\n            const item = visibleItems[i]!;\n            const actualIndex = visibleStart + i;\n            const isSelected = selected.has(item.value);\n            const isCursor = actualIndex === cursor;\n\n            const radio = isSelected ? S_RADIO_ACTIVE : S_RADIO_INACTIVE;\n            const label = isCursor ? pc.underline(item.label) : item.label;\n            const hint = item.hint ? pc.dim(` (${item.hint})`) : '';\n\n            const prefix = isCursor ? pc.cyan('❯') : ' ';\n            lines.push(`${S_BAR} ${prefix} ${radio} ${label}${hint}`);\n          }\n\n          // Show count if more items\n          const hiddenBefore = visibleStart;\n          const hiddenAfter = filtered.length - visibleEnd;\n          if (hiddenBefore > 0 || hiddenAfter > 0) {\n            const parts: string[] = [];\n            if (hiddenBefore > 0) parts.push(`↑ ${hiddenBefore} more`);\n            if (hiddenAfter > 0) parts.push(`↓ ${hiddenAfter} more`);\n            lines.push(`${S_BAR}  ${pc.dim(parts.join('  '))}`);\n          }\n        }\n\n        // Selected summary (include locked items)\n        lines.push(`${S_BAR}`);\n        const allSelectedLabels = [\n          ...(lockedSection ? lockedSection.items.map((i) => i.label) : []),\n          ...items.filter((item) => selected.has(item.value)).map((item) => item.label),\n        ];\n        if (allSelectedLabels.length === 0) {\n          lines.push(`${S_BAR}  ${pc.dim('Selected: (none)')}`);\n        } else {\n          const summary =\n            allSelectedLabels.length <= 3\n              ? allSelectedLabels.join(', ')\n              : `${allSelectedLabels.slice(0, 3).join(', ')} +${allSelectedLabels.length - 3} more`;\n          lines.push(`${S_BAR}  ${pc.green('Selected:')} ${summary}`);\n        }\n\n        lines.push(`${pc.dim('└')}`);\n      } else if (state === 'submit') {\n        // Final state - show what was selected (including locked)\n        const allSelectedLabels = [\n          ...(lockedSection ? lockedSection.items.map((i) => i.label) : []),\n          ...items.filter((item) => selected.has(item.value)).map((item) => item.label),\n        ];\n        lines.push(`${S_BAR}  ${pc.dim(allSelectedLabels.join(', '))}`);\n      } else if (state === 'cancel') {\n        lines.push(`${S_BAR}  ${pc.strikethrough(pc.dim('Cancelled'))}`);\n      }\n\n      process.stdout.write(lines.join('\\n') + '\\n');\n      lastRenderHeight = lines.length;\n    };\n\n    const cleanup = (): void => {\n      process.stdin.removeListener('keypress', keypressHandler);\n      if (process.stdin.isTTY) {\n        process.stdin.setRawMode(false);\n      }\n      rl.close();\n    };\n\n    const submit = (): void => {\n      // If required and no locked items, don't allow submitting with no selection\n      if (required && selected.size === 0 && lockedValues.length === 0) {\n        return;\n      }\n      render('submit');\n      cleanup();\n      // Include locked values in the result\n      resolve([...lockedValues, ...Array.from(selected)]);\n    };\n\n    const cancel = (): void => {\n      render('cancel');\n      cleanup();\n      resolve(cancelSymbol);\n    };\n\n    // Handle keypresses\n    const keypressHandler = (_str: string, key: readline.Key): void => {\n      if (!key) return;\n\n      const filtered = getFiltered();\n\n      if (key.name === 'return') {\n        submit();\n        return;\n      }\n\n      if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {\n        cancel();\n        return;\n      }\n\n      if (key.name === 'up') {\n        cursor = Math.max(0, cursor - 1);\n        render();\n        return;\n      }\n\n      if (key.name === 'down') {\n        cursor = Math.min(filtered.length - 1, cursor + 1);\n        render();\n        return;\n      }\n\n      if (key.name === 'space') {\n        const item = filtered[cursor];\n        if (item) {\n          if (selected.has(item.value)) {\n            selected.delete(item.value);\n          } else {\n            selected.add(item.value);\n          }\n        }\n        render();\n        return;\n      }\n\n      if (key.name === 'backspace') {\n        query = query.slice(0, -1);\n        cursor = 0;\n        render();\n        return;\n      }\n\n      // Regular character input\n      if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) {\n        query += key.sequence;\n        cursor = 0;\n        render();\n        return;\n      }\n    };\n\n    process.stdin.on('keypress', keypressHandler);\n\n    // Initial render\n    render();\n  });\n}\n"
  },
  {
    "path": "src/providers/index.ts",
    "content": "// Export types\nexport type { HostProvider, ProviderMatch, ProviderRegistry, RemoteSkill } from './types.ts';\n\n// Export registry functions\nexport { registry, registerProvider, findProvider, getProviders } from './registry.ts';\n\n// Export individual providers\nexport {\n  WellKnownProvider,\n  wellKnownProvider,\n  type WellKnownIndex,\n  type WellKnownSkillEntry,\n  type WellKnownSkill,\n} from './wellknown.ts';\n"
  },
  {
    "path": "src/providers/registry.ts",
    "content": "import type { HostProvider, ProviderRegistry } from './types.ts';\n\nclass ProviderRegistryImpl implements ProviderRegistry {\n  private providers: HostProvider[] = [];\n\n  register(provider: HostProvider): void {\n    // Check for duplicate IDs\n    if (this.providers.some((p) => p.id === provider.id)) {\n      throw new Error(`Provider with id \"${provider.id}\" already registered`);\n    }\n    this.providers.push(provider);\n  }\n\n  findProvider(url: string): HostProvider | null {\n    for (const provider of this.providers) {\n      const match = provider.match(url);\n      if (match.matches) {\n        return provider;\n      }\n    }\n    return null;\n  }\n\n  getProviders(): HostProvider[] {\n    return [...this.providers];\n  }\n}\n\n// Singleton registry instance\nexport const registry = new ProviderRegistryImpl();\n\n/**\n * Register a provider with the global registry.\n */\nexport function registerProvider(provider: HostProvider): void {\n  registry.register(provider);\n}\n\n/**\n * Find a provider that matches the given URL.\n */\nexport function findProvider(url: string): HostProvider | null {\n  return registry.findProvider(url);\n}\n\n/**\n * Get all registered providers.\n */\nexport function getProviders(): HostProvider[] {\n  return registry.getProviders();\n}\n"
  },
  {
    "path": "src/providers/types.ts",
    "content": "/**\n * Represents a parsed skill from a remote host.\n * Different hosts may have different ways of identifying skills.\n */\nexport interface RemoteSkill {\n  /** Display name of the skill (from frontmatter) */\n  name: string;\n  /** Description of the skill (from frontmatter) */\n  description: string;\n  /** Full markdown content including frontmatter */\n  content: string;\n  /** The identifier used for installation directory name */\n  installName: string;\n  /** The original source URL */\n  sourceUrl: string;\n  /** Any additional metadata from frontmatter */\n  metadata?: Record<string, unknown>;\n}\n\n/**\n * Result of attempting to match a URL to a provider.\n */\nexport interface ProviderMatch {\n  /** Whether the URL matches this provider */\n  matches: boolean;\n  /** The source identifier for telemetry/storage (e.g., \"mintlify/bun.com\", \"huggingface/hf-skills/hf-jobs\") */\n  sourceIdentifier?: string;\n}\n\n/**\n * Interface for remote SKILL.md host providers.\n * Each provider knows how to:\n * - Detect if a URL belongs to it\n * - Fetch and parse SKILL.md files\n * - Convert URLs to raw content URLs\n * - Provide source identifiers for telemetry\n */\nexport interface HostProvider {\n  /** Unique identifier for this provider (e.g., \"mintlify\", \"huggingface\", \"github\") */\n  readonly id: string;\n\n  /** Display name for this provider */\n  readonly displayName: string;\n\n  /**\n   * Check if a URL matches this provider.\n   * @param url - The URL to check\n   * @returns Match result with optional source identifier\n   */\n  match(url: string): ProviderMatch;\n\n  /**\n   * Fetch and parse a SKILL.md file from the given URL.\n   * @param url - The URL to the SKILL.md file\n   * @returns The parsed skill or null if invalid/not found\n   */\n  fetchSkill(url: string): Promise<RemoteSkill | null>;\n\n  /**\n   * Convert a user-facing URL to a raw content URL.\n   * For example, GitHub blob URLs to raw.githubusercontent.com URLs.\n   * @param url - The URL to convert\n   * @returns The raw content URL\n   */\n  toRawUrl(url: string): string;\n\n  /**\n   * Get the source identifier for telemetry/storage.\n   * This should be a stable identifier that can be used to group\n   * skills from the same source.\n   * @param url - The original URL\n   * @returns Source identifier (e.g., \"mintlify/bun.com\", \"huggingface/hf-skills/hf-jobs\")\n   */\n  getSourceIdentifier(url: string): string;\n}\n\n/**\n * Registry for managing host providers.\n */\nexport interface ProviderRegistry {\n  /**\n   * Register a new provider.\n   */\n  register(provider: HostProvider): void;\n\n  /**\n   * Find a provider that matches the given URL.\n   * @param url - The URL to match\n   * @returns The matching provider or null\n   */\n  findProvider(url: string): HostProvider | null;\n\n  /**\n   * Get all registered providers.\n   */\n  getProviders(): HostProvider[];\n}\n"
  },
  {
    "path": "src/providers/wellknown.ts",
    "content": "import matter from 'gray-matter';\nimport type { HostProvider, ProviderMatch, RemoteSkill } from './types.ts';\n\n/**\n * Represents the index.json structure for well-known skills.\n */\nexport interface WellKnownIndex {\n  skills: WellKnownSkillEntry[];\n}\n\n/**\n * Represents a skill entry in the index.json.\n */\nexport interface WellKnownSkillEntry {\n  /** Skill identifier. Must match the directory name. */\n  name: string;\n  /** Brief description of what the skill does. */\n  description: string;\n  /** Array of all files in the skill directory. */\n  files: string[];\n}\n\n/**\n * Represents a skill with all its files fetched from a well-known endpoint.\n */\nexport interface WellKnownSkill extends RemoteSkill {\n  /** All files in the skill, keyed by relative path */\n  files: Map<string, string>;\n  /** The entry from the index.json */\n  indexEntry: WellKnownSkillEntry;\n}\n\n/**\n * Well-known skills provider using RFC 8615 well-known URIs.\n *\n * Organizations can publish skills at:\n * https://example.com/.well-known/skills/\n *\n * URL formats supported:\n * - https://example.com (discovers all skills from root)\n * - https://example.com/docs (discovers from /docs/.well-known/skills/)\n * - https://example.com/.well-known/skills (discovers all skills)\n * - https://example.com/.well-known/skills/skill-name (specific skill)\n *\n * The source identifier is \"wellknown/{hostname}\" or \"wellknown/{hostname}/path\".\n */\nexport class WellKnownProvider implements HostProvider {\n  readonly id = 'well-known';\n  readonly displayName = 'Well-Known Skills';\n\n  private readonly WELL_KNOWN_PATH = '.well-known/skills';\n  private readonly INDEX_FILE = 'index.json';\n\n  /**\n   * Check if a URL could be a well-known skills endpoint.\n   * This is a fallback provider - it matches any HTTP(S) URL that is not\n   * a recognized pattern (GitHub, GitLab, owner/repo shorthand, etc.)\n   */\n  match(url: string): ProviderMatch {\n    // Must be a valid HTTP(S) URL\n    if (!url.startsWith('http://') && !url.startsWith('https://')) {\n      return { matches: false };\n    }\n\n    // Parse URL to extract hostname\n    try {\n      const parsed = new URL(url);\n\n      // Exclude known git hosts that have their own handling\n      const excludedHosts = ['github.com', 'gitlab.com', 'huggingface.co'];\n      if (excludedHosts.includes(parsed.hostname)) {\n        return { matches: false };\n      }\n\n      return {\n        matches: true,\n        sourceIdentifier: `wellknown/${parsed.hostname}`,\n      };\n    } catch {\n      return { matches: false };\n    }\n  }\n\n  /**\n   * Fetch the skills index from a well-known endpoint.\n   * Tries both the path-relative .well-known and the root .well-known.\n   */\n  async fetchIndex(\n    baseUrl: string\n  ): Promise<{ index: WellKnownIndex; resolvedBaseUrl: string } | null> {\n    try {\n      const parsed = new URL(baseUrl);\n      const basePath = parsed.pathname.replace(/\\/$/, ''); // Remove trailing slash\n\n      // Try path-relative .well-known first (e.g., /docs/.well-known/skills/)\n      // then fall back to root .well-known\n      const urlsToTry = [\n        // Path-relative: https://example.com/docs/.well-known/skills/index.json\n        {\n          indexUrl: `${parsed.protocol}//${parsed.host}${basePath}/${this.WELL_KNOWN_PATH}/${this.INDEX_FILE}`,\n          baseUrl: `${parsed.protocol}//${parsed.host}${basePath}`,\n        },\n      ];\n\n      // Also try root if we have a path\n      if (basePath && basePath !== '') {\n        urlsToTry.push({\n          indexUrl: `${parsed.protocol}//${parsed.host}/${this.WELL_KNOWN_PATH}/${this.INDEX_FILE}`,\n          baseUrl: `${parsed.protocol}//${parsed.host}`,\n        });\n      }\n\n      for (const { indexUrl, baseUrl: resolvedBase } of urlsToTry) {\n        try {\n          const response = await fetch(indexUrl);\n\n          if (!response.ok) {\n            continue;\n          }\n\n          const index = (await response.json()) as WellKnownIndex;\n\n          // Validate index structure\n          if (!index.skills || !Array.isArray(index.skills)) {\n            continue;\n          }\n\n          // Validate each skill entry\n          let allValid = true;\n          for (const entry of index.skills) {\n            if (!this.isValidSkillEntry(entry)) {\n              allValid = false;\n              break;\n            }\n          }\n\n          if (allValid) {\n            return { index, resolvedBaseUrl: resolvedBase };\n          }\n        } catch {\n          // Try next URL\n          continue;\n        }\n      }\n\n      return null;\n    } catch {\n      return null;\n    }\n  }\n\n  /**\n   * Validate a skill entry from the index.\n   */\n  private isValidSkillEntry(entry: unknown): entry is WellKnownSkillEntry {\n    if (!entry || typeof entry !== 'object') return false;\n\n    const e = entry as Record<string, unknown>;\n\n    // Required fields\n    if (typeof e.name !== 'string' || !e.name) return false;\n    if (typeof e.description !== 'string' || !e.description) return false;\n    if (!Array.isArray(e.files) || e.files.length === 0) return false;\n\n    // Validate name format (per spec: 1-64 chars, lowercase alphanumeric and hyphens)\n    const nameRegex = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;\n    if (!nameRegex.test(e.name) && e.name.length > 1) {\n      // Allow single char names like \"a\"\n      if (e.name.length === 1 && !/^[a-z0-9]$/.test(e.name)) {\n        return false;\n      }\n    }\n\n    // Validate files array\n    for (const file of e.files) {\n      if (typeof file !== 'string') return false;\n      // Files must not start with / or \\ or contain .. (path traversal prevention)\n      if (file.startsWith('/') || file.startsWith('\\\\') || file.includes('..')) return false;\n    }\n\n    // Must include SKILL.md\n    const hasSkillMd = e.files.some((f) => typeof f === 'string' && f.toLowerCase() === 'skill.md');\n    if (!hasSkillMd) return false;\n\n    return true;\n  }\n\n  /**\n   * Fetch a single skill and all its files from a well-known endpoint.\n   */\n  async fetchSkill(url: string): Promise<RemoteSkill | null> {\n    try {\n      const parsed = new URL(url);\n\n      // First, fetch the index to get skill metadata\n      const result = await this.fetchIndex(url);\n      if (!result) {\n        return null;\n      }\n\n      const { index, resolvedBaseUrl } = result;\n\n      // Determine which skill to fetch\n      let skillName: string | null = null;\n\n      // Check if URL specifies a specific skill\n      const pathMatch = parsed.pathname.match(/\\/.well-known\\/skills\\/([^/]+)\\/?$/);\n      if (pathMatch && pathMatch[1] && pathMatch[1] !== 'index.json') {\n        skillName = pathMatch[1];\n      } else if (index.skills.length === 1) {\n        // If only one skill in index, use that\n        skillName = index.skills[0]!.name;\n      }\n\n      if (!skillName) {\n        // Multiple skills available, return null - caller should use fetchAllSkills\n        return null;\n      }\n\n      // Find the skill in the index\n      const skillEntry = index.skills.find((s: WellKnownSkillEntry) => s.name === skillName);\n      if (!skillEntry) {\n        return null;\n      }\n\n      return this.fetchSkillByEntry(resolvedBaseUrl, skillEntry);\n    } catch {\n      return null;\n    }\n  }\n\n  /**\n   * Fetch a skill by its index entry.\n   * @param baseUrl - The base URL (e.g., https://example.com or https://example.com/docs)\n   * @param entry - The skill entry from index.json\n   */\n  async fetchSkillByEntry(\n    baseUrl: string,\n    entry: WellKnownSkillEntry\n  ): Promise<WellKnownSkill | null> {\n    try {\n      // Build the skill base URL: {baseUrl}/.well-known/skills/{skill-name}\n      const skillBaseUrl = `${baseUrl.replace(/\\/$/, '')}/${this.WELL_KNOWN_PATH}/${entry.name}`;\n\n      // Fetch SKILL.md first (required)\n      const skillMdUrl = `${skillBaseUrl}/SKILL.md`;\n      const response = await fetch(skillMdUrl);\n\n      if (!response.ok) {\n        return null;\n      }\n\n      const content = await response.text();\n      const { data } = matter(content);\n\n      // Validate frontmatter has name and description\n      if (!data.name || !data.description) {\n        return null;\n      }\n\n      // Fetch all other files\n      const files = new Map<string, string>();\n      files.set('SKILL.md', content);\n\n      // Fetch remaining files in parallel\n      const otherFiles = entry.files.filter((f) => f.toLowerCase() !== 'skill.md');\n      const filePromises = otherFiles.map(async (filePath) => {\n        try {\n          const fileUrl = `${skillBaseUrl}/${filePath}`;\n          const fileResponse = await fetch(fileUrl);\n          if (fileResponse.ok) {\n            const fileContent = await fileResponse.text();\n            return { path: filePath, content: fileContent };\n          }\n        } catch {\n          // Ignore individual file fetch errors\n        }\n        return null;\n      });\n\n      const fileResults = await Promise.all(filePromises);\n      for (const result of fileResults) {\n        if (result) {\n          files.set(result.path, result.content);\n        }\n      }\n\n      return {\n        name: data.name,\n        description: data.description,\n        content,\n        installName: entry.name,\n        sourceUrl: skillMdUrl,\n        metadata: data.metadata,\n        files,\n        indexEntry: entry,\n      };\n    } catch {\n      return null;\n    }\n  }\n\n  /**\n   * Fetch all skills from a well-known endpoint.\n   */\n  async fetchAllSkills(url: string): Promise<WellKnownSkill[]> {\n    try {\n      const result = await this.fetchIndex(url);\n      if (!result) {\n        return [];\n      }\n\n      const { index, resolvedBaseUrl } = result;\n\n      // Fetch all skills in parallel\n      const skillPromises = index.skills.map((entry: WellKnownSkillEntry) =>\n        this.fetchSkillByEntry(resolvedBaseUrl, entry)\n      );\n      const results = await Promise.all(skillPromises);\n\n      return results.filter((s: WellKnownSkill | null): s is WellKnownSkill => s !== null);\n    } catch {\n      return [];\n    }\n  }\n\n  /**\n   * Convert a user-facing URL to a skill URL.\n   * For well-known, this extracts the base domain and constructs the proper path.\n   */\n  toRawUrl(url: string): string {\n    try {\n      const parsed = new URL(url);\n      // If already pointing to a SKILL.md, return as-is\n      if (url.toLowerCase().endsWith('/skill.md')) {\n        return url;\n      }\n\n      // Check if URL specifies a skill path\n      const pathMatch = parsed.pathname.match(/\\/.well-known\\/skills\\/([^/]+)\\/?$/);\n      if (pathMatch && pathMatch[1]) {\n        const basePath = parsed.pathname.replace(/\\/.well-known\\/skills\\/.*$/, '');\n        return `${parsed.protocol}//${parsed.host}${basePath}/${this.WELL_KNOWN_PATH}/${pathMatch[1]}/SKILL.md`;\n      }\n\n      // Otherwise, return the index URL\n      const basePath = parsed.pathname.replace(/\\/$/, '');\n      return `${parsed.protocol}//${parsed.host}${basePath}/${this.WELL_KNOWN_PATH}/${this.INDEX_FILE}`;\n    } catch {\n      return url;\n    }\n  }\n\n  /**\n   * Get the source identifier for telemetry/storage.\n   * Returns the full hostname with www. stripped.\n   * e.g., \"https://mintlify.com/docs\" → \"mintlify.com\"\n   *       \"https://mppx-discovery-skills.vercel.app\" → \"mppx-discovery-skills.vercel.app\"\n   *       \"https://www.example.com\" → \"example.com\"\n   *       \"https://docs.lovable.dev\" → \"docs.lovable.dev\"\n   */\n  getSourceIdentifier(url: string): string {\n    try {\n      const parsed = new URL(url);\n      // Use full hostname, only strip www. prefix\n      return parsed.hostname.replace(/^www\\./, '');\n    } catch {\n      return 'unknown';\n    }\n  }\n\n  /**\n   * Check if a URL has a well-known skills index.\n   */\n  async hasSkillsIndex(url: string): Promise<boolean> {\n    const result = await this.fetchIndex(url);\n    return result !== null;\n  }\n}\n\nexport const wellKnownProvider = new WellKnownProvider();\n"
  },
  {
    "path": "src/remove.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { existsSync, rmSync, mkdirSync, writeFileSync, readdirSync } from 'fs';\nimport { join } from 'path';\nimport { tmpdir } from 'os';\nimport { runCli, runCliWithInput } from './test-utils.js';\n\ndescribe('remove command', { timeout: 30000 }, () => {\n  let testDir: string;\n  let skillsDir: string;\n\n  beforeEach(() => {\n    testDir = join(tmpdir(), `skills-remove-test-${Date.now()}`);\n    mkdirSync(testDir, { recursive: true });\n\n    // Create .agents/skills directory (canonical location)\n    skillsDir = join(testDir, '.agents', 'skills');\n    mkdirSync(skillsDir, { recursive: true });\n  });\n\n  afterEach(() => {\n    if (existsSync(testDir)) {\n      rmSync(testDir, { recursive: true, force: true });\n    }\n  });\n\n  function createTestSkill(name: string, description?: string) {\n    const skillDir = join(skillsDir, name);\n    mkdirSync(skillDir, { recursive: true });\n    writeFileSync(\n      join(skillDir, 'SKILL.md'),\n      `---\nname: ${name}\ndescription: ${description || `A test skill called ${name}`}\n---\n\n# ${name}\n\nThis is a test skill.\n`\n    );\n  }\n\n  function createAgentSkillsDir(agentName: string) {\n    const agentSkillsDir = join(testDir, agentName, 'skills');\n    mkdirSync(agentSkillsDir, { recursive: true });\n    return agentSkillsDir;\n  }\n\n  function createSymlink(skillName: string, targetDir: string) {\n    const skillPath = join(skillsDir, skillName);\n    const linkPath = join(targetDir, skillName);\n    try {\n      // Create relative symlink\n      const relativePath = join('..', '..', '.agents', 'skills', skillName);\n      const { symlinkSync } = require('fs');\n      symlinkSync(relativePath, linkPath);\n    } catch {\n      // Skip if symlinks aren't supported\n    }\n  }\n\n  describe('with no skills installed', () => {\n    it('should show message when no skills found', () => {\n      const result = runCli(['remove', '-y'], testDir);\n      expect(result.stdout).toContain('No skills found');\n      expect(result.stdout).toContain('to remove');\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should show error for non-existent skill name', () => {\n      const result = runCli(['remove', 'non-existent-skill', '-y'], testDir);\n      expect(result.stdout).toContain('No skills found');\n      expect(result.exitCode).toBe(0);\n    });\n  });\n\n  describe('with skills installed', () => {\n    beforeEach(() => {\n      createTestSkill('skill-one', 'First test skill');\n      createTestSkill('skill-two', 'Second test skill');\n      createTestSkill('skill-three', 'Third test skill');\n\n      // Create symlinks in agent directories\n      const claudeSkillsDir = createAgentSkillsDir('.claude');\n      createSymlink('skill-one', claudeSkillsDir);\n      createSymlink('skill-two', claudeSkillsDir);\n\n      const clineSkillsDir = createAgentSkillsDir('.cline');\n      createSymlink('skill-one', clineSkillsDir);\n      createSymlink('skill-three', clineSkillsDir);\n    });\n\n    it('should remove specific skill by name with -y flag', () => {\n      const result = runCli(['remove', 'skill-one', '-y'], testDir);\n\n      expect(result.stdout).toContain('Successfully removed');\n      expect(result.stdout).toContain('1 skill');\n\n      // Verify skill was removed from canonical location\n      expect(existsSync(join(skillsDir, 'skill-one'))).toBe(false);\n\n      // Verify other skills still exist\n      expect(existsSync(join(skillsDir, 'skill-two'))).toBe(true);\n      expect(existsSync(join(skillsDir, 'skill-three'))).toBe(true);\n    });\n\n    it('should remove multiple skills by name', () => {\n      const result = runCli(['remove', 'skill-one', 'skill-two', '-y'], testDir);\n\n      expect(result.stdout).toContain('Successfully removed');\n      expect(result.stdout).toContain('2 skill');\n\n      expect(existsSync(join(skillsDir, 'skill-one'))).toBe(false);\n      expect(existsSync(join(skillsDir, 'skill-two'))).toBe(false);\n      expect(existsSync(join(skillsDir, 'skill-three'))).toBe(true);\n    });\n\n    it('should remove all skills with --all flag', () => {\n      const result = runCli(['remove', '--all', '-y'], testDir);\n\n      expect(result.stdout).toContain('Successfully removed');\n      expect(result.stdout).toContain('3 skill');\n\n      // All skills removed\n      expect(existsSync(join(skillsDir, 'skill-one'))).toBe(false);\n      expect(existsSync(join(skillsDir, 'skill-two'))).toBe(false);\n      expect(existsSync(join(skillsDir, 'skill-three'))).toBe(false);\n    });\n\n    it('should show error for non-existent skill name when skills exist', () => {\n      const result = runCli(['remove', 'non-existent', '-y'], testDir);\n\n      expect(result.stdout).toContain('No matching skills');\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should be case-insensitive when matching skill names', () => {\n      const result = runCli(['remove', 'SKILL-ONE', '-y'], testDir);\n\n      expect(result.stdout).toContain('Successfully removed');\n      expect(existsSync(join(skillsDir, 'skill-one'))).toBe(false);\n    });\n\n    it('should remove only the specified skill and leave others', () => {\n      runCli(['remove', 'skill-two', '-y'], testDir);\n\n      // skill-two removed\n      expect(existsSync(join(skillsDir, 'skill-two'))).toBe(false);\n\n      // Others still exist\n      expect(existsSync(join(skillsDir, 'skill-one'))).toBe(true);\n      expect(existsSync(join(skillsDir, 'skill-three'))).toBe(true);\n    });\n\n    it('should list skills to remove before confirmation', () => {\n      // Answer 'n' to cancel the confirmation prompt\n      const result = runCliWithInput(['remove', 'skill-one', 'skill-two'], 'n', testDir);\n\n      // Should show the skills that will be removed\n      expect(result.stdout).toContain('Skills to remove');\n      expect(result.stdout).toContain('skill-one');\n      expect(result.stdout).toContain('skill-two');\n      expect(result.stdout).toContain('uninstall');\n\n      // Skills should NOT be removed since we cancelled\n      expect(existsSync(join(skillsDir, 'skill-one'))).toBe(true);\n      expect(existsSync(join(skillsDir, 'skill-two'))).toBe(true);\n    });\n  });\n\n  describe('agent filtering', () => {\n    beforeEach(() => {\n      createTestSkill('test-skill');\n      createAgentSkillsDir('.claude');\n      createAgentSkillsDir('.cline');\n    });\n\n    it('should show error for invalid agent name', () => {\n      const result = runCli(['remove', 'test-skill', '--agent', 'invalid-agent', '-y'], testDir);\n\n      expect(result.stdout).toContain('Invalid agents');\n      expect(result.stdout).toContain('invalid-agent');\n      expect(result.stdout).toContain('Valid agents');\n      expect(result.exitCode).toBe(1);\n    });\n\n    it('should accept valid agent names', () => {\n      // This should not error on agent validation\n      const result = runCli(['remove', 'test-skill', '--agent', 'claude-code', '-y'], testDir);\n      expect(result.stdout).not.toContain('Invalid agents');\n    });\n\n    it('should accept multiple agent names', () => {\n      const result = runCli(\n        ['remove', 'test-skill', '--agent', 'claude-code', 'cursor', '-y'],\n        testDir\n      );\n      expect(result.stdout).not.toContain('Invalid agents');\n    });\n  });\n\n  describe('global flag', () => {\n    beforeEach(() => {\n      createTestSkill('global-skill');\n    });\n\n    it('should accept --global flag without error', () => {\n      const result = runCli(['remove', 'global-skill', '--global', '-y'], testDir);\n      // Command should run without error (skill may not be found in global scope from test dir)\n      expect(result.exitCode).toBe(0);\n    });\n  });\n\n  describe('command aliases', () => {\n    beforeEach(() => {\n      createTestSkill('alias-test-skill');\n    });\n\n    it('should support \"rm\" alias', () => {\n      const result = runCli(['rm', 'alias-test-skill', '-y'], testDir);\n      expect(result.stdout).toContain('Successfully removed');\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should support \"r\" alias', () => {\n      const result = runCli(['r', 'alias-test-skill', '-y'], testDir);\n      expect(result.stdout).toContain('Successfully removed');\n      expect(result.exitCode).toBe(0);\n    });\n  });\n\n  describe('edge cases', () => {\n    it('should handle skill names with special characters', () => {\n      createTestSkill('skill-with-dashes');\n      createTestSkill('skill_with_underscores');\n\n      const result = runCli(['remove', 'skill-with-dashes', '-y'], testDir);\n      expect(result.stdout).toContain('Successfully removed');\n      expect(existsSync(join(skillsDir, 'skill-with-dashes'))).toBe(false);\n      expect(existsSync(join(skillsDir, 'skill_with_underscores'))).toBe(true);\n    });\n\n    it('should handle removing last remaining skill', () => {\n      createTestSkill('last-skill');\n\n      const result = runCli(['remove', 'last-skill', '-y'], testDir);\n      expect(result.stdout).toContain('Successfully removed');\n      expect(result.stdout).toContain('1 skill');\n\n      // Directory should be empty or removed\n      const remaining = readdirSync(skillsDir);\n      expect(remaining.length).toBe(0);\n    });\n\n    it('should handle directory without SKILL.md file', () => {\n      // Create a directory without SKILL.md\n      const invalidSkillDir = join(skillsDir, 'invalid-skill');\n      mkdirSync(invalidSkillDir, { recursive: true });\n      writeFileSync(join(invalidSkillDir, 'README.md'), 'Just a readme');\n\n      createTestSkill('valid-skill');\n\n      const result = runCli(['remove', 'valid-skill', '-y'], testDir);\n      expect(result.stdout).toContain('Successfully removed');\n\n      // Invalid directory should still be removed\n      expect(existsSync(join(skillsDir, 'invalid-skill'))).toBe(true);\n    });\n  });\n\n  describe('help and info', () => {\n    it('should show help with --help', () => {\n      const result = runCli(['remove', '--help'], testDir);\n      expect(result.stdout).toContain('Usage');\n      expect(result.stdout).toContain('remove');\n      expect(result.stdout).toContain('--global');\n      expect(result.stdout).toContain('--agent');\n      expect(result.stdout).toContain('--yes');\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should show help with -h', () => {\n      const result = runCli(['remove', '-h'], testDir);\n      expect(result.stdout).toContain('Usage');\n      expect(result.exitCode).toBe(0);\n    });\n  });\n\n  describe('option parsing', () => {\n    beforeEach(() => {\n      createTestSkill('parse-test-skill');\n    });\n\n    it('should parse -g as global', () => {\n      const result = runCli(['remove', 'parse-test-skill', '-g', '-y'], testDir);\n      expect(result.stdout).not.toContain('error');\n      expect(result.stdout).not.toContain('unrecognized');\n    });\n\n    it('should parse --yes flag', () => {\n      const result = runCli(['remove', 'parse-test-skill', '--yes'], testDir);\n      expect(result.exitCode).toBe(0);\n    });\n\n    it('should parse -a as agent', () => {\n      const result = runCli(['remove', 'parse-test-skill', '-a', 'claude-code', '-y'], testDir);\n      expect(result.stdout).not.toContain('Invalid agents');\n    });\n\n    it('should handle multiple values for --agent', () => {\n      const result = runCli(\n        ['remove', 'parse-test-skill', '--agent', 'claude-code', 'cursor', '-y'],\n        testDir\n      );\n      expect(result.stdout).not.toContain('Invalid agents');\n    });\n  });\n});\n"
  },
  {
    "path": "src/remove.ts",
    "content": "import * as p from '@clack/prompts';\nimport pc from 'picocolors';\nimport { readdir, rm, lstat } from 'fs/promises';\nimport { join } from 'path';\nimport { agents, detectInstalledAgents } from './agents.ts';\nimport { track } from './telemetry.ts';\nimport { removeSkillFromLock, getSkillFromLock } from './skill-lock.ts';\nimport type { AgentType } from './types.ts';\nimport {\n  getInstallPath,\n  getCanonicalPath,\n  getCanonicalSkillsDir,\n  sanitizeName,\n} from './installer.ts';\n\nexport interface RemoveOptions {\n  global?: boolean;\n  agent?: string[];\n  yes?: boolean;\n  all?: boolean;\n}\n\nexport async function removeCommand(skillNames: string[], options: RemoveOptions) {\n  const isGlobal = options.global ?? false;\n  const cwd = process.cwd();\n\n  const spinner = p.spinner();\n\n  spinner.start('Scanning for installed skills...');\n  const skillNamesSet = new Set<string>();\n\n  const scanDir = async (dir: string) => {\n    try {\n      const entries = await readdir(dir, { withFileTypes: true });\n      for (const entry of entries) {\n        if (entry.isDirectory()) {\n          skillNamesSet.add(entry.name);\n        }\n      }\n    } catch (err) {\n      if (err instanceof Error && (err as { code?: string }).code !== 'ENOENT') {\n        p.log.warn(`Could not scan directory ${dir}: ${err.message}`);\n      }\n    }\n  };\n\n  if (isGlobal) {\n    await scanDir(getCanonicalSkillsDir(true, cwd));\n    for (const agent of Object.values(agents)) {\n      if (agent.globalSkillsDir !== undefined) {\n        await scanDir(agent.globalSkillsDir);\n      }\n    }\n  } else {\n    await scanDir(getCanonicalSkillsDir(false, cwd));\n    for (const agent of Object.values(agents)) {\n      await scanDir(join(cwd, agent.skillsDir));\n    }\n  }\n\n  const installedSkills = Array.from(skillNamesSet).sort();\n  spinner.stop(`Found ${installedSkills.length} unique installed skill(s)`);\n\n  if (installedSkills.length === 0) {\n    p.outro(pc.yellow('No skills found to remove.'));\n    return;\n  }\n\n  // Validate agent options BEFORE prompting for skill selection\n  if (options.agent && options.agent.length > 0) {\n    const validAgents = Object.keys(agents);\n    const invalidAgents = options.agent.filter((a) => !validAgents.includes(a));\n\n    if (invalidAgents.length > 0) {\n      p.log.error(`Invalid agents: ${invalidAgents.join(', ')}`);\n      p.log.info(`Valid agents: ${validAgents.join(', ')}`);\n      process.exit(1);\n    }\n  }\n\n  let selectedSkills: string[] = [];\n\n  if (options.all) {\n    selectedSkills = installedSkills;\n  } else if (skillNames.length > 0) {\n    selectedSkills = installedSkills.filter((s) =>\n      skillNames.some((name) => name.toLowerCase() === s.toLowerCase())\n    );\n\n    if (selectedSkills.length === 0) {\n      p.log.error(`No matching skills found for: ${skillNames.join(', ')}`);\n      return;\n    }\n  } else {\n    const choices = installedSkills.map((s) => ({\n      value: s,\n      label: s,\n    }));\n\n    const selected = await p.multiselect({\n      message: `Select skills to remove ${pc.dim('(space to toggle)')}`,\n      options: choices,\n      required: true,\n    });\n\n    if (p.isCancel(selected)) {\n      p.cancel('Removal cancelled');\n      process.exit(0);\n    }\n\n    selectedSkills = selected as string[];\n  }\n\n  let targetAgents: AgentType[];\n  if (options.agent && options.agent.length > 0) {\n    targetAgents = options.agent as AgentType[];\n  } else {\n    // When removing, we should target all known agents to ensure\n    // ghost symlinks are cleaned up, even if the agent is not detected.\n    targetAgents = Object.keys(agents) as AgentType[];\n    spinner.stop(`Targeting ${targetAgents.length} potential agent(s)`);\n  }\n\n  if (!options.yes) {\n    console.log();\n    p.log.info('Skills to remove:');\n    for (const skill of selectedSkills) {\n      p.log.message(`  ${pc.red('•')} ${skill}`);\n    }\n    console.log();\n\n    const confirmed = await p.confirm({\n      message: `Are you sure you want to uninstall ${selectedSkills.length} skill(s)?`,\n    });\n\n    if (p.isCancel(confirmed) || !confirmed) {\n      p.cancel('Removal cancelled');\n      process.exit(0);\n    }\n  }\n\n  spinner.start('Removing skills...');\n\n  const results: {\n    skill: string;\n    success: boolean;\n    source?: string;\n    sourceType?: string;\n    error?: string;\n  }[] = [];\n\n  for (const skillName of selectedSkills) {\n    try {\n      const canonicalPath = getCanonicalPath(skillName, { global: isGlobal, cwd });\n\n      for (const agentKey of targetAgents) {\n        const agent = agents[agentKey];\n        const skillPath = getInstallPath(skillName, agentKey, { global: isGlobal, cwd });\n\n        // Determine potential paths to cleanup. For universal agents, getInstallPath\n        // now returns the canonical path, so we also need to check their 'native'\n        // directory to clean up any legacy symlinks.\n        const pathsToCleanup = new Set([skillPath]);\n        const sanitizedName = sanitizeName(skillName);\n        if (isGlobal && agent.globalSkillsDir) {\n          pathsToCleanup.add(join(agent.globalSkillsDir, sanitizedName));\n        } else {\n          pathsToCleanup.add(join(cwd, agent.skillsDir, sanitizedName));\n        }\n\n        for (const pathToCleanup of pathsToCleanup) {\n          // Skip if this is the canonical path - we'll handle that after checking all agents\n          if (pathToCleanup === canonicalPath) {\n            continue;\n          }\n\n          try {\n            const stats = await lstat(pathToCleanup).catch(() => null);\n            if (stats) {\n              await rm(pathToCleanup, { recursive: true, force: true });\n            }\n          } catch (err) {\n            p.log.warn(\n              `Could not remove skill from ${agent.displayName}: ${\n                err instanceof Error ? err.message : String(err)\n              }`\n            );\n          }\n        }\n      }\n\n      // Only remove the canonical path if no other installed agents are using it.\n      // This prevents breaking other agents when uninstalling from a specific agent (#287).\n      const installedAgents = await detectInstalledAgents();\n      const remainingAgents = installedAgents.filter((a) => !targetAgents.includes(a));\n\n      let isStillUsed = false;\n      for (const agentKey of remainingAgents) {\n        const path = getInstallPath(skillName, agentKey, { global: isGlobal, cwd });\n        const exists = await lstat(path).catch(() => null);\n        if (exists) {\n          isStillUsed = true;\n          break;\n        }\n      }\n\n      if (!isStillUsed) {\n        await rm(canonicalPath, { recursive: true, force: true });\n      }\n\n      const lockEntry = isGlobal ? await getSkillFromLock(skillName) : null;\n      const effectiveSource = lockEntry?.source || 'local';\n      const effectiveSourceType = lockEntry?.sourceType || 'local';\n\n      if (isGlobal) {\n        await removeSkillFromLock(skillName);\n      }\n\n      results.push({\n        skill: skillName,\n        success: true,\n        source: effectiveSource,\n        sourceType: effectiveSourceType,\n      });\n    } catch (err) {\n      results.push({\n        skill: skillName,\n        success: false,\n        error: err instanceof Error ? err.message : String(err),\n      });\n    }\n  }\n\n  spinner.stop('Removal process complete');\n\n  const successful = results.filter((r) => r.success);\n  const failed = results.filter((r) => !r.success);\n\n  // Track removal (grouped by source)\n  if (successful.length > 0) {\n    const bySource = new Map<string, { skills: string[]; sourceType?: string }>();\n\n    for (const r of successful) {\n      const source = r.source || 'local';\n      const existing = bySource.get(source) || { skills: [] };\n      existing.skills.push(r.skill);\n      existing.sourceType = r.sourceType;\n      bySource.set(source, existing);\n    }\n\n    for (const [source, data] of bySource) {\n      track({\n        event: 'remove',\n        source,\n        skills: data.skills.join(','),\n        agents: targetAgents.join(','),\n        ...(isGlobal && { global: '1' }),\n        sourceType: data.sourceType,\n      });\n    }\n  }\n\n  if (successful.length > 0) {\n    p.log.success(pc.green(`Successfully removed ${successful.length} skill(s)`));\n  }\n\n  if (failed.length > 0) {\n    p.log.error(pc.red(`Failed to remove ${failed.length} skill(s)`));\n    for (const r of failed) {\n      p.log.message(`  ${pc.red('✗')} ${r.skill}: ${r.error}`);\n    }\n  }\n\n  console.log();\n  p.outro(pc.green('Done!'));\n}\n\n/**\n * Parse command line options for the remove command.\n * Separates skill names from options flags.\n */\nexport function parseRemoveOptions(args: string[]): { skills: string[]; options: RemoveOptions } {\n  const options: RemoveOptions = {};\n  const skills: string[] = [];\n\n  for (let i = 0; i < args.length; i++) {\n    const arg = args[i];\n\n    if (arg === '-g' || arg === '--global') {\n      options.global = true;\n    } else if (arg === '-y' || arg === '--yes') {\n      options.yes = true;\n    } else if (arg === '--all') {\n      options.all = true;\n    } else if (arg === '-a' || arg === '--agent') {\n      options.agent = options.agent || [];\n      i++;\n      let nextArg = args[i];\n      while (i < args.length && nextArg && !nextArg.startsWith('-')) {\n        options.agent.push(nextArg);\n        i++;\n        nextArg = args[i];\n      }\n      i--; // Back up one since the loop will increment\n    } else if (arg && !arg.startsWith('-')) {\n      skills.push(arg);\n    }\n  }\n\n  return { skills, options };\n}\n"
  },
  {
    "path": "src/skill-lock.ts",
    "content": "import { readFile, writeFile, mkdir } from 'fs/promises';\nimport { join, dirname } from 'path';\nimport { homedir } from 'os';\nimport { createHash } from 'crypto';\nimport { execSync } from 'child_process';\n\nconst AGENTS_DIR = '.agents';\nconst LOCK_FILE = '.skill-lock.json';\nconst CURRENT_VERSION = 3; // Bumped from 2 to 3 for folder hash support (GitHub tree SHA)\n\n/**\n * Represents a single installed skill entry in the lock file.\n */\nexport interface SkillLockEntry {\n  /** Normalized source identifier (e.g., \"owner/repo\", \"mintlify/bun.com\") */\n  source: string;\n  /** The provider/source type (e.g., \"github\", \"mintlify\", \"huggingface\", \"local\") */\n  sourceType: string;\n  /** The original URL used to install the skill (for re-fetching updates) */\n  sourceUrl: string;\n  /** Subpath within the source repo, if applicable */\n  skillPath?: string;\n  /**\n   * GitHub tree SHA for the entire skill folder.\n   * This hash changes when ANY file in the skill folder changes.\n   * Fetched via GitHub Trees API by the telemetry server.\n   */\n  skillFolderHash: string;\n  /** ISO timestamp when the skill was first installed */\n  installedAt: string;\n  /** ISO timestamp when the skill was last updated */\n  updatedAt: string;\n  /** Name of the plugin this skill belongs to (if any) */\n  pluginName?: string;\n}\n\n/**\n * Tracks dismissed prompts so they're not shown again.\n */\nexport interface DismissedPrompts {\n  /** Dismissed the find-skills skill installation prompt */\n  findSkillsPrompt?: boolean;\n}\n\n/**\n * The structure of the skill lock file.\n */\nexport interface SkillLockFile {\n  /** Schema version for future migrations */\n  version: number;\n  /** Map of skill name to its lock entry */\n  skills: Record<string, SkillLockEntry>;\n  /** Tracks dismissed prompts */\n  dismissed?: DismissedPrompts;\n  /** Last selected agents for installation */\n  lastSelectedAgents?: string[];\n}\n\n/**\n * Get the path to the global skill lock file.\n * Use $XDG_STATE_HOME/skills/.skill-lock.json if set.\n * otherwise fall back to ~/.agents/.skill-lock.json\n */\nexport function getSkillLockPath(): string {\n  const xdgStateHome = process.env.XDG_STATE_HOME;\n  if (xdgStateHome) {\n    return join(xdgStateHome, 'skills', LOCK_FILE);\n  }\n  return join(homedir(), AGENTS_DIR, LOCK_FILE);\n}\n\n/**\n * Read the skill lock file.\n * Returns an empty lock file structure if the file doesn't exist.\n * Wipes the lock file if it's an old format (version < CURRENT_VERSION).\n */\nexport async function readSkillLock(): Promise<SkillLockFile> {\n  const lockPath = getSkillLockPath();\n\n  try {\n    const content = await readFile(lockPath, 'utf-8');\n    const parsed = JSON.parse(content) as SkillLockFile;\n\n    // Validate version - wipe if old format\n    if (typeof parsed.version !== 'number' || !parsed.skills) {\n      return createEmptyLockFile();\n    }\n\n    // If old version, wipe and start fresh (backwards incompatible change)\n    // v3 adds skillFolderHash - we want fresh installs to populate it\n    if (parsed.version < CURRENT_VERSION) {\n      return createEmptyLockFile();\n    }\n\n    return parsed;\n  } catch (error) {\n    // File doesn't exist or is invalid - return empty\n    return createEmptyLockFile();\n  }\n}\n\n/**\n * Write the skill lock file.\n * Creates the directory if it doesn't exist.\n */\nexport async function writeSkillLock(lock: SkillLockFile): Promise<void> {\n  const lockPath = getSkillLockPath();\n\n  // Ensure directory exists\n  await mkdir(dirname(lockPath), { recursive: true });\n\n  // Write with pretty formatting for human readability\n  const content = JSON.stringify(lock, null, 2);\n  await writeFile(lockPath, content, 'utf-8');\n}\n\n/**\n * Compute SHA-256 hash of content.\n */\nexport function computeContentHash(content: string): string {\n  return createHash('sha256').update(content, 'utf-8').digest('hex');\n}\n\n/**\n * Get GitHub token from user's environment.\n * Tries in order:\n * 1. GITHUB_TOKEN environment variable\n * 2. GH_TOKEN environment variable\n * 3. gh CLI auth token (if gh is installed)\n *\n * @returns The token string or null if not available\n */\nexport function getGitHubToken(): string | null {\n  // Check environment variables first\n  if (process.env.GITHUB_TOKEN) {\n    return process.env.GITHUB_TOKEN;\n  }\n  if (process.env.GH_TOKEN) {\n    return process.env.GH_TOKEN;\n  }\n\n  // Try gh CLI\n  try {\n    const token = execSync('gh auth token', {\n      encoding: 'utf-8',\n      stdio: ['pipe', 'pipe', 'pipe'],\n    }).trim();\n    if (token) {\n      return token;\n    }\n  } catch {\n    // gh not installed or not authenticated\n  }\n\n  return null;\n}\n\n/**\n * Fetch the tree SHA (folder hash) for a skill folder using GitHub's Trees API.\n * This makes ONE API call to get the entire repo tree, then extracts the SHA\n * for the specific skill folder.\n *\n * @param ownerRepo - GitHub owner/repo (e.g., \"vercel-labs/agent-skills\")\n * @param skillPath - Path to skill folder or SKILL.md (e.g., \"skills/react-best-practices/SKILL.md\")\n * @param token - Optional GitHub token for authenticated requests (higher rate limits)\n * @returns The tree SHA for the skill folder, or null if not found\n */\nexport async function fetchSkillFolderHash(\n  ownerRepo: string,\n  skillPath: string,\n  token?: string | null\n): Promise<string | null> {\n  // Normalize to forward slashes first (for GitHub API compatibility)\n  let folderPath = skillPath.replace(/\\\\/g, '/');\n\n  // Remove SKILL.md suffix to get folder path\n  if (folderPath.endsWith('/SKILL.md')) {\n    folderPath = folderPath.slice(0, -9);\n  } else if (folderPath.endsWith('SKILL.md')) {\n    folderPath = folderPath.slice(0, -8);\n  }\n\n  // Remove trailing slash\n  if (folderPath.endsWith('/')) {\n    folderPath = folderPath.slice(0, -1);\n  }\n\n  const branches = ['main', 'master'];\n\n  for (const branch of branches) {\n    try {\n      const url = `https://api.github.com/repos/${ownerRepo}/git/trees/${branch}?recursive=1`;\n      const headers: Record<string, string> = {\n        Accept: 'application/vnd.github.v3+json',\n        'User-Agent': 'skills-cli',\n      };\n      if (token) {\n        headers['Authorization'] = `Bearer ${token}`;\n      }\n\n      const response = await fetch(url, { headers });\n\n      if (!response.ok) continue;\n\n      const data = (await response.json()) as {\n        sha: string;\n        tree: Array<{ path: string; type: string; sha: string }>;\n      };\n\n      // If folderPath is empty, this is a root-level skill - use the root tree SHA\n      if (!folderPath) {\n        return data.sha;\n      }\n\n      // Find the tree entry for the skill folder\n      const folderEntry = data.tree.find(\n        (entry) => entry.type === 'tree' && entry.path === folderPath\n      );\n\n      if (folderEntry) {\n        return folderEntry.sha;\n      }\n    } catch {\n      continue;\n    }\n  }\n\n  return null;\n}\n\n/**\n * Add or update a skill entry in the lock file.\n */\nexport async function addSkillToLock(\n  skillName: string,\n  entry: Omit<SkillLockEntry, 'installedAt' | 'updatedAt'>\n): Promise<void> {\n  const lock = await readSkillLock();\n  const now = new Date().toISOString();\n\n  const existingEntry = lock.skills[skillName];\n\n  lock.skills[skillName] = {\n    ...entry,\n    installedAt: existingEntry?.installedAt ?? now,\n    updatedAt: now,\n  };\n\n  await writeSkillLock(lock);\n}\n\n/**\n * Remove a skill from the lock file.\n */\nexport async function removeSkillFromLock(skillName: string): Promise<boolean> {\n  const lock = await readSkillLock();\n\n  if (!(skillName in lock.skills)) {\n    return false;\n  }\n\n  delete lock.skills[skillName];\n  await writeSkillLock(lock);\n  return true;\n}\n\n/**\n * Get a skill entry from the lock file.\n */\nexport async function getSkillFromLock(skillName: string): Promise<SkillLockEntry | null> {\n  const lock = await readSkillLock();\n  return lock.skills[skillName] ?? null;\n}\n\n/**\n * Get all skills from the lock file.\n */\nexport async function getAllLockedSkills(): Promise<Record<string, SkillLockEntry>> {\n  const lock = await readSkillLock();\n  return lock.skills;\n}\n\n/**\n * Get skills grouped by source for batch update operations.\n */\nexport async function getSkillsBySource(): Promise<\n  Map<string, { skills: string[]; entry: SkillLockEntry }>\n> {\n  const lock = await readSkillLock();\n  const bySource = new Map<string, { skills: string[]; entry: SkillLockEntry }>();\n\n  for (const [skillName, entry] of Object.entries(lock.skills)) {\n    const existing = bySource.get(entry.source);\n    if (existing) {\n      existing.skills.push(skillName);\n    } else {\n      bySource.set(entry.source, { skills: [skillName], entry });\n    }\n  }\n\n  return bySource;\n}\n\n/**\n * Create an empty lock file structure.\n */\nfunction createEmptyLockFile(): SkillLockFile {\n  return {\n    version: CURRENT_VERSION,\n    skills: {},\n    dismissed: {},\n  };\n}\n\n/**\n * Check if a prompt has been dismissed.\n */\nexport async function isPromptDismissed(promptKey: keyof DismissedPrompts): Promise<boolean> {\n  const lock = await readSkillLock();\n  return lock.dismissed?.[promptKey] === true;\n}\n\n/**\n * Mark a prompt as dismissed.\n */\nexport async function dismissPrompt(promptKey: keyof DismissedPrompts): Promise<void> {\n  const lock = await readSkillLock();\n  if (!lock.dismissed) {\n    lock.dismissed = {};\n  }\n  lock.dismissed[promptKey] = true;\n  await writeSkillLock(lock);\n}\n\n/**\n * Get the last selected agents.\n */\nexport async function getLastSelectedAgents(): Promise<string[] | undefined> {\n  const lock = await readSkillLock();\n  return lock.lastSelectedAgents;\n}\n\n/**\n * Save the selected agents to the lock file.\n */\nexport async function saveSelectedAgents(agents: string[]): Promise<void> {\n  const lock = await readSkillLock();\n  lock.lastSelectedAgents = agents;\n  await writeSkillLock(lock);\n}\n"
  },
  {
    "path": "src/skills.ts",
    "content": "import { readdir, readFile, stat } from 'fs/promises';\nimport { join, basename, dirname, resolve, normalize, sep } from 'path';\nimport matter from 'gray-matter';\nimport type { Skill } from './types.ts';\nimport { getPluginSkillPaths, getPluginGroupings } from './plugin-manifest.ts';\n\nconst SKIP_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__'];\n\n/**\n * Check if internal skills should be installed.\n * Internal skills are hidden by default unless INSTALL_INTERNAL_SKILLS=1 is set.\n */\nexport function shouldInstallInternalSkills(): boolean {\n  const envValue = process.env.INSTALL_INTERNAL_SKILLS;\n  return envValue === '1' || envValue === 'true';\n}\n\nasync function hasSkillMd(dir: string): Promise<boolean> {\n  try {\n    const skillPath = join(dir, 'SKILL.md');\n    const stats = await stat(skillPath);\n    return stats.isFile();\n  } catch {\n    return false;\n  }\n}\n\nexport async function parseSkillMd(\n  skillMdPath: string,\n  options?: { includeInternal?: boolean }\n): Promise<Skill | null> {\n  try {\n    const content = await readFile(skillMdPath, 'utf-8');\n    const { data } = matter(content);\n\n    if (!data.name || !data.description) {\n      return null;\n    }\n\n    // Ensure name and description are strings (YAML can parse numbers, booleans, etc.)\n    if (typeof data.name !== 'string' || typeof data.description !== 'string') {\n      return null;\n    }\n\n    // Skip internal skills unless:\n    // 1. INSTALL_INTERNAL_SKILLS=1 is set, OR\n    // 2. includeInternal option is true (e.g., when user explicitly requests a skill)\n    const isInternal = data.metadata?.internal === true;\n    if (isInternal && !shouldInstallInternalSkills() && !options?.includeInternal) {\n      return null;\n    }\n\n    return {\n      name: data.name,\n      description: data.description,\n      path: dirname(skillMdPath),\n      rawContent: content,\n      metadata: data.metadata,\n    };\n  } catch {\n    return null;\n  }\n}\n\nasync function findSkillDirs(dir: string, depth = 0, maxDepth = 5): Promise<string[]> {\n  if (depth > maxDepth) return [];\n\n  try {\n    const [hasSkill, entries] = await Promise.all([\n      hasSkillMd(dir),\n      readdir(dir, { withFileTypes: true }).catch(() => []),\n    ]);\n\n    const currentDir = hasSkill ? [dir] : [];\n\n    // Search subdirectories in parallel\n    const subDirResults = await Promise.all(\n      entries\n        .filter((entry) => entry.isDirectory() && !SKIP_DIRS.includes(entry.name))\n        .map((entry) => findSkillDirs(join(dir, entry.name), depth + 1, maxDepth))\n    );\n\n    return [...currentDir, ...subDirResults.flat()];\n  } catch {\n    return [];\n  }\n}\n\nexport interface DiscoverSkillsOptions {\n  /** Include internal skills (e.g., when user explicitly requests a skill by name) */\n  includeInternal?: boolean;\n  /** Search all subdirectories even when a root SKILL.md exists */\n  fullDepth?: boolean;\n}\n\n/**\n * Validates that a resolved subpath stays within the base directory.\n * Prevents path traversal attacks where subpath contains \"..\" segments\n * that would escape the cloned repository directory.\n */\nexport function isSubpathSafe(basePath: string, subpath: string): boolean {\n  const normalizedBase = normalize(resolve(basePath));\n  const normalizedTarget = normalize(resolve(join(basePath, subpath)));\n\n  return normalizedTarget.startsWith(normalizedBase + sep) || normalizedTarget === normalizedBase;\n}\n\nexport async function discoverSkills(\n  basePath: string,\n  subpath?: string,\n  options?: DiscoverSkillsOptions\n): Promise<Skill[]> {\n  const skills: Skill[] = [];\n  const seenNames = new Set<string>();\n\n  // Validate subpath doesn't escape basePath (prevent path traversal)\n  if (subpath && !isSubpathSafe(basePath, subpath)) {\n    throw new Error(\n      `Invalid subpath: \"${subpath}\" resolves outside the repository directory. Subpath must not contain \"..\" segments that escape the base path.`\n    );\n  }\n\n  const searchPath = subpath ? join(basePath, subpath) : basePath;\n\n  // Get plugin groupings to map skills to their parent plugin\n  // We search for plugin definitions from the base search path\n  const pluginGroupings = await getPluginGroupings(searchPath);\n\n  // Helper to assign plugin name if available\n  const enhanceSkill = (skill: Skill) => {\n    const resolvedPath = resolve(skill.path);\n    if (pluginGroupings.has(resolvedPath)) {\n      skill.pluginName = pluginGroupings.get(resolvedPath);\n    }\n    return skill;\n  };\n\n  // If pointing directly at a skill, add it (and return early unless fullDepth is set)\n  if (await hasSkillMd(searchPath)) {\n    let skill = await parseSkillMd(join(searchPath, 'SKILL.md'), options);\n    if (skill) {\n      skill = enhanceSkill(skill);\n      skills.push(skill);\n      seenNames.add(skill.name);\n      // Only return early if fullDepth is not set\n      if (!options?.fullDepth) {\n        return skills;\n      }\n    }\n  }\n\n  // Search common skill locations first\n  const prioritySearchDirs = [\n    searchPath,\n    join(searchPath, 'skills'),\n    join(searchPath, 'skills/.curated'),\n    join(searchPath, 'skills/.experimental'),\n    join(searchPath, 'skills/.system'),\n    join(searchPath, '.agents/skills'),\n    join(searchPath, '.claude/skills'),\n    join(searchPath, '.cline/skills'),\n    join(searchPath, '.codebuddy/skills'),\n    join(searchPath, '.codex/skills'),\n    join(searchPath, '.commandcode/skills'),\n    join(searchPath, '.continue/skills'),\n\n    join(searchPath, '.github/skills'),\n    join(searchPath, '.goose/skills'),\n    join(searchPath, '.iflow/skills'),\n    join(searchPath, '.junie/skills'),\n    join(searchPath, '.kilocode/skills'),\n    join(searchPath, '.kiro/skills'),\n    join(searchPath, '.mux/skills'),\n    join(searchPath, '.neovate/skills'),\n    join(searchPath, '.opencode/skills'),\n    join(searchPath, '.openhands/skills'),\n    join(searchPath, '.pi/skills'),\n    join(searchPath, '.qoder/skills'),\n    join(searchPath, '.roo/skills'),\n    join(searchPath, '.trae/skills'),\n    join(searchPath, '.windsurf/skills'),\n    join(searchPath, '.zencoder/skills'),\n  ];\n\n  // Add skill paths declared in plugin manifests\n  prioritySearchDirs.push(...(await getPluginSkillPaths(searchPath)));\n\n  for (const dir of prioritySearchDirs) {\n    try {\n      const entries = await readdir(dir, { withFileTypes: true });\n\n      for (const entry of entries) {\n        if (entry.isDirectory()) {\n          const skillDir = join(dir, entry.name);\n          if (await hasSkillMd(skillDir)) {\n            let skill = await parseSkillMd(join(skillDir, 'SKILL.md'), options);\n            if (skill && !seenNames.has(skill.name)) {\n              skill = enhanceSkill(skill);\n              skills.push(skill);\n              seenNames.add(skill.name);\n            }\n          }\n        }\n      }\n    } catch {\n      // Directory doesn't exist\n    }\n  }\n\n  // Fall back to recursive search if nothing found, or if fullDepth is set\n  if (skills.length === 0 || options?.fullDepth) {\n    const allSkillDirs = await findSkillDirs(searchPath);\n\n    for (const skillDir of allSkillDirs) {\n      let skill = await parseSkillMd(join(skillDir, 'SKILL.md'), options);\n      if (skill && !seenNames.has(skill.name)) {\n        skill = enhanceSkill(skill);\n        skills.push(skill);\n        seenNames.add(skill.name);\n      }\n    }\n  }\n\n  return skills;\n}\n\nexport function getSkillDisplayName(skill: Skill): string {\n  return skill.name || basename(skill.path);\n}\n\n/**\n * Filter skills based on user input (case-insensitive direct matching).\n * Multi-word skill names must be quoted on the command line.\n */\nexport function filterSkills(skills: Skill[], inputNames: string[]): Skill[] {\n  const normalizedInputs = inputNames.map((n) => n.toLowerCase());\n\n  return skills.filter((skill) => {\n    const name = skill.name.toLowerCase();\n    const displayName = getSkillDisplayName(skill).toLowerCase();\n\n    return normalizedInputs.some((input) => input === name || input === displayName);\n  });\n}\n"
  },
  {
    "path": "src/source-parser.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { parseSource } from './source-parser.js';\n\ndescribe('source-parser', () => {\n  describe('GitLab Custom Domains & Subgroups', () => {\n    it('parses custom gitlab domain with deep subgroup paths', () => {\n      const result = parseSource('https://git.corp.com/group/subgroup/project/-/tree/main/src');\n      expect(result).toEqual({\n        type: 'gitlab',\n        url: 'https://git.corp.com/group/subgroup/project.git',\n        ref: 'main',\n        subpath: 'src',\n      });\n    });\n\n    it('parses gitlab tree with branch but no path', () => {\n      const result = parseSource('https://gitlab.example.com/org/repo/-/tree/v1.0');\n      expect(result).toEqual({\n        type: 'gitlab',\n        url: 'https://gitlab.example.com/org/repo.git',\n        ref: 'v1.0',\n      });\n    });\n\n    it('parses custom gitlab domain with port number', () => {\n      const result = parseSource('https://git.corp.com:8443/group/repo/-/tree/main');\n      expect(result).toMatchObject({\n        type: 'gitlab',\n        url: 'https://git.corp.com:8443/group/repo.git',\n        ref: 'main',\n      });\n    });\n\n    it('parses http protocol (non-ssl)', () => {\n      const result = parseSource('http://git.local/group/repo/-/tree/dev');\n      expect(result).toMatchObject({\n        type: 'gitlab',\n        url: 'http://git.local/group/repo.git',\n      });\n    });\n\n    it('parses personal project path (~user)', () => {\n      const result = parseSource('https://gitlab.com/~user/project/-/tree/main');\n      expect(result).toMatchObject({\n        type: 'gitlab',\n        url: 'https://gitlab.com/~user/project.git',\n      });\n    });\n  });\n\n  describe('Simplified Git Strategy', () => {\n    it('treats custom domains with .git as generic git', () => {\n      const result = parseSource('https://git.mycompany.com/my-group/my-repo.git');\n      expect(result).toEqual({\n        type: 'git',\n        url: 'https://git.mycompany.com/my-group/my-repo.git',\n      });\n    });\n\n    it('prevents false positives for generic URLs (falls through to well-known)', () => {\n      const result = parseSource('https://google.com/search/result');\n      expect(result.type).toBe('well-known');\n      expect(result.url).toBe('https://google.com/search/result');\n    });\n\n    it('retains official gitlab.com parsing for convenience', () => {\n      const result = parseSource('https://gitlab.com/owner/repo');\n      expect(result).toEqual({\n        type: 'gitlab',\n        url: 'https://gitlab.com/owner/repo.git',\n      });\n    });\n  });\n\n  describe('Existing GitHub Support', () => {\n    it('parses github shorthand', () => {\n      const result = parseSource('vercel-labs/agent-skills');\n      expect(result).toEqual({\n        type: 'github',\n        url: 'https://github.com/vercel-labs/agent-skills.git',\n        subpath: undefined,\n      });\n    });\n\n    it('parses github full URL', () => {\n      const result = parseSource('https://github.com/owner/repo/tree/main/path');\n      expect(result).toEqual({\n        type: 'github',\n        url: 'https://github.com/owner/repo.git',\n        ref: 'main',\n        subpath: 'path',\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "src/source-parser.ts",
    "content": "import { isAbsolute, resolve } from 'path';\nimport type { ParsedSource } from './types.ts';\n\n/**\n * Extract owner/repo (or group/subgroup/repo for GitLab) from a parsed source\n * for lockfile tracking and telemetry.\n * Returns null for local paths or unparseable sources.\n * Supports any Git host with an owner/repo URL structure, including GitLab subgroups.\n */\nexport function getOwnerRepo(parsed: ParsedSource): string | null {\n  if (parsed.type === 'local') {\n    return null;\n  }\n\n  // Handle Git SSH URLs (e.g., git@gitlab.com:owner/repo.git, git@github.com:owner/repo.git)\n  const sshMatch = parsed.url.match(/^git@[^:]+:(.+)$/);\n  if (sshMatch) {\n    let path = sshMatch[1]!;\n    path = path.replace(/\\.git$/, '');\n\n    // Must have at least owner/repo (one slash)\n    if (path.includes('/')) {\n      return path;\n    }\n    return null;\n  }\n\n  // Handle HTTP(S) URLs\n  if (!parsed.url.startsWith('http://') && !parsed.url.startsWith('https://')) {\n    return null;\n  }\n\n  try {\n    const url = new URL(parsed.url);\n    // Get pathname, remove leading slash and trailing .git\n    let path = url.pathname.slice(1);\n    path = path.replace(/\\.git$/, '');\n\n    // Must have at least owner/repo (one slash)\n    if (path.includes('/')) {\n      return path;\n    }\n  } catch {\n    // Invalid URL\n  }\n\n  return null;\n}\n\n/**\n * Extract owner and repo from an owner/repo string.\n * Returns null if the format is invalid.\n */\nexport function parseOwnerRepo(ownerRepo: string): { owner: string; repo: string } | null {\n  const match = ownerRepo.match(/^([^/]+)\\/([^/]+)$/);\n  if (match) {\n    return { owner: match[1]!, repo: match[2]! };\n  }\n  return null;\n}\n\n/**\n * Check if a GitHub repository is private.\n * Returns true if private, false if public, null if unable to determine.\n * Only works for GitHub repositories (GitLab not supported).\n */\nexport async function isRepoPrivate(owner: string, repo: string): Promise<boolean | null> {\n  try {\n    const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`);\n\n    // If repo doesn't exist or we don't have access, assume private to be safe\n    if (!res.ok) {\n      return null; // Unable to determine\n    }\n\n    const data = (await res.json()) as { private?: boolean };\n    return data.private === true;\n  } catch {\n    // On error, return null to indicate we couldn't determine\n    return null;\n  }\n}\n\n/**\n * Sanitizes a subpath to prevent path traversal attacks.\n * Rejects subpaths containing \"..\" segments that could escape the repository root.\n * Returns the sanitized subpath, or throws if the subpath is unsafe.\n */\nexport function sanitizeSubpath(subpath: string): string {\n  // Normalize to forward slashes for consistent handling\n  const normalized = subpath.replace(/\\\\/g, '/');\n\n  // Check each segment for \"..\"\n  const segments = normalized.split('/');\n  for (const segment of segments) {\n    if (segment === '..') {\n      throw new Error(\n        `Unsafe subpath: \"${subpath}\" contains path traversal segments. ` +\n          `Subpaths must not contain \"..\" components.`\n      );\n    }\n  }\n\n  return subpath;\n}\n\n/**\n * Check if a string represents a local file system path\n */\nfunction isLocalPath(input: string): boolean {\n  return (\n    isAbsolute(input) ||\n    input.startsWith('./') ||\n    input.startsWith('../') ||\n    input === '.' ||\n    input === '..' ||\n    // Windows absolute paths like C:\\ or D:\\\n    /^[a-zA-Z]:[/\\\\]/.test(input)\n  );\n}\n\n/**\n * Parse a source string into a structured format\n * Supports: local paths, GitHub URLs, GitLab URLs, GitHub shorthand, well-known URLs, and direct git URLs\n */\n// Source aliases: map common shorthand to canonical source\nconst SOURCE_ALIASES: Record<string, string> = {\n  'coinbase/agentWallet': 'coinbase/agentic-wallet-skills',\n};\n\nexport function parseSource(input: string): ParsedSource {\n  // Resolve source aliases before parsing\n  const alias = SOURCE_ALIASES[input];\n  if (alias) {\n    input = alias;\n  }\n\n  // Prefix shorthand: github:owner/repo -> owner/repo (handled by existing shorthand logic)\n  // Also supports github:owner/repo/subpath and github:owner/repo@skill\n  const githubPrefixMatch = input.match(/^github:(.+)$/);\n  if (githubPrefixMatch) {\n    return parseSource(githubPrefixMatch[1]!);\n  }\n\n  // Prefix shorthand: gitlab:owner/repo -> https://gitlab.com/owner/repo\n  const gitlabPrefixMatch = input.match(/^gitlab:(.+)$/);\n  if (gitlabPrefixMatch) {\n    return parseSource(`https://gitlab.com/${gitlabPrefixMatch[1]!}`);\n  }\n\n  // Local path: absolute, relative, or current directory\n  if (isLocalPath(input)) {\n    const resolvedPath = resolve(input);\n    // Return local type even if path doesn't exist - we'll handle validation in main flow\n    return {\n      type: 'local',\n      url: resolvedPath, // Store resolved path in url for consistency\n      localPath: resolvedPath,\n    };\n  }\n\n  // GitHub URL with path: https://github.com/owner/repo/tree/branch/path/to/skill\n  const githubTreeWithPathMatch = input.match(/github\\.com\\/([^/]+)\\/([^/]+)\\/tree\\/([^/]+)\\/(.+)/);\n  if (githubTreeWithPathMatch) {\n    const [, owner, repo, ref, subpath] = githubTreeWithPathMatch;\n    return {\n      type: 'github',\n      url: `https://github.com/${owner}/${repo}.git`,\n      ref,\n      subpath: subpath ? sanitizeSubpath(subpath) : subpath,\n    };\n  }\n\n  // GitHub URL with branch only: https://github.com/owner/repo/tree/branch\n  const githubTreeMatch = input.match(/github\\.com\\/([^/]+)\\/([^/]+)\\/tree\\/([^/]+)$/);\n  if (githubTreeMatch) {\n    const [, owner, repo, ref] = githubTreeMatch;\n    return {\n      type: 'github',\n      url: `https://github.com/${owner}/${repo}.git`,\n      ref,\n    };\n  }\n\n  // GitHub URL: https://github.com/owner/repo\n  const githubRepoMatch = input.match(/github\\.com\\/([^/]+)\\/([^/]+)/);\n  if (githubRepoMatch) {\n    const [, owner, repo] = githubRepoMatch;\n    const cleanRepo = repo!.replace(/\\.git$/, '');\n    return {\n      type: 'github',\n      url: `https://github.com/${owner}/${cleanRepo}.git`,\n    };\n  }\n\n  // GitLab URL with path (any GitLab instance): https://gitlab.com/owner/repo/-/tree/branch/path\n  // Key identifier is the \"/-/tree/\" path pattern unique to GitLab.\n  // Supports subgroups by using a non-greedy match for the repository path.\n  const gitlabTreeWithPathMatch = input.match(\n    /^(https?):\\/\\/([^/]+)\\/(.+?)\\/-\\/tree\\/([^/]+)\\/(.+)/\n  );\n  if (gitlabTreeWithPathMatch) {\n    const [, protocol, hostname, repoPath, ref, subpath] = gitlabTreeWithPathMatch;\n    if (hostname !== 'github.com' && repoPath) {\n      return {\n        type: 'gitlab',\n        url: `${protocol}://${hostname}/${repoPath.replace(/\\.git$/, '')}.git`,\n        ref,\n        subpath: subpath ? sanitizeSubpath(subpath) : subpath,\n      };\n    }\n  }\n\n  // GitLab URL with branch only (any GitLab instance): https://gitlab.com/owner/repo/-/tree/branch\n  const gitlabTreeMatch = input.match(/^(https?):\\/\\/([^/]+)\\/(.+?)\\/-\\/tree\\/([^/]+)$/);\n  if (gitlabTreeMatch) {\n    const [, protocol, hostname, repoPath, ref] = gitlabTreeMatch;\n    if (hostname !== 'github.com' && repoPath) {\n      return {\n        type: 'gitlab',\n        url: `${protocol}://${hostname}/${repoPath.replace(/\\.git$/, '')}.git`,\n        ref,\n      };\n    }\n  }\n\n  // GitLab.com URL: https://gitlab.com/owner/repo or https://gitlab.com/group/subgroup/repo\n  // Only for the official gitlab.com domain for user convenience.\n  // Supports nested subgroups (e.g., gitlab.com/group/subgroup1/subgroup2/repo).\n  const gitlabRepoMatch = input.match(/gitlab\\.com\\/(.+?)(?:\\.git)?\\/?$/);\n  if (gitlabRepoMatch) {\n    const repoPath = gitlabRepoMatch[1]!;\n    // Must have at least owner/repo (one slash)\n    if (repoPath.includes('/')) {\n      return {\n        type: 'gitlab',\n        url: `https://gitlab.com/${repoPath}.git`,\n      };\n    }\n  }\n\n  // GitHub shorthand: owner/repo, owner/repo/path/to/skill, or owner/repo@skill-name\n  // Exclude paths that start with . or / to avoid matching local paths\n  // First check for @skill syntax: owner/repo@skill-name\n  const atSkillMatch = input.match(/^([^/]+)\\/([^/@]+)@(.+)$/);\n  if (atSkillMatch && !input.includes(':') && !input.startsWith('.') && !input.startsWith('/')) {\n    const [, owner, repo, skillFilter] = atSkillMatch;\n    return {\n      type: 'github',\n      url: `https://github.com/${owner}/${repo}.git`,\n      skillFilter,\n    };\n  }\n\n  const shorthandMatch = input.match(/^([^/]+)\\/([^/]+)(?:\\/(.+))?$/);\n  if (shorthandMatch && !input.includes(':') && !input.startsWith('.') && !input.startsWith('/')) {\n    const [, owner, repo, subpath] = shorthandMatch;\n    return {\n      type: 'github',\n      url: `https://github.com/${owner}/${repo}.git`,\n      subpath: subpath ? sanitizeSubpath(subpath) : subpath,\n    };\n  }\n\n  // Well-known skills: arbitrary HTTP(S) URLs that aren't GitHub/GitLab\n  // This is the final fallback for URLs - we'll check for /.well-known/skills/index.json\n  if (isWellKnownUrl(input)) {\n    return {\n      type: 'well-known',\n      url: input,\n    };\n  }\n\n  // Fallback: treat as direct git URL\n  return {\n    type: 'git',\n    url: input,\n  };\n}\n\n/**\n * Check if a URL could be a well-known skills endpoint.\n * Must be HTTP(S) and not a known git host (GitHub, GitLab).\n * Also excludes URLs that look like git repos (.git suffix).\n */\nfunction isWellKnownUrl(input: string): boolean {\n  if (!input.startsWith('http://') && !input.startsWith('https://')) {\n    return false;\n  }\n\n  try {\n    const parsed = new URL(input);\n\n    // Exclude known git hosts that have their own handling\n    const excludedHosts = ['github.com', 'gitlab.com', 'raw.githubusercontent.com'];\n    if (excludedHosts.includes(parsed.hostname)) {\n      return false;\n    }\n\n    // Don't match URLs that look like git repos (should be handled by git type)\n    if (input.endsWith('.git')) {\n      return false;\n    }\n\n    return true;\n  } catch {\n    return false;\n  }\n}\n"
  },
  {
    "path": "src/sync.ts",
    "content": "import * as p from '@clack/prompts';\nimport pc from 'picocolors';\nimport { readdir, stat } from 'fs/promises';\nimport { join, sep } from 'path';\nimport { homedir } from 'os';\nimport { parseSkillMd } from './skills.ts';\nimport { installSkillForAgent, getCanonicalPath } from './installer.ts';\nimport {\n  detectInstalledAgents,\n  agents,\n  getUniversalAgents,\n  getNonUniversalAgents,\n} from './agents.ts';\nimport { searchMultiselect } from './prompts/search-multiselect.ts';\nimport { addSkillToLocalLock, computeSkillFolderHash, readLocalLock } from './local-lock.ts';\nimport type { Skill, AgentType } from './types.ts';\nimport { track } from './telemetry.ts';\n\nconst isCancelled = (value: unknown): value is symbol => typeof value === 'symbol';\n\nexport interface SyncOptions {\n  agent?: string[];\n  yes?: boolean;\n  force?: boolean;\n}\n\n/**\n * Shortens a path for display: replaces homedir with ~ and cwd with .\n */\nfunction shortenPath(fullPath: string, cwd: string): string {\n  const home = homedir();\n  if (fullPath === home || fullPath.startsWith(home + sep)) {\n    return '~' + fullPath.slice(home.length);\n  }\n  if (fullPath === cwd || fullPath.startsWith(cwd + sep)) {\n    return '.' + fullPath.slice(cwd.length);\n  }\n  return fullPath;\n}\n\n/**\n * Crawl node_modules for SKILL.md files.\n * Searches both top-level packages and scoped packages (@org/pkg).\n * Returns discovered skills with their source package name.\n */\nasync function discoverNodeModuleSkills(\n  cwd: string\n): Promise<Array<Skill & { packageName: string }>> {\n  const nodeModulesDir = join(cwd, 'node_modules');\n  const skills: Array<Skill & { packageName: string }> = [];\n\n  let topNames: string[];\n  try {\n    topNames = await readdir(nodeModulesDir);\n  } catch {\n    return skills;\n  }\n\n  const processPackageDir = async (pkgDir: string, packageName: string) => {\n    // Check for SKILL.md at package root\n    const rootSkill = await parseSkillMd(join(pkgDir, 'SKILL.md'));\n    if (rootSkill) {\n      skills.push({ ...rootSkill, packageName });\n      return;\n    }\n\n    // Check common skill locations within the package\n    const searchDirs = [pkgDir, join(pkgDir, 'skills'), join(pkgDir, '.agents', 'skills')];\n\n    for (const searchDir of searchDirs) {\n      try {\n        const entries = await readdir(searchDir);\n        for (const name of entries) {\n          const skillDir = join(searchDir, name);\n          try {\n            const s = await stat(skillDir);\n            if (!s.isDirectory()) continue;\n          } catch {\n            continue;\n          }\n          const skill = await parseSkillMd(join(skillDir, 'SKILL.md'));\n          if (skill) {\n            skills.push({ ...skill, packageName });\n          }\n        }\n      } catch {\n        // Directory doesn't exist\n      }\n    }\n  };\n\n  await Promise.all(\n    topNames.map(async (name) => {\n      if (name.startsWith('.')) return;\n\n      const fullPath = join(nodeModulesDir, name);\n      try {\n        const s = await stat(fullPath);\n        if (!s.isDirectory()) return;\n      } catch {\n        return;\n      }\n\n      if (name.startsWith('@')) {\n        // Scoped package: read @org/* entries\n        try {\n          const scopeNames = await readdir(fullPath);\n          await Promise.all(\n            scopeNames.map(async (scopedName) => {\n              const scopedPath = join(fullPath, scopedName);\n              try {\n                const s = await stat(scopedPath);\n                if (!s.isDirectory()) return;\n              } catch {\n                return;\n              }\n              await processPackageDir(scopedPath, `${name}/${scopedName}`);\n            })\n          );\n        } catch {\n          // Scope directory not readable\n        }\n      } else {\n        await processPackageDir(fullPath, name);\n      }\n    })\n  );\n\n  return skills;\n}\n\nexport async function runSync(args: string[], options: SyncOptions = {}): Promise<void> {\n  const cwd = process.cwd();\n\n  console.log();\n  p.intro(pc.bgCyan(pc.black(' skills experimental_sync ')));\n\n  const spinner = p.spinner();\n\n  // 1. Discover skills from node_modules\n  spinner.start('Scanning node_modules for skills...');\n  const discoveredSkills = await discoverNodeModuleSkills(cwd);\n\n  if (discoveredSkills.length === 0) {\n    spinner.stop(pc.yellow('No skills found'));\n    p.outro(pc.dim('No SKILL.md files found in node_modules.'));\n    return;\n  }\n\n  spinner.stop(\n    `Found ${pc.green(String(discoveredSkills.length))} skill${discoveredSkills.length > 1 ? 's' : ''} in node_modules`\n  );\n\n  // Show discovered skills\n  for (const skill of discoveredSkills) {\n    p.log.info(`${pc.cyan(skill.name)} ${pc.dim(`from ${skill.packageName}`)}`);\n    if (skill.description) {\n      p.log.message(pc.dim(`  ${skill.description}`));\n    }\n  }\n\n  // 2. Check which skills are already up-to-date via local lock\n  const localLock = await readLocalLock(cwd);\n  const toInstall: Array<Skill & { packageName: string }> = [];\n  const upToDate: string[] = [];\n\n  if (options.force) {\n    toInstall.push(...discoveredSkills);\n    p.log.info(pc.dim('Force mode: reinstalling all skills'));\n  } else {\n    for (const skill of discoveredSkills) {\n      const existingEntry = localLock.skills[skill.name];\n      if (existingEntry) {\n        // Compute current hash and compare\n        const currentHash = await computeSkillFolderHash(skill.path);\n        if (currentHash === existingEntry.computedHash) {\n          upToDate.push(skill.name);\n          continue;\n        }\n      }\n      toInstall.push(skill);\n    }\n\n    if (upToDate.length > 0) {\n      p.log.info(\n        pc.dim(`${upToDate.length} skill${upToDate.length !== 1 ? 's' : ''} already up to date`)\n      );\n    }\n\n    if (toInstall.length === 0) {\n      console.log();\n      p.outro(pc.green('All skills are up to date.'));\n      return;\n    }\n  }\n\n  p.log.info(`${toInstall.length} skill${toInstall.length !== 1 ? 's' : ''} to install/update`);\n\n  // 3. Select agents\n  let targetAgents: AgentType[];\n  const validAgents = Object.keys(agents);\n  const universalAgents = getUniversalAgents();\n\n  if (options.agent?.includes('*')) {\n    targetAgents = validAgents as AgentType[];\n    p.log.info(`Installing to all ${targetAgents.length} agents`);\n  } else if (options.agent && options.agent.length > 0) {\n    const invalidAgents = options.agent.filter((a) => !validAgents.includes(a));\n    if (invalidAgents.length > 0) {\n      p.log.error(`Invalid agents: ${invalidAgents.join(', ')}`);\n      p.log.info(`Valid agents: ${validAgents.join(', ')}`);\n      process.exit(1);\n    }\n    targetAgents = options.agent as AgentType[];\n  } else {\n    spinner.start('Loading agents...');\n    const installedAgents = await detectInstalledAgents();\n    const totalAgents = Object.keys(agents).length;\n    spinner.stop(`${totalAgents} agents`);\n\n    if (installedAgents.length === 0) {\n      if (options.yes) {\n        targetAgents = universalAgents;\n        p.log.info('Installing to universal agents');\n      } else {\n        const otherAgents = getNonUniversalAgents();\n\n        const otherChoices = otherAgents.map((a) => ({\n          value: a,\n          label: agents[a].displayName,\n          hint: agents[a].skillsDir,\n        }));\n\n        const selected = await searchMultiselect({\n          message: 'Which agents do you want to install to?',\n          items: otherChoices,\n          initialSelected: [],\n          lockedSection: {\n            title: 'Universal (.agents/skills)',\n            items: universalAgents.map((a) => ({\n              value: a,\n              label: agents[a].displayName,\n            })),\n          },\n        });\n\n        if (isCancelled(selected)) {\n          p.cancel('Sync cancelled');\n          process.exit(0);\n        }\n\n        targetAgents = selected as AgentType[];\n      }\n    } else if (installedAgents.length === 1 || options.yes) {\n      // Ensure universal agents are included\n      targetAgents = [...installedAgents];\n      for (const ua of universalAgents) {\n        if (!targetAgents.includes(ua)) {\n          targetAgents.push(ua);\n        }\n      }\n    } else {\n      const otherAgents = getNonUniversalAgents().filter((a) => installedAgents.includes(a));\n\n      const otherChoices = otherAgents.map((a) => ({\n        value: a,\n        label: agents[a].displayName,\n        hint: agents[a].skillsDir,\n      }));\n\n      const selected = await searchMultiselect({\n        message: 'Which agents do you want to install to?',\n        items: otherChoices,\n        initialSelected: installedAgents.filter((a) => !universalAgents.includes(a)),\n        lockedSection: {\n          title: 'Universal (.agents/skills)',\n          items: universalAgents.map((a) => ({\n            value: a,\n            label: agents[a].displayName,\n          })),\n        },\n      });\n\n      if (isCancelled(selected)) {\n        p.cancel('Sync cancelled');\n        process.exit(0);\n      }\n\n      targetAgents = selected as AgentType[];\n    }\n  }\n\n  // 4. Build summary\n  const summaryLines: string[] = [];\n  for (const skill of toInstall) {\n    const canonicalPath = getCanonicalPath(skill.name, { global: false });\n    const shortCanonical = shortenPath(canonicalPath, cwd);\n    summaryLines.push(`${pc.cyan(skill.name)} ${pc.dim(`← ${skill.packageName}`)}`);\n    summaryLines.push(`  ${pc.dim(shortCanonical)}`);\n  }\n\n  console.log();\n  p.note(summaryLines.join('\\n'), 'Sync Summary');\n\n  if (!options.yes) {\n    const confirmed = await p.confirm({ message: 'Proceed with sync?' });\n\n    if (p.isCancel(confirmed) || !confirmed) {\n      p.cancel('Sync cancelled');\n      process.exit(0);\n    }\n  }\n\n  // 5. Install skills (always project-scoped, always symlink)\n  spinner.start('Syncing skills...');\n\n  const results: Array<{\n    skill: string;\n    packageName: string;\n    agent: string;\n    success: boolean;\n    path: string;\n    canonicalPath?: string;\n    error?: string;\n  }> = [];\n\n  for (const skill of toInstall) {\n    for (const agent of targetAgents) {\n      const result = await installSkillForAgent(skill, agent, {\n        global: false,\n        cwd,\n        mode: 'symlink',\n      });\n      results.push({\n        skill: skill.name,\n        packageName: skill.packageName,\n        agent: agents[agent].displayName,\n        success: result.success,\n        path: result.path,\n        canonicalPath: result.canonicalPath,\n        error: result.error,\n      });\n    }\n  }\n\n  spinner.stop('Sync complete');\n\n  // 6. Update local lock file\n  const successful = results.filter((r) => r.success);\n  const failed = results.filter((r) => !r.success);\n  const successfulSkillNames = new Set(successful.map((r) => r.skill));\n\n  for (const skill of toInstall) {\n    if (successfulSkillNames.has(skill.name)) {\n      try {\n        const computedHash = await computeSkillFolderHash(skill.path);\n        await addSkillToLocalLock(\n          skill.name,\n          {\n            source: skill.packageName,\n            sourceType: 'node_modules',\n            computedHash,\n          },\n          cwd\n        );\n      } catch {\n        // Don't fail sync if lock file update fails\n      }\n    }\n  }\n\n  // 7. Display results\n  console.log();\n\n  if (successful.length > 0) {\n    const bySkill = new Map<string, typeof results>();\n    for (const r of successful) {\n      const skillResults = bySkill.get(r.skill) || [];\n      skillResults.push(r);\n      bySkill.set(r.skill, skillResults);\n    }\n\n    const resultLines: string[] = [];\n    for (const [skillName, skillResults] of bySkill) {\n      const firstResult = skillResults[0]!;\n      const pkg = toInstall.find((s) => s.name === skillName)?.packageName;\n      if (firstResult.canonicalPath) {\n        const shortPath = shortenPath(firstResult.canonicalPath, cwd);\n        resultLines.push(`${pc.green('✓')} ${skillName} ${pc.dim(`← ${pkg}`)}`);\n        resultLines.push(`  ${pc.dim(shortPath)}`);\n      } else {\n        resultLines.push(`${pc.green('✓')} ${skillName} ${pc.dim(`← ${pkg}`)}`);\n      }\n    }\n\n    const skillCount = bySkill.size;\n    const title = pc.green(`Synced ${skillCount} skill${skillCount !== 1 ? 's' : ''}`);\n    p.note(resultLines.join('\\n'), title);\n  }\n\n  if (failed.length > 0) {\n    console.log();\n    p.log.error(pc.red(`Failed to install ${failed.length}`));\n    for (const r of failed) {\n      p.log.message(`  ${pc.red('✗')} ${r.skill} → ${r.agent}: ${pc.dim(r.error)}`);\n    }\n  }\n\n  // Track telemetry\n  track({\n    event: 'experimental_sync',\n    skillCount: String(toInstall.length),\n    successCount: String(successfulSkillNames.size),\n    agents: targetAgents.join(','),\n  });\n\n  console.log();\n  p.outro(\n    pc.green('Done!') + pc.dim('  Review skills before use; they run with full agent permissions.')\n  );\n}\n\nexport function parseSyncOptions(args: string[]): { options: SyncOptions } {\n  const options: SyncOptions = {};\n\n  for (let i = 0; i < args.length; i++) {\n    const arg = args[i];\n\n    if (arg === '-y' || arg === '--yes') {\n      options.yes = true;\n    } else if (arg === '-f' || arg === '--force') {\n      options.force = true;\n    } else if (arg === '-a' || arg === '--agent') {\n      options.agent = options.agent || [];\n      i++;\n      let nextArg = args[i];\n      while (i < args.length && nextArg && !nextArg.startsWith('-')) {\n        options.agent.push(nextArg);\n        i++;\n        nextArg = args[i];\n      }\n      i--;\n    }\n  }\n\n  return { options };\n}\n"
  },
  {
    "path": "src/telemetry.ts",
    "content": "const TELEMETRY_URL = 'https://add-skill.vercel.sh/t';\nconst AUDIT_URL = 'https://add-skill.vercel.sh/audit';\n\ninterface InstallTelemetryData {\n  event: 'install';\n  source: string;\n  skills: string;\n  agents: string;\n  global?: '1';\n  skillFiles?: string; // JSON stringified { skillName: relativePath }\n  /**\n   * Source type for different hosts:\n   * - 'github': GitHub repository (default, uses raw.githubusercontent.com)\n   * - 'raw': Direct URL to SKILL.md (generic raw URL)\n   * - Provider IDs like 'mintlify', 'huggingface', etc.\n   */\n  sourceType?: string;\n}\n\ninterface RemoveTelemetryData {\n  event: 'remove';\n  source?: string;\n  skills: string;\n  agents: string;\n  global?: '1';\n  sourceType?: string;\n}\n\ninterface CheckTelemetryData {\n  event: 'check';\n  skillCount: string;\n  updatesAvailable: string;\n}\n\ninterface UpdateTelemetryData {\n  event: 'update';\n  skillCount: string;\n  successCount: string;\n  failCount: string;\n}\n\ninterface FindTelemetryData {\n  event: 'find';\n  query: string;\n  resultCount: string;\n  interactive?: '1';\n}\n\ninterface SyncTelemetryData {\n  event: 'experimental_sync';\n  skillCount: string;\n  successCount: string;\n  agents: string;\n}\n\ntype TelemetryData =\n  | InstallTelemetryData\n  | RemoveTelemetryData\n  | CheckTelemetryData\n  | UpdateTelemetryData\n  | FindTelemetryData\n  | SyncTelemetryData;\n\nlet cliVersion: string | null = null;\n\nfunction isCI(): boolean {\n  return !!(\n    process.env.CI ||\n    process.env.GITHUB_ACTIONS ||\n    process.env.GITLAB_CI ||\n    process.env.CIRCLECI ||\n    process.env.TRAVIS ||\n    process.env.BUILDKITE ||\n    process.env.JENKINS_URL ||\n    process.env.TEAMCITY_VERSION\n  );\n}\n\nfunction isEnabled(): boolean {\n  return !process.env.DISABLE_TELEMETRY && !process.env.DO_NOT_TRACK;\n}\n\nexport function setVersion(version: string): void {\n  cliVersion = version;\n}\n\n// ─── Security audit data ───\n\nexport interface PartnerAudit {\n  risk: 'safe' | 'low' | 'medium' | 'high' | 'critical' | 'unknown';\n  alerts?: number;\n  score?: number;\n  analyzedAt: string;\n}\n\nexport type SkillAuditData = Record<string, PartnerAudit>;\nexport type AuditResponse = Record<string, SkillAuditData>;\n\n/**\n * Fetch security audit results for skills from the audit API.\n * Returns null on any error or timeout — never blocks installation.\n */\nexport async function fetchAuditData(\n  source: string,\n  skillSlugs: string[],\n  timeoutMs = 3000\n): Promise<AuditResponse | null> {\n  if (skillSlugs.length === 0) return null;\n\n  try {\n    const params = new URLSearchParams({\n      source,\n      skills: skillSlugs.join(','),\n    });\n\n    const controller = new AbortController();\n    const timeout = setTimeout(() => controller.abort(), timeoutMs);\n\n    const response = await fetch(`${AUDIT_URL}?${params.toString()}`, {\n      signal: controller.signal,\n    });\n    clearTimeout(timeout);\n\n    if (!response.ok) return null;\n    return (await response.json()) as AuditResponse;\n  } catch {\n    return null;\n  }\n}\n\nexport function track(data: TelemetryData): void {\n  if (!isEnabled()) return;\n\n  try {\n    const params = new URLSearchParams();\n\n    // Add version\n    if (cliVersion) {\n      params.set('v', cliVersion);\n    }\n\n    // Add CI flag if running in CI\n    if (isCI()) {\n      params.set('ci', '1');\n    }\n\n    // Add event data\n    for (const [key, value] of Object.entries(data)) {\n      if (value !== undefined && value !== null) {\n        params.set(key, String(value));\n      }\n    }\n\n    // Fire and forget - don't await, silently ignore errors\n    fetch(`${TELEMETRY_URL}?${params.toString()}`).catch(() => {});\n  } catch {\n    // Silently fail - telemetry should never break the CLI\n  }\n}\n"
  },
  {
    "path": "src/test-utils.ts",
    "content": "import { execSync } from 'child_process';\nimport { join } from 'path';\n\n// const PROJECT_ROOT = join(import.meta.dirname, '..');\nconst CLI_PATH = join(import.meta.dirname, 'cli.ts');\n\nexport function stripAnsi(str: string): string {\n  return str.replace(/\\x1b\\[[0-9;]*m/g, '');\n}\n\nexport function stripLogo(str: string): string {\n  return str\n    .split('\\n')\n    .filter((line) => !line.includes('███') && !line.includes('╔') && !line.includes('╚'))\n    .join('\\n')\n    .replace(/^\\n+/, '');\n}\n\nexport function hasLogo(str: string): boolean {\n  return str.includes('███') || str.includes('╔') || str.includes('╚');\n}\n\nexport function runCli(\n  args: string[],\n  cwd?: string,\n  env?: Record<string, string>,\n  timeout?: number\n): { stdout: string; stderr: string; exitCode: number } {\n  try {\n    const output = execSync(`node \"${CLI_PATH}\" ${args.join(' ')}`, {\n      encoding: 'utf-8',\n      cwd,\n      stdio: ['pipe', 'pipe', 'pipe'],\n      env: env ? { ...process.env, ...env } : undefined,\n      timeout: timeout ?? 30000,\n    });\n    return { stdout: stripAnsi(output), stderr: '', exitCode: 0 };\n  } catch (error: any) {\n    return {\n      stdout: stripAnsi(error.stdout || ''),\n      stderr: stripAnsi(error.stderr || ''),\n      exitCode: error.status || 1,\n    };\n  }\n}\n\nexport function runCliOutput(args: string[], cwd?: string): string {\n  const result = runCli(args, cwd);\n  return result.stdout || result.stderr;\n}\n\nexport function runCliWithInput(\n  args: string[],\n  input: string,\n  cwd?: string\n): { stdout: string; stderr: string; exitCode: number } {\n  try {\n    const output = execSync(`node \"${CLI_PATH}\" ${args.join(' ')}`, {\n      encoding: 'utf-8',\n      cwd,\n      input: input + '\\n',\n      stdio: ['pipe', 'pipe', 'pipe'],\n    });\n    return { stdout: stripAnsi(output), stderr: '', exitCode: 0 };\n  } catch (error: any) {\n    return {\n      stdout: stripAnsi(error.stdout || ''),\n      stderr: stripAnsi(error.stderr || ''),\n      exitCode: error.status || 1,\n    };\n  }\n}\n"
  },
  {
    "path": "src/types.ts",
    "content": "export type AgentType =\n  | 'amp'\n  | 'antigravity'\n  | 'augment'\n  | 'claude-code'\n  | 'openclaw'\n  | 'cline'\n  | 'codebuddy'\n  | 'codex'\n  | 'command-code'\n  | 'continue'\n  | 'cortex'\n  | 'crush'\n  | 'cursor'\n  | 'deepagents'\n  | 'droid'\n  | 'gemini-cli'\n  | 'github-copilot'\n  | 'goose'\n  | 'iflow-cli'\n  | 'junie'\n  | 'kilo'\n  | 'kimi-cli'\n  | 'kiro-cli'\n  | 'kode'\n  | 'mcpjam'\n  | 'mistral-vibe'\n  | 'mux'\n  | 'neovate'\n  | 'opencode'\n  | 'openhands'\n  | 'pi'\n  | 'qoder'\n  | 'qwen-code'\n  | 'replit'\n  | 'roo'\n  | 'trae'\n  | 'trae-cn'\n  | 'warp'\n  | 'windsurf'\n  | 'zencoder'\n  | 'pochi'\n  | 'adal'\n  | 'universal';\n\nexport interface Skill {\n  name: string;\n  description: string;\n  path: string;\n  /** Raw SKILL.md content for hashing */\n  rawContent?: string;\n  /** Name of the plugin this skill belongs to (if any) */\n  pluginName?: string;\n  metadata?: Record<string, unknown>;\n}\n\nexport interface AgentConfig {\n  name: string;\n  displayName: string;\n  skillsDir: string;\n  /** Global skills directory. Set to undefined if the agent doesn't support global installation. */\n  globalSkillsDir: string | undefined;\n  detectInstalled: () => Promise<boolean>;\n  /** Whether to show this agent in the universal agents list. Defaults to true. */\n  showInUniversalList?: boolean;\n}\n\nexport interface ParsedSource {\n  type: 'github' | 'gitlab' | 'git' | 'local' | 'well-known';\n  url: string;\n  subpath?: string;\n  localPath?: string;\n  ref?: string;\n  /** Skill name extracted from @skill syntax (e.g., owner/repo@skill-name) */\n  skillFilter?: string;\n}\n\n/**\n * Represents a skill fetched from a remote host provider.\n */\nexport interface RemoteSkill {\n  /** Display name of the skill (from frontmatter) */\n  name: string;\n  /** Description of the skill (from frontmatter) */\n  description: string;\n  /** Full markdown content including frontmatter */\n  content: string;\n  /** The identifier used for installation directory name */\n  installName: string;\n  /** The original source URL */\n  sourceUrl: string;\n  /** The provider that fetched this skill */\n  providerId: string;\n  /** Source identifier for telemetry (e.g., \"mintlify.com\") */\n  sourceIdentifier: string;\n  /** Any additional metadata from frontmatter */\n  metadata?: Record<string, unknown>;\n}\n"
  },
  {
    "path": "tests/cross-platform-paths.test.ts",
    "content": "/**\n * Cross-platform path handling tests.\n *\n * These tests verify that path operations work correctly on both Unix and Windows.\n * They test the actual logic used in the codebase for path manipulation.\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { sep } from 'path';\n\n/**\n * Simulates the shortenPath function from add.ts (cross-platform version)\n */\nfunction shortenPath(fullPath: string, cwd: string, home: string, pathSep: string): string {\n  // Ensure we match complete path segments by checking for separator after the prefix\n  if (fullPath === home || fullPath.startsWith(home + pathSep)) {\n    return '~' + fullPath.slice(home.length);\n  }\n  if (fullPath === cwd || fullPath.startsWith(cwd + pathSep)) {\n    return '.' + fullPath.slice(cwd.length);\n  }\n  return fullPath;\n}\n\n/**\n * Simulates the path validation from wellknown.ts\n * Note: The actual validation uses simple `includes('..')` which will match\n * filenames like '...dots'. This is intentional - it's stricter security.\n */\nfunction isValidSkillFile(file: string): boolean {\n  if (typeof file !== 'string') return false;\n  // Files must not start with / or \\ or contain .. (path traversal prevention)\n  if (file.startsWith('/') || file.startsWith('\\\\') || file.includes('..')) return false;\n  return true;\n}\n\n/**\n * Simulates the SKILL.md path normalization from skill-lock.ts\n */\nfunction normalizeSkillPath(skillPath: string): string {\n  let folderPath = skillPath;\n\n  // Handle both forward and backslash separators for cross-platform compatibility\n  if (folderPath.endsWith('/SKILL.md') || folderPath.endsWith('\\\\SKILL.md')) {\n    folderPath = folderPath.slice(0, -9);\n  } else if (folderPath.endsWith('SKILL.md')) {\n    folderPath = folderPath.slice(0, -8);\n  }\n\n  if (folderPath.endsWith('/') || folderPath.endsWith('\\\\')) {\n    folderPath = folderPath.slice(0, -1);\n  }\n\n  // Convert to forward slashes for GitHub API\n  return folderPath.split('\\\\').join('/');\n}\n\ndescribe('shortenPath (Unix)', () => {\n  const pathSep = '/';\n  const home = '/Users/test';\n  const cwd = '/Users/test/projects/myproject';\n\n  it('replaces home directory with ~', () => {\n    const result = shortenPath('/Users/test/documents/file.txt', cwd, home, pathSep);\n    expect(result).toBe('~/documents/file.txt');\n  });\n\n  it('prefers home over cwd when cwd is under home', () => {\n    // When cwd is under home, home is checked first and matches\n    // This is the expected behavior - displays as ~/projects/myproject/...\n    const result = shortenPath('/Users/test/projects/myproject/src/file.ts', cwd, home, pathSep);\n    expect(result).toBe('~/projects/myproject/src/file.ts');\n  });\n\n  it('replaces cwd with . when cwd is not under home', () => {\n    // When cwd is outside home, cwd can match\n    const outsideHome = '/var/www/myproject';\n    const result = shortenPath('/var/www/myproject/src/file.ts', outsideHome, home, pathSep);\n    expect(result).toBe('./src/file.ts');\n  });\n\n  it('returns path unchanged if not under home or cwd', () => {\n    const result = shortenPath('/var/log/system.log', cwd, home, pathSep);\n    expect(result).toBe('/var/log/system.log');\n  });\n\n  it('handles exact home directory match', () => {\n    const result = shortenPath('/Users/test', cwd, home, pathSep);\n    expect(result).toBe('~');\n  });\n\n  it('handles exact cwd match when cwd is under home', () => {\n    // Since cwd is under home, home matches first\n    const result = shortenPath('/Users/test/projects/myproject', cwd, home, pathSep);\n    expect(result).toBe('~/projects/myproject');\n  });\n\n  it('handles exact cwd match when cwd is outside home', () => {\n    const outsideHome = '/var/www/myproject';\n    const result = shortenPath('/var/www/myproject', outsideHome, home, pathSep);\n    expect(result).toBe('.');\n  });\n\n  it('does not match partial directory names (home)', () => {\n    // /Users/tester should NOT match /Users/test\n    const result = shortenPath('/Users/tester/file.txt', cwd, home, pathSep);\n    expect(result).toBe('/Users/tester/file.txt');\n  });\n\n  it('does not match partial directory names (cwd)', () => {\n    // /Users/test/projects/myproject2 should NOT match /Users/test/projects/myproject\n    const result = shortenPath('/Users/test/projects/myproject2/file.txt', cwd, home, pathSep);\n    // It should still match home though\n    expect(result).toBe('~/projects/myproject2/file.txt');\n  });\n});\n\ndescribe('shortenPath (Windows)', () => {\n  const pathSep = '\\\\';\n  const home = 'C:\\\\Users\\\\test';\n  const cwd = 'C:\\\\Users\\\\test\\\\projects\\\\myproject';\n\n  it('replaces home directory with ~', () => {\n    const result = shortenPath('C:\\\\Users\\\\test\\\\documents\\\\file.txt', cwd, home, pathSep);\n    expect(result).toBe('~\\\\documents\\\\file.txt');\n  });\n\n  it('prefers home over cwd when cwd is under home', () => {\n    // When cwd is under home, home is checked first and matches\n    const result = shortenPath(\n      'C:\\\\Users\\\\test\\\\projects\\\\myproject\\\\src\\\\file.ts',\n      cwd,\n      home,\n      pathSep\n    );\n    expect(result).toBe('~\\\\projects\\\\myproject\\\\src\\\\file.ts');\n  });\n\n  it('replaces cwd with . when cwd is not under home', () => {\n    // When cwd is outside home, cwd can match\n    const outsideHome = 'D:\\\\projects\\\\myproject';\n    const result = shortenPath('D:\\\\projects\\\\myproject\\\\src\\\\file.ts', outsideHome, home, pathSep);\n    expect(result).toBe('.\\\\src\\\\file.ts');\n  });\n\n  it('returns path unchanged if not under home or cwd', () => {\n    const result = shortenPath('D:\\\\logs\\\\system.log', cwd, home, pathSep);\n    expect(result).toBe('D:\\\\logs\\\\system.log');\n  });\n\n  it('handles exact home directory match', () => {\n    const result = shortenPath('C:\\\\Users\\\\test', cwd, home, pathSep);\n    expect(result).toBe('~');\n  });\n\n  it('handles exact cwd match when cwd is under home', () => {\n    // Since cwd is under home, home matches first\n    const result = shortenPath('C:\\\\Users\\\\test\\\\projects\\\\myproject', cwd, home, pathSep);\n    expect(result).toBe('~\\\\projects\\\\myproject');\n  });\n\n  it('handles exact cwd match when cwd is outside home', () => {\n    const outsideHome = 'D:\\\\projects\\\\myproject';\n    const result = shortenPath('D:\\\\projects\\\\myproject', outsideHome, home, pathSep);\n    expect(result).toBe('.');\n  });\n\n  it('does not match partial directory names (home)', () => {\n    // C:\\Users\\tester should NOT match C:\\Users\\test\n    const result = shortenPath('C:\\\\Users\\\\tester\\\\file.txt', cwd, home, pathSep);\n    expect(result).toBe('C:\\\\Users\\\\tester\\\\file.txt');\n  });\n});\n\ndescribe('isValidSkillFile', () => {\n  it('accepts valid relative paths', () => {\n    expect(isValidSkillFile('SKILL.md')).toBe(true);\n    expect(isValidSkillFile('src/helper.ts')).toBe(true);\n    expect(isValidSkillFile('assets/logo.png')).toBe(true);\n  });\n\n  it('rejects paths starting with forward slash', () => {\n    expect(isValidSkillFile('/etc/passwd')).toBe(false);\n    expect(isValidSkillFile('/SKILL.md')).toBe(false);\n  });\n\n  it('rejects paths starting with backslash', () => {\n    expect(isValidSkillFile('\\\\Windows\\\\System32')).toBe(false);\n    expect(isValidSkillFile('\\\\SKILL.md')).toBe(false);\n  });\n\n  it('rejects paths with directory traversal', () => {\n    expect(isValidSkillFile('../../../etc/passwd')).toBe(false);\n    expect(isValidSkillFile('foo/../../../etc/passwd')).toBe(false);\n    expect(isValidSkillFile('..\\\\..\\\\Windows\\\\System32')).toBe(false);\n  });\n\n  it('allows dots in filenames (not traversal)', () => {\n    expect(isValidSkillFile('file.name.txt')).toBe(true);\n    expect(isValidSkillFile('.hidden')).toBe(true);\n    // Note: '...dots' contains '..' which is rejected for security\n    expect(isValidSkillFile('.config')).toBe(true);\n  });\n\n  it('rejects filenames containing .. (strict security)', () => {\n    // Even innocent-looking filenames with .. are rejected for security\n    expect(isValidSkillFile('...dots')).toBe(false);\n    expect(isValidSkillFile('file..name')).toBe(false);\n  });\n});\n\ndescribe('normalizeSkillPath', () => {\n  it('removes /SKILL.md suffix (Unix)', () => {\n    const result = normalizeSkillPath('skills/my-skill/SKILL.md');\n    expect(result).toBe('skills/my-skill');\n  });\n\n  it('removes \\\\SKILL.md suffix (Windows)', () => {\n    const result = normalizeSkillPath('skills\\\\my-skill\\\\SKILL.md');\n    expect(result).toBe('skills/my-skill');\n  });\n\n  it('removes SKILL.md without path separator', () => {\n    const result = normalizeSkillPath('SKILL.md');\n    expect(result).toBe('');\n  });\n\n  it('removes trailing forward slash', () => {\n    const result = normalizeSkillPath('skills/my-skill/');\n    expect(result).toBe('skills/my-skill');\n  });\n\n  it('removes trailing backslash', () => {\n    const result = normalizeSkillPath('skills\\\\my-skill\\\\');\n    expect(result).toBe('skills/my-skill');\n  });\n\n  it('converts Windows paths to forward slashes', () => {\n    const result = normalizeSkillPath('skills\\\\.curated\\\\advanced-skill\\\\SKILL.md');\n    expect(result).toBe('skills/.curated/advanced-skill');\n  });\n\n  it('handles mixed separators', () => {\n    const result = normalizeSkillPath('skills/category\\\\my-skill/SKILL.md');\n    expect(result).toBe('skills/category/my-skill');\n  });\n\n  it('handles root-level skill', () => {\n    const result = normalizeSkillPath('/SKILL.md');\n    expect(result).toBe('');\n  });\n\n  it('handles deep nested paths (Windows)', () => {\n    const result = normalizeSkillPath('a\\\\b\\\\c\\\\d\\\\e\\\\SKILL.md');\n    expect(result).toBe('a/b/c/d/e');\n  });\n});\n\ndescribe('platform detection', () => {\n  it('sep is correct for current platform', () => {\n    // This will be '/' on Unix/Mac and '\\\\' on Windows\n    expect(['/', '\\\\']).toContain(sep);\n  });\n});\n"
  },
  {
    "path": "tests/dist.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { execSync } from 'node:child_process';\nimport { join } from 'node:path';\n\nconst rootDir = join(import.meta.dirname, '..');\n\ndescribe('dist build', () => {\n  it('builds and runs without errors', { timeout: 30000 }, () => {\n    // Build the project\n    execSync('pnpm build', { cwd: rootDir, stdio: 'pipe' });\n\n    // Run the CLI - should exit cleanly with help output\n    const result = execSync('node dist/cli.mjs --help', {\n      cwd: rootDir,\n      stdio: 'pipe',\n      encoding: 'utf-8',\n    });\n\n    expect(result).toContain('skills');\n  });\n});\n"
  },
  {
    "path": "tests/full-depth-discovery.test.ts",
    "content": "/**\n * Tests for the --full-depth option in skill discovery.\n *\n * When a repository has both a root SKILL.md and nested skills in subdirectories,\n * the --full-depth flag allows discovering all skills instead of just the root one.\n */\n\nimport { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { mkdirSync, rmSync, writeFileSync } from 'fs';\nimport { join } from 'path';\nimport { tmpdir } from 'os';\nimport { discoverSkills } from '../src/skills.ts';\n\ndescribe('discoverSkills with fullDepth option', () => {\n  let testDir: string;\n\n  beforeEach(() => {\n    testDir = join(tmpdir(), `skills-full-depth-test-${Date.now()}`);\n    mkdirSync(testDir, { recursive: true });\n  });\n\n  afterEach(() => {\n    rmSync(testDir, { recursive: true, force: true });\n  });\n\n  it('should only return root skill when fullDepth is false', async () => {\n    // Create root SKILL.md\n    writeFileSync(\n      join(testDir, 'SKILL.md'),\n      `---\nname: root-skill\ndescription: Root level skill\n---\n\n# Root Skill\n`\n    );\n\n    // Create nested skill in skills/ directory\n    mkdirSync(join(testDir, 'skills', 'nested-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills', 'nested-skill', 'SKILL.md'),\n      `---\nname: nested-skill\ndescription: Nested skill\n---\n\n# Nested Skill\n`\n    );\n\n    const skills = await discoverSkills(testDir, undefined, { fullDepth: false });\n\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('root-skill');\n  });\n\n  it('should return all skills when fullDepth is true', async () => {\n    // Create root SKILL.md\n    writeFileSync(\n      join(testDir, 'SKILL.md'),\n      `---\nname: root-skill\ndescription: Root level skill\n---\n\n# Root Skill\n`\n    );\n\n    // Create nested skills in skills/ directory\n    mkdirSync(join(testDir, 'skills', 'nested-skill-1'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills', 'nested-skill-1', 'SKILL.md'),\n      `---\nname: nested-skill-1\ndescription: Nested skill 1\n---\n\n# Nested Skill 1\n`\n    );\n\n    mkdirSync(join(testDir, 'skills', 'nested-skill-2'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills', 'nested-skill-2', 'SKILL.md'),\n      `---\nname: nested-skill-2\ndescription: Nested skill 2\n---\n\n# Nested Skill 2\n`\n    );\n\n    const skills = await discoverSkills(testDir, undefined, { fullDepth: true });\n\n    expect(skills).toHaveLength(3);\n    const names = skills.map((s) => s.name).sort();\n    expect(names).toEqual(['nested-skill-1', 'nested-skill-2', 'root-skill']);\n  });\n\n  it('should default to early return (fullDepth: false behavior) when no option is provided', async () => {\n    // Create root SKILL.md\n    writeFileSync(\n      join(testDir, 'SKILL.md'),\n      `---\nname: root-skill\ndescription: Root level skill\n---\n\n# Root Skill\n`\n    );\n\n    // Create nested skill\n    mkdirSync(join(testDir, 'skills', 'nested-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills', 'nested-skill', 'SKILL.md'),\n      `---\nname: nested-skill\ndescription: Nested skill\n---\n\n# Nested Skill\n`\n    );\n\n    // No options passed - should default to early return\n    const skills = await discoverSkills(testDir);\n\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('root-skill');\n  });\n\n  it('should still find all skills when no root SKILL.md exists (regardless of fullDepth)', async () => {\n    // No root SKILL.md, just nested skills\n\n    mkdirSync(join(testDir, 'skills', 'skill-1'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills', 'skill-1', 'SKILL.md'),\n      `---\nname: skill-1\ndescription: Skill 1\n---\n\n# Skill 1\n`\n    );\n\n    mkdirSync(join(testDir, 'skills', 'skill-2'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills', 'skill-2', 'SKILL.md'),\n      `---\nname: skill-2\ndescription: Skill 2\n---\n\n# Skill 2\n`\n    );\n\n    // Without fullDepth\n    const skillsDefault = await discoverSkills(testDir);\n    expect(skillsDefault).toHaveLength(2);\n\n    // With fullDepth\n    const skillsFullDepth = await discoverSkills(testDir, undefined, { fullDepth: true });\n    expect(skillsFullDepth).toHaveLength(2);\n  });\n\n  it('should not duplicate skills when root and nested have the same name', async () => {\n    // Edge case: root SKILL.md and a nested skill with the same name\n    writeFileSync(\n      join(testDir, 'SKILL.md'),\n      `---\nname: my-skill\ndescription: Root level skill\n---\n\n# Root Skill\n`\n    );\n\n    // Create nested skill with same name\n    mkdirSync(join(testDir, 'skills', 'my-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills', 'my-skill', 'SKILL.md'),\n      `---\nname: my-skill\ndescription: Nested skill with same name\n---\n\n# Nested Skill\n`\n    );\n\n    const skills = await discoverSkills(testDir, undefined, { fullDepth: true });\n\n    // Should only have one skill (deduplication by name)\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('my-skill');\n  });\n});\n"
  },
  {
    "path": "tests/installer-symlink.test.ts",
    "content": "/**\n * Regression tests for symlink installs when canonical and agent paths match.\n */\n\nimport { describe, it, expect } from 'vitest';\nimport {\n  mkdtemp,\n  mkdir,\n  rm,\n  writeFile,\n  lstat,\n  readFile,\n  readlink,\n  symlink,\n  readdir,\n} from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { tmpdir } from 'node:os';\nimport { installSkillForAgent } from '../src/installer.ts';\n\nasync function makeSkillSource(root: string, name: string): Promise<string> {\n  const dir = join(root, 'source-skill');\n  await mkdir(dir, { recursive: true });\n  const skillMd = `---\\nname: ${name}\\ndescription: test\\n---\\n`;\n  await writeFile(join(dir, 'SKILL.md'), skillMd, 'utf-8');\n  return dir;\n}\n\ndescribe('installer symlink regression', () => {\n  it('does not create self-loop when canonical and agent paths match', async () => {\n    const root = await mkdtemp(join(tmpdir(), 'add-skill-'));\n    const projectDir = join(root, 'project');\n    await mkdir(projectDir, { recursive: true });\n\n    const skillName = 'self-loop-skill';\n    const skillDir = await makeSkillSource(root, skillName);\n\n    try {\n      const result = await installSkillForAgent(\n        { name: skillName, description: 'test', path: skillDir },\n        'amp',\n        { cwd: projectDir, mode: 'symlink', global: false }\n      );\n\n      expect(result.success).toBe(true);\n      expect(result.symlinkFailed).toBeUndefined();\n\n      const installedPath = join(projectDir, '.agents/skills', skillName);\n      const stats = await lstat(installedPath);\n      expect(stats.isSymbolicLink()).toBe(false);\n      expect(stats.isDirectory()).toBe(true);\n\n      const contents = await readFile(join(installedPath, 'SKILL.md'), 'utf-8');\n      expect(contents).toContain(`name: ${skillName}`);\n    } finally {\n      await rm(root, { recursive: true, force: true });\n    }\n  });\n\n  it('cleans pre-existing self-loop symlink in canonical dir', async () => {\n    const root = await mkdtemp(join(tmpdir(), 'add-skill-'));\n    const projectDir = join(root, 'project');\n    await mkdir(projectDir, { recursive: true });\n\n    const skillName = 'self-loop-skill';\n    const skillDir = await makeSkillSource(root, skillName);\n    const canonicalDir = join(projectDir, '.agents/skills', skillName);\n\n    try {\n      await mkdir(join(projectDir, '.agents/skills'), { recursive: true });\n      await symlink(skillName, canonicalDir);\n      const preStats = await lstat(canonicalDir);\n      expect(preStats.isSymbolicLink()).toBe(true);\n\n      const result = await installSkillForAgent(\n        { name: skillName, description: 'test', path: skillDir },\n        'amp',\n        { cwd: projectDir, mode: 'symlink', global: false }\n      );\n\n      expect(result.success).toBe(true);\n\n      const postStats = await lstat(canonicalDir);\n      expect(postStats.isSymbolicLink()).toBe(false);\n      expect(postStats.isDirectory()).toBe(true);\n    } finally {\n      await rm(root, { recursive: true, force: true });\n    }\n  });\n\n  // Regression test for #293: when agent skills dir is a symlink to canonical dir\n  it('handles agent skills dir being a symlink to canonical dir', async () => {\n    const root = await mkdtemp(join(tmpdir(), 'add-skill-'));\n    const projectDir = join(root, 'project');\n    await mkdir(projectDir, { recursive: true });\n\n    const skillName = 'symlinked-dir-skill';\n    const skillDir = await makeSkillSource(root, skillName);\n\n    // Create canonical dir: .agents/skills\n    const canonicalBase = join(projectDir, '.agents', 'skills');\n    await mkdir(canonicalBase, { recursive: true });\n\n    // Create .claude directory and symlink .claude/skills -> .agents/skills\n    const claudeDir = join(projectDir, '.claude');\n    await mkdir(claudeDir, { recursive: true });\n    const claudeSkillsDir = join(claudeDir, 'skills');\n    await symlink(canonicalBase, claudeSkillsDir);\n\n    try {\n      // Install for claude-code, which has skillsDir: '.claude/skills'\n      const result = await installSkillForAgent(\n        { name: skillName, description: 'test', path: skillDir },\n        'claude-code',\n        { cwd: projectDir, mode: 'symlink', global: false }\n      );\n\n      expect(result.success).toBe(true);\n      expect(result.symlinkFailed).toBeUndefined();\n\n      // The skill should exist in the canonical location\n      const canonicalSkillDir = join(canonicalBase, skillName);\n      const stats = await lstat(canonicalSkillDir);\n      expect(stats.isDirectory()).toBe(true);\n\n      // It should NOT be a broken symlink - it should be a real directory\n      const contents = await readFile(join(canonicalSkillDir, 'SKILL.md'), 'utf-8');\n      expect(contents).toContain(`name: ${skillName}`);\n\n      // The skill should also be accessible via the symlinked path\n      const claudeSkillDir = join(claudeSkillsDir, skillName);\n      const claudeContents = await readFile(join(claudeSkillDir, 'SKILL.md'), 'utf-8');\n      expect(claudeContents).toContain(`name: ${skillName}`);\n\n      // There should be no broken symlinks in canonical dir\n      const canonicalEntries = await readdir(canonicalBase, { withFileTypes: true });\n      for (const entry of canonicalEntries) {\n        if (entry.name === skillName) {\n          const entryPath = join(canonicalBase, entry.name);\n          const entryStats = await lstat(entryPath);\n          // Should be a real directory, not a symlink\n          expect(entryStats.isDirectory()).toBe(true);\n        }\n      }\n    } finally {\n      await rm(root, { recursive: true, force: true });\n    }\n  });\n\n  // Regression test for #294: universal-only global install should not create agent-specific symlinks\n  it('does not create agent-specific symlinks for universal agents on global install', async () => {\n    const root = await mkdtemp(join(tmpdir(), 'add-skill-'));\n\n    const skillName = 'universal-only-skill';\n    const skillDir = await makeSkillSource(root, skillName);\n\n    // We test with 'github-copilot', a universal agent (skillsDir: '.agents/skills')\n    // whose globalSkillsDir is different from canonical (~/.copilot/skills vs ~/.agents/skills)\n    // For testing, we use a project-level install to avoid writing to actual home dir.\n    // But the bug only manifests with global: true.\n    // We can't safely test with global: true in unit tests (it would write to ~/.copilot/skills).\n    // Instead, we verify that the installSkillForAgent function returns the canonical path\n    // as both path and canonicalPath for universal agents with global install.\n\n    // For a project-level install, universal agents have matching canonical and agent dirs,\n    // so we just verify the function works correctly.\n    const projectDir = join(root, 'project');\n    await mkdir(projectDir, { recursive: true });\n\n    try {\n      const result = await installSkillForAgent(\n        { name: skillName, description: 'test', path: skillDir },\n        'github-copilot', // Universal agent\n        { cwd: projectDir, mode: 'symlink', global: false }\n      );\n\n      expect(result.success).toBe(true);\n      expect(result.symlinkFailed).toBeUndefined();\n\n      // For a project-level universal agent, canonical and agent dir are the same\n      // (.agents/skills), so no symlink should be created\n      const installedPath = join(projectDir, '.agents/skills', skillName);\n      const stats = await lstat(installedPath);\n      expect(stats.isDirectory()).toBe(true);\n      expect(stats.isSymbolicLink()).toBe(false);\n    } finally {\n      await rm(root, { recursive: true, force: true });\n    }\n  });\n});\n"
  },
  {
    "path": "tests/list-installed.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { mkdir, writeFile, rm } from 'fs/promises';\nimport { join } from 'path';\nimport { tmpdir } from 'os';\nimport { listInstalledSkills } from '../src/installer.ts';\nimport * as agentsModule from '../src/agents.ts';\n\ndescribe('listInstalledSkills', () => {\n  let testDir: string;\n\n  beforeEach(async () => {\n    testDir = join(tmpdir(), `add-skill-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);\n    await mkdir(testDir, { recursive: true });\n  });\n\n  afterEach(async () => {\n    await rm(testDir, { recursive: true, force: true });\n  });\n\n  // Helper to create a skill directory with SKILL.md\n  async function createSkillDir(\n    basePath: string,\n    skillName: string,\n    skillData: { name: string; description: string }\n  ): Promise<string> {\n    const skillDir = join(basePath, '.agents', 'skills', skillName);\n    await mkdir(skillDir, { recursive: true });\n    const skillMdContent = `---\nname: ${skillData.name}\ndescription: ${skillData.description}\n---\n\n# ${skillData.name}\n\n${skillData.description}\n`;\n    await writeFile(join(skillDir, 'SKILL.md'), skillMdContent);\n    return skillDir;\n  }\n\n  it('should return empty array for empty directory', async () => {\n    const skills = await listInstalledSkills({ global: false, cwd: testDir });\n    expect(skills).toEqual([]);\n  });\n\n  it('should find single skill in project directory', async () => {\n    await createSkillDir(testDir, 'test-skill', {\n      name: 'test-skill',\n      description: 'A test skill',\n    });\n\n    const skills = await listInstalledSkills({ global: false, cwd: testDir });\n    expect(skills).toHaveLength(1);\n    expect(skills[0]!.name).toBe('test-skill');\n    expect(skills[0]!.description).toBe('A test skill');\n    expect(skills[0]!.scope).toBe('project');\n  });\n\n  it('should find multiple skills', async () => {\n    await createSkillDir(testDir, 'skill-1', {\n      name: 'skill-1',\n      description: 'First skill',\n    });\n    await createSkillDir(testDir, 'skill-2', {\n      name: 'skill-2',\n      description: 'Second skill',\n    });\n\n    const skills = await listInstalledSkills({ global: false, cwd: testDir });\n    expect(skills).toHaveLength(2);\n    const skillNames = skills.map((s) => s.name).sort();\n    expect(skillNames).toEqual(['skill-1', 'skill-2']);\n  });\n\n  it('should ignore directories without SKILL.md', async () => {\n    await createSkillDir(testDir, 'valid-skill', {\n      name: 'valid-skill',\n      description: 'Valid skill',\n    });\n\n    // Create a directory without SKILL.md\n    const invalidDir = join(testDir, '.agents', 'skills', 'invalid-skill');\n    await mkdir(invalidDir, { recursive: true });\n    await writeFile(join(invalidDir, 'other-file.txt'), 'content');\n\n    const skills = await listInstalledSkills({ global: false, cwd: testDir });\n    expect(skills).toHaveLength(1);\n    expect(skills[0]!.name).toBe('valid-skill');\n  });\n\n  it('should handle invalid SKILL.md gracefully', async () => {\n    await createSkillDir(testDir, 'valid-skill', {\n      name: 'valid-skill',\n      description: 'Valid skill',\n    });\n\n    // Create a directory with invalid SKILL.md (missing name/description)\n    const invalidDir = join(testDir, '.agents', 'skills', 'invalid-skill');\n    await mkdir(invalidDir, { recursive: true });\n    await writeFile(join(invalidDir, 'SKILL.md'), '# Invalid\\nNo frontmatter');\n\n    const skills = await listInstalledSkills({ global: false, cwd: testDir });\n    expect(skills).toHaveLength(1);\n    expect(skills[0]!.name).toBe('valid-skill');\n  });\n\n  it('should filter by scope - project only', async () => {\n    await createSkillDir(testDir, 'project-skill', {\n      name: 'project-skill',\n      description: 'Project skill',\n    });\n\n    const skills = await listInstalledSkills({ global: false, cwd: testDir });\n    expect(skills).toHaveLength(1);\n    expect(skills[0]!.scope).toBe('project');\n  });\n\n  it('should handle global scope option', async () => {\n    // Test with global: true - verifies the function doesn't crash\n    // Note: This checks ~/.agents/skills, results depend on system state\n    const skills = await listInstalledSkills({\n      global: true,\n      cwd: testDir,\n    });\n    expect(Array.isArray(skills)).toBe(true);\n  });\n\n  it('should apply agent filter', async () => {\n    await createSkillDir(testDir, 'test-skill', {\n      name: 'test-skill',\n      description: 'Test skill',\n    });\n\n    // Filter by a specific agent (skill should still be returned)\n    const skills = await listInstalledSkills({\n      global: false,\n      cwd: testDir,\n      agentFilter: ['cursor'] as any,\n    });\n    expect(skills).toHaveLength(1);\n    expect(skills[0]!.name).toBe('test-skill');\n  });\n\n  // Issue #225 part 1: Only installed agents should be attributed\n  it('should only attribute skills to installed agents (issue #225)', async () => {\n    // Mock: only Amp is installed (not Kimi, even though they share .agents/skills)\n    vi.spyOn(agentsModule, 'detectInstalledAgents').mockResolvedValue(['amp']);\n\n    await createSkillDir(testDir, 'test-skill', {\n      name: 'test-skill',\n      description: 'Test skill',\n    });\n\n    const skills = await listInstalledSkills({ global: false, cwd: testDir });\n\n    expect(skills).toHaveLength(1);\n    // Should only show amp, not kimi-cli\n    expect(skills[0]!.agents).toContain('amp');\n    expect(skills[0]!.agents).not.toContain('kimi-cli');\n\n    vi.restoreAllMocks();\n  });\n\n  // Issue #225 part 2: Skills in agent-specific directories should be found\n  it('should find skills in agent-specific directories (issue #225)', async () => {\n    vi.spyOn(agentsModule, 'detectInstalledAgents').mockResolvedValue(['cursor']);\n\n    // Cursor now uses .agents/skills (universal directory)\n    const cursorSkillDir = join(testDir, '.agents', 'skills', 'cursor-skill');\n    await mkdir(cursorSkillDir, { recursive: true });\n    await writeFile(\n      join(cursorSkillDir, 'SKILL.md'),\n      `---\nname: cursor-skill\ndescription: A skill in cursor directory\n---\n\n# cursor-skill\n`\n    );\n\n    const skills = await listInstalledSkills({ global: false, cwd: testDir });\n\n    expect(skills).toHaveLength(1);\n    expect(skills[0]!.name).toBe('cursor-skill');\n    expect(skills[0]!.agents).toContain('cursor');\n\n    vi.restoreAllMocks();\n  });\n});\n"
  },
  {
    "path": "tests/local-lock.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { tmpdir } from 'node:os';\nimport {\n  readLocalLock,\n  writeLocalLock,\n  addSkillToLocalLock,\n  removeSkillFromLocalLock,\n  computeSkillFolderHash,\n  getLocalLockPath,\n} from '../src/local-lock.ts';\n\ndescribe('local-lock', () => {\n  describe('getLocalLockPath', () => {\n    it('returns skills-lock.json in given directory', () => {\n      const result = getLocalLockPath('/some/project');\n      expect(result).toBe(join('/some/project', 'skills-lock.json'));\n    });\n\n    it('uses cwd when no directory given', () => {\n      const result = getLocalLockPath();\n      expect(result).toBe(join(process.cwd(), 'skills-lock.json'));\n    });\n  });\n\n  describe('readLocalLock', () => {\n    it('returns empty lock when file does not exist', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        const lock = await readLocalLock(dir);\n        expect(lock).toEqual({ version: 1, skills: {} });\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n\n    it('reads a valid lock file', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        const content = {\n          version: 1,\n          skills: {\n            'my-skill': {\n              source: 'vercel-labs/skills',\n              sourceType: 'github',\n              computedHash: 'abc123',\n            },\n          },\n        };\n        await writeFile(join(dir, 'skills-lock.json'), JSON.stringify(content), 'utf-8');\n\n        const lock = await readLocalLock(dir);\n        expect(lock.version).toBe(1);\n        expect(lock.skills['my-skill']).toEqual({\n          source: 'vercel-labs/skills',\n          sourceType: 'github',\n          computedHash: 'abc123',\n        });\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n\n    it('returns empty lock for corrupted JSON (merge conflict markers)', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        const conflicted = `{\n  \"version\": 1,\n  \"skills\": {\n<<<<<<< HEAD\n    \"skill-a\": { \"source\": \"org/repo-a\", \"sourceType\": \"github\", \"computedHash\": \"aaa\" }\n=======\n    \"skill-b\": { \"source\": \"org/repo-b\", \"sourceType\": \"github\", \"computedHash\": \"bbb\" }\n>>>>>>> feature-branch\n  }\n}`;\n        await writeFile(join(dir, 'skills-lock.json'), conflicted, 'utf-8');\n\n        const lock = await readLocalLock(dir);\n        expect(lock).toEqual({ version: 1, skills: {} });\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n\n    it('returns empty lock for invalid structure (missing skills key)', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        await writeFile(join(dir, 'skills-lock.json'), '{\"version\": 1}', 'utf-8');\n        const lock = await readLocalLock(dir);\n        expect(lock).toEqual({ version: 1, skills: {} });\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n  });\n\n  describe('writeLocalLock', () => {\n    it('writes sorted JSON with trailing newline', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        await writeLocalLock(\n          {\n            version: 1,\n            skills: {\n              'zebra-skill': {\n                source: 'org/z',\n                sourceType: 'github',\n                computedHash: 'zzz',\n              },\n              'alpha-skill': {\n                source: 'org/a',\n                sourceType: 'github',\n                computedHash: 'aaa',\n              },\n              'middle-skill': {\n                source: 'org/m',\n                sourceType: 'github',\n                computedHash: 'mmm',\n              },\n            },\n          },\n          dir\n        );\n\n        const raw = await readFile(join(dir, 'skills-lock.json'), 'utf-8');\n        expect(raw.endsWith('\\n')).toBe(true);\n\n        const parsed = JSON.parse(raw);\n        const keys = Object.keys(parsed.skills);\n        expect(keys).toEqual(['alpha-skill', 'middle-skill', 'zebra-skill']);\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n  });\n\n  describe('addSkillToLocalLock', () => {\n    it('adds a new skill to an empty lock', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        await addSkillToLocalLock(\n          'new-skill',\n          { source: 'org/repo', sourceType: 'github', computedHash: 'hash123' },\n          dir\n        );\n\n        const lock = await readLocalLock(dir);\n        expect(lock.skills['new-skill']).toEqual({\n          source: 'org/repo',\n          sourceType: 'github',\n          computedHash: 'hash123',\n        });\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n\n    it('updates an existing skill hash', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        await addSkillToLocalLock(\n          'my-skill',\n          { source: 'org/repo', sourceType: 'github', computedHash: 'old-hash' },\n          dir\n        );\n        await addSkillToLocalLock(\n          'my-skill',\n          { source: 'org/repo', sourceType: 'github', computedHash: 'new-hash' },\n          dir\n        );\n\n        const lock = await readLocalLock(dir);\n        expect(lock.skills['my-skill']!.computedHash).toBe('new-hash');\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n\n    it('preserves other skills when adding', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        await addSkillToLocalLock(\n          'skill-a',\n          { source: 'org/a', sourceType: 'github', computedHash: 'aaa' },\n          dir\n        );\n        await addSkillToLocalLock(\n          'skill-b',\n          { source: 'org/b', sourceType: 'github', computedHash: 'bbb' },\n          dir\n        );\n\n        const lock = await readLocalLock(dir);\n        expect(Object.keys(lock.skills)).toHaveLength(2);\n        expect(lock.skills['skill-a']!.computedHash).toBe('aaa');\n        expect(lock.skills['skill-b']!.computedHash).toBe('bbb');\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n  });\n\n  describe('removeSkillFromLocalLock', () => {\n    it('removes an existing skill', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        await addSkillToLocalLock(\n          'my-skill',\n          { source: 'org/repo', sourceType: 'github', computedHash: 'hash' },\n          dir\n        );\n\n        const removed = await removeSkillFromLocalLock('my-skill', dir);\n        expect(removed).toBe(true);\n\n        const lock = await readLocalLock(dir);\n        expect(lock.skills['my-skill']).toBeUndefined();\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n\n    it('returns false for non-existent skill', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        const removed = await removeSkillFromLocalLock('no-such-skill', dir);\n        expect(removed).toBe(false);\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n  });\n\n  describe('computeSkillFolderHash', () => {\n    it('produces a deterministic SHA-256 hash', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        const skillDir = join(dir, 'my-skill');\n        await mkdir(skillDir, { recursive: true });\n        await writeFile(\n          join(skillDir, 'SKILL.md'),\n          '---\\nname: test\\ndescription: test\\n---\\n# Test\\n',\n          'utf-8'\n        );\n\n        const hash1 = await computeSkillFolderHash(skillDir);\n        const hash2 = await computeSkillFolderHash(skillDir);\n        expect(hash1).toBe(hash2);\n        expect(hash1).toMatch(/^[a-f0-9]{64}$/);\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n\n    it('changes when file content changes', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        const skillDir = join(dir, 'my-skill');\n        await mkdir(skillDir, { recursive: true });\n        await writeFile(join(skillDir, 'SKILL.md'), 'version 1', 'utf-8');\n\n        const hash1 = await computeSkillFolderHash(skillDir);\n\n        await writeFile(join(skillDir, 'SKILL.md'), 'version 2', 'utf-8');\n\n        const hash2 = await computeSkillFolderHash(skillDir);\n        expect(hash1).not.toBe(hash2);\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n\n    it('changes when a file is added', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        const skillDir = join(dir, 'my-skill');\n        await mkdir(skillDir, { recursive: true });\n        await writeFile(join(skillDir, 'SKILL.md'), 'content', 'utf-8');\n\n        const hash1 = await computeSkillFolderHash(skillDir);\n\n        await writeFile(join(skillDir, 'extra.txt'), 'extra file', 'utf-8');\n\n        const hash2 = await computeSkillFolderHash(skillDir);\n        expect(hash1).not.toBe(hash2);\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n\n    it('changes when a file is renamed', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        const skillDir1 = join(dir, 'skill-v1');\n        await mkdir(skillDir1, { recursive: true });\n        await writeFile(join(skillDir1, 'old-name.md'), 'content', 'utf-8');\n\n        const skillDir2 = join(dir, 'skill-v2');\n        await mkdir(skillDir2, { recursive: true });\n        await writeFile(join(skillDir2, 'new-name.md'), 'content', 'utf-8');\n\n        const hash1 = await computeSkillFolderHash(skillDir1);\n        const hash2 = await computeSkillFolderHash(skillDir2);\n        expect(hash1).not.toBe(hash2);\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n\n    it('includes nested files in subdirectories', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        const skillDir = join(dir, 'my-skill');\n        await mkdir(join(skillDir, 'sub'), { recursive: true });\n        await writeFile(join(skillDir, 'SKILL.md'), 'root', 'utf-8');\n        await writeFile(join(skillDir, 'sub', 'helper.md'), 'nested', 'utf-8');\n\n        const hash1 = await computeSkillFolderHash(skillDir);\n\n        // Changing nested file should change hash\n        await writeFile(join(skillDir, 'sub', 'helper.md'), 'changed', 'utf-8');\n\n        const hash2 = await computeSkillFolderHash(skillDir);\n        expect(hash1).not.toBe(hash2);\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n\n    it('ignores .git and node_modules directories', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        const skillDir = join(dir, 'my-skill');\n        await mkdir(skillDir, { recursive: true });\n        await writeFile(join(skillDir, 'SKILL.md'), 'content', 'utf-8');\n\n        const hash1 = await computeSkillFolderHash(skillDir);\n\n        // Adding files in .git and node_modules should NOT change hash\n        await mkdir(join(skillDir, '.git'), { recursive: true });\n        await writeFile(join(skillDir, '.git', 'HEAD'), 'ref: refs/heads/main', 'utf-8');\n        await mkdir(join(skillDir, 'node_modules', 'foo'), { recursive: true });\n        await writeFile(join(skillDir, 'node_modules', 'foo', 'index.js'), 'noop', 'utf-8');\n\n        const hash2 = await computeSkillFolderHash(skillDir);\n        expect(hash1).toBe(hash2);\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n  });\n\n  describe('merge conflict friendliness', () => {\n    it('produces no-conflict output when two skills are added independently', async () => {\n      const dir = await mkdtemp(join(tmpdir(), 'lock-test-'));\n      try {\n        // Simulate branch A adding skill-a\n        await addSkillToLocalLock(\n          'skill-a',\n          { source: 'org/a', sourceType: 'github', computedHash: 'aaa' },\n          dir\n        );\n        const branchA = await readFile(join(dir, 'skills-lock.json'), 'utf-8');\n\n        // Reset to empty\n        await writeFile(join(dir, 'skills-lock.json'), '{\"version\":1,\"skills\":{}}', 'utf-8');\n\n        // Simulate branch B adding skill-b\n        await addSkillToLocalLock(\n          'skill-b',\n          { source: 'org/b', sourceType: 'github', computedHash: 'bbb' },\n          dir\n        );\n        const branchB = await readFile(join(dir, 'skills-lock.json'), 'utf-8');\n\n        // Both branches produce valid JSON with no timestamps to conflict on\n        const parsedA = JSON.parse(branchA);\n        const parsedB = JSON.parse(branchB);\n        expect(parsedA.skills['skill-a']).toBeDefined();\n        expect(parsedA.skills['skill-a'].computedHash).toBeDefined();\n        expect(parsedB.skills['skill-b']).toBeDefined();\n        expect(parsedB.skills['skill-b'].computedHash).toBeDefined();\n\n        // No timestamps present\n        expect(parsedA.skills['skill-a'].installedAt).toBeUndefined();\n        expect(parsedA.skills['skill-a'].updatedAt).toBeUndefined();\n      } finally {\n        await rm(dir, { recursive: true, force: true });\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "tests/openclaw-paths.test.ts",
    "content": "import { join } from 'path';\nimport { describe, it, expect } from 'vitest';\nimport { getOpenClawGlobalSkillsDir } from '../src/agents.ts';\n\ndescribe('openclaw global path resolution', () => {\n  const home = '/tmp/home';\n\n  it('prefers ~/.openclaw when present', () => {\n    const exists = (path: string) =>\n      path === join(home, '.openclaw') ||\n      path === join(home, '.clawdbot') ||\n      path === join(home, '.moltbot');\n    expect(getOpenClawGlobalSkillsDir(home, exists)).toBe(join(home, '.openclaw/skills'));\n  });\n\n  it('falls back to ~/.clawdbot when ~/.openclaw is missing', () => {\n    const exists = (path: string) =>\n      path === join(home, '.clawdbot') || path === join(home, '.moltbot');\n    expect(getOpenClawGlobalSkillsDir(home, exists)).toBe(join(home, '.clawdbot/skills'));\n  });\n\n  it('falls back to ~/.moltbot when only legacy path exists', () => {\n    const exists = (path: string) => path === join(home, '.moltbot');\n    expect(getOpenClawGlobalSkillsDir(home, exists)).toBe(join(home, '.moltbot/skills'));\n  });\n\n  it('defaults to ~/.openclaw when no known path exists', () => {\n    expect(getOpenClawGlobalSkillsDir(home, () => false)).toBe(join(home, '.openclaw/skills'));\n  });\n});\n"
  },
  {
    "path": "tests/plugin-grouping.test.ts",
    "content": "import { join, resolve } from 'path';\nimport { getPluginGroupings } from '../src/plugin-manifest.ts';\nimport { describe, it, expect, beforeAll, afterAll } from 'vitest';\nimport { mkdir, writeFile, rm } from 'fs/promises';\n\nconst TEST_DIR = join(process.cwd(), 'test-plugin-grouping');\n\ndescribe('getPluginGroupings', () => {\n  beforeAll(async () => {\n    await mkdir(TEST_DIR, { recursive: true });\n    await mkdir(join(TEST_DIR, '.claude-plugin'), { recursive: true });\n\n    const manifest = {\n      plugins: [\n        {\n          name: 'document-skills',\n          source: './',\n          skills: ['./skills/xlsx', './skills/docx'],\n        },\n        {\n          name: 'example-skills',\n          source: './',\n          skills: ['./skills/art'],\n        },\n      ],\n    };\n\n    await writeFile(join(TEST_DIR, '.claude-plugin/marketplace.json'), JSON.stringify(manifest));\n  });\n\n  afterAll(async () => {\n    await rm(TEST_DIR, { recursive: true, force: true });\n  });\n\n  it('should map skill paths to plugin names', async () => {\n    const groupings = await getPluginGroupings(TEST_DIR);\n\n    const xlsxPath = resolve(TEST_DIR, 'skills/xlsx');\n    const docxPath = resolve(TEST_DIR, 'skills/docx');\n    const artPath = resolve(TEST_DIR, 'skills/art');\n\n    expect(groupings.get(xlsxPath)).toBe('document-skills');\n    expect(groupings.get(docxPath)).toBe('document-skills');\n    expect(groupings.get(artPath)).toBe('example-skills');\n  });\n\n  it('should handle nested plugin sources', async () => {\n    // Create nested structure\n    const nestedDir = join(TEST_DIR, 'nested');\n    await mkdir(nestedDir, { recursive: true });\n    await mkdir(join(nestedDir, '.claude-plugin'), { recursive: true });\n\n    const manifest = {\n      plugins: [\n        {\n          name: 'nested-plugin',\n          source: './plugins/my-plugin',\n          skills: ['./skills/deep'],\n        },\n      ],\n    };\n\n    await writeFile(join(nestedDir, '.claude-plugin/marketplace.json'), JSON.stringify(manifest));\n\n    const groupings = await getPluginGroupings(nestedDir);\n    // source: ./plugins/my-plugin, skill: ./skills/deep\n    // path = nestedDir/plugins/my-plugin/skills/deep\n    const expectedPath = resolve(nestedDir, 'plugins/my-plugin/skills/deep');\n\n    expect(groupings.get(expectedPath)).toBe('nested-plugin');\n  });\n});\n"
  },
  {
    "path": "tests/plugin-manifest-discovery.test.ts",
    "content": "/**\n * Tests for discovering skills declared in plugin manifests.\n */\nimport { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { mkdirSync, rmSync, writeFileSync } from 'fs';\nimport { join } from 'path';\nimport { tmpdir } from 'os';\nimport { discoverSkills } from '../src/skills.ts';\n\ndescribe('discoverSkills with plugin manifests', () => {\n  let testDir: string;\n\n  beforeEach(() => {\n    testDir = join(tmpdir(), `skills-manifest-test-${Date.now()}`);\n    mkdirSync(testDir, { recursive: true });\n  });\n\n  afterEach(() => {\n    rmSync(testDir, { recursive: true, force: true });\n  });\n\n  it('should discover skills from marketplace.json', async () => {\n    // Create marketplace.json\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/marketplace.json'),\n      JSON.stringify({\n        name: 'test-marketplace',\n        owner: { name: 'Test' },\n        plugins: [\n          {\n            name: 'test-plugin',\n            source: './plugins/test-plugin',\n            skills: ['./skills/test-skill'],\n          },\n        ],\n      })\n    );\n\n    // Create the skill\n    mkdirSync(join(testDir, 'plugins/test-plugin/skills/test-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'plugins/test-plugin/skills/test-skill/SKILL.md'),\n      `---\nname: manifest-skill\ndescription: Skill discovered via manifest\n---\n# Test\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('manifest-skill');\n  });\n\n  it('should respect metadata.pluginRoot', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/marketplace.json'),\n      JSON.stringify({\n        metadata: { pluginRoot: './plugins' },\n        plugins: [\n          {\n            name: 'my-plugin',\n            source: 'my-plugin', // Relative to pluginRoot\n            skills: ['./skills/my-skill'],\n          },\n        ],\n      })\n    );\n\n    mkdirSync(join(testDir, 'plugins/my-plugin/skills/my-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'plugins/my-plugin/skills/my-skill/SKILL.md'),\n      `---\nname: pluginroot-skill\ndescription: Test\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('pluginroot-skill');\n  });\n\n  it('should discover skills from plugin.json', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/plugin.json'),\n      JSON.stringify({\n        name: 'single-plugin',\n        skills: ['./skills/single-skill'],\n      })\n    );\n\n    mkdirSync(join(testDir, 'skills/single-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills/single-skill/SKILL.md'),\n      `---\nname: single-plugin-skill\ndescription: Test\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('single-plugin-skill');\n  });\n\n  it('should skip remote source objects', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/marketplace.json'),\n      JSON.stringify({\n        plugins: [\n          {\n            name: 'remote-plugin',\n            source: { source: 'github', repo: 'owner/repo' },\n            skills: ['./skills/remote-skill'],\n          },\n        ],\n      })\n    );\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(0);\n  });\n\n  it('should handle missing manifest gracefully', async () => {\n    // No .claude-plugin directory\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(0);\n  });\n\n  it('should handle invalid JSON gracefully', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(join(testDir, '.claude-plugin/marketplace.json'), 'not valid json');\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(0);\n  });\n\n  it('should deduplicate skills found via manifest and priority dirs', async () => {\n    // Skill in both manifest path AND standard skills/ directory\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/plugin.json'),\n      JSON.stringify({ skills: ['./skills/dupe-skill'] })\n    );\n\n    mkdirSync(join(testDir, 'skills/dupe-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills/dupe-skill/SKILL.md'),\n      `---\nname: dupe-skill\ndescription: Test\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(1);\n  });\n\n  it('should discover multiple skills from multiple plugins', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/marketplace.json'),\n      JSON.stringify({\n        plugins: [\n          {\n            name: 'plugin-a',\n            source: './plugin-a',\n            skills: ['./skills/skill-1', './skills/skill-2'],\n          },\n          {\n            name: 'plugin-b',\n            source: './plugin-b',\n            skills: ['./skills/skill-3'],\n          },\n        ],\n      })\n    );\n\n    // Create skills for plugin-a\n    mkdirSync(join(testDir, 'plugin-a/skills/skill-1'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'plugin-a/skills/skill-1/SKILL.md'),\n      `---\nname: skill-1\ndescription: Test\n---\n`\n    );\n    mkdirSync(join(testDir, 'plugin-a/skills/skill-2'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'plugin-a/skills/skill-2/SKILL.md'),\n      `---\nname: skill-2\ndescription: Test\n---\n`\n    );\n\n    // Create skill for plugin-b\n    mkdirSync(join(testDir, 'plugin-b/skills/skill-3'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'plugin-b/skills/skill-3/SKILL.md'),\n      `---\nname: skill-3\ndescription: Test\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(3);\n    const names = skills.map((s) => s.name).sort();\n    expect(names).toEqual(['skill-1', 'skill-2', 'skill-3']);\n  });\n\n  it('should handle plugin without source (root-level plugin)', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/marketplace.json'),\n      JSON.stringify({\n        plugins: [\n          {\n            name: 'root-plugin',\n            // No source - plugin is at root\n            skills: ['./skills/root-skill'],\n          },\n        ],\n      })\n    );\n\n    mkdirSync(join(testDir, 'skills/root-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills/root-skill/SKILL.md'),\n      `---\nname: root-skill\ndescription: Test\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('root-skill');\n  });\n\n  it('should discover skills from adjacent skills/ when plugin.json has no skills array', async () => {\n    // plugin.json exists but doesn't declare skills\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/plugin.json'),\n      JSON.stringify({\n        name: 'plugin-without-skills-field',\n        description: 'A plugin that does not declare skills explicitly',\n      })\n    );\n\n    // Skills exist in conventional location\n    mkdirSync(join(testDir, 'skills/undeclared-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills/undeclared-skill/SKILL.md'),\n      `---\nname: undeclared-skill\ndescription: Discovered from conventional skills/ directory\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('undeclared-skill');\n  });\n\n  it('should discover skills from adjacent skills/ when plugin.json has empty skills array', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/plugin.json'),\n      JSON.stringify({\n        name: 'plugin-with-empty-skills',\n        skills: [], // Empty array\n      })\n    );\n\n    mkdirSync(join(testDir, 'skills/empty-array-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills/empty-array-skill/SKILL.md'),\n      `---\nname: empty-array-skill\ndescription: Test\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('empty-array-skill');\n  });\n\n  it('should discover skills from marketplace plugin without skills array', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/marketplace.json'),\n      JSON.stringify({\n        plugins: [\n          {\n            name: 'plugin-no-skills-field',\n            source: './my-plugin',\n            // No skills field - should discover from my-plugin/skills/\n          },\n        ],\n      })\n    );\n\n    mkdirSync(join(testDir, 'my-plugin/skills/auto-discovered'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'my-plugin/skills/auto-discovered/SKILL.md'),\n      `---\nname: auto-discovered\ndescription: Found via conventional skills/ in plugin\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('auto-discovered');\n  });\n\n  it('should discover both explicit and conventional skills from same plugin', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/marketplace.json'),\n      JSON.stringify({\n        plugins: [\n          {\n            name: 'mixed-plugin',\n            source: './mixed',\n            skills: ['./custom-skills/explicit-skill'], // Explicit path\n          },\n        ],\n      })\n    );\n\n    // Explicit skill in custom location\n    mkdirSync(join(testDir, 'mixed/custom-skills/explicit-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'mixed/custom-skills/explicit-skill/SKILL.md'),\n      `---\nname: explicit-skill\ndescription: Explicitly declared\n---\n`\n    );\n\n    // Conventional skill in skills/\n    mkdirSync(join(testDir, 'mixed/skills/conventional-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'mixed/skills/conventional-skill/SKILL.md'),\n      `---\nname: conventional-skill\ndescription: Found via convention\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(2);\n    const names = skills.map((s) => s.name).sort();\n    expect(names).toEqual(['conventional-skill', 'explicit-skill']);\n  });\n\n  it('should reject paths that traverse outside basePath', async () => {\n    // Create marketplace.json with malicious traversal paths\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/marketplace.json'),\n      JSON.stringify({\n        plugins: [\n          { source: '../../../etc', skills: ['./passwd'] }, // Traversal via source\n          { source: 'legit', skills: ['../../../outside/skill'] }, // Traversal via skill path\n        ],\n      })\n    );\n\n    // Create a legit plugin with a valid skill to ensure discovery still works\n    mkdirSync(join(testDir, 'legit/skills/valid-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'legit/skills/valid-skill/SKILL.md'),\n      `---\nname: valid-skill\ndescription: A valid skill inside basePath\n---\n`\n    );\n\n    // Create a skill outside testDir that should NOT be discovered\n    const outsideDir = join(testDir, '..', `outside-${Date.now()}`);\n    mkdirSync(join(outsideDir, 'skill'), { recursive: true });\n    writeFileSync(\n      join(outsideDir, 'skill/SKILL.md'),\n      `---\nname: outside-skill\ndescription: Should not be discovered\n---\n`\n    );\n\n    try {\n      const skills = await discoverSkills(testDir);\n      // Should only find the valid skill, not the traversal attempts\n      expect(skills).toHaveLength(1);\n      expect(skills[0].name).toBe('valid-skill');\n    } finally {\n      // Clean up outside directory\n      rmSync(outsideDir, { recursive: true, force: true });\n    }\n  });\n\n  it('should reject absolute paths in manifests', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n    writeFileSync(\n      join(testDir, '.claude-plugin/plugin.json'),\n      JSON.stringify({\n        skills: ['/etc/passwd', '/tmp/malicious-skill'],\n      })\n    );\n\n    // Create a valid skill via convention\n    mkdirSync(join(testDir, 'skills/safe-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills/safe-skill/SKILL.md'),\n      `---\nname: safe-skill\ndescription: Safe skill in conventional location\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    // Should only find the conventional skill\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('safe-skill');\n  });\n\n  it('should reject paths without ./ prefix (per Claude Code convention)', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n\n    // Paths without './' prefix should be rejected\n    // Use a non-standard directory that WON'T be found by fallback search\n    writeFileSync(\n      join(testDir, '.claude-plugin/marketplace.json'),\n      JSON.stringify({\n        metadata: { pluginRoot: 'custom-plugins' }, // Missing './' prefix - INVALID\n        plugins: [{ source: './my-plugin', skills: ['./custom-skills/my-skill'] }],\n      })\n    );\n\n    // Create the plugin in a non-standard location only reachable via manifest\n    mkdirSync(join(testDir, 'custom-plugins/my-plugin/custom-skills/my-skill'), {\n      recursive: true,\n    });\n    writeFileSync(\n      join(testDir, 'custom-plugins/my-plugin/custom-skills/my-skill/SKILL.md'),\n      `---\nname: unreachable-skill\ndescription: Should not be found - pluginRoot lacks ./\n---\n`\n    );\n\n    // Also create a skill in standard location to prevent fallback deep search\n    mkdirSync(join(testDir, 'skills/standard-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills/standard-skill/SKILL.md'),\n      `---\nname: standard-skill\ndescription: Found via standard location\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    // Only the standard skill should be found, not the one behind invalid pluginRoot\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('standard-skill');\n  });\n\n  it('should reject plugin sources without ./ prefix', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n\n    writeFileSync(\n      join(testDir, '.claude-plugin/marketplace.json'),\n      JSON.stringify({\n        plugins: [\n          { source: 'bare-plugin', skills: ['./skills/skill1'] }, // Invalid - no './'\n          { source: './valid-plugin', skills: ['./skills/skill2'] }, // Valid\n        ],\n      })\n    );\n\n    // Create both plugins\n    mkdirSync(join(testDir, 'bare-plugin/skills/skill1'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'bare-plugin/skills/skill1/SKILL.md'),\n      `---\nname: bare-skill\ndescription: Should not be found\n---\n`\n    );\n\n    mkdirSync(join(testDir, 'valid-plugin/skills/skill2'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'valid-plugin/skills/skill2/SKILL.md'),\n      `---\nname: valid-skill\ndescription: Should be found\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    expect(skills).toHaveLength(1);\n    expect(skills[0].name).toBe('valid-skill');\n  });\n\n  it('should reject skill paths without ./ prefix', async () => {\n    mkdirSync(join(testDir, '.claude-plugin'), { recursive: true });\n\n    // Use SEPARATE non-standard directories to isolate the test\n    // (parent dir scanning would find siblings if in same parent)\n    writeFileSync(\n      join(testDir, '.claude-plugin/plugin.json'),\n      JSON.stringify({\n        skills: ['invalid-loc/bare-skill', './valid-loc/valid-skill'], // First lacks ./\n      })\n    );\n\n    // Skill with invalid path (no ./) - in its own directory tree\n    mkdirSync(join(testDir, 'invalid-loc/bare-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'invalid-loc/bare-skill/SKILL.md'),\n      `---\nname: bare-skill\ndescription: Should not be found - path lacks ./\n---\n`\n    );\n\n    // Skill with valid path - in separate directory tree\n    mkdirSync(join(testDir, 'valid-loc/valid-skill'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'valid-loc/valid-skill/SKILL.md'),\n      `---\nname: valid-skill\ndescription: Should be found - path has ./\n---\n`\n    );\n\n    // Add a skill in standard location to prevent fallback search\n    mkdirSync(join(testDir, 'skills/standard'), { recursive: true });\n    writeFileSync(\n      join(testDir, 'skills/standard/SKILL.md'),\n      `---\nname: standard-skill\ndescription: Standard location\n---\n`\n    );\n\n    const skills = await discoverSkills(testDir);\n    const names = skills.map((s) => s.name).sort();\n    // Should find: valid-skill (via valid manifest path) and standard-skill (via convention)\n    // Should NOT find: bare-skill (manifest path lacks ./)\n    expect(names).toEqual(['standard-skill', 'valid-skill']);\n  });\n});\n"
  },
  {
    "path": "tests/remove-canonical.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { mkdir, rm, writeFile, lstat, symlink } from 'node:fs/promises';\nimport { join, resolve } from 'node:path';\nimport { tmpdir } from 'node:os';\nimport { removeCommand } from '../src/remove.ts';\nimport * as agentsModule from '../src/agents.ts';\n\n// Mock detectInstalledAgents\nvi.mock('../src/agents.ts', async () => {\n  const actual = await vi.importActual('../src/agents.ts');\n  return {\n    ...actual,\n    detectInstalledAgents: vi.fn(),\n  };\n});\n\ndescribe('removeCommand canonical protection', () => {\n  let tempDir: string;\n  let oldCwd: string;\n\n  beforeEach(async () => {\n    tempDir = await resolve(join(tmpdir(), 'skills-remove-test-' + Date.now()));\n    await mkdir(tempDir, { recursive: true });\n    oldCwd = process.cwd();\n    process.chdir(tempDir);\n\n    // Mock/Setup agent directories\n    // We need to simulate the structure that getInstallPath and getCanonicalPath expect\n    // Default skills dir is .agents/skills\n    await mkdir(join(tempDir, '.agents/skills'), { recursive: true });\n\n    // Setup two agents that use different dirs\n    // Claude uses .claude/skills\n    await mkdir(join(tempDir, '.claude/skills'), { recursive: true });\n    // Continue uses .continue/skills\n    await mkdir(join(tempDir, '.continue/skills'), { recursive: true });\n  });\n\n  afterEach(async () => {\n    process.chdir(oldCwd);\n    await rm(tempDir, { recursive: true, force: true });\n  });\n\n  it('should NOT remove canonical storage if other agents still have the skill installed', async () => {\n    const skillName = 'test-skill';\n    const canonicalPath = join(tempDir, '.agents/skills', skillName);\n    const claudePath = join(tempDir, '.claude/skills', skillName);\n    const continuePath = join(tempDir, '.continue/skills', skillName);\n\n    // 1. Create canonical storage\n    await mkdir(canonicalPath, { recursive: true });\n    await writeFile(join(canonicalPath, 'SKILL.md'), '# Test');\n\n    // 2. Install (symlink) to Claude and Continue\n    await symlink(canonicalPath, claudePath, 'junction');\n    await symlink(canonicalPath, continuePath, 'junction');\n\n    // Verify setup\n    expect(\n      (await lstat(claudePath)).isSymbolicLink() || (await lstat(claudePath)).isDirectory()\n    ).toBe(true);\n    expect(\n      (await lstat(continuePath)).isSymbolicLink() || (await lstat(continuePath)).isDirectory()\n    ).toBe(true);\n\n    // Mock agents: Claude and Continue are installed\n    vi.mocked(agentsModule.detectInstalledAgents).mockResolvedValue(['claude-code', 'continue']);\n\n    // 3. Remove from Claude only\n    // -a claude-code\n    await removeCommand([skillName], { agent: ['claude-code'], yes: true });\n\n    // 4. Verify results\n    // Claude path should be gone\n    await expect(lstat(claudePath)).rejects.toThrow();\n\n    // Canonical path SHOULD STILL EXIST because Continue uses it\n    expect((await lstat(canonicalPath)).isDirectory()).toBe(true);\n\n    // Continue path should still be valid\n    expect(\n      (await lstat(continuePath)).isSymbolicLink() || (await lstat(continuePath)).isDirectory()\n    ).toBe(true);\n  });\n\n  it('should remove canonical storage if NO other agents are using it', async () => {\n    const skillName = 'test-skill-2';\n    const canonicalPath = join(tempDir, '.agents/skills', skillName);\n    const claudePath = join(tempDir, '.claude/skills', skillName);\n\n    await mkdir(canonicalPath, { recursive: true });\n    await writeFile(join(canonicalPath, 'SKILL.md'), '# Test');\n    await symlink(canonicalPath, claudePath, 'junction');\n\n    // Mock agents: Only Claude is installed\n    vi.mocked(agentsModule.detectInstalledAgents).mockResolvedValue(['claude-code']);\n\n    // Remove from Claude\n    await removeCommand([skillName], { agent: ['claude-code'], yes: true });\n\n    // Both should be gone\n    await expect(lstat(claudePath)).rejects.toThrow();\n    await expect(lstat(canonicalPath)).rejects.toThrow();\n  });\n});\n"
  },
  {
    "path": "tests/sanitize-name.test.ts",
    "content": "/**\n * Unit tests for sanitizeName function in installer.ts\n *\n * These tests verify the sanitization logic for skill names to ensure:\n * - Path traversal attacks are prevented\n * - Names follow kebab-case convention\n * - Special characters are handled safely\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { sanitizeName } from '../src/installer.ts';\n\ndescribe('sanitizeName', () => {\n  describe('basic transformations', () => {\n    it('converts to lowercase', () => {\n      expect(sanitizeName('MySkill')).toBe('myskill');\n      expect(sanitizeName('UPPERCASE')).toBe('uppercase');\n    });\n\n    it('replaces spaces with hyphens', () => {\n      expect(sanitizeName('my skill')).toBe('my-skill');\n      expect(sanitizeName('Convex Best Practices')).toBe('convex-best-practices');\n    });\n\n    it('replaces multiple spaces with single hyphen', () => {\n      expect(sanitizeName('my   skill')).toBe('my-skill');\n    });\n\n    it('preserves dots and underscores', () => {\n      expect(sanitizeName('bun.sh')).toBe('bun.sh');\n      expect(sanitizeName('my_skill')).toBe('my_skill');\n      expect(sanitizeName('skill.v2_beta')).toBe('skill.v2_beta');\n    });\n\n    it('preserves numbers', () => {\n      expect(sanitizeName('skill123')).toBe('skill123');\n      expect(sanitizeName('v2.0')).toBe('v2.0');\n    });\n  });\n\n  describe('special character handling', () => {\n    it('replaces special characters with hyphens', () => {\n      expect(sanitizeName('skill@name')).toBe('skill-name');\n      expect(sanitizeName('skill#name')).toBe('skill-name');\n      expect(sanitizeName('skill$name')).toBe('skill-name');\n      expect(sanitizeName('skill!name')).toBe('skill-name');\n    });\n\n    it('collapses multiple special chars into single hyphen', () => {\n      expect(sanitizeName('skill@#$name')).toBe('skill-name');\n      expect(sanitizeName('a!!!b')).toBe('a-b');\n    });\n  });\n\n  describe('path traversal prevention', () => {\n    it('prevents path traversal with ../', () => {\n      expect(sanitizeName('../etc/passwd')).toBe('etc-passwd');\n      expect(sanitizeName('../../secret')).toBe('secret');\n    });\n\n    it('prevents path traversal with backslashes', () => {\n      expect(sanitizeName('..\\\\..\\\\secret')).toBe('secret');\n    });\n\n    it('handles absolute paths', () => {\n      expect(sanitizeName('/etc/passwd')).toBe('etc-passwd');\n      expect(sanitizeName('C:\\\\Windows\\\\System32')).toBe('c-windows-system32');\n    });\n  });\n\n  describe('leading/trailing cleanup', () => {\n    it('removes leading dots', () => {\n      expect(sanitizeName('.hidden')).toBe('hidden');\n      expect(sanitizeName('..hidden')).toBe('hidden');\n      expect(sanitizeName('...skill')).toBe('skill');\n    });\n\n    it('removes trailing dots', () => {\n      expect(sanitizeName('skill.')).toBe('skill');\n      expect(sanitizeName('skill..')).toBe('skill');\n    });\n\n    it('removes leading hyphens', () => {\n      expect(sanitizeName('-skill')).toBe('skill');\n      expect(sanitizeName('--skill')).toBe('skill');\n    });\n\n    it('removes trailing hyphens', () => {\n      expect(sanitizeName('skill-')).toBe('skill');\n      expect(sanitizeName('skill--')).toBe('skill');\n    });\n\n    it('removes mixed leading dots and hyphens', () => {\n      expect(sanitizeName('.-.-skill')).toBe('skill');\n      expect(sanitizeName('-.-.skill')).toBe('skill');\n    });\n  });\n\n  describe('edge cases', () => {\n    it('returns unnamed-skill for empty string', () => {\n      expect(sanitizeName('')).toBe('unnamed-skill');\n    });\n\n    it('returns unnamed-skill when only special chars', () => {\n      expect(sanitizeName('...')).toBe('unnamed-skill');\n      expect(sanitizeName('---')).toBe('unnamed-skill');\n      expect(sanitizeName('@#$%')).toBe('unnamed-skill');\n    });\n\n    it('handles very long names (truncates to 255 chars)', () => {\n      const longName = 'a'.repeat(300);\n      const result = sanitizeName(longName);\n      expect(result.length).toBe(255);\n      expect(result).toBe('a'.repeat(255));\n    });\n\n    it('handles unicode characters', () => {\n      expect(sanitizeName('skill日本語')).toBe('skill');\n      expect(sanitizeName('émoji🎉skill')).toBe('moji-skill');\n    });\n  });\n\n  describe('real-world examples', () => {\n    it('handles GitHub repo style names', () => {\n      expect(sanitizeName('vercel/next.js')).toBe('vercel-next.js');\n      expect(sanitizeName('owner/repo-name')).toBe('owner-repo-name');\n    });\n\n    it('handles URLs', () => {\n      expect(sanitizeName('https://example.com')).toBe('https-example.com');\n    });\n\n    it('handles mintlify style names', () => {\n      expect(sanitizeName('docs.example.com')).toBe('docs.example.com');\n      expect(sanitizeName('bun.sh')).toBe('bun.sh');\n    });\n  });\n});\n"
  },
  {
    "path": "tests/skill-matching.test.ts",
    "content": "/**\n * Unit tests for filterSkills function in skills.ts\n *\n * These tests verify the skill matching logic. Multi-word skill names\n * must be quoted on the command line (e.g., --skill \"Convex Best Practices\").\n */\n\nimport { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { mkdirSync, rmSync, writeFileSync } from 'fs';\nimport { join } from 'path';\nimport { tmpdir } from 'os';\nimport { filterSkills, parseSkillMd } from '../src/skills.ts';\nimport type { Skill } from '../src/types.ts';\n\n// Mock skill factory\nfunction makeSkill(name: string, path: string = '/tmp/skill'): Skill {\n  return { name, description: 'desc', path };\n}\n\nconst skills: Skill[] = [\n  makeSkill('convex-best-practices'),\n  makeSkill('Convex Best Practices'),\n  makeSkill('simple-skill'),\n  makeSkill('foo'),\n  makeSkill('bar'),\n];\n\ndescribe('filterSkills', () => {\n  describe('direct matching', () => {\n    it('matches exact name', () => {\n      const result = filterSkills(skills, ['foo']);\n      expect(result.length).toBe(1);\n      expect(result[0].name).toBe('foo');\n    });\n\n    it('matches case insensitive', () => {\n      const result = filterSkills(skills, ['FOO']);\n      expect(result.length).toBe(1);\n      expect(result[0].name).toBe('foo');\n    });\n\n    it('matches kebab-case skill name', () => {\n      const result = filterSkills(skills, ['convex-best-practices']);\n      expect(result.length).toBe(1);\n      expect(result[0].name).toBe('convex-best-practices');\n    });\n\n    it('matches multiple skills', () => {\n      const result = filterSkills(skills, ['foo', 'bar']);\n      expect(result.length).toBe(2);\n      const names = result.map((s) => s.name).sort();\n      expect(names).toEqual(['bar', 'foo']);\n    });\n  });\n\n  describe('quoted multi-word names', () => {\n    it('matches quoted multi-word name', () => {\n      // Simulates: --skill \"Convex Best Practices\"\n      const result = filterSkills(skills, ['Convex Best Practices']);\n      expect(result.length).toBe(1);\n      expect(result[0].name).toBe('Convex Best Practices');\n    });\n\n    it('matches quoted multi-word name case insensitive', () => {\n      const result = filterSkills(skills, ['convex best practices']);\n      expect(result.length).toBe(1);\n      expect(result[0].name).toBe('Convex Best Practices');\n    });\n  });\n\n  describe('unquoted multi-word names (should not match)', () => {\n    it('does not match unquoted multi-word args', () => {\n      // Simulates: --skill Convex Best Practices (unquoted - shell splits into 3 args)\n      // This should NOT match - users must quote multi-word names\n      const result = filterSkills(skills, ['Convex', 'Best', 'Practices']);\n      expect(result.length).toBe(0);\n    });\n\n    it('does not match partial words', () => {\n      const result = filterSkills(skills, ['Convex', 'Best']);\n      expect(result.length).toBe(0);\n    });\n  });\n\n  describe('no matches', () => {\n    it('returns empty array when no matches', () => {\n      const result = filterSkills(skills, ['nonexistent']);\n      expect(result.length).toBe(0);\n    });\n\n    it('returns empty array for empty input', () => {\n      const result = filterSkills(skills, []);\n      expect(result.length).toBe(0);\n    });\n  });\n});\n\ndescribe('parseSkillMd with non-string frontmatter values', () => {\n  let testDir: string;\n\n  beforeEach(() => {\n    testDir = join(tmpdir(), `skills-nonstring-test-${Date.now()}`);\n    mkdirSync(testDir, { recursive: true });\n  });\n\n  afterEach(() => {\n    rmSync(testDir, { recursive: true, force: true });\n  });\n\n  it('rejects skill with numeric name', async () => {\n    const skillPath = join(testDir, 'SKILL.md');\n    writeFileSync(\n      skillPath,\n      `---\nname: 123\ndescription: A skill with numeric name\n---\n\n# Numeric Name Skill\n`\n    );\n    const result = await parseSkillMd(skillPath);\n    expect(result).toBeNull();\n  });\n\n  it('rejects skill with boolean name', async () => {\n    const skillPath = join(testDir, 'SKILL.md');\n    writeFileSync(\n      skillPath,\n      `---\nname: true\ndescription: A skill with boolean name\n---\n\n# Boolean Name Skill\n`\n    );\n    const result = await parseSkillMd(skillPath);\n    expect(result).toBeNull();\n  });\n\n  it('rejects skill with array name', async () => {\n    const skillPath = join(testDir, 'SKILL.md');\n    writeFileSync(\n      skillPath,\n      `---\nname:\n  - foo\n  - bar\ndescription: A skill with array name\n---\n\n# Array Name Skill\n`\n    );\n    const result = await parseSkillMd(skillPath);\n    expect(result).toBeNull();\n  });\n\n  it('rejects skill with numeric description', async () => {\n    const skillPath = join(testDir, 'SKILL.md');\n    writeFileSync(\n      skillPath,\n      `---\nname: valid-name\ndescription: 456\n---\n\n# Numeric Description Skill\n`\n    );\n    const result = await parseSkillMd(skillPath);\n    expect(result).toBeNull();\n  });\n\n  it('accepts skill with valid string name and description', async () => {\n    const skillPath = join(testDir, 'SKILL.md');\n    writeFileSync(\n      skillPath,\n      `---\nname: valid-skill\ndescription: A valid skill\n---\n\n# Valid Skill\n`\n    );\n    const result = await parseSkillMd(skillPath);\n    expect(result).not.toBeNull();\n    expect(result!.name).toBe('valid-skill');\n  });\n});\n"
  },
  {
    "path": "tests/skill-path.test.ts",
    "content": "/**\n * Unit tests for skill path calculation in telemetry.\n *\n * These tests verify that the relativePath calculation for skillFiles\n * correctly produces paths relative to the repo root, not the search path.\n * Tests cover both Unix and Windows path styles.\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { sep } from 'path';\n\n/**\n * Simulates the relativePath calculation from add.ts (cross-platform version)\n */\nfunction calculateRelativePath(\n  tempDir: string | null,\n  skillPath: string,\n  pathSep: string = sep\n): string | null {\n  if (tempDir && skillPath === tempDir) {\n    // Skill is at root level of repo\n    return 'SKILL.md';\n  } else if (tempDir && skillPath.startsWith(tempDir + pathSep)) {\n    // Compute path relative to repo root (tempDir)\n    // Use forward slashes for telemetry (URL-style paths)\n    return (\n      skillPath\n        .slice(tempDir.length + 1)\n        .split(pathSep)\n        .join('/') + '/SKILL.md'\n    );\n  } else {\n    // Local path - skip telemetry\n    return null;\n  }\n}\n\ndescribe('calculateRelativePath (Unix paths)', () => {\n  // Explicitly use '/' as separator for Unix-style paths\n  const unixSep = '/';\n\n  it('skill at repo root', () => {\n    const tempDir = '/tmp/abc123';\n    const skillPath = '/tmp/abc123';\n    const result = calculateRelativePath(tempDir, skillPath, unixSep);\n    expect(result).toBe('SKILL.md');\n  });\n\n  it('skill in skills/ subdirectory', () => {\n    const tempDir = '/tmp/abc123';\n    const skillPath = '/tmp/abc123/skills/my-skill';\n    const result = calculateRelativePath(tempDir, skillPath, unixSep);\n    expect(result).toBe('skills/my-skill/SKILL.md');\n  });\n\n  it('skill in .claude/skills/ directory', () => {\n    const tempDir = '/tmp/abc123';\n    const skillPath = '/tmp/abc123/.claude/skills/my-skill';\n    const result = calculateRelativePath(tempDir, skillPath, unixSep);\n    expect(result).toBe('.claude/skills/my-skill/SKILL.md');\n  });\n\n  it('skill in nested subdirectory', () => {\n    const tempDir = '/tmp/abc123';\n    const skillPath = '/tmp/abc123/skills/.curated/advanced-skill';\n    const result = calculateRelativePath(tempDir, skillPath, unixSep);\n    expect(result).toBe('skills/.curated/advanced-skill/SKILL.md');\n  });\n\n  it('local path returns null', () => {\n    const tempDir = null;\n    const skillPath = '/Users/me/projects/my-skill';\n    const result = calculateRelativePath(tempDir, skillPath, unixSep);\n    expect(result).toBeNull();\n  });\n\n  it('path not under tempDir returns null', () => {\n    const tempDir = '/tmp/abc123';\n    const skillPath = '/tmp/other/my-skill';\n    const result = calculateRelativePath(tempDir, skillPath, unixSep);\n    expect(result).toBeNull();\n  });\n\n  it('onmax/nuxt-skills: skill in skills/ts-library', () => {\n    const tempDir = '/tmp/clone-xyz';\n    // discoverSkills finds /tmp/clone-xyz/skills/ts-library/SKILL.md\n    // skill.path = dirname(skillMdPath) = /tmp/clone-xyz/skills/ts-library\n    const skillPath = '/tmp/clone-xyz/skills/ts-library';\n    const result = calculateRelativePath(tempDir, skillPath, unixSep);\n    expect(result).toBe('skills/ts-library/SKILL.md');\n  });\n});\n\ndescribe('calculateRelativePath (Windows paths)', () => {\n  it('skill at repo root (Windows)', () => {\n    const tempDir = 'C:\\\\Users\\\\test\\\\AppData\\\\Local\\\\Temp\\\\abc123';\n    const skillPath = 'C:\\\\Users\\\\test\\\\AppData\\\\Local\\\\Temp\\\\abc123';\n    const result = calculateRelativePath(tempDir, skillPath, '\\\\');\n    expect(result).toBe('SKILL.md');\n  });\n\n  it('skill in skills\\\\ subdirectory (Windows)', () => {\n    const tempDir = 'C:\\\\Users\\\\test\\\\AppData\\\\Local\\\\Temp\\\\abc123';\n    const skillPath = 'C:\\\\Users\\\\test\\\\AppData\\\\Local\\\\Temp\\\\abc123\\\\skills\\\\my-skill';\n    const result = calculateRelativePath(tempDir, skillPath, '\\\\');\n    expect(result).toBe('skills/my-skill/SKILL.md');\n  });\n\n  it('skill in .claude\\\\skills\\\\ directory (Windows)', () => {\n    const tempDir = 'C:\\\\Users\\\\test\\\\AppData\\\\Local\\\\Temp\\\\abc123';\n    const skillPath = 'C:\\\\Users\\\\test\\\\AppData\\\\Local\\\\Temp\\\\abc123\\\\.claude\\\\skills\\\\my-skill';\n    const result = calculateRelativePath(tempDir, skillPath, '\\\\');\n    expect(result).toBe('.claude/skills/my-skill/SKILL.md');\n  });\n\n  it('skill in nested subdirectory (Windows)', () => {\n    const tempDir = 'C:\\\\Users\\\\test\\\\AppData\\\\Local\\\\Temp\\\\abc123';\n    const skillPath =\n      'C:\\\\Users\\\\test\\\\AppData\\\\Local\\\\Temp\\\\abc123\\\\skills\\\\.curated\\\\advanced-skill';\n    const result = calculateRelativePath(tempDir, skillPath, '\\\\');\n    expect(result).toBe('skills/.curated/advanced-skill/SKILL.md');\n  });\n\n  it('path not under tempDir returns null (Windows)', () => {\n    const tempDir = 'C:\\\\Users\\\\test\\\\AppData\\\\Local\\\\Temp\\\\abc123';\n    const skillPath = 'C:\\\\Users\\\\test\\\\AppData\\\\Local\\\\Temp\\\\other\\\\my-skill';\n    const result = calculateRelativePath(tempDir, skillPath, '\\\\');\n    expect(result).toBeNull();\n  });\n\n  it('handles similar path prefixes correctly (Windows)', () => {\n    // This tests that we don't match partial directory names\n    const tempDir = 'C:\\\\Users\\\\test\\\\AppData\\\\Local\\\\Temp\\\\abc';\n    const skillPath = 'C:\\\\Users\\\\test\\\\AppData\\\\Local\\\\Temp\\\\abc123\\\\skills\\\\my-skill';\n    const result = calculateRelativePath(tempDir, skillPath, '\\\\');\n    expect(result).toBeNull();\n  });\n});\n"
  },
  {
    "path": "tests/source-parser.test.ts",
    "content": "/**\n * Unit tests for source-parser.ts\n *\n * These tests verify the URL parsing logic - they don't make network requests\n * or clone repositories. They ensure that given a URL string, the parser\n * correctly extracts type, url, ref (branch), and subpath.\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { platform } from 'os';\nimport { parseSource, getOwnerRepo } from '../src/source-parser.ts';\n\nconst isWindows = platform() === 'win32';\n\ndescribe('parseSource', () => {\n  describe('GitHub URL tests', () => {\n    it('GitHub URL - basic repo', () => {\n      const result = parseSource('https://github.com/owner/repo');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/owner/repo.git');\n      expect(result.ref).toBeUndefined();\n      expect(result.subpath).toBeUndefined();\n    });\n\n    it('GitHub URL - with .git suffix', () => {\n      const result = parseSource('https://github.com/owner/repo.git');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/owner/repo.git');\n    });\n\n    it('GitHub URL - tree with branch only', () => {\n      const result = parseSource('https://github.com/owner/repo/tree/feature-branch');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/owner/repo.git');\n      expect(result.ref).toBe('feature-branch');\n      expect(result.subpath).toBeUndefined();\n    });\n\n    it('GitHub URL - tree with branch and path', () => {\n      const result = parseSource('https://github.com/owner/repo/tree/main/skills/my-skill');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/owner/repo.git');\n      expect(result.ref).toBe('main');\n      expect(result.subpath).toBe('skills/my-skill');\n    });\n\n    // Note: Branch names with slashes (e.g., feature/my-feature) are ambiguous.\n    // The parser treats the first segment as branch and rest as path.\n    // This matches GitHub's URL structure behavior.\n    it('GitHub URL - tree with slash in path (ambiguous branch)', () => {\n      const result = parseSource('https://github.com/owner/repo/tree/feature/my-feature');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/owner/repo.git');\n      expect(result.ref).toBe('feature');\n      expect(result.subpath).toBe('my-feature');\n    });\n  });\n\n  describe('GitLab URL tests', () => {\n    it('GitLab URL - basic repo', () => {\n      const result = parseSource('https://gitlab.com/owner/repo');\n      expect(result.type).toBe('gitlab');\n      expect(result.url).toBe('https://gitlab.com/owner/repo.git');\n      expect(result.ref).toBeUndefined();\n    });\n\n    it('GitLab URL - tree with branch only', () => {\n      const result = parseSource('https://gitlab.com/owner/repo/-/tree/develop');\n      expect(result.type).toBe('gitlab');\n      expect(result.url).toBe('https://gitlab.com/owner/repo.git');\n      expect(result.ref).toBe('develop');\n      expect(result.subpath).toBeUndefined();\n    });\n\n    it('GitLab URL - tree with branch and path', () => {\n      const result = parseSource('https://gitlab.com/owner/repo/-/tree/main/src/skills');\n      expect(result.type).toBe('gitlab');\n      expect(result.url).toBe('https://gitlab.com/owner/repo.git');\n      expect(result.ref).toBe('main');\n      expect(result.subpath).toBe('src/skills');\n    });\n\n    it('GitLab URL - with .git suffix', () => {\n      const result = parseSource('https://gitlab.com/owner/repo.git');\n      expect(result.type).toBe('gitlab');\n      expect(result.url).toBe('https://gitlab.com/owner/repo.git');\n    });\n\n    it('GitLab URL - subgroup (2 levels)', () => {\n      const result = parseSource('https://gitlab.com/group/subgroup/repo');\n      expect(result.type).toBe('gitlab');\n      expect(result.url).toBe('https://gitlab.com/group/subgroup/repo.git');\n      expect(result.ref).toBeUndefined();\n    });\n\n    it('GitLab URL - subgroup (3 levels)', () => {\n      const result = parseSource('https://gitlab.com/coresofthq/ai/agent-skills');\n      expect(result.type).toBe('gitlab');\n      expect(result.url).toBe('https://gitlab.com/coresofthq/ai/agent-skills.git');\n      expect(result.ref).toBeUndefined();\n    });\n\n    it('GitLab URL - deep subgroup with .git suffix', () => {\n      const result = parseSource('https://gitlab.com/org/team/project/repo.git');\n      expect(result.type).toBe('gitlab');\n      expect(result.url).toBe('https://gitlab.com/org/team/project/repo.git');\n    });\n\n    it('GitLab URL - subgroup with tree/branch', () => {\n      const result = parseSource('https://gitlab.com/group/subgroup/repo/-/tree/main');\n      expect(result.type).toBe('gitlab');\n      expect(result.url).toBe('https://gitlab.com/group/subgroup/repo.git');\n      expect(result.ref).toBe('main');\n      expect(result.subpath).toBeUndefined();\n    });\n\n    it('GitLab URL - subgroup with tree/branch/path', () => {\n      const result = parseSource(\n        'https://gitlab.com/group/subgroup/repo/-/tree/main/path/to/skill'\n      );\n      expect(result.type).toBe('gitlab');\n      expect(result.url).toBe('https://gitlab.com/group/subgroup/repo.git');\n      expect(result.ref).toBe('main');\n      expect(result.subpath).toBe('path/to/skill');\n    });\n\n    it('GitLab URL - trailing slash', () => {\n      const result = parseSource('https://gitlab.com/group/subgroup/repo/');\n      expect(result.type).toBe('gitlab');\n      expect(result.url).toBe('https://gitlab.com/group/subgroup/repo.git');\n    });\n  });\n\n  describe('GitHub shorthand tests', () => {\n    it('GitHub shorthand - owner/repo', () => {\n      const result = parseSource('owner/repo');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/owner/repo.git');\n      expect(result.ref).toBeUndefined();\n      expect(result.subpath).toBeUndefined();\n    });\n\n    it('GitHub shorthand - owner/repo/path', () => {\n      const result = parseSource('owner/repo/skills/my-skill');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/owner/repo.git');\n      expect(result.subpath).toBe('skills/my-skill');\n    });\n\n    it('GitHub shorthand - owner/repo@skill (skill filter syntax)', () => {\n      const result = parseSource('owner/repo@my-skill');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/owner/repo.git');\n      expect(result.skillFilter).toBe('my-skill');\n      expect(result.subpath).toBeUndefined();\n    });\n\n    it('GitHub shorthand - owner/repo@skill with hyphenated skill name', () => {\n      const result = parseSource('vercel-labs/agent-skills@find-skills');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/vercel-labs/agent-skills.git');\n      expect(result.skillFilter).toBe('find-skills');\n    });\n  });\n\n  describe('Local path tests', () => {\n    it('Local path - relative with ./', () => {\n      const result = parseSource('./my-skills');\n      expect(result.type).toBe('local');\n      expect(result.localPath).toContain('my-skills');\n    });\n\n    it('Local path - relative with ../', () => {\n      const result = parseSource('../other-skills');\n      expect(result.type).toBe('local');\n      expect(result.localPath).toContain('other-skills');\n    });\n\n    it('Local path - current directory', () => {\n      const result = parseSource('.');\n      expect(result.type).toBe('local');\n      expect(result.localPath).toBeTruthy();\n    });\n\n    it('Local path - absolute path', () => {\n      // Use platform-specific absolute path\n      const testPath = isWindows ? 'C:\\\\Users\\\\test\\\\skills' : '/home/user/skills';\n      const result = parseSource(testPath);\n      expect(result.type).toBe('local');\n      expect(result.localPath).toBe(testPath);\n    });\n  });\n\n  describe('Git URL fallback tests', () => {\n    it('Git URL - SSH format', () => {\n      const result = parseSource('git@github.com:owner/repo.git');\n      expect(result.type).toBe('git');\n      expect(result.url).toBe('git@github.com:owner/repo.git');\n    });\n\n    it('Git URL - custom host', () => {\n      const result = parseSource('https://git.example.com/owner/repo.git');\n      expect(result.type).toBe('git');\n      expect(result.url).toBe('https://git.example.com/owner/repo.git');\n    });\n  });\n});\n\ndescribe('getOwnerRepo', () => {\n  it('getOwnerRepo - GitHub URL', () => {\n    const parsed = parseSource('https://github.com/owner/repo');\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - GitHub URL with .git', () => {\n    const parsed = parseSource('https://github.com/owner/repo.git');\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - GitHub URL with tree/branch/path', () => {\n    const parsed = parseSource('https://github.com/owner/repo/tree/main/skills/my-skill');\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - GitHub shorthand', () => {\n    const parsed = parseSource('owner/repo');\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - GitHub shorthand with subpath', () => {\n    const parsed = parseSource('owner/repo/skills/my-skill');\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - GitLab URL', () => {\n    const parsed = parseSource('https://gitlab.com/owner/repo');\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - GitLab URL with tree', () => {\n    const parsed = parseSource('https://gitlab.com/owner/repo/-/tree/main/skills');\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - GitLab URL with subgroup', () => {\n    const parsed = parseSource('https://gitlab.com/coresofthq/ai/agent-skills');\n    expect(getOwnerRepo(parsed)).toBe('coresofthq/ai/agent-skills');\n  });\n\n  it('getOwnerRepo - local path returns null', () => {\n    const parsed = parseSource('./my-skills');\n    expect(getOwnerRepo(parsed)).toBeNull();\n  });\n\n  it('getOwnerRepo - absolute local path returns null', () => {\n    const parsed = parseSource('/home/user/skills');\n    expect(getOwnerRepo(parsed)).toBeNull();\n  });\n\n  it('getOwnerRepo - custom git host extracts owner/repo', () => {\n    const parsed = parseSource('https://git.example.com/owner/repo.git');\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - SSH format extracts owner/repo', () => {\n    const parsed = parseSource('git@github.com:owner/repo.git');\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - private GitLab instance extracts owner/repo', () => {\n    const parsed = parseSource('https://gitlab.company.com/team/repo');\n    expect(getOwnerRepo(parsed)).toBe('team/repo');\n  });\n\n  it('getOwnerRepo - self-hosted git with .git suffix', () => {\n    const parsed = parseSource('https://git.internal.io/myteam/skills.git');\n    expect(getOwnerRepo(parsed)).toBe('myteam/skills');\n  });\n\n  it('getOwnerRepo - URL with query string', () => {\n    const parsed = { type: 'git', url: 'https://git.example.com/owner/repo?ref=main' } as const;\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - URL with fragment', () => {\n    const parsed = { type: 'git', url: 'https://git.example.com/owner/repo#readme' } as const;\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - URL with .git and query string', () => {\n    const parsed = { type: 'git', url: 'https://git.example.com/owner/repo.git?ref=main' } as const;\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - GitLab subgroup (2 levels)', () => {\n    const parsed = { type: 'git', url: 'https://gitlab.com/group/subgroup/repo' } as const;\n    expect(getOwnerRepo(parsed)).toBe('group/subgroup/repo');\n  });\n\n  it('getOwnerRepo - GitLab subgroup (3 levels)', () => {\n    const parsed = { type: 'git', url: 'https://gitlab.com/org/team/project/repo.git' } as const;\n    expect(getOwnerRepo(parsed)).toBe('org/team/project/repo');\n  });\n\n  it('getOwnerRepo - GitLab subgroup with query string', () => {\n    const parsed = { type: 'git', url: 'https://gitlab.com/group/subgroup/repo?ref=main' } as const;\n    expect(getOwnerRepo(parsed)).toBe('group/subgroup/repo');\n  });\n\n  it('getOwnerRepo - self-hosted GitLab with subgroups', () => {\n    const parsed = {\n      type: 'git',\n      url: 'https://gitlab.company.com/division/team/repo.git',\n    } as const;\n    expect(getOwnerRepo(parsed)).toBe('division/team/repo');\n  });\n\n  it('getOwnerRepo - SSH URL (GitHub)', () => {\n    const parsed = { type: 'git', url: 'git@github.com:owner/repo.git' } as const;\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - SSH URL (GitLab)', () => {\n    const parsed = { type: 'git', url: 'git@gitlab.com:owner/repo.git' } as const;\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - SSH URL with subgroups (GitLab)', () => {\n    const parsed = {\n      type: 'git',\n      url: 'git@gitlab.com:group/subgroup/project/repo.git',\n    } as const;\n    expect(getOwnerRepo(parsed)).toBe('group/subgroup/project/repo');\n  });\n\n  it('getOwnerRepo - SSH URL without .git suffix', () => {\n    const parsed = { type: 'git', url: 'git@github.com:owner/repo' } as const;\n    expect(getOwnerRepo(parsed)).toBe('owner/repo');\n  });\n\n  it('getOwnerRepo - SSH URL (custom host)', () => {\n    const parsed = { type: 'git', url: 'git@git.company.com:org/team/repo.git' } as const;\n    expect(getOwnerRepo(parsed)).toBe('org/team/repo');\n  });\n\n  it('getOwnerRepo - SSH URL without path (returns null)', () => {\n    const parsed = { type: 'git', url: 'git@github.com:repo.git' } as const;\n    expect(getOwnerRepo(parsed)).toBeNull();\n  });\n});\n\ndescribe('Source aliases', () => {\n  it('resolves coinbase/agentWallet to coinbase/agentic-wallet-skills', () => {\n    const result = parseSource('coinbase/agentWallet');\n    expect(result.type).toBe('github');\n    expect(result.url).toBe('https://github.com/coinbase/agentic-wallet-skills.git');\n  });\n});\n\ndescribe('Prefix shorthand tests', () => {\n  describe('github: prefix', () => {\n    it('github:owner/repo - basic', () => {\n      const result = parseSource('github:owner/repo');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/owner/repo.git');\n      expect(result.subpath).toBeUndefined();\n    });\n\n    it('github:owner/repo/subpath', () => {\n      const result = parseSource('github:owner/repo/skills/my-skill');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/owner/repo.git');\n      expect(result.subpath).toBe('skills/my-skill');\n    });\n\n    it('github:owner/repo@skill-name', () => {\n      const result = parseSource('github:owner/repo@my-skill');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/owner/repo.git');\n      expect(result.skillFilter).toBe('my-skill');\n    });\n\n    it('github:googleworkspace/cli', () => {\n      const result = parseSource('github:googleworkspace/cli');\n      expect(result.type).toBe('github');\n      expect(result.url).toBe('https://github.com/googleworkspace/cli.git');\n    });\n  });\n\n  describe('gitlab: prefix', () => {\n    it('gitlab:owner/repo - basic', () => {\n      const result = parseSource('gitlab:owner/repo');\n      expect(result.type).toBe('gitlab');\n      expect(result.url).toBe('https://gitlab.com/owner/repo.git');\n    });\n\n    it('gitlab:group/subgroup/repo', () => {\n      const result = parseSource('gitlab:group/subgroup/repo');\n      expect(result.type).toBe('gitlab');\n      expect(result.url).toBe('https://gitlab.com/group/subgroup/repo.git');\n    });\n  });\n});\n"
  },
  {
    "path": "tests/subpath-traversal.test.ts",
    "content": "/**\n * Tests for path traversal prevention in subpath handling.\n *\n * These tests verify that:\n * 1. parseSource() rejects subpaths containing \"..\" segments\n * 2. isSubpathSafe() correctly detects traversal attempts\n * 3. discoverSkills() throws on unsafe subpaths\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { parseSource, sanitizeSubpath } from '../src/source-parser.ts';\nimport { isSubpathSafe } from '../src/skills.ts';\n\ndescribe('sanitizeSubpath', () => {\n  it('allows normal subpaths', () => {\n    expect(sanitizeSubpath('skills/my-skill')).toBe('skills/my-skill');\n    expect(sanitizeSubpath('path/to/skill')).toBe('path/to/skill');\n    expect(sanitizeSubpath('src')).toBe('src');\n  });\n\n  it('rejects subpaths with .. segments', () => {\n    expect(() => sanitizeSubpath('../etc')).toThrow('Unsafe subpath');\n    expect(() => sanitizeSubpath('../../etc/passwd')).toThrow('Unsafe subpath');\n    expect(() => sanitizeSubpath('skills/../../etc')).toThrow('Unsafe subpath');\n    expect(() => sanitizeSubpath('a/b/../../../etc')).toThrow('Unsafe subpath');\n  });\n\n  it('rejects subpaths with backslash traversal', () => {\n    expect(() => sanitizeSubpath('..\\\\etc')).toThrow('Unsafe subpath');\n    expect(() => sanitizeSubpath('..\\\\..\\\\secret')).toThrow('Unsafe subpath');\n  });\n\n  it('allows paths with dots that are not traversal', () => {\n    expect(sanitizeSubpath('.hidden')).toBe('.hidden');\n    expect(sanitizeSubpath('file.txt')).toBe('file.txt');\n    expect(sanitizeSubpath('path/to/.config')).toBe('path/to/.config');\n    expect(sanitizeSubpath('..skill')).toBe('..skill');\n    expect(sanitizeSubpath('skill..')).toBe('skill..');\n  });\n});\n\ndescribe('isSubpathSafe', () => {\n  it('returns true for subpaths within basePath', () => {\n    expect(isSubpathSafe('/tmp/repo', 'skills')).toBe(true);\n    expect(isSubpathSafe('/tmp/repo', 'skills/my-skill')).toBe(true);\n    expect(isSubpathSafe('/tmp/repo', 'a/b/c')).toBe(true);\n  });\n\n  it('returns false for subpaths that escape basePath', () => {\n    expect(isSubpathSafe('/tmp/repo', '..')).toBe(false);\n    expect(isSubpathSafe('/tmp/repo', '../etc')).toBe(false);\n    expect(isSubpathSafe('/tmp/repo', '../../etc/passwd')).toBe(false);\n    expect(isSubpathSafe('/tmp/repo', 'skills/../../..')).toBe(false);\n  });\n\n  it('handles normalized traversal that stays within', () => {\n    // \"skills/../other\" normalizes to \"other\" which is still within basePath\n    expect(isSubpathSafe('/tmp/repo', 'skills/../other')).toBe(true);\n  });\n\n  it('handles edge case of subpath resolving to basePath itself', () => {\n    expect(isSubpathSafe('/tmp/repo', '.')).toBe(true);\n    expect(isSubpathSafe('/tmp/repo', 'skills/..')).toBe(true);\n  });\n});\n\ndescribe('parseSource rejects traversal in subpaths', () => {\n  describe('GitHub tree URLs with path traversal', () => {\n    it('rejects .. in GitHub tree URL subpath', () => {\n      expect(() => parseSource('https://github.com/owner/repo/tree/main/../../etc')).toThrow(\n        'Unsafe subpath'\n      );\n    });\n\n    it('rejects deeply nested traversal', () => {\n      expect(() => parseSource('https://github.com/owner/repo/tree/main/a/b/../../../etc')).toThrow(\n        'Unsafe subpath'\n      );\n    });\n\n    it('allows valid GitHub tree URL subpath', () => {\n      const result = parseSource('https://github.com/owner/repo/tree/main/skills/my-skill');\n      expect(result.subpath).toBe('skills/my-skill');\n    });\n  });\n\n  describe('GitLab tree URLs with path traversal', () => {\n    it('rejects .. in GitLab tree URL subpath', () => {\n      expect(() => parseSource('https://gitlab.com/owner/repo/-/tree/main/../../etc')).toThrow(\n        'Unsafe subpath'\n      );\n    });\n\n    it('allows valid GitLab tree URL subpath', () => {\n      const result = parseSource('https://gitlab.com/owner/repo/-/tree/main/src/skills');\n      expect(result.subpath).toBe('src/skills');\n    });\n  });\n\n  describe('GitHub shorthand with path traversal', () => {\n    it('rejects .. in shorthand subpath', () => {\n      // Note: owner/repo/../../etc is parsed as owner/repo with subpath ../../etc\n      // The shorthand regex captures everything after owner/repo as subpath\n      expect(() => parseSource('owner/repo/../../etc')).toThrow('Unsafe subpath');\n    });\n\n    it('allows valid shorthand subpath', () => {\n      const result = parseSource('owner/repo/skills/my-skill');\n      expect(result.subpath).toBe('skills/my-skill');\n    });\n  });\n});\n"
  },
  {
    "path": "tests/sync.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs';\nimport { join } from 'path';\nimport { tmpdir } from 'os';\nimport { runCli } from '../src/test-utils.ts';\n\ndescribe('experimental_sync command', () => {\n  let testDir: string;\n\n  beforeEach(() => {\n    testDir = join(tmpdir(), `skills-sync-test-${Date.now()}`);\n    mkdirSync(testDir, { recursive: true });\n  });\n\n  afterEach(() => {\n    if (existsSync(testDir)) {\n      rmSync(testDir, { recursive: true, force: true });\n    }\n  });\n\n  describe('node_modules discovery', () => {\n    it('should find SKILL.md at package root', () => {\n      // Create a package with SKILL.md at root\n      const pkgDir = join(testDir, 'node_modules', 'my-skill-pkg');\n      mkdirSync(pkgDir, { recursive: true });\n      writeFileSync(\n        join(pkgDir, 'SKILL.md'),\n        `---\nname: root-skill\ndescription: A skill at package root\n---\n\n# Root Skill\nInstructions.\n`\n      );\n\n      const result = runCli(['experimental_sync', '-y', '-a', 'claude-code'], testDir);\n      expect(result.stdout).toContain('root-skill');\n      expect(result.stdout).toContain('my-skill-pkg');\n    });\n\n    it('should find skills in skills/ subdirectory', () => {\n      const skillDir = join(testDir, 'node_modules', 'my-lib', 'skills', 'helper-skill');\n      mkdirSync(skillDir, { recursive: true });\n      writeFileSync(\n        join(skillDir, 'SKILL.md'),\n        `---\nname: helper-skill\ndescription: A helper skill in skills/ dir\n---\n\n# Helper\nInstructions.\n`\n      );\n\n      const result = runCli(['experimental_sync', '-y', '-a', 'claude-code'], testDir);\n      expect(result.stdout).toContain('helper-skill');\n      expect(result.stdout).toContain('my-lib');\n    });\n\n    it('should find skills in scoped packages', () => {\n      const pkgDir = join(testDir, 'node_modules', '@acme', 'tools');\n      mkdirSync(pkgDir, { recursive: true });\n      writeFileSync(\n        join(pkgDir, 'SKILL.md'),\n        `---\nname: acme-tool\ndescription: A skill from a scoped package\n---\n\n# Acme Tool\nInstructions.\n`\n      );\n\n      const result = runCli(['experimental_sync', '-y', '-a', 'claude-code'], testDir);\n      expect(result.stdout).toContain('acme-tool');\n      expect(result.stdout).toContain('@acme/tools');\n    });\n\n    it('should show no skills found when node_modules is empty', () => {\n      mkdirSync(join(testDir, 'node_modules'), { recursive: true });\n\n      const result = runCli(['experimental_sync', '-y'], testDir);\n      expect(result.stdout).toContain('No skills found');\n    });\n\n    it('should show no skills found when no node_modules exists', () => {\n      const result = runCli(['experimental_sync', '-y'], testDir);\n      expect(result.stdout).toContain('No skills found');\n    });\n  });\n\n  describe('skills-lock.json', () => {\n    it('should write skills-lock.json after sync', () => {\n      const pkgDir = join(testDir, 'node_modules', 'my-pkg');\n      mkdirSync(pkgDir, { recursive: true });\n      writeFileSync(\n        join(pkgDir, 'SKILL.md'),\n        `---\nname: lock-test-skill\ndescription: Test lock file writing\n---\n\n# Lock Test\nInstructions.\n`\n      );\n\n      runCli(['experimental_sync', '-y', '-a', 'claude-code'], testDir);\n\n      const lockPath = join(testDir, 'skills-lock.json');\n      expect(existsSync(lockPath)).toBe(true);\n\n      const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));\n      expect(lock.version).toBe(1);\n      expect(lock.skills['lock-test-skill']).toBeDefined();\n      expect(lock.skills['lock-test-skill'].source).toBe('my-pkg');\n      expect(lock.skills['lock-test-skill'].sourceType).toBe('node_modules');\n      expect(lock.skills['lock-test-skill'].computedHash).toMatch(/^[a-f0-9]{64}$/);\n    });\n\n    it('should not have timestamps in lock entries', () => {\n      const pkgDir = join(testDir, 'node_modules', 'my-pkg');\n      mkdirSync(pkgDir, { recursive: true });\n      writeFileSync(\n        join(pkgDir, 'SKILL.md'),\n        `---\nname: no-timestamp-skill\ndescription: No timestamps\n---\n\n# Test\n`\n      );\n\n      runCli(['experimental_sync', '-y', '-a', 'claude-code'], testDir);\n\n      const lock = JSON.parse(readFileSync(join(testDir, 'skills-lock.json'), 'utf-8'));\n      const entry = lock.skills['no-timestamp-skill'];\n      expect(entry.installedAt).toBeUndefined();\n      expect(entry.updatedAt).toBeUndefined();\n    });\n\n    it('should sort skills alphabetically in lock file', () => {\n      // Create three packages in reverse order\n      for (const name of ['zebra-skill', 'alpha-skill', 'mid-skill']) {\n        const pkgDir = join(testDir, 'node_modules', name);\n        mkdirSync(pkgDir, { recursive: true });\n        writeFileSync(\n          join(pkgDir, 'SKILL.md'),\n          `---\nname: ${name}\ndescription: ${name} description\n---\n\n# ${name}\n`\n        );\n      }\n\n      runCli(['experimental_sync', '-y', '-a', 'claude-code'], testDir);\n\n      const raw = readFileSync(join(testDir, 'skills-lock.json'), 'utf-8');\n      const keys = Object.keys(JSON.parse(raw).skills);\n      expect(keys).toEqual(['alpha-skill', 'mid-skill', 'zebra-skill']);\n    });\n\n    it('should skip unchanged skills on second sync', () => {\n      const pkgDir = join(testDir, 'node_modules', 'my-pkg');\n      mkdirSync(pkgDir, { recursive: true });\n      writeFileSync(\n        join(pkgDir, 'SKILL.md'),\n        `---\nname: cached-skill\ndescription: Test caching\n---\n\n# Cached\n`\n      );\n\n      // First sync\n      runCli(['experimental_sync', '-y', '-a', 'claude-code'], testDir);\n\n      // Second sync - should say up to date\n      const result = runCli(['experimental_sync', '-y', '-a', 'claude-code'], testDir);\n      expect(result.stdout).toContain('up to date');\n    });\n\n    it('should reinstall when --force is used', () => {\n      const pkgDir = join(testDir, 'node_modules', 'my-pkg');\n      mkdirSync(pkgDir, { recursive: true });\n      writeFileSync(\n        join(pkgDir, 'SKILL.md'),\n        `---\nname: force-skill\ndescription: Test force\n---\n\n# Force\n`\n      );\n\n      // First sync\n      runCli(['experimental_sync', '-y', '-a', 'claude-code'], testDir);\n\n      // Second sync with --force should reinstall\n      const result = runCli(['experimental_sync', '-y', '-a', 'claude-code', '--force'], testDir);\n      expect(result.stdout).toContain('force-skill');\n      expect(result.stdout).not.toContain('All skills are up to date');\n    });\n  });\n\n  describe('CLI routing', () => {\n    it('should show experimental_sync in help output', () => {\n      const result = runCli(['--help']);\n      expect(result.stdout).toContain('experimental_sync');\n    });\n\n    it('should show experimental_sync in banner', () => {\n      const result = runCli([]);\n      expect(result.stdout).toContain('experimental_sync');\n    });\n  });\n\n  describe('multiple skills from one package', () => {\n    it('should discover multiple skills in skills/ subdirectory', () => {\n      const pkg = join(testDir, 'node_modules', 'multi-skill-pkg');\n      for (const name of ['skill-one', 'skill-two']) {\n        const dir = join(pkg, 'skills', name);\n        mkdirSync(dir, { recursive: true });\n        writeFileSync(\n          join(dir, 'SKILL.md'),\n          `---\nname: ${name}\ndescription: ${name} from multi package\n---\n\n# ${name}\n`\n        );\n      }\n\n      const result = runCli(['experimental_sync', '-y', '-a', 'claude-code'], testDir);\n      expect(result.stdout).toContain('skill-one');\n      expect(result.stdout).toContain('skill-two');\n      expect(result.stdout).toContain('multi-skill-pkg');\n    });\n  });\n});\n"
  },
  {
    "path": "tests/wellknown-provider.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { WellKnownProvider } from '../src/providers/wellknown.ts';\n\ndescribe('WellKnownProvider', () => {\n  const provider = new WellKnownProvider();\n\n  describe('match', () => {\n    it('should match arbitrary HTTP URLs', () => {\n      expect(provider.match('https://example.com').matches).toBe(true);\n      expect(provider.match('https://docs.example.com/skills').matches).toBe(true);\n      expect(provider.match('http://localhost:3000').matches).toBe(true);\n    });\n\n    it('should match URLs with paths', () => {\n      expect(provider.match('https://mintlify.com/docs').matches).toBe(true);\n      expect(provider.match('https://example.com/api/v1').matches).toBe(true);\n    });\n\n    it('should not match GitHub URLs', () => {\n      expect(provider.match('https://github.com/owner/repo').matches).toBe(false);\n    });\n\n    it('should not match GitLab URLs', () => {\n      expect(provider.match('https://gitlab.com/owner/repo').matches).toBe(false);\n    });\n\n    it('should not match HuggingFace URLs', () => {\n      expect(provider.match('https://huggingface.co/spaces/owner/repo').matches).toBe(false);\n    });\n\n    it('should not match non-HTTP URLs', () => {\n      expect(provider.match('git@github.com:owner/repo.git').matches).toBe(false);\n      expect(provider.match('ssh://git@example.com/repo').matches).toBe(false);\n      expect(provider.match('/local/path').matches).toBe(false);\n    });\n  });\n\n  describe('getSourceIdentifier', () => {\n    it('should return full hostname', () => {\n      expect(provider.getSourceIdentifier('https://example.com')).toBe('example.com');\n      expect(provider.getSourceIdentifier('https://mintlify.com')).toBe('mintlify.com');\n      expect(provider.getSourceIdentifier('https://lovable.dev')).toBe('lovable.dev');\n    });\n\n    it('should return same identifier regardless of path', () => {\n      expect(provider.getSourceIdentifier('https://example.com/docs')).toBe('example.com');\n      expect(provider.getSourceIdentifier('https://example.com/api/v1')).toBe('example.com');\n    });\n\n    it('should preserve subdomains', () => {\n      expect(provider.getSourceIdentifier('https://docs.example.com')).toBe('docs.example.com');\n      expect(provider.getSourceIdentifier('https://api.mintlify.com/docs')).toBe(\n        'api.mintlify.com'\n      );\n      expect(provider.getSourceIdentifier('https://mppx-discovery-skills.vercel.app')).toBe(\n        'mppx-discovery-skills.vercel.app'\n      );\n    });\n\n    it('should strip www. prefix', () => {\n      expect(provider.getSourceIdentifier('https://www.example.com')).toBe('example.com');\n      expect(provider.getSourceIdentifier('https://www.mintlify.com/docs')).toBe('mintlify.com');\n    });\n\n    it('should return unknown for invalid URLs', () => {\n      expect(provider.getSourceIdentifier('not-a-url')).toBe('unknown');\n    });\n  });\n\n  describe('toRawUrl', () => {\n    it('should return index.json URL for base URLs', () => {\n      const result = provider.toRawUrl('https://example.com');\n      expect(result).toBe('https://example.com/.well-known/skills/index.json');\n    });\n\n    it('should return index.json URL with path', () => {\n      const result = provider.toRawUrl('https://example.com/docs');\n      expect(result).toBe('https://example.com/docs/.well-known/skills/index.json');\n    });\n\n    it('should return SKILL.md URL if already pointing to skill.md', () => {\n      const url = 'https://example.com/.well-known/skills/my-skill/SKILL.md';\n      expect(provider.toRawUrl(url)).toBe(url);\n    });\n  });\n\n  describe('isValidSkillEntry (via fetchIndex validation)', () => {\n    // Since isValidSkillEntry is private, we test it indirectly through the provider's behavior\n\n    it('provider should have id \"well-known\"', () => {\n      expect(provider.id).toBe('well-known');\n    });\n\n    it('provider should have display name \"Well-Known Skills\"', () => {\n      expect(provider.displayName).toBe('Well-Known Skills');\n    });\n  });\n});\n\ndescribe('parseSource with well-known URLs', async () => {\n  // Import parseSource after provider is defined\n  const { parseSource } = await import('../src/source-parser.ts');\n\n  it('should parse arbitrary URL as well-known type', () => {\n    const result = parseSource('https://example.com');\n    expect(result.type).toBe('well-known');\n    expect(result.url).toBe('https://example.com');\n  });\n\n  it('should parse URL with path as well-known type', () => {\n    const result = parseSource('https://mintlify.com/docs');\n    expect(result.type).toBe('well-known');\n    expect(result.url).toBe('https://mintlify.com/docs');\n  });\n\n  it('should not parse GitHub URL as well-known', () => {\n    const result = parseSource('https://github.com/owner/repo');\n    expect(result.type).toBe('github');\n  });\n\n  it('should not parse .git URL as well-known', () => {\n    const result = parseSource('https://git.example.com/owner/repo.git');\n    expect(result.type).toBe('git');\n  });\n\n  it('should parse direct skill.md URL as well-known (no more direct-url type)', () => {\n    const result = parseSource('https://docs.example.com/skill.md');\n    expect(result.type).toBe('well-known');\n  });\n});\n"
  },
  {
    "path": "tests/xdg-config-paths.test.ts",
    "content": "/**\n * Tests for XDG config path handling (cross-platform).\n *\n * These tests verify that agents using XDG Base Directory specification\n * (OpenCode, Amp, Goose) use ~/.config paths consistently across all platforms,\n * NOT platform-specific paths like ~/Library/Preferences on macOS.\n *\n * This is critical because OpenCode uses xdg-basedir which always returns\n * ~/.config (or $XDG_CONFIG_HOME if set), regardless of platform.\n * The skills CLI must match this behavior to install skills in the correct location.\n *\n * See: https://github.com/vercel-labs/skills/pull/66\n * See: https://github.com/vercel-labs/skills/issues/63\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { homedir } from 'os';\nimport { join } from 'path';\nimport { agents } from '../src/agents.ts';\n\ndescribe('XDG config paths', () => {\n  const home = homedir();\n\n  describe('OpenCode', () => {\n    it('uses ~/.config/opencode/skills for global skills (not ~/Library/Preferences)', () => {\n      const expected = join(home, '.config', 'opencode', 'skills');\n      expect(agents.opencode.globalSkillsDir).toBe(expected);\n    });\n\n    it('does NOT use platform-specific paths like ~/Library/Preferences', () => {\n      expect(agents.opencode.globalSkillsDir).not.toContain('Library');\n      expect(agents.opencode.globalSkillsDir).not.toContain('Preferences');\n      expect(agents.opencode.globalSkillsDir).not.toContain('AppData');\n    });\n  });\n\n  describe('Amp', () => {\n    it('uses ~/.config/agents/skills for global skills', () => {\n      const expected = join(home, '.config', 'agents', 'skills');\n      expect(agents.amp.globalSkillsDir).toBe(expected);\n    });\n\n    it('does NOT use platform-specific paths', () => {\n      expect(agents.amp.globalSkillsDir).not.toContain('Library');\n      expect(agents.amp.globalSkillsDir).not.toContain('Preferences');\n      expect(agents.amp.globalSkillsDir).not.toContain('AppData');\n    });\n  });\n\n  describe('Goose', () => {\n    it('uses ~/.config/goose/skills for global skills', () => {\n      const expected = join(home, '.config', 'goose', 'skills');\n      expect(agents.goose.globalSkillsDir).toBe(expected);\n    });\n\n    it('does NOT use platform-specific paths', () => {\n      expect(agents.goose.globalSkillsDir).not.toContain('Library');\n      expect(agents.goose.globalSkillsDir).not.toContain('Preferences');\n      expect(agents.goose.globalSkillsDir).not.toContain('AppData');\n    });\n  });\n\n  describe('skill lock file path', () => {\n    function getSkillLockPath(xdgStateHome: string | undefined, homeDir: string): string {\n      if (xdgStateHome) {\n        return join(xdgStateHome, 'skills', '.skill-lock.json');\n      }\n      return join(homeDir, '.agents', '.skill-lock.json');\n    }\n\n    it('uses XDG_STATE_HOME when set', () => {\n      const result = getSkillLockPath('/custom/state', home);\n      expect(result).toBe(join('/custom/state', 'skills', '.skill-lock.json'));\n    });\n\n    it('falls back to ~/.agents when XDG_STATE_HOME is not set', () => {\n      const result = getSkillLockPath(undefined, home);\n      expect(result).toBe(join(home, '.agents', '.skill-lock.json'));\n    });\n  });\n\n  describe('non-XDG agents', () => {\n    it('cursor uses ~/.cursor/skills (home-based, not XDG)', () => {\n      const expected = join(home, '.cursor', 'skills');\n      expect(agents.cursor.globalSkillsDir).toBe(expected);\n    });\n\n    it('cline uses ~/.agents/skills (home-based, not XDG)', () => {\n      const expected = join(home, '.agents', 'skills');\n      expect(agents.cline.globalSkillsDir).toBe(expected);\n    });\n  });\n});\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    // Environment setup & latest features\n    \"lib\": [\"ESNext\"],\n    \"target\": \"ESNext\",\n    \"module\": \"ESNext\",\n    \"moduleDetection\": \"force\",\n    \"jsx\": \"react-jsx\",\n    \"allowJs\": true,\n\n    // Bundler mode\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n\n    // Best practices\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedIndexedAccess\": true,\n\n    // Some stricter flags (disabled by default)\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"noPropertyAccessFromIndexSignature\": false\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  }
]