[
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  pull_request:\n  push:\n    branches: [main]\n\nconcurrency:\n  group: ci-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  web:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: npm\n      - run: npm ci\n      - run: npm run version:check\n      - run: npm run lint\n      - run: npm run build\n\n  rust:\n    runs-on: ubuntu-latest\n    defaults:\n      run:\n        working-directory: src-tauri\n    steps:\n      - uses: actions/checkout@v4\n      - name: Install system dependencies (linux)\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libglib2.0-dev libgtk-3-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf\n      - uses: dtolnay/rust-toolchain@stable\n      - uses: Swatinem/rust-cache@v2\n        with:\n          workspaces: src-tauri\n      - run: cargo fmt --all -- --check\n      - run: cargo clippy --all-targets --all-features -- -D warnings\n      - run: cargo test --all\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - \"v*\"\n  workflow_dispatch:\n\npermissions:\n  contents: write\n\nconcurrency:\n  group: release-${{ github.ref_name }}\n  cancel-in-progress: true\n\njobs:\n  release:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          # macOS Intel (x86_64)\n          - os: macos-14\n            target: x86_64-apple-darwin\n            arch: x86_64\n          # macOS Apple Silicon (arm64)\n          - os: macos-14\n            target: aarch64-apple-darwin\n            arch: aarch64\n          # Windows x64 (Intel/AMD)\n          - os: windows-2022\n            target: x86_64-pc-windows-msvc\n            arch: x64\n          # Windows on Arm (aarch64)\n          - os: windows-2022\n            target: aarch64-pc-windows-msvc\n            arch: arm64\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n          cache: npm\n\n      - name: Setup Rust\n        uses: dtolnay/rust-toolchain@stable\n\n      - name: Rust cache\n        uses: Swatinem/rust-cache@v2\n        with:\n          workspaces: src-tauri\n\n      - name: Add macOS targets\n        run: rustup target add ${{ matrix.target }}\n\n      - name: Install macOS build deps\n        if: matrix.os == 'macos-14'\n        shell: bash\n        run: |\n          set -euxo pipefail\n          brew install pkg-config cmake\n\n      - name: Install Windows build deps\n        if: matrix.os == 'windows-2022'\n        shell: bash\n        run: |\n          set -euxo pipefail\n          # Install Strawberry Perl for OpenSSL build (required by openssl-sys)\n          choco install -y strawberryperl\n          # Add Strawberry Perl to PATH\n          export PATH=\"/c/Strawberry/perl/bin:$PATH\"\n          # WebView2 Runtime is pre-installed on windows-2022 runner\n          rustup target add ${{ matrix.target }}\n\n      - name: Install frontend deps\n        run: npm ci\n\n      - name: Prepare Tauri signing key\n        shell: bash\n        env:\n          RAW_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}\n          RAW_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}\n        run: |\n          node <<'NODE'\n          const fs = require('fs');\n\n          const raw = (process.env.RAW_KEY || '').trim();\n          if (!raw) {\n            console.warn('⚠️ 未配置 TAURI_SIGNING_PRIVATE_KEY，将生成未签名版本');\n            process.exit(0);\n          }\n\n          const looksLikeBase64 = (s) => /^[A-Za-z0-9+/=]+$/.test(s);\n          const startsWithUntrusted = (s) => s.split(/\\r?\\n/)[0].startsWith('untrusted comment:');\n\n          let normalized = '';\n          if (startsWithUntrusted(raw)) {\n            normalized = raw.endsWith('\\n') ? raw : raw + '\\n';\n          } else if (looksLikeBase64(raw)) {\n            try {\n              const decoded = Buffer.from(raw, 'base64').toString('utf8');\n              if (startsWithUntrusted(decoded.trim())) {\n                normalized = decoded.trimEnd() + '\\n';\n              } else {\n                normalized = `untrusted comment: tauri signing key\\n${raw.replace(/\\s+/g, '')}\\n`;\n              }\n            } catch {\n              normalized = `untrusted comment: tauri signing key\\n${raw.replace(/\\s+/g, '')}\\n`;\n            }\n          } else {\n            try {\n              const decoded = Buffer.from(raw, 'base64').toString('utf8');\n              if (startsWithUntrusted(decoded.trim())) {\n                normalized = decoded.trimEnd() + '\\n';\n              } else {\n                throw new Error('not minisign');\n              }\n            } catch {\n              console.error('❌ TAURI_SIGNING_PRIVATE_KEY 格式无法识别（不是两行原文/其 base64/单行 base64）');\n              process.exit(1);\n            }\n          }\n\n          const keyB64 = Buffer.from(normalized, 'utf8').toString('base64');\n          const envFile = process.env.GITHUB_ENV;\n          fs.appendFileSync(envFile, `TAURI_SIGNING_PRIVATE_KEY=${keyB64}\\n`);\n\n          const pwd = (process.env.RAW_PASSWORD || '').trim();\n          if (pwd) fs.appendFileSync(envFile, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${pwd}\\n`);\n          NODE\n\n      - name: Import Apple certificate (codesign)\n        shell: bash\n        env:\n          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}\n          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}\n          APPLE_SIGNING_IDENTITY_INPUT: ${{ secrets.APPLE_SIGNING_IDENTITY }}\n          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}\n        run: |\n          set -euo pipefail\n\n          if [ -z \"${APPLE_CERTIFICATE:-}\" ]; then\n            echo \"ℹ️ 未配置 APPLE_CERTIFICATE，跳过 codesign 导入\"\n            exit 0\n          fi\n          if [ -z \"${APPLE_CERTIFICATE_PASSWORD:-}\" ]; then\n            echo \"❌ 缺少 Secret：APPLE_CERTIFICATE_PASSWORD\" >&2\n            exit 1\n          fi\n          if [ -z \"${KEYCHAIN_PASSWORD:-}\" ]; then\n            echo \"❌ 缺少 Secret：KEYCHAIN_PASSWORD\" >&2\n            exit 1\n          fi\n\n          CERT_PATH=\"$RUNNER_TEMP/certificate.p12\"\n          KEYCHAIN_PATH=\"$RUNNER_TEMP/build.keychain-db\"\n\n          echo \"$APPLE_CERTIFICATE\" | (base64 --decode 2>/dev/null || base64 -D) > \"$CERT_PATH\"\n\n          security create-keychain -p \"$KEYCHAIN_PASSWORD\" \"$KEYCHAIN_PATH\"\n          security set-keychain-settings -lut 21600 \"$KEYCHAIN_PATH\"\n          security unlock-keychain -p \"$KEYCHAIN_PASSWORD\" \"$KEYCHAIN_PATH\"\n          security list-keychains -d user -s \"$KEYCHAIN_PATH\"\n          security default-keychain -s \"$KEYCHAIN_PATH\"\n\n          security import \"$CERT_PATH\" -k \"$KEYCHAIN_PATH\" -P \"$APPLE_CERTIFICATE_PASSWORD\" -T /usr/bin/codesign -T /usr/bin/security\n          security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k \"$KEYCHAIN_PASSWORD\" \"$KEYCHAIN_PATH\"\n\n          echo \"Available identities:\"\n          security find-identity -v -p codesigning \"$KEYCHAIN_PATH\" || true\n\n          if [ -n \"${APPLE_SIGNING_IDENTITY_INPUT:-}\" ]; then\n            echo \"APPLE_SIGNING_IDENTITY=${APPLE_SIGNING_IDENTITY_INPUT}\" >> \"$GITHUB_ENV\"\n          else\n            IDENTITY=\"$(security find-identity -v -p codesigning \"$KEYCHAIN_PATH\" | head -n 1 | sed -n 's/.*\\\"\\\\(.*\\\\)\\\".*/\\\\1/p' || true)\"\n            if [ -z \"$IDENTITY\" ]; then\n              echo \"❌ 未能从 keychain 推断 APPLE_SIGNING_IDENTITY，请配置 Secret：APPLE_SIGNING_IDENTITY\" >&2\n              exit 1\n            fi\n            echo \"APPLE_SIGNING_IDENTITY=$IDENTITY\" >> \"$GITHUB_ENV\"\n          fi\n\n      - name: Build Tauri App (macOS)\n        if: matrix.os == 'macos-14'\n        # updater 产物（*.tar.gz/*.sig）是基于 .app bundle 生成的，因此必须包含 app\n        shell: bash\n        run: |\n          set -euo pipefail\n          echo \"=== Building Tauri App for macOS ===\"\n          echo \"Target: ${{ matrix.target }}\"\n          echo \"Arch: ${{ matrix.arch }}\"\n          if [ -n \"${APPLE_SIGNING_IDENTITY:-}\" ]; then\n            echo \"✅ 使用 APPLE_SIGNING_IDENTITY=$APPLE_SIGNING_IDENTITY 进行签名\"\n          else\n            echo \"ℹ️ 未配置 APPLE_SIGNING_IDENTITY，构建未签名版本\"\n            unset APPLE_SIGNING_IDENTITY\n          fi\n          npm run tauri:build -- --target ${{ matrix.target }} --bundles app,dmg\n          echo \"=== Build completed ===\"\n          ls -la src-tauri/target/${{ matrix.target }}/release/bundle/ || true\n\n      - name: Build Tauri App (Windows)\n        if: matrix.os == 'windows-2022'\n        # Windows 产物：只生成 NSIS 安装程序\n        shell: bash\n        run: |\n          set -euo pipefail\n          echo \"=== Building Tauri App for Windows ===\"\n          echo \"Target: ${{ matrix.target }}\"\n          echo \"Arch: ${{ matrix.arch }}\"\n          # Add Strawberry Perl to PATH for OpenSSL build\n          export PATH=\"/c/Strawberry/perl/bin:$PATH\"\n          npm run tauri:build -- --target ${{ matrix.target }} --bundles nsis --no-sign\n          echo \"=== Build completed ===\"\n          ls -la src-tauri/target/${{ matrix.target }}/release/bundle/ || true\n\n      - name: Verify codesign (optional)\n        shell: bash\n        run: |\n          set -euo pipefail\n          if [ -z \"${APPLE_CERTIFICATE:-}\" ]; then\n            echo \"ℹ️ 未配置 APPLE_CERTIFICATE，跳过验签\"\n            exit 0\n          fi\n          TARGET=\"${{ matrix.target }}\"\n          APP_PATH=\"$(find \"src-tauri/target/${TARGET}/release/bundle\" -type d -name \"*.app\" | head -n 1 || true)\"\n          if [ -z \"$APP_PATH\" ]; then\n            echo \"⚠️ 未找到 .app，跳过验签\" >&2\n            exit 0\n          fi\n          echo \"APP_PATH=$APP_PATH\"\n          codesign -dv --verbose=4 \"$APP_PATH\" || true\n          codesign --verify --deep --strict --verbose=4 \"$APP_PATH\"\n\n      - name: Prepare macOS Assets\n        if: matrix.os == 'macos-14'\n        shell: bash\n        run: |\n          set -euo pipefail\n\n          mkdir -p release-assets\n          VERSION=\"${GITHUB_REF_NAME}\" # e.g. v0.1.4\n          ARCH=\"${{ matrix.arch }}\"\n          TARGET=\"${{ matrix.target }}\"\n\n          # 产物路径在不同 target 下可能不同：优先 target/<triple>/...，兜底 target/release/...\n          BUNDLE_DIRS=(\n            \"src-tauri/target/${TARGET}/release/bundle\"\n            \"src-tauri/target/release/bundle\"\n          )\n\n          TAR_GZ=\"\"\n          DMG=\"\"\n          for dir in \"${BUNDLE_DIRS[@]}\"; do\n            if [ -d \"${dir}\" ]; then\n              if [ -z \"${TAR_GZ}\" ]; then\n                TAR_GZ=\"$(find \"${dir}\" -type f -name \"*.tar.gz\" | head -n 1 || true)\"\n              fi\n              if [ -z \"${DMG}\" ]; then\n                DMG=\"$(find \"${dir}\" -type f -name \"*.dmg\" | head -n 1 || true)\"\n              fi\n            fi\n          done\n\n          if [ -z \"${TAR_GZ}\" ]; then\n            echo \"❌ 未找到 *.tar.gz updater 产物（target=${TARGET}, arch=${ARCH}）。请确认 `src-tauri/tauri.conf.json` 已设置 `bundle.createUpdaterArtifacts=true`，且构建包含 `--bundles app`。\" >&2\n            for dir in \"${BUNDLE_DIRS[@]}\"; do\n              echo \"---- list: ${dir}\" >&2\n              ls -la \"${dir}\" 2>/dev/null || true\n              find \"${dir}\" -maxdepth 6 -type f 2>/dev/null | head -n 200 >&2 || true\n            done\n            exit 1\n          fi\n\n          if [ ! -f \"${TAR_GZ}.sig\" ]; then\n            echo \"❌ 未找到 updater 签名文件：${TAR_GZ}.sig\" >&2\n            exit 1\n          fi\n\n          NEW_TAR_GZ=\"Skills-Hub-${VERSION}-macOS-${ARCH}.tar.gz\"\n          cp \"${TAR_GZ}\" \"release-assets/${NEW_TAR_GZ}\"\n          cp \"${TAR_GZ}.sig\" \"release-assets/${NEW_TAR_GZ}.sig\"\n\n          if [ -n \"${DMG}\" ]; then\n            NEW_DMG=\"Skills-Hub-${VERSION}-macOS-${ARCH}.dmg\"\n            cp \"${DMG}\" \"release-assets/${NEW_DMG}\"\n          else\n            echo \"⚠️ 未找到 macOS .dmg（可选）\" >&2\n          fi\n\n      - name: Prepare Windows Assets\n        if: matrix.os == 'windows-2022'\n        shell: bash\n        run: |\n          set -euo pipefail\n\n          mkdir -p release-assets\n          VERSION=\"${GITHUB_REF_NAME}\" # e.g. v0.1.4\n          ARCH=\"${{ matrix.arch }}\"\n          TARGET=\"${{ matrix.target }}\"\n\n          # 产物路径在不同 target 下可能不同：优先 target/<triple>/...，兜底 target/release/...\n          BUNDLE_DIRS=(\n            \"src-tauri/target/${TARGET}/release/bundle\"\n            \"src-tauri/target/release/bundle\"\n          )\n\n          EXE=\"\"\n          for dir in \"${BUNDLE_DIRS[@]}\"; do\n            if [ -d \"${dir}\" ]; then\n              if [ -z \"${EXE}\" ]; then\n                EXE=\"$(find \"${dir}\" -type f -name \"*.exe\" | head -n 1 || true)\"\n              fi\n            fi\n          done\n\n          if [ -z \"${EXE}\" ]; then\n            echo \"❌ 未找到 *.exe 安装程序（target=${TARGET}, arch=${ARCH}）。请确认构建包含 `--bundles nsis`。\" >&2\n            for dir in \"${BUNDLE_DIRS[@]}\"; do\n              echo \"---- list: ${dir}\" >&2\n              ls -la \"${dir}\" 2>/dev/null || true\n              find \"${dir}\" -maxdepth 6 -type f 2>/dev/null | head -n 200 >&2 || true\n            done\n            exit 1\n          fi\n\n          NEW_EXE=\"Skills-Hub-${VERSION}-Windows-${ARCH}.exe\"\n          cp \"${EXE}\" \"release-assets/${NEW_EXE}\"\n\n      - name: List prepared assets\n        shell: bash\n        run: ls -la release-assets || true\n\n      - name: Upload workflow artifacts\n        uses: actions/upload-artifact@v4\n        with:\n          name: release-assets-${{ matrix.arch }}\n          path: release-assets/*\n          if-no-files-found: error\n\n  assemble-updater-json:\n    name: Assemble updater.json\n    runs-on: macos-14\n    needs: release\n    permissions:\n      contents: write\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Download workflow artifacts\n        uses: actions/download-artifact@v4\n        with:\n          path: dl\n          pattern: release-assets-*\n          merge-multiple: true\n\n      - name: Generate release notes from changelog\n        env:\n          TAG: ${{ github.ref_name }}\n        shell: bash\n        run: |\n          set -euo pipefail\n          node scripts/extract-changelog.mjs \"$TAG\" CHANGELOG.md > release-notes.md\n          {\n            echo\n            echo '**Windows Note:** Windows SmartScreen may show a warning during installation. This is normal for unsigned executables. The application is safe to use.'\n            echo\n            echo '**macOS Note:** macOS Gatekeeper workaround (only needed on some macOS versions): `xattr -cr \"/Applications/Skills Hub.app\"` (https://v2.tauri.app/distribute/#macos).'\n          } >> release-notes.md\n\n      - name: Generate updater.json\n        env:\n          REPO: ${{ github.repository }}\n          TAG: ${{ github.ref_name }}\n        shell: bash\n        run: |\n          set -euo pipefail\n\n          VERSION=\"${TAG#v}\"\n          PUB_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)\n          base_url=\"https://github.com/$REPO/releases/download/$TAG\"\n\n          mac_arm_url=\"\"; mac_arm_sig=\"\"\n          mac_x64_url=\"\"; mac_x64_sig=\"\"\n\n          ls -la dl || true\n\n          shopt -s nullglob\n          for sig in dl/*.sig; do\n            base=${sig%.sig}\n            fname=$(basename \"$base\")\n            url=\"$base_url/$fname\"\n            sig_content=$(tr -d '\\r\\n' < \"$sig\")\n            case \"$fname\" in\n              *-macOS-aarch64.tar.gz)\n                mac_arm_url=\"$url\"; mac_arm_sig=\"$sig_content\";;\n              *-macOS-x86_64.tar.gz)\n                mac_x64_url=\"$url\"; mac_x64_sig=\"$sig_content\";;\n            esac\n          done\n\n          if [ -z \"${mac_arm_url:-}\" ] && [ -z \"${mac_x64_url:-}\" ]; then\n            echo \"❌ 未找到任何 macOS updater 签名（dl/*.sig）; 请确认构建产物包含 *.tar.gz.sig\" >&2\n            exit 1\n          fi\n\n          # Read release notes if available (generated in prior step or inline)\n          NOTES_FILE=\"release-notes.md\"\n          if [ -f \"$NOTES_FILE\" ]; then\n            NOTES_CONTENT=$(cat \"$NOTES_FILE\")\n          else\n            NOTES_CONTENT=\"Release $TAG\"\n          fi\n          # Escape for JSON: backslashes, double quotes, newlines\n          NOTES_JSON=$(printf '%s' \"$NOTES_CONTENT\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g' | awk '{printf \"%s\\\\n\", $0}' | sed 's/\\\\n$//')\n\n          tmp_json=$(mktemp)\n          {\n            echo '{'\n            echo \"  \\\"version\\\": \\\"$VERSION\\\",\"\n            echo \"  \\\"notes\\\": \\\"$NOTES_JSON\\\",\"\n            echo \"  \\\"pub_date\\\": \\\"$PUB_DATE\\\",\"\n            echo '  \"platforms\": {'\n            first=1\n            if [ -n \"${mac_arm_url:-}\" ] && [ -n \"${mac_arm_sig:-}\" ]; then\n              [ $first -eq 0 ] && echo ','\n              echo \"    \\\"darwin-aarch64\\\": {\\\"signature\\\": \\\"$mac_arm_sig\\\", \\\"url\\\": \\\"$mac_arm_url\\\"}\"\n              first=0\n            fi\n            if [ -n \"${mac_x64_url:-}\" ] && [ -n \"${mac_x64_sig:-}\" ]; then\n              [ $first -eq 0 ] && echo ','\n              echo \"    \\\"darwin-x86_64\\\": {\\\"signature\\\": \\\"$mac_x64_sig\\\", \\\"url\\\": \\\"$mac_x64_url\\\"}\"\n              first=0\n            fi\n            echo '  }'\n            echo '}'\n          } > \"$tmp_json\"\n\n          cat \"$tmp_json\"\n          mv \"$tmp_json\" updater.json\n\n      - name: Create/Update GitHub Release\n        uses: softprops/action-gh-release@v2\n        with:\n          tag_name: ${{ github.ref_name }}\n          name: Skills Hub ${{ github.ref_name }}\n          prerelease: ${{ contains(github.ref_name, '-') }}\n          body_path: release-notes.md\n          files: |\n            dl/*\n            updater.json\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/update-featured-skills.yml",
    "content": "name: Update Featured Skills\n\non:\n  schedule:\n    - cron: '0 0 * * *'\n  workflow_dispatch:\n\npermissions:\n  contents: write\n\njobs:\n  update:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n\n      - name: Fetch featured skills\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: node scripts/fetch-featured-skills.mjs\n\n      - name: Commit if changed\n        run: |\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n          git add featured-skills.json\n          git diff --cached --quiet || git commit -m \"chore: update featured-skills.json\"\n          git push\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n.cursor\n.trae\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\ncoverage/\nplans/\n\n.env\n\nbun.lock\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# Skills Hub - Project Rules\n\n## Overview\n\nSkills Hub is a cross-platform desktop app (Tauri 2 + React 19) for managing AI Agent Skills and syncing them to 47+ AI coding tools. Core concept: \"Install once, sync everywhere.\"\n\n## Tech Stack\n\n- **Frontend**: React 19 + TypeScript 5.9 (strict) + Vite 7 + Tailwind CSS 4\n- **Backend**: Rust (Edition 2021, MSRV 1.77.2) + Tauri 2\n- **Database**: SQLite (rusqlite, bundled)\n- **Git**: libgit2 (git2 crate, vendored-openssl)\n- **HTTP**: reqwest (rustls-tls, blocking)\n- **i18n**: i18next (English / Chinese bilingual)\n- **Notifications**: sonner (toast)\n- **Icons**: lucide-react\n\n## Common Commands\n\n```bash\nnpm run dev              # Vite dev server (port 5173)\nnpm run tauri:dev        # Tauri dev window (frontend + backend)\nnpm run build            # tsc + vite build\nnpm run check            # Full check: lint + build + rust:fmt:check + rust:clippy + rust:test\nnpm run lint             # ESLint (flat config v9)\nnpm run rust:test        # cargo test\nnpm run rust:clippy      # Rust lint\nnpm run rust:fmt         # Rust format\nnpm run rust:fmt:check   # Rust format check\n```\n\nAlways run `npm run check` before committing to ensure all checks pass.\n\n## Directory Structure\n\n```\nsrc/                          # React frontend\n├── App.tsx                   # Root component (centralized state, all modal states)\n├── App.css                   # Global styles (all component styles live here)\n├── index.css                 # CSS variables (theming) + Tailwind entry\n├── components/\n│   ├── Layout.tsx            # Main layout (sidebar + content area)\n│   └── skills/               # Skills feature module\n│       ├── Header.tsx        # Top bar (branding + language toggle + new button)\n│       ├── FilterBar.tsx     # Filter/sort bar\n│       ├── SkillsList.tsx    # Skills list container\n│       ├── SkillCard.tsx     # Individual skill card\n│       ├── LoadingOverlay.tsx\n│       ├── types.ts          # Shared DTO type definitions (frontend ↔ backend)\n│       └── modals/           # Modal components (8 total)\n└── i18n/\n    ├── index.ts              # i18next initialization\n    └── resources.ts          # Translation resources (EN/ZH)\n\nsrc-tauri/src/                # Rust backend\n├── main.rs                   # Entry point (calls app_lib::run)\n├── lib.rs                    # App initialization (plugin registration, DB, cleanup tasks)\n├── commands/\n│   ├── mod.rs                # Tauri command layer (23 commands + DTOs)\n│   └── tests/\n└── core/                     # Core business logic\n    ├── skill_store.rs        # SQLite ORM (4 tables: skills, skill_targets, settings, discovered_skills)\n    ├── installer.rs          # Skill installation (local/git, with multi-skill detection)\n    ├── sync_engine.rs        # Sync engine (symlink/junction/copy triple fallback)\n    ├── git_fetcher.rs        # Git clone/pull (with cache and TTL)\n    ├── tool_adapters/mod.rs  # Tool adapter registry (47 AI tools)\n    ├── onboarding.rs         # Existing skill scanning/discovery\n    ├── github_search.rs      # GitHub API search\n    ├── central_repo.rs       # Central repository path management\n    ├── content_hash.rs       # SHA256 directory content hashing\n    ├── cache_cleanup.rs      # Git cache cleanup\n    ├── temp_cleanup.rs       # Temp directory cleanup\n    └── tests/                # One test file per module (10 total)\n```\n\n## Architecture\n\n### Frontend ↔ Backend Communication\n- Uses Tauri IPC (`invoke`) to call backend commands\n- Frontend call pattern: `const result = await invoke('command_name', { param })`\n- Backend commands are defined in `commands/mod.rs` and registered in `lib.rs` via `generate_handler!`\n- New commands must be registered in both places\n\n### Frontend State Management\n- **No state management library** — all state is centralized in `App.tsx` via `useState`\n- Passed to child components via props drilling (modals receive many props)\n- Data refresh pattern: call `invoke('get_managed_skills')` after operations to re-fetch the list\n\n### Backend Layering\n- `commands/` layer: Tauri command definitions, DTO conversions, error formatting (no business logic)\n- `core/` layer: Pure business logic, independently testable\n- Async commands use `tauri::async_runtime::spawn_blocking` to wrap synchronous operations\n- Shared state injected via `app.manage(store)` + `State<'_, SkillStore>`\n\n### Error Handling\n- Backend uses `anyhow::Result<T>`, converted to string via `format_anyhow_error()` for the frontend\n- Special error prefixes for frontend identification: `MULTI_SKILLS|`, `TARGET_EXISTS|`, `TOOL_NOT_INSTALLED|`\n- Frontend catches with try-catch and displays errors via sonner toast\n\n## Coding Conventions\n\n### TypeScript\n- Strict mode: `noUnusedLocals` and `noUnusedParameters` are enabled — unused variables/params cause compile errors\n- Component files: PascalCase (`SkillCard.tsx`)\n- Props types: `ComponentNameProps` (`SkillCardProps`)\n- CSS class names: kebab-case (`modal-backdrop`, `skill-card`)\n- Modal conditional rendering: `if (!open) return null` (full unmount, not display:none)\n- Wrap presentational components with `memo()`\n- All user-visible text must use i18n (`t('key')`), translation keys defined in `src/i18n/resources.ts`\n- When adding new text, always provide both English and Chinese translations\n- DTO types are defined in `src/components/skills/types.ts` and must stay in sync with the Rust DTOs in `commands/mod.rs`\n\n### Rust\n- Functions/methods: snake_case\n- Constants: SCREAMING_SNAKE_CASE\n- Tauri command parameters use camelCase (to match frontend JS calling convention)\n- Use `anyhow::Context` to add context to errors\n- New core modules must be exported in `core/mod.rs`\n- Tests use `tempfile` crate for temp directories and `mockito` for HTTP mocking\n\n### Styling\n- Component styles go in `src/App.css` (not CSS Modules), using semantic CSS class names\n- Theming via CSS variables + `[data-theme=\"dark\"]` selector, variables defined in `src/index.css`\n- Tailwind utility classes and custom CSS classes can be mixed\n\n## Development Workflow\n\n1. **Before implementing**: Briefly describe the approach and list the files to be modified. Wait for confirmation before writing code.\n2. **Implement completely**: For features involving both frontend and backend, modify both sides in one pass — including Tauri command registration, DTO types, i18n translations (both EN and ZH), and UI.\n3. **Verify after changes**: Always run `npm run check` after implementation to ensure lint, build, and all Rust checks pass. Fix any errors before presenting the result.\n4. **Keep changes minimal**: Only modify what is necessary for the requirement. Do not refactor, add comments, or \"improve\" unrelated code.\n\n## Important Notes\n\n- Path handling must support `~` expansion (backend has `expand_home_path()`)\n- Sync strategy uses triple fallback: symlink → junction (Windows) → copy\n- Git uses vendored-openssl, HTTP uses rustls-tls — avoids system SSL issues\n- Version numbers must stay in sync between `package.json` and `src-tauri/tauri.conf.json` (validate with `npm run version:check`)\n- Rust crate is named `app_lib` (not the default package name) — use `app_lib::...` for imports\n- Database has a schema migration mechanism (`migrate_legacy_db_if_needed`) — consider migrations when modifying table structures\n- Tool adapter list is in `tool_adapters/mod.rs` — adding a new AI tool requires both a `ToolId` enum variant and an adapter instance\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n## [Unreleased]\n\n## [0.6.0] - 2026-05-05\n\n### Added\n- **Skill tags**: Add custom tags to managed skills for easier organization and filtering.\n- **Tags page**: Manage tags from a dedicated Tags page, including create, rename, delete, and quick navigation back to filtered My Skills views.\n- **Tag filtering**: Filter My Skills by one or more tags with OR matching, including a virtual `Untagged` filter for skills without tags.\n- **Per-skill tag editor**: Edit a skill's tag assignments directly from the skill card.\n- **Import search**: Search discovered skill candidates by name, description, or path before importing from a local directory or Git repository.\n\n### Changed\n- **My Skills filter bar**: Removed the manual refresh button; install, delete, sync, and tag-edit flows already refresh the list automatically.\n\n### Fixed\n- **Chinese filter bar layout**: Removing the refresh button fixes the cramped button layout in Chinese.\n- **Discovered skills review**: The discovered skills review dialog now supports search and keeps selection counts aligned with filtered results.\n\n## [0.5.0] - 2026-04-16\n\n### Added\n- **Project-level skill sync**: Skills can now be synced to selected project directories instead of only global tool directories.\n- **Skill scope controls**: My Skills cards now show a scope badge (`Global` / project count) and include a scope modal for switching between global and project sync.\n- **Scope filtering**: My Skills can be filtered by All / Global / Project scope.\n- **Hermes Agent adapter**: Added global sync support for Hermes Agent via `~/.hermes/skills` ([#54](https://github.com/qufei1993/skills-hub/issues/54)).\n\n### Changed\n- **My Skills filter bar**: The section title now displays the total skill count, search is more compact, and filter controls stay on one line in the default window.\n- **Default window size**: Increased the default desktop window from `800x600` to `960x680`.\n- **macOS close behavior**: Closing the main window now hides it instead of quitting the app; reopening from the Dock restores and focuses the window.\n- **Project sync support matrix**: Project-level sync is now treated as an explicit per-tool capability; tools without a confirmed project skills directory are global-only.\n\n### Fixed\n- **Import takeover for identical skills**: Importing an existing skill can now safely take over same-name targets when the content hash matches.\n- **Unsynced tool re-enable entry**: Tool buttons that were unsynced from a skill remain visible so they can be re-enabled.\n- **SKILL.md metadata parsing**: YAML block scalar descriptions in frontmatter now render correctly in skill cards and detail views.\n\n## [0.4.3] - 2026-04-11\n\n### Added\n- **Copaw tool adapter**: Support for Copaw AI coding tool (thanks @LeonDevLifeLog [PR#50](https://github.com/qufei1993/skills-hub/pull/50)).\n\n### Fixed\n- **Git skill install & frontmatter rendering**: Fixed issues with Git-based skill installation and frontmatter metadata rendering.\n- **Git skill discovery for container paths**: Fixed skill discovery failing when repository uses container-style directory paths.\n\n## [0.4.2] - 2026-04-06\n\n### Fixed\n- **New tools modal style**: \"New tools detected\" dialog now uses consistent header/footer structure (`modal-header` + `modal-footer`) matching all other modals, fixing missing padding and border separators ([#46](https://github.com/qufei1993/skills-hub/issues/46)).\n- **Git skill name derivation**: Installing a Git skill from a repo root (subpath `\".\"`) now correctly derives the name from the repository URL instead of using `\".\"` as the display name.\n\n## [0.4.1] - 2026-03-21\n\n### Added\n- **Frontmatter metadata table**: Markdown files with YAML frontmatter now render a GitHub-style metadata table at the top of the skill detail view.\n\n## [0.4.0] - 2026-03-20\n\n### Added\n- **In-app update check**: Check for updates directly within Settings, download and install without leaving the app ([#33](https://github.com/qufei1993/skills-hub/issues/33)).\n- **QoderWork tool adapter**: Support for QoderWork desktop AI agent (`~/.qoderwork/skills/`) ([#34](https://github.com/qufei1993/skills-hub/issues/34)).\n\n### Changed\n- **Settings promoted to full page**: Settings moved from a modal dialog to a dedicated page view, consistent with My Skills / Explore navigation pattern.\n- **Curated skills aggregation**: Explore page now sources skills from a curated list of 7 high-quality repositories.\n\n### Fixed\n- Language toggle briefly flashing \"Installing Skills...\" loading overlay on Explore page.\n\n## [0.3.0] - 2026-03-15\n\n### Added\n- **Explore page**: Explore promoted from a modal tab to an independent page with My Skills / Explore top-level navigation.\n- **Featured skills**: Explore page displays curated skills from ClawHub API (updated daily via GitHub Actions) with frontend filtering and one-click install.\n- **Online skill search**: Real-time search via skills.sh API (triggered at 2+ characters, 500ms debounce), results deduplicated against the featured list and shown in separate sections.\n- **Skill detail view**: Click a skill name to browse its files with a file tree, Markdown rendering (GFM + frontmatter stripping), and syntax highlighting (40+ languages, light/dark theme adaptive).\n- **Skill description field**: Description extracted from SKILL.md frontmatter at install time, stored in database, and displayed on My Skills cards.\n- **GitHub Token setting**: Optional GitHub Token input in settings to increase API rate limit from 60 to 5,000 requests/hour.\n- **MoltBot tool adapter**: Added standalone MoltBot tool support after OpenClaw rename/split.\n\n### Fixed\n- Git install deriving skill name as \"skills\" when URL points to a `skills/` subdirectory, causing duplicated sync paths ([#28](https://github.com/qufei1993/skills-hub/issues/28)).\n- GitHub API rate-limit errors now display the exact reset time instead of a generic message.\n- Windows \"Access Denied\" OS error 5 when syncing to tools ([#20](https://github.com/qufei1993/skills-hub/issues/20)).\n- Git repo directory structures not correctly recognized as skills ([#18](https://github.com/qufei1993/skills-hub/issues/18), [#8](https://github.com/qufei1993/skills-hub/issues/8)).\n- Repos using `.claude/skills/` directory format not detected ([#27](https://github.com/qufei1993/skills-hub/issues/27)).\n- OpenClaw path updated from `.moltbot/skills` to `.openclaw/skills` ([#29](https://github.com/qufei1993/skills-hub/issues/29)).\n\n### Changed\n- My Skills list: tool badges now only show synced tools, collapsing to `+N more` beyond 5.\n- Manual Add modal simplified to Local Directory / Git Repository tabs only (Explore tab removed).\n- Multi-skill repo online install now auto-matches target skill (exact → unique-contains → fallback to manual picker).\n\n## [0.2.0] - 2026-02-01\n\n### Added\n- **Windows platform support**: Full support for Windows build and release (thanks @jrtxio [PR#6](https://github.com/qufei1993/skills-hub/pull/6)).\n- Support and display for many new tools (e.g., Kimi Code CLI, Augment, OpenClaw, Cline, CodeBuddy, Command Code, Continue, Crush, Junie, iFlow CLI, Kiro CLI, Kode, MCPJam, Mistral Vibe, Mux, OpenClaude IDE, OpenHands, Pi, Qoder, Qwen Code, Trae/Trae CN, Zencoder, Neovate, Pochi, AdaL).\n- UI confirmation and linked selection for tools that share the same global skills directory.\n- Local import multi-skill discovery aligned with Git rules, with a selection list and invalid-item reasons.\n- New local import commands for listing candidates and installing a selected subpath with SKILL.md validation.\n\n### Changed\n- Antigravity global skills directory updated to `~/.gemini/antigravity/global_skills`.\n- OpenCode global skills directory corrected to `~/.config/opencode/skills`.\n- Tool status now includes `skills_dir`; frontend tool list/sync is driven by backend data and deduped by directory.\n- Sync/unsync now updates records across tools sharing a skills directory to avoid duplicate filesystem work and inconsistent state.\n- Local import flow now scans candidates first; single valid candidate installs directly, multi-candidate opens selection.\n\n## [0.1.1] - 2026-01-26\n\n### Changed\n- GitHub Actions release workflow for macOS packaging and uploading `updater.json` (`.github/workflows/release.yml`).\n- Cursor sync now always uses directory copy due to Cursor not following symlinks when discovering skills: https://forum.cursor.com/t/cursor-doesnt-follow-symlinks-to-discover-skills/149693/4\n- Managed skill update now re-syncs copy-mode targets using copy-only overwrite, and forces Cursor targets to copy to avoid accidental relinking.\n\n## [0.1.0] - 2026-01-25\n\n### Added\n- Initial release of Skills Hub desktop app (Tauri + React).\n- Central repository for Skills; sync to multiple AI coding tools (symlink/junction preferred, copy fallback).\n- Local import from folders.\n- Git import via repository URL or folder URL (`/tree/<branch>/<path>`), with multi-skill selection and batch install.\n- Sync and update: copy-mode targets can be refreshed; managed skills can be updated from source.\n- Migration intake: scan existing tool directories, import into central repo, and one‑click sync.\n- New tool detection and optional sync.\n- Basic settings: storage path, language, and theme.\n- Git cache with cleanup (days) and freshness window (seconds).\n\n### Build & Release\n- Local packaging scripts for macOS (dmg), Windows (msi/nsis), Linux (deb/appimage).\n- GitHub Actions build validation and tag-based draft releases (release notes pulled from `CHANGELOG.md`).\n\n### Performance\n- Git import and batch install optimizations: cached clones reduce repeated fetches; timeouts and non‑interactive git improve stability.\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "Read and follow all instructions in [AGENTS.md](./AGENTS.md).\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Code of Conduct\n\nThis project follows the Contributor Covenant Code of Conduct (v2.1).\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, caste, color, religion, or sexual\nidentity and orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n- Demonstrating empathy and kindness toward other people\n- Being respectful of differing opinions, viewpoints, and experiences\n- Giving and gracefully accepting constructive feedback\n- Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n- Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n- The use of sexualized language or imagery, and sexual attention or advances\n- Trolling, insulting or derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\n`qzfweb@gmail.com`.\n\nAll complaints will be reviewed and investigated promptly and fairly.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or permanent\nban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior, harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within the\ncommunity.\n\n## Attribution\n\nThis Code of Conduct is adapted from the Contributor Covenant, version 2.1,\navailable at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nThanks for taking the time to contribute to Skills Hub!\n\n## Development Requirements\n\n- Node.js 18+ (recommended: 20+)\n- Rust (stable)\n- Tauri system dependencies (install per the official Tauri docs for macOS/Windows/Linux)\n\n## Run Locally\n\n```bash\nnpm install\nnpm run tauri:dev\n```\n\n## Quality Checks\n\n```bash\nnpm run lint\nnpm run build\n```\n\n## Run Unit Tests\n\nRust unit tests live under `src-tauri/src/core/tests/`.\n\n```bash\ncd src-tauri\ncargo test\n```\n\n## Before Submitting a PR\n\n- Ensure `npm run lint` and `npm run build` pass\n- Ensure `cd src-tauri && cargo test` pass\n- Keep changes small and focused (do not commit local configs/caches/build artifacts)\n- For UI changes, include screenshots or a short recording\n\n## Reporting Issues\n\nPlease include the following in your issue report:\n\n- OS version (macOS/Windows/Linux)\n- Skills Hub version\n- Steps to reproduce and expected vs. actual behavior\n- Relevant logs (please redact local paths and any sensitive information)\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2026 Skills Hub contributors\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"
  },
  {
    "path": "README.md",
    "content": "# Skills Hub (Tauri Desktop)\n\nA cross-platform desktop app (Tauri + React) to manage Agent Skills in one place and sync them to multiple AI coding tools’ global or project-level skills directories (prefer symlink/junction, fallback to copy) — “Install once, sync everywhere”.\n\n## Documentation\n\n- English (default): `README.md` (this file)\n- 中文：[`docs/README.zh.md`](docs/README.zh.md)\n\n## Key Features\n\n- **Explore page**: Browse curated featured skills and search online — one-click install & sync to all detected tools\n- **Tags page**: Create, rename, and delete custom tags, then jump back to matching skills from one dedicated view\n- **Tag filtering**: Organize skills with multiple tags and filter My Skills by tag, including `Untagged` skills\n- **Global / project sync**: Sync skills globally across all projects, or scope them to selected project directories\n- **Scope controls**: Switch a skill between Global and Project scope, manage project directories, and filter My Skills by scope\n- **Skill detail view**: Click a skill name to browse its files with Markdown rendering and syntax highlighting (40+ languages)\n- **Unified view**: Managed skills, total skill count, scope badges, and per-tool activation status\n- **Onboarding migration**: Scan existing skills in installed tools, import into the Central Repo, and sync\n- **Import sources**: Local folder / Git URL (including searchable multi-skill repo selection, `.claude/skills/` directory support)\n- **Update**: Refresh from source; propagate updates to copy-mode targets\n- **New tool detection**: Detect newly installed tools and prompt to sync managed skills\n\n### My Skills\n![My Skills](docs/assets/my-skills.png)\n\n### Explore & Search\n![Explore](docs/assets/explore-search.png)\n\n### Manual Add\n![Manual Add](docs/assets/manual-add.png)\n\n### Skill Detail\n![Skill Detail](docs/assets/skill-detail.png)\n\n## Supported AI Coding Tools\n\nProject skills dirs are relative to the selected project root. Tools marked `N/A` do not have a confirmed project-level skills directory and are supported for global sync only.\n\n| tool key | Display name | global skills dir (relative to `~`) | project skills dir (relative to project) | detected if exists (relative to `~`) |\n| --- | --- | --- | --- | --- |\n| `cursor` | Cursor | `.cursor/skills` | `.agents/skills` | `.cursor` |\n| `claude_code` | Claude Code | `.claude/skills` | `.claude/skills` | `.claude` |\n| `codex` | Codex | `.codex/skills` | `.agents/skills` | `.codex` |\n| `opencode` | OpenCode | `.config/opencode/skills` | `.agents/skills` | `.config/opencode` |\n| `antigravity` | Antigravity | `.gemini/antigravity/skills` | `.agents/skills` | `.gemini/antigravity` |\n| `amp` | Amp | `.config/agents/skills` | `.agents/skills` | `.config/agents` |\n| `kimi_cli` | Kimi Code CLI | `.config/agents/skills` | `.agents/skills` | `.config/agents` |\n| `augment` | Augment | `.augment/skills` | `.augment/skills` | `.augment` |\n| `openclaw` | OpenClaw | `.openclaw/skills` | `skills` | `.openclaw` |\n| `copaw` | Copaw | `.copaw/skill_pool` | `.copaw/skill_pool` | `.copaw` |\n| `cline` | Cline | `.agents/skills` | `.agents/skills` | `.agents` |\n| `codebuddy` | CodeBuddy | `.codebuddy/skills` | `.codebuddy/skills` | `.codebuddy` |\n| `command_code` | Command Code | `.commandcode/skills` | `.commandcode/skills` | `.commandcode` |\n| `continue` | Continue | `.continue/skills` | `.continue/skills` | `.continue` |\n| `crush` | Crush | `.config/crush/skills` | `.crush/skills` | `.config/crush` |\n| `junie` | Junie | `.junie/skills` | `.junie/skills` | `.junie` |\n| `iflow_cli` | iFlow CLI | `.iflow/skills` | `.iflow/skills` | `.iflow` |\n| `kiro_cli` | Kiro CLI | `.kiro/skills` | `.kiro/skills` | `.kiro` |\n| `kode` | Kode | `.kode/skills` | `.kode/skills` | `.kode` |\n| `mcpjam` | MCPJam | `.mcpjam/skills` | `.mcpjam/skills` | `.mcpjam` |\n| `mistral_vibe` | Mistral Vibe | `.vibe/skills` | `.vibe/skills` | `.vibe` |\n| `mux` | Mux | `.mux/skills` | `.mux/skills` | `.mux` |\n| `openclaude` | OpenClaude IDE | `.openclaude/skills` | `.openclaude/skills` | `.openclaude` |\n| `openhands` | OpenHands | `.openhands/skills` | `.openhands/skills` | `.openhands` |\n| `pi` | Pi | `.pi/agent/skills` | `.pi/skills` | `.pi` |\n| `qoder` | Qoder | `.qoder/skills` | `.qoder/skills` | `.qoder` |\n| `qoderwork` | QoderWork | `.qoderwork/skills` | `.qoderwork/skills` | `.qoderwork` |\n| `qwen_code` | Qwen Code | `.qwen/skills` | `.qwen/skills` | `.qwen` |\n| `trae` | Trae | `.trae/skills` | `.trae/skills` | `.trae` |\n| `trae_cn` | Trae CN | `.trae-cn/skills` | `.trae/skills` | `.trae-cn` |\n| `zencoder` | Zencoder | `.zencoder/skills` | `.zencoder/skills` | `.zencoder` |\n| `neovate` | Neovate | `.neovate/skills` | `.neovate/skills` | `.neovate` |\n| `pochi` | Pochi | `.pochi/skills` | `.pochi/skills` | `.pochi` |\n| `adal` | AdaL | `.adal/skills` | `.adal/skills` | `.adal` |\n| `kilo_code` | Kilo Code | `.kilocode/skills` | `.kilocode/skills` | `.kilocode` |\n| `roo_code` | Roo Code | `.roo/skills` | `.roo/skills` | `.roo` |\n| `goose` | Goose | `.config/goose/skills` | `.goose/skills` | `.config/goose` |\n| `gemini_cli` | Gemini CLI | `.gemini/skills` | `.agents/skills` | `.gemini` |\n| `github_copilot` | GitHub Copilot | `.copilot/skills` | `.agents/skills` | `.copilot` |\n| `clawdbot` | Clawdbot | `.clawdbot/skills` | `.clawdbot/skills` | `.clawdbot` |\n| `droid` | Droid | `.factory/skills` | `.factory/skills` | `.factory` |\n| `windsurf` | Windsurf | `.codeium/windsurf/skills` | `.windsurf/skills` | `.codeium/windsurf` |\n| `moltbot` | MoltBot | `.moltbot/skills` | `.moltbot/skills` | `.moltbot` |\n| `hermes_agent` | Hermes Agent | `.hermes/skills` | N/A | `.hermes` |\n\n## Development\n\n### Prerequisites\n\n- Node.js 18+ (recommended: 20+)\n- Rust (stable)\n- Tauri system dependencies (follow Tauri official docs for your OS)\n\n```bash\nnpm install\nnpm run tauri:dev\n```\n\n### Build\n\n```bash\nnpm run lint\nnpm run build\nnpm run tauri:build\n```\n\n#### Platform build commands (from `package.json`)\n\n- macOS (dmg): `npm run tauri:build:mac:dmg`\n- macOS (universal dmg): `npm run tauri:build:mac:universal:dmg`\n- Windows (MSI): `npm run tauri:build:win:msi`\n- Windows (NSIS exe): `npm run tauri:build:win:exe`\n- Windows (MSI+NSIS): `npm run tauri:build:win:all`\n- Linux (deb): `npm run tauri:build:linux:deb`\n- Linux (AppImage): `npm run tauri:build:linux:appimage`\n- Linux (deb+AppImage): `npm run tauri:build:linux:all`\n\n### Tests (Rust)\n\n```bash\ncd src-tauri\ncargo test\n```\n\n## Contributing & Security\n\n- Contributing: [`CONTRIBUTING.md`](CONTRIBUTING.md)\n- Code of Conduct: [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md)\n- Security: [`SECURITY.md`](SECURITY.md)\n\n## FAQ / Notes\n\n- Where are skills stored? The Central Repo defaults to `~/.skillshub` (configurable in Settings).\n- What are tags for? Tags help you find and organize skills. They do not change where a skill is synced or which tools can use it.\n- What is project-level sync? The skill is still stored once in the Central Repo, but its sync target is a selected project directory such as `<project>/.agents/skills`, `<project>/.claude/skills`, or another tool-specific project skills path.\n- Why is Cursor sync always copy? Cursor currently does not support symlink/junction-based skill directories, so Skills Hub forces directory copy when syncing to Cursor.\n- Why does sync sometimes fall back to copy? Skills Hub prefers symlink/junction, but on some systems (especially Windows) symlinks may be restricted; in that case it falls back to directory copy.\n- What does `TARGET_EXISTS|...` mean? The target folder already exists and the operation did not overwrite it (default is non-destructive). Remove the existing folder or retry with the appropriate overwrite flow.\n- macOS Gatekeeper note (unsigned/notarized builds, may vary by macOS version): if you see “damaged” or “unverified developer”, run `xattr -cr \"/Applications/Skills Hub.app\"` (https://v2.tauri.app/distribute/#macos).\n\n## Supported Platforms\n\n- macOS (verified)\n- Windows (expected by design; not validated locally)\n- Linux (expected by design; not validated locally)\n\n## License\n\nMIT License — see `LICENSE`.\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nOnly the latest code on the `main` branch is supported.\n\n## Reporting a Vulnerability\n\nIf you discover a security vulnerability (e.g., arbitrary file read/write, path traversal, permission bypass, information disclosure, or command injection), please do not disclose it in a public issue.\n\nPlease report it via one of the following channels:\n\n- GitHub Security Advisories (recommended): create a private report via the repository's Security → Advisories page\n- Or email: `qzfweb@gmail.com`\n\nWe will confirm receipt and provide a remediation plan as soon as possible. Please keep details private until a fix is released.\n"
  },
  {
    "path": "docs/CHANGELOG.zh.md",
    "content": "# 更新日志\n\n本文件记录项目的重要变更（中文版本）。\n\n## [Unreleased]\n\n## [0.6.0] - 2026-05-05\n\n### 新增\n- **Skill 标签**：可为已托管 Skill 添加自定义标签，方便整理和筛选。\n- **标签页面**：新增独立 Tags / 标签页面，支持新建、重命名、删除标签，并可快速跳回已筛选的 My Skills 视图。\n- **标签筛选**：My Skills 支持按一个或多个标签筛选，使用 OR 匹配；同时提供虚拟 `Untagged` / `无标签` 筛选项。\n- **单个 Skill 标签编辑**：可直接从 Skill 卡片打开标签编辑入口，调整该 Skill 的标签关联。\n- **导入搜索**：从本地目录或 Git 仓库导入前，可按名称、描述或路径搜索候选 Skill。\n\n### 变更\n- **My Skills 筛选栏**：移除手动刷新按钮；安装、删除、同步和编辑标签等流程已自动刷新列表。\n\n### 修复\n- **中文筛选栏布局**：移除刷新按钮后，修复中文界面下按钮区域拥挤和样式错乱问题。\n- **发现 Skill 审核弹窗**：查看已发现 Skills 时支持搜索，并让选择数量与筛选结果保持一致。\n\n## [0.5.0] - 2026-04-16\n\n### 新增\n- **项目级 Skill 同步**：Skill 现在可以同步到指定项目目录，不再只支持同步到各工具的全局目录。\n- **同步范围控制**：My Skills 卡片新增范围徽标（全局 / 项目数量），并提供范围弹窗用于切换全局同步和项目同步。\n- **范围筛选**：My Skills 支持按全部 / 全局 / 项目范围筛选。\n- **Hermes Agent 工具适配**：新增 Hermes Agent 全局同步支持，目录为 `~/.hermes/skills`（[#54](https://github.com/qufei1993/skills-hub/issues/54)）。\n\n### 变更\n- **My Skills 筛选栏**：标题现在显示 Skill 总数，搜索框更紧凑，默认窗口下筛选控件保持单行展示。\n- **默认窗口尺寸**：桌面端默认窗口从 `800x600` 调整为 `960x680`。\n- **macOS 关闭行为**：点击主窗口关闭按钮现在隐藏窗口而不是退出应用；从 Dock 重新打开时会恢复并聚焦窗口。\n- **项目级同步支持矩阵**：项目级同步改为按工具显式声明；未确认项目级 skills 目录的工具仅作为全局同步目标。\n\n### 修复\n- **同名同内容 Skill 导入接管**：导入已有 Skill 时，如果目标同名目录内容一致，现在可以安全接管同步状态。\n- **取消同步后的工具重新启用入口**：从 Skill 取消同步的工具按钮会继续显示，便于重新启用。\n- **SKILL.md 元数据解析**：正确解析 frontmatter 中的 YAML block scalar 描述，并在卡片和详情页正常展示。\n\n## [0.4.3] - 2026-04-11\n\n### 新增\n- **Copaw 工具适配**：新增 Copaw AI 编程工具支持（感谢 @LeonDevLifeLog [PR#50](https://github.com/qufei1993/skills-hub/pull/50)）。\n\n### 修复\n- **Git 技能安装与 frontmatter 渲染**：修复 Git 技能安装及 frontmatter 元数据渲染问题。\n- **Git 技能发现（容器路径）**：修复仓库使用容器风格目录路径时技能发现失败的问题。\n\n## [0.4.2] - 2026-04-06\n\n### 修复\n- **检测到新工具弹窗样式**：「New tools detected」弹窗改用与其他弹窗一致的 `modal-header` + `modal-footer` 结构，修复标题缺少内边距和分隔线的问题（[#46](https://github.com/qufei1993/skills-hub/issues/46)）。\n- **Git 技能名称推导**：从仓库根目录（subpath 为 `\".\"`）安装 Git 技能时，现在正确从仓库 URL 推导名称，不再以 `\".\"` 作为展示名称。\n\n## [0.4.1] - 2026-03-21\n\n### 新增\n- **Frontmatter 元数据表格**：包含 YAML frontmatter 的 Markdown 文件在技能详情页顶部以 GitHub 风格的表格展示元数据。\n\n## [0.4.0] - 2026-03-20\n\n### 新增\n- **应用内检查更新**：在设置页内直接检查新版本，支持下载安装，无需手动访问 GitHub Releases（[#33](https://github.com/qufei1993/skills-hub/issues/33)）。\n- **QoderWork 工具适配**：新增 QoderWork 桌面 AI 代理支持（`~/.qoderwork/skills/`）（[#34](https://github.com/qufei1993/skills-hub/issues/34)）。\n\n### 变更\n- **设置页面化**：设置从模态弹窗升级为独立页面视图，与 My Skills / Explore 导航风格一致。\n- **精选技能聚合**：Explore 数据源改为 7 个精选高质量仓库。\n\n### 修复\n- 切换语言时 Explore 页面短暂闪现「Installing Skills...」加载遮罩。\n\n## [0.3.0] - 2026-03-15\n\n### 新增\n- **Explore 页面**：探索功能从弹窗提升为独立页面，顶部导航新增 My Skills / Explore 两个页面级 Tab 切换。\n- **精选技能推荐**：Explore 页展示由 ClawHub API 预生成的热门技能列表（GitHub Actions 每日更新），支持前端筛选和一键安装。\n- **在线技能搜索**：输入 ≥ 2 字符后通过 skills.sh API 实时搜索，500ms 防抖，搜索结果与精选列表自动去重、分区展示。\n- **技能详情页**：点击技能名称进入详情视图，支持文件树浏览、Markdown 渲染（GFM + frontmatter 剥离）和代码语法高亮（40+ 语言，亮/暗主题自适应）。\n- **技能描述字段**：安装时从 SKILL.md frontmatter 提取 description 存入数据库，My Skills 卡片展示描述文本。\n- **GitHub Token 配置**：设置页新增可选的 GitHub Token 输入，认证后 API 限额从 60 提升至 5000 次/小时。\n- **MoltBot 工具适配**：OpenClaw 更名拆分后新增独立的 MoltBot 工具支持。\n\n### 修复\n- Git 安装时 skill 名称为 \"skills\" 导致同步路径重复（[#28](https://github.com/qufei1993/skills-hub/issues/28)）。\n- GitHub API 限流错误未提示重置时间，现在显示具体重置时间。\n- Windows 同步时拒绝访问 OS error 5（[#20](https://github.com/qufei1993/skills-hub/issues/20)）。\n- Git 仓库目录结构无法被正确识别为 skill（[#18](https://github.com/qufei1993/skills-hub/issues/18)、[#8](https://github.com/qufei1993/skills-hub/issues/8)）。\n- 不支持 `.claude/skills/` 目录格式的仓库（[#27](https://github.com/qufei1993/skills-hub/issues/27)）。\n- OpenClaw 路径更新（`.moltbot/skills` → `.openclaw/skills`）（[#29](https://github.com/qufei1993/skills-hub/issues/29)）。\n\n### 变更\n- My Skills 列表优化：工具徽章只显示已同步的工具，超过 5 个折叠为 `+N more`。\n- 添加技能弹窗（Manual Add）精简为仅保留 Local Directory / Git Repository 两个 Tab。\n- 多技能仓库在线安装时支持自动匹配（精确 → 唯一包含 → 回退手动选择）。\n\n## [0.2.0] - 2026-02-01\n### 新增\n- **Windows 平台支持**：支持 Windows 构建与发布（感谢 @jrtxio [PR#6](https://github.com/qufei1993/skills-hub/pull/6)）。\n- 新增多款工具适配与显示（如 Kimi Code CLI、Augment、OpenClaw、Cline、CodeBuddy、Command Code、Continue、Crush、Junie、iFlow CLI、Kiro CLI、Kode、MCPJam、Mistral Vibe、Mux、OpenClaude IDE、OpenHands、Pi、Qoder、Qwen Code、Trae/Trae CN、Zencoder、Neovate、Pochi、AdaL 等）。\n- 前端新增共享技能目录提示与联动选择：同一全局 skills 目录的工具勾选/同步/取消同步会一起生效，并弹窗确认。\n- 本地导入对齐 Git 规则的 multi-skill 发现，支持批量选择并展示无效项原因。\n- 新增本地导入候选列表/按子路径安装的命令，并在安装前校验 SKILL.md。\n\n### 变更\n- Antigravity 默认全局技能目录更新为 `~/.gemini/antigravity/global_skills`。\n- OpenCode 全局技能目录修正为 `~/.config/opencode/skills`。\n- 工具状态接口增加 `skills_dir` 字段，前端列表与同步逻辑改为后端驱动并按目录去重。\n- 同一 skills 目录的工具在同步/取消同步时统一写入与清理记录，避免重复文件操作与状态不一致。\n- 本地导入流程改为先扫描候选：单个有效候选直接安装，多个候选进入选择列表。\n\n## [0.1.1] - 2026-01-26\n\n### 变更\n- GitHub Actions 发版工作流：macOS 打包并上传 `updater.json`（`.github/workflows/release.yml`）。\n- Cursor 同步固定使用 Copy：因为 Cursor 在发现 skills 时不会跟随 symlink：https://forum.cursor.com/t/cursor-doesnt-follow-symlinks-to-discover-skills/149693/4\n- 托管技能更新时：对 copy 模式目标使用“纯 copy 覆盖回灌”；并对 Cursor 目标强制回灌为 copy，避免误创建软链导致不可用。\n\n## [0.1.0] - 2026-01-24\n\n### 新增\n- Skills Hub 桌面应用（Tauri + React）初始发布。\n- Skills 中心仓库：统一托管并同步到多种 AI 编程工具（优先 symlink/junction，失败回退 copy）。\n- 本地导入：支持从本地文件夹导入 Skill。\n- Git 导入：支持仓库 URL/文件夹 URL（`/tree/<branch>/<path>`），支持多 Skill 候选选择与批量安装。\n- 同步与更新：copy 模式目标支持回灌更新；托管技能支持从来源更新。\n- 迁移接管：扫描工具目录中已有 Skills，导入中心仓库并可一键同步。\n- 新工具检测并可选择同步。\n- 基础设置：存储路径、界面语言、主题模式。\n- Git 缓存：支持按天清理与新鲜期（秒）配置。\n\n### 构建与发布\n- 本地打包脚本：macOS（dmg）、Windows（msi/nsis）、Linux（deb/appimage）。\n- GitHub Actions 跨平台构建验证与 tag 发布 Draft Release（从 `CHANGELOG.md` 自动提取发布说明）。\n\n### 性能\n- Git 导入/批量安装优化：缓存 clone 减少重复拉取；增加超时与无交互提示提升稳定性。\n"
  },
  {
    "path": "docs/README.zh.md",
    "content": "# Skills Hub（Tauri Desktop）\n\n一个跨平台桌面应用（Tauri + React），用于统一管理 Agent Skills，并把它们同步到多种 AI 编程工具的全局或项目级 skills 目录（优先 symlink/junction，失败回退 copy），实现 “Install once, sync everywhere”。\n\n> English documentation: [`README.md`](../README.md)\n\n## 主要功能\n\n- **Explore 探索页**：独立页面浏览精选技能推荐和在线搜索，一键安装并同步到所有已检测工具\n- **Tags 标签页**：在独立页面中新建、重命名、删除自定义标签，并快速跳转到对应的 Skill 列表\n- **标签筛选**：为 Skill 添加多个标签，并在 My Skills 中按标签筛选，包括查看 `无标签` Skill\n- **全局 / 项目级同步**：Skill 可同步到全局目录，在所有项目中生效；也可限定到指定项目目录中生效\n- **同步范围控制**：在全局和项目范围之间切换 Skill，管理项目目录，并按范围筛选 My Skills\n- **技能详情页**：点击技能名称查看完整文件内容，支持文件树浏览、Markdown 渲染和代码语法高亮（40+ 语言）\n- **统一视图**：查看 Hub 托管的 skills 总数、范围徽标及其在各工具的生效状态\n- **迁移接管**：扫描本机工具目录已有 skills，导入到中心仓库并可一键同步\n- **多来源导入**：本地目录 / Git 仓库 URL（含可搜索的 multi-skill 候选选择、`.claude/skills/` 目录格式支持）\n- **更新**：从原来源更新中心仓库内容，并回灌 copy 模式的目标\n- **新工具检测**：发现新安装工具时提示是否同步所有已托管 skills\n\n### My Skills — 技能管理列表\n![My Skills](./assets/my-skills.png)\n\n### Explore — 探索与在线搜索\n![Explore](./assets/explore-search.png)\n\n### Manual Add — 手动添加技能\n![Manual Add](./assets/manual-add.png)\n\n### Skill Detail — 技能详情与文件浏览\n![Skill Detail](./assets/skill-detail.png)\n\n## 支持的 AI 编程工具\n\n项目级 skills 目录相对所选项目根目录。标记为“不支持”的工具尚未确认项目级 skills 目录，仅支持全局同步。\n\n| tool key | 工具 | 全局 skills 目录（相对 `~`） | 项目级 skills 目录（相对项目根目录） | 存在即视为已安装（相对 `~`） |\n| --- | --- | --- | --- | --- |\n| `cursor` | Cursor | `.cursor/skills` | `.agents/skills` | `.cursor` |\n| `claude_code` | Claude Code | `.claude/skills` | `.claude/skills` | `.claude` |\n| `codex` | Codex | `.codex/skills` | `.agents/skills` | `.codex` |\n| `opencode` | OpenCode | `.config/opencode/skills` | `.agents/skills` | `.config/opencode` |\n| `antigravity` | Antigravity | `.gemini/antigravity/skills` | `.agents/skills` | `.gemini/antigravity` |\n| `amp` | Amp | `.config/agents/skills` | `.agents/skills` | `.config/agents` |\n| `kimi_cli` | Kimi Code CLI | `.config/agents/skills` | `.agents/skills` | `.config/agents` |\n| `augment` | Augment | `.augment/skills` | `.augment/skills` | `.augment` |\n| `openclaw` | OpenClaw | `.openclaw/skills` | `skills` | `.openclaw` |\n| `copaw` | Copaw | `.copaw/skill_pool` | `.copaw/skill_pool` | `.copaw` |\n| `cline` | Cline | `.agents/skills` | `.agents/skills` | `.agents` |\n| `codebuddy` | CodeBuddy | `.codebuddy/skills` | `.codebuddy/skills` | `.codebuddy` |\n| `command_code` | Command Code | `.commandcode/skills` | `.commandcode/skills` | `.commandcode` |\n| `continue` | Continue | `.continue/skills` | `.continue/skills` | `.continue` |\n| `crush` | Crush | `.config/crush/skills` | `.crush/skills` | `.config/crush` |\n| `junie` | Junie | `.junie/skills` | `.junie/skills` | `.junie` |\n| `iflow_cli` | iFlow CLI | `.iflow/skills` | `.iflow/skills` | `.iflow` |\n| `kiro_cli` | Kiro CLI | `.kiro/skills` | `.kiro/skills` | `.kiro` |\n| `kode` | Kode | `.kode/skills` | `.kode/skills` | `.kode` |\n| `mcpjam` | MCPJam | `.mcpjam/skills` | `.mcpjam/skills` | `.mcpjam` |\n| `mistral_vibe` | Mistral Vibe | `.vibe/skills` | `.vibe/skills` | `.vibe` |\n| `mux` | Mux | `.mux/skills` | `.mux/skills` | `.mux` |\n| `openclaude` | OpenClaude IDE | `.openclaude/skills` | `.openclaude/skills` | `.openclaude` |\n| `openhands` | OpenHands | `.openhands/skills` | `.openhands/skills` | `.openhands` |\n| `pi` | Pi | `.pi/agent/skills` | `.pi/skills` | `.pi` |\n| `qoder` | Qoder | `.qoder/skills` | `.qoder/skills` | `.qoder` |\n| `qoderwork` | QoderWork | `.qoderwork/skills` | `.qoderwork/skills` | `.qoderwork` |\n| `qwen_code` | Qwen Code | `.qwen/skills` | `.qwen/skills` | `.qwen` |\n| `trae` | Trae | `.trae/skills` | `.trae/skills` | `.trae` |\n| `trae_cn` | Trae CN | `.trae-cn/skills` | `.trae/skills` | `.trae-cn` |\n| `zencoder` | Zencoder | `.zencoder/skills` | `.zencoder/skills` | `.zencoder` |\n| `neovate` | Neovate | `.neovate/skills` | `.neovate/skills` | `.neovate` |\n| `pochi` | Pochi | `.pochi/skills` | `.pochi/skills` | `.pochi` |\n| `adal` | AdaL | `.adal/skills` | `.adal/skills` | `.adal` |\n| `kilo_code` | Kilo Code | `.kilocode/skills` | `.kilocode/skills` | `.kilocode` |\n| `roo_code` | Roo Code | `.roo/skills` | `.roo/skills` | `.roo` |\n| `goose` | Goose | `.config/goose/skills` | `.goose/skills` | `.config/goose` |\n| `gemini_cli` | Gemini CLI | `.gemini/skills` | `.agents/skills` | `.gemini` |\n| `github_copilot` | GitHub Copilot | `.copilot/skills` | `.agents/skills` | `.copilot` |\n| `clawdbot` | Clawdbot | `.clawdbot/skills` | `.clawdbot/skills` | `.clawdbot` |\n| `droid` | Droid | `.factory/skills` | `.factory/skills` | `.factory` |\n| `windsurf` | Windsurf | `.codeium/windsurf/skills` | `.windsurf/skills` | `.codeium/windsurf` |\n| `moltbot` | MoltBot | `.moltbot/skills` | `.moltbot/skills` | `.moltbot` |\n| `hermes_agent` | Hermes Agent | `.hermes/skills` | 不支持 | `.hermes` |\n\n完整路径规则与检测逻辑见 [`src-tauri/src/core/tool_adapters/mod.rs`](../src-tauri/src/core/tool_adapters/mod.rs)。\n\n## 开发\n\n### 环境要求\n\n- Node.js 18+（建议 20+）\n- Rust（stable）\n- Tauri 系统依赖（按官方文档安装）\n\n### 启动（桌面端）\n\n```bash\nnpm install\nnpm run tauri:dev\n```\n\n### 构建\n\n```bash\nnpm run lint\nnpm run build\nnpm run tauri:build\n```\n\n#### 各系统构建命令（来自 `package.json`）\n\n- macOS（dmg）：`npm run tauri:build:mac:dmg`\n- macOS（universal dmg）：`npm run tauri:build:mac:universal:dmg`\n- Windows（MSI）：`npm run tauri:build:win:msi`\n- Windows（NSIS exe）：`npm run tauri:build:win:exe`\n- Windows（MSI+NSIS）：`npm run tauri:build:win:all`\n- Linux（deb）：`npm run tauri:build:linux:deb`\n- Linux（AppImage）：`npm run tauri:build:linux:appimage`\n- Linux（deb+AppImage）：`npm run tauri:build:linux:all`\n\n### 测试（Rust）\n\n```bash\ncd src-tauri\ncargo test\n```\n\n## FAQ / 备注\n\n- Skill 存在哪里？中心仓库（Central Repo）默认是 `~/.skillshub`，可在设置里修改。\n- 标签用于什么？标签只用于查找和整理 Skill，不会改变 Skill 的同步目录，也不会改变哪些工具可以使用它。\n- 什么是项目级同步？Skill 仍然只在中心仓库保存一份，但同步目标变为指定项目目录，例如 `<project>/.agents/skills`、`<project>/.claude/skills` 或其它工具对应的项目级 skills 路径。\n- Cursor 为什么强制 Copy？Cursor 当前不支持软链（symlink/junction）形式的技能目录，因此同步到 Cursor 时会固定使用目录复制（copy）。\n- 为什么有时会变成 Copy？默认优先 symlink/junction，但在某些系统（尤其 Windows）可能因为权限/策略导致无法创建链接，会自动回退到目录复制。\n- `TARGET_EXISTS|...` 是什么意思？目标目录已存在且默认不覆盖（为了安全）。你需要先清理目标目录，或在“接管/覆盖”的明确流程里重试。\n- macOS Gatekeeper 备注（未签名/未公证构建，不同 macOS 版本表现可能不同）：如提示“已损坏/无法验证开发者”，可执行 `xattr -cr \"/Applications/Skills Hub.app\"`（https://v2.tauri.app/distribute/#macos）。\n\n## 支持的系统\n\n- macOS（已验证）\n- Windows（按架构应支持，未做本地验证）\n- Linux（按架构应支持，未做本地验证）\n\n## License\n\nMIT License（见 `LICENSE`）。\n"
  },
  {
    "path": "docs/future/profile-requirements.md",
    "content": "# Skill Profile / 配置方案后续需求记录\n\n## 背景\n\nGitHub Issue #23 提到“技能分组”能力：\n\n- Skill 作为底层资产存在。\n- 单个 Skill 可以被划分到多个组中复用。\n- 组支持快速挂载与切换。\n- 可根据不同项目和不同工具组合不同 Skill。\n\n这个方向和 v0.6.0 的标签功能有关联，但不属于同一个需求。\n\n标签解决的是：\n\n```text\n如何查找和整理 Skill。\n```\n\nProfile / 配置方案解决的是：\n\n```text\n如何让一套 Skill 作为当前工作场景实际生效。\n```\n\n因此 Profile 不纳入 v0.6.0，单独记录为后续评估需求。\n\n---\n\n## 建议命名\n\n不建议使用 `Group / 分组` 作为主 UI 名称。\n\n原因：\n\n- “分组”容易被用户理解成分类展示。\n- 它和 `Tag / 标签` 的语义过近。\n- 如果同时出现“标签”和“分组”，用户容易混淆。\n\n建议名称：\n\n```text\nProfile / 配置方案\n```\n\n中文 UI 可使用：\n\n```text\n配置方案\n```\n\n英文 UI 可使用：\n\n```text\nProfile\n```\n\n一句话定义：\n\n```text\nProfile 是一套可应用的 Skill 同步配置。\n```\n\n---\n\n## 与 Tag 的区别\n\n| 概念 | 目的 | 是否影响同步 | 是否可多选 |\n|------|------|--------------|------------|\n| Tag | 查找、筛选、整理 Skill | 否 | 是 |\n| Profile | 应用一套 Skill 配置 | 是 | 建议否 |\n\n核心区分：\n\n```text\nTag 用于找 Skill。\nProfile 用于用 Skill。\n```\n\n---\n\n## 初步产品规则\n\n### 1. Profile 建议单选激活\n\n第一版建议只允许一个 Active Profile。\n\n原因：\n\n- Profile 表示当前工作场景配置。\n- 多个 Profile 同时启用会让语义接近“标签组叠加”。\n- 多 Profile 会引入工具目标合并、冲突处理和预览复杂度。\n\n建议模型：\n\n```text\nCurrent Profile: Skills Hub Dev\n```\n\n切换 Profile 是替换当前同步配置，不是叠加。\n\n### 2. Profile 内部可以包含多个 Skill\n\n一个 Profile 可以包含多个 Skill：\n\n```text\nSkills Hub Dev\n- react\n- tauri-desktop\n- test-driven-development\n- frontend-design\n```\n\n### 3. 一个 Skill 可以属于多个 Profile\n\nSkill 是可复用资产。\n\n例如：\n\n```text\nfrontend-design\n- Skills Hub Dev\n- Review Flow\n- Docs Writing\n```\n\n同一个 Profile 内不能重复包含同一个 Skill。\n\n数据层建议使用：\n\n```sql\nUNIQUE(profile_id, skill_id)\n```\n\n### 4. Profile 可以配置目标工具\n\nProfile 可能需要记录目标工具：\n\n```text\nProfile: Skills Hub Dev\nSkills: react, tauri-desktop, test-driven-development\nTools: Cursor, Codex, Claude Code\n```\n\n是否在第一版实现目标工具配置，需要后续结合现有同步模型评估。\n\n### 5. 需要处理未加入任何 Profile 的 Skill\n\n类似标签中的 `Untagged`，Profile 维度也可能存在：\n\n```text\nUnassigned\n```\n\n含义：\n\n```text\n没有加入任何 Profile 的 Skill。\n```\n\n`Unassigned` 不是真实 Profile，而是系统虚拟状态。\n\n后续 UI 可在 Profiles 页面顶部提示：\n\n```text\n3 skills are not in any profile        [Review]\n```\n\n---\n\n## 关键交互问题\n\n后续实现前需要确认以下问题。\n\n### 1. Profile 是否直接影响同步结果\n\n需要明确：\n\n- 应用 Profile 时是否会新增同步。\n- 应用 Profile 时是否会移除不在 Profile 中的旧同步。\n- 是否只影响当前 Profile 的目标工具。\n- 是否影响全局同步和项目级同步。\n\n### 2. 切换 Profile 是否需要预览\n\n建议需要。\n\n示例：\n\n```text\nApply Docs Writing?\n\n+ 2 skills will be added\n- 3 skills will be removed\n= 1 skill will stay active\n\n[Cancel] [Apply Profile]\n```\n\n切换 Profile 涉及实际同步变更，不应该静默执行。\n\n### 3. Profile 是否绑定项目\n\nIssue #23 提到“针对不同项目”。\n\n需要评估：\n\n- Profile 是否绑定项目路径。\n- 进入某项目时是否自动推荐 Profile。\n- Profile 与 v0.5.0 项目级同步如何协作。\n- 项目切换是否自动应用 Profile。\n\n第一版建议不要自动应用，先做手动切换。\n\n### 4. 是否允许多个 Profile 同时启用\n\n当前建议不允许。\n\n如果后续确实需要多 Profile，应明确合并规则：\n\n- Skill 集合是并集还是交集。\n- 工具集合如何合并。\n- 同步移除如何判断。\n- 冲突时谁优先。\n\n在没有清晰规则前，不建议支持多 Profile 同时启用。\n\n---\n\n## 可能的 UI 方向\n\n### My Skills 顶部\n\n仅展示当前配置：\n\n```text\nCurrent Profile: Skills Hub Dev ▾\n```\n\n选择其他 Profile 后弹出变更预览。\n\n### Profiles 页面\n\n```text\nProfiles                                      [+ New Profile]\n\n左侧列表：\n- Skills Hub Dev      Active\n- Docs Writing\n- Review Flow\n\n右侧详情：\nSkills Hub Dev\nReact + Tauri + Rust workspace\n\nTools\n[Cursor] [Codex] [Claude Code]\n\nSkills\n[✓] react\n[✓] tauri-desktop\n[✓] test-driven-development\n[ ] youtube-transcript\n\n[Preview Changes] [Apply Profile]\n```\n\n### Unassigned 处理\n\nProfiles 页面顶部：\n\n```text\n3 skills are not in any profile        [Review]\n```\n\n点击 `Review` 后展示未分配 Skill，并允许加入某个 Profile。\n\n---\n\n## 数据模型草案\n\n仅供后续评估，不作为当前实现承诺。\n\n```sql\nCREATE TABLE skill_profiles (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  name TEXT NOT NULL UNIQUE,\n  description TEXT,\n  is_active INTEGER NOT NULL DEFAULT 0,\n  created_at TEXT NOT NULL,\n  updated_at TEXT NOT NULL\n);\n\nCREATE TABLE skill_profile_links (\n  profile_id INTEGER NOT NULL,\n  skill_id INTEGER NOT NULL,\n  created_at TEXT NOT NULL,\n  PRIMARY KEY (profile_id, skill_id),\n  FOREIGN KEY (profile_id) REFERENCES skill_profiles(id) ON DELETE CASCADE,\n  FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE\n);\n\nCREATE TABLE skill_profile_tools (\n  profile_id INTEGER NOT NULL,\n  tool TEXT NOT NULL,\n  created_at TEXT NOT NULL,\n  PRIMARY KEY (profile_id, tool),\n  FOREIGN KEY (profile_id) REFERENCES skill_profiles(id) ON DELETE CASCADE\n);\n```\n\n如果坚持单一 Active Profile，需要在应用层保证同一时间只有一个 `is_active = 1`。\n\n---\n\n## 暂不实现内容\n\n在需求没有进一步确认前，暂不实现：\n\n- Profile 创建 / 编辑。\n- Profile 切换。\n- Profile 自动应用到项目。\n- 多 Profile 同时启用。\n- Profile 与同步目标的合并规则。\n- Profile 未分配 Skill 的批量处理。\n\n---\n\n## 后续评估结论要求\n\n进入实现前，至少需要明确：\n\n- Profile 是否直接改变同步结果。\n- 切换 Profile 的删除策略。\n- 是否绑定项目路径。\n- 是否配置目标工具。\n- 是否只允许一个 Active Profile。\n- 未加入任何 Profile 的 Skill 如何处理。\n\n这些问题明确前，Profile 不应和 Tag 放在同一版本交付。\n"
  },
  {
    "path": "docs/releases/v0.1-v0.2/system-design.md",
    "content": "# Skills Hub (Tauri Desktop) — System Design\n\nThis document describes the system design of **Skills Hub**, aligned with the current repository implementation.\n\n> 中文版：[`docs/system-design.zh.md`](docs/system-design.zh.md)\n\n## 1. Background\n\nAI coding tools (Cursor, Claude Code, Codex, etc.) often support “Skills/Agents/Tools”, but each tool stores global skills in a different location. This causes duplicated installs, drift, and a lack of a unified view.\n\nSkills Hub solves this by storing skill contents in a **Central Repo** and mapping them into each tool via **symlink/junction/copy** — “Install once, sync everywhere”.\n\n## 2. Goals\n\n- Unified view of managed skills and per-tool activation\n- Onboarding migration: scan existing skills in installed tools, group by name, detect conflicts via content hash, then import & sync\n- Import sources: local folder and Git URLs (including multi-skill repo selection)\n- Update: refresh central content from source; propagate updates to copy-mode targets\n- Tool detection: detect newly installed tools and prompt to sync\n- Configurable Central Repo path (default `~/.skillshub`)\n\n## 3. Glossary\n\n- **Skill**: a directory-based capability package (typically contains `SKILL.md`)\n- **Managed Skill**: a skill stored in the Central Repo and indexed in SQLite\n- **Central Repo**: canonical storage directory, default `~/.skillshub`\n- **Tool/Agent**: a supported AI coding tool with a global skills directory\n- **Target**: per-tool mapping of a managed skill (symlink/junction/copy), stored in `skill_targets`\n- **Fingerprint / Content Hash**: directory hash used to detect identical vs conflicting variants\n\n## 4. Architecture\n\n### 4.1 System Context\n\n```mermaid\nflowchart TB\n  user[\"User\"]\n  app[\"Skills Hub Desktop App\\n(Tauri + React)\"]\n\n  fsTools[\"Tool global skills directories\\n~/.cursor/skills, etc.\"]\n  fsCentral[\"Central Repo\\n~/.skillshub\"]\n  db[\"SQLite\\nskills_hub.db\"]\n  cache[\"Cache\\nskills-hub-git-*\"]\n  gh[\"GitHub / Git Remote\"]\n\n  user -->|manage/import/sync| app\n  app <--> fsTools\n  app <--> fsCentral\n  app <--> db\n  app <--> cache\n  app <-->|clone/search| gh\n```\n\n### 4.2 Containers\n\n```mermaid\nflowchart LR\n  subgraph WebView[Frontend: React + Vite]\n    ui[\"UI Components\\n(App/Modals/SkillCard)\"]\n    invoke[\"invoke(command,args)\"]\n    ui --> invoke\n  end\n\n  subgraph Tauri[Backend: Rust + Tauri]\n    cmd[\"commands/*\\nDTO + spawn_blocking\"]\n    core[\"core/*\\ninstaller/sync/store/onboarding\"]\n    cmd --> core\n  end\n\n  invoke --> cmd\n  core --> db[\"SQLite\"]\n  core --> fsCentral[\"Central Repo\"]\n  core --> fsTools[\"Tools Skills Dirs\"]\n  core --> cache[\"Cache Temp Git Dirs\"]\n```\n\n## 5. Storage Design\n\n### 5.1 Filesystem\n\n- Central Repo (default): `~/.skillshub`\n- Git imports: clone into cache temp, then copy into Central Repo (Central Repo does not store `.git`)\n- Tool mapping: write into each tool’s skills directory via symlink/junction/copy\n\n### 5.2 SQLite\n\nDB path: `app_data_dir()/skills_hub.db`\n\nMain tables:\n\n- `skills`: managed skills in the Central Repo (source_type/source_ref/central_path/content_hash/updated_at, etc.)\n- `skill_targets`: per-tool activation state (tool/target_path/mode/status/synced_at)\n- `settings`: key/value settings (e.g., central repo path, installed tools set)\n\n```mermaid\nerDiagram\n  skills ||--o{ skill_targets : \"id = skill_id\"\n  skills {\n    TEXT id PK\n    TEXT name\n    TEXT source_type\n    TEXT source_ref\n    TEXT source_revision\n    TEXT central_path UK\n    TEXT content_hash\n    INTEGER created_at\n    INTEGER updated_at\n    INTEGER last_sync_at\n    INTEGER last_seen_at\n    TEXT status\n  }\n  skill_targets {\n    TEXT id PK\n    TEXT skill_id FK\n    TEXT tool\n    TEXT target_path\n    TEXT mode\n    TEXT status\n    TEXT last_error\n    INTEGER synced_at\n  }\n```\n\n## 6. Supported Tools\n\nAdapter definitions: `src-tauri/src/core/tool_adapters/mod.rs`.\n\n| tool key | Display name | skills dir (relative to `~`) | detect dir (relative to `~`) |\n| --- | --- | --- | --- |\n| `cursor` | Cursor | `.cursor/skills` | `.cursor` |\n| `claude_code` | Claude Code | `.claude/skills` | `.claude` |\n| `codex` | Codex | `.codex/skills` | `.codex` |\n| `opencode` | OpenCode | `.config/opencode/skills` | `.config/opencode` |\n| `antigravity` | Antigravity | `.gemini/antigravity/global_skills` | `.gemini/antigravity` |\n| `amp` | Amp | `.config/agents/skills` | `.config/agents` |\n| `kimi_cli` | Kimi Code CLI | `.config/agents/skills` | `.config/agents` |\n| `augment` | Augment | `.augment/rules` | `.augment` |\n| `openclaw` | OpenClaw | `.moltbot/skills` | `.moltbot` |\n| `cline` | Cline | `.cline/skills` | `.cline` |\n| `codebuddy` | CodeBuddy | `.codebuddy/skills` | `.codebuddy` |\n| `command_code` | Command Code | `.commandcode/skills` | `.commandcode` |\n| `continue` | Continue | `.continue/skills` | `.continue` |\n| `crush` | Crush | `.config/crush/skills` | `.config/crush` |\n| `junie` | Junie | `.junie/skills` | `.junie` |\n| `iflow_cli` | iFlow CLI | `.iflow/skills` | `.iflow` |\n| `kiro_cli` | Kiro CLI | `.kiro/skills` | `.kiro` |\n| `kode` | Kode | `.kode/skills` | `.kode` |\n| `mcpjam` | MCPJam | `.mcpjam/skills` | `.mcpjam` |\n| `mistral_vibe` | Mistral Vibe | `.vibe/skills` | `.vibe` |\n| `mux` | Mux | `.mux/skills` | `.mux` |\n| `openclaude` | OpenClaude IDE | `.openclaude/skills` | `.openclaude` |\n| `openhands` | OpenHands | `.openhands/skills` | `.openhands` |\n| `pi` | Pi | `.pi/agent/skills` | `.pi` |\n| `qoder` | Qoder | `.qoder/skills` | `.qoder` |\n| `qwen_code` | Qwen Code | `.qwen/skills` | `.qwen` |\n| `trae` | Trae | `.trae/skills` | `.trae` |\n| `trae_cn` | Trae CN | `.trae-cn/skills` | `.trae-cn` |\n| `zencoder` | Zencoder | `.zencoder/skills` | `.zencoder` |\n| `neovate` | Neovate | `.neovate/skills` | `.neovate` |\n| `pochi` | Pochi | `.pochi/skills` | `.pochi` |\n| `adal` | AdaL | `.adal/skills` | `.adal` |\n| `kilo_code` | Kilo Code | `.kilocode/skills` | `.kilocode` |\n| `roo_code` | Roo Code | `.roo/skills` | `.roo` |\n| `goose` | Goose | `.config/goose/skills` | `.config/goose` |\n| `gemini_cli` | Gemini CLI | `.gemini/skills` | `.gemini` |\n| `github_copilot` | GitHub Copilot | `.copilot/skills` | `.copilot` |\n| `clawdbot` | Clawdbot | `.clawdbot/skills` | `.clawdbot` |\n| `droid` | Droid | `.factory/skills` | `.factory` |\n| `windsurf` | Windsurf | `.codeium/windsurf/skills` | `.codeium/windsurf` |\n\n## 7. Command Contract (overview)\n\nCommands are exposed from `src-tauri/src/commands/mod.rs` and invoked from the frontend.\n\nKey commands:\n\n- `get_central_repo_path`, `set_central_repo_path`\n- `get_tool_status`, `get_onboarding_plan`, `get_managed_skills`\n- `install_local`, `install_git`, `list_git_skills_cmd`, `install_git_selection`\n- `sync_skill_to_tool`, `unsync_skill_from_tool`\n- `update_managed_skill`, `delete_managed_skill`\n\nFrontend-visible error prefixes:\n\n- `MULTI_SKILLS|...`\n- `TARGET_EXISTS|<path>`\n- `TOOL_NOT_INSTALLED|<tool>`\n\n## 8. Key UX Flows (summary)\n\n- Startup: load central repo path, tool status, onboarding plan, and managed skills list.\n- Import discovered skills: copy a selected variant into the Central Repo, then sync to selected tools (with safe overwrite rules).\n- Update: rebuild central content from source; resync copy-mode targets.\n"
  },
  {
    "path": "docs/releases/v0.1-v0.2/system-design.zh.md",
    "content": "# Skills Hub（Tauri Desktop）系统设计文档\n\n> English version: [`docs/system-design.md`](system-design.md)\n\n> 基于当前仓库实现（commit `b5246ab`），结合历史计划与 UI 设计稿整理，目标是给后来维护者提供一份“能落地、可对照代码”的完整系统设计说明。  \n\n## 1. 背景与问题定义\n\n现代 AI 编程工具（如 Cursor、Claude Code、Codex 等）往往使用“Skills/Agents/Tools”机制扩展能力，但各工具的全局 skills 目录分散在用户主目录下不同位置，导致：\n\n- **无法统一查看**：用户很难知道“我有哪些 Skill、在哪些工具生效、版本是否一致”。\n- **重复安装与漂移**：同一个 Skill 被复制到多个工具目录，更新后不一致。\n- **迁移成本高**：安装 Skills Hub 后，用户机器上可能已存在大量 skills，需要安全接管并去重。\n\nSkills Hub 的核心思路是将 Skill 内容集中存放在“中心仓库（Central Repo）”，并把各工具目录中的技能以 **symlink/junction/copy** 的方式映射到中心仓库，实现 “Install once, sync everywhere”。\n\n## 2. 目标与非目标\n\n### 2.1 目标（当前实现覆盖）\n\n- **统一视图**：列出 Hub 托管的 skills、其来源（local/git）以及对各工具的生效状态。\n- **多工具同步**：对已安装工具，在其默认 skills 目录生成映射（优先链接，失败回退复制）。\n- **迁移接管（Onboarding）**：扫描已安装工具目录中的已有 skills，按名称聚合并通过目录指纹检测冲突，提供导入与同步能力。\n- **多来源导入**：\n  - 本地目录导入（复制到中心仓库并入库）\n  - Git 导入（支持 GitHub repo URL 与 folder URL；支持 multi-skill 仓库的候选选择）\n  - GitHub 搜索（后端已实现，前端入口当前为 disabled）\n- **更新**：按来源（git/local）重建中心目录；对 copy 模式的目标回灌更新。\n- **工具动态检测**：启动时检测“新安装工具”，提示是否一键同步已托管 skills。\n- **可配置中心仓库路径**：默认 `~/.skillshub`。\n\n### 2.2 非目标（当前版本不做/不保证）\n\n- 不做复杂的版本并存（例如 `name@cursor`）与多版本依赖解析。\n- 不保证对“用户手工维护的外部 symlink 链接”做完整接管策略（仅在扫描中识别为 link 并展示）。\n- 不提供云同步/多设备同步能力（当前为本机文件系统 + SQLite）。\n- 不包含自动定时更新（设置文案有“24h auto-update”的占位，但当前未落地定时任务）。\n\n## 3. 术语与核心概念\n\n- **Skill**：一个以目录为单位的能力包，通常包含 `SKILL.md` 等文件。\n- **Managed Skill**：被 Skills Hub 托管并持久化到 SQLite 的 Skill（中心目录为权威内容）。\n- **Central Repo（中心仓库）**：Hub 存放 skills 内容的中心目录，默认 `~/.skillshub`（可配置）。\n- **Tool/Agent**：一个支持 skills 的 AI 工具（cursor/claude_code/codex/...），每个工具有默认 skills 目录。\n- **Target（同步目标）**：某个 managed skill 在某个工具目录里的映射结果（symlink/junction/copy），对应 DB 表 `skill_targets`。\n- **Onboarding Plan**：首次/手动扫描得到的“候选导入集合”，按 skill name 聚合为 group，并在冲突时提供 variant 选择。\n- **Fingerprint/Content Hash**：对 skill 目录计算的哈希（忽略 `.git` 等），用于判断不同工具里同名 skill 是否同内容。\n\n## 4. 总体架构（C4 风格）\n\n> 为了便于快速建立“脑内地图”，本章提供若干 Mermaid 架构/流程图。GitHub/多数 Markdown 预览器可直接渲染；若你的编辑器不支持，可用 Mermaid Preview 插件查看。\n\n### 4.1 系统上下文（Context）\n\n- 用户通过桌面应用管理本机 skills。\n- 应用需要读写：\n  - 用户主目录下各工具的默认 skills 目录（以及 detect 目录）\n  - 中心仓库目录（默认 `~/.skillshub`）\n  - 应用数据目录中的 SQLite DB（`skills_hub.db`）\n  - 应用缓存目录中的 git 临时 clone 目录（`skills-hub-git-*`）\n\n```mermaid\nflowchart TB\n  user[\"用户\"]\n  app[\"Skills Hub 桌面应用\\n(Tauri + React)\"]\n\n  fsTools[\"各工具全局 Skills 目录\\n~/.cursor/skills 等\"]\n  fsCentral[\"中心仓库\\n~/.skillshub\"]\n  db[\"SQLite\\nskills_hub.db\"]\n  cache[\"Cache\\nskills-hub-git-*\"]\n  gh[\"GitHub / 任意 Git 仓库\"]\n\n  user -->|管理/导入/同步| app\n  app <--> fsTools\n  app <--> fsCentral\n  app <--> db\n  app <--> cache\n  app <-->|clone/search| gh\n```\n\n### 4.2 容器（Containers）\n\n- **Frontend（WebView）**：React + Vite，负责 UI/交互、i18n、主题切换、调用 Tauri commands。\n- **Backend（Tauri Rust）**：提供文件系统操作、Git 拉取、SQLite 持久化、工具适配与扫描、同步引擎等能力。\n- **SQLite（嵌入式）**：`rusqlite`（bundled）存储托管技能与同步状态等。\n\n```mermaid\nflowchart LR\n  subgraph WebView[Frontend: React + Vite]\n    ui[\"UI Components\\n(App/Modals/SkillCard)\"]\n    i18n[\"i18n + Theme\"]\n    invoke[\"invoke(command,args)\"]\n    ui --> invoke\n    ui --> i18n\n  end\n\n  subgraph Tauri[Backend: Rust + Tauri]\n    cmd[\"commands/*\\nDTO + spawn_blocking\\nerror formatting\"]\n    core[\"core/*\\ninstaller/sync/store/onboarding\"]\n    cmd --> core\n  end\n\n  invoke --> cmd\n  core --> db[\"SQLite\"]\n  core --> fsCentral[\"Central Repo\"]\n  core --> fsTools[\"Tools Skills Dirs\"]\n  core --> cache[\"Cache Temp Git Dirs\"]\n  core --> gh[\"GitHub API / Git clone\"]\n```\n\n### 4.3 组件（Components）\n\n前端（`src/`）：\n\n- `src/main.tsx`：入口，初始化 i18n，渲染 `App`。\n- `src/App.tsx`：单页 Dashboard，聚合状态与业务流程（扫描/导入/同步/更新/删除/设置）。\n- `src/components/skills/*`：Header、FilterBar、SkillCard、SkillsList、各类 Modal。\n- `src/i18n/*`：i18next 资源与初始化。\n- `src/index.css`、`src/App.css`：设计稿风格的 token + 组件样式（支持 light/dark）。\n\n后端（`src-tauri/src/`）：\n\n- `src-tauri/src/lib.rs`：Tauri Builder，初始化 DB、注册 commands、启动临时目录清理任务。\n- `src-tauri/src/commands/mod.rs`：Tauri commands 对外接口层（线程隔离、错误格式化、DTO）。\n- `src-tauri/src/core/*`：核心业务模块（见 6 章）。\n\n## 5. 数据与存储设计\n\n### 5.1 文件系统布局\n\n#### 中心仓库\n\n- 默认路径：`~/.skillshub`（`src-tauri/src/core/central_repo.rs`）\n- 每个 Skill 使用一个目录：`<central_repo>/<skill_name>/`\n- 特性：\n  - **不存完整 git repo**：git 导入使用临时 clone，再把内容复制进中心目录，避免中心目录包含 `.git`。\n  - **名称即目录名**：默认取来源目录名 / repo 名 / subpath 末段；允许用户在导入时指定 display name。\n\n#### Git 临时目录（缓存）\n\n- 位置：Tauri `app_cache_dir()`（OS 特定）\n- 命名：`skills-hub-git-<uuid>`\n- 安全标记：写入 marker 文件 `.skills-hub-git-temp`\n- 清理策略：应用启动后台 best-effort 清理超过 24h 的目录（仅匹配 prefix + marker）\n\n#### 工具目录\n\n每个 tool adapter 提供：\n\n- `relative_skills_dir`：全局 skills 目录（相对 home）\n- `relative_detect_dir`：用于判断工具是否“已安装”的目录（相对 home）\n\n例如 Cursor：\n\n- detect：`~/.cursor`\n- skills：`~/.cursor/skills`\n\n当前支持的 AI 编程工具（Tool Adapters，见 `src-tauri/src/core/tool_adapters/mod.rs`）如下：\n\n| tool key | 显示名称 | skills 目录（相对 `~`） | detect 目录（相对 `~`） |\n| --- | --- | --- | --- |\n| `cursor` | Cursor | `.cursor/skills` | `.cursor` |\n| `claude_code` | Claude Code | `.claude/skills` | `.claude` |\n| `codex` | Codex | `.codex/skills` | `.codex` |\n| `opencode` | OpenCode | `.config/opencode/skills` | `.config/opencode` |\n| `antigravity` | Antigravity | `.gemini/antigravity/global_skills` | `.gemini/antigravity` |\n| `amp` | Amp | `.config/agents/skills` | `.config/agents` |\n| `kimi_cli` | Kimi Code CLI | `.config/agents/skills` | `.config/agents` |\n| `augment` | Augment | `.augment/rules` | `.augment` |\n| `openclaw` | OpenClaw | `.moltbot/skills` | `.moltbot` |\n| `cline` | Cline | `.cline/skills` | `.cline` |\n| `codebuddy` | CodeBuddy | `.codebuddy/skills` | `.codebuddy` |\n| `command_code` | Command Code | `.commandcode/skills` | `.commandcode` |\n| `continue` | Continue | `.continue/skills` | `.continue` |\n| `crush` | Crush | `.config/crush/skills` | `.config/crush` |\n| `junie` | Junie | `.junie/skills` | `.junie` |\n| `iflow_cli` | iFlow CLI | `.iflow/skills` | `.iflow` |\n| `kiro_cli` | Kiro CLI | `.kiro/skills` | `.kiro` |\n| `kode` | Kode | `.kode/skills` | `.kode` |\n| `mcpjam` | MCPJam | `.mcpjam/skills` | `.mcpjam` |\n| `mistral_vibe` | Mistral Vibe | `.vibe/skills` | `.vibe` |\n| `mux` | Mux | `.mux/skills` | `.mux` |\n| `openclaude` | OpenClaude IDE | `.openclaude/skills` | `.openclaude` |\n| `openhands` | OpenHands | `.openhands/skills` | `.openhands` |\n| `pi` | Pi | `.pi/agent/skills` | `.pi` |\n| `qoder` | Qoder | `.qoder/skills` | `.qoder` |\n| `qwen_code` | Qwen Code | `.qwen/skills` | `.qwen` |\n| `trae` | Trae | `.trae/skills` | `.trae` |\n| `trae_cn` | Trae CN | `.trae-cn/skills` | `.trae-cn` |\n| `zencoder` | Zencoder | `.zencoder/skills` | `.zencoder` |\n| `neovate` | Neovate | `.neovate/skills` | `.neovate` |\n| `pochi` | Pochi | `.pochi/skills` | `.pochi` |\n| `adal` | AdaL | `.adal/skills` | `.adal` |\n| `kilo_code` | Kilo Code | `.kilocode/skills` | `.kilocode` |\n| `roo_code` | Roo Code | `.roo/skills` | `.roo` |\n| `goose` | Goose | `.config/goose/skills` | `.config/goose` |\n| `gemini_cli` | Gemini CLI | `.gemini/skills` | `.gemini` |\n| `github_copilot` | GitHub Copilot | `.copilot/skills` | `.copilot` |\n| `clawdbot` | Clawdbot | `.clawdbot/skills` | `.clawdbot` |\n| `droid` | Droid | `.factory/skills` | `.factory` |\n| `windsurf` | Windsurf | `.codeium/windsurf/skills` | `.codeium/windsurf` |\n\n备注：\n- 工具“是否安装”的判断规则：detect 目录存在即认为已安装（`is_tool_installed`）。\n- 扫描 Codex 的 skills 时会过滤目录名 `.system`（避免把系统内置技能当作可迁移对象）。\n\n### 5.2 SQLite 数据模型\n\nDB 文件路径：`app_data_dir()/skills_hub.db`（`src-tauri/src/core/skill_store.rs`）\n\n```mermaid\nerDiagram\n  skills ||--o{ skill_targets : \"id = skill_id\"\n  skills {\n    TEXT id PK\n    TEXT name\n    TEXT source_type\n    TEXT source_ref\n    TEXT source_revision\n    TEXT central_path UK\n    TEXT content_hash\n    INTEGER created_at\n    INTEGER updated_at\n    INTEGER last_sync_at\n    INTEGER last_seen_at\n    TEXT status\n  }\n  skill_targets {\n    TEXT id PK\n    TEXT skill_id FK\n    TEXT tool\n    TEXT target_path\n    TEXT mode\n    TEXT status\n    TEXT last_error\n    INTEGER synced_at\n  }\n  settings {\n    TEXT key PK\n    TEXT value\n  }\n  discovered_skills {\n    TEXT id PK\n    TEXT tool\n    TEXT found_path\n    TEXT name_guess\n    TEXT fingerprint\n    INTEGER found_at\n    TEXT imported_skill_id FK\n  }\n```\n\n#### 表：`skills`\n\n代表 Hub 托管的技能（中心仓库权威）。\n\n- `id`：UUID\n  - `name`：展示名称（也用于中心目录名）\n  - `source_type`：`local` | `git`\n  - `source_ref`：本地路径或 URL\n  - `source_revision`：git commit（若可得）\n  - `central_path`：中心目录路径（unique）\n  - `content_hash`：目录 fingerprint（可为空）\n  - `created_at` / `updated_at` / `last_sync_at` / `last_seen_at`\n  - `status`：目前主要使用 `ok`\n\n#### 表：`skill_targets`\n\n每条记录代表一个 skill 在某个工具中的生效映射。\n\n- `skill_id` + `tool` 唯一\n- `target_path`：工具目录中的路径（最终路径）\n- `mode`：`auto` | `symlink` | `junction` | `copy`\n- `status` / `last_error` / `synced_at`\n\n#### 表：`settings`\n\nkey/value 存储：\n\n- `central_repo_path`：中心仓库路径（可选）\n- `installed_tools_v1`：最近一次检测到的已安装工具 key 列表（JSON）\n- `onboarding_completed`：当前实现提供 set/get 接口，但 Onboarding 是否完成逻辑尚未作为 gating 条件使用（可作为后续增强点）\n\n#### 表：`discovered_skills`\n\n当前 schema 存在，但前端/后端主流程使用的是“运行时扫描生成 plan”，并未将扫描结果落库（预留后续增强）。\n\n## 6. 后端核心模块设计（Rust）\n\n> 代码集中在 `src-tauri/src/core/*`，commands 仅做线程隔离/DTO/错误格式化。\n\n### 6.1 Tool Adapters（工具适配层）\n\n文件：`src-tauri/src/core/tool_adapters/mod.rs`\n\n职责：\n\n- 定义 `ToolId` 与 `ToolAdapter`（display name、skills 路径、detect 路径）。\n- `is_tool_installed()`：通过 detect 目录存在性判断。\n- `scan_tool_dir()`：遍历 skills 目录下的一级子目录作为 skill 名；Codex 额外过滤 `.system`。\n- `detect_link()`：用 `symlink_metadata/read_link` 尝试识别链接，并返回 `is_link/link_target`（用于 Onboarding 展示）。\n\n### 6.2 Onboarding（扫描与聚合）\n\n文件：`src-tauri/src/core/onboarding.rs`\n\n流程：\n\n1. 遍历所有 adapters，跳过未安装工具。\n2. 扫描 tools 的 skills 目录得到 `DetectedSkill` 列表。\n3. 对每个 detected skill 计算 `fingerprint = hash_dir(path)`（忽略 `.git` 等）。\n4. 按 `skill.name` 聚合为 group：\n   - `has_conflict`：同组内 fingerprint 去重后数量 > 1（无 fingerprint 时按 1 处理）。\n\n输出：`OnboardingPlan`（`total_tools_scanned/total_skills_found/groups`）。\n\n### 6.3 Content Hash（目录指纹）\n\n文件：`src-tauri/src/core/content_hash.rs`\n\n实现要点：\n\n- WalkDir 遍历目录（不 follow links）。\n- 忽略：`.git`、`.DS_Store`、`Thumbs.db`、`.gitignore`（按名称）。\n- 哈希包含相对路径 + 文件内容。\n\n### 6.4 Sync Engine（混合同步）\n\n文件：`src-tauri/src/core/sync_engine.rs`\n\n核心策略：\n\n- 目标不存在：\n  1) 尝试 symlink（Unix；Windows 尝试 symlink_dir）\n  2) Windows 额外尝试 junction（需要 `junction` crate）\n  3) 最后回退 copy（递归复制）\n- 目标已存在：\n  - 若目标是指向 source 的同一个链接：视为已同步（幂等）。\n  - 否则：\n    - `overwrite=false`：报错 `target already exists`\n    - `overwrite=true`：先删除目标目录，再按正常流程同步\n\n设计取舍：\n\n- 将“是否覆盖”作为显式参数，默认不覆盖，避免破坏用户既有目录。\n- 通过 “先 staging、后 swap” 的更新策略，降低更新时半成品状态风险（见 installer 的 update）。\n\n### 6.5 Installer（导入/更新）\n\n文件：`src-tauri/src/core/installer.rs`\n\n#### 本地导入（`install_local_skill`）\n\n- 将 source 目录递归复制到 `central_repo/<name>`。\n- 生成 `SkillRecord` 入库（`source_type=local`，`source_ref=source_path`）。\n- 若中心目录已存在：报错 `skill already exists in central repo`（前端会映射为友好提示）。\n\n#### Git 导入（`install_git_skill`）\n\n- 解析 GitHub URL（支持 repo root、`.git`、`/tree/<branch>/<path>`、`/blob/<branch>/<path>`）。\n- clone 到缓存临时目录（优先系统 `git` CLI，失败回退 libgit2），标记 `.skills-hub-git-temp`。\n- 复制目标目录到中心仓库：\n  - folder URL：复制 subpath\n  - repo root URL：若检测到 `skills/` 下存在 >=2 个 `SKILL.md`，抛出 `MULTI_SKILLS|...` 引导用户改用 folder URL 或走候选选择流程\n- 删除临时目录（best-effort）\n- 入库 `source_type=git`、`source_ref=原始 URL`、`source_revision=HEAD`\n\n#### Multi-skill 仓库候选（`list_git_skills` / `install_git_skill_from_selection`）\n\n- `list_git_skills`：\n  - root-level `SKILL.md` -> candidate `\".\"`\n  - 扫描 `skills/*`、`skills/.curated/*`、`skills/.experimental/*`、`skills/.system/*`\n  - 解析 `SKILL.md` 的 YAML front matter 获取 `name/description`（若存在）\n- `install_git_skill_from_selection`：\n  - clone -> copy -> 入库（类似 git 导入）\n  - display name 默认取 subpath 末段或 repo 名\n\n#### 更新（`update_managed_skill_from_source`）\n\n- 根据 `skills.source_type` 重新构建新内容到 sibling staging dir：`.skills-hub-update-<uuid>`\n- swap：删除旧中心目录 -> rename staging（跨盘 rename 失败则 copy fallback）\n- 更新 `skills.updated_at/content_hash/source_revision` 等\n- 若 `skill_targets.mode == \"copy\"`：对这些 target 执行 overwrite 同步，让工具目录内容跟随更新（symlink/junction 自动生效无需处理）\n\n### 6.6 Git Fetcher（拉取策略）\n\n文件：`src-tauri/src/core/git_fetcher.rs`\n\n策略：\n\n- 优先使用系统 `git` CLI（更符合用户本机网络/代理/证书/Keychain 配置）。\n- CLI 失败再回退 libgit2（保持在无 git 环境仍可用）。\n\n### 6.7 GitHub Search（仓库搜索）\n\n文件：`src-tauri/src/core/github_search.rs`\n\n- 调用 GitHub Search API：`https://api.github.com/search/repositories`\n- `reqwest::blocking`，设置 `User-Agent: skills-hub`\n- 返回 `RepoSummary`（full_name/html_url/description/stars/updated_at/clone_url）\n- 备注：当前前端搜索 tab 为 disabled；可作为后续启用点。\n\n### 6.8 Temp Cleanup（临时目录清理）\n\n文件：`src-tauri/src/core/temp_cleanup.rs`\n\n- 仅清理满足三重条件的目录：\n  1) 位于 app_cache_dir\n  2) 名称前缀 `skills-hub-git-`\n  3) 含 marker 文件 `.skills-hub-git-temp`\n- 并要求目录 `modified` 时间超过 max_age（目前 24h）。\n\n## 7. Commands（前后端接口契约）\n\n文件：`src-tauri/src/commands/mod.rs`\n\n### 7.1 调用方式\n\n前端通过 `@tauri-apps/api/core` 的 `invoke(command, args)` 调用；耗时操作统一在后端 `spawn_blocking`。\n\n### 7.2 主要 commands 列表\n\n- `get_central_repo_path() -> string`\n- `set_central_repo_path(path: string) -> string`\n- `get_tool_status() -> { tools[], installed[], newly_installed[] }`\n- `get_onboarding_plan() -> OnboardingPlan`\n- `get_managed_skills() -> ManagedSkill[]`\n- `install_local(sourcePath: string, name?: string) -> InstallResultDto`\n- `install_git(repoUrl: string, name?: string) -> InstallResultDto`\n- `list_git_skills_cmd(repoUrl: string) -> GitSkillCandidate[]`\n- `install_git_selection(repoUrl: string, subpath: string, name?: string) -> InstallResultDto`\n- `import_existing_skill(sourcePath: string, name?: string) -> InstallResultDto`（当前与 `install_local` 等价）\n- `sync_skill_dir(source_path: string, target_path: string) -> { mode_used, target_path }`（底层工具）\n- `sync_skill_to_tool(sourcePath: string, skillId: string, tool: string, name: string, overwrite?: boolean) -> { mode_used, target_path }`\n- `unsync_skill_from_tool(skillId: string, tool: string) -> void`\n- `update_managed_skill(skillId: string) -> { skill_id, name, content_hash?, source_revision?, updated_targets[] }`\n- `delete_managed_skill(skillId: string) -> void`\n- `search_github(query: string, limit?: number) -> RepoSummary[]`\n\n### 7.3 错误契约与前端分流\n\n后端对 `anyhow::Error` 进行格式化，并保留以下前缀供前端识别：\n\n- `MULTI_SKILLS|...`：仓库包含多个 skill，需要走候选选择或提供 folder URL。\n- `TARGET_EXISTS|<path>`：目标目录存在且未覆盖，前端提示用户清理/取消勾选。\n- `TOOL_NOT_INSTALLED|<tool>`：工具未安装。\n\n此外对 GitHub clone 失败做了启发式中文提示（TLS/鉴权/DNS/超时等）。\n\n## 8. 前端 UI 与交互设计\n\n### 8.1 页面结构（当前为单页 Dashboard）\n\n文件：`src/App.tsx` + `src/components/skills/*`\n\n主要区域：\n\n- Header：品牌、语言切换、设置、添加 Skill\n- FilterBar：排序（updated/name）、搜索、刷新\n- Discovered Banner：扫描到可导入 skills 时显示 “Review & Import”\n- Skills List：卡片列表（每个 skill 显示来源、更新时间、工具 pills、更新/删除按钮）\n- Modals：\n  - AddSkillModal（local/git 两个 tab；选择 sync targets）\n  - ImportModal（Onboarding plan 的 group/variant 选择与导入）\n  - GitPickModal（multi-skill 仓库候选选择）\n  - SettingsModal（语言、主题、中心仓库路径）\n  - DeleteModal（删除确认）\n  - NewToolsModal（新安装工具提示是否 sync all）\n  - LoadingOverlay（耗时操作遮罩与提示）\n\n### 8.2 i18n 与主题\n\n- i18n：`react-i18next`，资源在 `src/i18n/resources.ts`，默认 `en`，可切换 `zh`。\n- 主题：`src/index.css` 定义 CSS variables，`src/App.tsx` 使用 localStorage 保存 `skills-theme`（system/light/dark），并同步 `documentElement.dataset.theme`。\n\n### 8.3 核心用户路径（前端侧）\n\n#### 启动加载\n\n1. 若运行在 Tauri 环境：\n   - 拉取 `get_central_repo_path`（展示在设置）\n   - 拉取 `get_tool_status`（工具安装状态 + newly installed 检测）\n   - 拉取 `get_onboarding_plan`（用于 discovered banner）\n   - 拉取 `get_managed_skills`（托管列表）\n\n```mermaid\nsequenceDiagram\n  autonumber\n  participant UI as Frontend (App.tsx)\n  participant CMD as Tauri commands\n  participant CORE as core/*\n  participant DB as SQLite\n  participant FS as FileSystem\n\n  UI->>CMD: get_central_repo_path()\n  CMD->>CORE: resolve + ensure_central_repo\n  CORE->>DB: settings.get(central_repo_path)\n  CORE->>FS: create_dir_all(~/.skillshub)\n  CMD-->>UI: path\n\n  UI->>CMD: get_tool_status()\n  CMD->>CORE: default_tool_adapters + is_tool_installed\n  CORE->>FS: exists(~/.cursor 等)\n  CORE->>DB: settings.get/set(installed_tools_v1)\n  CMD-->>UI: installed + newly_installed\n\n  UI->>CMD: get_onboarding_plan()\n  CMD->>CORE: build_onboarding_plan()\n  CORE->>FS: scan_tool_dir + hash_dir\n  CMD-->>UI: OnboardingPlan\n\n  UI->>CMD: get_managed_skills()\n  CMD->>DB: list_skills + list_skill_targets\n  CMD-->>UI: ManagedSkill[]\n```\n\n#### 导入已发现 Skills（Review & Import）\n\n1. 打开 ImportModal（若 plan 为空先调用 `get_onboarding_plan`）\n2. 按 group 勾选要导入的技能\n3. 若 `has_conflict`，必须在 variants 中选择一个来源路径\n4. 执行导入：\n   - 对每个选中 group：调用 `import_existing_skill(sourcePath=<chosen variant path>, name=<group name>)`\n   - 再对“已安装且勾选的工具”逐个调用 `sync_skill_to_tool`\n   - `overwrite` 策略：若同步回“来源工具”则 `overwrite=true`（接管）；否则默认不覆盖\n\n```mermaid\nsequenceDiagram\n  autonumber\n  participant UI as ImportModal\n  participant CMD as commands\n  participant INST as installer\n  participant SYNC as sync_engine\n  participant DB as SkillStore(SQLite)\n  participant FS as FileSystem\n\n  UI->>CMD: import_existing_skill(sourcePath, name)\n  CMD->>INST: install_local_skill()\n  INST->>FS: copy sourcePath -> ~/.skillshub/<name>\n  INST->>DB: upsert skills row\n  CMD-->>UI: {skill_id, central_path, name}\n\n  loop 对每个已选工具\n    UI->>CMD: sync_skill_to_tool(sourcePath=central_path, tool, name, overwrite?)\n    CMD->>SYNC: sync_dir_hybrid_with_overwrite()\n    alt 目标不存在/可覆盖\n      SYNC->>FS: symlink/junction/copy -> tool skills dir\n      CMD->>DB: upsert skill_targets\n      CMD-->>UI: {mode_used, target_path}\n    else 目标存在且不覆盖\n      CMD-->>UI: TARGET_EXISTS|<path>\n    end\n  end\n```\n\n#### 添加 Skill（Local/Git）\n\n- local：先 `list_local_skills_cmd` 扫描目录（规则与 Git 一致），\n  - 多个候选则弹出选择列表（无效候选置灰并标注原因）\n  - 单个有效候选则 `install_local_selection` -> 对选中工具逐个 `sync_skill_to_tool`\n- git：\n  - folder URL：直接 `install_git` -> 同步\n  - repo root URL：`list_git_skills_cmd` -> GitPickModal 选择 -> `install_git_selection` -> 同步\n\n#### 切换工具生效（Tool Pills）\n\n- 未生效 -> 生效：`sync_skill_to_tool(sourcePath=central_path, ...)`\n- 已生效 -> 取消：`unsync_skill_from_tool(skillId, tool)`\n\n#### 更新 & 删除\n\n- 更新：`update_managed_skill(skillId)`（后端会对 copy targets 回灌更新）\n- 删除：`delete_managed_skill(skillId)`（先清理工具目录映射，再删除中心目录与 DB）\n\n```mermaid\nflowchart TB\n  A[更新 update_managed_skill] --> B{source_type}\n  B -->|git| C[clone 到 cache temp\\nskills-hub-git-*]\n  B -->|local| D[读取 source_ref 目录]\n  C --> E[copy 到 staging dir\\n.skills-hub-update-*]\n  D --> E\n  E --> F[swap: 删除旧 central_dir\\nrename/copy 回 central_path]\n  F --> G[更新 skills.updated_at/hash/revision]\n  G --> H{targets.mode == copy ?}\n  H -->|是| I[overwrite 同步到 target_path]\n  H -->|否| J[symlink/junction 自动生效]\n```\n\n## 9. 关键一致性与安全策略\n\n### 9.1 “默认不破坏用户环境”\n\n- 同步默认 `overwrite=false`，目标存在即失败并给出 `TARGET_EXISTS|...`。\n- 仅在明确需要“接管”的场景，前端才传 `overwrite=true`（当前：导入 discovered skill 且同步回来源工具）。\n\n### 9.2 删除与清理的边界\n\n- `delete_managed_skill` 仅清理 DB 中记录过的 `skill_targets.target_path`，不会全盘扫描/删除工具目录。\n- Git 临时目录清理限定 prefix + marker + age gate，降低误删风险。\n\n### 9.3 权限与跨平台差异\n\n- Windows 上 symlink 可能受权限/策略限制：引擎会尝试 junction，最后回退 copy。\n- 复制模式的 targets 需要在更新时显式回灌，否则工具目录可能滞后；后端已实现该传播逻辑。\n\n## 10. 性能与稳定性\n\n- 后端耗时操作使用 `spawn_blocking`，避免阻塞 UI。\n- 前端 LoadingOverlay 在长操作期间提供提示（clone/IO 10–60s）。\n- Git 拉取优先系统 git，提高 macOS 网络/证书兼容性。\n- 错误信息：\n  - 尽量包含 root cause\n  - 对 clone “临时目录路径”做脱敏（减少噪音）\n  - 对 GitHub/TLS/鉴权等提供可行动提示\n\n## 11. 测试与验证建议（仓库当前缺少自动化测试时的落地方案）\n\n> 当前仓库未显式提供 Rust/前端测试用例。建议按模块逐步补齐：\n\n- Rust（core）：\n  - `content_hash`：忽略文件名/顺序稳定性\n  - `parse_github_url`：覆盖 repo/tree/blob/.git 组合\n  - `sync_engine`：用临时目录验证 overwrite/幂等行为（平台差异可通过条件编译分组）\n- 前端：\n  - `App` 的业务逻辑建议逐步下沉到 hooks（便于单测）\n  - Modal 表单校验与错误映射（`TARGET_EXISTS|` 等）\n\n## 12. 现状梳理与后续路线图（建议）\n\n### 12.1 已完成（与实现一致）\n\n- 多工具 adapter 支持与安装检测\n- Onboarding 扫描（冲突检测 + link 展示）\n- local/git 导入与 multi-skill 候选选择\n- 混合同步（symlink/junction/copy）\n- 更新（copy targets 回灌）\n- 新安装工具提示\n- 中心仓库路径设置与迁移\n\n### 12.2 后续增强方向（按价值/风险）\n\n1. **启用 GitHub 搜索 UI**：对接 `search_github`，并支持一键安装候选仓库。\n2. **扫描结果落库**：使用 `discovered_skills` 表持久化“发现但未导入”的技能，支持忽略/标记与增量刷新。\n3. **Onboarding gating**：引入 `settings.onboarding_completed`，仅在首次启动/用户触发时弹出导入引导，避免每次都显示 discovered banner。\n4. **更强冲突策略**：支持 `name@variant` 的版本并存（需要 UI 显式展示与命名规范）。\n5. **维护任务**：提供“清理失效 targets / 修复 broken link / 重新同步所有 copy targets”入口。\n"
  },
  {
    "path": "docs/releases/v0.3.0/plan-bug-fixes.md",
    "content": "# Bug 修复计划\n\n来源：https://github.com/qufei1993/skills-hub/issues\n\n---\n\n## Bug 1：Git 安装时 skill 名称为 \"skills\" 导致路径重复（#28）\n\n**Issue**: https://github.com/qufei1993/skills-hub/issues/28\n**严重程度**: P0\n**状态**: ✅ 已修复（commit 69ab806）\n\n### 问题描述\n\n通过 Git URL 安装 skill 时，如果 URL 指向名为 `skills` 的子目录（如 `https://github.com/xxx/repo/tree/main/skills`），`install_git_skill` 会从 subpath 推导 name 为 `\"skills\"`，导致同步到工具时路径变成 `~/.claude/skills/skills/`。\n\n### 根因\n\n`installer.rs:94-104` 的 name 推导逻辑只看 subpath/URL，不读 SKILL.md 的 name 字段：\n\n```rust\nlet name = name.unwrap_or_else(|| {\n    if let Some(subpath) = &parsed.subpath {\n        subpath.rsplit('/').next()  // ← subpath 是 \"skills\" 时，name 就是 \"skills\"\n```\n\n而本地导入走 `install_local_skill_from_selection`，会先从 SKILL.md 读 name，所以不受影响。\n\n### 复现步骤\n\n1. 在 Skills Hub 中选择「Git 仓库」\n2. 输入 `https://github.com/anthropics/skills/tree/main/skills`\n3. 不填显示名称，直接安装\n4. 同步到 Claude Code 后，路径变成 `~/.claude/skills/skills/`\n\n### 修复方案\n\n修改 `installer.rs` 的 `install_git_skill`，在 name 推导后、写入 central_path 之前，尝试从已下载内容的 SKILL.md 读取 name 覆盖。同时增加保底校验：如果 name 仍为 `\"skills\"`，报错要求用户手动指定名称。\n\n### 涉及文件\n\n- `src-tauri/src/core/installer.rs` — `install_git_skill` 函数 name 推导逻辑\n\n---\n\n## Bug 2：GitHub API 限流错误未提示重置时间\n\n**Issue**: 使用中发现（关联 #28 复现过程）\n**严重程度**: P2\n**状态**: ✅ 已修复\n\n### 问题描述\n\n当 GitHub API 返回 403（速率限制）时，错误提示只显示\"请稍后再试\"，未告诉用户具体的重置时间。GitHub 响应头中包含 `x-ratelimit-reset` 字段（Unix 时间戳），应提取并展示。\n\n### 根因\n\n`github_download.rs:70` 调用 `.error_for_status()` 直接将 403 转为通用错误，丢弃了响应头信息。`installer.rs:148-149` 捕获 403 时也只返回固定文案。\n\n### 修复方案\n\n在 `github_download.rs` 中，不使用 `.error_for_status()`，而是手动检查 status code。当遇到 403 时，从响应头提取 `x-ratelimit-reset` 和 `x-ratelimit-remaining`，将重置时间格式化为本地时间后包含在错误消息中。\n\n示例错误消息：`\"GitHub API 访问被拒绝（触发了频率限制）。将于 18:44 重置，请届时再试。\"`\n\n### 涉及文件\n\n- `src-tauri/src/core/github_download.rs` — HTTP 请求错误处理\n- `src-tauri/src/core/installer.rs` — 403 错误消息构造\n\n---\n\n## 改进：设置页增加 GitHub Token 配置\n\n**严重程度**: P1\n**状态**: ✅ 已实现（commit d2c1cc0）\n\n### 问题描述\n\n当前所有 GitHub API 请求均为未认证（60 次/小时/IP），安装 skill 时递归下载目录会快速耗尽配额。用户无法配置 Token 来提升限额。\n\n### 方案\n\n1. **设置页 UI**：增加一个可选的 GitHub Token 输入框（密码类型，支持显示/隐藏），存入 `settings` 表（key: `github_token`）\n2. **后端传递**：`github_download.rs` 和 `github_search.rs` 发请求时，从 SkillStore 读取 token，如果存在则加上 `Authorization: Bearer <token>` 请求头\n3. **限额提升**：认证后 GitHub API 限额从 60 → 5000 次/小时（按账号计算）\n4. **安全**：Token 仅存本地 SQLite，不上传不同步。设置页提示用户生成 fine-grained PAT，只需 `public_repo` 读取权限\n\n### 涉及文件\n\n- `src-tauri/src/core/github_download.rs` — 请求时携带 token\n- `src-tauri/src/core/github_search.rs` — 请求时携带 token\n- `src-tauri/src/core/skill_store.rs` — 读取 `github_token` 设置\n- `src/App.tsx` — 设置弹窗增加 GitHub Token 输入\n- `src/i18n/resources.ts` — 新增中英文翻译\n\n---\n\n## Bug 3：Windows 拒绝访问 OS error 5（#20）\n\n**Issue**: https://github.com/qufei1993/skills-hub/issues/20\n**严重程度**: P0\n**状态**: ✅ 已修复（commit 93d9aca）\n\n### 问题描述\n\nWindows 用户点击 AI-IDE 同步选项时报 OS error 5（权限不足），即使对应工具未安装。\n\n### 可能原因\n\n1. Windows 上创建 symlink 需要管理员权限或开发者模式，`sync_engine.rs` 的 fallback（symlink → junction → copy）可能在某些环境下全部失败\n2. 尝试同步到未安装工具的目录时，`is_tool_installed` 检查可能误判（检测目录存在但无写入权限）\n\n### 涉及文件\n\n- `src-tauri/src/core/sync_engine.rs` — symlink/junction/copy fallback 逻辑\n- `src-tauri/src/core/tool_adapters/mod.rs` — `is_tool_installed` 检测逻辑\n- `src-tauri/src/commands/mod.rs` — `sync_skill_to_tool` 错误处理\n\n---\n\n## Bug 4：Skill 扫描逻辑对部分目录结构失效（#18 + #8）\n\n**Issue**: https://github.com/qufei1993/skills-hub/issues/18 / https://github.com/qufei1993/skills-hub/issues/8\n**严重程度**: P1\n**状态**: ✅ 已修复\n\n### 问题描述\n\n- #18：某些 git 仓库目录结构无法被正确识别为 skill\n- #8：skill 显示在仓库中但实际不存在；发现的 skill 无法导入\n\n### 根因\n\n**#18**：`list_git_skills` 和 `install_git_skill` 的多技能检测只扫描 `skills/` 目录下的子目录。当仓库把 skill 直接放在根目录的子文件夹（如 `repo/my-skill/SKILL.md`，不套 `skills/` 父目录）时，扫描结果为空，用户看到\"该仓库中没有 Skills\"。\n\n示例仓库：`axtonliu/axton-obsidian-visual-skills`，结构为 `repo/excalidraw-diagram/SKILL.md`、`repo/mermaid-visualizer/SKILL.md` 等，无 `skills/` 目录。\n\n**#8**：`scan_tool_dir`（onboarding 扫描）把工具 skills 目录下的**每个子目录**都当成 skill（不检查 SKILL.md），导致不含 SKILL.md 的目录也被\"发现\"，但导入时又因缺少 SKILL.md 失败。\n\n### 修复方案\n\n1. **#18**：在 `list_git_skills` 中增加扫描仓库根目录直接子目录中的 SKILL.md；在 `install_git_skill` 的多技能检测中同样覆盖根目录子目录\n2. **#8**：`scan_tool_dir` 保持现有行为（不强制要求 SKILL.md），但前端导入时已有校验。或者在 `scan_tool_dir` 中区分\"有 SKILL.md\"和\"无 SKILL.md\"的目录\n\n### 涉及文件\n\n- `src-tauri/src/core/installer.rs` — `list_git_skills` 和 `install_git_skill` 的扫描范围\n- `src-tauri/src/core/tool_adapters/mod.rs` — `scan_tool_dir` 扫描逻辑\n\n---\n\n## Bug 5：Skill 名称冲突无法安装（#12）\n\n**Issue**: https://github.com/qufei1993/skills-hub/issues/12\n**严重程度**: P1\n**状态**: ✅ 已关闭（PR #30 合并，UI 已有「显示名称」输入框可手动指定别名，冲突时提示用户重命名）\n\n### 涉及文件\n\n- `src-tauri/src/core/installer.rs` — 安装时 name 冲突处理\n\n---\n\n## Bug 6：新增 skill 未被自动扫描（#19）\n\n**Issue**: https://github.com/qufei1993/skills-hub/issues/19\n**严重程度**: P2\n**状态**: ✅ 已关闭（非 bug，预期行为）\n\n### 调查结论\n\n`~/.skillshub/` 是 Skills Hub 的内部存储，外部工具不应直接写入。实际上 OpenCode 创建的 skill 会落在 `~/.config/opencode/skills/` 下（普通目录），不会进入 `~/.skillshub/`。用户误以为写入了 skillshub 目录。\n\n如需将外部工具中新建的 skill 纳入 Skills Hub 管理，可通过\"导入已有 Skill\"功能手动操作。\n\n---\n\n## Bug 7：不支持 .claude/skills/ 目录格式的仓库（#27）\n\n**Issue**: https://github.com/qufei1993/skills-hub/issues/27\n**严重程度**: P1\n**状态**: ✅ 已修复（PR #31 合并）\n\n### 问题描述\n\n使用 `.claude-plugin/plugin.json` + `.claude/skills/` 目录结构的仓库（如 `nextlevelbuilder/ui-ux-pro-max-skill`）无法被 Skills Hub 识别和安装，因为扫描逻辑只查找 `SKILL.md` 文件。\n\n### 根因\n\n`list_git_skills`、`count_skills_in_repo`、`list_local_skills` 只扫描 `skills/`、`skills/.curated/` 等目录，不扫描 `.claude/skills/`。且只认 `SKILL.md` 为有效标记，`.claude/skills/` 下的目录即使内容完整也被忽略。\n\n### 修复方案\n\n1. 新增 `.claude/skills/` 为扫描路径（与 `skills/` 并列）\n2. `.claude/skills/` 下的子目录即使没有 `SKILL.md` 也视为有效 skill\n3. 无 `SKILL.md` 时用文件夹名做 name，从 `.claude-plugin/plugin.json` 读取 description\n\n### 涉及文件\n\n- `src-tauri/src/core/installer.rs` — `SKILL_SCAN_BASES` 常量、`is_skill_dir`、`is_claude_skill_dir`、`read_plugin_description`、`extract_skill_info` 辅助函数\n\n---\n\n## Bug 8：界面上没有 OpenClaw 的同步（#29）\n\n**Issue**: https://github.com/qufei1993/skills-hub/issues/29\n**严重程度**: P2\n**状态**: ✅ 已关闭（PR #26 已修复代码，文档已补充更新）\n\n### 问题描述\n\n用户界面上找不到 OpenClaw 的同步选项。\n\n### 根因\n\nOpenClaw 更名后将配置目录从 `.moltbot/` 迁移到 `.openclaw/`，代码中的 `tool_adapters` 仍指向旧路径。\n\n### 修复内容\n\n1. PR #26（社区贡献）修复了代码：OpenClaw 路径 `.moltbot/skills` → `.openclaw/skills`，原 `.moltbot` 拆分为独立的 MoltBot 工具\n2. 补充更新了 `README.md` 和 `docs/README.zh.md` 中的工具列表（OpenClaw 路径 + MoltBot 新增行）\n3. 补充了 `src/i18n/resources.ts` 中缺少的 `moltbot: 'MoltBot'` 翻译\n\n### 涉及文件\n\n- `src-tauri/src/core/tool_adapters/mod.rs` — OpenClaw 路径更新 + MoltBot 新增（PR #26）\n- `README.md` — 工具列表更新\n- `docs/README.zh.md` — 工具列表更新\n- `src/i18n/resources.ts` — MoltBot 翻译新增\n"
  },
  {
    "path": "docs/releases/v0.3.0/plan-explore-page-redesign.md",
    "content": "# 需求：Explore 页面独立化 + My Skills 列表优化\n\n## 背景\n\n当前\"添加 Skill\"的交互流程存在问题：探索、本地添加、Git 添加三个功能挤在一个弹窗的三个 Tab 里。用户在探索 Tab 点击一个 skill 后，只是把 URL 填入 Git Tab 并跳转，还需手动确认并点击安装，流程冗长（5 步）。\n\n## 设计稿\n\n`docs/skills_hub_v2_design.html` — 包含 4 个屏幕的完整交互设计。\n\n## 改动概览\n\n### 一、导航结构调整\n\n**现状**：单页面 + 弹窗内 3 个 Tab（探索/本地/Git）\n**目标**：顶部导航 2 个页面级 Tab + 弹窗仅保留手动添加\n\n- App Header 内新增 **My Skills** / **Explore** 两个导航 Tab\n- 点击切换页面视图（非路由，纯前端状态切换）\n- 默认展示 My Skills 页\n\n### 二、Explore 页（新页面）\n\n将探索功能从弹窗提升为独立页面，作为\"获取新 Skill 的唯一入口\"。\n\n#### 布局\n- 顶部：搜索栏 + **Manual** 按钮（触发手动添加弹窗）\n- 搜索栏下方：提示文字 \"Data from clawhub.ai · Have a Git URL or local path? Click Manual to add directly\"\n- 内容区：双列卡片网格\n\n#### Explore 卡片\n每张卡片展示：\n- Skill 名称\n- 作者/来源（repo 路径）\n- 描述（最多 2 行截断）\n- 下载量 / Stars\n- 兼容工具小标签（折叠显示，如 `Cursor` `Claude` `+5`）\n- **Install 按钮**（右上角，一键安装）\n- 已安装的 Skill 显示为绿色 **Installed** 状态（禁用按钮）\n\n#### 一键安装流程\n- 点击 Install → 自动使用所有已检测到的工具作为同步目标 → 安装并同步\n- 安装成功后底部 toast 提示：\"xxx installed and synced to N tools\"\n- 安装完成后按钮状态变为 Installed\n\n#### 搜索态\n- 输入 ≥ 2 字符触发搜索\n- 结果分为 \"Featured Matches\" 和 \"Online Results\" 两个区域\n- 搜索关键词高亮\n\n### 三、Manual Add 弹窗（精简）\n\n- 从 Explore 页的 Manual 按钮触发\n- **移除探索 Tab**，仅保留 Local Directory / Git Repository 两个 Tab\n- 其余逻辑不变（工具选择器、安装流程）\n\n### 四、My Skills 页（列表优化）\n\n#### 移除\n- 移除 Add 按钮（添加功能统一收口到 Explore 页）\n\n#### 卡片增加 description\n- 每张卡片新增描述文本，显示在名称下方，最多 2 行截断\n- 信息层次：名称 → 描述 → 来源+时间 → 工具徽章\n\n#### 工具徽章优化\n- **只显示已同步的工具**（绿色带圆点），不再显示未同步的灰色徽章\n- 超过 5 个折叠为 `+N more`\n- 减少视觉噪音，让每张卡片高度一致\n\n## 后端改动\n\n### 新增 description 字段\n\n当前 `ManagedSkillDto` 没有 description 字段，需要：\n\n1. 从 Skill 目录的 `SKILL.md` frontmatter 中解析 `description` 字段\n2. 在安装时提取并存入数据库（`skills` 表新增 `description` 列）\n3. `ManagedSkillDto` 新增 `description: Option<String>` 字段返回给前端\n\n### 涉及文件\n- `src-tauri/src/core/skill_store.rs` — 表结构迁移，新增 description 列\n- `src-tauri/src/core/installer.rs` — 安装时解析 SKILL.md 提取 description\n- `src-tauri/src/commands/mod.rs` — DTO 新增字段\n\n## 前端改动\n\n### 涉及文件\n- `src/App.tsx` — 新增页面级 Tab 状态，拆分 Explore 视图逻辑\n- `src/App.css` — Explore 页样式、卡片优化样式\n- `src/components/skills/types.ts` — DTO 同步新增 description 字段\n- `src/components/skills/modals/AddSkillModal.tsx` — 移除探索 Tab，仅保留 Local/Git\n- `src/i18n/resources.ts` — 新增/调整翻译 key（My Skills / Explore 导航等）\n\n### 可能新增文件\n- `src/components/skills/ExplorePage.tsx` — Explore 页面组件\n- `src/components/skills/ExploreCard.tsx` — Explore 卡片组件\n\n## 实施顺序建议\n\n1. **后端**：description 字段（表迁移 + SKILL.md 解析 + DTO）\n2. **前端**：导航 Tab 切换 + My Skills 列表优化（description 展示 + 工具徽章折叠）\n3. **前端**：Explore 页面（搜索 + 卡片网格 + 一键安装）\n4. **前端**：Manual Add 弹窗精简（移除探索 Tab，改为从 Explore 页触发）\n5. **联调 & 测试**\n"
  },
  {
    "path": "docs/releases/v0.3.0/plan-featured-skills.md",
    "content": "# 需求一：精选技能推荐列表 — 实施计划\n\n## Context\n\n用户打开\"添加技能\"时，只有手动输入本地路径或 Git URL，缺乏发现能力。需要在 AddSkillModal 中新增\"探索\"标签页，展示由 CI 预生成的热门技能列表，用户点击后自动走现有 Git 安装流程。\n\n数据源：ClawHub API (`clawhub.ai/api/v1/skills`)，由 GitHub Actions 每日拉取生成 `featured-skills.json` 提交到仓库。应用运行时从 GitHub raw URL 获取该 JSON（带本地缓存兜底），避免直接依赖 ClawHub API。\n\n## 关键发现\n\n- AddSkillModal 已有一个 **disabled 的搜索标签按钮**（第 88-90 行），可直接改造为\"探索\"\n- `addModalTab` 类型当前为 `'local' | 'git'`，需扩展为三值联合\n- 后端已有 `reqwest::blocking::Client` + `github_search.rs` 的 HTTP 模式可复用\n- `SkillStore` 已有 `get_setting` / `set_setting` 可用于缓存\n- `parse_github_url` 已支持 `https://github.com/owner/repo/tree/branch/path` 格式\n- 安装 URL 格式：`https://github.com/openclaw/skills/tree/main/skills/{username}/{slug}`\n\n## 步骤 1：CI — GitHub Actions 工作流 + 拉取脚本\n\n### 新建 `.github/workflows/update-featured-skills.yml`\n\n- 每日 UTC 0:00 定时运行 + 支持手动触发\n- 执行 Node.js 脚本 `scripts/fetch-featured-skills.mjs`\n- 若 JSON 有变化则自动提交\n\n### 新建 `scripts/fetch-featured-skills.mjs`\n\n逻辑：\n1. 调用 `GET https://clawhub.ai/api/v1/skills?sort=downloads&limit=100`\n2. 调用 GitHub API 获取 `openclaw/skills` 仓库 `skills/` 目录结构（用于匹配 slug → 实际路径）\n3. 对每个 ClawHub 技能，在 `openclaw/skills` 目录中按 slug 匹配找到 `{username}/{slug}` 路径\n4. 生成 `featured-skills.json`，结构：\n\n```json\n{\n  \"updated_at\": \"2026-03-13T00:00:00Z\",\n  \"skills\": [\n    {\n      \"slug\": \"self-improving-agent\",\n      \"name\": \"self-improving-agent\",\n      \"summary\": \"Captures learnings, errors...\",\n      \"downloads\": 197815,\n      \"stars\": 1934,\n      \"source_url\": \"https://github.com/openclaw/skills/tree/main/skills/username/self-improving-agent\"\n    }\n  ]\n}\n```\n\n5. 未匹配到 GitHub 路径的技能，`source_url` 留空，前端不显示安装按钮\n\n### 同时先手动运行一次脚本，生成初始 `featured-skills.json` 提交到仓库根目录\n\n## 步骤 2：后端 — 新建 `core/featured_skills.rs`\n\n参考 `github_search.rs` (src-tauri/src/core/github_search.rs) 模式。\n\n```rust\n// 数据结构\npub struct FeaturedSkillsData { updated_at: String, skills: Vec<FeaturedSkill> }\npub struct FeaturedSkill { slug, name, summary, downloads: u64, stars: u64, source_url: String }\n\n// 核心函数\npub fn fetch_featured_skills(store: &SkillStore) -> Result<Vec<FeaturedSkill>>\n```\n\n逻辑：\n1. `reqwest::blocking::Client` 请求 `https://raw.githubusercontent.com/{owner}/{repo}/main/featured-skills.json`\n2. 成功 → 解析 JSON，缓存到 `store.set_setting(\"featured_skills_cache\", &json_str)`\n3. 失败 → 从 `store.get_setting(\"featured_skills_cache\")` 读缓存\n4. 都失败 → 返回空 Vec（优雅降级，不 bail）\n5. 过滤掉 `source_url` 为空的条目\n\n### 修改 `core/mod.rs`\n\n添加 `pub mod featured_skills;`\n\n## 步骤 3：后端 — 注册 Tauri 命令\n\n### 修改 `src-tauri/src/commands/mod.rs`\n\n新增 DTO 和命令：\n\n```rust\n#[derive(Debug, Serialize)]\npub struct FeaturedSkillDto {\n    pub slug: String,\n    pub name: String,\n    pub summary: String,\n    pub downloads: u64,\n    pub stars: u64,\n    pub source_url: String,\n}\n\n#[tauri::command]\npub async fn get_featured_skills(store: State<'_, SkillStore>) -> Result<Vec<FeaturedSkillDto>, String>\n```\n\n使用标准的 `spawn_blocking` + `format_anyhow_error` 模式。\n\n### 修改 `src-tauri/src/lib.rs`\n\n在 `generate_handler!` 中注册 `commands::get_featured_skills`。\n\n## 步骤 4：前端 — 类型定义\n\n### 修改 `src/components/skills/types.ts`\n\n```typescript\nexport type FeaturedSkillDto = {\n  slug: string\n  name: string\n  summary: string\n  downloads: number\n  stars: number\n  source_url: string\n}\n```\n\n## 步骤 5：前端 — App.tsx 状态管理\n\n### 修改 `src/App.tsx`\n\n1. **Tab 类型扩展**：`useState<'local' | 'git' | 'explore'>('explore')` — 默认打开探索标签\n2. **新增状态**：\n   - `const [featuredSkills, setFeaturedSkills] = useState<FeaturedSkillDto[]>([])`\n   - `const [featuredLoading, setFeaturedLoading] = useState(false)`\n   - `const [exploreFilter, setExploreFilter] = useState('')`\n3. **新增 `loadFeaturedSkills`**：调用 `invoke('get_featured_skills')`，在 `handleOpenAdd` 中触发（仅首次或数据为空时）\n4. **新增 `handleSelectFeaturedSkill(sourceUrl: string)`**：\n   - `setGitUrl(sourceUrl)`\n   - `setAddModalTab('git')` — 自动跳转到 Git 标签\n5. **传递新 props 给 AddSkillModal**：`featuredSkills`, `featuredLoading`, `exploreFilter`, `onExploreFilterChange`, `onSelectFeaturedSkill`\n\n## 步骤 6：前端 — 修改 AddSkillModal\n\n### 修改 `src/components/skills/modals/AddSkillModal.tsx`\n\n1. **Props 类型扩展**：添加 explore 相关的 6 个新 props\n2. **启用探索标签**：替换第 88-90 行 disabled 按钮为可点击的 explore 标签\n3. **三分支条件渲染**：\n   - `addModalTab === 'explore'` → 探索内容\n   - `addModalTab === 'local'` → 本地表单（现有）\n   - `addModalTab === 'git'` → Git 表单（现有）\n4. **探索标签内容**：\n   - 筛选输入框（前端过滤 name + summary）\n   - 可滚动列表（max-height ~400px）\n   - 每项显示：name（粗体）、summary（截断）、downloads + stars 统计\n   - 点击 → 调用 `onSelectFeaturedSkill(source_url)`\n   - Loading / Empty 状态\n5. **条件隐藏底部区域**：explore 标签时隐藏 \"Install to tools\" 复选框区域和 footer 按钮（用户在此标签只浏览，安装在 git 标签完成）\n\n## 步骤 7：前端 — i18n 翻译\n\n### 修改 `src/i18n/resources.ts`\n\n```\nEN:\n  exploreTab: 'Explore'\n  exploreFilterPlaceholder: 'Filter skills...'\n  exploreEmpty: 'No featured skills available.'\n  exploreLoading: 'Loading featured skills...'\n  exploreError: 'Failed to load featured skills.'\n\nZH:\n  exploreTab: '探索'\n  exploreFilterPlaceholder: '筛选技能...'\n  exploreEmpty: '暂无精选技能。'\n  exploreLoading: '加载精选技能中...'\n  exploreError: '加载精选技能失败。'\n```\n\n## 步骤 8：前端 — CSS 样式\n\n### 修改 `src/App.css`\n\n添加探索标签页样式：\n- `.explore-filter` — 输入框\n- `.explore-list` — 可滚动容器 (max-height: 400px, overflow-y: auto)\n- `.explore-skill-item` — 单条技能行 (cursor: pointer, hover 高亮)\n- `.explore-skill-name` — 名称\n- `.explore-skill-summary` — 简介 (text-overflow: ellipsis)\n- `.explore-skill-stats` — 统计数字\n- `.explore-empty` / `.explore-loading` — 状态提示\n\n## 步骤 9：后端测试\n\n### 新建 `src-tauri/src/core/tests/featured_skills.rs`\n\n使用 `mockito` mock HTTP：\n- 测试正常 JSON 解析\n- 测试 HTTP 失败时缓存 fallback\n- 测试空/畸形 JSON 的优雅降级\n\n## 修改文件清单\n\n| 文件 | 操作 | 说明 |\n|------|------|------|\n| `.github/workflows/update-featured-skills.yml` | 新建 | CI 定时任务 |\n| `scripts/fetch-featured-skills.mjs` | 新建 | 数据拉取脚本 |\n| `featured-skills.json` | 新建 | CI 生成的精选列表 |\n| `src-tauri/src/core/featured_skills.rs` | 新建 | 后端核心逻辑 |\n| `src-tauri/src/core/mod.rs` | 修改 | 导出新模块 |\n| `src-tauri/src/commands/mod.rs` | 修改 | 新增命令 + DTO |\n| `src-tauri/src/lib.rs` | 修改 | 注册命令 |\n| `src/components/skills/types.ts` | 修改 | 新增前端 DTO |\n| `src/App.tsx` | 修改 | 状态 + 回调 + props |\n| `src/components/skills/modals/AddSkillModal.tsx` | 修改 | UI 改造核心 |\n| `src/i18n/resources.ts` | 修改 | 中英文翻译 |\n| `src/App.css` | 修改 | 探索标签样式 |\n| `src-tauri/src/core/tests/featured_skills.rs` | 新建 | 后端测试 |\n\n## 验证方式\n\n1. `npm run check` — lint + build + rust:clippy + rust:test 全部通过\n2. `npm run tauri:dev` — 打开 AddSkillModal，默认显示\"探索\"标签\n3. 确认列表加载正常（或网络失败时显示友好提示）\n4. 输入关键词，确认筛选功能工作\n5. 点击某个技能，确认自动跳转到 Git 标签并填充 URL\n6. 在 Git 标签点击安装，确认走通完整安装流程\n"
  },
  {
    "path": "docs/releases/v0.3.0/plan-online-search.md",
    "content": "# 需求二：在线技能搜索与安装 — 实施计划（已完成）\n\n## Context\n\n需求一已实现\"探索\"标签页，展示精选技能列表。但精选列表覆盖有限，用户有明确需求时（如\"找 React 相关技能\"），需要实时搜索能力。搜索功能内嵌在探索标签页中：用户输入关键词 → 精选本地过滤秒出 + 在线搜索结果分区展示，清空搜索框 → 恢复精选列表。\n\n数据源：`skills.sh/api/search?q={query}&limit=20`（无需认证，模糊搜索，最少 2 字符）。无 CORS，必须从 Rust 后端调用。\n\n### API 返回字段\n\n```json\n{\n  \"id\": \"vercel-labs/agent-skills/vercel-react-best-practices\",\n  \"skillId\": \"vercel-react-best-practices\",\n  \"name\": \"vercel-react-best-practices\",\n  \"installs\": 205992,\n  \"source\": \"vercel-labs/agent-skills\"\n}\n```\n\n> **重要发现**：`id` 和 `name` 不能直接映射到仓库内的文件路径。例如 API `name: \"json-render-react\"` 对应仓库目录 `skills/react/`、SKILL.md `name: \"react\"`。skills.sh 平台对名称做了转换，与仓库实际 SKILL.md frontmatter name 不一致。\n\n## 核心设计决策\n\n### 方案：分区展示，精选 + 在线互补\n\n输入框升级为双模式，两个数据源分区展示：\n- 输入 < 2 字符：仅前端本地过滤精选列表（现有行为不变）\n- 输入 >= 2 字符：上方显示精选列表本地过滤结果（秒出，有 summary），下方分割线后显示在线搜索结果（500ms 防抖，有 installs）\n- 清空输入框：隐藏在线搜索区域，恢复完整精选列表\n\n```\n用户输入 \"react\"\n  ↓ 立刻\n  ┌─ 精选推荐（本地过滤） ──────────────┐\n  │  vercel-react-best-practices        │  ← 有 summary\n  │  vercel-react-native-skills         │\n  └─────────────────────────────────────┘\n  ↓ 500ms 后\n  ┌─ 在线搜索 skills.sh ───────────────┐\n  │  react-expert (203K installs)       │  ← 无 summary，有 installs\n  │  react (57K installs)               │\n  └─────────────────────────────────────┘\n```\n\n### 在线搜索去重\n\n在线结果中与精选列表 name 重复的条目自动过滤（`useMemo` 按 name 去重）。\n\n### 点击安装 — 多技能仓库自动匹配\n\n点击在线搜索结果时，`source_url` 是仓库地址（`https://github.com/owner/repo`），不含子目录路径（因为 API 数据无法可靠映射到仓库文件路径）。安装流程：\n\n1. 设置 `gitUrl` = 仓库地址，`autoSelectSkillName` = API 返回的 skill name\n2. 用户点安装 → `list_git_skills_cmd` 克隆仓库列出所有候选 skill\n3. **自动匹配策略**（三级回退）：\n   - 精确匹配：API name === SKILL.md name（如 `vercel-react-best-practices`）\n   - 唯一包含匹配：API name 包含某个 SKILL.md name，且仅一个候选命中（如 `json-render-react` 包含 `react`）\n   - 回退 picker：匹配失败或多个候选命中时，弹出选择弹窗让用户手动选\n4. 单技能仓库：直接安装（现有逻辑不变）\n\n## 实现步骤\n\n### 步骤 1：后端 — `core/skills_search.rs`（新建）\n\n参考 `github_search.rs` 模式。\n\n```rust\n#[derive(Debug, Deserialize)]\nstruct SkillsShResponse { skills: Vec<SkillsShItem> }\nstruct SkillsShItem { name: String, installs: u64, source: String }\n\npub struct OnlineSkillResult {\n    pub name: String,\n    pub installs: u64,\n    pub source: String,        // \"owner/repo\"\n    pub source_url: String,    // \"https://github.com/owner/repo\"\n}\n\npub fn search_skills_online(query: &str, limit: usize) -> Result<Vec<OnlineSkillResult>>\n// 内部函数 search_skills_online_inner(base_url, query, limit) 支持测试注入\n```\n\n`source_url` 由 `source` 字段拼接（`https://github.com/{source}`）。不使用 `id` 字段构造路径（无法映射到仓库实际目录结构）。\n\n在 `core/mod.rs` 添加 `pub mod skills_search;`。\n\n### 步骤 2：后端 — 注册 Tauri 命令\n\n`commands/mod.rs` 新增：\n\n```rust\n#[derive(Debug, Serialize)]\npub struct OnlineSkillDto { name, installs, source, source_url }\n\nimpl From<OnlineSkillResult> for OnlineSkillDto { ... }\n\n#[tauri::command]\npub async fn search_skills_online(query: String, limit: Option<u32>) -> Result<Vec<OnlineSkillDto>, String>\n```\n\n不需要 `State<SkillStore>`（纯 HTTP 调用）。在 `lib.rs` 的 `generate_handler!` 中注册。\n\n### 步骤 3：前端 — 类型定义\n\n`src/components/skills/types.ts` 新增：\n\n```typescript\nexport type OnlineSkillDto = {\n  name: string\n  installs: number\n  source: string      // \"owner/repo\"\n  source_url: string  // \"https://github.com/owner/repo\"\n}\n```\n\n### 步骤 4：前端 — App.tsx 状态与逻辑\n\n1. **新增状态**：\n   - `searchResults: OnlineSkillDto[]` — 在线搜索结果\n   - `searchLoading: boolean` — 搜索加载中\n   - `searchTimerRef: useRef` — 500ms 防抖 timer\n   - `autoSelectSkillName: string | null` — 从在线搜索点击时记录的目标 skill 名称\n\n2. **`handleExploreFilterChange`**：\n   - 设置 `exploreFilter`（驱动精选列表本地过滤，秒出）\n   - < 2 字符 → 清除 timer + 清空搜索结果\n   - >= 2 字符 → 500ms 防抖后调用 `invoke('search_skills_online', { query, limit: 20 })`\n\n3. **`handleSelectSearchResult(sourceUrl, skillName)`**：\n   - `setGitUrl(sourceUrl)` + `setAutoSelectSkillName(skillName)` + 切换到 git tab\n\n4. **`handleCreateGit` 中多技能分支增加自动匹配**：\n   - 当 `autoSelectSkillName` 存在且 `candidates.length > 1` 时，按三级回退策略自动匹配\n   - 匹配成功 → 直接 `install_git_selection` 安装\n   - 匹配失败 → 回退到 picker 弹窗\n\n### 步骤 5：前端 — AddSkillModal 分区布局\n\nProps 新增：`searchResults`, `searchLoading`, `onSelectSearchResult(sourceUrl, skillName)`\n\n```\n{/* 区域 1：精选推荐（始终显示，本地过滤） */}\n{isSearchActive && <SectionTitle>精选推荐</SectionTitle>}\n<explore-list>精选过滤结果</explore-list>\n\n{/* 区域 2：在线搜索（仅 >= 2 字符时显示） */}\n{isSearchActive && <>\n  <SectionTitle>在线搜索</SectionTitle>\n  {searchLoading ? <Loading /> : deduplicatedResults.map(skill => (\n    <SkillItem name={skill.name} source={skill.source} installs={skill.installs}\n               onClick={() => onSelectSearchResult(skill.source_url, skill.name)} />\n  ))}\n</>}\n\n{/* 全局空状态 */}\n{无精选 && 无搜索 && <Empty />}\n```\n\n去重：`useMemo` 从 `searchResults` 中过滤掉 name 已存在于 `filteredSkills` 的条目。\n\n### 步骤 6：i18n 翻译\n\n```\nEN:\n  exploreFilterPlaceholder: 'Filter or search skills online...'\n  exploreFeaturedTitle: 'Featured'\n  exploreOnlineTitle: 'Online Results'\n  searchLoading: 'Searching skills.sh...'\n  searchEmpty: 'No additional results found.'\n  searchError: 'Online search failed.'\n\nZH:\n  exploreFilterPlaceholder: '筛选精选或在线搜索技能...'\n  exploreFeaturedTitle: '精选推荐'\n  exploreOnlineTitle: '在线搜索'\n  searchLoading: '正在搜索 skills.sh...'\n  searchEmpty: '未找到更多结果。'\n  searchError: '在线搜索失败。'\n```\n\n### 步骤 7：CSS 样式\n\n- `.explore-section-title` — 分区标题（小字灰色，带上边框分隔，uppercase）\n- `.explore-skill-source` — source 字段（灰色小字 12px）\n- 复用现有 `.explore-skill-item` / `.explore-list` 样式\n\n### 步骤 8：后端测试\n\n`src-tauri/src/core/tests/skills_search.rs`，使用 `mockito` mock：\n- `parses_search_results` — 正常解析 + source_url 拼接\n- `source_url_is_constructed_from_source` — source → GitHub URL\n- `http_error_returns_error` — HTTP 500 错误处理\n- `empty_results` — 空结果\n\n## 修改文件清单\n\n| 文件 | 操作 | 说明 |\n|------|------|------|\n| `src-tauri/src/core/skills_search.rs` | 新建 | 搜索核心逻辑（请求 skills.sh API） |\n| `src-tauri/src/core/mod.rs` | 修改 | 导出 `skills_search` 模块 |\n| `src-tauri/src/commands/mod.rs` | 修改 | 新增 `OnlineSkillDto` + `search_skills_online` 命令 |\n| `src-tauri/src/lib.rs` | 修改 | 在 `generate_handler!` 注册命令 |\n| `src-tauri/src/core/tests/skills_search.rs` | 新建 | 4 个后端测试 |\n| `src/components/skills/types.ts` | 修改 | 新增 `OnlineSkillDto` 类型 |\n| `src/App.tsx` | 修改 | 搜索状态 + 防抖 + `autoSelectSkillName` 自动匹配 |\n| `src/components/skills/modals/AddSkillModal.tsx` | 修改 | 分区展示 UI + 去重 |\n| `src/i18n/resources.ts` | 修改 | 搜索相关中英文翻译 |\n| `src/App.css` | 修改 | 分区标题 + source 样式 |\n\n## 验证方式\n\n1. `npm run check` — 全部通过（lint + build + rust fmt/clippy/test）\n2. 打开探索标签，输入 1 个字符 → 仅本地过滤精选列表，无在线搜索区域\n3. 输入 2+ 字符 → 精选过滤结果秒出（上方），500ms 后在线搜索区域出现\n4. 在线搜索结果中不包含精选列表已有的条目（去重）\n5. 搜索结果正确展示（name, installs, source）\n6. 清空输入框 → 在线搜索区域消失，恢复完整精选列表\n7. 点击搜索结果 → 跳转到 Git 标签填充仓库 URL → 安装时自动匹配目标 skill\n8. 多技能仓库自动匹配失败时 → 回退到手动选择弹窗\n9. 断网时搜索 → 在线区域显示错误提示，精选列表不受影响\n\n## 已知限制\n\n- skills.sh API 的 `name` 与仓库 SKILL.md frontmatter `name` 不一定一致（如 `json-render-react` vs `react`），自动匹配使用精确 + 唯一包含策略，极端情况可能回退到手动选择\n- `source_url` 只包含仓库地址，不含子目录路径（API `id` 字段无法可靠映射到仓库文件路径）\n- 多技能仓库首次安装需克隆完整仓库以获取候选列表（后续命中缓存）\n"
  },
  {
    "path": "docs/releases/v0.3.0/plan-skill-detail-view.md",
    "content": "# 技能详情页（Skill Detail View）— 实施计划（已完成）\n\n## Context\n\n当前已安装的技能以卡片列表展示，但无法查看技能的具体文件内容。用户希望点击技能卡片后能看到技能的所有文件，默认显示 SKILL.md，支持切换文件和返回列表。\n\n### 设计原型\n\n原型图位于 `docs/skills_hub_v2_design.html` 的 Screen 5A–5D 部分。\n\n### 交互流程\n\n1. 在 My Skills 列表中，技能名称为可点击链接（hover 变蓝色）\n2. 点击后整个内容区替换为详情视图（非模态框），Header 导航栏保持不变，My Skills tab 保持高亮\n3. 详情视图顶部显示返回按钮、技能名称、描述、来源、更新时间、文件数\n4. 下方为左右分栏：左侧文件树（260px），右侧文件内容（语法高亮 / Markdown 渲染）\n5. 默认选中 SKILL.md（排首位），点击左侧其他文件切换内容\n6. 文件夹默认折叠，点击展开/收起\n7. 点击返回按钮回到列表视图\n\n## 核心设计决策\n\n### 视图切换而非模态框\n\n采用扩展 `activeView` 状态增加 `'detail'` 视图的方式，与现有的 `'myskills'` / `'explore'` 视图切换机制一致。优势：\n- 复用现有视图切换逻辑\n- 详情视图可占满整个内容区域，空间充足\n- detail 时 Header 仍高亮 My Skills tab，保持导航一致性\n\n### 文件内容渲染策略（三层）\n\n根据文件类型选择不同渲染方式：\n1. **Markdown 文件**（`.md`/`.mdx`）：使用 `react-markdown` + `remark-gfm` + `remark-frontmatter` 渲染为 GitHub 风格 Markdown（标题、表格、代码块高亮、引用块等），YAML frontmatter 自动剥离不显示\n2. **代码文件**（`.ts`/`.js`/`.py`/`.rs` 等 40+ 种语言）：使用 `react-syntax-highlighter`（Prism）语法高亮 + 行号，自动检测暗色/亮色主题切换 `oneDark`/`oneLight` 配色\n3. **其他文件**：纯文本显示 + 行号\n\n### 左侧文件树（自建，非第三方库）\n\n将扁平路径构建为树形结构，文件夹可折叠/展开，默认折叠。排序：目录在前 → 文件在后，SKILL.md 排首位，其余按字母排序。\n\n### 文件遍历复用 content_hash.rs 的过滤模式\n\n`list_files` 使用与 `content_hash.rs` 相同的 walkdir + IGNORE_NAMES 过滤（排除 `.git`、`.DS_Store` 等），保持一致性。\n\n---\n\n## 步骤一：后端 — 新建 `src-tauri/src/core/skill_files.rs`\n\n复用 `content_hash.rs` 的 walkdir + IGNORE_NAMES 过滤模式。\n\n### 两个函数\n\n**`list_files(central_path: &Path) -> Result<Vec<FileEntry>>`**\n- 使用 walkdir 遍历目录，过滤 IGNORE_NAMES\n- 返回 `Vec<FileEntry>`（相对路径 + 文件大小）\n- SKILL.md 排在首位（排序时特殊处理）\n- 其余文件按路径字母排序\n\n**`read_file(central_path: &Path, relative_path: &str) -> Result<String>`**\n- 路径穿越防护：禁止包含 `..`，canonicalize 后验证仍在 central_path 内\n- 1MB 大小限制，超出返回友好错误信息\n- 非 UTF-8 文件返回明确错误提示\n- 读取并返回文件内容字符串\n\n### FileEntry 结构体\n\n```rust\npub struct FileEntry {\n    pub path: String,  // 相对路径\n    pub size: u64,     // 文件大小（字节）\n}\n```\n\n### 模块导出\n\n在 `src-tauri/src/core/mod.rs` 中添加 `pub mod skill_files;`。\n\n---\n\n## 步骤二：后端 — 新增 Tauri 命令\n\n在 `src-tauri/src/commands/mod.rs` 中：\n\n### DTO\n\n```rust\n#[derive(Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct SkillFileEntry {\n    pub path: String,\n    pub size: u64,\n}\n```\n\n### 命令\n\n```rust\n#[tauri::command]\npub async fn list_skill_files(central_path: String) -> Result<Vec<SkillFileEntry>, String>\n// 调用 core::skill_files::list_files，转换为 DTO\n\n#[tauri::command]\npub async fn read_skill_file(central_path: String, file_path: String) -> Result<String, String>\n// 调用 core::skill_files::read_file\n```\n\n### 注册\n\n在 `src-tauri/src/lib.rs` 的 `generate_handler!` 中添加：\n- `commands::list_skill_files`\n- `commands::read_skill_file`\n\n---\n\n## 步骤三：前端 — 类型定义\n\n在 `src/components/skills/types.ts` 中新增：\n\n```typescript\nexport type SkillFileEntry = {\n  path: string\n  size: number\n}\n```\n\n---\n\n## 步骤四：前端 — 新建 `src/components/skills/SkillDetailView.tsx`\n\n### 依赖\n\n- `react-syntax-highlighter`（Prism 版）— 代码高亮 + 行号，40+ 语言，oneLight/oneDark 主题\n- `react-markdown` — Markdown 渲染\n- `remark-gfm` — GitHub Flavored Markdown（表格、删除线、任务列表等）\n- `remark-frontmatter` — 剥离 YAML frontmatter（SKILL.md 头部 metadata）\n\n### Props\n\n```typescript\ntype SkillDetailViewProps = {\n  skill: ManagedSkill\n  onBack: () => void\n  invokeTauri: <T>(command: string, args?: Record<string, unknown>) => Promise<T>\n  formatRelative: (ms: number | null | undefined) => string\n  t: TFunction\n}\n```\n\n### 内部状态\n\n- `files: SkillFileEntry[]` — 文件列表\n- `activeFile: string | null` — 当前选中的文件路径\n- `fileContent: string` — 当前文件内容\n- `loadingFiles: boolean` — 文件列表加载中\n- `loadingContent: boolean` — 文件内容加载中\n- `expanded: Set<string>` — 已展开的文件夹路径集合\n\n### 内部子组件\n\n- **`FileTreeNode`**（memo）— 递归渲染文件树节点，文件夹带 ChevronRight/ChevronDown + Folder/FolderOpen 图标\n- **`FileContentRenderer`**（memo）— 根据文件类型选择 Markdown / SyntaxHighlighter / 纯文本渲染\n\n### 行为\n\n- `useEffect` 挂载时调用 `invoke('list_skill_files', { centralPath })` 获取文件列表\n- 将扁平路径通过 `buildTree()` 构建为树形结构\n- 文件夹默认折叠（`expanded` 初始为空 Set）\n- 获取到文件列表后，默认选中第一个文件（应为 SKILL.md）\n- `activeFile` 变化时调用 `invoke('read_skill_file', { centralPath, filePath })` 读取内容\n- 自动检测暗色/亮色主题（读取 `data-theme` 属性），切换语法高亮配色\n- 错误处理：通过 toast 显示错误信息\n\n### 布局\n\n```\n┌──────────────────────────────────────────────────────┐\n│ [← Back]                                              │\n│ 技能名称                                               │\n│ 描述                                                   │\n│ 来源 · 更新时间 · N files                               │\n├──────────────┬───────────────────────────────────────┤\n│ FILES        │  file-path                      size   │\n│ ▸ skills/    │┌─────────────────────────────────────┐│\n│ ▾ examples/  ││  Markdown 渲染 / 语法高亮 + 行号     ││\n│   basic.md   ││                                     ││\n│   adv.md     ││                                     ││\n│ SKILL.md ●   │└─────────────────────────────────────┘│\n└──────────────┴───────────────────────────────────────┘\n```\n\n---\n\n## 步骤五：前端 — 修改 `src/App.tsx`\n\n### 状态变更\n\n- `activeView` 类型扩展为 `'myskills' | 'explore' | 'detail'`\n- 新增 `detailSkill: ManagedSkill | null` 状态\n\n### 回调函数\n\n```typescript\nconst handleOpenDetail = useCallback((skill: ManagedSkill) => {\n  setDetailSkill(skill)\n  setActiveView('detail')\n}, [])\n\nconst handleBackToList = useCallback(() => {\n  setDetailSkill(null)\n  setActiveView('myskills')\n}, [])\n```\n\n### 渲染\n\n在 `skills-main` 区域增加 `activeView === 'detail'` 分支，渲染 `<SkillDetailView>`。\n\n---\n\n## 步骤六：前端 — 修改 `src/components/skills/SkillCard.tsx`\n\n- 新增 `onOpenDetail: (skill: ManagedSkill) => void` prop\n- `.skill-name` 改为 `<button>` 元素，添加 `onClick={() => onOpenDetail(skill)}`\n- 添加 `clickable` CSS class（hover 变蓝色）\n\n---\n\n## 步骤七：前端 — 修改 `src/components/skills/SkillsList.tsx`\n\n- 新增 `onOpenDetail` prop\n- 透传到每个 `<SkillCard>` 组件\n\n---\n\n## 步骤八：前端 — 修改 `src/components/skills/Header.tsx`\n\n- `activeView` 类型扩展为 `'myskills' | 'explore' | 'detail'`\n- detail 视图时 My Skills tab 保持高亮状态（判断条件改为 `activeView === 'myskills' || activeView === 'detail'`）\n\n---\n\n## 步骤九：样式 — `src/App.css`\n\n新增详情视图相关 CSS class：\n\n**布局结构：**\n- `.detail-view` — 整体容器（flex column, 填满空间）\n- `.detail-header` — 顶部技能信息区\n- `.detail-back-btn` — 返回按钮（hover 变蓝）\n- `.detail-skill-name` — 技能名称（大号加粗）\n- `.detail-desc` — 描述文字\n- `.detail-meta` — 来源/时间/文件数元信息行\n- `.detail-body` — 左右分栏容器（flex row）\n\n**文件树侧栏（260px）：**\n- `.detail-file-list` — 侧栏容器（bg-panel 背景）\n- `.file-list-title` — \"Files\" 标题\n- `.file-tree` — 树容器\n- `.tree-item` — 树节点（通用，28px 最小高度）\n- `.tree-dir` — 目录节点\n- `.tree-file` — 文件节点（active 蓝色高亮）\n- `.tree-chevron` — 折叠箭头\n- `.tree-icon-folder` — 文件夹图标（蓝色）\n- `.tree-icon-file` — 文件图标\n- `.tree-name` — 名称（ellipsis 截断）\n- `.tree-size` — 文件大小\n\n**文件内容区：**\n- `.detail-file-content` — 右侧内容区\n- `.file-content-header` — 文件路径 + 大小（sticky top, bg-panel 背景）\n- `.file-content-body` — 内容容器\n\n**Markdown 渲染样式：**\n- `.markdown-body` — Markdown 容器（max-width 860px，GitHub 风格排版）\n- `.markdown-body h1/h2` — 标题带底部边框\n- `.markdown-body pre` — 代码块（border + border-radius）\n- `.markdown-body .md-inline-code` — 行内代码\n- `.markdown-body table/th/td` — 表格样式\n- `.markdown-body blockquote` — 引用块（蓝色左边框）\n\n**通用：**\n- `.skill-name.clickable` — 卡片中可点击的技能名称\n\n暗色主题通过 CSS 变量自动适配，无需额外样式。\n\n---\n\n## 步骤十：i18n — `src/i18n/resources.ts`\n\n新增翻译 key（`detail` 命名空间）：\n\n| Key | EN | ZH |\n|-----|----|----|\n| `detail.back` | Back | 返回 |\n| `detail.files` | Files | 文件 |\n| `detail.noFiles` | No files found | 未找到文件 |\n| `detail.loadingFiles` | Loading files... | 加载文件中... |\n| `detail.loadingContent` | Loading file content... | 加载文件内容中... |\n| `detail.readError` | Failed to read file | 读取文件失败 |\n| `detail.fileCount` | {{count}} files | {{count}} 个文件 |\n\n---\n\n## 新增依赖\n\n| 包名 | 用途 |\n|------|------|\n| `react-syntax-highlighter` | 代码语法高亮 + 行号（Prism 版，oneLight/oneDark 主题） |\n| `@types/react-syntax-highlighter` | TypeScript 类型定义 |\n| `react-markdown` | Markdown 渲染 |\n| `remark-gfm` | GitHub Flavored Markdown 支持 |\n| `remark-frontmatter` | YAML frontmatter 剥离 |\n\n---\n\n## 修改文件清单\n\n| 文件 | 操作 | 说明 |\n|------|------|------|\n| `package.json` | 修改 | 新增 5 个依赖 |\n| `src-tauri/src/core/skill_files.rs` | **新建** | list_files + read_file 核心逻辑 |\n| `src-tauri/src/core/mod.rs` | 修改 | 导出 skill_files 模块 |\n| `src-tauri/src/commands/mod.rs` | 修改 | 新增 2 个命令 + SkillFileEntry DTO |\n| `src-tauri/src/lib.rs` | 修改 | 注册 list_skill_files、read_skill_file |\n| `src/components/skills/types.ts` | 修改 | 新增 SkillFileEntry 类型 |\n| `src/components/skills/SkillDetailView.tsx` | **新建** | 详情视图组件（文件树 + Markdown/高亮渲染） |\n| `src/components/skills/SkillCard.tsx` | 修改 | 新增 onOpenDetail prop + 点击事件 |\n| `src/components/skills/SkillsList.tsx` | 修改 | 透传 onOpenDetail prop |\n| `src/components/skills/Header.tsx` | 修改 | activeView 类型扩展 |\n| `src/App.tsx` | 修改 | 新增状态 + 渲染分支 + import SkillDetailView |\n| `src/App.css` | 修改 | 新增文件树 + Markdown + 详情视图样式 |\n| `src/i18n/resources.ts` | 修改 | 新增翻译 key（中英双语） |\n\n---\n\n## 验证\n\n1. `npm run check` — 确保 lint + build + rust clippy/test 全部通过 ✅\n2. `npm run tauri:dev` — 手动测试：\n   - 点击技能名称进入详情视图\n   - 默认显示 SKILL.md 内容（Markdown 渲染，frontmatter 已剥离）\n   - 切换代码文件，语法高亮 + 行号正确显示\n   - 文件树目录可折叠/展开，默认折叠\n   - 点击返回按钮回到列表\n   - Header 导航状态正确（detail 时 My Skills 高亮）\n   - 暗色/亮色主题下显示正常\n   - 加载状态正确显示\n"
  },
  {
    "path": "docs/releases/v0.3.1/plan-in-app-update.md",
    "content": "# 需求：应用内检查更新（Issue #33）\n\n## Context\n\n来源：https://github.com/qufei1993/skills-hub/issues/33\n\n用户每次跟进版本都需要手动进入 GitHub releases 页面下载，体验不便。需要在软件内支持手动检查更新功能。\n\n## 现状分析\n\n后端 Tauri updater 插件已完全就绪：\n- `tauri-plugin-updater` v2 已安装（Cargo.toml + package.json）\n- `tauri.conf.json` 已配置更新端点（GitHub releases）+ 公钥签名验证\n- `lib.rs` 已注册插件\n- i18n 翻译键已全部就绪（EN/ZH）\n\n唯一缺失：前端没有调用更新 API 的 UI。\n\n## 实施方案\n\n在 SettingsModal 底部版本信息区域扩展为\"检查更新\"功能块：\n\n1. **SettingsModal.tsx** — 添加更新状态管理 + UI\n   - 状态：idle → checking → up-to-date / available → downloading → done / error\n   - 使用 `@tauri-apps/plugin-updater` 的 `check()` 和 `downloadAndInstall()` API\n   - 保存 update 对象引用避免重复请求\n   - 弹窗关闭时重置状态\n\n2. **App.css** — 添加更新区块样式\n   - 版本号 + 按钮水平排列\n   - 更新可用时显示高亮区块\n   - 错误/成功状态样式\n\n3. **版本号** — 0.3.0 → 0.3.1\n\n## 涉及文件\n\n| 文件 | 改动 |\n|------|------|\n| `src/components/skills/modals/SettingsModal.tsx` | 添加更新检查 UI + 逻辑 |\n| `src/App.css` | 添加更新区块样式 |\n| `package.json` | 版本号 → 0.3.1 |\n| `src-tauri/tauri.conf.json` | 版本号 → 0.3.1 |\n| `src-tauri/Cargo.toml` | 版本号 → 0.3.1 |\n"
  },
  {
    "path": "docs/releases/v0.3.1/plan-qoderwork-support.md",
    "content": "# 需求：支持 QoderWork 目录（Issue #34）\n\n## Context\n\n来源：https://github.com/qufei1993/skills-hub/issues/34\n\nQoderWork 是 Qoder 推出的桌面 AI 代理产品，与 Qoder IDE 独立，使用 `~/.qoderwork/skills/` 目录存放技能。当前代码库已支持 Qoder（`.qoder/skills`），但尚未支持 QoderWork。\n\n## 实施方案\n\n参照现有 Qoder 适配器模式，在 3 处添加 QoderWork 支持：\n\n### 1. `src-tauri/src/core/tool_adapters/mod.rs`\n\n- **ToolId 枚举**：在 `Qoder` 之后添加 `QoderWork`\n- **as_key() 方法**：添加 `ToolId::QoderWork => \"qoderwork\"`\n- **default_tool_adapters()**：添加 ToolAdapter 实例：\n  ```rust\n  ToolAdapter {\n      id: ToolId::QoderWork,\n      display_name: \"QoderWork\",\n      relative_skills_dir: \".qoderwork/skills\",\n      relative_detect_dir: \".qoderwork\",\n  },\n  ```\n\n### 2. `src/i18n/resources.ts`\n\n- 英文 tools 对象：添加 `qoderwork: 'QoderWork'`\n- 中文 tools 对象：添加 `qoderwork: 'QoderWork'`\n\n## 验证\n\n- `npm run check` 确保 lint、build、Rust clippy/test 全部通过\n"
  },
  {
    "path": "docs/releases/v0.3.1/plan-settings-page.md",
    "content": "# 需求：设置弹窗改为独立页面\n\n## Context\n\n设置功能原来是一个 560px 宽的模态弹窗（SettingsModal），在小窗口下容易被遮挡，需要最大化窗口才能完整查看。将其改为 `activeView` 视图系统中的独立页面，与 myskills/explore/detail 并列，UX 更自然。\n\n## 实施方案\n\n### 1. 组件迁移：`modals/SettingsModal.tsx` → `SettingsPage.tsx`\n\n- 移动到 `src/components/skills/SettingsPage.tsx`，与 `ExplorePage`、`SkillDetailView` 同级\n- 类型名 `SettingsModalProps` → `SettingsPageProps`\n  - 删除 `open: boolean`\n  - `onRequestClose` → `onBack`\n- 去掉 modal 外壳（`modal-backdrop` / `modal` / `modal-header` / `modal-footer`）\n- 复用 `detail-header` + `detail-back-btn` 样式，与 SkillDetailView 保持一致\n- useEffect 从依赖 `open` 改为挂载/卸载模式\n- 删除 `if (!open) return null` 守卫\n- 删除底部 \"Done\" 按钮\n\n### 2. `App.tsx` — 状态和路由\n\n- `activeView` 类型扩展为 `'myskills' | 'explore' | 'detail' | 'settings'`\n- 删除 `showSettingsModal` 布尔状态\n- `handleOpenSettings` 改为 `setActiveView('settings')`\n- `handleCloseSettings` 改为 `setActiveView('myskills')`\n- `<main>` 条件渲染新增 `settings` 分支，渲染 `<SettingsPage>`\n- 删除底部的 `<SettingsModal>` 渲染块\n\n### 3. `Header.tsx` — 导航状态\n\n- `activeView` 类型加 `| 'settings'`\n- 设置齿轮按钮在 `activeView === 'settings'` 时添加 `active` 样式\n\n### 4. `App.css` — 样式调整\n\n- 删除 `.settings-modal` 和 `.settings-body` 规则\n- 新增 `.settings-page`（居中布局）和 `.settings-page-body`（560px 最大宽度，居中）\n- 复用 `.detail-header` / `.detail-back-btn` / `.detail-skill-name` 样式\n- 所有 `.settings-field` 等子样式保持不变\n\n### 不需要改动\n\n- **i18n**：复用已有的 `detail.back`、`settings` key\n- **Rust 后端**：纯前端改动\n- **types.ts**：无 DTO 变更\n"
  },
  {
    "path": "docs/releases/v0.3.1/skills-aggregation-repo.md",
    "content": "# 需求：Skills 聚合数据源升级 — 精选仓库列表方案\n\n## 背景\n\nSkills Hub 应用的\"探索\"功能依赖 `featured-skills.json` 提供精选技能列表。早期方案通过 GitHub Search API 搜索 `claude-code-skill` 等 topic 标签自动发现仓库，但属于盲目搜索，质量不可控，噪音多。\n\n**新方案**：维护一份精选仓库列表（Curated Repos），直接从已知的高质量仓库中获取 skills。质量可控、API 调用极少、维护简单。\n\n## 目标\n\n重写 `scripts/fetch-featured-skills.mjs` 脚本，从精选仓库列表直接获取元数据并深入检测 skill 目录结构，为每个 skill 生成独立条目，最终输出 `featured-skills.json`（最多 300 条）。保持现有的 GitHub Actions 定时更新机制不变。\n\n## 架构（保持不变）\n\n```\nskills-desktop-app/\n├── featured-skills.json                          # 精选技能数据（应用内嵌 fallback + 在线更新源）\n├── scripts/\n│   └── fetch-featured-skills.mjs                 # 聚合脚本（需重写）\n└── .github/workflows/\n    └── update-featured-skills.yml                # 每日定时运行（保持不变）\n```\n\n## 数据源 — 精选仓库列表\n\n从 GitHub Topic 盲目搜索改为直接指定高质量仓库：\n\n| 仓库 | Stars | 说明 |\n|------|-------|------|\n| `anthropics/skills` | ~96k | Anthropic 官方 Agent Skills |\n| `sickn33/antigravity-awesome-skills` | ~24.7k | 1000+ 社区 skills 集合 |\n| `K-Dense-AI/claude-scientific-skills` | ~15.3k | 170+ 科学研究 skills |\n| `travisvn/awesome-claude-skills` | ~9.1k | 精选 Claude skills 列表 |\n| `VoltAgent/awesome-agent-skills` | ~8.4k | 500+ Agent skills |\n| `anthropics/knowledge-work-plugins` | ~7.7k | 官方知识工作插件 |\n| `alirezarezvani/claude-skills` | ~5.2k | 192+ 社区 skills |\n\n列表定义在脚本顶部 `CURATED_REPOS` 常量中，新增/移除仓库只需编辑此数组。\n\n## 脚本重写逻辑\n\n### 1. 数据采集（仓库元数据）\n\n通过 GitHub Repos API 逐个获取精选仓库的元数据：\n\n```\nGET /repos/{owner}/{repo}\n```\n\n返回：`full_name`, `description`, `stargazers_count`, `topics[]`, `updated_at`, `html_url`, `default_branch`\n\n认证：**必须**使用环境变量 `GITHUB_TOKEN`，脚本启动时校验。\n\n失败处理：获取失败的仓库跳过（打印警告），不影响其余仓库。\n\n### 2. Skill 检测（仓库内深入扫描）\n\n使用 GitHub Git Trees API 获取仓库目录结构（单次请求，`recursive=1`）：\n\n```\nGET /repos/{owner}/{repo}/git/trees/{default_branch}?recursive=1\n```\n\n#### Skill 目录扫描规则（与应用端 `installer.rs` 保持一致）\n\n扫描以下基础路径下的子目录：\n\n```\nskills/\nskills/.curated/\nskills/.experimental/\nskills/.system/\n.claude/skills/\n```\n\n以及**根目录的直接子目录**（排除 `skills/`、`.claude/`、`.git/` 等特殊目录）。\n\n#### 新增：`.claude-plugin/plugin.json` 检测\n\n为兼容 `anthropics/knowledge-work-plugins` 等使用插件格式的仓库，增加检测规则：\n\n- 根级子目录包含 `.claude-plugin/plugin.json` 文件 → 视为有效 skill\n\n#### Skill 判定条件\n\n一个目录被视为有效 skill，需满足以下任一条件：\n- 目录内存在 `SKILL.md` 文件\n- 目录位于 `.claude/skills/` 路径下（即使没有 `SKILL.md`）\n- 目录内存在 `.claude-plugin/plugin.json` 文件（插件格式）\n\n#### 单 skill 仓库\n\n如果仓库根目录本身就是一个 skill（根目录有 `SKILL.md`），且没有检测到子目录 skill，则整个仓库视为单 skill，`source_url` 指向仓库根路径。\n\n#### Skill 名称与描述\n\n- **名称**：从 skill 目录名生成（kebab-case → Title Case）\n- **描述**：使用仓库的 `description` 字段（同仓库内所有 skill 共享）\n\n> 注：不使用 Contents API 获取 SKILL.md 内容，避免大量 API 调用。目录名 + 仓库 description 已满足展示需求。\n\n### 3. 自动分类\n\n基于仓库 `topics[]` 和 `description` 关键词匹配（分类继承自仓库，同仓库内所有 skill 共享分类）：\n\n| 关键词 | 分类 |\n|--------|------|\n| browser, automation, playwright, puppeteer | browser-automation |\n| security, audit, vulnerability, pentest | security |\n| devops, deploy, infra, docker, kubernetes | devops |\n| marketing, seo, ads, advertising | marketing |\n| database, sql, postgres, mongo | database |\n| git, github, pr, code-review | development |\n| ai, llm, agent, model | ai-assistant |\n| 以上都不匹配 | general |\n\n### 4. 排序与截取\n\n1. 按仓库 `stargazers_count` 降序，同星数按 skill 名称字母序\n2. **截取前 300 条**（`MAX_SKILLS = 300`），避免输出过大\n\n### 5. 输出\n\n生成 `featured-skills.json`，**向前兼容现有数据结构**：\n\n```json\n{\n  \"updated_at\": \"2026-03-19T00:00:00Z\",\n  \"total\": 300,\n  \"categories\": [\"general\", \"browser-automation\", \"security\", \"devops\", ...],\n  \"skills\": [\n    {\n      \"slug\": \"commit\",\n      \"name\": \"Conventional Commit\",\n      \"summary\": \"Generate conventional commit messages...\",\n      \"downloads\": 0,\n      \"stars\": 96000,\n      \"category\": \"development\",\n      \"tags\": [\"claude-code-skill\", \"git\", \"commit\"],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/commit\",\n      \"updated_at\": \"2026-03-17T15:10:09Z\"\n    }\n  ]\n}\n```\n\n#### 向前兼容策略\n\n新格式**必须保留现有字段**，确保后端 `FeaturedSkillRaw` 和前端 `FeaturedSkillDto` 无需任何改动即可解析新数据：\n\n| 字段 | 现有 | 新版 | 兼容处理 |\n|------|------|------|----------|\n| `slug` | ✅ | ✅ | 不变 |\n| `name` | ✅ | ✅ | 不变 |\n| `summary` | ✅ | ✅ | 不变 |\n| `downloads` | ✅ | ✅ | **保留，固定为 0**（无真实数据源） |\n| `stars` | ✅ | ✅ | 填入 GitHub 真实 star 数 |\n| `source_url` | ✅ | ✅ | 精确到 skill 目录的路径 |\n| `category` | ❌ | ✅ 新增 | 后端 `#[serde(default)]` 自动忽略未知字段 |\n| `tags` | ❌ | ✅ 新增 | 同上 |\n| `updated_at`（skill 级） | ❌ | ✅ 新增 | 同上 |\n\n**结论**：脚本输出格式升级后，后端和前端代码**零改动**即可正常工作。\n\n#### 字段说明\n\n- `slug`：skill 目录名（单 skill 仓库则为仓库名）\n- `name`：基于 skill 目录名生成（kebab-case → Title Case）\n- `summary`：仓库 `description`（同仓库内所有 skill 共享）\n- `downloads`：固定为 `0`（向前兼容，无真实数据源）\n- `stars`：所属仓库的 GitHub star 数\n- `category`：基于仓库 topics/description 的自动分类结果\n- `tags`：仓库 `topics[]`（最多取 5 个）\n- `source_url`：**精确到 skill 目录的 GitHub URL**，格式为 `https://github.com/{owner}/{repo}/tree/{branch}/{skill-path}`；单 skill 仓库则为 `https://github.com/{owner}/{repo}`\n- `updated_at`：仓库最后更新时间\n\n## API 用量估算\n\n| 阶段 | API | 请求数 | 说明 |\n|------|-----|--------|------|\n| 获取仓库元数据 | Repos API | 7 | 每个精选仓库 1 次 |\n| 获取目录树 | Git Trees API | 7 | 每个仓库 1 次 |\n| **合计** | | **14** | |\n\n对比旧方案（Topic 搜索）的 ~412 次请求，新方案仅需 14 次，**几乎不可能触发速率限制**。\n\nGitHub API 限额（已认证）：5000 次/小时。即使未认证（60 次/小时）也完全够用，但仍建议使用 token 以确保稳定性。\n\n## GitHub Actions 配置\n\n保持现有 `.github/workflows/update-featured-skills.yml` 不变：\n\n```yaml\n- name: Fetch featured skills\n  run: node scripts/fetch-featured-skills.mjs\n  env:\n    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n```\n\n## Skills Hub 应用端适配\n\n**后端和前端代码无需任何改动**（向前兼容）：\n\n- `FeaturedSkillRaw` / `FeaturedSkillDto` 结构体不变\n- 新增的 `category`、`tags`、`updated_at` 等字段被 serde 自动忽略\n- 内嵌 fallback（`featured-skills.json`）随脚本运行自动更新\n\n### 后续可选增强（独立需求）\n\n1. 后端 DTO 新增 `category`、`tags` 字段以支持前端展示\n2. 探索页面增加按分类筛选\n3. 排序改为按星数排序\n\n## 技术要点\n\n- **精选仓库列表**：质量可控，新增仓库只需编辑 `CURATED_REPOS` 数组\n- **极低 API 用量**：14 次请求 vs 旧方案 412 次，无速率限制风险\n- **零外部依赖**：仅依赖 GitHub API（公开、稳定、有 SLA）\n- **Skill 粒度聚合**：每条记录精确到仓库内具体 skill 目录\n- **与应用检测逻辑一致**：skill 扫描规则与 `installer.rs` 保持同步\n- **新增插件格式支持**：兼容 `.claude-plugin/plugin.json` 结构\n- **数量上限**：最多 300 条，按星数排序取 top\n- **项目内维护**：脚本、数据、CI 全部在 skills-desktop-app 仓库内\n\n## 变更清单\n\n| 文件 | 操作 |\n|------|------|\n| `scripts/fetch-featured-skills.mjs` | 重写（精选仓库列表 + Repos API + Trees API） |\n| `featured-skills.json` | 内容更新（精选仓库来源，≤300 条） |\n| `docs/requirements/skills-aggregation-repo.md` | 更新需求文档 |\n| `.github/workflows/update-featured-skills.yml` | **无需改动** |\n| 后端代码 | **无需改动**（向前兼容） |\n| 前端代码 | **无需改动**（向前兼容） |\n"
  },
  {
    "path": "docs/releases/v0.4.0/bugfix-language-toggle-loading-overlay.md",
    "content": "# Bugfix：切换语言时闪现 \"Installing Skills...\" 弹窗\n\n## 问题描述\n\n在 Explore 页面点击语言切换按钮（EN/中）时，会短暂弹出 \"Installing Skills...\" 加载遮罩，一两秒后自动消失。\n\n## 根因分析\n\n`invokeTauri` 函数的 `useCallback` 依赖数组包含了 `t`（i18next 翻译函数）：\n\n```ts\nconst invokeTauri = useCallback(async (...) => {\n  if (!isTauri) throw new Error(t('errors.notTauri'))\n  ...\n}, [isTauri, t])  // ← t 导致级联重建\n```\n\n切换语言时的级联链：\n\n1. `i18n.changeLanguage()` → `t` 引用变化\n2. `t` 变化 → `invokeTauri` 重新创建（依赖 `t`）\n3. `invokeTauri` 变化 → `loadPlan` 重新创建（依赖 `invokeTauri`）\n4. `loadPlan` 变化 → `useEffect([isTauri, loadPlan])` 重新触发\n5. `loadPlan()` 执行 → `setLoading(true)` → 显示加载弹窗\n6. 请求完成 → `setLoading(false)` → 弹窗消失\n\n## 修复方案\n\n将 `invokeTauri` 中的 `t('errors.notTauri')` 替换为硬编码字符串，从依赖数组移除 `t`：\n\n```ts\nconst invokeTauri = useCallback(async (...) => {\n  if (!isTauri) throw new Error('Tauri API is not available')\n  ...\n}, [isTauri])  // ← 不再依赖 t\n```\n\n该错误消息仅在非 Tauri 环境下触发（实际不可能发生），无需翻译。\n\n## 修改文件\n\n- `src/App.tsx`：`invokeTauri` useCallback 依赖数组移除 `t`\n"
  },
  {
    "path": "docs/releases/v0.4.0/plan-in-app-update.md",
    "content": "# 需求：应用内检查更新（Issue #33）\n\n## Context\n\n来源：https://github.com/qufei1993/skills-hub/issues/33\n\n用户每次跟进版本都需要手动进入 GitHub releases 页面下载，体验不便。需要在软件内支持手动检查更新功能。\n\n## 现状分析\n\n后端 Tauri updater 插件已完全就绪：\n- `tauri-plugin-updater` v2 已安装（Cargo.toml + package.json）\n- `tauri.conf.json` 已配置更新端点（GitHub releases）+ 公钥签名验证\n- `lib.rs` 已注册插件\n- i18n 翻译键已全部就绪（EN/ZH）\n\n唯一缺失：前端没有调用更新 API 的 UI。\n\n## 实施方案\n\n在 SettingsModal 底部版本信息区域扩展为\"检查更新\"功能块：\n\n1. **SettingsModal.tsx** — 添加更新状态管理 + UI\n   - 状态：idle → checking → up-to-date / available → downloading → done / error\n   - 使用 `@tauri-apps/plugin-updater` 的 `check()` 和 `downloadAndInstall()` API\n   - 保存 update 对象引用避免重复请求\n   - 弹窗关闭时重置状态\n\n2. **App.css** — 添加更新区块样式\n   - 版本号 + 按钮水平排列\n   - 更新可用时显示高亮区块\n   - 错误/成功状态样式\n\n3. **版本号** — 0.3.0 → 0.3.1\n\n## 涉及文件\n\n| 文件 | 改动 |\n|------|------|\n| `src/components/skills/modals/SettingsModal.tsx` | 添加更新检查 UI + 逻辑 |\n| `src/App.css` | 添加更新区块样式 |\n| `package.json` | 版本号 → 0.3.1 |\n| `src-tauri/tauri.conf.json` | 版本号 → 0.3.1 |\n| `src-tauri/Cargo.toml` | 版本号 → 0.3.1 |\n"
  },
  {
    "path": "docs/releases/v0.4.0/plan-qoderwork-support.md",
    "content": "# 需求：支持 QoderWork 目录（Issue #34）\n\n## Context\n\n来源：https://github.com/qufei1993/skills-hub/issues/34\n\nQoderWork 是 Qoder 推出的桌面 AI 代理产品，与 Qoder IDE 独立，使用 `~/.qoderwork/skills/` 目录存放技能。当前代码库已支持 Qoder（`.qoder/skills`），但尚未支持 QoderWork。\n\n## 实施方案\n\n参照现有 Qoder 适配器模式，在 3 处添加 QoderWork 支持：\n\n### 1. `src-tauri/src/core/tool_adapters/mod.rs`\n\n- **ToolId 枚举**：在 `Qoder` 之后添加 `QoderWork`\n- **as_key() 方法**：添加 `ToolId::QoderWork => \"qoderwork\"`\n- **default_tool_adapters()**：添加 ToolAdapter 实例：\n  ```rust\n  ToolAdapter {\n      id: ToolId::QoderWork,\n      display_name: \"QoderWork\",\n      relative_skills_dir: \".qoderwork/skills\",\n      relative_detect_dir: \".qoderwork\",\n  },\n  ```\n\n### 2. `src/i18n/resources.ts`\n\n- 英文 tools 对象：添加 `qoderwork: 'QoderWork'`\n- 中文 tools 对象：添加 `qoderwork: 'QoderWork'`\n\n## 验证\n\n- `npm run check` 确保 lint、build、Rust clippy/test 全部通过\n"
  },
  {
    "path": "docs/releases/v0.4.0/plan-settings-page.md",
    "content": "# 需求：设置弹窗改为独立页面\n\n## Context\n\n设置功能原来是一个 560px 宽的模态弹窗（SettingsModal），在小窗口下容易被遮挡，需要最大化窗口才能完整查看。将其改为 `activeView` 视图系统中的独立页面，与 myskills/explore/detail 并列，UX 更自然。\n\n## 实施方案\n\n### 1. 组件迁移：`modals/SettingsModal.tsx` → `SettingsPage.tsx`\n\n- 移动到 `src/components/skills/SettingsPage.tsx`，与 `ExplorePage`、`SkillDetailView` 同级\n- 类型名 `SettingsModalProps` → `SettingsPageProps`\n  - 删除 `open: boolean`\n  - `onRequestClose` → `onBack`\n- 去掉 modal 外壳（`modal-backdrop` / `modal` / `modal-header` / `modal-footer`）\n- 复用 `detail-header` + `detail-back-btn` 样式，与 SkillDetailView 保持一致\n- useEffect 从依赖 `open` 改为挂载/卸载模式\n- 删除 `if (!open) return null` 守卫\n- 删除底部 \"Done\" 按钮\n\n### 2. `App.tsx` — 状态和路由\n\n- `activeView` 类型扩展为 `'myskills' | 'explore' | 'detail' | 'settings'`\n- 删除 `showSettingsModal` 布尔状态\n- `handleOpenSettings` 改为 `setActiveView('settings')`\n- `handleCloseSettings` 改为 `setActiveView('myskills')`\n- `<main>` 条件渲染新增 `settings` 分支，渲染 `<SettingsPage>`\n- 删除底部的 `<SettingsModal>` 渲染块\n\n### 3. `Header.tsx` — 导航状态\n\n- `activeView` 类型加 `| 'settings'`\n- 设置齿轮按钮在 `activeView === 'settings'` 时添加 `active` 样式\n\n### 4. `App.css` — 样式调整\n\n- 删除 `.settings-modal` 和 `.settings-body` 规则\n- 新增 `.settings-page`（居中布局）和 `.settings-page-body`（560px 最大宽度，居中）\n- 复用 `.detail-header` / `.detail-back-btn` / `.detail-skill-name` 样式\n- 所有 `.settings-field` 等子样式保持不变\n\n### 不需要改动\n\n- **i18n**：复用已有的 `detail.back`、`settings` key\n- **Rust 后端**：纯前端改动\n- **types.ts**：无 DTO 变更\n"
  },
  {
    "path": "docs/releases/v0.4.0/skills-aggregation-repo.md",
    "content": "# 需求：Skills 聚合数据源升级 — 精选仓库列表方案\n\n## 背景\n\nSkills Hub 应用的\"探索\"功能依赖 `featured-skills.json` 提供精选技能列表。早期方案通过 GitHub Search API 搜索 `claude-code-skill` 等 topic 标签自动发现仓库，但属于盲目搜索，质量不可控，噪音多。\n\n**新方案**：维护一份精选仓库列表（Curated Repos），直接从已知的高质量仓库中获取 skills。质量可控、API 调用极少、维护简单。\n\n## 目标\n\n重写 `scripts/fetch-featured-skills.mjs` 脚本，从精选仓库列表直接获取元数据并深入检测 skill 目录结构，为每个 skill 生成独立条目，最终输出 `featured-skills.json`（最多 300 条）。保持现有的 GitHub Actions 定时更新机制不变。\n\n## 架构（保持不变）\n\n```\nskills-desktop-app/\n├── featured-skills.json                          # 精选技能数据（应用内嵌 fallback + 在线更新源）\n├── scripts/\n│   └── fetch-featured-skills.mjs                 # 聚合脚本（需重写）\n└── .github/workflows/\n    └── update-featured-skills.yml                # 每日定时运行（保持不变）\n```\n\n## 数据源 — 精选仓库列表\n\n从 GitHub Topic 盲目搜索改为直接指定高质量仓库：\n\n| 仓库 | Stars | 说明 |\n|------|-------|------|\n| `anthropics/skills` | ~96k | Anthropic 官方 Agent Skills |\n| `sickn33/antigravity-awesome-skills` | ~24.7k | 1000+ 社区 skills 集合 |\n| `K-Dense-AI/claude-scientific-skills` | ~15.3k | 170+ 科学研究 skills |\n| `travisvn/awesome-claude-skills` | ~9.1k | 精选 Claude skills 列表 |\n| `VoltAgent/awesome-agent-skills` | ~8.4k | 500+ Agent skills |\n| `anthropics/knowledge-work-plugins` | ~7.7k | 官方知识工作插件 |\n| `alirezarezvani/claude-skills` | ~5.2k | 192+ 社区 skills |\n\n列表定义在脚本顶部 `CURATED_REPOS` 常量中，新增/移除仓库只需编辑此数组。\n\n## 脚本重写逻辑\n\n### 1. 数据采集（仓库元数据）\n\n通过 GitHub Repos API 逐个获取精选仓库的元数据：\n\n```\nGET /repos/{owner}/{repo}\n```\n\n返回：`full_name`, `description`, `stargazers_count`, `topics[]`, `updated_at`, `html_url`, `default_branch`\n\n认证：**必须**使用环境变量 `GITHUB_TOKEN`，脚本启动时校验。\n\n失败处理：获取失败的仓库跳过（打印警告），不影响其余仓库。\n\n### 2. Skill 检测（仓库内深入扫描）\n\n使用 GitHub Git Trees API 获取仓库目录结构（单次请求，`recursive=1`）：\n\n```\nGET /repos/{owner}/{repo}/git/trees/{default_branch}?recursive=1\n```\n\n#### Skill 目录扫描规则（与应用端 `installer.rs` 保持一致）\n\n扫描以下基础路径下的子目录：\n\n```\nskills/\nskills/.curated/\nskills/.experimental/\nskills/.system/\n.claude/skills/\n```\n\n以及**根目录的直接子目录**（排除 `skills/`、`.claude/`、`.git/` 等特殊目录）。\n\n#### 新增：`.claude-plugin/plugin.json` 检测\n\n为兼容 `anthropics/knowledge-work-plugins` 等使用插件格式的仓库，增加检测规则：\n\n- 根级子目录包含 `.claude-plugin/plugin.json` 文件 → 视为有效 skill\n\n#### Skill 判定条件\n\n一个目录被视为有效 skill，需满足以下任一条件：\n- 目录内存在 `SKILL.md` 文件\n- 目录位于 `.claude/skills/` 路径下（即使没有 `SKILL.md`）\n- 目录内存在 `.claude-plugin/plugin.json` 文件（插件格式）\n\n#### 单 skill 仓库\n\n如果仓库根目录本身就是一个 skill（根目录有 `SKILL.md`），且没有检测到子目录 skill，则整个仓库视为单 skill，`source_url` 指向仓库根路径。\n\n#### Skill 名称与描述\n\n- **名称**：从 skill 目录名生成（kebab-case → Title Case）\n- **描述**：使用仓库的 `description` 字段（同仓库内所有 skill 共享）\n\n> 注：不使用 Contents API 获取 SKILL.md 内容，避免大量 API 调用。目录名 + 仓库 description 已满足展示需求。\n\n### 3. 自动分类\n\n基于仓库 `topics[]` 和 `description` 关键词匹配（分类继承自仓库，同仓库内所有 skill 共享分类）：\n\n| 关键词 | 分类 |\n|--------|------|\n| browser, automation, playwright, puppeteer | browser-automation |\n| security, audit, vulnerability, pentest | security |\n| devops, deploy, infra, docker, kubernetes | devops |\n| marketing, seo, ads, advertising | marketing |\n| database, sql, postgres, mongo | database |\n| git, github, pr, code-review | development |\n| ai, llm, agent, model | ai-assistant |\n| 以上都不匹配 | general |\n\n### 4. 排序与截取\n\n1. 按仓库 `stargazers_count` 降序，同星数按 skill 名称字母序\n2. **截取前 300 条**（`MAX_SKILLS = 300`），避免输出过大\n\n### 5. 输出\n\n生成 `featured-skills.json`，**向前兼容现有数据结构**：\n\n```json\n{\n  \"updated_at\": \"2026-03-19T00:00:00Z\",\n  \"total\": 300,\n  \"categories\": [\"general\", \"browser-automation\", \"security\", \"devops\", ...],\n  \"skills\": [\n    {\n      \"slug\": \"commit\",\n      \"name\": \"Conventional Commit\",\n      \"summary\": \"Generate conventional commit messages...\",\n      \"downloads\": 0,\n      \"stars\": 96000,\n      \"category\": \"development\",\n      \"tags\": [\"claude-code-skill\", \"git\", \"commit\"],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/commit\",\n      \"updated_at\": \"2026-03-17T15:10:09Z\"\n    }\n  ]\n}\n```\n\n#### 向前兼容策略\n\n新格式**必须保留现有字段**，确保后端 `FeaturedSkillRaw` 和前端 `FeaturedSkillDto` 无需任何改动即可解析新数据：\n\n| 字段 | 现有 | 新版 | 兼容处理 |\n|------|------|------|----------|\n| `slug` | ✅ | ✅ | 不变 |\n| `name` | ✅ | ✅ | 不变 |\n| `summary` | ✅ | ✅ | 不变 |\n| `downloads` | ✅ | ✅ | **保留，固定为 0**（无真实数据源） |\n| `stars` | ✅ | ✅ | 填入 GitHub 真实 star 数 |\n| `source_url` | ✅ | ✅ | 精确到 skill 目录的路径 |\n| `category` | ❌ | ✅ 新增 | 后端 `#[serde(default)]` 自动忽略未知字段 |\n| `tags` | ❌ | ✅ 新增 | 同上 |\n| `updated_at`（skill 级） | ❌ | ✅ 新增 | 同上 |\n\n**结论**：脚本输出格式升级后，后端和前端代码**零改动**即可正常工作。\n\n#### 字段说明\n\n- `slug`：skill 目录名（单 skill 仓库则为仓库名）\n- `name`：基于 skill 目录名生成（kebab-case → Title Case）\n- `summary`：仓库 `description`（同仓库内所有 skill 共享）\n- `downloads`：固定为 `0`（向前兼容，无真实数据源）\n- `stars`：所属仓库的 GitHub star 数\n- `category`：基于仓库 topics/description 的自动分类结果\n- `tags`：仓库 `topics[]`（最多取 5 个）\n- `source_url`：**精确到 skill 目录的 GitHub URL**，格式为 `https://github.com/{owner}/{repo}/tree/{branch}/{skill-path}`；单 skill 仓库则为 `https://github.com/{owner}/{repo}`\n- `updated_at`：仓库最后更新时间\n\n## API 用量估算\n\n| 阶段 | API | 请求数 | 说明 |\n|------|-----|--------|------|\n| 获取仓库元数据 | Repos API | 7 | 每个精选仓库 1 次 |\n| 获取目录树 | Git Trees API | 7 | 每个仓库 1 次 |\n| **合计** | | **14** | |\n\n对比旧方案（Topic 搜索）的 ~412 次请求，新方案仅需 14 次，**几乎不可能触发速率限制**。\n\nGitHub API 限额（已认证）：5000 次/小时。即使未认证（60 次/小时）也完全够用，但仍建议使用 token 以确保稳定性。\n\n## GitHub Actions 配置\n\n保持现有 `.github/workflows/update-featured-skills.yml` 不变：\n\n```yaml\n- name: Fetch featured skills\n  run: node scripts/fetch-featured-skills.mjs\n  env:\n    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n```\n\n## Skills Hub 应用端适配\n\n**后端和前端代码无需任何改动**（向前兼容）：\n\n- `FeaturedSkillRaw` / `FeaturedSkillDto` 结构体不变\n- 新增的 `category`、`tags`、`updated_at` 等字段被 serde 自动忽略\n- 内嵌 fallback（`featured-skills.json`）随脚本运行自动更新\n\n### 后续可选增强（独立需求）\n\n1. 后端 DTO 新增 `category`、`tags` 字段以支持前端展示\n2. 探索页面增加按分类筛选\n3. 排序改为按星数排序\n\n## 技术要点\n\n- **精选仓库列表**：质量可控，新增仓库只需编辑 `CURATED_REPOS` 数组\n- **极低 API 用量**：14 次请求 vs 旧方案 412 次，无速率限制风险\n- **零外部依赖**：仅依赖 GitHub API（公开、稳定、有 SLA）\n- **Skill 粒度聚合**：每条记录精确到仓库内具体 skill 目录\n- **与应用检测逻辑一致**：skill 扫描规则与 `installer.rs` 保持同步\n- **新增插件格式支持**：兼容 `.claude-plugin/plugin.json` 结构\n- **数量上限**：最多 300 条，按星数排序取 top\n- **项目内维护**：脚本、数据、CI 全部在 skills-desktop-app 仓库内\n\n## 变更清单\n\n| 文件 | 操作 |\n|------|------|\n| `scripts/fetch-featured-skills.mjs` | 重写（精选仓库列表 + Repos API + Trees API） |\n| `featured-skills.json` | 内容更新（精选仓库来源，≤300 条） |\n| `docs/requirements/skills-aggregation-repo.md` | 更新需求文档 |\n| `.github/workflows/update-featured-skills.yml` | **无需改动** |\n| 后端代码 | **无需改动**（向前兼容） |\n| 前端代码 | **无需改动**（向前兼容） |\n"
  },
  {
    "path": "docs/releases/v0.4.1/plan-frontmatter-table.md",
    "content": "# Plan: Skill 详情页 Frontmatter 元数据表格展示\n\n## Context\n当前 `SkillDetailView` 使用 `remarkFrontmatter` 插件，该插件只是让 react-markdown 忽略 YAML frontmatter（不报错），但不会渲染它。用户希望像 GitHub 一样，将 frontmatter 中的字段以表格形式展示在 markdown 内容顶部。\n\n## 修改方案\n\n### 仅需修改 1 个文件\n**`src/components/skills/SkillDetailView.tsx`** — `FileContentRenderer` 组件\n\n### 实现步骤\n\n1. **添加 frontmatter 解析函数** — 在 `FileContentRenderer` 中，对 markdown 文件的 content 进行简单的 YAML frontmatter 提取：\n   - 检测 `---` 开头和结束标记\n   - 逐行解析 `key: value` 对，得到 `Record<string, string>`\n   - 分离出 frontmatter 数据和剩余的 markdown body\n\n2. **渲染 frontmatter 表格** — 在 `<Markdown>` 组件之前，如果存在 frontmatter 数据，渲染一个 HTML 表格：\n   - 表头为 frontmatter 的 key（如 name, description, license）\n   - 表体为对应的 value\n   - 复用现有的 `.markdown-body table/th/td` 样式（已在 App.css 中定义）\n\n3. **传递剩余内容给 Markdown** — 将去除 frontmatter 后的 body 传给 `<Markdown>` 组件，同时保留 `remarkFrontmatter` 插件（作为安全兜底）\n\n### 不需要的改动\n- 不需要安装新依赖（不用 `gray-matter` 等库，简单的字符串解析即可）\n- 不需要修改后端\n- 不需要新增 CSS（已有 table 样式）\n- 不需要 i18n（表头直接使用 frontmatter 的 key 名）\n\n## 验证\n- `npm run check` 通过\n- 打开一个包含 frontmatter 的 skill（如 SKILL.md），确认顶部显示元数据表格，下方正常渲染 markdown 内容\n- 打开没有 frontmatter 的文件，确认不会显示表格，渲染正常\n"
  },
  {
    "path": "docs/releases/v0.4.2/bugfix-new-tools-modal-style.md",
    "content": "# Bugfix：首次安装后打开时\"检测到新工具\"弹窗样式异常\n\n## 问题描述\n\n安装后第一次打开 Skills Hub，\"New tools detected\"（检测到新工具）弹窗的样式与其他弹窗不一致：标题文字紧贴弹窗边框顶部，操作按钮缺少内边距和分隔线。\n\n## 根因分析\n\n`NewToolsModal` 的 DOM 结构与项目其他弹窗不一致：\n\n```tsx\n// 修复前（错误结构）\n<div className=\"modal\">\n  <div className=\"modal-title\">...</div>   // 无 padding，贴顶\n  <div className=\"modal-body\">...</div>\n  <div className=\"modal-actions\">...</div> // 只有 margin-top，无 padding\n</div>\n```\n\n- `.modal-title` 本身只有 `font-size` / `font-weight` / `color`，依赖 `.modal-header` 提供 `padding: 16px 20px` 和 `border-bottom`\n- `.modal-actions` 只有 `margin-top: 16px`，没有水平和底部内边距，按钮会贴近弹窗边缘\n- 缺少 `role=\"dialog\"` 和 `aria-modal=\"true\"` 无障碍属性\n\n## 修复方案\n\n将结构统一为与其他弹窗一致的 `.modal-header` + `.modal-footer` 模式：\n\n```tsx\n// 修复后（正确结构）\n<div className=\"modal\" role=\"dialog\" aria-modal=\"true\">\n  <div className=\"modal-header\">\n    <div className=\"modal-title\">...</div>  // padding: 16px 20px + border-bottom\n  </div>\n  <div className=\"modal-body\">...</div>\n  <div className=\"modal-footer\">...</div>   // padding: 16px 20px + border-top\n</div>\n```\n\n## 修改文件\n\n- `src/components/skills/modals/NewToolsModal.tsx`：补充 `.modal-header` 包裹标题，将 `.modal-actions` 改为 `.modal-footer`，新增 `role`/`aria-modal` 属性\n"
  },
  {
    "path": "docs/releases/v0.4.2/bugfix-root-skill-install-false-exists.md",
    "content": "# Bugfix：从探索页安装根目录级 Skill 时误报\"已存在于 Hub\"\n\n## 问题描述\n\n在探索页搜索并点击安装某个 Skill（如 `titanwings/colleague-skill`）时，弹出错误提示：\n\n> 「.」已存在于 Hub，可前往\"我的 Skills\"中更新。\n\n实际上该 Skill 从未安装过。\n\n## 根因分析\n\n`titanwings/colleague-skill` 是一个**根目录级 Skill**，即 `SKILL.md` 位于仓库根目录。`list_git_skills` 为此类 Skill 返回的候选项中 `subpath = \".\"`。\n\n`install_git_skill_from_selection` 在推导 `display_name` 时，直接对 subpath 做字符串处理：\n\n```rust\n// 修复前\nlet mut display_name = name.unwrap_or_else(|| {\n    subpath\n        .rsplit('/')\n        .next()\n        .map(|s| s.to_string())\n        .unwrap_or_else(|| derive_name_from_repo_url(&parsed.clone_url))\n});\n```\n\n当 `subpath = \".\"` 时，`rsplit('/').next()` 返回 `\".\"`，导致：\n\n- `display_name = \".\"`\n- `central_path = central_dir.join(\".\") = central_dir`（即中央仓库目录本身）\n- `central_path.exists()` 永远为 `true`\n- 触发错误：`skill already exists in central repo: /path/to/central/.`\n\n前端 `formatErrorMessage` 从路径中提取名称时调用 `.split('/').pop()` 得到 `\".\"`，最终显示「.」已存在于 Hub。\n\n## 修复方案\n\n当 `subpath == \".\"` 时，改为从 repo URL 推导初始名称，后续逻辑会继续用 `SKILL.md` 中的名称做最终重命名：\n\n```rust\n// 修复后\nlet mut display_name = name.unwrap_or_else(|| {\n    if subpath == \".\" {\n        derive_name_from_repo_url(&parsed.clone_url)\n    } else {\n        subpath\n            .rsplit('/')\n            .next()\n            .map(|s| s.to_string())\n            .unwrap_or_else(|| derive_name_from_repo_url(&parsed.clone_url))\n    }\n});\n```\n\n## 修改文件\n\n- `src-tauri/src/core/installer.rs`：`install_git_skill_from_selection` 函数中对 `subpath == \".\"` 单独处理，避免用 `\".\"` 作为 skill 名称\n"
  },
  {
    "path": "docs/releases/v0.4.3/add-copaw-support.md",
    "content": "# 新增：支持 Copaw 工具\n\n> 贡献者：[@LeonDevLifeLog](https://github.com/LeonDevLifeLog)，PR [qufei1993/skills-hub#50](https://github.com/qufei1993/skills-hub/pull/50)\n\n## 更新内容\n\n新增对 [CoPaw](https://github.com/agentscope-ai/CoPaw) 的支持。CoPaw 是 AgentScope 出品的 AI 个人助理，支持多端接入（钉钉、飞书、微信、Discord 等）、技能扩展与多智能体协作。\n\n安装 skill 后，Skills Hub 可自动将其同步到 CoPaw 的本地技能池。\n\n## 技术细节\n\nCoPaw 的技能池路径与大多数工具不同，使用 `skill_pool` 而非 `skills`：\n\n| 字段 | 值 |\n|------|-----|\n| Tool ID | `copaw` |\n| 显示名称 | Copaw |\n| 技能目录 | `~/.copaw/skill_pool/` |\n| 检测目录 | `~/.copaw/` |\n\n## 修改文件\n\n- `src-tauri/src/core/tool_adapters/mod.rs`：新增 `ToolId::Copaw` 枚举变体及对应的 `ToolAdapter`\n- `README.md`：工具支持列表新增 Copaw\n- `docs/README.zh.md`：同步更新中文版工具支持列表\n"
  },
  {
    "path": "docs/releases/v0.4.3/bugfix-github-install-and-frontmatter.md",
    "content": "# Bugfix：优化 GitHub Skill 安装速度并修复多行 Frontmatter 渲染\n\n## 问题 1：从 GitHub 仓库安装 Skill 很慢并最终超时\n\n### 问题描述\n\n从 GitHub 仓库安装某些 Skill 时，安装弹窗会长时间停留在加载状态，最后超时失败。典型表现：\n\n- 弹窗持续显示「正在安装技能...」\n- 日志提示正在执行文件/网络操作\n- GitHub 网络较慢或仓库文件较多时更容易复现\n\n### 根因分析\n\n对于形如 `https://github.com/owner/repo/tree/branch/path` 的 GitHub 子目录 URL，原逻辑会优先使用 GitHub Contents API 递归下载目录。\n\n该方式的问题是：\n\n- 需要按目录递归请求 GitHub API\n- 文件下载是串行 HTTP 请求\n- 文件较多时请求数量快速增加\n- API 下载失败后还会 fallback 到 `git clone`，导致慢路径被走两遍\n\n因此在网络不稳定或仓库较大时，用户会看到长时间转圈后超时。\n\n### 修复方案\n\n新增 `clone_or_pull_sparse`，对 GitHub 子目录安装优先使用系统 `git` 执行浅克隆 + 稀疏检出：\n\n```bash\ngit clone --depth 1 --filter=blob:none --sparse --no-tags ...\ngit sparse-checkout set --no-cone <subpath>\n```\n\n这样只检出目标 Skill 子目录，避免下载整个仓库或逐文件调用 GitHub API。\n\n修复后流程：\n\n- 子目录 GitHub URL：优先走 sparse checkout\n- sparse checkout 失败：再 fallback 到 GitHub Contents API\n- 更新已安装 Skill：如果记录了 `source_subpath`，同样优先走 sparse checkout\n- 缓存 key 加入 `subpath`，避免不同子目录复用同一个稀疏工作区造成冲突\n\n### 影响范围\n\n该优化主要改善精确到子目录的安装链接，例如：\n\n```text\nhttps://github.com/anthropics/skills/tree/main/skills/frontend-design\n```\n\n如果用户输入的是仓库根 URL，应用仍需要先扫描仓库中的 Skill 候选项，该场景仍可能触发完整浅克隆。\n\n## 问题 2：搜索/手动添加无法处理非标准 Skill 容器目录\n\n### 问题描述\n\n从探索页在线搜索安装 `technical-writer` 时，搜索结果来自 `skills.sh`：\n\n```json\n{\n  \"name\": \"technical-writer\",\n  \"skillId\": \"technical-writer\",\n  \"source\": \"shubhamsaboo/awesome-llm-apps\"\n}\n```\n\n前端只能拿到仓库根地址：\n\n```text\nhttps://github.com/Shubhamsaboo/awesome-llm-apps\n```\n\n实际 Skill 位于：\n\n```text\nawesome_agent_skills/technical-writer/SKILL.md\n```\n\n原扫描逻辑只识别固定目录（如 `skills/*`、`.claude/skills/*`）和根目录直接子目录，无法发现 `awesome_agent_skills/*` 这种容器目录下的 Skill，导致搜索安装失败或回退到错误提示。\n\n手动添加也存在类似问题：如果输入容器目录链接，例如：\n\n```text\nhttps://github.com/Shubhamsaboo/awesome-llm-apps/blob/main/awesome_agent_skills\n```\n\n原逻辑会尝试把整个容器目录当成 Skill 安装，导致 Hub 中出现大量子目录而不是一个有效 Skill。\n\n### 根因分析\n\n问题分为两层：\n\n- 搜索结果只提供仓库根 `source`，不包含实际 Skill 文件夹路径\n- 后端发现逻辑依赖目录白名单，无法覆盖 `awesome_agent_skills`、`agent-skills`、`custom-agent-skills` 等变体\n- 手动 `tree/blob` 子路径绕过候选发现，直接调用安装命令，因此容器目录不会进入 picker\n- 安装入口缺少最终校验，子路径存在但不是有效 Skill 目录时也可能被复制进 Hub\n\n### 修复方案\n\n将 Git Skill 发现改为分层模型，避免继续堆具体目录名白名单：\n\n1. 固定目录快速扫描：\n   - `skills/*`\n   - `skills/.curated/*`\n   - `skills/.experimental/*`\n   - `skills/.system/*`\n   - `.claude/skills/*`\n2. 根目录直接 Skill：\n   - `repo/my-skill/SKILL.md`\n3. 根目录 Skill 容器：\n   - 只扫描根目录下名称包含 `skill` 的容器目录的一层子目录\n   - 示例：`repo/awesome_agent_skills/technical-writer/SKILL.md`\n   - 示例：`repo/custom-agent-skills/python-expert/SKILL.md`\n\n该方案不是全仓库递归扫描，只多扫一层 `*skill*` 容器目录，性能可控。\n\n同时调整手动添加流程：\n\n- `tree/blob` 路径先调用 `list_git_skills_cmd` 做候选发现\n- 路径本身是 Skill：返回 1 个候选并自动安装\n- 路径是 Skill 容器：列出容器下一层候选，单个自动安装，多个弹 picker\n- 完全找不到 Skill：才提示未找到可导入 Skill\n\n并在安装入口增加最终校验：\n\n- 复制源必须是有效 Skill 目录\n- 有 `SKILL.md`，或是允许的 `.claude/skills/*` 目录\n- 容器目录本身没有 `SKILL.md` 时，拒绝安装并提示粘贴具体 Skill 文件夹链接\n\n另外保留对 `blob/.../SKILL.md` 的规范化：\n\n```text\n.../blob/main/awesome_agent_skills/technical-writer/SKILL.md\n```\n\n会转换为：\n\n```text\nawesome_agent_skills/technical-writer\n```\n\n## 问题 3：`description: |` 只渲染出一个 `|`\n\n### 问题描述\n\n部分 `SKILL.md` 使用 YAML block scalar 写多行描述：\n\n```yaml\n---\nname: technical-writer\ndescription: |\n  Creates clear documentation, API references, guides, and\n  technical content for developers and users.\nauthor: awesome-llm-apps\n---\n```\n\n详情页 Frontmatter 表格中只显示 `|`，后面的描述内容没有显示。\n\n### 根因分析\n\n前端详情页的 `parseFrontmatter` 和后端 `parse_skill_md` 都只支持简单的 `key: value` 单行解析。\n\n当遇到 `description: |` 时：\n\n- `description` 被解析成字面量 `|`\n- 后续缩进的多行文本没有关联到 `description`\n- 后端存入数据库的描述也可能变成 `|`\n\n### 修复方案\n\n前端和后端同时支持 YAML block scalar：\n\n- `description: |`：保留换行，适合多行描述\n- `description: >`：折叠为单段文本，适合普通段落\n\n同时调整 Markdown 表格样式：\n\n- `td` 使用 `white-space: pre-wrap` 保留多行文本\n- 单元格顶部对齐\n- 长文本允许换行，避免撑破布局\n\n## 验证\n\n已完成以下验证：\n\n- `npm run build` 通过\n- `cargo test -q` 通过，`79 passed`\n- `cargo fmt --all -- --check` 通过\n- 新增 Rust 测试覆盖 `description: |` 解析\n- 新增 Rust 测试覆盖 `blob/.../SKILL.md` 路径规范化\n- 新增 Rust 测试覆盖 `*skill*` 容器目录发现、非 skill 容器跳过、候选去重\n- 新增 Rust 测试覆盖容器目录拒绝安装、容器下具体子 Skill 可安装\n- 使用真实 GitHub 仓库验证 sparse checkout 可在约 2 秒内检出目标子目录\n\n## 修改文件\n\n- `src-tauri/src/core/git_fetcher.rs`：新增 `clone_or_pull_sparse`\n- `src-tauri/src/core/installer.rs`：GitHub 子目录安装和更新优先使用 sparse checkout；补充 block scalar 解析；新增分层 Skill 发现和安装前校验\n- `src-tauri/src/core/github_download.rs`：避免将根目录 `.` 走 GitHub Contents API 子目录下载路径\n- `src-tauri/src/core/tests/git_fetcher.rs`：新增 sparse checkout 测试\n- `src-tauri/src/core/tests/installer.rs`：新增 `description: |`、路径规范化、分层发现、容器目录校验相关测试\n- `src/App.tsx`：手动 `tree/blob` 路径改为先发现候选，再自动安装或弹出选择器\n- `src/components/skills/SkillDetailView.tsx`：前端 Frontmatter 解析支持 `|` 和 `>`\n- `src/App.css`：修复 Frontmatter 表格中多行描述的展示样式\n"
  },
  {
    "path": "docs/releases/v0.5.0/implementation-plan.md",
    "content": "# 项目级 Skill 同步功能实现计划\n\n## Context\n\nSkills Hub v0.4.x 只有全局同步。Skill 安装到中央仓库 `~/.skillshub/` 后，再同步到各工具的全局 Skills 目录。\n\nv0.5.0 增加项目级同步能力：同一个 Skill 可以选择同步到全局，或同步到一个或多个项目目录下的工具 Skills 目录。\n\n当前实现已经从原始方案做过几轮交互收敛，本计划按最新代码实现记录。\n\n---\n\n## 已确认的产品规则\n\n### 1. 安装流程不变\n\nSkill 仍然只安装到中央仓库：\n\n```text\n~/.skillshub/<skill-name>/\n```\n\n全局 / 项目只影响同步目标路径。\n\n### 2. Scope 是 Skill 级别设置\n\n每个 Skill 当前只有一个主要 scope：\n\n```text\nglobal | project\n```\n\n工具按钮不单独配置 scope，只控制该工具是否参与当前 scope 的同步。\n\n### 3. 切换 scope 后默认同步当前已安装工具\n\n切换范围时，以 `get_tool_status().installed` 返回的当前已安装工具为准：\n\n- 全局 → 项目：同步到所选项目下所有当前已安装工具。\n- 项目 → 全局：同步到所有当前已安装工具的全局目录。\n\n不能把系统支持的全部工具展示出来，也不能根据历史同步过的工具推断同步范围。\n\n### 4. 工具按钮样式不区分 scope\n\n工具按钮只表达同步状态：\n\n- active：已同步\n- inactive：未同步\n\n项目级状态通过范围徽标表达，例如 `1 个项目`。项目级工具按钮不使用蓝色样式。\n\n### 5. 项目列表是草稿态\n\n同步范围弹窗中的项目目录列表在点击“应用”前都是草稿：\n\n- 添加项目后点取消，不保存、不统计、不同步。\n- 删除项目后点取消，不解除同步。\n- 只有点击应用后，才提交最终项目列表。\n\n切换到项目范围时，必须至少选择一个项目目录才能应用。\n\n---\n\n## 1. 数据库迁移\n\n**文件**：`src-tauri/src/core/skill_store.rs`\n\n### Schema\n\n`SCHEMA_VERSION` 升级到 4。\n\n`skill_targets` 新增：\n\n```sql\nscope TEXT NOT NULL DEFAULT 'global',\nproject_path TEXT NULL\n```\n\n唯一索引：\n\n```sql\nCREATE UNIQUE INDEX idx_skill_targets_unique_scope\nON skill_targets(skill_id, tool, scope, COALESCE(project_path, ''));\n```\n\n### 迁移规则\n\nV3 → V4 使用重建表迁移：\n\n1. 创建 `skill_targets_new`\n2. 复制旧表数据，旧记录统一写为：\n\n   ```text\n   scope = 'global'\n   project_path = NULL\n   ```\n\n3. 删除旧表\n4. 重命名新表\n5. 创建新的唯一索引\n\n### Rust 结构\n\n`SkillTargetRecord` 增加：\n\n```rust\npub scope: String,\npub project_path: Option<String>,\n```\n\n相关方法签名调整：\n\n```rust\nget_skill_target(skill_id, tool, scope, project_path)\ndelete_skill_target(skill_id, tool, scope, project_path)\n```\n\n### 兼容性\n\n老用户升级后，既有全局同步记录会保留，并被识别为全局 scope。不会影响原有全局同步状态。\n\n---\n\n## 2. Tool Adapter 扩展\n\n**文件**：`src-tauri/src/core/tool_adapters/mod.rs`\n\n### 新增函数\n\n```rust\nresolve_project_path(adapter, project_root)\nsupports_project_scope(adapter)\nproject_relative_skills_dir(adapter)\nadapters_sharing_project_skills_dir(adapter)\n```\n\n### 当前实现规则\n\n- `supports_project_scope()` 当前返回 `true`。\n- UI 不根据支持矩阵展示全部工具，只展示当前已安装工具。\n- 项目路径不直接复用全局 `relative_skills_dir`，而是先走 `project_relative_skills_dir()` 的显式映射。\n- 未显式映射的工具回退到 adapter 自身的 `relative_skills_dir`。\n\n### 关键路径映射\n\n| 工具 | 项目级路径 |\n|------|------------|\n| Cursor | `.agents/skills` |\n| Codex | `.agents/skills` |\n| OpenCode | `.agents/skills` |\n| Gemini CLI | `.agents/skills` |\n| GitHub Copilot | `.agents/skills` |\n| Amp | `.agents/skills` |\n| Kimi Code CLI | `.agents/skills` |\n| Antigravity | `.agents/skills` |\n| Cline | `.agents/skills` |\n| Claude Code | `.claude/skills` |\n| OpenClaw | `skills` |\n| Windsurf | `.windsurf/skills` |\n| Qwen Code | `.qwen/skills` |\n\n完整映射以 `project_relative_skills_dir()` 为准。\n\n### 共享目录\n\n项目级同步会按项目路径分组：\n\n```rust\nadapters_sharing_project_skills_dir(adapter)\n```\n\n共享同一目录的工具只写一份文件系统目标，但会为当前已安装的共享工具写入各自的 `skill_targets` 记录。\n\n---\n\n## 3. 后端命令\n\n**文件**：`src-tauri/src/commands/mod.rs`\n\n### DTO\n\n`ToolInfoDto` 增加：\n\n```rust\nsupports_project_scope: bool\n```\n\n`SkillTargetDto` 增加：\n\n```rust\nscope: String,\nproject_path: Option<String>,\n```\n\n### `sync_skill_to_tool`\n\n新增可选参数：\n\n```rust\nscope: Option<String>,\nprojectPath: Option<String>,\n```\n\n规则：\n\n- `scope` 默认为 `global`。\n- 只允许 `global` / `project`。\n- `scope = project` 时，`projectPath` 必填，且必须是已存在目录。\n- `scope = global` 时继续检查工具是否安装。\n- `scope = project` 时使用 `resolve_project_path()` 生成目标目录。\n- 如果同 scope / projectPath 下已有有效 target，且目标存在，则幂等返回成功。\n- 同步成功后，对共享同一 Skills 目录且当前已安装的工具写入同步记录。\n\n错误前缀沿用：\n\n```text\nTARGET_EXISTS|\nTOOL_NOT_INSTALLED|\nTOOL_NOT_WRITABLE|\n```\n\n### `unsync_skill_from_tool`\n\n新增参数同上。\n\n规则：\n\n- 按 `skillId + tool + scope + projectPath` 定位记录。\n- 共享目录工具一起更新 DB 记录。\n- 文件系统目标只删除一次。\n- 全局范围下，如果共享组内没有任何工具已安装，则视为已经无效，直接成功。\n\n### 最近项目\n\n新增命令：\n\n```rust\nget_recent_projects()\nsave_recent_project(projectPath)\n```\n\n实现：\n\n- 存储在 settings 表的 `recent_projects_v1`\n- JSON 数组\n- 新路径插到最前\n- 去重\n- 最多保留 8 条\n- 只在用户点击“应用”提交项目范围后保存\n\n### 命令注册\n\n**文件**：`src-tauri/src/lib.rs`\n\n注册：\n\n```rust\ncommands::get_recent_projects\ncommands::save_recent_project\n```\n\n---\n\n## 4. 前端类型\n\n**文件**：`src/components/skills/types.ts`\n\n`ManagedSkill.targets` 增加：\n\n```ts\nscope: 'global' | 'project' | string\nproject_path?: string | null\n```\n\n`ToolInfoDto` 增加：\n\n```ts\nsupports_project_scope: boolean\n```\n\n---\n\n## 5. 前端 UI\n\n### FilterBar\n\n**文件**：`src/components/skills/FilterBar.tsx`\n\n新增 scope 下拉筛选：\n\n```text\n全部 / 全局 / 项目\n```\n\n布局要求：\n\n- 靠右，和排序、搜索、刷新同一组。\n- 下拉样式参考排序按钮。\n- 不显示额外“范围”文案。\n- 排序按钮不显示“排序：”前缀。\n\n### SkillCard\n\n**文件**：`src/components/skills/SkillCard.tsx`\n\n新增范围徽标：\n\n```text\n全局\nN 个项目\n```\n\n项目数量从后端真实 `project` target 中统计，不使用弹窗草稿或本地缓存。\n\n工具按钮：\n\n- 只展示当前用户已安装工具。\n- active / inactive 样式保持此前一致。\n- 不因为项目级 scope 改成蓝色。\n\n### ScopeSyncModal\n\n**文件**：`src/components/skills/modals/ScopeSyncModal.tsx`\n\n新增同步范围弹窗。\n\n文案：\n\n```text\n选择这个 Skill 生效的位置。\n\n全局\n在所有项目中可用\n\n项目\n仅在选择的项目中可用\n```\n\n交互：\n\n- radio 切换后直接展示对应内容，不弹额外确认框。\n- 项目模式下展示项目目录列表、选择项目目录按钮、最近项目。\n- 项目目录列表使用组件内部 `draftProjects`。\n- 点击取消丢弃草稿。\n- 点击应用调用 `onScopeChange(draftScope, draftProjects)`。\n- 项目模式下 `draftProjects.length === 0` 时禁用应用按钮并显示提示。\n\n### App\n\n**文件**：`src/App.tsx`\n\n新增状态：\n\n```ts\nscopeFilter\nscopeModalSkill\nrecentProjects\nskillScopeState\n```\n\n关键逻辑：\n\n- `getSkillScope(skill)` 以后端实际 target 为主，本地缓存只作兜底。\n- `getSkillProjects(skill)` 只从后端 project target 统计项目路径。\n- `handleScopeChange(nextScope, nextProjects)`：\n  - 清理目标 scope 之外的旧 target。\n  - 项目 scope 下，同时清理不在最终项目列表中的旧项目 target。\n  - 切到项目时，同步到 `nextProjects × installedToolIds`。\n  - 切到全局时，同步到 `installedToolIds`。\n  - 应用成功后才写入最近项目和本地 scope 缓存。\n- `handlePickProject()` 只返回文件夹选择结果，不直接写入 Skill 状态。\n\n---\n\n## 6. i18n\n\n**文件**：`src/i18n/resources.ts`\n\n新增或更新 key：\n\n| Key | EN | ZH |\n|-----|----|----|\n| `scope.all` | All | 全部 |\n| `scope.global` | Global | 全局 |\n| `scope.project` | Project | 项目 |\n| `scope.globalBadge` | Global | 全局 |\n| `scope.projectCount` | `{{count}} projects` | `{{count}} 个项目` |\n| `projectSync.title` | Sync Scope | 同步范围 |\n| `projectSync.help` | Choose where this Skill is available. | 选择这个 Skill 生效的位置。 |\n| `projectSync.globalDesc` | Available in all projects | 在所有项目中可用 |\n| `projectSync.projectDesc` | Available only in selected projects | 仅在选择的项目中可用 |\n| `projectSync.projectRequired` | Select at least one project directory to apply project scope. | 请至少选择一个项目目录后再应用项目范围。 |\n\n历史确认弹窗相关 key 可保留，但当前交互不再使用。\n\n---\n\n## 7. 关键文件清单\n\n| 文件 | 改动类型 |\n|------|----------|\n| `src-tauri/src/core/skill_store.rs` | V4 迁移、结构体、查询方法 |\n| `src-tauri/src/core/tool_adapters/mod.rs` | 项目路径映射、共享项目目录分组 |\n| `src-tauri/src/commands/mod.rs` | 命令参数、DTO、最近项目命令 |\n| `src-tauri/src/lib.rs` | 注册新命令 |\n| `src/components/skills/types.ts` | DTO 类型更新 |\n| `src/components/skills/FilterBar.tsx` | 范围筛选下拉 |\n| `src/components/skills/SkillCard.tsx` | 范围徽标、工具展示 |\n| `src/components/skills/modals/ScopeSyncModal.tsx` | 新增范围弹窗 |\n| `src/App.tsx` | 状态、筛选、切换、同步逻辑 |\n| `src/i18n/resources.ts` | 翻译 |\n| `src/App.css` | 新增弹窗、范围徽标、筛选样式 |\n\n---\n\n## 8. 验证方式\n\n1. `npm run check` 通过。\n2. 旧数据库升级后，旧 `skill_targets` 均为 `scope = global`，`project_path = NULL`。\n3. 全局同步一个 Skill，确认工具按钮保持原 active 样式，范围徽标为“全局”。\n4. 打开同步范围弹窗，选择“项目”但不选项目时，“应用”不可点。\n5. 选择项目后点取消，卡片项目数量不变化，目录不被同步，最近项目不保存。\n6. 选择项目后点应用，确认同步到项目目录下当前已安装工具。\n7. 再次打开弹窗，删除项目后点取消，原项目同步仍保留。\n8. 删除项目后点应用，确认该项目 target 被清理。\n9. 项目切回全局后，确认项目 target 被清理，全局 target 写入当前已安装工具。\n10. FilterBar 的“全部 / 全局 / 项目”筛选结果正确。\n"
  },
  {
    "path": "docs/releases/v0.5.0/project-scope-design.md",
    "content": "# 项目级 Skill 同步 — 设计文档\n\n## 背景\n\nSkills Hub v0.4.x 只支持全局同步：Skill 安装到中央仓库后，同步到各工具的全局目录，在所有项目中均可使用。\n\nv0.5.0 新增**项目级同步**：Skill 可以同步到指定项目中的工具目录，使其只在这些项目中生效。\n\n---\n\n## 核心原则\n\n### 安装位置不变\n\nSkill 文件仍然只安装并维护在中央仓库中：\n\n```text\n~/.skillshub/<skill-name>/\n```\n\n全局 / 项目只决定同步目标，不改变 Hub 中的 Skill 文件。\n\n### 同步范围是 Skill 级别设置\n\n每个 Skill 有一个当前同步范围：\n\n| 范围 | 含义 |\n|------|------|\n| 全局 | 同步到各工具的全局 Skills 目录 |\n| 项目 | 同步到所选项目下各工具的项目级 Skills 目录 |\n\n同步范围不是每个工具单独设置。工具按钮只表示该工具是否参与当前范围的同步。\n\n### 切换范围默认同步当前已安装工具\n\n全局和项目之间切换时，系统会以**当前用户已安装的工具**为准重新同步：\n\n- 全局 → 项目：移除非项目范围的旧同步记录，并将该 Skill 同步到所选项目下所有当前已安装工具。\n- 项目 → 全局：移除非全局范围的旧同步记录，并将该 Skill 同步到所有当前已安装工具的全局目录。\n\n这里的“所有工具”不是系统支持的全部工具，而是当前检测到已安装的工具。\n\n---\n\n## 同步路径\n\n### 全局路径\n\n全局同步继续使用各工具 adapter 中已有的全局路径，例如：\n\n```text\n~/.claude/skills/<skill-name>\n~/.codex/skills/<skill-name>\n```\n\n### 项目路径\n\n项目级同步使用独立的项目路径映射。部分工具的项目级路径和全局路径不一致，不能直接复用全局 `relative_skills_dir`。\n\n当前实现中所有工具都允许项目级同步；UI 只展示当前用户已安装的工具。\n\n主要项目级路径如下：\n\n| 工具 | 项目级 Skills 目录 |\n|------|--------------------|\n| Cursor / Codex / OpenCode / Gemini CLI / GitHub Copilot / Amp / Kimi Code CLI / Antigravity / Cline | `<project>/.agents/skills/` |\n| Claude Code | `<project>/.claude/skills/` |\n| OpenClaw | `<project>/skills/` |\n| Windsurf | `<project>/.windsurf/skills/` |\n| Qwen Code | `<project>/.qwen/skills/` |\n| OpenHands | `<project>/.openhands/skills/` |\n| 其他已映射工具 | 使用 `project_relative_skills_dir()` 中的显式映射 |\n| 未显式映射工具 | 回退到该工具的全局 `relative_skills_dir` |\n\n共享同一项目级目录的工具会共用同一个同步目标。例如多个工具都使用 `<project>/.agents/skills/` 时，文件系统只写一份，数据库只为当前已安装且共享该目录的工具记录同步状态。\n\n---\n\n## 同步方式\n\n同步引擎仍沿用现有策略：\n\n```text\nsymlink -> junction（Windows）-> copy\n```\n\n因此文档中“同步目标”不承诺一定是软链接；具体模式由同步引擎决定，并记录在 `skill_targets.mode` 中。\n\n---\n\n## 交互设计\n\n### Skill Card\n\nSkill 卡片 meta 行新增范围徽标：\n\n```text\nux-designer\nExpert UX design assistance...\nshubhamsaboo/awesome-llm-apps · 10 小时前 · [1 个项目]\n\n[● Cursor] [● Claude Code] [● Codex] [OpenClaw]\n```\n\n范围徽标：\n\n| 文案 | 含义 |\n|------|------|\n| 全局 | 当前 Skill 使用全局同步 |\n| N 个项目 | 当前 Skill 使用项目级同步，且已同步到 N 个项目 |\n\n点击范围徽标打开“同步范围”弹窗。\n\n### 工具按钮\n\n工具按钮颜色不区分全局 / 项目，继续沿用原有语义：\n\n| 状态 | 含义 | 样式 |\n|------|------|------|\n| 已同步 | 该工具已参与当前范围同步 | 原有 active 样式 |\n| 未同步 | 该工具未参与当前范围同步 | 原有 inactive 样式 |\n\n全局 / 项目的区别由范围徽标表达，不通过工具按钮颜色表达。\n\n### 同步范围弹窗\n\n弹窗只暴露用户决策需要的信息：\n\n```text\n同步范围 · ux-designer\n\n选择这个 Skill 生效的位置。\n\n○ 全局\n  在所有项目中可用\n\n● 项目\n  仅在选择的项目中可用\n\n项目目录\n  /Users/may/Desktop/test/cc-weixin-test    [x]\n  [选择项目目录...]\n\n最近使用\n  /Users/may/Desktop/test/cursor-browser    [添加]\n\n[取消] [应用]\n```\n\n交互规则：\n\n- 切换 radio 后立即展示对应区域，不再弹出额外确认框。\n- 选择“项目”时，必须至少选择一个项目目录才能点击“应用”。\n- 新增、移除项目目录均为弹窗内草稿状态。\n- 点击“取消”不会保存项目列表，不会影响卡片项目数量，也不会触发同步。\n- 点击“应用”后才提交范围和项目列表，并执行同步。\n- 最近项目只在应用项目范围后保存，最多保留 8 个。\n\n### 筛选栏\n\n范围筛选使用下拉样式，和排序 / 搜索 / 刷新保持同一行布局：\n\n```text\n全部 Skills                         [全部 v] [最近更新 ↕] [搜索 skills...] [刷新]\n```\n\n筛选项：\n\n| 选项 | 显示内容 |\n|------|----------|\n| 全部 | 所有 Skill |\n| 全局 | 当前范围为全局的 Skill |\n| 项目 | 当前范围为项目的 Skill |\n\n筛选在前端完成，不需要新增后端查询接口。\n\n---\n\n## 数据模型\n\n`skill_targets` 增加范围维度：\n\n```text\nscope TEXT NOT NULL DEFAULT 'global'\nproject_path TEXT NULL\n```\n\n唯一索引：\n\n```sql\nUNIQUE(skill_id, tool, scope, COALESCE(project_path, ''))\n```\n\n含义：\n\n- 全局 target：`scope = 'global'`，`project_path = NULL`\n- 项目 target：`scope = 'project'`，`project_path = <project root>`\n\n旧数据库升级到 v0.5.0 时，既有同步记录会迁移为：\n\n```text\nscope = 'global'\nproject_path = NULL\n```\n\n因此老用户升级后，原有全局同步状态保持不变。\n\n---\n\n## 前后端接口\n\n### `sync_skill_to_tool`\n\n新增可选参数：\n\n```text\nscope?: 'global' | 'project'\nprojectPath?: string\n```\n\n规则：\n\n- `scope` 缺省为 `global`，保持向后兼容。\n- `scope = project` 时必须传 `projectPath`，且路径必须是已存在目录。\n- 全局同步会检查工具是否已安装。\n- 项目同步不依赖全局工具安装路径，但最终记录只写入当前已安装工具。\n- 同一路径已有有效 target 时视为幂等成功。\n\n### `unsync_skill_from_tool`\n\n同样新增：\n\n```text\nscope?: 'global' | 'project'\nprojectPath?: string\n```\n\n规则：\n\n- 按 `skill_id + tool + scope + project_path` 删除目标记录。\n- 共享同一 Skills 目录的工具会一起更新数据库状态。\n- 文件系统目标只删除一次，避免共享目录重复删除。\n\n### 最近项目\n\n新增命令：\n\n```text\nget_recent_projects\nsave_recent_project(projectPath)\n```\n\n最近项目存入 `settings.recent_projects_v1`，用于项目级弹窗快捷添加。\n\n---\n\n## 实现变更概览\n\n| 层 | 文件 | 变更 |\n|----|------|------|\n| DB | `skill_store.rs` | `skill_targets` 增加 `scope`、`project_path`，V3→V4 重建表迁移 |\n| 后端 | `tool_adapters/mod.rs` | 新增项目级路径解析、共享项目目录分组、项目路径映射 |\n| 后端 | `commands/mod.rs` | `sync/unsync` 增加 `scope`、`projectPath`；新增最近项目命令 |\n| 后端 | `lib.rs` | 注册 `get_recent_projects`、`save_recent_project` |\n| 前端 | `types.ts` | DTO 增加 `scope`、`project_path`、`supports_project_scope` |\n| 前端 | `FilterBar.tsx` | 新增范围下拉筛选 |\n| 前端 | `SkillCard.tsx` | meta 行增加范围徽标；工具按钮保持原有 active/inactive 样式 |\n| 前端 | `ScopeSyncModal.tsx` | 新建同步范围弹窗，使用草稿项目列表，应用后提交 |\n| 前端 | `App.tsx` | 新增范围状态、筛选、切换、项目同步逻辑 |\n| i18n | `resources.ts` | 新增 `scope.*`、`projectSync.*` 翻译键 |\n\n详细实现步骤见：[实现计划](./implementation-plan.md)\n"
  },
  {
    "path": "docs/releases/v0.5.0/ux-optimizations.md",
    "content": "# UX 优化记录\n\n收录不需要单独文档的小型 UX 改进。\n\n---\n\n## 关闭按钮改为隐藏窗口（macOS）\n\n**变更：** 点击红色 X 按钮不再退出应用，而是隐藏窗口。\n\n**原因：** macOS 上许多主流应用（Slack、Discord 等）均采用此交互模式——应用在后台持续运行，下次打开时响应更快。需要真正退出时使用 `Cmd+Q` 或菜单栏退出。\n\n**实现方式：**\n- 拦截 `CloseRequested` 窗口事件，阻止默认关闭行为，改为隐藏窗口。\n- 点击 Dock 图标时触发 `RunEvent::Reopen`，重新显示窗口并聚焦。\n\n**涉及文件：** `src-tauri/src/lib.rs`\n\n---\n\n## Skill 描述与 Markdown 预览优化\n\n**变更：** 修复 `SKILL.md` frontmatter 在列表和详情页中的展示问题。\n\n**原因：** 部分 Skill 使用 YAML 折叠块语法（例如 `description: >-`）。旧解析逻辑没有识别 `>-`、`>+`、`|-`、`|+`，导致列表卡片错误显示 `>-`，详情页元信息也可能出现描述缺失或排版异常。\n\n**实现方式：**\n- 后端 `SKILL.md` 解析支持 YAML block scalar 的 chomping indicator：`>`、`>-`、`>+`、`|`、`|-`、`|+`。\n- 启动时重新从 `SKILL.md` 比对并回填描述，纠正已入库的旧错误值。\n- 详情页 frontmatter 改为响应式 key/value 元信息区，避免短字段被表格挤压成竖排。\n- Markdown 预览内容区居中展示，并保留最大可读宽度。\n\n**涉及文件：**\n- `src-tauri/src/core/installer.rs`\n- `src-tauri/src/core/skill_store.rs`\n- `src-tauri/src/core/tests/installer.rs`\n- `src-tauri/src/core/tests/skill_store.rs`\n- `src/components/skills/SkillDetailView.tsx`\n- `src/App.css`\n\n---\n\n## 取消同步后的 Agent 重新启用入口\n\n**变更：** 修复在“我的 skills”页面取消部分 agent 同步后，已取消同步的 agent 没有重新启用入口的问题。\n\n**原因：** v0.4.3 中未同步 agent 只会在卡片展开状态下渲染。触发场景是：某个 skill 同步到多个 agent 后，用户取消 Qoder、GitHub Copilot 等部分 agent；如果该 skill 剩余已同步 agent 数量不超过 5 个，卡片不会显示 `+N more` 展开按钮，导致被取消同步的 agent 无法以灰色按钮显示，看起来像“消失了，无法重新添加”。关闭重开应用也不会恢复，因为这是渲染条件问题，不是临时状态卡住。\n\n**实现方式：**\n- 当卡片不需要折叠时，直接显示未同步 agent 的灰色按钮。\n- 保留折叠场景下通过 `+N more` 展开查看完整 agent 列表的行为。\n\n**涉及文件：** `src/components/skills/SkillCard.tsx`\n\n---\n\n## Bug：导入同名且内容一致的 Skill 时同步冲突\n\n**关联 Issue：** https://github.com/qufei1993/skills-hub/issues/51\n\n**状态：** 已在本地验证修复，待 v0.5.0 发版后关闭 issue。\n\n**变更：** 修复从一个工具导入 Skill 后，同名且内容一致的其它工具目录仍被判定为同步冲突的问题；同时修复导入弹窗在部分同步失败后不关闭、未选择任何 Skill 时仍可点击“导入并同步”的问题。\n\n**原因：** 旧同步逻辑只判断目标目录是否存在。即使 Codex、Cursor 等工具下的同名 Skill 内容完全一致，也会返回 `TARGET_EXISTS`，导致该工具永远无法被 Hub 接管。导入流程还会在部分失败时只显示错误 toast，不关闭 `ImportModal`。\n\n**实现方式：**\n- `sync_skill_to_tool` 增加 `overwriteIfSameContent` 参数。\n- 后端在目标目录已存在时比较 Hub 中央仓库 Skill 与目标目录的内容 hash；内容一致时允许安全接管，内容不一致时继续阻止覆盖。\n- 所有前端同步入口都传入 `overwriteIfSameContent: true`，确保导入、创建后同步、单个工具同步、批量同步和范围切换行为一致。\n- 导入流程改为逐项处理，单个 Skill 导入或同步失败不会中断后续项。\n- 导入结束后统一关闭弹窗；有错误时只显示错误提示，全部成功时才显示成功提示。\n- 未选择任何 Skill 时禁用“导入并同步”，并在提交入口增加空选择校验。\n\n**涉及文件：**\n- `src-tauri/src/commands/mod.rs`\n- `src/App.tsx`\n- `src/components/skills/modals/ImportModal.tsx`\n\n---\n\n## 新增：Hermes Agent 全局同步支持\n\n**关联 Issue：** https://github.com/qufei1993/skills-hub/issues/54\n\n**状态：** 已在本地验证，纳入 v0.5.0。\n\n**变更：** 新增 Hermes Agent 工具适配，支持将 Skill 全局同步到 `~/.hermes/skills`。\n\n**原因：** Issue #54 请求支持 Hermes Agent。调研 Hermes Agent 官方文档后，仅确认默认 `HERMES_HOME` 为 `~/.hermes`，Skills 位于 `HERMES_HOME/skills`。没有找到明确的项目级 skills 目录规范，因此本次只声明并实现全局同步支持，避免为项目级同步捏造路径。\n\n**实现方式：**\n- 新增 `hermes_agent` 工具 key，展示名为 `Hermes Agent`。\n- 全局 skills 目录配置为 `.hermes/skills`，安装检测目录为 `.hermes`。\n- `supports_project_scope` 对 Hermes Agent 返回 `false`。\n- 后端在项目级同步入口拒绝不支持项目级同步的工具，返回 `PROJECT_SCOPE_UNSUPPORTED|<tool>`。\n- 前端在项目级批量同步时跳过 Hermes Agent；用户在项目级 Skill 上单独点击 Hermes Agent 时显示“不支持项目级同步”提示。\n- README 和 CHANGELOG 同步标注 Hermes Agent 仅支持全局同步，并列入 v0.5.0。\n\n**涉及文件：**\n- `src-tauri/src/core/tool_adapters/mod.rs`\n- `src-tauri/src/commands/mod.rs`\n- `src-tauri/src/core/tests/tool_adapters.rs`\n- `src/App.tsx`\n- `src/i18n/resources.ts`\n- `README.md`\n- `docs/README.zh.md`\n- `CHANGELOG.md`\n- `docs/CHANGELOG.zh.md`\n"
  },
  {
    "path": "docs/releases/v0.6.0/minor-updates.md",
    "content": "# v0.6.0 小需求与体验优化记录\n\n这个文件用于记录 v0.6.0 周期内较小的需求、体验优化和界面修正。后续同类变更继续追加到这里，避免为每个小项单独创建发布记录文件。\n\n## 2026-05-05\n\n### 删除 My Skills 筛选栏刷新按钮\n\n- 删除 My Skills 筛选栏中的 `Refresh` / `刷新` 按钮，解决中文界面下按钮样式错乱问题（GitHub Issue #61，PR #63）。\n- 确认该按钮仅手动重新读取当前 Skill 列表和标签，不触发重新扫描工具、Git 更新或重新同步。\n- 安装、删除、同步、编辑标签等操作后已有自动刷新，不影响现有数据更新流程。\n- 修复验证（PR #63）：`npm run check`。\n\n### 查看并导入 Skill 增加搜索栏\n\n- 在“查看并导入 Skill”弹窗中新增搜索栏，支持按名称、描述和路径筛选候选项（GitHub Issue #57）。\n- 搜索结果会同步影响“全选”和已选数量统计，方便在多个 Skill 中快速定位目标。\n- 本地目录导入和 Git 仓库导入两个入口都已支持该交互。\n- “查看发现的 Skills”弹窗也已补上搜索栏，支持按 Skill 名称或路径筛选已发现条目。\n- 修复验证：`npm run lint`、`npx tsc -b`、`npm run rust:fmt:check`、`npm run rust:clippy`、`npm run rust:test`。\n"
  },
  {
    "path": "docs/releases/v0.6.0/tag-management.md",
    "content": "# Skill 标签管理功能设计与实现计划\n\n## 背景\n\n随着用户安装的 Skill 数量增加，当前列表主要依赖名称、描述和同步状态浏览。用户很难按技术方向或使用场景快速定位 Skill，例如：\n\n- 前端 / React / UI\n- Rust / Tauri\n- 文档 / 图表 / 自动化\n- 测试 / 代码审查\n\nv0.6.0 聚焦解决这个问题：为 Skill 增加**自定义标签**能力，用于整理和筛选 Skill。\n\n关联需求：\n\n- GitHub Issue #15：添加 tag 功能。\n\n---\n\n## 本期目标\n\nv0.6.0 只实现标签功能。\n\n标签用于：\n\n- 给 Skill 添加一个或多个自定义标签。\n- 在 My Skills 页面按标签筛选 Skill。\n- 找出还没有设置标签的 Skill。\n- 通过独立的 Tags 页面查看和编辑标签。\n\n标签不用于：\n\n- 控制 Skill 是否同步。\n- 控制 Skill 同步到哪些工具。\n- 作为一套可切换的工作配置。\n\n一句话定义：\n\n```text\nTag 用于找 Skill，不用于改变 Skill 的生效范围。\n```\n\n---\n\n## 产品规则\n\n### 1. Skill 可以没有标签\n\n没有标签是合法状态。\n\n系统提供虚拟筛选项：\n\n```text\nUntagged\n```\n\n含义：\n\n```text\n显示没有任何标签的 Skill。\n```\n\n`Untagged` 不是用户创建的真实标签，不能重命名、删除，也不写入标签表。\n\n### 2. Skill 可以有多个标签\n\n一个 Skill 可以关联多个标签：\n\n```text\nfrontend-design: Frontend, Docs, UI\n```\n\n同一个 Skill 下不能重复添加同一个标签。\n\n### 3. 标签筛选支持多选\n\nMy Skills 页面提供 `Tags` 下拉筛选器：\n\n```text\n[All ▾] [Most recent ⇅] [Tags ▾] [Search skills...] [Refresh]\n```\n\n下拉内容：\n\n```text\nTags                              Match any\n\n[Search tags...]\n\n[ ] Untagged                  5\n[✓] Frontend                  8\n[✓] React                     3\n[ ] Rust                      2\n[ ] Docs                      6\n\nClear all\n```\n\n筛选逻辑使用 `OR`：\n\n```text\n选择 Frontend + Docs\n```\n\n显示包含 `Frontend` 或 `Docs` 的 Skill。\n\n### 4. 筛选即时生效\n\n标签筛选只影响列表展示，不修改数据，因此不需要 `Apply`。\n\n交互规则：\n\n- 勾选标签后，Skill 列表立即更新。\n- 取消勾选后，Skill 列表立即更新。\n- 下拉保持打开，方便连续选择。\n- 点击外部关闭下拉。\n- `Clear all` 立即清空标签筛选。\n\n### 5. 标签页面是独立入口\n\n标签不放在 Settings 中，避免入口过深。\n\n入口：\n\n- 顶部主导航的 `Tags` / `标签`\n- My Skills 页面标签筛选区的辅助入口（保留一个快速跳转点）\n\n页面层级：\n\n```text\nTags\n```\n\n页面内容：\n\n```text\nTags\n\n标签用于筛选和整理 skills，不会改变同步结果。\n\n5 skills have no tags                         [Review]\n\n[Search tags...]                              [+ New Tag]\n\nTag name        Skills      Last used       Actions\nFrontend        8           2d ago          View  Rename  Delete\nDocs            6           5d ago          View  Rename  Delete\nRust            2           9d ago          View  Rename  Delete\n```\n\n`Review` 点击后回到 My Skills，并应用 `Untagged` 筛选。\n\n`View` 点击后回到 My Skills，并应用对应标签筛选。\n\n---\n\n## UI 行为\n\n### Skill 卡片\n\nSkill 卡片展示最多 2-3 个标签，避免挤压工具同步状态。\n\n有标签：\n\n```text\nfrontend-design    #Frontend #Docs\n```\n\n无标签：\n\n```text\nlegacy-shell-helper\n```\n\n没有标签时不展示空文案，只保留标签编辑入口。\n\n### 标签编辑\n\n每个 Skill 需要有入口编辑标签。\n\n当前实现使用 Skill 卡片右侧的标签图标按钮作为快捷入口，点击后打开标签编辑。\n\n编辑内容：\n\n```text\nEdit Tags: frontend-design\n\n[✓] Frontend\n[✓] Docs\n[ ] Rust\n[ ] Testing\n\n[Done]\n```\n\n### 新建标签\n\n新建标签入口在 `Tags` 页面。\n\n规则：\n\n- 标签名不能为空。\n- 标签名去除首尾空格。\n- 标签名大小写不敏感去重。\n- 新建后出现在标签管理表和筛选下拉中。\n\n### 重命名标签\n\n重命名标签会更新所有关联 Skill 的标签展示。\n\n规则：\n\n- 不能重命名为已存在标签。\n- 重命名后保留原有关联关系。\n- 如果当前正在按旧标签筛选，筛选条件同步切换为新标签名。\n\n### 删除标签\n\n删除标签只删除标签和关联关系，不删除 Skill。\n\n删除前需要确认：\n\n```text\nDelete \"Docs\" from 6 skills?\nThis only removes the tag, not the skills.\n```\n\n删除后：\n\n- 标签从所有 Skill 上移除。\n- 如果某些 Skill 因此没有任何标签，它们会进入 `Untagged`。\n\n---\n\n## 数据模型\n\n新增两张表：\n\n```sql\nCREATE TABLE skill_tags (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  name TEXT NOT NULL UNIQUE,\n  created_at TEXT NOT NULL,\n  updated_at TEXT NOT NULL\n);\n\nCREATE TABLE skill_tag_links (\n  skill_id INTEGER NOT NULL,\n  tag_id INTEGER NOT NULL,\n  created_at TEXT NOT NULL,\n  PRIMARY KEY (skill_id, tag_id),\n  FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,\n  FOREIGN KEY (tag_id) REFERENCES skill_tags(id) ON DELETE CASCADE\n);\n```\n\n约束：\n\n- `skill_tags.name` 唯一。\n- `skill_tag_links` 使用 `(skill_id, tag_id)` 主键，防止同一 Skill 重复关联同一标签。\n- `Untagged` 不入库，由 `NOT EXISTS skill_tag_links` 动态计算。\n\n迁移：\n\n- 升级数据库 schema 版本。\n- 老用户升级后，既有 Skill 默认没有标签。\n- 不自动生成标签，避免误分类。\n\n---\n\n## 后端能力\n\n建议新增 core 方法：\n\n```rust\ncreate_tag(name)\nrename_tag(tag_id, name)\ndelete_tag(tag_id)\nlist_tags_with_counts()\nset_skill_tags(skill_id, tag_ids)\nget_skill_tags(skill_id)\nlist_untagged_skill_ids()\n```\n\n建议新增 Tauri commands：\n\n```text\nget_tags\ncreate_tag\nrename_tag\ndelete_tag\nset_skill_tags\nget_skill_tags\n```\n\n`get_managed_skills` 返回的 Skill DTO 增加：\n\n```ts\ntags: TagDto[]\n```\n\n`TagDto`：\n\n```ts\ntype TagDto = {\n  id: number;\n  name: string;\n};\n```\n\n标签筛选第一版可在前端完成，不需要新增服务端筛选参数。\n\n---\n\n## 前端改动\n\n主要涉及：\n\n- `src/App.tsx`\n- `src/components/skills/types.ts`\n- `src/components/skills/FilterBar.tsx`\n- `src/components/skills/SkillCard.tsx`\n- 新增 Tags 页面组件\n- 新增标签编辑弹窗或详情页 Tags 区块\n- `src/i18n/resources.ts`\n- `src/App.css`\n\n### 状态\n\n新增状态：\n\n```ts\ntags\nselectedTagIds\ntagSearch\ntagManagerSearch\ntagEditorSkill\n```\n\n### 筛选\n\n前端筛选逻辑：\n\n```text\nselectedTags 为空 -> 显示全部\nselectedTags 包含普通标签 -> 命中任意一个标签\nselectedTags 包含 Untagged -> skill.tags.length === 0\n```\n\n### i18n\n\n所有用户可见文案需要提供英文和中文：\n\n- Tags\n- Untagged\n- New Tag\n- Rename\n- Delete\n- Review\n- Clear all\n- Match any\n\n---\n\n## 测试重点\n\n### 后端\n\n- 新建标签成功。\n- 重复标签名被拒绝。\n- 重命名标签保留关联关系。\n- 删除标签后关联关系清理。\n- 同一 Skill 不能重复关联同一标签。\n- 删除 Skill 后标签关联被清理。\n- `Untagged` 统计正确。\n\n### 前端\n\n- 多选标签即时筛选。\n- `Untagged` 筛选正确。\n- `Clear all` 清空筛选。\n- 顶部 `Tags` 进入标签页面。\n- Tags 页面 `Review` 能筛选无标签 Skill。\n- Tags 页面 `View` 能筛选指定标签。\n- 无标签 Skill 不显示空文案。\n- 编辑标签后卡片和筛选结果刷新。\n- 在添加 Skill 弹窗中可直接选择标签，创建后自动绑定。\n\n---\n\n## 发布范围\n\nv0.6.0 包含：\n\n- 自定义标签数据模型。\n- 标签 CRUD。\n- Skill 标签关联编辑。\n- My Skills 标签多选筛选。\n- `Untagged` 虚拟筛选项。\n- 独立 Tags 页面。\n- 添加 Skill 时选择标签。\n- 中英文文案。\n- 后端和前端测试。\n\nv0.6.0 不包含：\n\n- 标签自动推荐。\n- 批量给多个 Skill 添加标签。\n"
  },
  {
    "path": "docs/skills_hub_design.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Skills Hub - Design Preview (Optimized Light Mode)</title>\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n    <link href=\"https://fonts.googleapis.com/css2?family=Fira+Sans:wght@300;400;500;600;700&family=Fira+Code:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n    <style>\n        :root {\n            /* Colors - Clean Light Theme */\n            --bg-app: #ffffff;\n            --bg-panel: #fcfcfc;\n            --bg-element: #f4f4f5;\n            --bg-element-hover: #e4e4e7;\n            \n            --border-subtle: #e4e4e7;\n            --border-strong: #d4d4d8;\n            \n            --text-primary: #18181b;\n            --text-secondary: #52525b;\n            --text-tertiary: #a1a1aa;\n            \n            /* Executive Dashboard Accent Palette */\n            --accent-primary: #2563EB; /* Slightly darker blue for white bg */\n            --accent-primary-hover: #1D4ED8;\n            --accent-primary-fg: #FFFFFF;\n            \n            --status-success: #059669;\n            --status-warning: #d97706;\n            --status-error: #dc2626;\n            --status-info: #2563EB;\n            \n            /* Typography */\n            --font-ui: 'Fira Sans', system-ui, -apple-system, sans-serif;\n            --font-mono: 'Fira Code', monospace;\n            \n            /* Spacing & Layout */\n            --radius-sm: 4px;\n            --radius-md: 8px;\n            --radius-lg: 12px;\n            \n            /* Effects */\n            --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);\n            --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.05);\n            --glow-text: none;\n        }\n\n        * {\n            box-sizing: border-box;\n            margin: 0;\n            padding: 0;\n        }\n\n        body {\n            background-color: #e5e5e5;\n            color: var(--text-primary);\n            font-family: var(--font-ui);\n            padding: 40px;\n            display: flex;\n            flex-direction: column;\n            align-items: center;\n            gap: 60px;\n            min-height: 100vh;\n            -webkit-font-smoothing: antialiased;\n        }\n\n        /* Presentation Label */\n        .design-label {\n            position: absolute;\n            top: -30px;\n            left: 0;\n            font-family: var(--font-mono);\n            font-size: 12px;\n            color: var(--text-secondary);\n            text-transform: uppercase;\n            letter-spacing: 0.1em;\n            text-shadow: none;\n        }\n\n        /* App Window Frame Container */\n        .window-frame {\n            position: relative;\n            width: 1000px; /* Slightly wider for data density */\n            height: 700px;\n            background: var(--bg-app);\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-lg);\n            box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);\n            display: flex;\n            flex-direction: column;\n            overflow: hidden;\n        }\n\n        /* Title Bar */\n        .title-bar {\n            height: 40px;\n            background: var(--bg-app);\n            border-bottom: 1px solid var(--border-subtle);\n            display: flex;\n            align-items: center;\n            padding: 0 16px;\n            -webkit-app-region: drag;\n        }\n\n        .traffic-lights {\n            display: flex;\n            gap: 8px;\n        }\n\n        .traffic-light {\n            width: 12px;\n            height: 12px;\n            border-radius: 50%;\n        }\n        .traffic-light.close { background: #ff5f56; border: 1px solid rgba(0,0,0,0.1); }\n        .traffic-light.minimize { background: #ffbd2e; border: 1px solid rgba(0,0,0,0.1); }\n        .traffic-light.maximize { background: #27c93f; border: 1px solid rgba(0,0,0,0.1); }\n\n        .app-layout {\n            display: flex;\n            flex-direction: column;\n            flex: 1;\n            overflow: hidden;\n        }\n\n        /* Top Navigation Header */\n        .app-header {\n            height: 64px;\n            padding: 0 24px;\n            display: flex;\n            align-items: center;\n            justify-content: space-between;\n            border-bottom: 1px solid var(--border-subtle);\n            background: rgba(255, 255, 255, 0.8); /* Glass effect base */\n            backdrop-filter: blur(12px);\n            -webkit-backdrop-filter: blur(12px);\n            z-index: 10;\n        }\n\n        .brand-area {\n            display: flex;\n            align-items: center;\n            gap: 12px;\n        }\n\n        .logo-icon {\n            width: 36px;\n            height: 36px;\n            display: block;\n            object-fit: contain;\n            border-radius: 8px;\n        }\n        \n        .brand-text {\n            font-weight: 700;\n            font-size: 20px;\n            letter-spacing: -0.03em;\n            /* UI UX Pro Max Gradient: Blue -> Purple -> Orange */\n            background: linear-gradient(to right, #2563EB, #9333EA, #EA580C);\n            -webkit-background-clip: text;\n            -webkit-text-fill-color: transparent;\n        }\n\n        .header-actions {\n            display: flex;\n            align-items: center;\n            gap: 16px;\n        }\n\n        /* Optimized Language Switcher */\n        .lang-btn {\n            font-size: 12px;\n            font-weight: 600;\n            color: var(--text-secondary);\n            cursor: pointer;\n            padding: 6px 10px;\n            border-radius: var(--radius-sm);\n            background: transparent;\n            transition: all 0.2s;\n            border: 1px solid var(--border-subtle);\n            font-family: var(--font-mono);\n            display: flex;\n            align-items: center;\n            gap: 6px;\n        }\n        .lang-btn:hover {\n            color: var(--text-primary);\n            background: var(--bg-element);\n            border-color: var(--text-tertiary);\n        }\n\n        .icon-btn-header {\n            width: 36px;\n            height: 36px;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            border-radius: var(--radius-md);\n            color: var(--text-secondary);\n            cursor: pointer;\n            transition: all 0.2s;\n            overflow: visible;\n        }\n        .icon-btn-header:hover {\n            background: var(--bg-element);\n            color: var(--text-primary);\n            transform: translateY(-1px);\n        }\n        \n        .icon-btn-header svg {\n            width: 20px;\n            height: 20px;\n        }\n\n        .btn {\n            height: 36px;\n            padding: 0 16px;\n            border-radius: var(--radius-md);\n            font-size: 13px;\n            font-weight: 600;\n            cursor: pointer;\n            display: flex;\n            align-items: center;\n            gap: 8px;\n            border: 1px solid transparent;\n            transition: all 0.2s;\n            position: relative;\n            overflow: hidden;\n        }\n\n        .btn-primary {\n            background: var(--accent-primary);\n            color: var(--accent-primary-fg);\n            box-shadow: 0 4px 6px -1px rgba(37, 99, 235, 0.2);\n        }\n        .btn-primary:hover {\n            background: var(--accent-primary-hover);\n            transform: translateY(-1px);\n            box-shadow: 0 6px 8px -1px rgba(37, 99, 235, 0.3);\n        }\n        .btn-primary:active {\n            transform: translateY(0);\n        }\n\n        .btn-secondary {\n            background: transparent;\n            border-color: var(--border-subtle);\n            color: var(--text-primary);\n        }\n        .btn-secondary:hover {\n            border-color: var(--border-strong);\n            background: var(--bg-element);\n        }\n        \n        .btn-danger {\n            background: #fef2f2;\n            color: var(--status-error);\n            border: 1px solid #fee2e2;\n        }\n        .btn-danger:hover {\n            background: #fee2e2;\n            border-color: var(--status-error);\n        }\n        .btn-danger-solid {\n            background: var(--status-error);\n            color: #fff;\n            box-shadow: 0 4px 6px -1px rgba(220, 38, 38, 0.2);\n        }\n        .btn-danger-solid:hover {\n            opacity: 0.9;\n            transform: translateY(-1px);\n        }\n\n        /* Stats Grid (New) */\n        .stats-grid {\n            display: grid;\n            grid-template-columns: repeat(3, 1fr);\n            gap: 16px;\n            padding: 24px 32px 0 32px;\n        }\n\n        .stat-card {\n            background: var(--bg-panel);\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-md);\n            padding: 16px;\n            display: flex;\n            flex-direction: column;\n            gap: 4px;\n            transition: all 0.2s;\n        }\n        .stat-card:hover {\n            border-color: var(--border-strong);\n            transform: translateY(-1px);\n        }\n\n        .stat-label {\n            font-size: 12px;\n            color: var(--text-tertiary);\n            font-weight: 500;\n            text-transform: uppercase;\n            letter-spacing: 0.05em;\n        }\n        .stat-value {\n            font-size: 24px;\n            font-weight: 600;\n            color: var(--text-primary);\n            letter-spacing: -0.02em;\n        }\n\n        /* Sub-Header / Filter Bar */\n        .filter-bar {\n            padding: 20px 32px 0 32px;\n            display: flex;\n            justify-content: space-between;\n            align-items: center;\n        }\n\n        .filter-title {\n            font-size: 14px;\n            font-weight: 500;\n            color: var(--text-secondary);\n            font-family: var(--font-mono);\n        }\n\n        .search-container {\n            position: relative;\n        }\n\n        .search-input {\n            background: var(--bg-panel);\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-md);\n            height: 36px;\n            padding: 0 12px 0 36px;\n            color: var(--text-primary);\n            width: 280px;\n            font-size: 13px;\n            transition: all 0.2s;\n            font-family: var(--font-ui);\n        }\n        .search-input:focus {\n            outline: none;\n            border-color: var(--accent-primary);\n            background: var(--bg-app);\n            box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);\n        }\n\n        .search-icon-abs {\n            position: absolute;\n            left: 12px;\n            top: 50%;\n            transform: translateY(-50%);\n            width: 16px;\n            height: 16px;\n            color: var(--text-tertiary);\n            pointer-events: none;\n        }\n\n\n        /* Main Content */\n        .main-content {\n            flex: 1;\n            display: flex;\n            flex-direction: column;\n            background: var(--bg-app);\n            overflow: hidden;\n        }\n\n        /* Skills List - Data Dense */\n        .skills-list {\n            padding: 16px 32px 32px 32px;\n            display: flex;\n            flex-direction: column;\n            gap: 12px;\n            overflow-y: auto;\n        }\n\n        .skill-card {\n            background: var(--bg-app);\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-lg);\n            padding: 16px 20px;\n            display: grid;\n            grid-template-columns: 48px 2fr 1.5fr auto;\n            gap: 20px;\n            align-items: center;\n            transition: all 0.2s;\n            position: relative;\n        }\n\n        .skill-card:hover {\n            border-color: var(--border-strong);\n            background: var(--bg-panel);\n            transform: translateY(-1px);\n            box-shadow: var(--shadow-sm);\n            z-index: 1;\n        }\n\n        .skill-icon {\n            width: 48px;\n            height: 48px;\n            background: var(--bg-element);\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-md);\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            color: var(--text-secondary);\n            font-size: 20px;\n            transition: all 0.2s;\n        }\n        .skill-card:hover .skill-icon {\n            color: var(--text-primary);\n            border-color: var(--border-strong);\n            background: #fff;\n        }\n\n        .skill-main {\n            display: flex;\n            flex-direction: column;\n            gap: 4px;\n            justify-content: center;\n        }\n\n        .skill-header-row {\n            display: flex;\n            align-items: center;\n            gap: 10px;\n        }\n\n        .skill-name {\n            font-weight: 600;\n            color: var(--text-primary);\n            font-size: 15px;\n            letter-spacing: -0.01em;\n        }\n\n        .skill-version-badge {\n            font-family: var(--font-mono);\n            font-size: 10px;\n            color: var(--text-secondary);\n            background: var(--bg-element);\n            padding: 2px 6px;\n            border-radius: 4px;\n            border: 1px solid var(--border-subtle);\n        }\n\n        .skill-meta-row {\n            display: flex;\n            align-items: center;\n            gap: 12px;\n            margin-top: 4px;\n        }\n\n        .skill-source {\n            font-family: var(--font-mono);\n            font-size: 11px;\n            color: var(--text-tertiary);\n            display: flex;\n            align-items: center;\n            gap: 6px;\n        }\n\n        .skill-status-col {\n            display: flex;\n            flex-direction: column;\n            gap: 6px;\n            justify-content: center;\n        }\n        \n        .status-label {\n            font-size: 11px;\n            font-weight: 500;\n            color: var(--text-tertiary);\n            text-transform: uppercase;\n            letter-spacing: 0.05em;\n        }\n\n        /* Discovery Styles */\n        .discovery-header {\n            display: flex;\n            align-items: center;\n            gap: 8px;\n            margin-bottom: 8px;\n            padding: 0 4px;\n        }\n        .discovery-title {\n            font-size: 12px;\n            font-weight: 600;\n            color: var(--text-secondary);\n            text-transform: uppercase;\n            letter-spacing: 0.05em;\n        }\n        .discovery-count {\n            background: var(--status-warning);\n            color: white;\n            font-size: 10px;\n            font-weight: 700;\n            padding: 1px 6px;\n            border-radius: 10px;\n        }\n        .skill-card.discovery {\n            background: #FFFBEB; /* Light yellow tint for discovery */\n            border-style: dashed;\n            border-color: #FCD34D;\n        }\n        .skill-card.discovery:hover {\n            border-color: var(--status-warning);\n            background: #FEF3C7;\n            border-style: solid;\n        }\n\n        /* Tool Matrix - Optimized for Density */\n        .tool-matrix {\n            display: flex;\n            align-items: center;\n            gap: 6px;\n            flex-wrap: wrap;\n        }\n\n        .tool-pill {\n            height: 22px;\n            padding: 0 8px;\n            border-radius: 4px;\n            font-size: 11px;\n            font-weight: 500;\n            display: flex;\n            align-items: center;\n            gap: 6px;\n            cursor: pointer;\n            transition: all 0.2s;\n            border: 1px solid var(--border-subtle);\n            font-family: var(--font-ui);\n            background: #fff;\n            color: var(--text-secondary);\n        }\n\n        /* Active Tool State - High Contrast */\n        .tool-pill.active {\n            background: #fff;\n            color: var(--status-success);\n            border-color: #d1fae5;\n        }\n        .tool-pill.active .status-badge {\n            background: var(--status-success);\n            box-shadow: 0 0 0 2px #d1fae5;\n        }\n        \n        .tool-pill.active:hover {\n            border-color: var(--status-success);\n            transform: translateY(-1px);\n        }\n\n        /* Inactive Tool State */\n        .tool-pill.inactive {\n            background: var(--bg-element);\n            color: var(--text-tertiary);\n            border-color: transparent;\n        }\n\n        .tool-pill.inactive:hover {\n            color: var(--text-secondary);\n            background: var(--bg-element-hover);\n        }\n        \n        /* Not Installed / Unknown State */\n        .tool-pill.disabled {\n            opacity: 0.5;\n            cursor: not-allowed;\n            border: 1px dashed var(--border-strong);\n            background: transparent;\n            color: var(--text-tertiary);\n        }\n\n        .status-badge {\n            width: 6px;\n            height: 6px;\n            border-radius: 50%;\n            background: var(--text-tertiary);\n        }\n\n        .skill-actions-col {\n            display: flex;\n            flex-direction: row;\n            align-items: center;\n            gap: 8px;\n            align-self: center;\n        }\n\n        /* Card Action Buttons */\n        .card-btn {\n            height: 32px;\n            width: 32px;\n            padding: 0;\n            border-radius: var(--radius-md);\n            background: transparent;\n            color: var(--text-tertiary);\n            border: 1px solid transparent;\n            cursor: pointer;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            gap: 8px;\n            transition: all 0.2s;\n        }\n        .card-btn:hover {\n            color: var(--text-primary);\n            background: var(--bg-element);\n            border-color: var(--border-subtle);\n            transform: translateY(-1px);\n            box-shadow: var(--shadow-sm);\n        }\n        .card-btn.primary-action {\n            color: var(--accent-primary);\n            background: #eff6ff;\n            border-color: #dbeafe;\n        }\n        .card-btn.primary-action:hover {\n            background: var(--accent-primary);\n            color: #fff;\n            border-color: var(--accent-primary);\n        }\n        .card-btn.danger-action:hover {\n            color: var(--status-error);\n            background: #fef2f2;\n            border-color: #fee2e2;\n        }\n\n\n        /* Modal Overlay */\n        .modal-showcase {\n            display: flex;\n            gap: 40px;\n            justify-content: center;\n            flex-wrap: wrap;\n            width: 1000px;\n        }\n\n        .modal-window {\n            width: 480px;\n            background: var(--bg-app);\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-lg);\n            box-shadow: var(--shadow-lg);\n            overflow: hidden;\n            position: relative;\n            animation: modalFadeIn 0.2s ease-out;\n        }\n\n        @keyframes modalFadeIn {\n            from { opacity: 0; transform: translateY(10px); }\n            to { opacity: 1; transform: translateY(0); }\n        }\n\n        .modal-header {\n            padding: 20px 24px;\n            border-bottom: 1px solid var(--border-subtle);\n            display: flex;\n            justify-content: space-between;\n            align-items: center;\n        }\n        .modal-title { font-weight: 600; font-size: 16px; color: var(--text-primary); }\n        .modal-close { color: var(--text-tertiary); cursor: pointer; transition: color 0.2s; }\n        .modal-close:hover { color: var(--text-primary); }\n\n        .modal-body {\n            padding: 24px;\n        }\n\n        /* Onboarding Styles */\n        .onboarding-list {\n            display: flex;\n            flex-direction: column;\n            gap: 10px;\n            margin-bottom: 24px;\n        }\n\n        .import-item {\n            display: flex;\n            align-items: flex-start;\n            gap: 14px;\n            padding: 14px;\n            background: var(--bg-panel);\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-md);\n            transition: border-color 0.2s;\n        }\n        .import-item:hover { border-color: var(--border-strong); }\n\n        .checkbox {\n            width: 18px;\n            height: 18px;\n            border: 1px solid var(--text-tertiary);\n            border-radius: 4px;\n            margin-top: 2px;\n            display: grid;\n            place-items: center;\n            cursor: pointer;\n            transition: all 0.2s;\n        }\n        .checkbox.checked {\n            background: var(--accent-primary);\n            border-color: var(--accent-primary);\n        }\n        .checkbox.checked::after {\n            content: \"✓\";\n            color: #fff;\n            font-size: 12px;\n            font-weight: bold;\n        }\n\n        .import-details {\n            flex: 1;\n        }\n        .import-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; color: var(--text-primary); }\n        .import-path { font-family: var(--font-mono); font-size: 12px; color: var(--text-tertiary); word-break: break-all; }\n        \n        .conflict-badge {\n            font-size: 11px;\n            color: var(--status-warning);\n            background: #fffbeb;\n            padding: 2px 8px;\n            border-radius: 4px;\n            margin-top: 8px;\n            display: inline-block;\n            border: 1px solid #fcd34d;\n        }\n\n        /* Tabs */\n        .tabs {\n            display: flex;\n            gap: 24px;\n            border-bottom: 1px solid var(--border-subtle);\n            margin-bottom: 24px;\n        }\n        .tab-item {\n            padding-bottom: 10px;\n            font-size: 14px;\n            color: var(--text-secondary);\n            cursor: pointer;\n            border-bottom: 2px solid transparent;\n            transition: all 0.2s;\n        }\n        .tab-item:hover { color: var(--text-primary); }\n        .tab-item.active {\n            color: var(--accent-primary);\n            border-color: var(--accent-primary);\n            font-weight: 500;\n        }\n\n        /* Form Elements */\n        .form-group {\n            margin-bottom: 20px;\n        }\n        .label {\n            display: block;\n            font-size: 13px;\n            font-weight: 500;\n            color: var(--text-secondary);\n            margin-bottom: 8px;\n        }\n        .input {\n            width: 100%;\n            background: var(--bg-app);\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-md);\n            padding: 10px 14px;\n            color: var(--text-primary);\n            font-family: var(--font-mono);\n            font-size: 13px;\n            transition: all 0.2s;\n        }\n        .input:focus {\n            border-color: var(--accent-primary);\n            outline: none;\n            box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);\n        }\n\n        .helper-text {\n            font-size: 12px;\n            color: var(--text-tertiary);\n            margin-top: 6px;\n        }\n        \n        /* Settings List */\n        .settings-list {\n            display: flex;\n            flex-direction: column;\n        }\n        \n        .setting-item {\n            display: flex;\n            align-items: center;\n            justify-content: space-between;\n            padding: 16px 0;\n            border-bottom: 1px solid var(--border-subtle);\n        }\n        .setting-item:last-child { border-bottom: none; }\n        \n        .setting-info { flex: 1; margin-right: 20px; }\n        .setting-label { font-size: 14px; font-weight: 500; color: var(--text-primary); }\n        .setting-desc { font-size: 12px; color: var(--text-tertiary); margin-top: 4px; line-height: 1.4; }\n        \n        .toggle {\n            width: 44px;\n            height: 24px;\n            background: var(--bg-element);\n            border-radius: 12px;\n            position: relative;\n            cursor: pointer;\n            transition: background 0.2s;\n            border: 1px solid var(--border-subtle);\n        }\n        .toggle.checked { background: var(--status-success); border-color: var(--status-success); }\n        .toggle-knob {\n            width: 18px;\n            height: 18px;\n            background: white;\n            border-radius: 50%;\n            position: absolute;\n            top: 2px;\n            left: 2px;\n            transition: transform 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);\n            box-shadow: 0 1px 2px rgba(0,0,0,0.2);\n        }\n        .toggle.checked .toggle-knob { transform: translateX(20px); }\n\n        .modal-footer {\n            padding: 20px 24px;\n            border-top: 1px solid var(--border-subtle);\n            display: flex;\n            justify-content: flex-end;\n            gap: 12px;\n            background: var(--bg-panel);\n        }\n        \n        .modal-footer.space-between {\n            justify-content: space-between;\n        }\n        \n        /* Loading States */\n        .loader-spinner {\n            width: 24px;\n            height: 24px;\n            border: 2px solid var(--border-subtle);\n            border-top-color: var(--accent-primary);\n            border-radius: 50%;\n            animation: spin 0.8s linear infinite;\n        }\n        \n        @keyframes spin {\n            to { transform: rotate(360deg); }\n        }\n\n        .progress-bar {\n            height: 6px;\n            background: var(--bg-element);\n            border-radius: 3px;\n            width: 100%;\n            overflow: hidden;\n            margin-top: 12px;\n        }\n        .progress-fill {\n            height: 100%;\n            background: var(--accent-primary);\n            width: 60%;\n            border-radius: 3px;\n            animation: shimmer 1.5s infinite linear;\n            background: linear-gradient(90deg, var(--accent-primary) 0%, #60A5FA 50%, var(--accent-primary) 100%);\n            background-size: 200% 100%;\n        }\n        \n        @keyframes shimmer {\n            0% { background-position: 100% 0; }\n            100% { background-position: -100% 0; }\n        }\n        \n        /* Loading Modal Content */\n        .loading-content {\n            display: flex;\n            flex-direction: column;\n            align-items: center;\n            justify-content: center;\n            padding: 40px 20px;\n            text-align: center;\n        }\n        .loading-text {\n            margin-top: 20px;\n            font-size: 15px;\n            font-weight: 500;\n            color: var(--text-primary);\n        }\n        .loading-subtext {\n            margin-top: 8px;\n            font-size: 13px;\n            color: var(--text-tertiary);\n            font-family: var(--font-mono);\n        }\n\n    </style>\n</head>\n<body>\n\n    <!-- 1. MAIN WINDOW DASHBOARD -->\n    <div style=\"position: relative;\">\n        <div class=\"design-label\">01. Dashboard</div>\n        <div class=\"window-frame\">\n            <div class=\"title-bar\">\n                <div class=\"traffic-lights\">\n                    <div class=\"traffic-light close\"></div>\n                    <div class=\"traffic-light minimize\"></div>\n                    <div class=\"traffic-light maximize\"></div>\n                </div>\n            </div>\n            \n            <div class=\"app-layout\">\n                <!-- Header -->\n                <header class=\"app-header\">\n                    <div class=\"brand-area\">\n                        <img class=\"logo-icon\" src=\"../public/logo.png\" alt=\"\" />\n                        <div class=\"brand-text\">Skills Hub</div>\n                    </div>\n\n                    <div class=\"header-actions\">\n                        <!-- Language Switcher -->\n                        <button class=\"lang-btn\">\n                            <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n                            EN\n                        </button>\n\n                        <!-- Settings Icon -->\n                        <div class=\"icon-btn-header\" title=\"Settings\">\n                            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                                <circle cx=\"12\" cy=\"12\" r=\"3\"></circle>\n                                <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>\n                            </svg>\n                        </div>\n\n                        <!-- New Skill Button -->\n                        <button class=\"btn btn-primary\">\n                            <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\">\n                                <line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"></line>\n                                <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line>\n                            </svg>\n                            New Skill\n                        </button>\n                    </div>\n                </header>\n\n                <!-- Content -->\n                <main class=\"main-content\">\n                    \n                    <!-- Filter/Search Bar Area -->\n                    <div class=\"filter-bar\">\n                        <div class=\"filter-title\">All Skills</div>\n                        <div style=\"display: flex; gap: 12px;\">\n                             <!-- Sort Simulation -->\n                            <button class=\"btn btn-secondary\" style=\"height: 36px; padding: 0 12px; font-weight: normal;\">\n                                <span style=\"color: var(--text-tertiary); margin-right: 4px;\">Sort:</span> Recently Updated\n                                <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" style=\"margin-left: 4px;\"><path d=\"M6 9l6 6 6-6\"/></svg>\n                            </button>\n                            <div class=\"search-container\">\n                                <svg class=\"search-icon-abs\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                                    <circle cx=\"11\" cy=\"11\" r=\"8\"></circle>\n                                    <line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line>\n                                </svg>\n                                <input type=\"text\" class=\"search-input\" placeholder=\"Search skills...\" />\n                            </div>\n                        </div>\n                    </div>\n\n                    <div class=\"skills-list\">\n                        \n                        <!-- Discovered Section Banner -->\n                        <div class=\"discovery-banner\" style=\"display: flex; align-items: center; justify-content: space-between; background: #FFFBEB; border: 1px dashed #FCD34D; border-radius: var(--radius-lg); padding: 12px 20px; margin-bottom: 20px;\">\n                            <div style=\"display: flex; align-items: center; gap: 12px;\">\n                                <div style=\"width: 32px; height: 32px; background: rgba(245, 158, 11, 0.1); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; color: var(--status-warning);\">\n                                    <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z\"></path></svg>\n                                </div>\n                                <div>\n                                    <div style=\"font-weight: 600; color: var(--text-primary); font-size: 14px;\">3 New Skills Discovered</div>\n                                    <div style=\"font-size: 12px; color: var(--text-secondary);\">Found unmanaged skills in your local tools (Cursor, VS Code).</div>\n                                </div>\n                            </div>\n                            <button class=\"btn btn-primary\" style=\"background: var(--status-warning); border-color: var(--status-warning); color: #fff; height: 32px;\">Review & Import</button>\n                        </div>\n\n                        <!-- Skill Card 1: Git Source -->\n                        <div class=\"skill-card\">\n                            <div class=\"skill-icon\">\n                                <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22\"></path></svg>\n                            </div>\n                            \n                            <!-- Main Info -->\n                            <div class=\"skill-main\">\n                                <div class=\"skill-header-row\">\n                                    <div class=\"skill-name\">frontend-design</div>\n                                    <div class=\"skill-version-badge\">v2.1.0</div>\n                                </div>\n                                <div class=\"skill-meta-row\">\n                                    <div class=\"skill-source\">\n                                        <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22\"></path></svg>\n                                        vercel-labs/ai-sdk\n                                    </div>\n                                    <div class=\"skill-source\">\n                                        <span style=\"color: var(--text-tertiary);\">•</span> Updated 2h ago\n                                    </div>\n                                </div>\n                            </div>\n                            \n                            <!-- Status & Tools -->\n                            <div class=\"skill-status-col\">\n                                <div class=\"status-label\">Active Tools</div>\n                                <div class=\"tool-matrix\">\n                                    <div class=\"tool-pill active\" title=\"Synced\"><div class=\"status-badge\"></div> Cursor</div>\n                                    <div class=\"tool-pill active\" title=\"Synced\"><div class=\"status-badge\"></div> Claude</div>\n                                    <div class=\"tool-pill inactive\" title=\"Click to sync\">VS Code</div>\n                                </div>\n                            </div>\n\n                            <!-- Actions -->\n                            <div class=\"skill-actions-col\">\n                                <button class=\"card-btn primary-action\" title=\"Sync / Update\">\n                                    <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                                        <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"></path>\n                                        <path d=\"M8 16H3v5\"></path>\n                                        <path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"></path>\n                                        <path d=\"M16 8h5V3\"></path>\n                                    </svg>\n                                </button>\n                                <button class=\"card-btn danger-action\" title=\"Delete\">\n                                    <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M3 6h18\"></path><path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\"></path><path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\"></path></svg>\n                                </button>\n                            </div>\n                        </div>\n\n                        <!-- Skill Card 2: Local Source -->\n                        <div class=\"skill-card\">\n                            <div class=\"skill-icon\">\n                                <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"></path></svg>\n                            </div>\n                            \n                            <!-- Main Info -->\n                            <div class=\"skill-main\">\n                                <div class=\"skill-header-row\">\n                                    <div class=\"skill-name\">company-utils</div>\n                                    <div class=\"skill-version-badge\">local</div>\n                                </div>\n                                <div class=\"skill-meta-row\">\n                                    <div class=\"skill-source\">\n                                        <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"></path></svg>\n                                        ~/dev/company/skills/utils\n                                    </div>\n                                    <div class=\"skill-source\">\n                                        <span style=\"color: var(--text-tertiary);\">•</span> Edited just now\n                                    </div>\n                                </div>\n                            </div>\n                            \n                            <!-- Status & Tools -->\n                            <div class=\"skill-status-col\">\n                                <div class=\"status-label\">Active Tools</div>\n                                <div class=\"tool-matrix\">\n                                    <div class=\"tool-pill active\"><div class=\"status-badge\"></div> Cursor</div>\n                                    <div class=\"tool-pill disabled\">Windsurf</div>\n                                </div>\n                            </div>\n\n                            <!-- Actions -->\n                            <div class=\"skill-actions-col\">\n                                <button class=\"card-btn primary-action\" title=\"Sync / Update\">\n                                    <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                                        <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"></path>\n                                        <path d=\"M8 16H3v5\"></path>\n                                        <path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"></path>\n                                        <path d=\"M16 8h5V3\"></path>\n                                    </svg>\n                                </button>\n                                <button class=\"card-btn danger-action\" title=\"Delete\">\n                                    <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M3 6h18\"></path><path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\"></path><path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\"></path></svg>\n                                </button>\n                            </div>\n                        </div>\n\n                    </div>\n                </main>\n            </div>\n        </div>\n    </div>\n\n    <!-- 2. ADD SKILL FLOW (Replaces previous modal section) -->\n    <div class=\"modal-showcase\">\n        \n        <!-- Step 1: Add Skill (Simplified) -->\n        <div style=\"position: relative;\">\n            <div class=\"design-label\">02. Add Skill</div>\n            <div class=\"modal-window\">\n                <div class=\"modal-header\">\n                    <div class=\"modal-title\">Add New Skill</div>\n                    <div class=\"modal-close\">✕</div>\n                </div>\n                <div class=\"modal-body\">\n                    <div class=\"tabs\">\n                        <div class=\"tab-item\">Local Folder</div>\n                        <div class=\"tab-item active\">Git Repository</div>\n                        <div class=\"tab-item\">Search</div>\n                    </div>\n\n                    <div class=\"form-group\">\n                        <label class=\"label\">Repository URL</label>\n                        <input type=\"text\" class=\"input\" value=\"https://github.com/cntrl-b/mono-skills\" />\n                    </div>\n\n                    <div class=\"form-group\" style=\"margin-top: 20px;\">\n                        <label class=\"label\">Install to Tools</label>\n                        <div class=\"tool-matrix\" style=\"gap: 8px;\">\n                            <!-- Simulated installed tools (selected by default) -->\n                            <div class=\"tool-pill active\" style=\"cursor: pointer;\">\n                                <div class=\"status-badge\"></div> Cursor\n                            </div>\n                            <div class=\"tool-pill active\" style=\"cursor: pointer;\">\n                                <div class=\"status-badge\"></div> Claude\n                            </div>\n                            <div class=\"tool-pill active\" style=\"cursor: pointer;\">\n                                <div class=\"status-badge\"></div> VS Code\n                            </div>\n                            <!-- Simulated uninstalled tool -->\n                            <div class=\"tool-pill disabled\" title=\"Not installed\">\n                                Windsurf\n                            </div>\n                        </div>\n                        <div class=\"helper-text\">Selected tools will sync immediately after installation.</div>\n                    </div>\n                </div>\n                <div class=\"modal-footer\">\n                    <button class=\"btn btn-secondary\">Cancel</button>\n                    <button class=\"btn btn-primary\">Install</button>\n                </div>\n            </div>\n        </div>\n\n        <!-- Step 2: Multi-Skill Selection -->\n        <div style=\"position: relative;\">\n            <div class=\"design-label\">03. Select Skills from Repo</div>\n            <div class=\"modal-window\">\n                <div class=\"modal-header\">\n                    <div class=\"modal-title\">Select Skills to Install</div>\n                    <div class=\"modal-close\">✕</div>\n                </div>\n                <div class=\"modal-body\">\n                    <p class=\"label\" style=\"margin-bottom: 12px; color: var(--text-primary);\">\n                        We found multiple skills in this repository. Select the ones you want to install:\n                    </p>\n                    \n                    <div class=\"onboarding-list\" style=\"max-height: 200px; overflow-y: auto;\">\n                        <div class=\"import-item\">\n                            <div class=\"checkbox checked\"></div>\n                            <div class=\"import-details\">\n                                <div class=\"import-name\">react-expert</div>\n                                <div class=\"import-path\">/packages/react-expert</div>\n                            </div>\n                        </div>\n                        <div class=\"import-item\">\n                            <div class=\"checkbox\"></div>\n                            <div class=\"import-details\">\n                                <div class=\"import-name\">python-data</div>\n                                <div class=\"import-path\">/packages/python-data</div>\n                            </div>\n                        </div>\n                         <div class=\"import-item\">\n                            <div class=\"checkbox checked\"></div>\n                            <div class=\"import-details\">\n                                <div class=\"import-name\">git-workflow</div>\n                                <div class=\"import-path\">/packages/git-workflow</div>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"modal-footer\">\n                    <button class=\"btn btn-secondary\">Cancel</button>\n                    <button class=\"btn btn-primary\">Install Selected (2)</button>\n                </div>\n            </div>\n        </div>\n\n        <!-- Step 3: Loading State -->\n        <div style=\"position: relative;\">\n            <div class=\"design-label\">04. Installing / Loading</div>\n            <div class=\"modal-window\" style=\"height: 200px;\">\n                <div class=\"loading-content\">\n                    <div class=\"loader-spinner\"></div>\n                    <div class=\"loading-text\">Installing Skills...</div>\n                    <div class=\"loading-subtext\">Cloning repository and syncing to tools</div>\n                    \n                    <div style=\"width: 200px; margin-top: 12px;\">\n                        <div class=\"progress-bar\">\n                            <div class=\"progress-fill\"></div>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n\n    </div>\n\n    <!-- 3. OTHER MODALS -->\n    <div class=\"modal-showcase\" style=\"margin-top: 60px;\">\n        <!-- Settings Modal (Cleaned up) -->\n        <div style=\"position: relative;\">\n            <div class=\"design-label\">05. Settings Modal (I18n)</div>\n            <div class=\"modal-window\" style=\"width: 540px;\">\n                <div class=\"modal-header\">\n                    <div class=\"modal-title\">Settings</div>\n                    <div class=\"modal-close\">✕</div>\n                </div>\n                <div class=\"modal-body\">\n                    \n                    <!-- Language Setting -->\n                    <div class=\"form-group\" style=\"margin-bottom: 24px;\">\n                        <label class=\"label\">Interface Language</label>\n                        <div style=\"position: relative;\">\n                            <select class=\"input\" style=\"appearance: none; cursor: pointer; font-family: var(--font-ui);\">\n                                <option value=\"en\" selected>English</option>\n                                <option value=\"zh\">中文 (Chinese)</option>\n                            </select>\n                            <svg style=\"position: absolute; right: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--text-tertiary);\" width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M6 9l6 6 6-6\"/></svg>\n                        </div>\n                    </div>\n\n                    <div class=\"form-group\" style=\"margin-top: 8px;\">\n                        <label class=\"label\">Skills Storage Path</label>\n                        <div style=\"display: flex; gap: 8px;\">\n                            <input type=\"text\" class=\"input\" value=\"/Users/username/.skills-hub/storage\" readonly />\n                            <button class=\"btn btn-secondary\">Browse</button>\n                        </div>\n                        <div class=\"helper-text\">Local copies of Git skills will be stored here.</div>\n                    </div>\n                    \n                    <h3 style=\"font-size: 13px; font-weight: 600; color: var(--text-primary); margin: 24px 0 12px 0;\">Updates & Maintenance</h3>\n                    <div class=\"settings-list\">\n                        <div class=\"setting-item\">\n                            <div class=\"setting-info\">\n                                <div class=\"setting-label\">Auto-update Skills</div>\n                                <div class=\"setting-desc\">Automatically pull changes for Git skills every 24h</div>\n                            </div>\n                            <div class=\"toggle checked\"><div class=\"toggle-knob\"></div></div>\n                        </div>\n                        <div class=\"setting-item\">\n                            <div class=\"setting-info\">\n                                <div class=\"setting-label\">Storage Cleanup</div>\n                                <div class=\"setting-desc\">Clean up temporary download files (240MB)</div>\n                            </div>\n                            <button class=\"btn btn-secondary\" style=\"height: 28px;\">Clean Now</button>\n                        </div>\n                    </div>\n                    <div style=\"margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border-subtle); text-align: center;\">\n                         <div style=\"font-size: 12px; color: var(--text-tertiary);\">Skills Hub v1.0.0</div>\n                    </div>\n                </div>\n                <div class=\"modal-footer\">\n                    <button class=\"btn btn-primary\" style=\"width: 100%;\">Done</button>\n                </div>\n            </div>\n        </div>\n\n        <!-- Delete Confirmation Modal -->\n        <div style=\"position: relative;\">\n            <div class=\"design-label\">06. Delete Confirmation</div>\n            <div class=\"modal-window\" style=\"width: 400px;\">\n                <div class=\"modal-header\" style=\"border-bottom: none; padding-bottom: 0;\">\n                    <div class=\"modal-title\" style=\"color: var(--status-error); display: flex; align-items: center; gap: 8px;\">\n                        <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"></path><line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"13\"></line><line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"></line></svg>\n                        Delete Skill?\n                    </div>\n                </div>\n                <div class=\"modal-body\">\n                    <p style=\"font-size: 14px; color: var(--text-secondary); line-height: 1.5; margin-bottom: 16px;\">\n                        Are you sure you want to delete <strong style=\"color: var(--text-primary);\">frontend-design</strong>?\n                    </p>\n                    <div style=\"background: rgba(220, 38, 38, 0.05); border: 1px solid rgba(220, 38, 38, 0.1); border-radius: var(--radius-md); padding: 12px;\">\n                        <ul style=\"list-style: none; font-size: 12px; color: var(--text-secondary);\">\n                            <li style=\"display: flex; gap: 8px; margin-bottom: 4px;\"><span style=\"color: var(--status-error);\">•</span> Remove from <strong style=\"color: var(--text-primary);\">Cursor</strong>, <strong style=\"color: var(--text-primary);\">Claude</strong></li>\n                            <li style=\"display: flex; gap: 8px;\"><span style=\"color: var(--status-error);\">•</span> Delete local copy from Hub</li>\n                        </ul>\n                    </div>\n                </div>\n                <div class=\"modal-footer space-between\">\n                    <button class=\"btn btn-secondary\">Cancel</button>\n                    <button class=\"btn btn-danger-solid\">Yes, Delete</button>\n                </div>\n            </div>\n        </div>\n\n        <!-- Import Discovered Skills Modal -->\n        <div style=\"position: relative;\">\n            <div class=\"design-label\">07. Import Discovered</div>\n            <div class=\"modal-window\" style=\"width: 500px;\">\n                <div class=\"modal-header\">\n                    <div class=\"modal-title\">Import Discovered Skills</div>\n                    <div class=\"modal-close\">✕</div>\n                </div>\n                <div class=\"modal-body\">\n                    <p class=\"label\" style=\"margin-bottom: 12px; color: var(--text-secondary);\">\n                        Select skills to take over and manage in Hub.\n                    </p>\n                    \n                    <div class=\"onboarding-list\" style=\"max-height: 250px; overflow-y: auto;\">\n                        <div class=\"import-item\">\n                            <div class=\"checkbox checked\"></div>\n                            <div class=\"import-details\">\n                                <div class=\"import-name\">legacy-chat-skills</div>\n                                <div class=\"import-path\" style=\"margin-bottom: 4px;\">~/.cursor/extensions/cursor-skills/legacy</div>\n                                <div style=\"display: flex; gap: 8px; align-items: center;\">\n                                    <div class=\"tool-pill active\" style=\"height: 18px; font-size: 10px; border-color: rgba(245, 158, 11, 0.3); background: rgba(245, 158, 11, 0.05); color: var(--status-warning);\">\n                                        Found in Cursor\n                                    </div>\n                                </div>\n                            </div>\n                        </div>\n                        <div class=\"import-item\">\n                            <div class=\"checkbox checked\"></div>\n                            <div class=\"import-details\">\n                                <div class=\"import-name\">python-snippets</div>\n                                <div class=\"import-path\" style=\"margin-bottom: 4px;\">~/.vscode/extensions/python-snippets</div>\n                                <div style=\"display: flex; gap: 8px; align-items: center;\">\n                                    <div class=\"tool-pill active\" style=\"height: 18px; font-size: 10px; border-color: rgba(245, 158, 11, 0.3); background: rgba(245, 158, 11, 0.05); color: var(--status-warning);\">\n                                        Found in VS Code\n                                    </div>\n                                </div>\n                            </div>\n                        </div>\n                        <div class=\"import-item\">\n                            <div class=\"checkbox\"></div>\n                            <div class=\"import-details\">\n                                <div class=\"import-name\">random-utils</div>\n                                <div class=\"import-path\" style=\"margin-bottom: 4px;\">~/Downloads/random-utils</div>\n                                <div style=\"display: flex; gap: 8px; align-items: center;\">\n                                    <div class=\"tool-pill active\" style=\"height: 18px; font-size: 10px; border-color: rgba(245, 158, 11, 0.3); background: rgba(245, 158, 11, 0.05); color: var(--status-warning);\">\n                                        Found in Cursor\n                                    </div>\n                                    <span style=\"font-size: 10px; color: var(--text-tertiary);\">Duplicate?</span>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"modal-footer\">\n                    <button class=\"btn btn-secondary\">Ignore All</button>\n                    <button class=\"btn btn-primary\">Import Selected (2)</button>\n                </div>\n            </div>\n        </div>\n    </div>\n\n</body>\n</html>\n"
  },
  {
    "path": "docs/skills_hub_v2_design.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Skills Hub V2 - Design Preview</title>\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n    <link href=\"https://fonts.googleapis.com/css2?family=Fira+Sans:wght@300;400;500;600;700&family=Fira+Code:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n    <style>\n        :root {\n            --bg-app: #ffffff;\n            --bg-panel: #fcfcfc;\n            --bg-element: #f4f4f5;\n            --bg-element-hover: #e4e4e7;\n            --border-subtle: #e4e4e7;\n            --border-strong: #d4d4d8;\n            --text-primary: #18181b;\n            --text-secondary: #52525b;\n            --text-tertiary: #a1a1aa;\n            --accent-primary: #2563EB;\n            --accent-primary-hover: #1D4ED8;\n            --accent-primary-fg: #FFFFFF;\n            --accent-primary-light: #EFF6FF;\n            --accent-primary-border: #BFDBFE;\n            --status-success: #059669;\n            --status-success-light: #ECFDF5;\n            --status-success-border: #A7F3D0;\n            --status-warning: #d97706;\n            --status-warning-light: #FFFBEB;\n            --status-error: #dc2626;\n            --status-info: #2563EB;\n            --font-ui: 'Fira Sans', system-ui, -apple-system, sans-serif;\n            --font-mono: 'Fira Code', monospace;\n            --radius-sm: 4px;\n            --radius-md: 8px;\n            --radius-lg: 12px;\n            --radius-xl: 16px;\n            --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);\n            --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.05);\n            --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.03);\n        }\n\n        * { box-sizing: border-box; margin: 0; padding: 0; }\n\n        body {\n            background-color: #e5e5e5;\n            color: var(--text-primary);\n            font-family: var(--font-ui);\n            padding: 40px;\n            display: flex;\n            flex-direction: column;\n            align-items: center;\n            gap: 60px;\n            min-height: 100vh;\n            -webkit-font-smoothing: antialiased;\n        }\n\n        /* ===== Design Labels ===== */\n        .design-label {\n            position: absolute;\n            top: -30px;\n            left: 0;\n            font-family: var(--font-mono);\n            font-size: 12px;\n            color: var(--text-secondary);\n            text-transform: uppercase;\n            letter-spacing: 0.1em;\n        }\n\n        /* ===== Window Frame ===== */\n        .window-frame {\n            position: relative;\n            width: 1000px;\n            height: 700px;\n            background: var(--bg-app);\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-lg);\n            box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15);\n            display: flex;\n            flex-direction: column;\n            overflow: hidden;\n        }\n\n        .title-bar {\n            height: 40px;\n            background: var(--bg-app);\n            border-bottom: 1px solid var(--border-subtle);\n            display: flex;\n            align-items: center;\n            padding: 0 16px;\n            flex-shrink: 0;\n        }\n        .traffic-lights { display: flex; gap: 8px; }\n        .traffic-light { width: 12px; height: 12px; border-radius: 50%; }\n        .traffic-light.close { background: #ff5f56; border: 1px solid rgba(0,0,0,0.1); }\n        .traffic-light.minimize { background: #ffbd2e; border: 1px solid rgba(0,0,0,0.1); }\n        .traffic-light.maximize { background: #27c93f; border: 1px solid rgba(0,0,0,0.1); }\n\n        /* ===== App Header ===== */\n        .app-header {\n            height: 56px;\n            padding: 0 24px;\n            display: flex;\n            align-items: center;\n            justify-content: space-between;\n            border-bottom: 1px solid var(--border-subtle);\n            background: var(--bg-app);\n            flex-shrink: 0;\n            z-index: 10;\n        }\n        .header-left {\n            display: flex;\n            align-items: center;\n            gap: 24px;\n        }\n        .brand-area {\n            display: flex;\n            align-items: center;\n            gap: 10px;\n        }\n        .brand-icon {\n            width: 28px;\n            height: 28px;\n            background: linear-gradient(135deg, #2563EB, #7C3AED);\n            border-radius: var(--radius-md);\n            display: flex;\n            align-items: center;\n            justify-content: center;\n        }\n        .brand-icon svg { width: 16px; height: 16px; color: white; }\n        .brand-text {\n            font-weight: 700;\n            font-size: 17px;\n            letter-spacing: -0.03em;\n            color: var(--text-primary);\n        }\n\n        /* ===== Nav Tabs (in header) ===== */\n        .nav-tabs {\n            display: flex;\n            align-items: center;\n            gap: 4px;\n            height: 100%;\n        }\n        .nav-tab {\n            height: 100%;\n            display: flex;\n            align-items: center;\n            gap: 6px;\n            padding: 0 14px;\n            font-size: 13px;\n            font-weight: 500;\n            color: var(--text-tertiary);\n            cursor: pointer;\n            border: none;\n            background: none;\n            position: relative;\n            transition: color 0.2s;\n            font-family: var(--font-ui);\n        }\n        .nav-tab:hover { color: var(--text-secondary); }\n        .nav-tab.active {\n            color: var(--accent-primary);\n            font-weight: 600;\n        }\n        .nav-tab.active::after {\n            content: '';\n            position: absolute;\n            bottom: 0;\n            left: 8px;\n            right: 8px;\n            height: 2px;\n            background: var(--accent-primary);\n            border-radius: 2px 2px 0 0;\n        }\n        .nav-tab svg { width: 16px; height: 16px; }\n\n        .header-actions {\n            display: flex;\n            align-items: center;\n            gap: 10px;\n        }\n        .lang-btn {\n            font-size: 12px;\n            font-weight: 500;\n            color: var(--text-secondary);\n            cursor: pointer;\n            padding: 5px 10px;\n            border-radius: var(--radius-sm);\n            background: transparent;\n            border: 1px solid var(--border-subtle);\n            font-family: var(--font-mono);\n            transition: all 0.2s;\n        }\n        .lang-btn:hover {\n            color: var(--text-primary);\n            background: var(--bg-element);\n        }\n        .icon-btn {\n            width: 32px;\n            height: 32px;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            border-radius: var(--radius-md);\n            color: var(--text-secondary);\n            cursor: pointer;\n            transition: all 0.2s;\n            border: none;\n            background: none;\n        }\n        .icon-btn:hover {\n            background: var(--bg-element);\n            color: var(--text-primary);\n        }\n        .icon-btn svg { width: 18px; height: 18px; }\n\n        /* ===== Shared ===== */\n        .app-body { flex: 1; display: flex; flex-direction: column; overflow: hidden; }\n\n        .btn {\n            height: 32px;\n            padding: 0 14px;\n            border-radius: var(--radius-md);\n            font-size: 13px;\n            font-weight: 500;\n            cursor: pointer;\n            display: inline-flex;\n            align-items: center;\n            gap: 6px;\n            border: 1px solid transparent;\n            transition: all 0.2s;\n            font-family: var(--font-ui);\n            white-space: nowrap;\n        }\n        .btn svg { width: 14px; height: 14px; }\n        .btn-primary {\n            background: var(--accent-primary);\n            color: var(--accent-primary-fg);\n        }\n        .btn-primary:hover {\n            background: var(--accent-primary-hover);\n        }\n        .btn-secondary {\n            background: transparent;\n            border-color: var(--border-subtle);\n            color: var(--text-primary);\n        }\n        .btn-secondary:hover {\n            border-color: var(--border-strong);\n            background: var(--bg-element);\n        }\n        .btn-ghost {\n            background: transparent;\n            color: var(--text-secondary);\n            border: none;\n            padding: 0 8px;\n        }\n        .btn-ghost:hover { color: var(--text-primary); background: var(--bg-element); }\n        .btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }\n        .btn-install {\n            background: var(--accent-primary);\n            color: white;\n            height: 30px;\n            padding: 0 14px;\n            font-size: 12px;\n            font-weight: 600;\n            border-radius: var(--radius-md);\n            border: none;\n            cursor: pointer;\n            transition: all 0.2s;\n            font-family: var(--font-ui);\n        }\n        .btn-install:hover { background: var(--accent-primary-hover); }\n        .btn-installed {\n            background: var(--status-success-light);\n            color: var(--status-success);\n            height: 30px;\n            padding: 0 14px;\n            font-size: 12px;\n            font-weight: 600;\n            border-radius: var(--radius-md);\n            border: 1px solid var(--status-success-border);\n            cursor: default;\n            font-family: var(--font-ui);\n        }\n\n        /* ============================================\n           VIEW 1: MY SKILLS\n           ============================================ */\n        .view-myskills { flex: 1; display: flex; flex-direction: column; overflow: hidden; }\n\n        /* Sub-header / toolbar */\n        .toolbar {\n            padding: 16px 24px;\n            display: flex;\n            align-items: center;\n            justify-content: space-between;\n            flex-shrink: 0;\n        }\n        .toolbar-left {\n            display: flex;\n            align-items: center;\n            gap: 12px;\n        }\n        .toolbar-title {\n            font-size: 13px;\n            font-weight: 500;\n            color: var(--text-secondary);\n        }\n        .toolbar-count {\n            font-size: 12px;\n            color: var(--text-tertiary);\n            font-family: var(--font-mono);\n            background: var(--bg-element);\n            padding: 2px 8px;\n            border-radius: 10px;\n        }\n        .toolbar-right {\n            display: flex;\n            align-items: center;\n            gap: 8px;\n        }\n\n        .search-box {\n            position: relative;\n        }\n        .search-box input {\n            background: var(--bg-panel);\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-md);\n            height: 32px;\n            padding: 0 12px 0 32px;\n            color: var(--text-primary);\n            width: 200px;\n            font-size: 13px;\n            transition: all 0.2s;\n            font-family: var(--font-ui);\n        }\n        .search-box input:focus {\n            outline: none;\n            border-color: var(--accent-primary);\n            box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);\n            width: 240px;\n        }\n        .search-box input::placeholder { color: var(--text-tertiary); }\n        .search-box svg {\n            position: absolute;\n            left: 10px;\n            top: 50%;\n            transform: translateY(-50%);\n            width: 14px;\n            height: 14px;\n            color: var(--text-tertiary);\n            pointer-events: none;\n        }\n\n        /* Skills List */\n        .skills-scroll {\n            flex: 1;\n            overflow-y: auto;\n            padding: 0 24px 24px 24px;\n        }\n\n        /* Discovery banner */\n        .discovery-banner {\n            display: flex;\n            align-items: center;\n            justify-content: space-between;\n            padding: 12px 16px;\n            background: var(--accent-primary-light);\n            border: 1px solid var(--accent-primary-border);\n            border-radius: var(--radius-lg);\n            margin-bottom: 12px;\n        }\n        .discovery-left {\n            display: flex;\n            align-items: center;\n            gap: 10px;\n        }\n        .discovery-icon {\n            width: 32px;\n            height: 32px;\n            background: var(--accent-primary);\n            border-radius: var(--radius-md);\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            color: white;\n            flex-shrink: 0;\n        }\n        .discovery-icon svg { width: 16px; height: 16px; }\n        .discovery-text { font-size: 13px; color: var(--text-primary); font-weight: 500; }\n        .discovery-sub { font-size: 12px; color: var(--text-secondary); margin-top: 1px; }\n\n        /* Skill Card - matches current implementation */\n        .skill-row {\n            display: flex;\n            align-items: flex-start;\n            gap: 16px;\n            padding: 16px 20px;\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-lg);\n            margin-bottom: 8px;\n            transition: all 0.2s;\n            cursor: pointer;\n            background: var(--bg-app);\n            position: relative;\n        }\n        .skill-row:hover {\n            border-color: var(--border-strong);\n            background: var(--bg-panel);\n            box-shadow: var(--shadow-sm);\n        }\n\n        .skill-icon-sm {\n            width: 44px;\n            height: 44px;\n            background: var(--bg-element);\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-md);\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            color: var(--text-secondary);\n            flex-shrink: 0;\n            margin-top: 2px;\n        }\n        .skill-icon-sm svg { width: 20px; height: 20px; }\n        .skill-row:hover .skill-icon-sm {\n            border-color: var(--border-strong);\n            color: var(--text-primary);\n        }\n\n        .skill-info { display: flex; flex-direction: column; gap: 6px; min-width: 0; flex: 1; }\n        .skill-name-row { display: flex; align-items: center; gap: 8px; }\n        .skill-name {\n            font-weight: 700;\n            font-size: 15px;\n            color: var(--text-primary);\n            white-space: nowrap;\n            overflow: hidden;\n            text-overflow: ellipsis;\n        }\n        .skill-desc {\n            font-size: 13px;\n            color: var(--text-secondary);\n            line-height: 1.5;\n            display: -webkit-box;\n            -webkit-line-clamp: 2;\n            -webkit-box-orient: vertical;\n            overflow: hidden;\n        }\n        .skill-source-row {\n            display: flex;\n            align-items: center;\n            gap: 8px;\n        }\n        .skill-source {\n            font-family: var(--font-mono);\n            font-size: 12px;\n            color: var(--text-tertiary);\n            background: var(--bg-element);\n            padding: 2px 8px;\n            border-radius: var(--radius-sm);\n            white-space: nowrap;\n            overflow: hidden;\n            text-overflow: ellipsis;\n            max-width: 260px;\n        }\n        .skill-time {\n            font-size: 12px;\n            color: var(--text-tertiary);\n        }\n\n        /* Tool Badges - only show synced, compact */\n        .tool-badges { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 2px; }\n        .tool-badge {\n            font-size: 11px;\n            font-weight: 500;\n            padding: 2px 8px;\n            border-radius: 10px;\n            white-space: nowrap;\n            display: flex;\n            align-items: center;\n            gap: 4px;\n        }\n        .tool-badge.synced {\n            background: var(--status-success-light);\n            color: var(--status-success);\n            border: 1px solid var(--status-success-border);\n        }\n        .tool-badge.synced::before {\n            content: '';\n            width: 5px;\n            height: 5px;\n            border-radius: 50%;\n            background: var(--status-success);\n        }\n        .tool-badge.more {\n            background: var(--bg-element);\n            color: var(--text-tertiary);\n            border: 1px solid var(--border-subtle);\n            font-family: var(--font-mono);\n            font-size: 10px;\n        }\n\n        /* Row actions - always visible, top-right */\n        .row-actions {\n            display: flex;\n            align-items: center;\n            gap: 2px;\n            flex-shrink: 0;\n            margin-top: 2px;\n        }\n        .action-btn {\n            width: 32px;\n            height: 32px;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            border-radius: var(--radius-md);\n            color: var(--text-tertiary);\n            cursor: pointer;\n            transition: all 0.15s;\n            border: none;\n            background: none;\n        }\n        .action-btn:hover { background: var(--bg-element); color: var(--text-primary); }\n        .action-btn.danger:hover { background: #FEF2F2; color: var(--status-error); }\n        .action-btn svg { width: 16px; height: 16px; }\n\n\n\n        /* ============================================\n           VIEW 2: EXPLORE SKILLS\n           ============================================ */\n        .view-explore { flex: 1; display: flex; flex-direction: column; overflow: hidden; }\n\n        /* Explore Hero / Search */\n        .explore-hero {\n            padding: 28px 32px 20px 32px;\n            flex-shrink: 0;\n        }\n        .explore-search-row {\n            display: flex;\n            align-items: center;\n            gap: 12px;\n        }\n        .explore-search {\n            flex: 1;\n            position: relative;\n        }\n        .explore-search input {\n            width: 100%;\n            height: 40px;\n            background: var(--bg-panel);\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-lg);\n            padding: 0 16px 0 40px;\n            font-size: 14px;\n            color: var(--text-primary);\n            transition: all 0.2s;\n            font-family: var(--font-ui);\n        }\n        .explore-search input:focus {\n            outline: none;\n            border-color: var(--accent-primary);\n            box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);\n        }\n        .explore-search input::placeholder { color: var(--text-tertiary); }\n        .explore-search svg {\n            position: absolute;\n            left: 14px;\n            top: 50%;\n            transform: translateY(-50%);\n            width: 16px;\n            height: 16px;\n            color: var(--text-tertiary);\n            pointer-events: none;\n        }\n        .explore-source-label {\n            font-size: 11px;\n            color: var(--text-tertiary);\n            margin-top: 8px;\n            padding-left: 4px;\n        }\n        .explore-source-label a {\n            color: var(--accent-primary);\n            text-decoration: none;\n        }\n\n        /* Category Pills */\n        .category-bar {\n            display: flex;\n            align-items: center;\n            gap: 6px;\n            padding: 0 32px 16px 32px;\n            flex-shrink: 0;\n            overflow-x: auto;\n        }\n        .category-pill {\n            height: 28px;\n            padding: 0 12px;\n            border-radius: 14px;\n            font-size: 12px;\n            font-weight: 500;\n            border: 1px solid var(--border-subtle);\n            background: var(--bg-app);\n            color: var(--text-secondary);\n            cursor: pointer;\n            transition: all 0.2s;\n            white-space: nowrap;\n            font-family: var(--font-ui);\n        }\n        .category-pill:hover {\n            border-color: var(--border-strong);\n            background: var(--bg-element);\n        }\n        .category-pill.active {\n            background: var(--accent-primary);\n            color: white;\n            border-color: var(--accent-primary);\n        }\n\n        /* Explore Grid */\n        .explore-scroll {\n            flex: 1;\n            overflow-y: auto;\n            padding: 0 32px 32px 32px;\n        }\n        .explore-section-title {\n            font-size: 13px;\n            font-weight: 600;\n            color: var(--text-secondary);\n            text-transform: uppercase;\n            letter-spacing: 0.04em;\n            margin-bottom: 12px;\n            display: flex;\n            align-items: center;\n            gap: 8px;\n        }\n        .explore-section-title svg { width: 14px; height: 14px; color: var(--text-tertiary); }\n\n        .explore-grid {\n            display: grid;\n            grid-template-columns: repeat(2, 1fr);\n            gap: 10px;\n            margin-bottom: 28px;\n        }\n\n        .explore-card {\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-lg);\n            padding: 16px;\n            display: flex;\n            flex-direction: column;\n            gap: 10px;\n            transition: all 0.2s;\n            cursor: pointer;\n            background: var(--bg-app);\n        }\n        .explore-card:hover {\n            border-color: var(--border-strong);\n            box-shadow: var(--shadow-md);\n        }\n\n        .explore-card-top {\n            display: flex;\n            align-items: flex-start;\n            justify-content: space-between;\n            gap: 12px;\n        }\n        .explore-card-info {\n            flex: 1;\n            min-width: 0;\n        }\n        .explore-card-name {\n            font-weight: 600;\n            font-size: 14px;\n            color: var(--text-primary);\n            margin-bottom: 2px;\n        }\n        .explore-card-author {\n            font-family: var(--font-mono);\n            font-size: 11px;\n            color: var(--text-tertiary);\n        }\n        .explore-card-desc {\n            font-size: 12.5px;\n            color: var(--text-secondary);\n            line-height: 1.5;\n            display: -webkit-box;\n            -webkit-line-clamp: 2;\n            -webkit-box-orient: vertical;\n            overflow: hidden;\n        }\n        .explore-card-bottom {\n            display: flex;\n            align-items: center;\n            justify-content: space-between;\n            gap: 8px;\n        }\n        .explore-card-stats {\n            display: flex;\n            align-items: center;\n            gap: 12px;\n        }\n        .explore-stat {\n            display: flex;\n            align-items: center;\n            gap: 4px;\n            font-size: 11px;\n            color: var(--text-tertiary);\n        }\n        .explore-stat svg { width: 12px; height: 12px; }\n        .explore-card-tools {\n            display: flex;\n            gap: 3px;\n            flex-wrap: wrap;\n        }\n        .mini-tool-badge {\n            font-size: 10px;\n            font-weight: 500;\n            padding: 1px 6px;\n            border-radius: 8px;\n            background: var(--bg-element);\n            color: var(--text-tertiary);\n            border: 1px solid var(--border-subtle);\n        }\n\n        /* ============================================\n           VIEW 3: MANUAL ADD (modal - kept simple)\n           ============================================ */\n        .modal-backdrop {\n            position: absolute;\n            inset: 0;\n            background: rgba(0,0,0,0.3);\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            z-index: 100;\n            backdrop-filter: blur(2px);\n        }\n        .modal {\n            background: var(--bg-app);\n            border-radius: var(--radius-xl);\n            width: 460px;\n            box-shadow: var(--shadow-lg);\n            border: 1px solid var(--border-subtle);\n            overflow: hidden;\n        }\n        .modal-header {\n            padding: 20px 24px 16px 24px;\n            display: flex;\n            align-items: center;\n            justify-content: space-between;\n        }\n        .modal-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }\n        .modal-close {\n            width: 28px; height: 28px;\n            display: flex; align-items: center; justify-content: center;\n            border-radius: var(--radius-md);\n            border: none; background: none;\n            color: var(--text-tertiary); cursor: pointer;\n            font-size: 16px; transition: all 0.15s;\n        }\n        .modal-close:hover { background: var(--bg-element); color: var(--text-primary); }\n\n        .modal-tabs {\n            display: flex;\n            gap: 0;\n            padding: 0 24px;\n            border-bottom: 1px solid var(--border-subtle);\n        }\n        .modal-tab {\n            padding: 8px 14px;\n            font-size: 13px;\n            font-weight: 500;\n            color: var(--text-tertiary);\n            cursor: pointer;\n            border: none;\n            background: none;\n            position: relative;\n            transition: color 0.2s;\n            font-family: var(--font-ui);\n        }\n        .modal-tab:hover { color: var(--text-secondary); }\n        .modal-tab.active { color: var(--accent-primary); }\n        .modal-tab.active::after {\n            content: '';\n            position: absolute;\n            bottom: -1px;\n            left: 8px; right: 8px;\n            height: 2px;\n            background: var(--accent-primary);\n            border-radius: 2px 2px 0 0;\n        }\n\n        .modal-body { padding: 20px 24px; }\n        .form-group { margin-bottom: 16px; }\n        .form-label {\n            display: block;\n            font-size: 13px;\n            font-weight: 500;\n            color: var(--text-secondary);\n            margin-bottom: 6px;\n        }\n        .form-input {\n            width: 100%;\n            height: 36px;\n            border: 1px solid var(--border-subtle);\n            border-radius: var(--radius-md);\n            padding: 0 12px;\n            font-size: 13px;\n            color: var(--text-primary);\n            background: var(--bg-panel);\n            font-family: var(--font-ui);\n            transition: all 0.2s;\n        }\n        .form-input:focus {\n            outline: none;\n            border-color: var(--accent-primary);\n            box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);\n        }\n        .form-input::placeholder { color: var(--text-tertiary); }\n        .form-hint { font-size: 11px; color: var(--text-tertiary); margin-top: 4px; }\n\n        .tool-selector { display: flex; flex-wrap: wrap; gap: 6px; }\n        .tool-chip {\n            display: flex;\n            align-items: center;\n            gap: 4px;\n            height: 28px;\n            padding: 0 10px;\n            border-radius: 14px;\n            font-size: 12px;\n            font-weight: 500;\n            border: 1px solid var(--border-subtle);\n            background: var(--bg-app);\n            color: var(--text-secondary);\n            cursor: pointer;\n            transition: all 0.15s;\n        }\n        .tool-chip:hover { border-color: var(--border-strong); }\n        .tool-chip.selected {\n            background: var(--accent-primary-light);\n            border-color: var(--accent-primary-border);\n            color: var(--accent-primary);\n        }\n        .tool-chip .dot {\n            width: 6px; height: 6px; border-radius: 50%;\n            background: var(--status-success);\n            display: none;\n        }\n        .tool-chip.selected .dot { display: block; }\n\n        .modal-footer {\n            padding: 16px 24px;\n            display: flex;\n            justify-content: flex-end;\n            gap: 8px;\n            border-top: 1px solid var(--border-subtle);\n        }\n\n        /* ===== Install Confirmation Toast ===== */\n        .install-toast {\n            position: absolute;\n            bottom: 20px;\n            left: 50%;\n            transform: translateX(-50%);\n            background: var(--text-primary);\n            color: white;\n            padding: 10px 20px;\n            border-radius: var(--radius-lg);\n            font-size: 13px;\n            font-weight: 500;\n            display: flex;\n            align-items: center;\n            gap: 8px;\n            box-shadow: var(--shadow-lg);\n            z-index: 200;\n        }\n        .install-toast svg { width: 16px; height: 16px; color: #34D399; }\n\n        /* ===== Utilities ===== */\n        .hidden { display: none !important; }\n        .flex-center { display: flex; align-items: center; justify-content: center; }\n\n        /* Scrollbar */\n        ::-webkit-scrollbar { width: 6px; }\n        ::-webkit-scrollbar-track { background: transparent; }\n        ::-webkit-scrollbar-thumb {\n            background: var(--border-strong);\n            border-radius: 3px;\n        }\n        ::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }\n\n        /* ===== Annotation callouts ===== */\n        .annotation {\n            position: absolute;\n            font-family: var(--font-mono);\n            font-size: 11px;\n            color: var(--accent-primary);\n            pointer-events: none;\n            z-index: 50;\n        }\n        .annotation::before {\n            content: '';\n            position: absolute;\n            width: 6px; height: 6px;\n            background: var(--accent-primary);\n            border-radius: 50%;\n        }\n    </style>\n</head>\n<body>\n\n<!-- ==========================================\n     SCREEN 1: MY SKILLS (Main View)\n     ========================================== -->\n<div class=\"window-frame\" style=\"position: relative;\">\n    <span class=\"design-label\">Screen 1 — My Skills</span>\n    <div class=\"title-bar\">\n        <div class=\"traffic-lights\">\n            <div class=\"traffic-light close\"></div>\n            <div class=\"traffic-light minimize\"></div>\n            <div class=\"traffic-light maximize\"></div>\n        </div>\n    </div>\n\n    <div class=\"app-header\">\n        <div class=\"header-left\">\n            <div class=\"brand-area\">\n                <div class=\"brand-icon\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2L2 7l10 5 10-5-10-5z\"/><path d=\"M2 17l10 5 10-5\"/><path d=\"M2 12l10 5 10-5\"/></svg>\n                </div>\n                <span class=\"brand-text\">Skills Hub</span>\n            </div>\n            <nav class=\"nav-tabs\">\n                <button class=\"nav-tab active\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 9h18V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4z\"/><path d=\"M3 9v10a2 2 0 002 2h14a2 2 0 002-2V9\"/><line x1=\"12\" y1=\"13\" x2=\"12\" y2=\"17\"/><line x1=\"8\" y1=\"13\" x2=\"8\" y2=\"17\"/><line x1=\"16\" y1=\"13\" x2=\"16\" y2=\"17\"/></svg>\n                    My Skills\n                </button>\n                <button class=\"nav-tab\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n                    Explore\n                </button>\n            </nav>\n        </div>\n        <div class=\"header-actions\">\n            <button class=\"lang-btn\">EN</button>\n            <button class=\"icon-btn\" aria-label=\"Settings\">\n                <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42\"/></svg>\n            </button>\n        </div>\n    </div>\n\n    <div class=\"app-body\">\n        <div class=\"view-myskills\">\n            <div class=\"toolbar\">\n                <div class=\"toolbar-left\">\n                    <span class=\"toolbar-title\">All Skills</span>\n                    <span class=\"toolbar-count\">5</span>\n                    <button class=\"btn btn-sm btn-secondary\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"6 9 12 15 18 9\"/></svg>\n                        Latest\n                    </button>\n                </div>\n                <div class=\"toolbar-right\">\n                    <div class=\"search-box\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n                        <input type=\"text\" placeholder=\"Search skills...\" />\n                    </div>\n                    <button class=\"btn btn-sm btn-secondary\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 4 23 10 17 10\"/><path d=\"M20.49 15a9 9 0 11-2.12-9.36L23 10\"/></svg>\n                        Refresh\n                    </button>\n                </div>\n            </div>\n\n            <div class=\"skills-scroll\">\n                <!-- Discovery Banner -->\n                <div class=\"discovery-banner\">\n                    <div class=\"discovery-left\">\n                        <div class=\"discovery-icon\">\n                            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n                        </div>\n                        <div>\n                            <div class=\"discovery-text\">Found 5 existing skills on your system</div>\n                            <div class=\"discovery-sub\">Click to review and import them</div>\n                        </div>\n                    </div>\n                    <button class=\"btn btn-sm btn-primary\">Review & Import</button>\n                </div>\n\n                <!-- Skill Row 1: react -->\n                <div class=\"skill-row\">\n                    <div class=\"skill-icon-sm\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22\"/></svg>\n                    </div>\n                    <div class=\"skill-info\">\n                        <div class=\"skill-name-row\">\n                            <span class=\"skill-name\">react</span>\n                        </div>\n                        <div class=\"skill-desc\">React renderer for json-render that turns JSON specs into React components. Use when working with @json-render/react, building React UIs from JSON, creating component catalogs.</div>\n                        <div class=\"skill-source-row\">\n                            <span class=\"skill-source\">vercel-labs/json-render</span>\n                            <span class=\"skill-time\">&middot; 22 min ago</span>\n                        </div>\n                        <div class=\"tool-badges\">\n                            <span class=\"tool-badge synced\">Cursor</span>\n                            <span class=\"tool-badge synced\">Claude Code</span>\n                            <span class=\"tool-badge synced\">Codex</span>\n                            <span class=\"tool-badge synced\">OpenCode</span>\n                            <span class=\"tool-badge synced\">Antigravity</span>\n                            <span class=\"tool-badge more\">+5 more</span>\n                        </div>\n\n                    </div>\n                    <div class=\"row-actions\">\n                        <button class=\"action-btn\" aria-label=\"Sync\">\n                            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 4 23 10 17 10\"/><path d=\"M20.49 15a9 9 0 11-2.12-9.36L23 10\"/></svg>\n                        </button>\n                        <button class=\"action-btn danger\" aria-label=\"Delete\">\n                            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"/><path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg>\n                        </button>\n                    </div>\n                </div>\n\n                <!-- Skill Row 2: baoyu-article-illustrator (9/10 synced, Cline unsynced) -->\n                <div class=\"skill-row\">\n                    <div class=\"skill-icon-sm\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22\"/></svg>\n                    </div>\n                    <div class=\"skill-info\">\n                        <div class=\"skill-name-row\">\n                            <span class=\"skill-name\">baoyu-article-illustrator</span>\n                        </div>\n                        <div class=\"skill-desc\">Analyzes article structure, identifies positions requiring visual aids, generates illustrations with Type x Style two-dimension approach.</div>\n                        <div class=\"skill-source-row\">\n                            <span class=\"skill-source\">JimLiu/baoyu-skills</span>\n                            <span class=\"skill-time\">&middot; 31 days ago</span>\n                        </div>\n                        <div class=\"tool-badges\">\n                            <span class=\"tool-badge synced\">Cursor</span>\n                            <span class=\"tool-badge synced\">Claude Code</span>\n                            <span class=\"tool-badge synced\">Codex</span>\n                            <span class=\"tool-badge synced\">OpenCode</span>\n                            <span class=\"tool-badge synced\">Antigravity</span>\n                            <span class=\"tool-badge more\">+4 more</span>\n                        </div>\n                    </div>\n                    <div class=\"row-actions\">\n                        <button class=\"action-btn\" aria-label=\"Sync\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 4 23 10 17 10\"/><path d=\"M20.49 15a9 9 0 11-2.12-9.36L23 10\"/></svg></button>\n                        <button class=\"action-btn danger\" aria-label=\"Delete\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"/><path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg></button>\n                    </div>\n                </div>\n\n                <!-- Skill Row 3: excalidraw-diagram -->\n                <div class=\"skill-row\">\n                    <div class=\"skill-icon-sm\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22\"/></svg>\n                    </div>\n                    <div class=\"skill-info\">\n                        <div class=\"skill-name-row\">\n                            <span class=\"skill-name\">excalidraw-diagram</span>\n                        </div>\n                        <div class=\"skill-desc\">Generate Excalidraw diagrams from text content. Supports Obsidian, Standard, and Animated output modes for flowcharts, mind maps, and visualizations.</div>\n                        <div class=\"skill-source-row\">\n                            <span class=\"skill-source\">axtonliu/axton-obsidian-visual-skills</span>\n                            <span class=\"skill-time\">&middot; 37 days ago</span>\n                        </div>\n                        <div class=\"tool-badges\">\n                            <span class=\"tool-badge synced\">Cursor</span>\n                            <span class=\"tool-badge synced\">Claude Code</span>\n                            <span class=\"tool-badge synced\">Codex</span>\n                            <span class=\"tool-badge synced\">OpenCode</span>\n                            <span class=\"tool-badge synced\">Antigravity</span>\n                            <span class=\"tool-badge more\">+4 more</span>\n                        </div>\n                    </div>\n                    <div class=\"row-actions\">\n                        <button class=\"action-btn\" aria-label=\"Sync\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 4 23 10 17 10\"/><path d=\"M20.49 15a9 9 0 11-2.12-9.36L23 10\"/></svg></button>\n                        <button class=\"action-btn danger\" aria-label=\"Delete\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"/><path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg></button>\n                    </div>\n                </div>\n\n                <!-- Skill Row 4: youtube-transcript (local, fewer synced) -->\n                <div class=\"skill-row\">\n                    <div class=\"skill-icon-sm\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z\"/></svg>\n                    </div>\n                    <div class=\"skill-info\">\n                        <div class=\"skill-name-row\">\n                            <span class=\"skill-name\">youtube-transcript</span>\n                        </div>\n                        <div class=\"skill-desc\">Download YouTube video transcripts. Use when user provides a YouTube URL or asks to download/get/fetch captions or subtitles from a video.</div>\n                        <div class=\"skill-source-row\">\n                            <span class=\"skill-source\">/Users/may/Library/Application Support/com...</span>\n                            <span class=\"skill-time\">&middot; 45 days ago</span>\n                        </div>\n                        <div class=\"tool-badges\">\n                            <span class=\"tool-badge synced\">Cursor</span>\n                            <span class=\"tool-badge synced\">Claude Code</span>\n                            <span class=\"tool-badge synced\">Codex</span>\n                            <span class=\"tool-badge synced\">OpenCode</span>\n                            <span class=\"tool-badge synced\">Antigravity</span>\n                            <span class=\"tool-badge more\">+3 more</span>\n                        </div>\n                    </div>\n                    <div class=\"row-actions\">\n                        <button class=\"action-btn\" aria-label=\"Sync\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 4 23 10 17 10\"/><path d=\"M20.49 15a9 9 0 11-2.12-9.36L23 10\"/></svg></button>\n                        <button class=\"action-btn danger\" aria-label=\"Delete\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"/><path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg></button>\n                    </div>\n                </div>\n\n                <!-- Skill Row 5: frontend-design -->\n                <div class=\"skill-row\">\n                    <div class=\"skill-icon-sm\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22\"/></svg>\n                    </div>\n                    <div class=\"skill-info\">\n                        <div class=\"skill-name-row\">\n                            <span class=\"skill-name\">frontend-design</span>\n                        </div>\n                        <div class=\"skill-desc\">Frontend design best practices and guidelines for building modern, accessible web interfaces with React and CSS.</div>\n                        <div class=\"skill-source-row\">\n                            <span class=\"skill-source\">anthropics/skills</span>\n                            <span class=\"skill-time\">&middot; 46 days ago</span>\n                        </div>\n                        <div class=\"tool-badges\">\n                            <span class=\"tool-badge synced\">Cursor</span>\n                            <span class=\"tool-badge synced\">Claude Code</span>\n                            <span class=\"tool-badge synced\">Codex</span>\n                            <span class=\"tool-badge synced\">OpenCode</span>\n                            <span class=\"tool-badge synced\">Antigravity</span>\n                            <span class=\"tool-badge more\">+3 more</span>\n                        </div>\n                    </div>\n                    <div class=\"row-actions\">\n                        <button class=\"action-btn\" aria-label=\"Sync\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 4 23 10 17 10\"/><path d=\"M20.49 15a9 9 0 11-2.12-9.36L23 10\"/></svg></button>\n                        <button class=\"action-btn danger\" aria-label=\"Delete\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"/><path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg></button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n\n\n<!-- ==========================================\n     SCREEN 2: EXPLORE SKILLS (Marketplace)\n     ========================================== -->\n<div class=\"window-frame\" style=\"position: relative;\">\n    <span class=\"design-label\">Screen 2 — Explore Skills (Marketplace)</span>\n    <div class=\"title-bar\">\n        <div class=\"traffic-lights\">\n            <div class=\"traffic-light close\"></div>\n            <div class=\"traffic-light minimize\"></div>\n            <div class=\"traffic-light maximize\"></div>\n        </div>\n    </div>\n\n    <div class=\"app-header\">\n        <div class=\"header-left\">\n            <div class=\"brand-area\">\n                <div class=\"brand-icon\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2L2 7l10 5 10-5-10-5z\"/><path d=\"M2 17l10 5 10-5\"/><path d=\"M2 12l10 5 10-5\"/></svg>\n                </div>\n                <span class=\"brand-text\">Skills Hub</span>\n            </div>\n            <nav class=\"nav-tabs\">\n                <button class=\"nav-tab\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 9h18V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4z\"/><path d=\"M3 9v10a2 2 0 002 2h14a2 2 0 002-2V9\"/><line x1=\"12\" y1=\"13\" x2=\"12\" y2=\"17\"/><line x1=\"8\" y1=\"13\" x2=\"8\" y2=\"17\"/><line x1=\"16\" y1=\"13\" x2=\"16\" y2=\"17\"/></svg>\n                    My Skills\n                </button>\n                <button class=\"nav-tab active\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n                    Explore\n                </button>\n            </nav>\n        </div>\n        <div class=\"header-actions\">\n            <button class=\"lang-btn\">EN</button>\n            <button class=\"icon-btn\" aria-label=\"Settings\">\n                <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42\"/></svg>\n            </button>\n        </div>\n    </div>\n\n    <div class=\"app-body\">\n        <div class=\"view-explore\">\n            <!-- Search Area -->\n            <div class=\"explore-hero\">\n                <div class=\"explore-search-row\">\n                    <div class=\"explore-search\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n                        <input type=\"text\" placeholder=\"Search skills by name, description, or keyword...\" />\n                    </div>\n                    <button class=\"btn btn-secondary\" style=\"height:40px; padding: 0 16px; flex-shrink:0;\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"width:15px;height:15px;\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n                        Manual\n                    </button>\n                </div>\n                <div class=\"explore-source-label\">Data from <a>clawhub.ai</a> &middot; Have a Git URL or local path? Click <b>Manual</b> to add directly</div>\n            </div>\n\n            <!-- Skills Grid -->\n            <div class=\"explore-scroll\">\n                <!-- Featured Section -->\n                <div class=\"explore-section-title\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polygon points=\"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2\"/></svg>\n                    Featured\n                </div>\n                <div class=\"explore-grid\">\n                    <!-- Card 1 -->\n                    <div class=\"explore-card\">\n                        <div class=\"explore-card-top\">\n                            <div class=\"explore-card-info\">\n                                <div class=\"explore-card-name\">self-improving-agent</div>\n                                <div class=\"explore-card-author\">dmarx/bench-warmers</div>\n                            </div>\n                            <button class=\"btn-install\">Install</button>\n                        </div>\n                        <div class=\"explore-card-desc\">Captures learnings, errors, and corrections to enable continuous improvement. Use when: (1) A command or approach fails...</div>\n                        <div class=\"explore-card-bottom\">\n                            <div class=\"explore-card-stats\">\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>\n                                    199.1K\n                                </span>\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polygon points=\"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2\"/></svg>\n                                    1.9K\n                                </span>\n                            </div>\n                            <div class=\"explore-card-tools\">\n                                <span class=\"mini-tool-badge\">Cursor</span>\n                                <span class=\"mini-tool-badge\">Claude</span>\n                                <span class=\"mini-tool-badge\">+5</span>\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Card 2 -->\n                    <div class=\"explore-card\">\n                        <div class=\"explore-card-top\">\n                            <div class=\"explore-card-info\">\n                                <div class=\"explore-card-name\">Find Skills</div>\n                                <div class=\"explore-card-author\">anthropics/skills</div>\n                            </div>\n                            <button class=\"btn-install\">Install</button>\n                        </div>\n                        <div class=\"explore-card-desc\">Helps users discover and install agent skills when they ask questions like \"how do I do X\", \"find a skill for X\"...</div>\n                        <div class=\"explore-card-bottom\">\n                            <div class=\"explore-card-stats\">\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>\n                                    193.0K\n                                </span>\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polygon points=\"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2\"/></svg>\n                                    800\n                                </span>\n                            </div>\n                            <div class=\"explore-card-tools\">\n                                <span class=\"mini-tool-badge\">Cursor</span>\n                                <span class=\"mini-tool-badge\">Claude</span>\n                                <span class=\"mini-tool-badge\">+4</span>\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Card 3 - Already Installed -->\n                    <div class=\"explore-card\">\n                        <div class=\"explore-card-top\">\n                            <div class=\"explore-card-info\">\n                                <div class=\"explore-card-name\">Summarize</div>\n                                <div class=\"explore-card-author\">anthropics/skills</div>\n                            </div>\n                            <button class=\"btn-installed\">\n                                <svg style=\"width:12px;height:12px;margin-right:2px;\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg>\n                                Installed\n                            </button>\n                        </div>\n                        <div class=\"explore-card-desc\">Summarize URLs or files with the summarize CLI (web, PDFs, images, audio, YouTube).</div>\n                        <div class=\"explore-card-bottom\">\n                            <div class=\"explore-card-stats\">\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>\n                                    147.8K\n                                </span>\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polygon points=\"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2\"/></svg>\n                                    566\n                                </span>\n                            </div>\n                            <div class=\"explore-card-tools\">\n                                <span class=\"mini-tool-badge\">Cursor</span>\n                                <span class=\"mini-tool-badge\">Claude</span>\n                                <span class=\"mini-tool-badge\">+3</span>\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Card 4 -->\n                    <div class=\"explore-card\">\n                        <div class=\"explore-card-top\">\n                            <div class=\"explore-card-info\">\n                                <div class=\"explore-card-name\">Agent Browser</div>\n                                <div class=\"explore-card-author\">nicobailon/browser-tools</div>\n                            </div>\n                            <button class=\"btn-install\">Install</button>\n                        </div>\n                        <div class=\"explore-card-desc\">A fast Rust-based headless browser automation CLI with Node.js fallback that enables AI agents to navigate, click...</div>\n                        <div class=\"explore-card-bottom\">\n                            <div class=\"explore-card-stats\">\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>\n                                    121.2K\n                                </span>\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polygon points=\"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2\"/></svg>\n                                    539\n                                </span>\n                            </div>\n                            <div class=\"explore-card-tools\">\n                                <span class=\"mini-tool-badge\">Cursor</span>\n                                <span class=\"mini-tool-badge\">Claude</span>\n                                <span class=\"mini-tool-badge\">+6</span>\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Card 5 -->\n                    <div class=\"explore-card\">\n                        <div class=\"explore-card-top\">\n                            <div class=\"explore-card-info\">\n                                <div class=\"explore-card-name\">Gog</div>\n                                <div class=\"explore-card-author\">erniebrodeur/gog</div>\n                            </div>\n                            <button class=\"btn-install\">Install</button>\n                        </div>\n                        <div class=\"explore-card-desc\">Google Workspace CLI for Gmail, Calendar, Drive, Contacts, Sheets, and Docs.</div>\n                        <div class=\"explore-card-bottom\">\n                            <div class=\"explore-card-stats\">\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>\n                                    109.2K\n                                </span>\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polygon points=\"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2\"/></svg>\n                                    714\n                                </span>\n                            </div>\n                            <div class=\"explore-card-tools\">\n                                <span class=\"mini-tool-badge\">Cursor</span>\n                                <span class=\"mini-tool-badge\">Claude</span>\n                                <span class=\"mini-tool-badge\">+3</span>\n                            </div>\n                        </div>\n                    </div>\n\n                    <!-- Card 6 -->\n                    <div class=\"explore-card\">\n                        <div class=\"explore-card-top\">\n                            <div class=\"explore-card-info\">\n                                <div class=\"explore-card-name\">Github</div>\n                                <div class=\"explore-card-author\">anthropics/skills</div>\n                            </div>\n                            <button class=\"btn-install\">Install</button>\n                        </div>\n                        <div class=\"explore-card-desc\">Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs...</div>\n                        <div class=\"explore-card-bottom\">\n                            <div class=\"explore-card-stats\">\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>\n                                    103.9K\n                                </span>\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polygon points=\"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2\"/></svg>\n                                    336\n                                </span>\n                            </div>\n                            <div class=\"explore-card-tools\">\n                                <span class=\"mini-tool-badge\">Cursor</span>\n                                <span class=\"mini-tool-badge\">Claude</span>\n                                <span class=\"mini-tool-badge\">+5</span>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n\n    <!-- Install Success Toast -->\n    <div class=\"install-toast\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M22 11.08V12a10 10 0 11-5.93-9.14\"/><polyline points=\"22 4 12 14.01 9 11.01\"/></svg>\n        \"Agent Browser\" installed and synced to 3 tools\n    </div>\n</div>\n\n\n<!-- ==========================================\n     SCREEN 3: MANUAL ADD (Modal on Explore)\n     ========================================== -->\n<div class=\"window-frame\" style=\"position: relative;\">\n    <span class=\"design-label\">Screen 3 — Manual Add (overlays Explore page)</span>\n    <div class=\"title-bar\">\n        <div class=\"traffic-lights\">\n            <div class=\"traffic-light close\"></div>\n            <div class=\"traffic-light minimize\"></div>\n            <div class=\"traffic-light maximize\"></div>\n        </div>\n    </div>\n\n    <div class=\"app-header\" style=\"opacity: 0.5;\">\n        <div class=\"header-left\">\n            <div class=\"brand-area\">\n                <div class=\"brand-icon\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2L2 7l10 5 10-5-10-5z\"/><path d=\"M2 17l10 5 10-5\"/><path d=\"M2 12l10 5 10-5\"/></svg>\n                </div>\n                <span class=\"brand-text\">Skills Hub</span>\n            </div>\n            <nav class=\"nav-tabs\" style=\"opacity:0.5;\">\n                <button class=\"nav-tab\">My Skills</button>\n                <button class=\"nav-tab active\">Explore</button>\n            </nav>\n        </div>\n    </div>\n\n    <div class=\"app-body\" style=\"position: relative;\">\n        <!-- Dimmed Explore page background -->\n        <div style=\"flex:1; background: var(--bg-element); opacity: 0.15;\"></div>\n\n        <!-- Modal -->\n        <div class=\"modal-backdrop\">\n            <div class=\"modal\">\n                <div class=\"modal-header\">\n                    <div class=\"modal-title\">Add Skill</div>\n                    <button class=\"modal-close\" aria-label=\"Close\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"width:16px;height:16px;\"><line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/></svg>\n                    </button>\n                </div>\n\n                <div class=\"modal-tabs\">\n                    <button class=\"modal-tab\">Local Directory</button>\n                    <button class=\"modal-tab active\">Git Repository</button>\n                </div>\n\n                <div class=\"modal-body\">\n                    <div class=\"form-group\">\n                        <label class=\"form-label\">Repository URL</label>\n                        <input class=\"form-input\" placeholder=\"https://github.com/user/repo\" value=\"https://github.com/anthropics/skills\" />\n                    </div>\n                    <div class=\"form-group\">\n                        <label class=\"form-label\">Name (optional)</label>\n                        <input class=\"form-input\" placeholder=\"Auto-detected from repo\" />\n                        <div class=\"form-hint\">Leave blank to auto-detect from repository</div>\n                    </div>\n                    <div class=\"form-group\">\n                        <label class=\"form-label\">Sync to tools</label>\n                        <div class=\"tool-selector\">\n                            <label class=\"tool-chip selected\"><span class=\"dot\"></span> Cursor</label>\n                            <label class=\"tool-chip selected\"><span class=\"dot\"></span> Claude Code</label>\n                            <label class=\"tool-chip selected\"><span class=\"dot\"></span> Codex</label>\n                            <label class=\"tool-chip\">OpenCode</label>\n                            <label class=\"tool-chip\">Windsurf</label>\n                            <label class=\"tool-chip\">Amp</label>\n                            <label class=\"tool-chip\">Cline</label>\n                        </div>\n                    </div>\n                </div>\n\n                <div class=\"modal-footer\">\n                    <button class=\"btn btn-secondary\">Cancel</button>\n                    <button class=\"btn btn-primary\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"width:14px;height:14px;\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>\n                        Install\n                    </button>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n\n\n<!-- ==========================================\n     SCREEN 4: EXPLORE WITH SEARCH RESULTS\n     ========================================== -->\n<div class=\"window-frame\" style=\"position: relative;\">\n    <span class=\"design-label\">Screen 4 — Explore with search active</span>\n    <div class=\"title-bar\">\n        <div class=\"traffic-lights\">\n            <div class=\"traffic-light close\"></div>\n            <div class=\"traffic-light minimize\"></div>\n            <div class=\"traffic-light maximize\"></div>\n        </div>\n    </div>\n\n    <div class=\"app-header\">\n        <div class=\"header-left\">\n            <div class=\"brand-area\">\n                <div class=\"brand-icon\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2L2 7l10 5 10-5-10-5z\"/><path d=\"M2 17l10 5 10-5\"/><path d=\"M2 12l10 5 10-5\"/></svg>\n                </div>\n                <span class=\"brand-text\">Skills Hub</span>\n            </div>\n            <nav class=\"nav-tabs\">\n                <button class=\"nav-tab\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 9h18V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4z\"/><path d=\"M3 9v10a2 2 0 002 2h14a2 2 0 002-2V9\"/><line x1=\"12\" y1=\"13\" x2=\"12\" y2=\"17\"/><line x1=\"8\" y1=\"13\" x2=\"8\" y2=\"17\"/><line x1=\"16\" y1=\"13\" x2=\"16\" y2=\"17\"/></svg>\n                    My Skills\n                </button>\n                <button class=\"nav-tab active\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n                    Explore\n                </button>\n            </nav>\n        </div>\n        <div class=\"header-actions\">\n            <button class=\"lang-btn\">EN</button>\n            <button class=\"icon-btn\" aria-label=\"Settings\">\n                <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42\"/></svg>\n            </button>\n        </div>\n    </div>\n\n    <div class=\"app-body\">\n        <div class=\"view-explore\">\n            <div class=\"explore-hero\">\n                <div class=\"explore-search-row\">\n                    <div class=\"explore-search\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n                        <input type=\"text\" placeholder=\"Search skills...\" value=\"browser\" style=\"border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);\" />\n                    </div>\n                    <button class=\"btn btn-secondary\" style=\"height:40px; padding: 0 16px; flex-shrink:0;\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"width:15px;height:15px;\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n                        Manual\n                    </button>\n                </div>\n                <div class=\"explore-source-label\">3 results from <a>clawhub.ai</a></div>\n            </div>\n\n            <div class=\"explore-scroll\">\n                <!-- Featured matches -->\n                <div class=\"explore-section-title\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polygon points=\"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2\"/></svg>\n                    Featured Matches\n                </div>\n                <div class=\"explore-grid\">\n                    <div class=\"explore-card\" style=\"border-color: var(--accent-primary-border);\">\n                        <div class=\"explore-card-top\">\n                            <div class=\"explore-card-info\">\n                                <div class=\"explore-card-name\">Agent <mark style=\"background:#DBEAFE;color:var(--accent-primary);padding:0 2px;border-radius:2px;\">Browser</mark></div>\n                                <div class=\"explore-card-author\">nicobailon/browser-tools</div>\n                            </div>\n                            <button class=\"btn-install\">Install</button>\n                        </div>\n                        <div class=\"explore-card-desc\">A fast Rust-based headless browser automation CLI with Node.js fallback that enables AI agents to navigate, click...</div>\n                        <div class=\"explore-card-bottom\">\n                            <div class=\"explore-card-stats\">\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>\n                                    121.2K\n                                </span>\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polygon points=\"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2\"/></svg>\n                                    539\n                                </span>\n                            </div>\n                            <div class=\"explore-card-tools\">\n                                <span class=\"mini-tool-badge\">Cursor</span>\n                                <span class=\"mini-tool-badge\">Claude</span>\n                                <span class=\"mini-tool-badge\">+6</span>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n\n                <!-- Online search results -->\n                <div class=\"explore-section-title\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"/><path d=\"M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z\"/></svg>\n                    Online Results\n                </div>\n                <div class=\"explore-grid\">\n                    <div class=\"explore-card\">\n                        <div class=\"explore-card-top\">\n                            <div class=\"explore-card-info\">\n                                <div class=\"explore-card-name\"><mark style=\"background:#DBEAFE;color:var(--accent-primary);padding:0 2px;border-radius:2px;\">Browser</mark> Use</div>\n                                <div class=\"explore-card-author\">anthropics/browser-use-skill</div>\n                            </div>\n                            <button class=\"btn-install\">Install</button>\n                        </div>\n                        <div class=\"explore-card-desc\">Enables AI agents to control a headless browser for web research, form filling, and data extraction tasks.</div>\n                        <div class=\"explore-card-bottom\">\n                            <div class=\"explore-card-stats\">\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>\n                                    45.3K installs\n                                </span>\n                            </div>\n                            <div class=\"explore-card-tools\">\n                                <span class=\"mini-tool-badge\">Cursor</span>\n                                <span class=\"mini-tool-badge\">Claude</span>\n                            </div>\n                        </div>\n                    </div>\n\n                    <div class=\"explore-card\">\n                        <div class=\"explore-card-top\">\n                            <div class=\"explore-card-info\">\n                                <div class=\"explore-card-name\"><mark style=\"background:#DBEAFE;color:var(--accent-primary);padding:0 2px;border-radius:2px;\">Browser</mark> Screenshot</div>\n                                <div class=\"explore-card-author\">nicobailon/screenshot-tools</div>\n                            </div>\n                            <button class=\"btn-install\">Install</button>\n                        </div>\n                        <div class=\"explore-card-desc\">Take screenshots of web pages and convert them to various formats for analysis.</div>\n                        <div class=\"explore-card-bottom\">\n                            <div class=\"explore-card-stats\">\n                                <span class=\"explore-stat\">\n                                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>\n                                    12.8K installs\n                                </span>\n                            </div>\n                            <div class=\"explore-card-tools\">\n                                <span class=\"mini-tool-badge\">Cursor</span>\n                                <span class=\"mini-tool-badge\">Claude</span>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n\n\n<!-- ==========================================\n     SCREEN 5: SKILL DETAIL VIEW (from My Skills)\n     ========================================== -->\n<style>\n    /* ===== Detail View Styles ===== */\n    .detail-view { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0; }\n\n    .detail-header {\n        padding: 16px 24px;\n        border-bottom: 1px solid var(--border-subtle);\n        flex-shrink: 0;\n        display: flex;\n        flex-direction: column;\n        gap: 6px;\n    }\n    .detail-back-btn {\n        display: inline-flex;\n        align-items: center;\n        gap: 6px;\n        font-size: 13px;\n        font-weight: 500;\n        color: var(--text-secondary);\n        cursor: pointer;\n        background: none;\n        border: none;\n        padding: 4px 8px;\n        border-radius: var(--radius-sm);\n        transition: all 0.2s;\n        font-family: var(--font-ui);\n    }\n    .detail-back-btn:hover { color: var(--accent-primary); background: rgba(37,99,235,0.06); }\n    .detail-back-btn svg { width: 16px; height: 16px; }\n\n    .detail-skill-name {\n        font-size: 20px;\n        font-weight: 700;\n        color: var(--text-primary);\n        letter-spacing: -0.02em;\n    }\n    .detail-desc {\n        font-size: 13px;\n        color: var(--text-secondary);\n        line-height: 1.5;\n    }\n    .detail-meta {\n        display: flex;\n        align-items: center;\n        gap: 14px;\n        font-size: 12px;\n        color: var(--text-tertiary);\n    }\n    .detail-meta-item { display: flex; align-items: center; gap: 5px; }\n    .detail-meta-item svg { width: 13px; height: 13px; }\n    .detail-meta-dot { color: var(--border-strong); }\n\n    /* Split pane */\n    .detail-body { flex: 1; display: flex; overflow: hidden; min-height: 0; }\n\n    .detail-file-list {\n        width: 220px;\n        border-right: 1px solid var(--border-subtle);\n        overflow-y: auto;\n        flex-shrink: 0;\n        background: var(--bg-panel);\n        padding: 8px 0;\n    }\n    .file-list-title {\n        font-size: 11px;\n        font-weight: 600;\n        text-transform: uppercase;\n        letter-spacing: 0.06em;\n        color: var(--text-tertiary);\n        padding: 8px 16px 6px;\n    }\n    .file-item {\n        display: flex;\n        align-items: center;\n        gap: 8px;\n        padding: 7px 16px;\n        font-size: 13px;\n        color: var(--text-secondary);\n        cursor: pointer;\n        transition: all 0.15s;\n        border-left: 2px solid transparent;\n    }\n    .file-item:hover { background: var(--bg-element); color: var(--text-primary); }\n    .file-item.active {\n        background: rgba(37,99,235,0.06);\n        color: var(--accent-primary);\n        font-weight: 500;\n        border-left-color: var(--accent-primary);\n    }\n    .file-item svg { width: 14px; height: 14px; flex-shrink: 0; }\n    .file-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }\n    .file-item-size { font-size: 11px; color: var(--text-tertiary); flex-shrink: 0; font-family: var(--font-mono); }\n\n    .detail-file-content { flex: 1; overflow: auto; min-width: 0; background: var(--bg-app); }\n    .file-content-header {\n        padding: 10px 24px;\n        border-bottom: 1px solid var(--border-subtle);\n        display: flex;\n        align-items: center;\n        justify-content: space-between;\n        position: sticky;\n        top: 0;\n        background: var(--bg-app);\n        z-index: 5;\n    }\n    .file-content-path {\n        font-family: var(--font-mono);\n        font-size: 12px;\n        color: var(--text-secondary);\n        display: flex;\n        align-items: center;\n        gap: 6px;\n    }\n    .file-content-path svg { width: 14px; height: 14px; color: var(--text-tertiary); }\n    .file-content-size { font-size: 11px; color: var(--text-tertiary); font-family: var(--font-mono); }\n    .file-content-pre {\n        padding: 16px 24px;\n        font-family: var(--font-mono);\n        font-size: 13px;\n        line-height: 1.7;\n        color: var(--text-primary);\n        white-space: pre-wrap;\n        word-break: break-word;\n        margin: 0;\n        tab-size: 4;\n    }\n\n    /* Loading state */\n    .file-content-loading {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        height: 200px;\n        color: var(--text-tertiary);\n        font-size: 13px;\n        gap: 8px;\n    }\n    .spinner-sm {\n        width: 16px;\n        height: 16px;\n        border: 2px solid var(--border-subtle);\n        border-top-color: var(--accent-primary);\n        border-radius: 50%;\n        animation: detail-spin 0.8s linear infinite;\n    }\n    @keyframes detail-spin { to { transform: rotate(360deg); } }\n\n    /* Clickable skill name in card */\n    .skill-name.clickable { cursor: pointer; transition: color 0.15s; }\n    .skill-name.clickable:hover { color: var(--accent-primary); }\n\n    /* Click indicator */\n    .click-ring {\n        position: absolute;\n        width: 22px; height: 22px;\n        border-radius: 50%;\n        background: rgba(225,29,72,0.12);\n        border: 2px solid #e11d48;\n        animation: ring-pulse 1.5s ease-in-out infinite;\n        pointer-events: none;\n    }\n    @keyframes ring-pulse {\n        0% { transform: scale(1); opacity: 1; }\n        50% { transform: scale(1.3); opacity: 0.5; }\n        100% { transform: scale(1); opacity: 1; }\n    }\n    .callout {\n        position: absolute;\n        font-family: var(--font-mono);\n        font-size: 11px;\n        color: #e11d48;\n        white-space: nowrap;\n    }\n    .callout::before { content: '→ '; font-weight: bold; }\n\n    /* Flow arrow between screens */\n    .flow-arrow-v {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        padding: 8px 0;\n        color: #999;\n    }\n    .flow-arrow-v svg { width: 32px; height: 32px; }\n</style>\n\n<!-- 5A: List view - showing clickable skill name -->\n<div class=\"window-frame\" style=\"position: relative;\">\n    <span class=\"design-label\">Screen 5A — Click skill name to open detail</span>\n    <div class=\"title-bar\">\n        <div class=\"traffic-lights\">\n            <div class=\"traffic-light close\"></div>\n            <div class=\"traffic-light minimize\"></div>\n            <div class=\"traffic-light maximize\"></div>\n        </div>\n    </div>\n\n    <div class=\"app-header\">\n        <div class=\"header-left\">\n            <div class=\"brand-area\">\n                <div class=\"brand-icon\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2L2 7l10 5 10-5-10-5z\"/><path d=\"M2 17l10 5 10-5\"/><path d=\"M2 12l10 5 10-5\"/></svg>\n                </div>\n                <span class=\"brand-text\">Skills Hub</span>\n            </div>\n            <nav class=\"nav-tabs\">\n                <button class=\"nav-tab active\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 9h18V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4z\"/><path d=\"M3 9v10a2 2 0 002 2h14a2 2 0 002-2V9\"/></svg>\n                    My Skills\n                </button>\n                <button class=\"nav-tab\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n                    Explore\n                </button>\n            </nav>\n        </div>\n        <div class=\"header-actions\">\n            <button class=\"lang-btn\">EN</button>\n            <button class=\"icon-btn\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42\"/></svg></button>\n        </div>\n    </div>\n\n    <div class=\"app-body\">\n        <div class=\"view-myskills\">\n            <div class=\"toolbar\">\n                <div class=\"toolbar-left\">\n                    <span class=\"toolbar-title\">All Skills</span>\n                    <span class=\"toolbar-count\">3</span>\n                </div>\n            </div>\n            <div class=\"skills-scroll\">\n                <!-- Card 1 - click indicator on name -->\n                <div class=\"skill-row\" style=\"position: relative;\">\n                    <div class=\"skill-icon-sm\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22\"/></svg></div>\n                    <div class=\"skill-info\">\n                        <div class=\"skill-name-row\">\n                            <span class=\"skill-name clickable\" style=\"color: var(--accent-primary); text-decoration: underline; text-underline-offset: 3px;\">react</span>\n                        </div>\n                        <div class=\"skill-desc\">React renderer for json-render that turns JSON specs into React components.</div>\n                        <div class=\"skill-source-row\">\n                            <span class=\"skill-source\">vercel-labs/json-render</span>\n                            <span class=\"skill-time\">&middot; 22 min ago</span>\n                        </div>\n                        <div class=\"tool-badges\">\n                            <span class=\"tool-badge synced\">Cursor</span>\n                            <span class=\"tool-badge synced\">Claude Code</span>\n                            <span class=\"tool-badge more\">+3</span>\n                        </div>\n                    </div>\n                    <div class=\"row-actions\">\n                        <button class=\"action-btn\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 4 23 10 17 10\"/><path d=\"M20.49 15a9 9 0 11-2.12-9.36L23 10\"/></svg></button>\n                        <button class=\"action-btn danger\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"/><path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg></button>\n                    </div>\n                    <!-- Click indicator -->\n                    <div class=\"click-ring\" style=\"top: 16px; left: 90px;\"></div>\n                    <span class=\"callout\" style=\"top: 10px; right: -180px;\">Click name to view detail</span>\n                </div>\n\n                <!-- Card 2 -->\n                <div class=\"skill-row\">\n                    <div class=\"skill-icon-sm\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z\"/></svg></div>\n                    <div class=\"skill-info\">\n                        <div class=\"skill-name-row\"><span class=\"skill-name clickable\">commit-message</span></div>\n                        <div class=\"skill-desc\">Generate conventional commit messages with scope and body.</div>\n                        <div class=\"skill-source-row\">\n                            <span class=\"skill-source\">/Users/may/skills/commit-msg</span>\n                            <span class=\"skill-time\">&middot; 1 day ago</span>\n                        </div>\n                        <div class=\"tool-badges\"><span class=\"tool-badge synced\">Cursor</span></div>\n                    </div>\n                    <div class=\"row-actions\">\n                        <button class=\"action-btn\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 4 23 10 17 10\"/><path d=\"M20.49 15a9 9 0 11-2.12-9.36L23 10\"/></svg></button>\n                        <button class=\"action-btn danger\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"/><path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg></button>\n                    </div>\n                </div>\n\n                <!-- Card 3 -->\n                <div class=\"skill-row\">\n                    <div class=\"skill-icon-sm\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22\"/></svg></div>\n                    <div class=\"skill-info\">\n                        <div class=\"skill-name-row\"><span class=\"skill-name clickable\">debug-helper</span></div>\n                        <div class=\"skill-desc\">Systematic debugging workflow with root cause analysis.</div>\n                        <div class=\"skill-source-row\">\n                            <span class=\"skill-source\">org/debug-tools</span>\n                            <span class=\"skill-time\">&middot; 3 days ago</span>\n                        </div>\n                        <div class=\"tool-badges\">\n                            <span class=\"tool-badge synced\">Claude Code</span>\n                            <span class=\"tool-badge synced\">Cursor</span>\n                            <span class=\"tool-badge synced\">Windsurf</span>\n                        </div>\n                    </div>\n                    <div class=\"row-actions\">\n                        <button class=\"action-btn\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 4 23 10 17 10\"/><path d=\"M20.49 15a9 9 0 11-2.12-9.36L23 10\"/></svg></button>\n                        <button class=\"action-btn danger\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"3 6 5 6 21 6\"/><path d=\"M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg></button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n\n<!-- Flow Arrow -->\n<div class=\"flow-arrow-v\">\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><polyline points=\"19 12 12 19 5 12\"/></svg>\n</div>\n\n\n<!-- ==========================================\n     SCREEN 5B: DETAIL VIEW (SKILL.md selected)\n     ========================================== -->\n<div class=\"window-frame\" style=\"position: relative;\">\n    <span class=\"design-label\">Screen 5B — Skill Detail View (SKILL.md default)</span>\n    <div class=\"title-bar\">\n        <div class=\"traffic-lights\">\n            <div class=\"traffic-light close\"></div>\n            <div class=\"traffic-light minimize\"></div>\n            <div class=\"traffic-light maximize\"></div>\n        </div>\n    </div>\n\n    <div class=\"app-header\">\n        <div class=\"header-left\">\n            <div class=\"brand-area\">\n                <div class=\"brand-icon\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2L2 7l10 5 10-5-10-5z\"/><path d=\"M2 17l10 5 10-5\"/><path d=\"M2 12l10 5 10-5\"/></svg>\n                </div>\n                <span class=\"brand-text\">Skills Hub</span>\n            </div>\n            <nav class=\"nav-tabs\">\n                <button class=\"nav-tab active\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 9h18V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4z\"/><path d=\"M3 9v10a2 2 0 002 2h14a2 2 0 002-2V9\"/></svg>\n                    My Skills\n                </button>\n                <button class=\"nav-tab\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n                    Explore\n                </button>\n            </nav>\n        </div>\n        <div class=\"header-actions\">\n            <button class=\"lang-btn\">EN</button>\n            <button class=\"icon-btn\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42\"/></svg></button>\n        </div>\n    </div>\n\n    <div class=\"app-body\">\n        <div class=\"detail-view\">\n            <div class=\"detail-header\">\n                <button class=\"detail-back-btn\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m15 18-6-6 6-6\"/></svg>\n                    Back\n                </button>\n                <div class=\"detail-skill-name\">react</div>\n                <div class=\"detail-desc\">React renderer for json-render that turns JSON specs into React components.</div>\n                <div class=\"detail-meta\">\n                    <span class=\"detail-meta-item\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22\"/></svg>\n                        vercel-labs/json-render\n                    </span>\n                    <span class=\"detail-meta-dot\">&middot;</span>\n                    <span class=\"detail-meta-item\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><polyline points=\"12 6 12 12 16 14\"/></svg>\n                        Updated 22 min ago\n                    </span>\n                    <span class=\"detail-meta-dot\">&middot;</span>\n                    <span class=\"detail-meta-item\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n                        4 files\n                    </span>\n                </div>\n            </div>\n\n            <div class=\"detail-body\">\n                <!-- File sidebar -->\n                <div class=\"detail-file-list\">\n                    <div class=\"file-list-title\">Files</div>\n                    <div class=\"file-item active\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n                        <span class=\"file-item-name\">SKILL.md</span>\n                        <span class=\"file-item-size\">2.4 KB</span>\n                    </div>\n                    <div class=\"file-item\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n                        <span class=\"file-item-name\">component-spec.md</span>\n                        <span class=\"file-item-size\">1.8 KB</span>\n                    </div>\n                    <div class=\"file-item\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n                        <span class=\"file-item-name\">examples/basic.md</span>\n                        <span class=\"file-item-size\">3.1 KB</span>\n                    </div>\n                    <div class=\"file-item\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n                        <span class=\"file-item-name\">examples/advanced.md</span>\n                        <span class=\"file-item-size\">1.1 KB</span>\n                    </div>\n                </div>\n\n                <!-- File content -->\n                <div class=\"detail-file-content\">\n                    <div class=\"file-content-header\">\n                        <span class=\"file-content-path\">\n                            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n                            SKILL.md\n                        </span>\n                        <span class=\"file-content-size\">2.4 KB</span>\n                    </div>\n                    <pre class=\"file-content-pre\"># React Renderer\n\n## Description\nReact renderer for json-render that turns JSON specs\ninto React components.\n\n## When to use\n- Working with @json-render/react\n- Building React UIs from JSON specifications\n- Creating component catalogs from JSON definitions\n\n## Usage\n```jsx\nimport { render } from '@json-render/react';\n\nconst spec = {\n  type: 'Card',\n  props: { title: 'Hello' },\n  children: [\n    { type: 'Text', props: { value: 'World' } }\n  ]\n};\n\nconst App = () => render(spec);\n```\n\n## Configuration\n- `strict`: Enable strict type checking (default: true)\n- `components`: Custom component registry map\n- `fallback`: Fallback component for unknown types\n\n## Supported Components\nButton, Card, Text, Input, List, Grid, Modal, Tabs</pre>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n\n<!-- Flow Arrow -->\n<div class=\"flow-arrow-v\">\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><polyline points=\"19 12 12 19 5 12\"/></svg>\n</div>\n\n\n<!-- ==========================================\n     SCREEN 5C: DETAIL VIEW (switched file)\n     ========================================== -->\n<div class=\"window-frame\" style=\"position: relative;\">\n    <span class=\"design-label\">Screen 5C — Detail View (switched to another file)</span>\n    <div class=\"title-bar\">\n        <div class=\"traffic-lights\">\n            <div class=\"traffic-light close\"></div>\n            <div class=\"traffic-light minimize\"></div>\n            <div class=\"traffic-light maximize\"></div>\n        </div>\n    </div>\n\n    <div class=\"app-header\">\n        <div class=\"header-left\">\n            <div class=\"brand-area\">\n                <div class=\"brand-icon\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2L2 7l10 5 10-5-10-5z\"/><path d=\"M2 17l10 5 10-5\"/><path d=\"M2 12l10 5 10-5\"/></svg>\n                </div>\n                <span class=\"brand-text\">Skills Hub</span>\n            </div>\n            <nav class=\"nav-tabs\">\n                <button class=\"nav-tab active\">My Skills</button>\n                <button class=\"nav-tab\">Explore</button>\n            </nav>\n        </div>\n        <div class=\"header-actions\">\n            <button class=\"lang-btn\">EN</button>\n            <button class=\"icon-btn\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42\"/></svg></button>\n        </div>\n    </div>\n\n    <div class=\"app-body\">\n        <div class=\"detail-view\">\n            <div class=\"detail-header\">\n                <button class=\"detail-back-btn\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m15 18-6-6 6-6\"/></svg>\n                    Back\n                </button>\n                <div class=\"detail-skill-name\">react</div>\n                <div class=\"detail-desc\">React renderer for json-render that turns JSON specs into React components.</div>\n                <div class=\"detail-meta\">\n                    <span class=\"detail-meta-item\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22\"/></svg>\n                        vercel-labs/json-render\n                    </span>\n                    <span class=\"detail-meta-dot\">&middot;</span>\n                    <span class=\"detail-meta-item\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><polyline points=\"12 6 12 12 16 14\"/></svg>\n                        Updated 22 min ago\n                    </span>\n                    <span class=\"detail-meta-dot\">&middot;</span>\n                    <span class=\"detail-meta-item\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n                        4 files\n                    </span>\n                </div>\n            </div>\n\n            <div class=\"detail-body\">\n                <div class=\"detail-file-list\">\n                    <div class=\"file-list-title\">Files</div>\n                    <div class=\"file-item\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n                        <span class=\"file-item-name\">SKILL.md</span>\n                        <span class=\"file-item-size\">2.4 KB</span>\n                    </div>\n                    <div class=\"file-item\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n                        <span class=\"file-item-name\">component-spec.md</span>\n                        <span class=\"file-item-size\">1.8 KB</span>\n                    </div>\n                    <div class=\"file-item active\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n                        <span class=\"file-item-name\">examples/basic.md</span>\n                        <span class=\"file-item-size\">3.1 KB</span>\n                    </div>\n                    <div class=\"file-item\">\n                        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n                        <span class=\"file-item-name\">examples/advanced.md</span>\n                        <span class=\"file-item-size\">1.1 KB</span>\n                    </div>\n                </div>\n\n                <div class=\"detail-file-content\">\n                    <div class=\"file-content-header\">\n                        <span class=\"file-content-path\">\n                            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/></svg>\n                            examples/basic.md\n                        </span>\n                        <span class=\"file-content-size\">3.1 KB</span>\n                    </div>\n                    <pre class=\"file-content-pre\"># Basic Examples\n\n## Simple Card\n```json\n{\n  \"type\": \"Card\",\n  \"props\": {\n    \"title\": \"Welcome\",\n    \"variant\": \"outlined\"\n  },\n  \"children\": [\n    {\n      \"type\": \"Text\",\n      \"props\": { \"value\": \"Hello, World!\" }\n    }\n  ]\n}\n```\n\n## Button Group\n```json\n{\n  \"type\": \"ButtonGroup\",\n  \"props\": { \"direction\": \"horizontal\" },\n  \"children\": [\n    {\n      \"type\": \"Button\",\n      \"props\": { \"label\": \"Save\", \"variant\": \"primary\" }\n    },\n    {\n      \"type\": \"Button\",\n      \"props\": { \"label\": \"Cancel\", \"variant\": \"secondary\" }\n    }\n  ]\n}\n```\n\n## Form with Input\n```json\n{\n  \"type\": \"Form\",\n  \"props\": { \"onSubmit\": \"handleSubmit\" },\n  \"children\": [\n    {\n      \"type\": \"Input\",\n      \"props\": {\n        \"name\": \"email\",\n        \"label\": \"Email Address\",\n        \"type\": \"email\",\n        \"required\": true\n      }\n    }\n  ]\n}\n```</pre>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n\n<!-- Flow Arrow -->\n<div class=\"flow-arrow-v\">\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><polyline points=\"19 12 12 19 5 12\"/></svg>\n</div>\n\n\n<!-- ==========================================\n     SCREEN 5D: DETAIL VIEW (loading state)\n     ========================================== -->\n<div class=\"window-frame\" style=\"position: relative; height: 420px;\">\n    <span class=\"design-label\">Screen 5D — Detail View (loading state)</span>\n    <div class=\"title-bar\">\n        <div class=\"traffic-lights\">\n            <div class=\"traffic-light close\"></div>\n            <div class=\"traffic-light minimize\"></div>\n            <div class=\"traffic-light maximize\"></div>\n        </div>\n    </div>\n\n    <div class=\"app-header\">\n        <div class=\"header-left\">\n            <div class=\"brand-area\">\n                <div class=\"brand-icon\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2L2 7l10 5 10-5-10-5z\"/><path d=\"M2 17l10 5 10-5\"/><path d=\"M2 12l10 5 10-5\"/></svg>\n                </div>\n                <span class=\"brand-text\">Skills Hub</span>\n            </div>\n            <nav class=\"nav-tabs\">\n                <button class=\"nav-tab active\">My Skills</button>\n                <button class=\"nav-tab\">Explore</button>\n            </nav>\n        </div>\n    </div>\n\n    <div class=\"app-body\">\n        <div class=\"detail-view\">\n            <div class=\"detail-header\">\n                <button class=\"detail-back-btn\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m15 18-6-6 6-6\"/></svg>\n                    Back\n                </button>\n                <div class=\"detail-skill-name\">react</div>\n            </div>\n\n            <div class=\"detail-body\">\n                <div class=\"detail-file-list\">\n                    <div class=\"file-list-title\">Files</div>\n                    <div class=\"file-content-loading\" style=\"height: 120px;\">\n                        <div class=\"spinner-sm\"></div>\n                        Loading...\n                    </div>\n                </div>\n                <div class=\"detail-file-content\">\n                    <div class=\"file-content-loading\">\n                        <div class=\"spinner-sm\"></div>\n                        Loading file content...\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n\n</body>\n</html>\n"
  },
  {
    "path": "docs/tag_profile_interactive_prototype.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <title>Skills Hub - Tags and Profiles Prototype</title>\n  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n  <link href=\"https://fonts.googleapis.com/css2?family=Fira+Sans:wght@300;400;500;600;700&family=Fira+Code:wght@400;500;600&display=swap\" rel=\"stylesheet\">\n  <style>\n    :root {\n      --bg-app: #ffffff;\n      --bg-page: #e5e5e5;\n      --bg-panel: #fcfcfc;\n      --bg-element: #f4f4f5;\n      --bg-element-hover: #e4e4e7;\n      --border-subtle: #e4e4e7;\n      --border-strong: #d4d4d8;\n      --text-primary: #18181b;\n      --text-secondary: #52525b;\n      --text-tertiary: #a1a1aa;\n      --accent-primary: #2563EB;\n      --accent-primary-hover: #1D4ED8;\n      --accent-primary-fg: #FFFFFF;\n      --accent-primary-light: #EFF6FF;\n      --accent-primary-border: #BFDBFE;\n      --status-success: #059669;\n      --status-success-light: #ECFDF5;\n      --status-success-border: #A7F3D0;\n      --status-warning: #d97706;\n      --status-warning-light: #FFFBEB;\n      --status-warning-border: #FDE68A;\n      --status-error: #dc2626;\n      --status-error-light: #FEF2F2;\n      --status-error-border: #FECACA;\n      --font-ui: 'Fira Sans', system-ui, -apple-system, sans-serif;\n      --font-mono: 'Fira Code', monospace;\n      --radius-sm: 4px;\n      --radius-md: 8px;\n      --radius-lg: 12px;\n      --radius-xl: 16px;\n      --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);\n      --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.05);\n      --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.03);\n    }\n\n    * { box-sizing: border-box; margin: 0; padding: 0; }\n\n    body {\n      min-height: 100vh;\n      background: var(--bg-page);\n      color: var(--text-primary);\n      font-family: var(--font-ui);\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      padding: 32px;\n      -webkit-font-smoothing: antialiased;\n    }\n\n    button,\n    input {\n      font: inherit;\n    }\n\n    button {\n      cursor: pointer;\n    }\n\n    .window-frame {\n      width: min(1180px, calc(100vw - 48px));\n      height: min(760px, calc(100vh - 48px));\n      min-height: 680px;\n      background: var(--bg-app);\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-lg);\n      box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.16);\n      display: flex;\n      flex-direction: column;\n      overflow: hidden;\n      position: relative;\n    }\n\n    .title-bar {\n      height: 40px;\n      background: var(--bg-app);\n      border-bottom: 1px solid var(--border-subtle);\n      display: flex;\n      align-items: center;\n      padding: 0 16px;\n      flex-shrink: 0;\n    }\n\n    .traffic-lights { display: flex; gap: 8px; }\n    .traffic-light { width: 12px; height: 12px; border-radius: 50%; }\n    .traffic-light.close { background: #ff5f56; border: 1px solid rgba(0,0,0,0.1); }\n    .traffic-light.minimize { background: #ffbd2e; border: 1px solid rgba(0,0,0,0.1); }\n    .traffic-light.maximize { background: #27c93f; border: 1px solid rgba(0,0,0,0.1); }\n\n    .app-header {\n      height: 56px;\n      padding: 0 24px;\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      border-bottom: 1px solid var(--border-subtle);\n      background: var(--bg-app);\n      flex-shrink: 0;\n      z-index: 10;\n    }\n\n    .header-left,\n    .header-actions,\n    .brand-area,\n    .nav-tabs,\n    .toolbar-left,\n    .toolbar-right {\n      display: flex;\n      align-items: center;\n    }\n\n    .header-left { gap: 24px; }\n    .brand-area { gap: 10px; }\n    .brand-icon {\n      width: 28px;\n      height: 28px;\n      background: linear-gradient(135deg, #2563EB, #7C3AED);\n      border-radius: var(--radius-md);\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      color: white;\n    }\n    .brand-icon svg { width: 16px; height: 16px; }\n    .brand-text {\n      font-weight: 700;\n      font-size: 17px;\n      color: var(--text-primary);\n    }\n\n    .nav-tabs {\n      gap: 4px;\n      height: 56px;\n    }\n\n    .nav-tab {\n      height: 100%;\n      display: flex;\n      align-items: center;\n      gap: 6px;\n      padding: 0 14px;\n      font-size: 13px;\n      font-weight: 500;\n      color: var(--text-tertiary);\n      border: none;\n      background: none;\n      position: relative;\n      transition: color 0.2s;\n    }\n\n    .nav-tab:hover { color: var(--text-secondary); }\n    .nav-tab.active {\n      color: var(--accent-primary);\n      font-weight: 600;\n    }\n    .nav-tab.active::after {\n      content: '';\n      position: absolute;\n      bottom: 0;\n      left: 8px;\n      right: 8px;\n      height: 2px;\n      background: var(--accent-primary);\n      border-radius: 2px 2px 0 0;\n    }\n    .nav-tab svg { width: 16px; height: 16px; }\n\n    .header-actions { gap: 10px; }\n    .lang-btn {\n      height: 32px;\n      font-size: 12px;\n      font-weight: 500;\n      color: var(--text-secondary);\n      padding: 0 10px;\n      border-radius: var(--radius-sm);\n      background: transparent;\n      border: 1px solid var(--border-subtle);\n      font-family: var(--font-mono);\n    }\n\n    .icon-btn,\n    .action-btn {\n      width: 32px;\n      height: 32px;\n      display: inline-flex;\n      align-items: center;\n      justify-content: center;\n      border-radius: var(--radius-md);\n      color: var(--text-secondary);\n      border: none;\n      background: none;\n      transition: all 0.15s;\n    }\n    .icon-btn:hover,\n    .action-btn:hover {\n      background: var(--bg-element);\n      color: var(--text-primary);\n    }\n    .icon-btn svg,\n    .action-btn svg { width: 16px; height: 16px; }\n\n    .btn {\n      height: 32px;\n      padding: 0 14px;\n      border-radius: var(--radius-md);\n      font-size: 13px;\n      font-weight: 500;\n      display: inline-flex;\n      align-items: center;\n      justify-content: center;\n      gap: 6px;\n      border: 1px solid transparent;\n      transition: all 0.2s;\n      white-space: nowrap;\n    }\n    .btn svg { width: 14px; height: 14px; }\n    .btn-primary {\n      background: var(--accent-primary);\n      color: var(--accent-primary-fg);\n    }\n    .btn-primary:hover { background: var(--accent-primary-hover); }\n    .btn-secondary {\n      background: transparent;\n      border-color: var(--border-subtle);\n      color: var(--text-primary);\n    }\n    .btn-secondary:hover {\n      border-color: var(--border-strong);\n      background: var(--bg-element);\n    }\n    .btn-ghost {\n      background: transparent;\n      color: var(--text-secondary);\n      border-color: transparent;\n      padding: 0 8px;\n    }\n    .btn-ghost:hover {\n      color: var(--text-primary);\n      background: var(--bg-element);\n    }\n    .btn-sm { height: 28px; padding: 0 10px; font-size: 12px; }\n\n    .app-body {\n      flex: 1;\n      min-height: 0;\n      display: flex;\n      flex-direction: column;\n      overflow: hidden;\n    }\n\n    .view {\n      flex: 1;\n      min-height: 0;\n      display: none;\n      flex-direction: column;\n      overflow: hidden;\n    }\n    .view.active { display: flex; }\n\n    .toolbar {\n      padding: 16px 24px 12px;\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      flex-shrink: 0;\n      gap: 16px;\n    }\n    .toolbar-left { gap: 12px; min-width: 0; }\n    .toolbar-right { gap: 8px; }\n    .toolbar-title {\n      font-size: 13px;\n      font-weight: 600;\n      color: var(--text-secondary);\n      white-space: nowrap;\n    }\n    .toolbar-count {\n      font-size: 12px;\n      color: var(--text-tertiary);\n      font-family: var(--font-mono);\n      background: var(--bg-element);\n      padding: 2px 8px;\n      border-radius: 10px;\n    }\n\n    .profile-switcher {\n      height: 32px;\n      display: inline-flex;\n      align-items: center;\n      gap: 8px;\n      padding: 0 10px;\n      border: 1px solid var(--accent-primary-border);\n      border-radius: var(--radius-md);\n      background: var(--accent-primary-light);\n      color: var(--accent-primary);\n      font-size: 13px;\n      font-weight: 600;\n      position: relative;\n    }\n    .profile-switcher svg { width: 14px; height: 14px; }\n    .profile-menu {\n      position: absolute;\n      top: 36px;\n      left: 0;\n      width: 250px;\n      padding: 6px;\n      background: var(--bg-app);\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-lg);\n      box-shadow: var(--shadow-lg);\n      z-index: 30;\n      display: none;\n    }\n    .profile-menu.open { display: block; }\n    .profile-menu-item {\n      width: 100%;\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      border: none;\n      background: transparent;\n      padding: 9px 10px;\n      border-radius: var(--radius-md);\n      color: var(--text-secondary);\n      text-align: left;\n      font-size: 13px;\n    }\n    .profile-menu-item:hover { background: var(--bg-element); color: var(--text-primary); }\n    .profile-menu-item.active { color: var(--accent-primary); background: var(--accent-primary-light); }\n    .profile-menu-item small {\n      font-size: 11px;\n      color: var(--text-tertiary);\n      font-family: var(--font-mono);\n    }\n    .profile-menu-divider {\n      height: 1px;\n      background: var(--border-subtle);\n      margin: 6px 4px;\n    }\n\n    .search-box {\n      position: relative;\n      flex-shrink: 0;\n    }\n    .search-box input {\n      background: var(--bg-panel);\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-md);\n      height: 32px;\n      padding: 0 12px 0 32px;\n      color: var(--text-primary);\n      width: 230px;\n      font-size: 13px;\n      transition: all 0.2s;\n    }\n    .search-box input:focus {\n      outline: none;\n      border-color: var(--accent-primary);\n      box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);\n      width: 260px;\n    }\n    .search-box svg {\n      position: absolute;\n      left: 10px;\n      top: 50%;\n      transform: translateY(-50%);\n      width: 14px;\n      height: 14px;\n      color: var(--text-tertiary);\n      pointer-events: none;\n    }\n\n    .filter-select {\n      height: 32px;\n      padding: 0 12px;\n      border-radius: var(--radius-md);\n      border: 1px solid var(--border-subtle);\n      background: var(--bg-app);\n      color: var(--text-primary);\n      font-size: 13px;\n      font-weight: 600;\n      display: inline-flex;\n      align-items: center;\n      gap: 7px;\n      white-space: nowrap;\n      transition: all 0.2s;\n      position: relative;\n    }\n    .filter-select:hover {\n      border-color: var(--border-strong);\n      background: var(--bg-element);\n    }\n    .filter-select.active {\n      color: var(--accent-primary);\n      border-color: var(--accent-primary-border);\n      background: var(--accent-primary-light);\n    }\n    .filter-select svg { width: 14px; height: 14px; }\n    .filter-dropdown {\n      position: absolute;\n      top: 36px;\n      left: 0;\n      width: 292px;\n      background: var(--bg-app);\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-lg);\n      box-shadow: var(--shadow-lg);\n      padding: 10px;\n      z-index: 40;\n      display: none;\n      color: var(--text-primary);\n    }\n    .filter-dropdown.open { display: block; }\n    .dropdown-title {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      gap: 10px;\n      font-size: 13px;\n      font-weight: 700;\n      margin-bottom: 8px;\n    }\n    .dropdown-search {\n      position: relative;\n      margin-bottom: 8px;\n    }\n    .dropdown-search input {\n      width: 100%;\n      height: 32px;\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-md);\n      background: var(--bg-panel);\n      padding: 0 10px 0 30px;\n      font-size: 12px;\n      color: var(--text-primary);\n    }\n    .dropdown-search input:focus {\n      outline: none;\n      border-color: var(--accent-primary);\n      box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);\n    }\n    .dropdown-search svg {\n      position: absolute;\n      left: 10px;\n      top: 50%;\n      transform: translateY(-50%);\n      width: 13px;\n      height: 13px;\n      color: var(--text-tertiary);\n    }\n    .tag-option-list {\n      max-height: 230px;\n      overflow: auto;\n      margin: 0 -4px;\n    }\n    .tag-option {\n      width: 100%;\n      min-height: 34px;\n      display: grid;\n      grid-template-columns: 24px minmax(0, 1fr) auto;\n      align-items: center;\n      gap: 8px;\n      border: none;\n      background: transparent;\n      border-radius: var(--radius-md);\n      padding: 0 8px;\n      color: var(--text-secondary);\n      text-align: left;\n      font-size: 13px;\n    }\n    .tag-option:hover {\n      background: var(--bg-element);\n      color: var(--text-primary);\n    }\n    .tag-option .option-count {\n      font-family: var(--font-mono);\n      font-size: 11px;\n      color: var(--text-tertiary);\n    }\n    .dropdown-footer {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      gap: 8px;\n      border-top: 1px solid var(--border-subtle);\n      margin-top: 8px;\n      padding-top: 8px;\n    }\n\n    .skills-scroll {\n      flex: 1;\n      overflow: auto;\n      padding: 0 24px 24px;\n    }\n\n    .skill-row {\n      display: flex;\n      align-items: flex-start;\n      gap: 16px;\n      padding: 16px 20px;\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-lg);\n      margin-bottom: 8px;\n      transition: all 0.2s;\n      background: var(--bg-app);\n      position: relative;\n    }\n    .skill-row:hover {\n      border-color: var(--border-strong);\n      background: var(--bg-panel);\n      box-shadow: var(--shadow-sm);\n    }\n    .skill-icon-sm {\n      width: 44px;\n      height: 44px;\n      background: var(--bg-element);\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-md);\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      color: var(--text-secondary);\n      flex-shrink: 0;\n      margin-top: 2px;\n    }\n    .skill-icon-sm svg { width: 20px; height: 20px; }\n    .skill-info {\n      display: flex;\n      flex-direction: column;\n      gap: 6px;\n      min-width: 0;\n      flex: 1;\n    }\n    .skill-name-row {\n      display: flex;\n      align-items: center;\n      gap: 8px;\n      min-width: 0;\n      padding-right: 80px;\n    }\n    .skill-name {\n      font-weight: 700;\n      font-size: 15px;\n      color: var(--text-primary);\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n    }\n    .skill-desc {\n      font-size: 13px;\n      color: var(--text-secondary);\n      line-height: 1.5;\n      display: -webkit-box;\n      -webkit-line-clamp: 2;\n      -webkit-box-orient: vertical;\n      overflow: hidden;\n    }\n    .skill-meta-row,\n    .tool-badges,\n    .skill-tags {\n      display: flex;\n      align-items: center;\n      flex-wrap: wrap;\n      gap: 5px;\n    }\n    .skill-source {\n      font-family: var(--font-mono);\n      font-size: 12px;\n      color: var(--text-tertiary);\n      background: var(--bg-element);\n      padding: 2px 8px;\n      border-radius: var(--radius-sm);\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n      max-width: 260px;\n    }\n    .skill-time {\n      font-size: 12px;\n      color: var(--text-tertiary);\n    }\n    .inline-tag {\n      height: 20px;\n      padding: 0 7px;\n      border-radius: 10px;\n      background: var(--bg-element);\n      color: var(--text-secondary);\n      border: 1px solid var(--border-subtle);\n      font-size: 11px;\n      font-weight: 500;\n      display: inline-flex;\n      align-items: center;\n    }\n    .inline-tag.empty {\n      color: var(--text-tertiary);\n      border-style: dashed;\n      background: var(--bg-panel);\n    }\n    .tool-badge {\n      font-size: 11px;\n      font-weight: 500;\n      padding: 2px 8px;\n      border-radius: 10px;\n      white-space: nowrap;\n      display: flex;\n      align-items: center;\n      gap: 4px;\n    }\n    .tool-badge.synced {\n      background: var(--status-success-light);\n      color: var(--status-success);\n      border: 1px solid var(--status-success-border);\n    }\n    .tool-badge.ghost {\n      background: var(--bg-element);\n      color: var(--text-tertiary);\n      border: 1px solid var(--border-subtle);\n      font-family: var(--font-mono);\n      font-size: 10px;\n    }\n    .row-actions {\n      display: flex;\n      align-items: center;\n      gap: 2px;\n      flex-shrink: 0;\n      margin-top: 2px;\n      position: absolute;\n      top: 14px;\n      right: 14px;\n    }\n\n    .empty-state {\n      border: 1px dashed var(--border-strong);\n      border-radius: var(--radius-lg);\n      padding: 44px 20px;\n      text-align: center;\n      color: var(--text-secondary);\n      background: var(--bg-panel);\n    }\n    .empty-state strong {\n      display: block;\n      color: var(--text-primary);\n      font-size: 15px;\n      margin-bottom: 4px;\n    }\n\n    .profiles-layout {\n      flex: 1;\n      min-height: 0;\n      display: grid;\n      grid-template-columns: 300px minmax(0, 1fr);\n      border-top: 1px solid var(--border-subtle);\n    }\n    .tags-page {\n      flex: 1;\n      min-height: 0;\n      overflow: auto;\n      padding: 20px 24px 24px;\n      border-top: 1px solid var(--border-subtle);\n    }\n    .page-heading {\n      display: flex;\n      align-items: flex-start;\n      justify-content: space-between;\n      gap: 16px;\n      margin-bottom: 18px;\n    }\n    .breadcrumb-button {\n      border: none;\n      background: transparent;\n      color: var(--text-secondary);\n      font-size: 13px;\n      font-weight: 600;\n      display: inline-flex;\n      align-items: center;\n      gap: 6px;\n      margin-bottom: 8px;\n      padding: 0;\n    }\n    .breadcrumb-button:hover { color: var(--accent-primary); }\n    .page-title {\n      font-size: 22px;\n      font-weight: 700;\n      color: var(--text-primary);\n      margin-bottom: 4px;\n    }\n    .page-desc {\n      font-size: 13px;\n      color: var(--text-secondary);\n      line-height: 1.5;\n      max-width: 620px;\n    }\n    .tag-table-toolbar {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      gap: 12px;\n      margin-bottom: 12px;\n    }\n    .untagged-callout {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      gap: 16px;\n      padding: 14px 16px;\n      border: 1px dashed var(--accent-primary-border);\n      border-radius: var(--radius-lg);\n      background: var(--accent-primary-light);\n      margin-bottom: 12px;\n    }\n    .untagged-callout strong {\n      display: block;\n      font-size: 13px;\n      color: var(--text-primary);\n      margin-bottom: 2px;\n    }\n    .untagged-callout span {\n      font-size: 12px;\n      color: var(--text-secondary);\n    }\n    .tag-table {\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-lg);\n      overflow: hidden;\n      background: var(--bg-app);\n    }\n    .tag-table-row {\n      min-height: 52px;\n      display: grid;\n      grid-template-columns: minmax(0, 1fr) 100px 130px auto;\n      align-items: center;\n      gap: 12px;\n      padding: 0 14px;\n      border-bottom: 1px solid var(--border-subtle);\n    }\n    .tag-table-row:last-child { border-bottom: none; }\n    .tag-table-row.header {\n      min-height: 40px;\n      background: var(--bg-panel);\n      color: var(--text-tertiary);\n      font-size: 12px;\n      font-weight: 700;\n    }\n    .tag-table-actions {\n      display: flex;\n      justify-content: flex-end;\n      gap: 4px;\n    }\n    .profiles-sidebar {\n      border-right: 1px solid var(--border-subtle);\n      background: var(--bg-panel);\n      overflow: auto;\n      padding: 16px;\n    }\n    .profile-card {\n      width: 100%;\n      text-align: left;\n      background: var(--bg-app);\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-lg);\n      padding: 14px;\n      margin-bottom: 8px;\n      transition: all 0.2s;\n    }\n    .profile-card:hover {\n      border-color: var(--border-strong);\n      box-shadow: var(--shadow-sm);\n    }\n    .profile-card.active {\n      border-color: var(--accent-primary-border);\n      background: var(--accent-primary-light);\n    }\n    .profile-card-top {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      gap: 8px;\n      margin-bottom: 5px;\n    }\n    .profile-name {\n      font-size: 14px;\n      font-weight: 700;\n      color: var(--text-primary);\n    }\n    .status-pill {\n      height: 20px;\n      padding: 0 7px;\n      border-radius: 10px;\n      font-size: 10px;\n      font-weight: 700;\n      text-transform: uppercase;\n      display: inline-flex;\n      align-items: center;\n      white-space: nowrap;\n    }\n    .status-pill.active {\n      background: var(--status-success-light);\n      color: var(--status-success);\n      border: 1px solid var(--status-success-border);\n    }\n    .status-pill.draft {\n      background: var(--status-warning-light);\n      color: var(--status-warning);\n      border: 1px solid var(--status-warning-border);\n    }\n    .profile-card-desc {\n      font-size: 12px;\n      color: var(--text-secondary);\n      line-height: 1.45;\n      margin-bottom: 10px;\n    }\n    .profile-card-meta {\n      display: flex;\n      gap: 8px;\n      flex-wrap: wrap;\n    }\n    .profile-card-meta span {\n      font-family: var(--font-mono);\n      font-size: 11px;\n      color: var(--text-tertiary);\n      background: var(--bg-element);\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-sm);\n      padding: 2px 6px;\n    }\n\n    .profile-detail {\n      min-width: 0;\n      overflow: auto;\n      padding: 20px 24px 24px;\n    }\n    .detail-header {\n      display: flex;\n      align-items: flex-start;\n      justify-content: space-between;\n      gap: 16px;\n      margin-bottom: 18px;\n    }\n    .detail-title {\n      font-size: 22px;\n      font-weight: 700;\n      color: var(--text-primary);\n      margin-bottom: 4px;\n    }\n    .detail-desc {\n      font-size: 13px;\n      color: var(--text-secondary);\n      line-height: 1.5;\n      max-width: 620px;\n    }\n    .detail-actions {\n      display: flex;\n      gap: 8px;\n      flex-shrink: 0;\n    }\n    .section {\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-lg);\n      background: var(--bg-app);\n      margin-bottom: 12px;\n      overflow: hidden;\n    }\n    .section-header {\n      min-height: 44px;\n      padding: 0 14px;\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      border-bottom: 1px solid var(--border-subtle);\n      background: var(--bg-panel);\n    }\n    .section-title {\n      display: flex;\n      align-items: center;\n      gap: 8px;\n      font-size: 13px;\n      font-weight: 700;\n      color: var(--text-primary);\n    }\n    .section-title svg { width: 15px; height: 15px; color: var(--text-tertiary); }\n    .section-note {\n      font-size: 12px;\n      color: var(--text-tertiary);\n    }\n    .tool-grid {\n      padding: 14px;\n      display: flex;\n      gap: 8px;\n      flex-wrap: wrap;\n    }\n    .select-chip {\n      min-height: 32px;\n      display: inline-flex;\n      align-items: center;\n      gap: 7px;\n      padding: 0 10px;\n      border-radius: 16px;\n      border: 1px solid var(--border-subtle);\n      background: var(--bg-app);\n      color: var(--text-secondary);\n      font-size: 12px;\n      font-weight: 500;\n      transition: all 0.15s;\n    }\n    .select-chip:hover { border-color: var(--border-strong); background: var(--bg-element); }\n    .select-chip.selected {\n      background: var(--accent-primary-light);\n      border-color: var(--accent-primary-border);\n      color: var(--accent-primary);\n    }\n    .select-chip .check-dot {\n      width: 7px;\n      height: 7px;\n      border-radius: 50%;\n      background: var(--accent-primary);\n      display: none;\n    }\n    .select-chip.selected .check-dot { display: block; }\n\n    .profile-skill-list {\n      padding: 8px 14px 14px;\n    }\n    .profile-skill-row {\n      min-height: 48px;\n      display: grid;\n      grid-template-columns: 28px minmax(0, 1fr) auto;\n      align-items: center;\n      gap: 10px;\n      border-bottom: 1px solid var(--border-subtle);\n    }\n    .profile-skill-row:last-child { border-bottom: none; }\n    .checkbox {\n      width: 18px;\n      height: 18px;\n      border-radius: var(--radius-sm);\n      border: 1px solid var(--border-strong);\n      background: var(--bg-app);\n      display: inline-flex;\n      align-items: center;\n      justify-content: center;\n      color: white;\n      transition: all 0.15s;\n    }\n    .checkbox.checked {\n      background: var(--accent-primary);\n      border-color: var(--accent-primary);\n    }\n    .checkbox svg {\n      width: 13px;\n      height: 13px;\n      display: none;\n    }\n    .checkbox.checked svg { display: block; }\n    .profile-skill-main { min-width: 0; }\n    .profile-skill-name {\n      font-size: 13px;\n      font-weight: 700;\n      color: var(--text-primary);\n      margin-bottom: 2px;\n    }\n    .profile-skill-description {\n      font-size: 12px;\n      color: var(--text-secondary);\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n    }\n    .profile-skill-tags {\n      display: flex;\n      justify-content: flex-end;\n      gap: 5px;\n      flex-wrap: wrap;\n      max-width: 260px;\n    }\n\n    .preview-grid {\n      display: grid;\n      grid-template-columns: repeat(3, 1fr);\n      gap: 8px;\n      padding: 14px;\n    }\n    .preview-card {\n      border-radius: var(--radius-md);\n      border: 1px solid var(--border-subtle);\n      background: var(--bg-panel);\n      padding: 12px;\n    }\n    .preview-card strong {\n      display: block;\n      font-size: 18px;\n      margin-bottom: 3px;\n    }\n    .preview-card span {\n      font-size: 12px;\n      color: var(--text-secondary);\n    }\n    .preview-card.add strong { color: var(--status-success); }\n    .preview-card.remove strong { color: var(--status-error); }\n    .preview-card.keep strong { color: var(--accent-primary); }\n\n    .modal-backdrop {\n      position: absolute;\n      inset: 0;\n      background: rgba(0,0,0,0.34);\n      display: none;\n      align-items: center;\n      justify-content: center;\n      z-index: 100;\n      backdrop-filter: blur(2px);\n    }\n    .modal-backdrop.open { display: flex; }\n    .modal {\n      width: 520px;\n      max-width: calc(100% - 48px);\n      background: var(--bg-app);\n      border-radius: var(--radius-xl);\n      border: 1px solid var(--border-subtle);\n      box-shadow: var(--shadow-lg);\n      overflow: hidden;\n    }\n    .modal-header {\n      padding: 18px 22px 14px;\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      gap: 12px;\n    }\n    .modal-title {\n      font-size: 16px;\n      font-weight: 700;\n      color: var(--text-primary);\n    }\n    .modal-close {\n      width: 30px;\n      height: 30px;\n      border-radius: var(--radius-md);\n      border: none;\n      background: transparent;\n      color: var(--text-tertiary);\n      font-size: 18px;\n    }\n    .modal-close:hover { background: var(--bg-element); color: var(--text-primary); }\n    .modal-body {\n      padding: 0 22px 18px;\n    }\n    .modal-footer {\n      padding: 14px 22px;\n      display: flex;\n      justify-content: flex-end;\n      gap: 8px;\n      border-top: 1px solid var(--border-subtle);\n      background: var(--bg-panel);\n    }\n    .change-list {\n      display: grid;\n      gap: 8px;\n      margin-top: 12px;\n    }\n    .change-item {\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      gap: 12px;\n      padding: 10px 12px;\n      border-radius: var(--radius-md);\n      border: 1px solid var(--border-subtle);\n      background: var(--bg-panel);\n      font-size: 13px;\n    }\n    .change-item .kind {\n      font-family: var(--font-mono);\n      font-size: 11px;\n      font-weight: 700;\n    }\n    .change-item.add { border-color: var(--status-success-border); background: var(--status-success-light); }\n    .change-item.add .kind { color: var(--status-success); }\n    .change-item.remove { border-color: var(--status-error-border); background: var(--status-error-light); }\n    .change-item.remove .kind { color: var(--status-error); }\n    .change-item.keep .kind { color: var(--accent-primary); }\n\n    .tag-editor-list {\n      display: flex;\n      flex-wrap: wrap;\n      gap: 8px;\n      margin-top: 12px;\n    }\n    .tag-manager-toolbar {\n      display: flex;\n      align-items: center;\n      gap: 8px;\n      margin-top: 12px;\n    }\n    .tag-manager-toolbar .form-input {\n      flex: 1;\n      height: 34px;\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-md);\n      padding: 0 10px;\n      background: var(--bg-panel);\n      color: var(--text-primary);\n      font-size: 13px;\n    }\n    .tag-manager-toolbar .form-input:focus {\n      outline: none;\n      border-color: var(--accent-primary);\n      box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);\n    }\n    .tag-manager-list {\n      margin-top: 14px;\n      border: 1px solid var(--border-subtle);\n      border-radius: var(--radius-lg);\n      overflow: hidden;\n    }\n    .tag-manager-row {\n      min-height: 46px;\n      display: grid;\n      grid-template-columns: minmax(0, 1fr) 90px auto;\n      align-items: center;\n      gap: 12px;\n      padding: 8px 10px;\n      border-bottom: 1px solid var(--border-subtle);\n      background: var(--bg-app);\n    }\n    .tag-manager-row:last-child { border-bottom: none; }\n    .tag-manager-name {\n      display: flex;\n      align-items: center;\n      gap: 8px;\n      min-width: 0;\n      font-size: 13px;\n      font-weight: 600;\n    }\n    .tag-color-dot {\n      width: 8px;\n      height: 8px;\n      border-radius: 50%;\n      background: var(--accent-primary);\n      flex-shrink: 0;\n    }\n    .tag-manager-count {\n      font-family: var(--font-mono);\n      font-size: 11px;\n      color: var(--text-tertiary);\n      white-space: nowrap;\n    }\n    .tag-manager-actions {\n      display: flex;\n      justify-content: flex-end;\n      gap: 4px;\n    }\n\n    .toast {\n      position: absolute;\n      left: 50%;\n      bottom: 18px;\n      transform: translateX(-50%) translateY(24px);\n      opacity: 0;\n      pointer-events: none;\n      background: var(--text-primary);\n      color: white;\n      border-radius: var(--radius-lg);\n      padding: 10px 16px;\n      display: flex;\n      align-items: center;\n      gap: 8px;\n      font-size: 13px;\n      font-weight: 600;\n      box-shadow: var(--shadow-lg);\n      transition: all 0.2s;\n      z-index: 200;\n    }\n    .toast.show {\n      transform: translateX(-50%) translateY(0);\n      opacity: 1;\n    }\n    .toast svg { width: 16px; height: 16px; color: #34D399; }\n\n    @media (max-width: 900px) {\n      body { padding: 16px; align-items: flex-start; }\n      .window-frame {\n        width: calc(100vw - 32px);\n        height: calc(100vh - 32px);\n        min-height: 720px;\n      }\n      .profiles-layout {\n        grid-template-columns: 1fr;\n      }\n      .profiles-sidebar {\n        max-height: 220px;\n        border-right: none;\n        border-bottom: 1px solid var(--border-subtle);\n      }\n      .toolbar {\n        align-items: flex-start;\n        flex-direction: column;\n      }\n      .toolbar-right,\n      .search-box,\n      .search-box input {\n        width: 100%;\n      }\n      .search-box input:focus { width: 100%; }\n      .preview-grid { grid-template-columns: 1fr; }\n    }\n  </style>\n</head>\n<body>\n  <main class=\"window-frame\" aria-label=\"Skills Hub tags and profiles prototype\">\n    <div class=\"title-bar\">\n      <div class=\"traffic-lights\" aria-hidden=\"true\">\n        <div class=\"traffic-light close\"></div>\n        <div class=\"traffic-light minimize\"></div>\n        <div class=\"traffic-light maximize\"></div>\n      </div>\n    </div>\n\n    <header class=\"app-header\">\n      <div class=\"header-left\">\n        <div class=\"brand-area\">\n          <div class=\"brand-icon\" aria-hidden=\"true\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2L2 7l10 5 10-5-10-5z\"/><path d=\"M2 17l10 5 10-5\"/><path d=\"M2 12l10 5 10-5\"/></svg>\n          </div>\n          <span class=\"brand-text\">Skills Hub</span>\n        </div>\n        <nav class=\"nav-tabs\" aria-label=\"Primary\">\n          <button class=\"nav-tab active\" data-nav=\"skills\" type=\"button\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M3 9h18V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4z\"/><path d=\"M3 9v10a2 2 0 002 2h14a2 2 0 002-2V9\"/><line x1=\"12\" y1=\"13\" x2=\"12\" y2=\"17\"/></svg>\n            My Skills\n          </button>\n          <button class=\"nav-tab\" data-nav=\"profiles\" type=\"button\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"M7 8h10M7 12h6M7 16h8\"/></svg>\n            Profiles\n          </button>\n          <button class=\"nav-tab\" type=\"button\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n            Explore\n          </button>\n        </nav>\n      </div>\n      <div class=\"header-actions\">\n        <button class=\"lang-btn\" type=\"button\">EN</button>\n        <button class=\"icon-btn\" aria-label=\"Settings\" type=\"button\">\n          <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42\"/></svg>\n        </button>\n      </div>\n    </header>\n\n    <div class=\"app-body\">\n      <section class=\"view active\" id=\"skillsView\" aria-label=\"My Skills\">\n        <div class=\"toolbar\">\n          <div class=\"toolbar-left\">\n            <span class=\"toolbar-title\">All Skills</span>\n            <span class=\"toolbar-count\" id=\"skillCount\">0</span>\n            <button class=\"filter-select\" type=\"button\">\n              All\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"6 9 12 15 18 9\"/></svg>\n            </button>\n            <button class=\"filter-select\" type=\"button\">\n              Most recent\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M7 3v18M7 21l-4-4M7 21l4-4M17 21V3M17 3l-4 4M17 3l4 4\"/></svg>\n            </button>\n            <div class=\"filter-select\" id=\"tagFilterButton\" role=\"button\" tabindex=\"0\" aria-haspopup=\"true\" aria-expanded=\"false\">\n              <span id=\"tagFilterLabel\">Tags</span>\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"6 9 12 15 18 9\"/></svg>\n              <div class=\"filter-dropdown\" id=\"tagFilterDropdown\"></div>\n            </div>\n            <div class=\"profile-switcher\" id=\"profileSwitcher\" role=\"button\" tabindex=\"0\" aria-haspopup=\"true\" aria-expanded=\"false\">\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"M7 8h10M7 12h6\"/></svg>\n              <span>Current Profile: <span id=\"currentProfileName\">Skills Hub Dev</span></span>\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"6 9 12 15 18 9\"/></svg>\n              <div class=\"profile-menu\" id=\"profileMenu\" role=\"menu\"></div>\n            </div>\n          </div>\n          <div class=\"toolbar-right\">\n            <div class=\"search-box\">\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n              <input id=\"skillSearch\" type=\"search\" placeholder=\"Search skills...\" />\n            </div>\n          </div>\n        </div>\n\n        <div class=\"skills-scroll\">\n          <div id=\"skillsList\"></div>\n        </div>\n      </section>\n\n      <section class=\"view\" id=\"tagsView\" aria-label=\"Manage Tags\">\n        <div class=\"tags-page\">\n          <div class=\"page-heading\">\n            <div>\n              <button class=\"breadcrumb-button\" type=\"button\" id=\"backToSkillsFromTags\">\n                <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"15 18 9 12 15 6\"/></svg>\n                My Skills\n              </button>\n              <h1 class=\"page-title\">Tags</h1>\n              <p class=\"page-desc\">标签用于筛选和整理 skills，不会改变 Profile，也不会影响同步结果。</p>\n            </div>\n            <button class=\"btn btn-primary\" type=\"button\" id=\"newTagFromPage\">New Tag</button>\n          </div>\n          <div class=\"tag-table-toolbar\">\n            <div class=\"search-box\">\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n              <input id=\"tagManagerSearch\" type=\"search\" placeholder=\"Search tags...\" />\n            </div>\n            <span class=\"section-note\" id=\"tagManagerCount\">0 tags</span>\n          </div>\n          <div id=\"untaggedCallout\"></div>\n          <div class=\"tag-table\" id=\"tagTable\"></div>\n        </div>\n      </section>\n\n      <section class=\"view\" id=\"profilesView\" aria-label=\"Profiles\">\n        <div class=\"toolbar\">\n          <div class=\"toolbar-left\">\n            <span class=\"toolbar-title\">Profiles</span>\n            <span class=\"toolbar-count\" id=\"profileCount\">0</span>\n            <span class=\"section-note\">一套 Profile = skills + target tools，用于批量同步</span>\n          </div>\n          <div class=\"toolbar-right\">\n            <button class=\"btn btn-sm btn-secondary\" type=\"button\" id=\"duplicateProfile\">\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><rect x=\"8\" y=\"8\" width=\"12\" height=\"12\" rx=\"2\"/><path d=\"M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2\"/></svg>\n              Duplicate\n            </button>\n            <button class=\"btn btn-sm btn-primary\" type=\"button\" id=\"newProfile\">\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n              New Profile\n            </button>\n          </div>\n        </div>\n\n        <div class=\"profiles-layout\">\n          <aside class=\"profiles-sidebar\" id=\"profilesSidebar\" aria-label=\"Profile list\"></aside>\n          <div class=\"profile-detail\">\n            <div class=\"detail-header\">\n              <div>\n                <h1 class=\"detail-title\" id=\"detailTitle\"></h1>\n                <p class=\"detail-desc\" id=\"detailDesc\"></p>\n              </div>\n              <div class=\"detail-actions\">\n                <button class=\"btn btn-secondary\" type=\"button\" id=\"previewProfileButton\">Preview Changes</button>\n                <button class=\"btn btn-primary\" type=\"button\" id=\"applyProfileButton\">Apply Profile</button>\n              </div>\n            </div>\n\n            <section class=\"section\">\n              <div class=\"section-header\">\n                <div class=\"section-title\">\n                  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M4 7h16M4 12h16M4 17h10\"/></svg>\n                  Target Tools\n                </div>\n                <span class=\"section-note\">这些工具会使用当前 Profile 的 skill 集合</span>\n              </div>\n              <div class=\"tool-grid\" id=\"toolGrid\"></div>\n            </section>\n\n            <section class=\"section\">\n              <div class=\"section-header\">\n                <div class=\"section-title\">\n                  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M3 9h18V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4z\"/><path d=\"M3 9v10a2 2 0 002 2h14a2 2 0 002-2V9\"/></svg>\n                  Included Skills\n                </div>\n                <span class=\"section-note\" id=\"selectedSkillNote\"></span>\n              </div>\n              <div class=\"profile-skill-list\" id=\"profileSkillList\"></div>\n            </section>\n\n            <section class=\"section\">\n              <div class=\"section-header\">\n                <div class=\"section-title\">\n                  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.12 2.12 0 113 3L7 19l-4 1 1-4Z\"/></svg>\n                  Apply Preview\n                </div>\n                <span class=\"section-note\">切换前先展示增删变化，避免误改工具内实际生效的 skills</span>\n              </div>\n              <div class=\"preview-grid\" id=\"previewGrid\"></div>\n            </section>\n          </div>\n        </div>\n      </section>\n    </div>\n\n    <div class=\"modal-backdrop\" id=\"applyModal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"applyModalTitle\">\n      <div class=\"modal\">\n        <div class=\"modal-header\">\n          <div>\n            <div class=\"modal-title\" id=\"applyModalTitle\">Apply Profile</div>\n            <div class=\"section-note\" id=\"applyModalSubtitle\"></div>\n          </div>\n          <button class=\"modal-close\" type=\"button\" data-close-modal aria-label=\"Close\">x</button>\n        </div>\n        <div class=\"modal-body\">\n          <div class=\"change-list\" id=\"changeList\"></div>\n        </div>\n        <div class=\"modal-footer\">\n          <button class=\"btn btn-secondary\" type=\"button\" data-close-modal>Cancel</button>\n          <button class=\"btn btn-primary\" type=\"button\" id=\"confirmApply\">Apply Changes</button>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"modal-backdrop\" id=\"tagModal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"tagModalTitle\">\n      <div class=\"modal\">\n        <div class=\"modal-header\">\n          <div>\n            <div class=\"modal-title\" id=\"tagModalTitle\">Edit Tags</div>\n            <div class=\"section-note\" id=\"tagModalSubtitle\"></div>\n          </div>\n          <button class=\"modal-close\" type=\"button\" data-close-modal aria-label=\"Close\">x</button>\n        </div>\n        <div class=\"modal-body\">\n          <div class=\"section-note\">Tag 是检索维度，不会改变 Profile 或同步结果。</div>\n          <div class=\"tag-editor-list\" id=\"tagEditorList\"></div>\n        </div>\n        <div class=\"modal-footer\">\n          <button class=\"btn btn-secondary\" type=\"button\" data-close-modal>Done</button>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"modal-backdrop\" id=\"tagManagerModal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"tagManagerTitle\">\n      <div class=\"modal\">\n        <div class=\"modal-header\">\n          <div>\n            <div class=\"modal-title\" id=\"tagManagerTitle\">Manage Tags</div>\n            <div class=\"section-note\">全局标签管理：创建、重命名、删除标签；不会改变 Profile 或同步状态。</div>\n          </div>\n          <button class=\"modal-close\" type=\"button\" data-close-modal aria-label=\"Close\">x</button>\n        </div>\n        <div class=\"modal-body\">\n          <div class=\"tag-manager-toolbar\">\n            <input class=\"form-input\" id=\"newTagInput\" type=\"text\" placeholder=\"New tag name, e.g. Backend\" />\n            <button class=\"btn btn-primary\" type=\"button\" id=\"createTagButton\">New Tag</button>\n          </div>\n          <div class=\"tag-manager-list\" id=\"tagManagerList\"></div>\n        </div>\n        <div class=\"modal-footer\">\n          <button class=\"btn btn-secondary\" type=\"button\" data-close-modal>Done</button>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"toast\" id=\"toast\" role=\"status\" aria-live=\"polite\">\n      <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\"><polyline points=\"20 6 9 17 4 12\"/></svg>\n      <span id=\"toastText\">Applied</span>\n    </div>\n  </main>\n\n  <script>\n    const tools = ['Cursor', 'Codex', 'Claude Code', 'OpenCode', 'Windsurf', 'Cline'];\n    let allTags = ['Frontend', 'React', 'Rust', 'Tauri', 'Docs', 'Testing', 'Diagram', 'Automation'];\n\n    const skills = [\n      {\n        id: 'react',\n        name: 'react',\n        desc: 'React renderer for JSON specs and component catalogs.',\n        source: 'vercel-labs/json-render',\n        time: '22 min ago',\n        tags: ['Frontend', 'React'],\n        syncedTools: ['Cursor', 'Codex', 'Claude Code']\n      },\n      {\n        id: 'tauri',\n        name: 'tauri-desktop',\n        desc: 'Desktop app guidance for Tauri 2, Rust commands, and frontend bridge work.',\n        source: 'local/skills/tauri-desktop',\n        time: '2 days ago',\n        tags: ['Rust', 'Tauri'],\n        syncedTools: ['Cursor', 'Codex']\n      },\n      {\n        id: 'test-driven',\n        name: 'test-driven-development',\n        desc: 'Use when implementing a feature or bugfix with focused verification.',\n        source: 'skills/test-driven-development',\n        time: '7 days ago',\n        tags: ['Testing', 'Automation'],\n        syncedTools: ['Cursor', 'Codex', 'Claude Code']\n      },\n      {\n        id: 'excalidraw',\n        name: 'excalidraw-diagram',\n        desc: 'Generate Excalidraw diagrams from text content, flowcharts, and mind maps.',\n        source: 'axtonliu/axton-visual-skills',\n        time: '37 days ago',\n        tags: ['Docs', 'Diagram'],\n        syncedTools: ['Cursor', 'Claude Code']\n      },\n      {\n        id: 'youtube',\n        name: 'youtube-transcript',\n        desc: 'Download YouTube transcripts and captions from video URLs.',\n        source: 'local/plugins/youtube-transcript',\n        time: '45 days ago',\n        tags: ['Docs', 'Automation'],\n        syncedTools: ['Cursor', 'OpenCode']\n      },\n      {\n        id: 'frontend-design',\n        name: 'frontend-design',\n        desc: 'Frontend design best practices for accessible React and CSS interfaces.',\n        source: 'anthropics/skills',\n        time: '46 days ago',\n        tags: ['Frontend', 'Docs'],\n        syncedTools: ['Cursor', 'Codex', 'Windsurf']\n      },\n      {\n        id: 'legacy-shell',\n        name: 'legacy-shell-helper',\n        desc: 'Local helper skill imported from an existing tool directory and not organized yet.',\n        source: 'local/skills/legacy-shell-helper',\n        time: '52 days ago',\n        tags: [],\n        syncedTools: ['Cursor']\n      }\n    ];\n\n    const profiles = [\n      {\n        id: 'skills-hub-dev',\n        name: 'Skills Hub Dev',\n        desc: 'React + Tauri + Rust 桌面应用开发场景，适合当前仓库。',\n        skillIds: ['react', 'tauri', 'test-driven', 'frontend-design'],\n        tools: ['Cursor', 'Codex', 'Claude Code'],\n        active: true\n      },\n      {\n        id: 'docs-writing',\n        name: 'Docs Writing',\n        desc: '资料处理、转写、画图和技术文档写作。',\n        skillIds: ['excalidraw', 'youtube', 'frontend-design'],\n        tools: ['Codex', 'Claude Code'],\n        active: false\n      },\n      {\n        id: 'review-flow',\n        name: 'Review Flow',\n        desc: '代码审查、测试补齐、设计检查的轻量配置。',\n        skillIds: ['test-driven', 'frontend-design', 'react'],\n        tools: ['Cursor', 'Codex'],\n        active: false\n      }\n    ];\n\n    let activeView = 'skills';\n    let selectedTags = [];\n    let selectedProfileId = profiles.find((profile) => profile.active).id;\n    let selectedProfileInEditor = selectedProfileId;\n    let searchTerm = '';\n    let tagSearchTerm = '';\n    let tagManagerSearchTerm = '';\n    let pendingApplyProfileId = selectedProfileId;\n    let editingSkillId = null;\n\n    const state = {\n      liveSkillIds: new Set(profiles.find((profile) => profile.active).skillIds),\n      liveTools: new Set(profiles.find((profile) => profile.active).tools)\n    };\n\n    const iconBox = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M3 9h18V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4z\"/><path d=\"M3 9v10a2 2 0 002 2h14a2 2 0 002-2V9\"/></svg>';\n    const iconTag = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z\"/><line x1=\"7\" y1=\"7\" x2=\"7.01\" y2=\"7\"/></svg>';\n    const iconSync = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"23 4 23 10 17 10\"/><path d=\"M20.49 15a9 9 0 11-2.12-9.36L23 10\"/></svg>';\n    const iconCheck = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\"><polyline points=\"20 6 9 17 4 12\"/></svg>';\n    const UNTAGGED_FILTER = '__untagged__';\n    const UNTAGGED_LABEL = 'Untagged';\n\n    function getProfile(id) {\n      return profiles.find((profile) => profile.id === id);\n    }\n\n    function getSkill(id) {\n      return skills.find((skill) => skill.id === id);\n    }\n\n    function getFilterLabel(tag) {\n      return tag === UNTAGGED_FILTER ? UNTAGGED_LABEL : tag;\n    }\n\n    function getUntaggedCount() {\n      return skills.filter((skill) => skill.tags.length === 0).length;\n    }\n\n    function switchView(view) {\n      activeView = view;\n      document.querySelectorAll('.nav-tab[data-nav]').forEach((tab) => {\n        tab.classList.toggle('active', tab.dataset.nav === view);\n      });\n      document.getElementById('skillsView').classList.toggle('active', view === 'skills');\n      document.getElementById('tagsView').classList.toggle('active', view === 'tags');\n      document.getElementById('profilesView').classList.toggle('active', view === 'profiles');\n      renderAll();\n    }\n\n    function calculatePreview(profile) {\n      const targetSkills = new Set(profile.skillIds);\n      const targetTools = new Set(profile.tools);\n      const add = [...targetSkills].filter((id) => !state.liveSkillIds.has(id));\n      const remove = [...state.liveSkillIds].filter((id) => !targetSkills.has(id));\n      const keep = [...targetSkills].filter((id) => state.liveSkillIds.has(id));\n      const toolAdd = [...targetTools].filter((tool) => !state.liveTools.has(tool));\n      const toolRemove = [...state.liveTools].filter((tool) => !targetTools.has(tool));\n      return { add, remove, keep, toolAdd, toolRemove };\n    }\n\n    function renderNavProfileMenu() {\n      const menu = document.getElementById('profileMenu');\n      menu.innerHTML = profiles.map((profile) => `\n        <button class=\"profile-menu-item ${profile.id === selectedProfileId ? 'active' : ''}\" type=\"button\" data-menu-profile=\"${profile.id}\">\n          <span>${profile.name}</span>\n          <small>${profile.skillIds.length} skills</small>\n        </button>\n      `).join('') + `\n        <div class=\"profile-menu-divider\"></div>\n        <button class=\"profile-menu-item\" type=\"button\" id=\"manageProfilesMenuItem\">\n          <span>Manage Profiles</span>\n          <small>Open</small>\n        </button>\n      `;\n\n      menu.querySelectorAll('[data-menu-profile]').forEach((button) => {\n        button.addEventListener('click', (event) => {\n          event.stopPropagation();\n          const profileId = button.dataset.menuProfile;\n          selectedProfileId = profileId;\n          selectedProfileInEditor = profileId;\n          pendingApplyProfileId = profileId;\n          menu.classList.remove('open');\n          document.getElementById('profileSwitcher').setAttribute('aria-expanded', 'false');\n          renderAll();\n          openApplyModal(profileId);\n        });\n      });\n      document.getElementById('manageProfilesMenuItem').addEventListener('click', (event) => {\n        event.stopPropagation();\n        menu.classList.remove('open');\n        document.getElementById('profileSwitcher').setAttribute('aria-expanded', 'false');\n        switchView('profiles');\n      });\n    }\n\n    function renderTagFilter() {\n      const label = document.getElementById('tagFilterLabel');\n      const button = document.getElementById('tagFilterButton');\n      const dropdown = document.getElementById('tagFilterDropdown');\n      label.textContent = selectedTags.length === 0\n        ? 'Tags'\n        : selectedTags.length === 1\n          ? `Tag: ${getFilterLabel(selectedTags[0])}`\n          : `Tags: ${selectedTags.length} selected`;\n      button.classList.toggle('active', selectedTags.length > 0);\n\n      const normalizedSearch = tagSearchTerm.toLowerCase();\n      const tagOptions = [\n        { id: UNTAGGED_FILTER, label: UNTAGGED_LABEL, count: getUntaggedCount(), virtual: true },\n        ...allTags.map((tag) => ({ id: tag, label: tag, count: getTagUsageCount(tag), virtual: false }))\n      ];\n      const visibleTags = tagOptions.filter((tag) => tag.label.toLowerCase().includes(normalizedSearch));\n      dropdown.innerHTML = `\n        <div class=\"dropdown-title\">\n          <span>${selectedTags.length ? `Tags: ${selectedTags.length} selected` : 'Tags'}</span>\n          <span class=\"section-note\">Match any</span>\n        </div>\n        <div class=\"dropdown-search\">\n          <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n          <input id=\"tagFilterSearch\" type=\"search\" placeholder=\"Search tags...\" value=\"${tagSearchTerm}\" />\n        </div>\n        <div class=\"tag-option-list\">\n          ${visibleTags.map((tag) => `\n            <button class=\"tag-option\" type=\"button\" data-toggle-filter-tag=\"${tag.id}\">\n              <span class=\"checkbox ${selectedTags.includes(tag.id) ? 'checked' : ''}\">${iconCheck}</span>\n              <span>${tag.label}${tag.virtual ? '<span class=\"section-note\"> · no tags</span>' : ''}</span>\n              <span class=\"option-count\">${tag.count}</span>\n            </button>\n          `).join('') || '<div class=\"empty-state\"><strong>No tags</strong><span>Try another keyword.</span></div>'}\n        </div>\n        <div class=\"dropdown-footer\">\n          <button class=\"btn btn-sm btn-ghost\" type=\"button\" id=\"clearTagFilter\">Clear all</button>\n          <button class=\"btn btn-sm btn-ghost\" type=\"button\" id=\"openTagsPageFromDropdown\">Manage Tags...</button>\n        </div>\n      `;\n\n      dropdown.querySelectorAll('[data-toggle-filter-tag]').forEach((item) => {\n        item.addEventListener('click', (event) => {\n          event.stopPropagation();\n          const tag = item.dataset.toggleFilterTag;\n          selectedTags = selectedTags.includes(tag)\n            ? selectedTags.filter((itemTag) => itemTag !== tag)\n            : [...selectedTags, tag];\n          renderTagFilter();\n          renderSkills();\n          document.getElementById('tagFilterDropdown').classList.add('open');\n        });\n      });\n      document.getElementById('tagFilterSearch').addEventListener('click', (event) => event.stopPropagation());\n      document.getElementById('tagFilterSearch').addEventListener('input', (event) => {\n        tagSearchTerm = event.target.value;\n        renderTagFilter();\n        document.getElementById('tagFilterDropdown').classList.add('open');\n        document.getElementById('tagFilterSearch').focus();\n      });\n      document.getElementById('clearTagFilter').addEventListener('click', (event) => {\n        event.stopPropagation();\n        selectedTags = [];\n        tagSearchTerm = '';\n        renderTagFilter();\n        renderSkills();\n        document.getElementById('tagFilterDropdown').classList.add('open');\n      });\n      document.getElementById('openTagsPageFromDropdown').addEventListener('click', (event) => {\n        event.stopPropagation();\n        closeTagDropdown();\n        switchView('tags');\n      });\n    }\n\n    function closeTagDropdown() {\n      document.getElementById('tagFilterDropdown').classList.remove('open');\n      document.getElementById('tagFilterButton').setAttribute('aria-expanded', 'false');\n    }\n\n    function renderSkills() {\n      const list = document.getElementById('skillsList');\n      const filteredSkills = skills.filter((skill) => {\n        const tagMatch = selectedTags.length === 0 || selectedTags.some((tag) => {\n          if (tag === UNTAGGED_FILTER) return skill.tags.length === 0;\n          return skill.tags.includes(tag);\n        });\n        const searchMatch = !searchTerm\n          || skill.name.toLowerCase().includes(searchTerm)\n          || skill.desc.toLowerCase().includes(searchTerm)\n          || skill.tags.some((tag) => tag.toLowerCase().includes(searchTerm));\n        return tagMatch && searchMatch;\n      });\n\n      document.getElementById('skillCount').textContent = filteredSkills.length;\n\n      if (filteredSkills.length === 0) {\n        list.innerHTML = '<div class=\"empty-state\"><strong>No matching skills</strong><span>Try another tag or search keyword.</span></div>';\n        return;\n      }\n\n      list.innerHTML = filteredSkills.map((skill) => {\n        const toolBadges = skill.syncedTools.slice(0, 4).map((tool) => `<span class=\"tool-badge synced\">${tool}</span>`).join('');\n        const more = skill.syncedTools.length > 4 ? `<span class=\"tool-badge ghost\">+${skill.syncedTools.length - 4}</span>` : '';\n        const tagChips = skill.tags.length\n          ? skill.tags.map((tag) => `<button class=\"inline-tag\" type=\"button\" data-inline-tag=\"${tag}\">#${tag}</button>`).join('')\n          : `<button class=\"inline-tag empty\" type=\"button\" data-edit-tags=\"${skill.id}\">No tags</button>`;\n        return `\n          <article class=\"skill-row\">\n            <div class=\"skill-icon-sm\" aria-hidden=\"true\">${iconBox}</div>\n            <div class=\"skill-info\">\n              <div class=\"skill-name-row\">\n                <span class=\"skill-name\">${skill.name}</span>\n                <div class=\"skill-tags\">${tagChips}</div>\n              </div>\n              <div class=\"skill-desc\">${skill.desc}</div>\n              <div class=\"skill-meta-row\">\n                <span class=\"skill-source\">${skill.source}</span>\n                <span class=\"skill-time\">&middot; ${skill.time}</span>\n              </div>\n              <div class=\"tool-badges\">${toolBadges}${more}</div>\n            </div>\n            <div class=\"row-actions\">\n              <button class=\"action-btn\" type=\"button\" data-edit-tags=\"${skill.id}\" aria-label=\"Edit tags\">${iconTag}</button>\n              <button class=\"action-btn\" type=\"button\" aria-label=\"Sync\">${iconSync}</button>\n            </div>\n          </article>\n        `;\n      }).join('');\n\n      list.querySelectorAll('[data-inline-tag]').forEach((button) => {\n        button.addEventListener('click', () => {\n          selectedTags = [button.dataset.inlineTag];\n          renderTagFilter();\n          renderSkills();\n        });\n      });\n\n      list.querySelectorAll('[data-edit-tags]').forEach((button) => {\n        button.addEventListener('click', () => openTagModal(button.dataset.editTags));\n      });\n    }\n\n    function renderProfileSummary() {\n      const profile = getProfile(selectedProfileId);\n      document.getElementById('currentProfileName').textContent = profile.name;\n    }\n\n    function renderProfilesSidebar() {\n      const sidebar = document.getElementById('profilesSidebar');\n      document.getElementById('profileCount').textContent = profiles.length;\n      sidebar.innerHTML = profiles.map((profile) => `\n        <button class=\"profile-card ${profile.id === selectedProfileInEditor ? 'active' : ''}\" type=\"button\" data-profile-card=\"${profile.id}\">\n          <div class=\"profile-card-top\">\n            <span class=\"profile-name\">${profile.name}</span>\n            ${profile.id === selectedProfileId ? '<span class=\"status-pill active\">Active</span>' : '<span class=\"status-pill draft\">Draft</span>'}\n          </div>\n          <div class=\"profile-card-desc\">${profile.desc}</div>\n          <div class=\"profile-card-meta\">\n            <span>${profile.skillIds.length} skills</span>\n            <span>${profile.tools.length} tools</span>\n          </div>\n        </button>\n      `).join('');\n\n      sidebar.querySelectorAll('[data-profile-card]').forEach((button) => {\n        button.addEventListener('click', () => {\n          selectedProfileInEditor = button.dataset.profileCard;\n          renderProfiles();\n        });\n      });\n    }\n\n    function renderProfileDetail() {\n      const profile = getProfile(selectedProfileInEditor);\n      const preview = calculatePreview(profile);\n\n      document.getElementById('detailTitle').textContent = profile.name;\n      document.getElementById('detailDesc').textContent = profile.desc;\n      document.getElementById('selectedSkillNote').textContent = `${profile.skillIds.length} selected, group 内不重复，跨 Profile 可复用`;\n\n      const toolGrid = document.getElementById('toolGrid');\n      toolGrid.innerHTML = tools.map((tool) => `\n        <button class=\"select-chip ${profile.tools.includes(tool) ? 'selected' : ''}\" type=\"button\" data-toggle-tool=\"${tool}\">\n          <span class=\"check-dot\"></span>${tool}\n        </button>\n      `).join('');\n      toolGrid.querySelectorAll('[data-toggle-tool]').forEach((button) => {\n        button.addEventListener('click', () => {\n          const tool = button.dataset.toggleTool;\n          if (profile.tools.includes(tool)) {\n            profile.tools = profile.tools.filter((item) => item !== tool);\n          } else {\n            profile.tools = [...profile.tools, tool];\n          }\n          renderAll();\n        });\n      });\n\n      const profileSkillList = document.getElementById('profileSkillList');\n      profileSkillList.innerHTML = skills.map((skill) => {\n        const selected = profile.skillIds.includes(skill.id);\n        return `\n          <button class=\"profile-skill-row\" type=\"button\" data-toggle-skill=\"${skill.id}\">\n            <span class=\"checkbox ${selected ? 'checked' : ''}\">${iconCheck}</span>\n            <span class=\"profile-skill-main\">\n              <span class=\"profile-skill-name\">${skill.name}</span>\n              <span class=\"profile-skill-description\">${skill.desc}</span>\n            </span>\n            <span class=\"profile-skill-tags\">\n              ${skill.tags.slice(0, 3).map((tag) => `<span class=\"inline-tag\">#${tag}</span>`).join('')}\n            </span>\n          </button>\n        `;\n      }).join('');\n      profileSkillList.querySelectorAll('[data-toggle-skill]').forEach((button) => {\n        button.addEventListener('click', () => {\n          const skillId = button.dataset.toggleSkill;\n          if (profile.skillIds.includes(skillId)) {\n            profile.skillIds = profile.skillIds.filter((id) => id !== skillId);\n          } else {\n            profile.skillIds = [...profile.skillIds, skillId];\n          }\n          renderAll();\n        });\n      });\n\n      document.getElementById('previewGrid').innerHTML = `\n        <div class=\"preview-card add\"><strong>+${preview.add.length}</strong><span>skills will be added</span></div>\n        <div class=\"preview-card remove\"><strong>-${preview.remove.length}</strong><span>skills will be removed</span></div>\n        <div class=\"preview-card keep\"><strong>${preview.keep.length}</strong><span>skills will stay active</span></div>\n      `;\n    }\n\n    function renderProfiles() {\n      renderProfilesSidebar();\n      renderProfileDetail();\n    }\n\n    function openApplyModal(profileId) {\n      pendingApplyProfileId = profileId;\n      const profile = getProfile(profileId);\n      const preview = calculatePreview(profile);\n      const modal = document.getElementById('applyModal');\n      const list = document.getElementById('changeList');\n      document.getElementById('applyModalTitle').textContent = `Apply ${profile.name}`;\n      document.getElementById('applyModalSubtitle').textContent =\n        `${profile.skillIds.length} skills will sync to ${profile.tools.join(', ') || 'no tools selected'}.`;\n\n      const changes = [\n        ...preview.add.map((id) => ({ type: 'add', name: getSkill(id).name, label: 'ADD SKILL' })),\n        ...preview.remove.map((id) => ({ type: 'remove', name: getSkill(id).name, label: 'REMOVE SKILL' })),\n        ...preview.keep.slice(0, 4).map((id) => ({ type: 'keep', name: getSkill(id).name, label: 'KEEP' })),\n        ...preview.toolAdd.map((tool) => ({ type: 'add', name: tool, label: 'ADD TOOL' })),\n        ...preview.toolRemove.map((tool) => ({ type: 'remove', name: tool, label: 'REMOVE TOOL' }))\n      ];\n\n      list.innerHTML = changes.length\n        ? changes.map((change) => `<div class=\"change-item ${change.type}\"><span>${change.name}</span><span class=\"kind\">${change.label}</span></div>`).join('')\n        : '<div class=\"change-item keep\"><span>No changes compared with current live sync state.</span><span class=\"kind\">UNCHANGED</span></div>';\n\n      modal.classList.add('open');\n    }\n\n    function applyProfile(profileId) {\n      const profile = getProfile(profileId);\n      selectedProfileId = profileId;\n      selectedProfileInEditor = profileId;\n      profiles.forEach((item) => {\n        item.active = item.id === profileId;\n      });\n      state.liveSkillIds = new Set(profile.skillIds);\n      state.liveTools = new Set(profile.tools);\n      closeModals();\n      renderAll();\n      showToast(`${profile.name} applied`);\n    }\n\n    function openTagModal(skillId) {\n      editingSkillId = skillId;\n      const skill = getSkill(skillId);\n      document.getElementById('tagModalTitle').textContent = `Edit Tags: ${skill.name}`;\n      document.getElementById('tagModalSubtitle').textContent = 'Click tags to add or remove them from this skill.';\n      renderTagEditor();\n      document.getElementById('tagModal').classList.add('open');\n    }\n\n    function renderTagEditor() {\n      const skill = getSkill(editingSkillId);\n      document.getElementById('tagEditorList').innerHTML = allTags.map((tag) => `\n        <button class=\"select-chip ${skill.tags.includes(tag) ? 'selected' : ''}\" type=\"button\" data-editor-tag=\"${tag}\">\n          <span class=\"check-dot\"></span>${tag}\n        </button>\n      `).join('');\n\n      document.querySelectorAll('[data-editor-tag]').forEach((button) => {\n        button.addEventListener('click', () => {\n          const tag = button.dataset.editorTag;\n          if (skill.tags.includes(tag)) {\n            skill.tags = skill.tags.filter((item) => item !== tag);\n          } else {\n            skill.tags = [...skill.tags, tag];\n          }\n          renderTagEditor();\n          renderTagFilter();\n          renderTagsPage();\n          renderSkills();\n          renderProfiles();\n        });\n      });\n    }\n\n    function getTagUsageCount(tag) {\n      return skills.filter((skill) => skill.tags.includes(tag)).length;\n    }\n\n    function openTagManagerModal() {\n      renderTagManager();\n      document.getElementById('tagManagerModal').classList.add('open');\n      document.getElementById('newTagInput').focus();\n    }\n\n    function renderTagManager() {\n      const list = document.getElementById('tagManagerList');\n      list.innerHTML = allTags.map((tag, index) => `\n        <div class=\"tag-manager-row\">\n          <div class=\"tag-manager-name\">\n            <span class=\"tag-color-dot\" style=\"background:${getTagColor(index)}\"></span>\n            <span>${tag}</span>\n          </div>\n          <div class=\"tag-manager-count\">${getTagUsageCount(tag)} skills</div>\n          <div class=\"tag-manager-actions\">\n            <button class=\"btn btn-sm btn-ghost\" type=\"button\" data-rename-tag=\"${tag}\">Rename</button>\n            <button class=\"btn btn-sm btn-ghost\" type=\"button\" data-delete-tag=\"${tag}\">Delete</button>\n          </div>\n        </div>\n      `).join('');\n\n      list.querySelectorAll('[data-rename-tag]').forEach((button) => {\n        button.addEventListener('click', () => renameTag(button.dataset.renameTag));\n      });\n\n      list.querySelectorAll('[data-delete-tag]').forEach((button) => {\n        button.addEventListener('click', () => deleteTag(button.dataset.deleteTag));\n      });\n    }\n\n    function renderTagsPage() {\n      const table = document.getElementById('tagTable');\n      const normalizedSearch = tagManagerSearchTerm.toLowerCase();\n      const visibleTags = allTags.filter((tag) => tag.toLowerCase().includes(normalizedSearch));\n      document.getElementById('tagManagerCount').textContent = `${visibleTags.length} tags`;\n      table.innerHTML = `\n        <div class=\"tag-table-row header\">\n          <div>Tag name</div>\n          <div>Skills</div>\n          <div>Last used</div>\n          <div></div>\n        </div>\n        ${visibleTags.map((tag, index) => `\n          <div class=\"tag-table-row\">\n            <div class=\"tag-manager-name\">\n              <span class=\"tag-color-dot\" style=\"background:${getTagColor(index)}\"></span>\n              <span>${tag}</span>\n            </div>\n            <div class=\"tag-manager-count\">${getTagUsageCount(tag)} skills</div>\n            <div class=\"tag-manager-count\">${index + 2}d ago</div>\n            <div class=\"tag-table-actions\">\n              <button class=\"btn btn-sm btn-ghost\" type=\"button\" data-filter-tag-from-page=\"${tag}\">View</button>\n              <button class=\"btn btn-sm btn-ghost\" type=\"button\" data-rename-tag=\"${tag}\">Rename</button>\n              <button class=\"btn btn-sm btn-ghost\" type=\"button\" data-delete-tag=\"${tag}\">Delete</button>\n            </div>\n          </div>\n        `).join('') || '<div class=\"empty-state\"><strong>No tags</strong><span>Create a tag or change the search keyword.</span></div>'}\n      `;\n      document.getElementById('untaggedCallout').innerHTML = getUntaggedCount() > 0 ? `\n        <div class=\"untagged-callout\">\n          <div>\n            <strong>${getUntaggedCount()} skills have no tags</strong>\n            <span>Untagged 是系统虚拟状态，不会出现在可重命名或删除的标签表里。</span>\n          </div>\n          <button class=\"btn btn-sm btn-secondary\" type=\"button\" id=\"reviewUntagged\">Review</button>\n        </div>\n      ` : '';\n      const reviewUntagged = document.getElementById('reviewUntagged');\n      if (reviewUntagged) {\n        reviewUntagged.addEventListener('click', () => {\n          selectedTags = [UNTAGGED_FILTER];\n          switchView('skills');\n        });\n      }\n\n      table.querySelectorAll('[data-filter-tag-from-page]').forEach((button) => {\n        button.addEventListener('click', () => {\n          selectedTags = [button.dataset.filterTagFromPage];\n          switchView('skills');\n        });\n      });\n      table.querySelectorAll('[data-rename-tag]').forEach((button) => {\n        button.addEventListener('click', () => renameTag(button.dataset.renameTag));\n      });\n      table.querySelectorAll('[data-delete-tag]').forEach((button) => {\n        button.addEventListener('click', () => deleteTag(button.dataset.deleteTag));\n      });\n    }\n\n    function getTagColor(index) {\n      return ['#2563EB', '#7C3AED', '#059669', '#D97706', '#0891B2', '#DB2777', '#4F46E5', '#16A34A'][index % 8];\n    }\n\n    function normalizeTagName(name) {\n      return name.trim().replace(/\\s+/g, ' ');\n    }\n\n    function createTag() {\n      const input = document.getElementById('newTagInput');\n      const tagName = normalizeTagName(input.value);\n      if (!tagName) return;\n      if (allTags.some((tag) => tag.toLowerCase() === tagName.toLowerCase())) {\n        showToast('Tag already exists');\n        return;\n      }\n      allTags = [...allTags, tagName];\n      input.value = '';\n      renderAll();\n      renderTagManager();\n      showToast(`Tag \"${tagName}\" created`);\n    }\n\n    function renameTag(oldName) {\n      const nextName = normalizeTagName(window.prompt('Rename tag', oldName) || '');\n      if (!nextName || nextName === oldName) return;\n      if (allTags.some((tag) => tag.toLowerCase() === nextName.toLowerCase())) {\n        showToast('Tag already exists');\n        return;\n      }\n      allTags = allTags.map((tag) => tag === oldName ? nextName : tag);\n      skills.forEach((skill) => {\n        skill.tags = skill.tags.map((tag) => tag === oldName ? nextName : tag);\n      });\n      selectedTags = selectedTags.map((tag) => tag === oldName ? nextName : tag);\n      renderAll();\n      renderTagManager();\n      showToast(`Tag renamed to \"${nextName}\"`);\n    }\n\n    function deleteTag(tagName) {\n      const inUse = getTagUsageCount(tagName);\n      const confirmed = window.confirm(`Delete \"${tagName}\" from ${inUse} skills? This only removes the tag, not the skills.`);\n      if (!confirmed) return;\n      allTags = allTags.filter((tag) => tag !== tagName);\n      skills.forEach((skill) => {\n        skill.tags = skill.tags.filter((tag) => tag !== tagName);\n      });\n      selectedTags = selectedTags.filter((tag) => tag !== tagName);\n      renderAll();\n      renderTagManager();\n      showToast(`Tag \"${tagName}\" deleted`);\n    }\n\n    function closeModals() {\n      document.querySelectorAll('.modal-backdrop').forEach((modal) => modal.classList.remove('open'));\n    }\n\n    function showToast(text) {\n      const toast = document.getElementById('toast');\n      document.getElementById('toastText').textContent = text;\n      toast.classList.add('show');\n      window.clearTimeout(showToast.timer);\n      showToast.timer = window.setTimeout(() => toast.classList.remove('show'), 2200);\n    }\n\n    function renderAll() {\n      renderNavProfileMenu();\n      renderTagFilter();\n      renderSkills();\n      renderProfileSummary();\n      renderProfiles();\n      renderTagsPage();\n    }\n\n    document.querySelectorAll('.nav-tab[data-nav]').forEach((tab) => {\n      tab.addEventListener('click', () => switchView(tab.dataset.nav));\n    });\n\n    document.getElementById('previewProfileButton').addEventListener('click', () => openApplyModal(selectedProfileInEditor));\n    document.getElementById('applyProfileButton').addEventListener('click', () => openApplyModal(selectedProfileInEditor));\n    document.getElementById('confirmApply').addEventListener('click', () => applyProfile(pendingApplyProfileId));\n    document.getElementById('createTagButton').addEventListener('click', createTag);\n    document.getElementById('newTagInput').addEventListener('keydown', (event) => {\n      if (event.key === 'Enter') createTag();\n    });\n    document.getElementById('newTagFromPage').addEventListener('click', openTagManagerModal);\n    document.getElementById('backToSkillsFromTags').addEventListener('click', () => switchView('skills'));\n    document.getElementById('tagManagerSearch').addEventListener('input', (event) => {\n      tagManagerSearchTerm = event.target.value.trim();\n      renderTagsPage();\n    });\n    document.getElementById('tagFilterButton').addEventListener('click', (event) => {\n      event.stopPropagation();\n      const dropdown = document.getElementById('tagFilterDropdown');\n      const isOpen = dropdown.classList.toggle('open');\n      document.getElementById('tagFilterButton').setAttribute('aria-expanded', String(isOpen));\n      if (isOpen) {\n        tagSearchTerm = '';\n        renderTagFilter();\n        document.getElementById('tagFilterDropdown').classList.add('open');\n      }\n    });\n\n    document.getElementById('profileSwitcher').addEventListener('click', () => {\n      const menu = document.getElementById('profileMenu');\n      const isOpen = menu.classList.toggle('open');\n      document.getElementById('profileSwitcher').setAttribute('aria-expanded', String(isOpen));\n    });\n\n    document.getElementById('skillSearch').addEventListener('input', (event) => {\n      searchTerm = event.target.value.trim().toLowerCase();\n      renderSkills();\n    });\n\n    document.getElementById('duplicateProfile').addEventListener('click', () => {\n      const source = getProfile(selectedProfileInEditor);\n      const copy = {\n        id: `${source.id}-copy-${Date.now()}`,\n        name: `${source.name} Copy`,\n        desc: source.desc,\n        skillIds: [...source.skillIds],\n        tools: [...source.tools],\n        active: false\n      };\n      profiles.push(copy);\n      selectedProfileInEditor = copy.id;\n      renderAll();\n      showToast('Profile duplicated');\n    });\n\n    document.getElementById('newProfile').addEventListener('click', () => {\n      const profile = {\n        id: `custom-${Date.now()}`,\n        name: 'Custom Profile',\n        desc: 'A new profile for a specific project or workflow.',\n        skillIds: [],\n        tools: ['Cursor'],\n        active: false\n      };\n      profiles.push(profile);\n      selectedProfileInEditor = profile.id;\n      renderAll();\n      showToast('Profile created');\n    });\n\n    document.querySelectorAll('[data-close-modal]').forEach((button) => {\n      button.addEventListener('click', closeModals);\n    });\n\n    document.querySelectorAll('.modal-backdrop').forEach((backdrop) => {\n      backdrop.addEventListener('click', (event) => {\n        if (event.target === backdrop) closeModals();\n      });\n    });\n\n    document.addEventListener('keydown', (event) => {\n      if (event.key === 'Escape') {\n        closeModals();\n        document.getElementById('profileMenu').classList.remove('open');\n        closeTagDropdown();\n      }\n    });\n\n    document.addEventListener('click', (event) => {\n      if (!document.getElementById('tagFilterButton').contains(event.target)) {\n        closeTagDropdown();\n      }\n    });\n\n    renderAll();\n  </script>\n</body>\n</html>\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import js from '@eslint/js'\nimport globals from 'globals'\nimport reactHooks from 'eslint-plugin-react-hooks'\nimport reactRefresh from 'eslint-plugin-react-refresh'\nimport tseslint from 'typescript-eslint'\nimport { defineConfig, globalIgnores } from 'eslint/config'\n\nexport default defineConfig([\n  globalIgnores(['dist', 'src-tauri/target']),\n  {\n    files: ['**/*.{ts,tsx}'],\n    extends: [\n      js.configs.recommended,\n      tseslint.configs.recommended,\n      reactHooks.configs.flat.recommended,\n      reactRefresh.configs.vite,\n    ],\n    languageOptions: {\n      ecmaVersion: 2020,\n      globals: globals.browser,\n    },\n  },\n])\n"
  },
  {
    "path": "featured-skills.json",
    "content": "{\n  \"updated_at\": \"2026-05-13T01:47:46.473Z\",\n  \"total\": 300,\n  \"categories\": [\n    \"ai-assistant\",\n    \"development\"\n  ],\n  \"skills\": [\n    {\n      \"slug\": \"algorithmic-art\",\n      \"name\": \"algorithmic-art\",\n      \"summary\": \"Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/algorithmic-art\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"brand-guidelines\",\n      \"name\": \"brand-guidelines\",\n      \"summary\": \"Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/brand-guidelines\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"canvas-design\",\n      \"name\": \"canvas-design\",\n      \"summary\": \"Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/canvas-design\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"claude-api\",\n      \"name\": \"claude-api\",\n      \"summary\": \"Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements). TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project. SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/claude-api\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"doc-coauthoring\",\n      \"name\": \"doc-coauthoring\",\n      \"summary\": \"Guide users through a structured workflow for co-authoring documentation. Use when user wants to write documentation, proposals, technical specs, decision docs, or similar structured content. This workflow helps users efficiently transfer context, refine content through iteration, and verify the doc works for readers. Trigger when user mentions writing docs, creating proposals, drafting specs, or similar documentation tasks.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/doc-coauthoring\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"docx\",\n      \"name\": \"docx\",\n      \"summary\": \"Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of 'Word doc', 'word document', '.docx', or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a 'report', 'memo', 'letter', 'template', or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/docx\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"frontend-design\",\n      \"name\": \"frontend-design\",\n      \"summary\": \"Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/frontend-design\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"internal-comms\",\n      \"name\": \"internal-comms\",\n      \"summary\": \"A set of resources to help me write all kinds of internal communications, using the formats that my company likes to use. Claude should use this skill whenever asked to write some sort of internal communications (status reports, leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.).\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/internal-comms\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"mcp-builder\",\n      \"name\": \"mcp-builder\",\n      \"summary\": \"Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/mcp-builder\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"pdf\",\n      \"name\": \"pdf\",\n      \"summary\": \"Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/pdf\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"pptx\",\n      \"name\": \"pptx\",\n      \"summary\": \"Use this skill any time a .pptx file is involved in any way — as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx file (even if the extracted content will be used elsewhere, like in an email or summary); editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates, layouts, speaker notes, or comments. Trigger whenever the user mentions \\\\\\\"deck,\\\\\\\" \\\\\\\"slides,\\\\\\\" \\\\\\\"presentation,\\\\\\\" or references a .pptx filename, regardless of what they plan to do with the content afterward. If a .pptx file needs to be opened, created, or touched, use this skill.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/pptx\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"skill-creator\",\n      \"name\": \"skill-creator\",\n      \"summary\": \"Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/skill-creator\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"slack-gif-creator\",\n      \"name\": \"slack-gif-creator\",\n      \"summary\": \"Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like \\\"make me a GIF of X doing Y for Slack.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/slack-gif-creator\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"template\",\n      \"name\": \"template-skill\",\n      \"summary\": \"Replace with description of the skill and when Claude should use it.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/template\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"theme-factory\",\n      \"name\": \"theme-factory\",\n      \"summary\": \"Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/theme-factory\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"web-artifacts-builder\",\n      \"name\": \"web-artifacts-builder\",\n      \"summary\": \"Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/web-artifacts-builder\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"webapp-testing\",\n      \"name\": \"webapp-testing\",\n      \"summary\": \"Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/webapp-testing\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"xlsx\",\n      \"name\": \"xlsx\",\n      \"summary\": \"Use this skill any time a spreadsheet file is the primary input or output. This means any task where the user wants to: open, read, edit, or fix an existing .xlsx, .xlsm, .csv, or .tsv file (e.g., adding columns, computing formulas, formatting, charting, cleaning messy data); create a new spreadsheet from scratch or from other data sources; or convert between tabular file formats. Trigger especially when the user references a spreadsheet file by name or path — even casually (like \\\\\\\"the xlsx in my downloads\\\\\\\") — and wants something done to it or produced from it. Also trigger for cleaning or restructuring messy tabular data files (malformed rows, misplaced headers, junk data) into proper spreadsheets. The deliverable must be a spreadsheet file. Do NOT trigger when the primary deliverable is a Word document, HTML report, standalone Python script, database pipeline, or Google Sheets API integration, even if tabular data is involved.\",\n      \"downloads\": 0,\n      \"stars\": 133029,\n      \"category\": \"ai-assistant\",\n      \"tags\": [\n        \"agent-skills\"\n      ],\n      \"source_url\": \"https://github.com/anthropics/skills/tree/main/skills/xlsx\",\n      \"updated_at\": \"2026-05-13T01:45:43Z\"\n    },\n    {\n      \"slug\": \"00-andruia-consultant\",\n      \"name\": \"00-andruia-consultant\",\n      \"summary\": \"Arquitecto de Soluciones Principal y Consultor Tecnológico de Andru.ia. Diagnostica y traza la hoja de ruta óptima para proyectos de IA en español.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/00-andruia-consultant\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"007\",\n      \"name\": \"007\",\n      \"summary\": \"Security audit, hardening, threat modeling (STRIDE/PASTA), Red/Blue Team, OWASP checks, code review, incident response, and infrastructure security for any project.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/007\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"10-andruia-skill-smith\",\n      \"name\": \"10-andruia-skill-smith\",\n      \"summary\": \"Ingeniero de Sistemas de Andru.ia. Diseña, redacta y despliega nuevas habilidades (skills) dentro del repositorio siguiendo el Estándar de Diamante.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/10-andruia-skill-smith\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"20-andruia-niche-intelligence\",\n      \"name\": \"20-andruia-niche-intelligence\",\n      \"summary\": \"Estratega de Inteligencia de Dominio de Andru.ia. Analiza el nicho específico de un proyecto para inyectar conocimientos, regulaciones y estándares únicos del sector. Actívalo tras definir el nicho.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/20-andruia-niche-intelligence\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"3d-web-experience\",\n      \"name\": \"3d-web-experience\",\n      \"summary\": \"Expert in building 3D experiences for the web - Three.js, React\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/3d-web-experience\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ab-test-setup\",\n      \"name\": \"ab-test-setup\",\n      \"summary\": \"Structured guide for setting up A/B tests with mandatory gates for hypothesis, metrics, and execution readiness.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ab-test-setup\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"acceptance-orchestrator\",\n      \"name\": \"acceptance-orchestrator\",\n      \"summary\": \"Use when a coding task should be driven end-to-end from issue intake through implementation, review, deployment, and acceptance verification with minimal human re-intervention.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/acceptance-orchestrator\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"accessibility-compliance-accessibility-audit\",\n      \"name\": \"accessibility-compliance-accessibility-audit\",\n      \"summary\": \"You are an accessibility expert specializing in WCAG compliance, inclusive design, and assistive technology compatibility. Conduct audits, identify barriers, and provide remediation guidance.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/accessibility-compliance-accessibility-audit\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"active-directory-attacks\",\n      \"name\": \"active-directory-attacks\",\n      \"summary\": \"Provide comprehensive techniques for attacking Microsoft Active Directory environments. Covers reconnaissance, credential harvesting, Kerberos attacks, lateral movement, privilege escalation, and domain dominance for red team operations and penetration testing.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/active-directory-attacks\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"activecampaign-automation\",\n      \"name\": \"activecampaign-automation\",\n      \"summary\": \"Automate ActiveCampaign tasks via Rube MCP (Composio): manage contacts, tags, list subscriptions, automation enrollment, and tasks. Always search tools first for current schemas.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/activecampaign-automation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ad-creative\",\n      \"name\": \"ad-creative\",\n      \"summary\": \"Create, iterate, and scale paid ad creative for Google Ads, Meta, LinkedIn, TikTok, and similar platforms. Use when generating headlines, descriptions, primary text, or large sets of ad variations for testing and performance optimization.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ad-creative\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"address-github-comments\",\n      \"name\": \"address-github-comments\",\n      \"summary\": \"Use when you need to address review or issue comments on an open GitHub Pull Request using the gh CLI.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/address-github-comments\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"adhx\",\n      \"name\": \"adhx\",\n      \"summary\": \"Fetch any X/Twitter post as clean LLM-friendly JSON. Converts x.com, twitter.com, or adhx.com links into structured data with full article content, author info, and engagement metrics. No scraping or browser required.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/adhx\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"advanced-evaluation\",\n      \"name\": \"advanced-evaluation\",\n      \"summary\": \"This skill should be used when the user asks to \\\"implement LLM-as-judge\\\", \\\"compare model outputs\\\", \\\"create evaluation rubrics\\\", \\\"mitigate evaluation bias\\\", or mentions direct scoring, pairwise comparison, position bias, evaluation pipelines, or automated quality assessment.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/advanced-evaluation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"advogado-criminal\",\n      \"name\": \"advogado-criminal\",\n      \"summary\": \"Advogado criminalista especializado em Maria da Penha, violencia domestica, feminicidio, direito penal brasileiro, medidas protetivas, inquerito policial e acao penal.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/advogado-criminal\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"advogado-especialista\",\n      \"name\": \"advogado-especialista\",\n      \"summary\": \"Advogado especialista em todas as areas do Direito brasileiro: familia, criminal, trabalhista, tributario, consumidor, imobiliario, empresarial, civil e constitucional.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/advogado-especialista\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"aegisops-ai\",\n      \"name\": \"aegisops-ai\",\n      \"summary\": \"Autonomous DevSecOps & FinOps Guardrails. Orchestrates Gemini 3 Flash to audit Linux Kernel patches, Terraform cost drifts, and K8s compliance.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/aegisops-ai\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agent-evaluation\",\n      \"name\": \"agent-evaluation\",\n      \"summary\": \"Testing and benchmarking LLM agents including behavioral testing,\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agent-evaluation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agent-framework-azure-ai-py\",\n      \"name\": \"agent-framework-azure-ai-py\",\n      \"summary\": \"Build persistent agents on Azure AI Foundry using the Microsoft Agent Framework Python SDK.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agent-framework-azure-ai-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agent-manager-skill\",\n      \"name\": \"agent-manager-skill\",\n      \"summary\": \"Manage multiple local CLI agents via tmux sessions (start/stop/monitor/assign) with cron-friendly scheduling.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agent-manager-skill\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agent-memory-mcp\",\n      \"name\": \"agent-memory-mcp\",\n      \"summary\": \"A hybrid memory system that provides persistent, searchable knowledge management for AI agents (Architecture, Patterns, Decisions).\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agent-memory-mcp\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agent-memory-systems\",\n      \"name\": \"agent-memory-systems\",\n      \"summary\": \"Memory is the cornerstone of intelligent agents. Without it, every\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agent-memory-systems\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agent-orchestration-improve-agent\",\n      \"name\": \"agent-orchestration-improve-agent\",\n      \"summary\": \"Systematic improvement of existing agents through performance analysis, prompt engineering, and continuous iteration.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agent-orchestration-improve-agent\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agent-orchestration-multi-agent-optimize\",\n      \"name\": \"agent-orchestration-multi-agent-optimize\",\n      \"summary\": \"Optimize multi-agent systems with coordinated profiling, workload distribution, and cost-aware orchestration. Use when improving agent performance, throughput, or reliability.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agent-orchestration-multi-agent-optimize\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agent-orchestrator\",\n      \"name\": \"agent-orchestrator\",\n      \"summary\": \"Meta-skill que orquestra todos os agentes do ecossistema. Scan automatico de skills, match por capacidades, coordenacao de workflows multi-skill e registry management.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agent-orchestrator\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agent-tool-builder\",\n      \"name\": \"agent-tool-builder\",\n      \"summary\": \"Tools are how AI agents interact with the world. A well-designed\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agent-tool-builder\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agentflow\",\n      \"name\": \"agentflow\",\n      \"summary\": \"Orchestrate autonomous AI development pipelines through your Kanban board (Asana, GitHub Projects, Linear). Manages multi-worker Claude Code dispatch, deterministic quality gates, adversarial review, per-task cost tracking, and crash-proof pipeline execution.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agentflow\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agentfolio\",\n      \"name\": \"agentfolio\",\n      \"summary\": \"Skill for discovering and researching autonomous AI agents, tools, and ecosystems using the AgentFolio directory.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agentfolio\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agentic-actions-auditor\",\n      \"name\": \"agentic-actions-auditor\",\n      \"summary\": \">\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agentic-actions-auditor\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agentmail\",\n      \"name\": \"agentmail\",\n      \"summary\": \"Email infrastructure for AI agents. Create accounts, send/receive emails, manage webhooks, and check karma balance via the AgentMail API.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agentmail\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agentphone\",\n      \"name\": \"agentphone\",\n      \"summary\": \"Build AI phone agents with AgentPhone API. Use when the user wants to make phone calls, send/receive SMS, manage phone numbers, create voice agents, set up webhooks, or check usage — anything related to telephony, phone numbers, or voice AI.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agentphone\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agents-md\",\n      \"name\": \"agents-md\",\n      \"summary\": \"This skill should be used when the user asks to \\\"create AGENTS.md\\\", \\\"update AGENTS.md\\\", \\\"maintain agent docs\\\", \\\"set up CLAUDE.md\\\", or needs to keep agent instructions concise. Enforces research-backed best practices for minimal, high-signal agent documentation.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agents-md\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agents-v2-py\",\n      \"name\": \"agents-v2-py\",\n      \"summary\": \"Build container-based Foundry Agents with Azure AI Projects SDK (ImageBasedHostedAgentDefinition). Use when creating hosted agents with custom container images in Azure AI Foundry.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agents-v2-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"agenttrace-session-audit\",\n      \"name\": \"agenttrace-session-audit\",\n      \"summary\": \"Audit local AI coding-agent sessions with agenttrace for cost, tool failures, latency, anomalies, health, diffs, and CI gates.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/agenttrace-session-audit\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-agent-development\",\n      \"name\": \"ai-agent-development\",\n      \"summary\": \"AI agent development workflow for building autonomous agents, multi-agent systems, and agent orchestration with CrewAI, LangGraph, and custom agents.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-agent-development\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-agents-architect\",\n      \"name\": \"ai-agents-architect\",\n      \"summary\": \"Expert in designing and building autonomous AI agents. Masters tool\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-agents-architect\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-analyzer\",\n      \"name\": \"ai-analyzer\",\n      \"summary\": \"AI驱动的综合健康分析系统，整合多维度健康数据、识别异常模式、预测健康风险、提供个性化建议。支持智能问答和AI健康报告生成。\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-analyzer\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-dev-jobs-mcp\",\n      \"name\": \"ai-dev-jobs-mcp\",\n      \"summary\": \"Search 8,400+ AI and ML jobs across 489 companies, inspect listings and employers, match roles, and view salary and market stats via AI Dev Jobs MCP\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-dev-jobs-mcp\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-engineer\",\n      \"name\": \"ai-engineer\",\n      \"summary\": \"Build production-ready LLM applications, advanced RAG systems, and intelligent agents. Implements vector search, multimodal AI, agent orchestration, and enterprise AI integrations.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-engineer\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-engineering-toolkit\",\n      \"name\": \"ai-engineering-toolkit\",\n      \"summary\": \"6 production-ready AI engineering workflows: prompt evaluation (8-dimension scoring), context budget planning, RAG pipeline design, agent security audit (65-point checklist), eval harness building, and product sense coaching.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-engineering-toolkit\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-md\",\n      \"name\": \"ai-md\",\n      \"summary\": \"Convert human-written CLAUDE.md into AI-native structured-label format. Battle-tested across 4 models. Same rules, fewer tokens, higher compliance.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-md\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-ml\",\n      \"name\": \"ai-ml\",\n      \"summary\": \"AI and machine learning workflow covering LLM application development, RAG implementation, agent architecture, ML pipelines, and AI-powered features.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-ml\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-native-cli\",\n      \"name\": \"ai-native-cli\",\n      \"summary\": \"Design spec with 98 rules for building CLI tools that AI agents can safely use. Covers structured JSON output, error handling, input contracts, safety guardrails, exit codes, and agent self-description.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-native-cli\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-product\",\n      \"name\": \"ai-product\",\n      \"summary\": \"Every product will be AI-powered. The question is whether you'll\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-product\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-seo\",\n      \"name\": \"ai-seo\",\n      \"summary\": \"Optimize content for AI search and LLM citations across AI Overviews, ChatGPT, Perplexity, Claude, Gemini, and similar systems. Use when improving AI visibility, answer engine optimization, or citation readiness.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-seo\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-studio-image\",\n      \"name\": \"ai-studio-image\",\n      \"summary\": \"Geracao de imagens humanizadas via Google AI Studio (Gemini). Fotos realistas estilo influencer ou educacional com iluminacao natural e imperfeicoes sutis.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-studio-image\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ai-wrapper-product\",\n      \"name\": \"ai-wrapper-product\",\n      \"summary\": \"Expert in building products that wrap AI APIs (OpenAI, Anthropic,\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ai-wrapper-product\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"airflow-dag-patterns\",\n      \"name\": \"airflow-dag-patterns\",\n      \"summary\": \"Build production Apache Airflow DAGs with best practices for operators, sensors, testing, and deployment. Use when creating data pipelines, orchestrating workflows, or scheduling batch jobs.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/airflow-dag-patterns\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"airtable-automation\",\n      \"name\": \"airtable-automation\",\n      \"summary\": \"Automate Airtable tasks via Rube MCP (Composio): records, bases, tables, fields, views. Always search tools first for current schemas.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/airtable-automation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"akf-trust-metadata\",\n      \"name\": \"akf-trust-metadata\",\n      \"summary\": \"The AI native file format. EXIF for AI — stamps every file with trust scores, source provenance, and compliance metadata. Embeds into 20+ formats (DOCX, PDF, images, code). EU AI Act, SOX, HIPAA auditing.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/akf-trust-metadata\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"algolia-search\",\n      \"name\": \"algolia-search\",\n      \"summary\": \"Expert patterns for Algolia search implementation, indexing\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/algolia-search\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"alpha-vantage\",\n      \"name\": \"alpha-vantage\",\n      \"summary\": \"Access 20+ years of global financial data: equities, options, forex, crypto, commodities, economic indicators, and 50+ technical indicators.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/alpha-vantage\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"amazon-alexa\",\n      \"name\": \"amazon-alexa\",\n      \"summary\": \"Integracao completa com Amazon Alexa para criar skills de voz inteligentes, transformar Alexa em assistente com Claude como cerebro (projeto Auri) e integrar com AWS ecosystem (Lambda, DynamoDB, Polly, Transcribe, Lex, Smart Home).\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/amazon-alexa\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"amplitude-automation\",\n      \"name\": \"amplitude-automation\",\n      \"summary\": \"Automate Amplitude tasks via Rube MCP (Composio): events, user activity, cohorts, user identification. Always search tools first for current schemas.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/amplitude-automation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"analytics-product\",\n      \"name\": \"analytics-product\",\n      \"summary\": \"Analytics de produto — PostHog, Mixpanel, eventos, funnels, cohorts, retencao, north star metric, OKRs e dashboards de produto.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/analytics-product\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"analytics-tracking\",\n      \"name\": \"analytics-tracking\",\n      \"summary\": \"Design, audit, and improve analytics tracking systems that produce reliable, decision-ready data.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/analytics-tracking\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"analyze-project\",\n      \"name\": \"analyze-project\",\n      \"summary\": \"Forensic root cause analyzer for Antigravity sessions. Classifies scope deltas, rework patterns, root causes, hotspots, and auto-improves prompts/health.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/analyze-project\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"andrej-karpathy\",\n      \"name\": \"andrej-karpathy\",\n      \"summary\": \"Agente que simula Andrej Karpathy — ex-Director of AI da Tesla, co-fundador da OpenAI, fundador da Eureka Labs, e o maior educador de deep learning do mundo.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/andrej-karpathy\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"android_ui_verification\",\n      \"name\": \"android_ui_verification\",\n      \"summary\": \"Automated end-to-end UI testing and verification on an Android Emulator using ADB.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/android_ui_verification\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"android-jetpack-compose-expert\",\n      \"name\": \"android-jetpack-compose-expert\",\n      \"summary\": \"Expert guidance for building modern Android UIs with Jetpack Compose, covering state management, navigation, performance, and Material Design 3.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/android-jetpack-compose-expert\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"angular\",\n      \"name\": \"angular\",\n      \"summary\": \"Modern Angular (v20+) expert with deep knowledge of Signals, Standalone Components, Zoneless applications, SSR/Hydration, and reactive patterns.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/angular\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"angular-best-practices\",\n      \"name\": \"angular-best-practices\",\n      \"summary\": \"Angular performance optimization and best practices guide. Use when writing, reviewing, or refactoring Angular code for optimal performance, bundle size, and rendering efficiency.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/angular-best-practices\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"angular-migration\",\n      \"name\": \"angular-migration\",\n      \"summary\": \"Master AngularJS to Angular migration, including hybrid apps, component conversion, dependency injection changes, and routing migration.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/angular-migration\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"angular-state-management\",\n      \"name\": \"angular-state-management\",\n      \"summary\": \"Master modern Angular state management with Signals, NgRx, and RxJS. Use when setting up global state, managing component stores, choosing between state solutions, or migrating from legacy patterns.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/angular-state-management\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"angular-ui-patterns\",\n      \"name\": \"angular-ui-patterns\",\n      \"summary\": \"Modern Angular UI patterns for loading states, error handling, and data display. Use when building UI components, handling async data, or managing component states.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/angular-ui-patterns\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"animejs-animation\",\n      \"name\": \"animejs-animation\",\n      \"summary\": \"Advanced JavaScript animation library skill for creating complex, high-performance web animations.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/animejs-animation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"anti-reversing-techniques\",\n      \"name\": \"anti-reversing-techniques\",\n      \"summary\": \"AUTHORIZED USE ONLY: This skill contains dual-use security techniques. Before proceeding with any bypass or analysis: > 1.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/anti-reversing-techniques\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"antigravity-design-expert\",\n      \"name\": \"antigravity-design-expert\",\n      \"summary\": \"Core UI/UX engineering skill for building highly interactive, spatial, weightless, and glassmorphism-based web interfaces using GSAP and 3D CSS.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/antigravity-design-expert\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"antigravity-skill-orchestrator\",\n      \"name\": \"antigravity-skill-orchestrator\",\n      \"summary\": \"A meta-skill that understands task requirements, dynamically selects appropriate skills, tracks successful skill combinations using agent-memory-mcp, and prevents skill overuse for simple tasks.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/antigravity-skill-orchestrator\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"antigravity-workflows\",\n      \"name\": \"antigravity-workflows\",\n      \"summary\": \"Orchestrate multiple Antigravity skills through guided workflows for SaaS MVP delivery, security audits, AI agent builds, and browser QA.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/antigravity-workflows\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"aomi-transact\",\n      \"name\": \"aomi-transact\",\n      \"summary\": \"Build natural-language crypto/DeFi agents and EVM MCP plugins (Claude Code, Cursor, Codex, Gemini). Aomi turns prompts into wallet-signed txs on Ethereum, Base, Arbitrum, Optimism, Polygon, Linea — non-custodial, fork-simulated. 40+ apps: Uniswap, Aave, Lido, Morpho, GMX, Hyperliquid, Polymarket.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/aomi-transact\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"api-design-principles\",\n      \"name\": \"api-design-principles\",\n      \"summary\": \"Master REST and GraphQL API design principles to build intuitive, scalable, and maintainable APIs that delight developers and stand the test of time.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/api-design-principles\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"api-documentation\",\n      \"name\": \"api-documentation\",\n      \"summary\": \"API documentation workflow for generating OpenAPI specs, creating developer guides, and maintaining comprehensive API documentation.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/api-documentation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"api-documentation-generator\",\n      \"name\": \"api-documentation-generator\",\n      \"summary\": \"Generate comprehensive, developer-friendly API documentation from code, including endpoints, parameters, examples, and best practices\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/api-documentation-generator\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"api-documenter\",\n      \"name\": \"api-documenter\",\n      \"summary\": \"Master API documentation with OpenAPI 3.1, AI-powered tools, and modern developer experience practices. Create interactive docs, generate SDKs, and build comprehensive developer portals.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/api-documenter\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"api-endpoint-builder\",\n      \"name\": \"api-endpoint-builder\",\n      \"summary\": \"Builds production-ready REST API endpoints with validation, error handling, authentication, and documentation. Follows best practices for security and scalability.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/api-endpoint-builder\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"api-fuzzing-bug-bounty\",\n      \"name\": \"api-fuzzing-bug-bounty\",\n      \"summary\": \"Provide comprehensive techniques for testing REST, SOAP, and GraphQL APIs during bug bounty hunting and penetration testing engagements. Covers vulnerability discovery, authentication bypass, IDOR exploitation, and API-specific attack vectors.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/api-fuzzing-bug-bounty\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"api-patterns\",\n      \"name\": \"api-patterns\",\n      \"summary\": \"API design principles and decision-making. REST vs GraphQL vs tRPC selection, response formats, versioning, pagination.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/api-patterns\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"api-security-best-practices\",\n      \"name\": \"api-security-best-practices\",\n      \"summary\": \"Implement secure API design patterns including authentication, authorization, input validation, rate limiting, and protection against common API vulnerabilities\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/api-security-best-practices\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"api-security-testing\",\n      \"name\": \"api-security-testing\",\n      \"summary\": \"API security testing workflow for REST and GraphQL APIs covering authentication, authorization, rate limiting, input validation, and security best practices.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/api-security-testing\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"api-testing-observability-api-mock\",\n      \"name\": \"api-testing-observability-api-mock\",\n      \"summary\": \"You are an API mocking expert specializing in realistic mock services for development, testing, and demos. Design mocks that simulate real API behavior and enable parallel development.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/api-testing-observability-api-mock\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"apify-actor-development\",\n      \"name\": \"apify-actor-development\",\n      \"summary\": \"Important: Before you begin, fill in the generatedBy property in the meta section of .actor/actor.json. Replace it with the tool and model you're currently using, such as \\\\\\\"Claude Code with Claude Sonnet 4.5\\\\\\\". This helps Apify monitor and improve AGENTS.md for specific AI tools and models.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/apify-actor-development\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"apify-actorization\",\n      \"name\": \"apify-actorization\",\n      \"summary\": \"Actorization converts existing software into reusable serverless applications compatible with the Apify platform. Actors are programs packaged as Docker images that accept well-defined JSON input, perform an action, and optionally produce structured JSON output.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/apify-actorization\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"apify-audience-analysis\",\n      \"name\": \"apify-audience-analysis\",\n      \"summary\": \"Understand audience demographics, preferences, behavior patterns, and engagement quality across Facebook, Instagram, YouTube, and TikTok.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/apify-audience-analysis\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"apify-brand-reputation-monitoring\",\n      \"name\": \"apify-brand-reputation-monitoring\",\n      \"summary\": \"Scrape reviews, ratings, and brand mentions from multiple platforms using Apify Actors.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/apify-brand-reputation-monitoring\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"apify-competitor-intelligence\",\n      \"name\": \"apify-competitor-intelligence\",\n      \"summary\": \"Analyze competitor strategies, content, pricing, ads, and market positioning across Google Maps, Booking.com, Facebook, Instagram, YouTube, and TikTok.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/apify-competitor-intelligence\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"apify-content-analytics\",\n      \"name\": \"apify-content-analytics\",\n      \"summary\": \"Track engagement metrics, measure campaign ROI, and analyze content performance across Instagram, Facebook, YouTube, and TikTok.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/apify-content-analytics\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"apify-ecommerce\",\n      \"name\": \"apify-ecommerce\",\n      \"summary\": \"Extract product data, prices, reviews, and seller information from any e-commerce platform using Apify's E-commerce Scraping Tool.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/apify-ecommerce\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"apify-influencer-discovery\",\n      \"name\": \"apify-influencer-discovery\",\n      \"summary\": \"Find and evaluate influencers for brand partnerships, verify authenticity, and track collaboration performance across Instagram, Facebook, YouTube, and TikTok.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/apify-influencer-discovery\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"apify-lead-generation\",\n      \"name\": \"apify-lead-generation\",\n      \"summary\": \"Scrape leads from multiple platforms using Apify Actors.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/apify-lead-generation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"apify-market-research\",\n      \"name\": \"apify-market-research\",\n      \"summary\": \"Analyze market conditions, geographic opportunities, pricing, consumer behavior, and product validation across Google Maps, Facebook, Instagram, Booking.com, and TripAdvisor.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/apify-market-research\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"apify-trend-analysis\",\n      \"name\": \"apify-trend-analysis\",\n      \"summary\": \"Discover and track emerging trends across Google Trends, Instagram, Facebook, YouTube, and TikTok to inform content strategy.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/apify-trend-analysis\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"apify-ultimate-scraper\",\n      \"name\": \"apify-ultimate-scraper\",\n      \"summary\": \"AI-driven data extraction from 55+ Actors across all major platforms. This skill automatically selects the best Actor for your task.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/apify-ultimate-scraper\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"app-builder\",\n      \"name\": \"app-builder\",\n      \"summary\": \"Main application building orchestrator. Creates full-stack applications from natural language requests. Determines project type, selects tech stack, coordinates agents.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/app-builder\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"app-store-changelog\",\n      \"name\": \"app-store-changelog\",\n      \"summary\": \"Generate user-facing App Store release notes from git history since the last tag.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/app-store-changelog\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"app-store-optimization\",\n      \"name\": \"app-store-optimization\",\n      \"summary\": \"Complete App Store Optimization (ASO) toolkit for researching, optimizing, and tracking mobile app performance on Apple App Store and Google Play Store\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/app-store-optimization\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"appdeploy\",\n      \"name\": \"appdeploy\",\n      \"summary\": \"Deploy web apps with backend APIs, database, and file storage. Use when the user asks to deploy or publish a website or web app and wants a public URL. Uses HTTP API via curl.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/appdeploy\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"application-performance-performance-optimization\",\n      \"name\": \"application-performance-performance-optimization\",\n      \"summary\": \"Optimize end-to-end application performance with profiling, observability, and backend/frontend tuning. Use when coordinating performance optimization across the stack.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/application-performance-performance-optimization\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"architect-review\",\n      \"name\": \"architect-review\",\n      \"summary\": \"Master software architect specializing in modern architecture\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/architect-review\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"architecture\",\n      \"name\": \"architecture\",\n      \"summary\": \"Architectural decision-making framework. Requirements analysis, trade-off evaluation, ADR documentation. Use when making architecture decisions or analyzing system design.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/architecture\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"architecture-decision-records\",\n      \"name\": \"architecture-decision-records\",\n      \"summary\": \"Comprehensive patterns for creating, maintaining, and managing Architecture Decision Records (ADRs) that capture the context and rationale behind significant technical decisions.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/architecture-decision-records\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"architecture-patterns\",\n      \"name\": \"architecture-patterns\",\n      \"summary\": \"Master proven backend architecture patterns including Clean Architecture, Hexagonal Architecture, and Domain-Driven Design to build maintainable, testable, and scalable systems.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/architecture-patterns\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"arm-cortex-expert\",\n      \"name\": \"arm-cortex-expert\",\n      \"summary\": \"Senior embedded software engineer specializing in firmware and driver development for ARM Cortex-M microcontrollers (Teensy, STM32, nRF52, SAMD).\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/arm-cortex-expert\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"asana-automation\",\n      \"name\": \"asana-automation\",\n      \"summary\": \"Automate Asana tasks via Rube MCP (Composio): tasks, projects, sections, teams, workspaces. Always search tools first for current schemas.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/asana-automation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"ask-questions-if-underspecified\",\n      \"name\": \"ask-questions-if-underspecified\",\n      \"summary\": \"Clarify requirements before implementing. Use when serious doubts arise.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/ask-questions-if-underspecified\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"astro\",\n      \"name\": \"astro\",\n      \"summary\": \"Build content-focused websites with Astro — zero JS by default, islands architecture, multi-framework components, and Markdown/MDX support.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/astro\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"astropy\",\n      \"name\": \"astropy\",\n      \"summary\": \"Astropy is the core Python package for astronomy, providing essential functionality for astronomical research and data analysis.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/astropy\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"async-python-patterns\",\n      \"name\": \"async-python-patterns\",\n      \"summary\": \"Comprehensive guidance for implementing asynchronous Python applications using asyncio, concurrent programming patterns, and async/await for building high-performance, non-blocking systems.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/async-python-patterns\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"attack-tree-construction\",\n      \"name\": \"attack-tree-construction\",\n      \"summary\": \"Build comprehensive attack trees to visualize threat paths. Use when mapping attack scenarios, identifying defense gaps, or communicating security risks to stakeholders.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/attack-tree-construction\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"audio-transcriber\",\n      \"name\": \"audio-transcriber\",\n      \"summary\": \"Transform audio recordings into professional Markdown documentation with intelligent summaries using LLM integration\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/audio-transcriber\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"audit-context-building\",\n      \"name\": \"audit-context-building\",\n      \"summary\": \"Enables ultra-granular, line-by-line code analysis to build deep architectural context before vulnerability or bug finding.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/audit-context-building\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"audit-skills\",\n      \"name\": \"audit-skills\",\n      \"summary\": \"Expert security auditor for AI Skills and Bundles. Performs non-intrusive static analysis to identify malicious patterns, data leaks, system stability risks, and obfuscated payloads across Windows, macOS, Linux/Unix, and Mobile (Android/iOS).\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/audit-skills\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"auri-core\",\n      \"name\": \"auri-core\",\n      \"summary\": \"Auri: assistente de voz inteligente (Alexa + Claude claude-opus-4-20250805). Visao do produto, persona Vitoria Neural, stack AWS, modelo Free/Pro/Business/Enterprise, roadmap 4 fases, GTM, north star WAC e analise competitiva.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/auri-core\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"auth-implementation-patterns\",\n      \"name\": \"auth-implementation-patterns\",\n      \"summary\": \"Build secure, scalable authentication and authorization systems using industry-standard patterns and modern best practices.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/auth-implementation-patterns\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"autonomous-agent-patterns\",\n      \"name\": \"autonomous-agent-patterns\",\n      \"summary\": \"Design patterns for building autonomous coding agents, inspired by [Cline](https://github.com/cline/cline) and [OpenAI Codex](https://github.com/openai/codex).\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/autonomous-agent-patterns\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"autonomous-agents\",\n      \"name\": \"autonomous-agents\",\n      \"summary\": \"Autonomous agents are AI systems that can independently decompose\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/autonomous-agents\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"avalonia-layout-zafiro\",\n      \"name\": \"avalonia-layout-zafiro\",\n      \"summary\": \"Guidelines for modern Avalonia UI layout using Zafiro.Avalonia, emphasizing shared styles, generic components, and avoiding XAML redundancy.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/avalonia-layout-zafiro\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"avalonia-viewmodels-zafiro\",\n      \"name\": \"avalonia-viewmodels-zafiro\",\n      \"summary\": \"Optimal ViewModel and Wizard creation patterns for Avalonia using Zafiro and ReactiveUI.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/avalonia-viewmodels-zafiro\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"avalonia-zafiro-development\",\n      \"name\": \"avalonia-zafiro-development\",\n      \"summary\": \"Mandatory skills, conventions, and behavioral rules for Avalonia UI development using the Zafiro toolkit.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/avalonia-zafiro-development\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"avoid-ai-writing\",\n      \"name\": \"avoid-ai-writing\",\n      \"summary\": \"Audit and rewrite content to remove 21 categories of AI writing patterns with a 43-entry replacement table\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/avoid-ai-writing\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"awareness-stage-mapper\",\n      \"name\": \"awareness-stage-mapper\",\n      \"summary\": \"One sentence - what this skill does and when to invoke it\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/awareness-stage-mapper\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"aws-cost-cleanup\",\n      \"name\": \"aws-cost-cleanup\",\n      \"summary\": \"Automated cleanup of unused AWS resources to reduce costs\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/aws-cost-cleanup\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"aws-cost-optimizer\",\n      \"name\": \"aws-cost-optimizer\",\n      \"summary\": \"Comprehensive AWS cost analysis and optimization recommendations using AWS CLI and Cost Explorer\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/aws-cost-optimizer\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"aws-penetration-testing\",\n      \"name\": \"aws-penetration-testing\",\n      \"summary\": \"Provide comprehensive techniques for penetration testing AWS cloud environments. Covers IAM enumeration, privilege escalation, SSRF to metadata endpoint, S3 bucket exploitation, Lambda code extraction, and persistence techniques for red team operations.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/aws-penetration-testing\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"aws-serverless\",\n      \"name\": \"aws-serverless\",\n      \"summary\": \"Specialized skill for building production-ready serverless\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/aws-serverless\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"aws-skills\",\n      \"name\": \"aws-skills\",\n      \"summary\": \"AWS development with infrastructure automation and cloud architecture patterns\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/aws-skills\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"awt-e2e-testing\",\n      \"name\": \"awt-e2e-testing\",\n      \"summary\": \"AI-powered E2E web testing — eyes and hands for AI coding tools. Declarative YAML scenarios, Playwright execution, visual matching (OpenCV + OCR), platform auto-detection (Flutter/React/Vue), learning DB. Install: npx skills add ksgisang/awt-skill --skill awt -g\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/awt-e2e-testing\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"axiom\",\n      \"name\": \"axiom\",\n      \"summary\": \"First-principles assumption auditor. Classifies each hidden assumption (fact / convention / belief / interest-driven), ranks by fragility × impact, and rebuilds conclusions from verified premises. Bilingual: auto-detects Chinese or English.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/axiom\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azd-deployment\",\n      \"name\": \"azd-deployment\",\n      \"summary\": \"Deploy containerized frontend + backend applications to Azure Container Apps with remote builds, managed identity, and idempotent infrastructure.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azd-deployment\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-agents-persistent-dotnet\",\n      \"name\": \"azure-ai-agents-persistent-dotnet\",\n      \"summary\": \"Azure AI Agents Persistent SDK for .NET. Low-level SDK for creating and managing AI agents with threads, messages, runs, and tools.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-agents-persistent-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-agents-persistent-java\",\n      \"name\": \"azure-ai-agents-persistent-java\",\n      \"summary\": \"Azure AI Agents Persistent SDK for Java. Low-level SDK for creating and managing AI agents with threads, messages, runs, and tools.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-agents-persistent-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-anomalydetector-java\",\n      \"name\": \"azure-ai-anomalydetector-java\",\n      \"summary\": \"Build anomaly detection applications with Azure AI Anomaly Detector SDK for Java. Use when implementing univariate/multivariate anomaly detection, time-series analysis, or AI-powered monitoring.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-anomalydetector-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-contentsafety-java\",\n      \"name\": \"azure-ai-contentsafety-java\",\n      \"summary\": \"Build content moderation applications using the Azure AI Content Safety SDK for Java.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-contentsafety-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-contentsafety-py\",\n      \"name\": \"azure-ai-contentsafety-py\",\n      \"summary\": \"Azure AI Content Safety SDK for Python. Use for detecting harmful content in text and images with multi-severity classification.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-contentsafety-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-contentsafety-ts\",\n      \"name\": \"azure-ai-contentsafety-ts\",\n      \"summary\": \"Analyze text and images for harmful content with customizable blocklists.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-contentsafety-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-contentunderstanding-py\",\n      \"name\": \"azure-ai-contentunderstanding-py\",\n      \"summary\": \"Azure AI Content Understanding SDK for Python. Use for multimodal content extraction from documents, images, audio, and video.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-contentunderstanding-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-document-intelligence-dotnet\",\n      \"name\": \"azure-ai-document-intelligence-dotnet\",\n      \"summary\": \"Azure AI Document Intelligence SDK for .NET. Extract text, tables, and structured data from documents using prebuilt and custom models.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-document-intelligence-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-document-intelligence-ts\",\n      \"name\": \"azure-ai-document-intelligence-ts\",\n      \"summary\": \"Extract text, tables, and structured data from documents using prebuilt and custom models.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-document-intelligence-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-formrecognizer-java\",\n      \"name\": \"azure-ai-formrecognizer-java\",\n      \"summary\": \"Build document analysis applications using the Azure AI Document Intelligence SDK for Java.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-formrecognizer-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-ml-py\",\n      \"name\": \"azure-ai-ml-py\",\n      \"summary\": \"Azure Machine Learning SDK v2 for Python. Use for ML workspaces, jobs, models, datasets, compute, and pipelines.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-ml-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-openai-dotnet\",\n      \"name\": \"azure-ai-openai-dotnet\",\n      \"summary\": \"Azure OpenAI SDK for .NET. Client library for Azure OpenAI and OpenAI services. Use for chat completions, embeddings, image generation, audio transcription, and assistants.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-openai-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-projects-dotnet\",\n      \"name\": \"azure-ai-projects-dotnet\",\n      \"summary\": \"Azure AI Projects SDK for .NET. High-level client for Azure AI Foundry projects including agents, connections, datasets, deployments, evaluations, and indexes.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-projects-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-projects-java\",\n      \"name\": \"azure-ai-projects-java\",\n      \"summary\": \"Azure AI Projects SDK for Java. High-level SDK for Azure AI Foundry project management including connections, datasets, indexes, and evaluations.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-projects-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-projects-py\",\n      \"name\": \"azure-ai-projects-py\",\n      \"summary\": \"Build AI applications on Microsoft Foundry using the azure-ai-projects SDK.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-projects-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-projects-ts\",\n      \"name\": \"azure-ai-projects-ts\",\n      \"summary\": \"High-level SDK for Azure AI Foundry projects with agents, connections, deployments, and evaluations.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-projects-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-textanalytics-py\",\n      \"name\": \"azure-ai-textanalytics-py\",\n      \"summary\": \"Azure AI Text Analytics SDK for sentiment analysis, entity recognition, key phrases, language detection, PII, and healthcare NLP. Use for natural language processing on text.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-textanalytics-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-transcription-py\",\n      \"name\": \"azure-ai-transcription-py\",\n      \"summary\": \"Azure AI Transcription SDK for Python. Use for real-time and batch speech-to-text transcription with timestamps and diarization.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-transcription-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-translation-document-py\",\n      \"name\": \"azure-ai-translation-document-py\",\n      \"summary\": \"Azure AI Document Translation SDK for batch translation of documents with format preservation. Use for translating Word, PDF, Excel, PowerPoint, and other document formats at scale.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-translation-document-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-translation-text-py\",\n      \"name\": \"azure-ai-translation-text-py\",\n      \"summary\": \"Azure AI Text Translation SDK for real-time text translation, transliteration, language detection, and dictionary lookup. Use for translating text content in applications.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-translation-text-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-translation-ts\",\n      \"name\": \"azure-ai-translation-ts\",\n      \"summary\": \"Text and document translation with REST-style clients.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-translation-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-vision-imageanalysis-java\",\n      \"name\": \"azure-ai-vision-imageanalysis-java\",\n      \"summary\": \"Build image analysis applications with Azure AI Vision SDK for Java. Use when implementing image captioning, OCR text extraction, object detection, tagging, or smart cropping.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-vision-imageanalysis-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-vision-imageanalysis-py\",\n      \"name\": \"azure-ai-vision-imageanalysis-py\",\n      \"summary\": \"Azure AI Vision Image Analysis SDK for captions, tags, objects, OCR, people detection, and smart cropping. Use for computer vision and image understanding tasks.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-vision-imageanalysis-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-voicelive-dotnet\",\n      \"name\": \"azure-ai-voicelive-dotnet\",\n      \"summary\": \"Azure AI Voice Live SDK for .NET. Build real-time voice AI applications with bidirectional WebSocket communication.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-voicelive-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-voicelive-java\",\n      \"name\": \"azure-ai-voicelive-java\",\n      \"summary\": \"Azure AI VoiceLive SDK for Java. Real-time bidirectional voice conversations with AI assistants using WebSocket.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-voicelive-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-voicelive-py\",\n      \"name\": \"azure-ai-voicelive-py\",\n      \"summary\": \"Build real-time voice AI applications with bidirectional WebSocket communication.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-voicelive-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-ai-voicelive-ts\",\n      \"name\": \"azure-ai-voicelive-ts\",\n      \"summary\": \"Azure AI Voice Live SDK for JavaScript/TypeScript. Build real-time voice AI applications with bidirectional WebSocket communication.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-ai-voicelive-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-appconfiguration-java\",\n      \"name\": \"azure-appconfiguration-java\",\n      \"summary\": \"Azure App Configuration SDK for Java. Centralized application configuration management with key-value settings, feature flags, and snapshots.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-appconfiguration-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-appconfiguration-py\",\n      \"name\": \"azure-appconfiguration-py\",\n      \"summary\": \"Azure App Configuration SDK for Python. Use for centralized configuration management, feature flags, and dynamic settings.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-appconfiguration-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-appconfiguration-ts\",\n      \"name\": \"azure-appconfiguration-ts\",\n      \"summary\": \"Centralized configuration management with feature flags and dynamic refresh.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-appconfiguration-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-communication-callautomation-java\",\n      \"name\": \"azure-communication-callautomation-java\",\n      \"summary\": \"Build server-side call automation workflows including IVR systems, call routing, recording, and AI-powered interactions.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-communication-callautomation-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-communication-callingserver-java\",\n      \"name\": \"azure-communication-callingserver-java\",\n      \"summary\": \"⚠️ DEPRECATED: This SDK has been renamed to Call Automation. For new projects, use azure-communication-callautomation instead. This skill is for maintaining legacy code only.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-communication-callingserver-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-communication-chat-java\",\n      \"name\": \"azure-communication-chat-java\",\n      \"summary\": \"Build real-time chat applications with thread management, messaging, participants, and read receipts.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-communication-chat-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-communication-common-java\",\n      \"name\": \"azure-communication-common-java\",\n      \"summary\": \"Azure Communication Services common utilities for Java. Use when working with CommunicationTokenCredential, user identifiers, token refresh, or shared authentication across ACS services.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-communication-common-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-communication-sms-java\",\n      \"name\": \"azure-communication-sms-java\",\n      \"summary\": \"Send SMS messages with Azure Communication Services SMS Java SDK. Use when implementing SMS notifications, alerts, OTP delivery, bulk messaging, or delivery reports.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-communication-sms-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-compute-batch-java\",\n      \"name\": \"azure-compute-batch-java\",\n      \"summary\": \"Azure Batch SDK for Java. Run large-scale parallel and HPC batch jobs with pools, jobs, tasks, and compute nodes.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-compute-batch-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-containerregistry-py\",\n      \"name\": \"azure-containerregistry-py\",\n      \"summary\": \"Azure Container Registry SDK for Python. Use for managing container images, artifacts, and repositories.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-containerregistry-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-cosmos-db-py\",\n      \"name\": \"azure-cosmos-db-py\",\n      \"summary\": \"Build production-grade Azure Cosmos DB NoSQL services following clean code, security best practices, and TDD principles.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-cosmos-db-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-cosmos-java\",\n      \"name\": \"azure-cosmos-java\",\n      \"summary\": \"Azure Cosmos DB SDK for Java. NoSQL database operations with global distribution, multi-model support, and reactive patterns.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-cosmos-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-cosmos-py\",\n      \"name\": \"azure-cosmos-py\",\n      \"summary\": \"Azure Cosmos DB SDK for Python (NoSQL API). Use for document CRUD, queries, containers, and globally distributed data.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-cosmos-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-cosmos-rust\",\n      \"name\": \"azure-cosmos-rust\",\n      \"summary\": \"Azure Cosmos DB SDK for Rust (NoSQL API). Use for document CRUD, queries, containers, and globally distributed data.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-cosmos-rust\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-cosmos-ts\",\n      \"name\": \"azure-cosmos-ts\",\n      \"summary\": \"Azure Cosmos DB JavaScript/TypeScript SDK (@azure/cosmos) for data plane operations. Use for CRUD operations on documents, queries, bulk operations, and container management.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-cosmos-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-data-tables-java\",\n      \"name\": \"azure-data-tables-java\",\n      \"summary\": \"Build table storage applications using the Azure Tables SDK for Java. Works with both Azure Table Storage and Cosmos DB Table API.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-data-tables-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-data-tables-py\",\n      \"name\": \"azure-data-tables-py\",\n      \"summary\": \"Azure Tables SDK for Python (Storage and Cosmos DB). Use for NoSQL key-value storage, entity CRUD, and batch operations.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-data-tables-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-eventgrid-dotnet\",\n      \"name\": \"azure-eventgrid-dotnet\",\n      \"summary\": \"Azure Event Grid SDK for .NET. Client library for publishing and consuming events with Azure Event Grid. Use for event-driven architectures, pub/sub messaging, CloudEvents, and EventGridEvents.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-eventgrid-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-eventgrid-java\",\n      \"name\": \"azure-eventgrid-java\",\n      \"summary\": \"Build event-driven applications with Azure Event Grid SDK for Java. Use when publishing events, implementing pub/sub patterns, or integrating with Azure services via events.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-eventgrid-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-eventgrid-py\",\n      \"name\": \"azure-eventgrid-py\",\n      \"summary\": \"Azure Event Grid SDK for Python. Use for publishing events, handling CloudEvents, and event-driven architectures.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-eventgrid-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-eventhub-dotnet\",\n      \"name\": \"azure-eventhub-dotnet\",\n      \"summary\": \"Azure Event Hubs SDK for .NET.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-eventhub-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-eventhub-java\",\n      \"name\": \"azure-eventhub-java\",\n      \"summary\": \"Build real-time streaming applications with Azure Event Hubs SDK for Java. Use when implementing event streaming, high-throughput data ingestion, or building event-driven architectures.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-eventhub-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-eventhub-py\",\n      \"name\": \"azure-eventhub-py\",\n      \"summary\": \"Azure Event Hubs SDK for Python streaming. Use for high-throughput event ingestion, producers, consumers, and checkpointing.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-eventhub-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-eventhub-rust\",\n      \"name\": \"azure-eventhub-rust\",\n      \"summary\": \"Azure Event Hubs SDK for Rust. Use for sending and receiving events, streaming data ingestion.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-eventhub-rust\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-eventhub-ts\",\n      \"name\": \"azure-eventhub-ts\",\n      \"summary\": \"High-throughput event streaming and real-time data ingestion.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-eventhub-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-functions\",\n      \"name\": \"azure-functions\",\n      \"summary\": \"Expert patterns for Azure Functions development including isolated\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-functions\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-identity-dotnet\",\n      \"name\": \"azure-identity-dotnet\",\n      \"summary\": \"Azure Identity SDK for .NET. Authentication library for Azure SDK clients using Microsoft Entra ID. Use for DefaultAzureCredential, managed identity, service principals, and developer credentials.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-identity-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-identity-java\",\n      \"name\": \"azure-identity-java\",\n      \"summary\": \"Authenticate Java applications with Azure services using Microsoft Entra ID (Azure AD).\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-identity-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-identity-py\",\n      \"name\": \"azure-identity-py\",\n      \"summary\": \"Azure Identity SDK for Python authentication. Use for DefaultAzureCredential, managed identity, service principals, and token caching.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-identity-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-identity-rust\",\n      \"name\": \"azure-identity-rust\",\n      \"summary\": \"Azure Identity SDK for Rust authentication. Use for DeveloperToolsCredential, ManagedIdentityCredential, ClientSecretCredential, and token-based authentication.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-identity-rust\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-identity-ts\",\n      \"name\": \"azure-identity-ts\",\n      \"summary\": \"Authenticate to Azure services with various credential types.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-identity-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-keyvault-certificates-rust\",\n      \"name\": \"azure-keyvault-certificates-rust\",\n      \"summary\": \"Azure Key Vault Certificates SDK for Rust. Use for creating, importing, and managing certificates.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-keyvault-certificates-rust\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-keyvault-keys-rust\",\n      \"name\": \"azure-keyvault-keys-rust\",\n      \"summary\": \"Azure Key Vault Keys SDK for Rust. Use for creating, managing, and using cryptographic keys. Triggers: \\\"keyvault keys rust\\\", \\\"KeyClient rust\\\", \\\"create key rust\\\", \\\"encrypt rust\\\", \\\"sign rust\\\".\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-keyvault-keys-rust\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-keyvault-keys-ts\",\n      \"name\": \"azure-keyvault-keys-ts\",\n      \"summary\": \"Manage cryptographic keys using Azure Key Vault Keys SDK for JavaScript (@azure/keyvault-keys). Use when creating, encrypting/decrypting, signing, or rotating keys.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-keyvault-keys-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-keyvault-py\",\n      \"name\": \"azure-keyvault-py\",\n      \"summary\": \"Azure Key Vault SDK for Python. Use for secrets, keys, and certificates management with secure storage.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-keyvault-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-keyvault-secrets-rust\",\n      \"name\": \"azure-keyvault-secrets-rust\",\n      \"summary\": \"Azure Key Vault Secrets SDK for Rust. Use for storing and retrieving secrets, passwords, and API keys. Triggers: \\\"keyvault secrets rust\\\", \\\"SecretClient rust\\\", \\\"get secret rust\\\", \\\"set secret rust\\\".\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-keyvault-secrets-rust\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-keyvault-secrets-ts\",\n      \"name\": \"azure-keyvault-secrets-ts\",\n      \"summary\": \"Manage secrets using Azure Key Vault Secrets SDK for JavaScript (@azure/keyvault-secrets). Use when storing and retrieving application secrets or configuration values.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-keyvault-secrets-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-maps-search-dotnet\",\n      \"name\": \"azure-maps-search-dotnet\",\n      \"summary\": \"Azure Maps SDK for .NET. Location-based services including geocoding, routing, rendering, geolocation, and weather. Use for address search, directions, map tiles, IP geolocation, and weather data.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-maps-search-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-messaging-webpubsub-java\",\n      \"name\": \"azure-messaging-webpubsub-java\",\n      \"summary\": \"Build real-time web applications with Azure Web PubSub SDK for Java. Use when implementing WebSocket-based messaging, live updates, chat applications, or server-to-client push notifications.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-messaging-webpubsub-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-messaging-webpubsubservice-py\",\n      \"name\": \"azure-messaging-webpubsubservice-py\",\n      \"summary\": \"Azure Web PubSub Service SDK for Python. Use for real-time messaging, WebSocket connections, and pub/sub patterns.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-messaging-webpubsubservice-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-mgmt-apicenter-dotnet\",\n      \"name\": \"azure-mgmt-apicenter-dotnet\",\n      \"summary\": \"Azure API Center SDK for .NET. Centralized API inventory management with governance, versioning, and discovery.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-mgmt-apicenter-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-mgmt-apicenter-py\",\n      \"name\": \"azure-mgmt-apicenter-py\",\n      \"summary\": \"Azure API Center Management SDK for Python. Use for managing API inventory, metadata, and governance across your organization.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-mgmt-apicenter-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-mgmt-apimanagement-dotnet\",\n      \"name\": \"azure-mgmt-apimanagement-dotnet\",\n      \"summary\": \"Azure Resource Manager SDK for API Management in .NET.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-mgmt-apimanagement-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-mgmt-apimanagement-py\",\n      \"name\": \"azure-mgmt-apimanagement-py\",\n      \"summary\": \"Azure API Management SDK for Python. Use for managing APIM services, APIs, products, subscriptions, and policies.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-mgmt-apimanagement-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-mgmt-applicationinsights-dotnet\",\n      \"name\": \"azure-mgmt-applicationinsights-dotnet\",\n      \"summary\": \"Azure Application Insights SDK for .NET. Application performance monitoring and observability resource management.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-mgmt-applicationinsights-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-mgmt-arizeaiobservabilityeval-dotnet\",\n      \"name\": \"azure-mgmt-arizeaiobservabilityeval-dotnet\",\n      \"summary\": \"Azure Resource Manager SDK for Arize AI Observability and Evaluation (.NET).\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-mgmt-arizeaiobservabilityeval-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-mgmt-botservice-dotnet\",\n      \"name\": \"azure-mgmt-botservice-dotnet\",\n      \"summary\": \"Azure Resource Manager SDK for Bot Service in .NET. Management plane operations for creating and managing Azure Bot resources, channels (Teams, DirectLine, Slack), and connection settings.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-mgmt-botservice-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-mgmt-botservice-py\",\n      \"name\": \"azure-mgmt-botservice-py\",\n      \"summary\": \"Azure Bot Service Management SDK for Python. Use for creating, managing, and configuring Azure Bot Service resources.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-mgmt-botservice-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-mgmt-fabric-dotnet\",\n      \"name\": \"azure-mgmt-fabric-dotnet\",\n      \"summary\": \"Azure Resource Manager SDK for Fabric in .NET.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-mgmt-fabric-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-mgmt-fabric-py\",\n      \"name\": \"azure-mgmt-fabric-py\",\n      \"summary\": \"Azure Fabric Management SDK for Python. Use for managing Microsoft Fabric capacities and resources.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-mgmt-fabric-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-mgmt-mongodbatlas-dotnet\",\n      \"name\": \"azure-mgmt-mongodbatlas-dotnet\",\n      \"summary\": \"Manage MongoDB Atlas Organizations as Azure ARM resources with unified billing through Azure Marketplace.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-mgmt-mongodbatlas-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-mgmt-weightsandbiases-dotnet\",\n      \"name\": \"azure-mgmt-weightsandbiases-dotnet\",\n      \"summary\": \"Azure Weights & Biases SDK for .NET. ML experiment tracking and model management via Azure Marketplace. Use for creating W&B instances, managing SSO, marketplace integration, and ML observability.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-mgmt-weightsandbiases-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-microsoft-playwright-testing-ts\",\n      \"name\": \"azure-microsoft-playwright-testing-ts\",\n      \"summary\": \"Run Playwright tests at scale with cloud-hosted browsers and integrated Azure portal reporting.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-microsoft-playwright-testing-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-monitor-ingestion-java\",\n      \"name\": \"azure-monitor-ingestion-java\",\n      \"summary\": \"Azure Monitor Ingestion SDK for Java. Send custom logs to Azure Monitor via Data Collection Rules (DCR) and Data Collection Endpoints (DCE).\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-monitor-ingestion-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-monitor-ingestion-py\",\n      \"name\": \"azure-monitor-ingestion-py\",\n      \"summary\": \"Azure Monitor Ingestion SDK for Python. Use for sending custom logs to Log Analytics workspace via Logs Ingestion API.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-monitor-ingestion-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-monitor-opentelemetry-exporter-java\",\n      \"name\": \"azure-monitor-opentelemetry-exporter-java\",\n      \"summary\": \"Azure Monitor OpenTelemetry Exporter for Java. Export OpenTelemetry traces, metrics, and logs to Azure Monitor/Application Insights.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-monitor-opentelemetry-exporter-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-monitor-opentelemetry-exporter-py\",\n      \"name\": \"azure-monitor-opentelemetry-exporter-py\",\n      \"summary\": \"Azure Monitor OpenTelemetry Exporter for Python. Use for low-level OpenTelemetry export to Application Insights.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-monitor-opentelemetry-exporter-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-monitor-opentelemetry-py\",\n      \"name\": \"azure-monitor-opentelemetry-py\",\n      \"summary\": \"Azure Monitor OpenTelemetry Distro for Python. Use for one-line Application Insights setup with auto-instrumentation.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-monitor-opentelemetry-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-monitor-opentelemetry-ts\",\n      \"name\": \"azure-monitor-opentelemetry-ts\",\n      \"summary\": \"Auto-instrument Node.js applications with distributed tracing, metrics, and logs.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-monitor-opentelemetry-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-monitor-query-java\",\n      \"name\": \"azure-monitor-query-java\",\n      \"summary\": \"Azure Monitor Query SDK for Java. Execute Kusto queries against Log Analytics workspaces and query metrics from Azure resources.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-monitor-query-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-monitor-query-py\",\n      \"name\": \"azure-monitor-query-py\",\n      \"summary\": \"Azure Monitor Query SDK for Python. Use for querying Log Analytics workspaces and Azure Monitor metrics.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-monitor-query-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-postgres-ts\",\n      \"name\": \"azure-postgres-ts\",\n      \"summary\": \"Connect to Azure Database for PostgreSQL Flexible Server from Node.js/TypeScript using the pg (node-postgres) package.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-postgres-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-resource-manager-cosmosdb-dotnet\",\n      \"name\": \"azure-resource-manager-cosmosdb-dotnet\",\n      \"summary\": \"Azure Resource Manager SDK for Cosmos DB in .NET.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-resource-manager-cosmosdb-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-resource-manager-durabletask-dotnet\",\n      \"name\": \"azure-resource-manager-durabletask-dotnet\",\n      \"summary\": \"Azure Resource Manager SDK for Durable Task Scheduler in .NET.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-resource-manager-durabletask-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-resource-manager-mysql-dotnet\",\n      \"name\": \"azure-resource-manager-mysql-dotnet\",\n      \"summary\": \"Azure MySQL Flexible Server SDK for .NET. Database management for MySQL Flexible Server deployments.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-resource-manager-mysql-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-resource-manager-playwright-dotnet\",\n      \"name\": \"azure-resource-manager-playwright-dotnet\",\n      \"summary\": \"Azure Resource Manager SDK for Microsoft Playwright Testing in .NET.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-resource-manager-playwright-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-resource-manager-postgresql-dotnet\",\n      \"name\": \"azure-resource-manager-postgresql-dotnet\",\n      \"summary\": \"Azure PostgreSQL Flexible Server SDK for .NET. Database management for PostgreSQL Flexible Server deployments.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-resource-manager-postgresql-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-resource-manager-redis-dotnet\",\n      \"name\": \"azure-resource-manager-redis-dotnet\",\n      \"summary\": \"Azure Resource Manager SDK for Redis in .NET.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-resource-manager-redis-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-resource-manager-sql-dotnet\",\n      \"name\": \"azure-resource-manager-sql-dotnet\",\n      \"summary\": \"Azure Resource Manager SDK for Azure SQL in .NET.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-resource-manager-sql-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-search-documents-dotnet\",\n      \"name\": \"azure-search-documents-dotnet\",\n      \"summary\": \"Azure AI Search SDK for .NET (Azure.Search.Documents). Use for building search applications with full-text, vector, semantic, and hybrid search.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-search-documents-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-search-documents-py\",\n      \"name\": \"azure-search-documents-py\",\n      \"summary\": \"Azure AI Search SDK for Python. Use for vector search, hybrid search, semantic ranking, indexing, and skillsets.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-search-documents-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-search-documents-ts\",\n      \"name\": \"azure-search-documents-ts\",\n      \"summary\": \"Build search applications with vector, hybrid, and semantic search capabilities.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-search-documents-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-security-keyvault-keys-dotnet\",\n      \"name\": \"azure-security-keyvault-keys-dotnet\",\n      \"summary\": \"Azure Key Vault Keys SDK for .NET. Client library for managing cryptographic keys in Azure Key Vault and Managed HSM. Use for key creation, rotation, encryption, decryption, signing, and verification.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-security-keyvault-keys-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-security-keyvault-keys-java\",\n      \"name\": \"azure-security-keyvault-keys-java\",\n      \"summary\": \"Azure Key Vault Keys Java SDK for cryptographic key management. Use when creating, managing, or using RSA/EC keys, performing encrypt/decrypt/sign/verify operations, or working with HSM-backed keys.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-security-keyvault-keys-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-security-keyvault-secrets-java\",\n      \"name\": \"azure-security-keyvault-secrets-java\",\n      \"summary\": \"Azure Key Vault Secrets Java SDK for secret management. Use when storing, retrieving, or managing passwords, API keys, connection strings, or other sensitive configuration data.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-security-keyvault-secrets-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-servicebus-dotnet\",\n      \"name\": \"azure-servicebus-dotnet\",\n      \"summary\": \"Azure Service Bus SDK for .NET. Enterprise messaging with queues, topics, subscriptions, and sessions.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-servicebus-dotnet\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-servicebus-py\",\n      \"name\": \"azure-servicebus-py\",\n      \"summary\": \"Azure Service Bus SDK for Python messaging. Use for queues, topics, subscriptions, and enterprise messaging patterns.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-servicebus-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-servicebus-ts\",\n      \"name\": \"azure-servicebus-ts\",\n      \"summary\": \"Enterprise messaging with queues, topics, and subscriptions.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-servicebus-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-speech-to-text-rest-py\",\n      \"name\": \"azure-speech-to-text-rest-py\",\n      \"summary\": \"Azure Speech to Text REST API for short audio (Python). Use for simple speech recognition of audio files up to 60 seconds without the Speech SDK.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-speech-to-text-rest-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-storage-blob-java\",\n      \"name\": \"azure-storage-blob-java\",\n      \"summary\": \"Build blob storage applications using the Azure Storage Blob SDK for Java.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-storage-blob-java\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-storage-blob-py\",\n      \"name\": \"azure-storage-blob-py\",\n      \"summary\": \"Azure Blob Storage SDK for Python. Use for uploading, downloading, listing blobs, managing containers, and blob lifecycle.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-storage-blob-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-storage-blob-rust\",\n      \"name\": \"azure-storage-blob-rust\",\n      \"summary\": \"Azure Blob Storage SDK for Rust. Use for uploading, downloading, and managing blobs and containers.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-storage-blob-rust\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-storage-blob-ts\",\n      \"name\": \"azure-storage-blob-ts\",\n      \"summary\": \"Azure Blob Storage JavaScript/TypeScript SDK (@azure/storage-blob) for blob operations. Use for uploading, downloading, listing, and managing blobs and containers.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-storage-blob-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-storage-file-datalake-py\",\n      \"name\": \"azure-storage-file-datalake-py\",\n      \"summary\": \"Azure Data Lake Storage Gen2 SDK for Python. Use for hierarchical file systems, big data analytics, and file/directory operations.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-storage-file-datalake-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-storage-file-share-py\",\n      \"name\": \"azure-storage-file-share-py\",\n      \"summary\": \"Azure Storage File Share SDK for Python. Use for SMB file shares, directories, and file operations in the cloud.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-storage-file-share-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-storage-file-share-ts\",\n      \"name\": \"azure-storage-file-share-ts\",\n      \"summary\": \"Azure File Share JavaScript/TypeScript SDK (@azure/storage-file-share) for SMB file share operations.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-storage-file-share-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-storage-queue-py\",\n      \"name\": \"azure-storage-queue-py\",\n      \"summary\": \"Azure Queue Storage SDK for Python. Use for reliable message queuing, task distribution, and asynchronous processing.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-storage-queue-py\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-storage-queue-ts\",\n      \"name\": \"azure-storage-queue-ts\",\n      \"summary\": \"Azure Queue Storage JavaScript/TypeScript SDK (@azure/storage-queue) for message queue operations. Use for sending, receiving, peeking, and deleting messages in queues.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-storage-queue-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"azure-web-pubsub-ts\",\n      \"name\": \"azure-web-pubsub-ts\",\n      \"summary\": \"Real-time messaging with WebSocket connections and pub/sub patterns.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/azure-web-pubsub-ts\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"backend-architect\",\n      \"name\": \"backend-architect\",\n      \"summary\": \"Expert backend architect specializing in scalable API design, microservices architecture, and distributed systems.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/backend-architect\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"backend-dev-guidelines\",\n      \"name\": \"backend-dev-guidelines\",\n      \"summary\": \"You are a senior backend engineer operating production-grade services under strict architectural and reliability constraints. Use when routes, controllers, services, repositories, express middleware, or prisma database access.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/backend-dev-guidelines\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"backend-development-feature-development\",\n      \"name\": \"backend-development-feature-development\",\n      \"summary\": \"Orchestrate end-to-end backend feature development from requirements to deployment. Use when coordinating multi-phase feature delivery across teams and services.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/backend-development-feature-development\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"backend-security-coder\",\n      \"name\": \"backend-security-coder\",\n      \"summary\": \"Expert in secure backend coding practices specializing in input validation, authentication, and API security. Use PROACTIVELY for backend security implementations or security code reviews.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/backend-security-coder\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"backtesting-frameworks\",\n      \"name\": \"backtesting-frameworks\",\n      \"summary\": \"Build robust, production-grade backtesting systems that avoid common pitfalls and produce reliable strategy performance estimates.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/backtesting-frameworks\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bamboohr-automation\",\n      \"name\": \"bamboohr-automation\",\n      \"summary\": \"Automate BambooHR tasks via Rube MCP (Composio): employees, time-off, benefits, dependents, employee updates. Always search tools first for current schemas.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bamboohr-automation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"basecamp-automation\",\n      \"name\": \"basecamp-automation\",\n      \"summary\": \"Automate Basecamp project management, to-dos, messages, people, and to-do list organization via Rube MCP (Composio). Always search tools first for current schemas.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/basecamp-automation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"baseline-ui\",\n      \"name\": \"baseline-ui\",\n      \"summary\": \"Validates animation durations, enforces typography scale, checks component accessibility, and prevents layout anti-patterns in Tailwind CSS projects. Use when building UI components, reviewing CSS utilities, styling React views, or enforcing design consistency.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/baseline-ui\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bash-defensive-patterns\",\n      \"name\": \"bash-defensive-patterns\",\n      \"summary\": \"Master defensive Bash programming techniques for production-grade scripts. Use when writing robust shell scripts, CI/CD pipelines, or system utilities requiring fault tolerance and safety.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bash-defensive-patterns\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bash-linux\",\n      \"name\": \"bash-linux\",\n      \"summary\": \"Bash/Linux terminal patterns. Critical commands, piping, error handling, scripting. Use when working on macOS or Linux systems.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bash-linux\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bash-pro\",\n      \"name\": \"bash-pro\",\n      \"summary\": \"Master of defensive Bash scripting for production automation, CI/CD\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bash-pro\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bash-scripting\",\n      \"name\": \"bash-scripting\",\n      \"summary\": \"Bash scripting workflow for creating production-ready shell scripts with defensive patterns, error handling, and testing.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bash-scripting\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bats-testing-patterns\",\n      \"name\": \"bats-testing-patterns\",\n      \"summary\": \"Master Bash Automated Testing System (Bats) for comprehensive shell script testing. Use when writing tests for shell scripts, CI/CD pipelines, or requiring test-driven development of shell utilities.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bats-testing-patterns\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bazel-build-optimization\",\n      \"name\": \"bazel-build-optimization\",\n      \"summary\": \"Optimize Bazel builds for large-scale monorepos. Use when configuring Bazel, implementing remote execution, or optimizing build performance for enterprise codebases.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bazel-build-optimization\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bdi-mental-states\",\n      \"name\": \"bdi-mental-states\",\n      \"summary\": \"This skill should be used when the user asks to \\\"model agent mental states\\\", \\\"implement BDI architecture\\\", \\\"create belief-desire-intention models\\\", \\\"transform RDF to beliefs\\\", \\\"build cognitive agent\\\", or mentions BDI ontology, mental state modeling, rational agency, or neuro-symbolic AI integration.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bdi-mental-states\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bdistill-behavioral-xray\",\n      \"name\": \"bdistill-behavioral-xray\",\n      \"summary\": \"X-ray any AI model's behavioral patterns — refusal boundaries, hallucination tendencies, reasoning style, formatting defaults. No API key needed.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bdistill-behavioral-xray\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bdistill-knowledge-extraction\",\n      \"name\": \"bdistill-knowledge-extraction\",\n      \"summary\": \"Extract structured domain knowledge from AI models in-session or from local open-source models via Ollama. No API key needed.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bdistill-knowledge-extraction\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"beautiful-prose\",\n      \"name\": \"beautiful-prose\",\n      \"summary\": \"A hard-edged writing style contract for timeless, forceful English prose without modern AI tics. Use when users ask for prose or rewrites that must be clean, exact, concrete, and free of AI cadence, filler, or therapeutic tone.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/beautiful-prose\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"behavioral-modes\",\n      \"name\": \"behavioral-modes\",\n      \"summary\": \"AI operational modes (brainstorm, implement, debug, review, teach, ship, orchestrate). Use to adapt behavior based on task type.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/behavioral-modes\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bevy-ecs-expert\",\n      \"name\": \"bevy-ecs-expert\",\n      \"summary\": \"Master Bevy's Entity Component System (ECS) in Rust, covering Systems, Queries, Resources, and parallel scheduling.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bevy-ecs-expert\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bill-gates\",\n      \"name\": \"bill-gates\",\n      \"summary\": \"Agente que simula Bill Gates — cofundador da Microsoft, arquiteto da industria de software comercial, estrategista tecnologico global, investidor sistemico e filantropo baseado em dados.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bill-gates\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"billing-automation\",\n      \"name\": \"billing-automation\",\n      \"summary\": \"Master automated billing systems including recurring billing, invoice generation, dunning management, proration, and tax calculation.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/billing-automation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"binary-analysis-patterns\",\n      \"name\": \"binary-analysis-patterns\",\n      \"summary\": \"Comprehensive patterns and techniques for analyzing compiled binaries, understanding assembly code, and reconstructing program logic.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/binary-analysis-patterns\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"biopython\",\n      \"name\": \"biopython\",\n      \"summary\": \"Biopython is a comprehensive set of freely available Python tools for biological computation. It provides functionality for sequence manipulation, file I/O, database access, structural bioinformatics, phylogenetics, and many other bioinformatics tasks.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/biopython\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"bitbucket-automation\",\n      \"name\": \"bitbucket-automation\",\n      \"summary\": \"Automate Bitbucket repositories, pull requests, branches, issues, and workspace management via Rube MCP (Composio). Always search tools first for current schemas.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/bitbucket-automation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"blockchain-developer\",\n      \"name\": \"blockchain-developer\",\n      \"summary\": \"Build production-ready Web3 applications, smart contracts, and decentralized systems. Implements DeFi protocols, NFT platforms, DAOs, and enterprise blockchain integrations.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/blockchain-developer\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"blockrun\",\n      \"name\": \"blockrun\",\n      \"summary\": \"BlockRun works with Claude Code and Google Antigravity.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/blockrun\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"blog-writing-guide\",\n      \"name\": \"blog-writing-guide\",\n      \"summary\": \"This skill enforces Sentry's blog writing standards across every post — whether you're helping an engineer write their first blog post or a marketer draft a product announcement.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/blog-writing-guide\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"blueprint\",\n      \"name\": \"blueprint\",\n      \"summary\": \"Turn a one-line objective into a step-by-step construction plan any coding agent can execute cold. Each step has a self-contained context brief — a fresh agent in a new session can pick up any step without reading prior steps.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/blueprint\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"box-automation\",\n      \"name\": \"box-automation\",\n      \"summary\": \"Automate Box operations including file upload/download, content search, folder management, collaboration, metadata queries, and sign requests through Composio's Box toolkit.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/box-automation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"brainstorming\",\n      \"name\": \"brainstorming\",\n      \"summary\": \"Use before creative or constructive work (features, architecture, behavior). Transforms vague ideas into validated designs through disciplined reasoning and collaboration.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/brainstorming\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"brand-guidelines-anthropic\",\n      \"name\": \"brand-guidelines-anthropic\",\n      \"summary\": \"To access Anthropic's official brand identity and style resources, use this skill.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/brand-guidelines-anthropic\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"brand-guidelines-community\",\n      \"name\": \"brand-guidelines-community\",\n      \"summary\": \"To access Anthropic's official brand identity and style resources, use this skill.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/brand-guidelines-community\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"brand-perception-psychologist\",\n      \"name\": \"brand-perception-psychologist\",\n      \"summary\": \"One sentence - what this skill does and when to invoke it\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/brand-perception-psychologist\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"brevo-automation\",\n      \"name\": \"brevo-automation\",\n      \"summary\": \"Automate Brevo (formerly Sendinblue) email marketing operations through Composio's Brevo toolkit via Rube MCP.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/brevo-automation\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"broken-authentication\",\n      \"name\": \"broken-authentication\",\n      \"summary\": \"Identify and exploit authentication and session management vulnerabilities in web applications. Broken authentication consistently ranks in the OWASP Top 10 and can lead to account takeover, identity theft, and unauthorized access to sensitive systems.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/broken-authentication\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    },\n    {\n      \"slug\": \"brooks-lint\",\n      \"name\": \"brooks-lint\",\n      \"summary\": \"AI code reviewer grounded in classic software engineering books for catching design smells, coupling issues, and architectural risks.\",\n      \"downloads\": 0,\n      \"stars\": 37331,\n      \"category\": \"development\",\n      \"tags\": [\n        \"agent-skills\",\n        \"agentic-skills\",\n        \"ai-agent-skills\",\n        \"ai-agents\",\n        \"ai-coding\"\n      ],\n      \"source_url\": \"https://github.com/sickn33/antigravity-awesome-skills/tree/main/skills/brooks-lint\",\n      \"updated_at\": \"2026-05-13T01:43:57Z\"\n    }\n  ]\n}\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/vite.svg\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Vite + React + TS</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"skills-hub\",\n  \"private\": true,\n  \"version\": \"0.6.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite --port 5173 --strictPort\",\n    \"build\": \"tsc -b && vite build\",\n    \"lint\": \"eslint .\",\n    \"rust:fmt\": \"cd src-tauri && cargo fmt --all\",\n    \"rust:fmt:check\": \"cd src-tauri && cargo fmt --all -- --check\",\n    \"rust:clippy\": \"cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings\",\n    \"rust:test\": \"cd src-tauri && cargo test\",\n    \"check\": \"npm run lint && npm run build && npm run rust:fmt:check && npm run rust:clippy && npm run rust:test\",\n    \"preview\": \"vite preview\",\n    \"version:set\": \"node scripts/version.mjs set\",\n    \"version:sync\": \"node scripts/version.mjs sync\",\n    \"version:check\": \"node scripts/version.mjs check\",\n    \"tauri\": \"tauri\",\n    \"tauri:dev\": \"tauri dev\",\n    \"tauri:icon:desktop\": \"node scripts/tauri-icon-desktop.mjs\",\n    \"tauri:build\": \"npm run version:check && tauri build\",\n    \"tauri:build:mac:dmg\": \"npm run tauri:build -- --bundles dmg\",\n    \"tauri:build:mac:universal:dmg\": \"npm run tauri:build -- --target universal-apple-darwin --bundles dmg\",\n    \"tauri:build:win:exe\": \"npm run tauri:build -- --bundles nsis\",\n    \"tauri:build:win:msi\": \"npm run tauri:build -- --bundles msi\",\n    \"tauri:build:win:all\": \"npm run tauri:build -- --bundles msi,nsis\",\n    \"tauri:build:linux:deb\": \"npm run tauri:build -- --bundles deb\",\n    \"tauri:build:linux:appimage\": \"npm run tauri:build -- --bundles appimage\",\n    \"tauri:build:linux:all\": \"npm run tauri:build -- --bundles deb,appimage\"\n  },\n  \"dependencies\": {\n    \"@tailwindcss/vite\": \"^4.1.18\",\n    \"@tauri-apps/api\": \"^2.9.1\",\n    \"@tauri-apps/plugin-dialog\": \"^2.5.3\",\n    \"@tauri-apps/plugin-opener\": \"^2.5.3\",\n    \"@tauri-apps/plugin-updater\": \"^2.5.3\",\n    \"clsx\": \"^2.1.1\",\n    \"i18next\": \"^25.7.4\",\n    \"i18next-browser-languagedetector\": \"^8.2.0\",\n    \"lucide-react\": \"^0.562.0\",\n    \"react\": \"^19.2.3\",\n    \"react-dom\": \"^19.2.3\",\n    \"react-i18next\": \"^16.5.3\",\n    \"react-markdown\": \"^10.1.0\",\n    \"react-router-dom\": \"^7.12.0\",\n    \"react-syntax-highlighter\": \"^16.1.1\",\n    \"remark-frontmatter\": \"^5.0.0\",\n    \"remark-gfm\": \"^4.0.1\",\n    \"sonner\": \"^2.0.7\",\n    \"tailwind-merge\": \"^3.4.0\",\n    \"tailwindcss\": \"^4.1.18\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.2\",\n    \"@tauri-apps/cli\": \"^2.9.6\",\n    \"@types/node\": \"^24.10.7\",\n    \"@types/react\": \"^19.2.8\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@types/react-syntax-highlighter\": \"^15.5.13\",\n    \"@vitejs/plugin-react\": \"^5.1.2\",\n    \"eslint\": \"^9.39.2\",\n    \"eslint-plugin-react-hooks\": \"^7.0.1\",\n    \"eslint-plugin-react-refresh\": \"^0.4.25\",\n    \"globals\": \"^16.5.0\",\n    \"typescript\": \"~5.9.3\",\n    \"typescript-eslint\": \"^8.53.0\",\n    \"vite\": \"^7.3.1\"\n  }\n}\n"
  },
  {
    "path": "scripts/coverage-rust.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nROOT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\ncd \"$ROOT_DIR/src-tauri\"\n\ncargo llvm-cov --workspace --html --output-dir \"$ROOT_DIR/coverage/llvm-cov\"\n"
  },
  {
    "path": "scripts/extract-changelog.mjs",
    "content": "import fs from 'node:fs'\n\nfunction normalizeVersion(input) {\n  const v = String(input || '').trim()\n  if (!v) return null\n  return v.startsWith('v') ? v.slice(1) : v\n}\n\nfunction extractSection(changelogText, version) {\n  const lines = changelogText.split(/\\r?\\n/)\n  const headerRe = new RegExp(`^##\\\\s+\\\\[${escapeRegExp(version)}\\\\](\\\\s*-\\\\s*.*)?$`)\n  const altHeaderRe = new RegExp(`^##\\\\s+${escapeRegExp(version)}(\\\\s*-\\\\s*.*)?$`)\n  const headerVRe = new RegExp(`^##\\\\s+\\\\[v${escapeRegExp(version)}\\\\](\\\\s*-\\\\s*.*)?$`)\n  const altHeaderVRe = new RegExp(`^##\\\\s+v${escapeRegExp(version)}(\\\\s*-\\\\s*.*)?$`)\n\n  let start = -1\n  for (let i = 0; i < lines.length; i += 1) {\n    const line = lines[i].trimEnd()\n    if (headerRe.test(line) || altHeaderRe.test(line) || headerVRe.test(line) || altHeaderVRe.test(line)) {\n      start = i + 1\n      break\n    }\n  }\n  if (start === -1) return null\n\n  let end = lines.length\n  for (let i = start; i < lines.length; i += 1) {\n    if (/^##\\s+/.test(lines[i])) {\n      end = i\n      break\n    }\n  }\n\n  const body = lines.slice(start, end).join('\\n').trim()\n  return body || null\n}\n\nfunction listVersions(changelogText) {\n  const lines = changelogText.split(/\\r?\\n/)\n  const versions = []\n  for (const rawLine of lines) {\n    const line = rawLine.trimEnd()\n    const m =\n      line.match(/^##\\s+\\[(?<v>v?\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)\\](?:\\s*-.*)?$/) ||\n      line.match(/^##\\s+(?<v>v?\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)(?:\\s*-.*)?$/)\n    if (m?.groups?.v) versions.push(m.groups.v)\n  }\n  return [...new Set(versions)]\n}\n\nfunction escapeRegExp(str) {\n  return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\nconst [tagOrVersion, changelogPathArg] = process.argv.slice(2)\nconst version = normalizeVersion(tagOrVersion)\nif (!version) {\n  console.error('Usage: node scripts/extract-changelog.mjs <tag-or-version> [CHANGELOG.md]')\n  process.exit(2)\n}\n\nconst changelogPath = changelogPathArg || 'CHANGELOG.md'\nif (!fs.existsSync(changelogPath)) {\n  console.error(`CHANGELOG not found: ${changelogPath}`)\n  process.exit(2)\n}\n\nconst changelogText = fs.readFileSync(changelogPath, 'utf8')\nconst section = extractSection(changelogText, version)\nif (!section) {\n  console.error(`Version section not found in ${changelogPath}: ${version}`)\n  const available = listVersions(changelogText)\n  if (available.length) {\n    console.error(`Available versions found: ${available.join(', ')}`)\n  } else {\n    console.error('No version headers detected. Expected headers like: \"## [0.1.1] - 2026-01-24\"')\n  }\n  process.exit(3)\n}\n\nprocess.stdout.write(section + '\\n')\n"
  },
  {
    "path": "scripts/fetch-featured-skills.mjs",
    "content": "#!/usr/bin/env node\n\n/**\n * Aggregates AI Agent Skills from a curated list of high-quality GitHub repositories.\n *\n * Fetches metadata and directory trees for each curated repo, detects individual skills\n * within each repo, and outputs featured-skills.json with one entry per skill.\n *\n * API budget: ~14 requests total (7 Repos API + 7 Trees API).\n *\n * Requires: GITHUB_TOKEN environment variable.\n */\n\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs'\nimport { resolve } from 'node:path'\n\n// Load .env file\nconst envPath = resolve(import.meta.dirname, '..', '.env')\nif (existsSync(envPath)) {\n  for (const line of readFileSync(envPath, 'utf-8').split('\\n')) {\n    const trimmed = line.trim()\n    if (!trimmed || trimmed.startsWith('#')) continue\n    const idx = trimmed.indexOf('=')\n    if (idx === -1) continue\n    const key = trimmed.slice(0, idx).trim()\n    const value = trimmed.slice(idx + 1).trim()\n    if (!process.env[key]) process.env[key] = value\n  }\n}\n\nconst OUTPUT_FILE = 'featured-skills.json'\n\n// Curated high-quality skill repositories (ordered by stars desc)\nconst CURATED_REPOS = [\n  'anthropics/skills',\n  'sickn33/antigravity-awesome-skills',\n  'K-Dense-AI/claude-scientific-skills',\n  'travisvn/awesome-claude-skills',\n  'VoltAgent/awesome-agent-skills',\n  'anthropics/knowledge-work-plugins',\n  'alirezarezvani/claude-skills',\n]\n\nconst MAX_SKILLS = 300\nconst CONCURRENCY = 10\nconst MAX_RATE_LIMIT_WAIT_SECS = 60\n\n// Skill scan bases matching installer.rs SKILL_SCAN_BASES\nconst SKILL_SCAN_BASES = [\n  'skills',\n  'skills/.curated',\n  'skills/.experimental',\n  'skills/.system',\n  '.claude/skills',\n]\n\n// Directories to skip when scanning root-level subdirs\nconst ROOT_SKIP_DIRS = new Set([\n  'skills', '.claude', '.git', '.github', '.vscode', 'node_modules',\n  '.idea', '.DS_Store', 'dist', 'build', 'out', 'target',\n  'docs', 'test', 'tests', '__tests__', 'examples', 'src', 'lib',\n])\n\n// Category classification rules\nconst CATEGORY_RULES = [\n  { keywords: ['browser', 'automation', 'playwright', 'puppeteer'], category: 'browser-automation' },\n  { keywords: ['security', 'audit', 'vulnerability', 'pentest'], category: 'security' },\n  { keywords: ['devops', 'deploy', 'infra', 'docker', 'kubernetes'], category: 'devops' },\n  { keywords: ['marketing', 'seo', 'ads', 'advertising'], category: 'marketing' },\n  { keywords: ['database', 'sql', 'postgres', 'mongo'], category: 'database' },\n  { keywords: ['git', 'github', 'pr', 'code-review'], category: 'development' },\n  { keywords: ['ai', 'llm', 'agent', 'model'], category: 'ai-assistant' },\n]\n\nconst GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''\n\n// ─── HTTP helpers ───\n\nasync function fetchJson(url, retries = 3) {\n  const headers = {\n    Accept: 'application/vnd.github+json',\n    'User-Agent': 'skills-hub-aggregator',\n  }\n  if (GITHUB_TOKEN) {\n    headers.Authorization = `Bearer ${GITHUB_TOKEN}`\n  }\n\n  for (let attempt = 0; attempt <= retries; attempt++) {\n    const res = await fetch(url, { headers })\n\n    if (res.status === 403 || res.status === 429) {\n      const resetHeader = res.headers.get('x-ratelimit-reset')\n      let waitSecs = resetHeader\n        ? Math.max(Number(resetHeader) - Math.floor(Date.now() / 1000), 1)\n        : Math.pow(2, attempt + 1)\n\n      if (waitSecs > MAX_RATE_LIMIT_WAIT_SECS) {\n        console.warn(`Rate limited, reset in ${waitSecs}s (exceeds max ${MAX_RATE_LIMIT_WAIT_SECS}s) — skipping`)\n        return null\n      }\n      console.warn(`Rate limited (${res.status}), waiting ${waitSecs}s (attempt ${attempt + 1}/${retries + 1})...`)\n      await sleep(waitSecs * 1000)\n      continue\n    }\n\n    if (!res.ok) {\n      if (attempt < retries) {\n        await sleep(Math.pow(2, attempt) * 1000)\n        continue\n      }\n      return null\n    }\n\n    return res.json()\n  }\n  return null\n}\n\nfunction sleep(ms) {\n  return new Promise((r) => setTimeout(r, ms))\n}\n\n// Run async tasks with bounded concurrency\nasync function pMap(items, fn, concurrency) {\n  const results = new Array(items.length)\n  let idx = 0\n\n  async function worker() {\n    while (idx < items.length) {\n      const i = idx++\n      results[i] = await fn(items[i], i)\n    }\n  }\n\n  const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())\n  await Promise.all(workers)\n  return results\n}\n\n// ─── Step 1: Fetch curated repo metadata ───\n\nasync function fetchRepoMetadata(fullName) {\n  const url = `https://api.github.com/repos/${fullName}`\n  const data = await fetchJson(url)\n  if (!data || !data.full_name) {\n    console.warn(`  Skipping ${fullName}: unable to fetch metadata`)\n    return null\n  }\n  return data\n}\n\nasync function fetchAllRepoMetadata() {\n  console.log(`Fetching metadata for ${CURATED_REPOS.length} curated repos...`)\n  const results = await pMap(\n    CURATED_REPOS,\n    async (fullName) => {\n      console.log(`  Fetching: ${fullName}`)\n      return fetchRepoMetadata(fullName)\n    },\n    CONCURRENCY,\n  )\n  const repos = results.filter(Boolean)\n  console.log(`Successfully fetched ${repos.length}/${CURATED_REPOS.length} repos`)\n  return repos\n}\n\n// ─── Step 2: Detect skills in each repo ───\n\nfunction detectSkillsFromTree(treeItems) {\n  const filePaths = new Set()\n  const dirPaths = new Set()\n  for (const item of treeItems) {\n    if (item.type === 'blob') filePaths.add(item.path)\n    else if (item.type === 'tree') dirPaths.add(item.path)\n  }\n\n  const skills = [] // { dirPath }\n  const foundDirs = new Set()\n\n  // Scan SKILL_SCAN_BASES\n  for (const base of SKILL_SCAN_BASES) {\n    for (const dir of dirPaths) {\n      if (!dir.startsWith(base + '/')) continue\n      const rest = dir.slice(base.length + 1)\n      if (rest.includes('/')) continue // not a direct child\n\n      const hasSkillMd = filePaths.has(dir + '/SKILL.md')\n      const isClaudeSkill = base === '.claude/skills'\n\n      if (hasSkillMd || isClaudeSkill) {\n        if (!foundDirs.has(dir)) {\n          foundDirs.add(dir)\n          skills.push({ dirPath: dir })\n        }\n      }\n    }\n  }\n\n  // Scan root-level subdirectories (must have SKILL.md or .claude-plugin/plugin.json)\n  for (const dir of dirPaths) {\n    if (dir.includes('/')) continue\n    if (ROOT_SKIP_DIRS.has(dir) || dir.startsWith('.')) continue\n    if (foundDirs.has(dir)) continue\n\n    if (filePaths.has(dir + '/SKILL.md') || filePaths.has(dir + '/.claude-plugin/plugin.json')) {\n      foundDirs.add(dir)\n      skills.push({ dirPath: dir })\n    }\n  }\n\n  // Single-skill repo: root has SKILL.md and no sub-skills found\n  if (skills.length === 0 && filePaths.has('SKILL.md')) {\n    skills.push({ dirPath: null })\n  }\n\n  return skills\n}\n\nasync function getRepoTree(owner, repo, branch) {\n  const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`\n  const data = await fetchJson(url, 2)\n  if (!data || !data.tree) return null\n  if (data.truncated) {\n    console.warn(`  Warning: tree for ${owner}/${repo} was truncated, some skills may be missed`)\n  }\n  return data.tree\n}\n\n// ─── Step 3: Classify ───\n\nfunction classify(topics, description) {\n  const text = [...(topics || []), description || ''].join(' ').toLowerCase()\n  for (const rule of CATEGORY_RULES) {\n    if (rule.keywords.some((kw) => text.includes(kw))) {\n      return rule.category\n    }\n  }\n  return 'general'\n}\n\n// ─── SKILL.md helpers ───\n\nfunction parseSkillMdFrontmatter(content) {\n  const lines = content.split('\\n')\n  if (lines[0].trim() !== '---') return null\n  let name = null\n  let description = null\n  for (let i = 1; i < lines.length; i++) {\n    const l = lines[i].trim()\n    if (l === '---') break\n    if (l.startsWith('name:')) {\n      name = l.slice(5).trim().replace(/^[\"']|[\"']$/g, '')\n    } else if (l.startsWith('description:')) {\n      description = l.slice(12).trim().replace(/^[\"']|[\"']$/g, '')\n    }\n  }\n  return { name, description }\n}\n\nasync function fetchSkillMdContent(owner, repo, branch, dirPath) {\n  const filePath = dirPath ? `${dirPath}/SKILL.md` : 'SKILL.md'\n  const url = `https://api.github.com/repos/${owner}/${repo}/contents/${encodeURIComponent(filePath)}?ref=${encodeURIComponent(branch)}`\n  const data = await fetchJson(url, 1)\n  if (!data || !data.content) return null\n  const content = Buffer.from(data.content, 'base64').toString('utf-8')\n  return parseSkillMdFrontmatter(content)\n}\n\n// ─── Name helpers ───\n\nfunction kebabToTitle(name) {\n  return name\n    .split(/[-_]/)\n    .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n    .join(' ')\n}\n\nfunction slugFromDirPath(dirPath, repoName) {\n  if (!dirPath) return repoName\n  const parts = dirPath.split('/')\n  return parts[parts.length - 1]\n}\n\n// ─── Main ───\n\nasync function main() {\n  if (!GITHUB_TOKEN) {\n    console.error('Error: GITHUB_TOKEN environment variable is required.')\n    console.error('Set it via: export GITHUB_TOKEN=ghp_xxx')\n    process.exit(1)\n  }\n\n  // Step 1: Fetch curated repo metadata\n  const repos = await fetchAllRepoMetadata()\n\n  // Step 2: Detect skills in each repo via Trees API\n  console.log('Scanning repo trees for skills...')\n  const skillEntries = [] // { repo, dirPath }\n  let treeFailures = 0\n\n  await pMap(\n    repos,\n    async (repo) => {\n      const [owner, repoName] = repo.full_name.split('/')\n      const tree = await getRepoTree(owner, repoName, repo.default_branch)\n      if (!tree) {\n        treeFailures++\n        return\n      }\n\n      const detected = detectSkillsFromTree(tree)\n      if (detected.length === 0) {\n        // No detectable skill structure — treat whole repo as single skill\n        skillEntries.push({ repo, dirPath: null })\n        return\n      }\n\n      for (const s of detected) {\n        skillEntries.push({ repo, dirPath: s.dirPath })\n      }\n    },\n    CONCURRENCY,\n  )\n\n  console.log(`Detected ${skillEntries.length} skills across ${repos.length} repos (${treeFailures} tree fetch failures)`)\n\n  // Fallback: if no skills detected, keep existing local file\n  if (skillEntries.length === 0) {\n    if (existsSync(OUTPUT_FILE)) {\n      console.warn('No skills fetched from GitHub — keeping existing local featured-skills.json')\n    } else {\n      console.error('No skills fetched and no local fallback exists.')\n      process.exit(1)\n    }\n    return\n  }\n\n  // Step 3: Sort by stars desc, deduplicate, take top MAX_SKILLS\n  skillEntries.sort((a, b) => b.repo.stargazers_count - a.repo.stargazers_count)\n\n  const seenSlugs = new Set()\n  const dedupedEntries = skillEntries.filter((entry) => {\n    const slug = slugFromDirPath(entry.dirPath, entry.repo.full_name.split('/')[1])\n    if (seenSlugs.has(slug)) return false\n    seenSlugs.add(slug)\n    return true\n  })\n  console.log(`After dedup: ${dedupedEntries.length} unique skills (removed ${skillEntries.length - dedupedEntries.length} duplicates)`)\n\n  const topEntries = dedupedEntries.slice(0, MAX_SKILLS)\n\n  // Step 4: Fetch SKILL.md for top skills to get real names\n  console.log(`Fetching SKILL.md for ${topEntries.length} skills...`)\n  const skillMdMap = new Map() // index -> { name, description }\n  await pMap(\n    topEntries,\n    async (entry, i) => {\n      const [owner, repoName] = entry.repo.full_name.split('/')\n      const md = await fetchSkillMdContent(owner, repoName, entry.repo.default_branch, entry.dirPath)\n      if (md) skillMdMap.set(i, md)\n    },\n    CONCURRENCY,\n  )\n  console.log(`Fetched SKILL.md for ${skillMdMap.size}/${topEntries.length} skills`)\n\n  // Filter out entries without SKILL.md\n  const validEntries = topEntries.filter((_, i) => skillMdMap.has(i))\n  console.log(`${validEntries.length} skills have SKILL.md (filtered out ${topEntries.length - validEntries.length})`)\n\n  // Rebuild index mapping after filtering\n  const validMdList = validEntries.map((entry, _i) => {\n    const origIndex = topEntries.indexOf(entry)\n    return { entry, md: skillMdMap.get(origIndex) }\n  })\n\n  // Step 5: Build output\n  const categorySet = new Set()\n  const topSkills = validMdList.map(({ entry, md }) => {\n    const { repo, dirPath } = entry\n    const repoName = repo.full_name.split('/')[1]\n    const slug = slugFromDirPath(dirPath, repoName)\n    const name = md.name || slug\n    const summary = (md && md.description) || repo.description || ''\n    const category = classify(repo.topics, repo.description)\n    categorySet.add(category)\n\n    let sourceUrl\n    if (dirPath) {\n      sourceUrl = `${repo.html_url}/tree/${repo.default_branch}/${dirPath}`\n    } else {\n      sourceUrl = repo.html_url\n    }\n\n    return {\n      slug,\n      name,\n      summary,\n      downloads: 0,\n      stars: repo.stargazers_count,\n      category,\n      tags: (repo.topics || []).slice(0, 5),\n      source_url: sourceUrl,\n      updated_at: repo.updated_at,\n    }\n  })\n\n  // Re-sort after name update (stars desc, then name asc)\n  topSkills.sort((a, b) => b.stars - a.stars || a.name.localeCompare(b.name))\n\n  const categories = Array.from(categorySet).sort()\n\n  const output = {\n    updated_at: new Date().toISOString(),\n    total: topSkills.length,\n    categories,\n    skills: topSkills,\n  }\n\n  writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2) + '\\n')\n  console.log(`Wrote ${topSkills.length} skills (of ${skillEntries.length} detected) to ${OUTPUT_FILE}`)\n}\n\nmain().catch((err) => {\n  console.error('Fatal error:', err)\n  process.exit(1)\n})\n"
  },
  {
    "path": "scripts/tauri-icon-desktop.mjs",
    "content": "import { spawnSync } from 'node:child_process'\nimport fs from 'node:fs'\nimport os from 'node:os'\nimport path from 'node:path'\n\nconst projectRoot = path.resolve(import.meta.dirname, '..')\nconst outDir = path.join(projectRoot, 'src-tauri', 'icons')\n\nconst cliArgs = process.argv.slice(2)\nconst sourceArg = cliArgs[0]\n\nconst defaultSourceCandidates = [\n  path.join(outDir, 'icon-source.png'),\n  path.join(projectRoot, 'public', 'logo.png'),\n]\n\nconst source =\n  sourceArg ??\n  defaultSourceCandidates.find((candidate) => fs.existsSync(candidate))\n\nif (!source) {\n  console.error(\n    '未找到图标源文件，请传入路径，例如：node scripts/tauri-icon-desktop.mjs public/logo.png',\n  )\n  process.exit(1)\n}\n\nif (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true })\n\nconst tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-hub-tauri-icons-'))\ntry {\n  const res = spawnSync(\n    'npx',\n    ['--yes', 'tauri', 'icon', source, '-o', tempDir],\n    { stdio: 'inherit', cwd: projectRoot },\n  )\n  if (res.status !== 0) process.exit(res.status ?? 1)\n\n  const keepFiles = new Set([\n    '32x32.png',\n    '64x64.png',\n    '128x128.png',\n    '128x128@2x.png',\n    'icon.png',\n    'icon.icns',\n    'icon.ico',\n  ])\n\n  for (const filename of keepFiles) {\n    const src = path.join(tempDir, filename)\n    const dst = path.join(outDir, filename)\n    if (!fs.existsSync(src)) {\n      console.error(`缺少生成文件：${src}`)\n      process.exit(1)\n    }\n    fs.copyFileSync(src, dst)\n  }\n\n  for (const entry of fs.readdirSync(outDir, { withFileTypes: true })) {\n    if (entry.isDirectory()) {\n      fs.rmSync(path.join(outDir, entry.name), { recursive: true, force: true })\n      continue\n    }\n    if (entry.name === 'icon-source.png') continue\n    if (!keepFiles.has(entry.name)) fs.rmSync(path.join(outDir, entry.name))\n  }\n} finally {\n  fs.rmSync(tempDir, { recursive: true, force: true })\n}\n\n"
  },
  {
    "path": "scripts/version.mjs",
    "content": "#!/usr/bin/env node\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport process from \"node:process\";\n\nconst ROOT = process.cwd();\n\nfunction read(filePath) {\n  return fs.readFileSync(path.join(ROOT, filePath), \"utf8\");\n}\n\nfunction write(filePath, contents) {\n  fs.writeFileSync(path.join(ROOT, filePath), contents, \"utf8\");\n}\n\nfunction replaceJsonStringProp(filePath, propName, newValue) {\n  const original = read(filePath);\n  const re = new RegExp(`(\"${propName}\"\\\\s*:\\\\s*\")([^\"]*)(\")`);\n  const m = original.match(re);\n  if (!m) throw new Error(`Cannot find \"${propName}\" in ${filePath}`);\n  const updated = original.replace(re, `$1${newValue}$3`);\n  JSON.parse(updated);\n  if (updated !== original) write(filePath, updated);\n  return { from: m[2], to: newValue, changed: updated !== original };\n}\n\nfunction replaceCargoPackageVersion(filePath, newValue) {\n  const original = read(filePath);\n  const pkgHeader = original.match(/^\\[package\\]\\s*$/m);\n  if (!pkgHeader) throw new Error(`Cannot find [package] section in ${filePath}`);\n  const pkgStart = pkgHeader.index ?? 0;\n  const afterPkg = original.slice(pkgStart + pkgHeader[0].length);\n  const nextSection = afterPkg.match(/^\\[[^\\]]+\\]\\s*$/m);\n  const pkgEnd = nextSection?.index != null ? pkgStart + pkgHeader[0].length + nextSection.index : original.length;\n\n  const before = original.slice(0, pkgStart);\n  const pkgSection = original.slice(pkgStart, pkgEnd);\n  const after = original.slice(pkgEnd);\n\n  const re = /^version\\s*=\\s*\"([^\"]*)\"\\s*$/m;\n  const m = pkgSection.match(re);\n  if (!m) throw new Error(`Cannot find package version in ${filePath}`);\n  const updatedSection = pkgSection.replace(re, `version = \"${newValue}\"`);\n\n  const updated = `${before}${updatedSection}${after}`;\n  if (updated !== original) write(filePath, updated);\n  return { from: m[1], to: newValue, changed: updated !== original };\n}\n\nfunction getPackageJsonVersion() {\n  const pkg = JSON.parse(read(\"package.json\"));\n  if (!pkg.version || typeof pkg.version !== \"string\") {\n    throw new Error(\"package.json missing valid version\");\n  }\n  return pkg.version;\n}\n\nfunction setPackageJsonVersion(newVersion) {\n  return replaceJsonStringProp(\"package.json\", \"version\", newVersion);\n}\n\nfunction syncFromPackageJson() {\n  const version = getPackageJsonVersion();\n  const results = [];\n  results.push({ file: \"package.json\", ...(replaceJsonStringProp(\"package.json\", \"version\", version)) });\n  results.push({ file: \"src-tauri/tauri.conf.json\", ...(replaceJsonStringProp(\"src-tauri/tauri.conf.json\", \"version\", version)) });\n  results.push({ file: \"src-tauri/Cargo.toml\", ...(replaceCargoPackageVersion(\"src-tauri/Cargo.toml\", version)) });\n  return { version, results };\n}\n\nfunction checkInSync() {\n  const version = getPackageJsonVersion();\n  const mismatches = [];\n\n  const tauriConfVersion = JSON.parse(read(\"src-tauri/tauri.conf.json\")).version;\n  if (tauriConfVersion !== version) {\n    mismatches.push(`src-tauri/tauri.conf.json version=${tauriConfVersion} (expected ${version})`);\n  }\n\n  const cargoToml = read(\"src-tauri/Cargo.toml\");\n  const pkgHeader = cargoToml.match(/^\\[package\\]\\s*$/m);\n  if (!pkgHeader) throw new Error(\"src-tauri/Cargo.toml missing [package] section\");\n  const pkgStart = pkgHeader.index ?? 0;\n  const afterPkg = cargoToml.slice(pkgStart + pkgHeader[0].length);\n  const nextSection = afterPkg.match(/^\\[[^\\]]+\\]\\s*$/m);\n  const pkgEnd = nextSection?.index != null ? pkgStart + pkgHeader[0].length + nextSection.index : cargoToml.length;\n  const pkgSection = cargoToml.slice(pkgStart, pkgEnd);\n  const m = pkgSection.match(/^version\\s*=\\s*\"([^\"]*)\"\\s*$/m);\n  if (!m) throw new Error(\"src-tauri/Cargo.toml missing package version\");\n  const cargoVersion = m[1];\n  if (cargoVersion !== version) {\n    mismatches.push(`src-tauri/Cargo.toml version=${cargoVersion} (expected ${version})`);\n  }\n\n  return { version, mismatches };\n}\n\nfunction usage() {\n  console.log(\"Usage:\");\n  console.log(\"  node scripts/version.mjs set <x.y.z>\");\n  console.log(\"  node scripts/version.mjs sync\");\n  console.log(\"  node scripts/version.mjs check\");\n}\n\nasync function main() {\n  const [cmd, arg] = process.argv.slice(2);\n  if (!cmd) {\n    usage();\n    process.exit(1);\n  }\n\n  if (cmd === \"set\") {\n    if (!arg) {\n      usage();\n      process.exit(1);\n    }\n    setPackageJsonVersion(arg);\n    syncFromPackageJson();\n    console.log(`Version set to ${arg}`);\n    return;\n  }\n\n  if (cmd === \"sync\") {\n    const { version, results } = syncFromPackageJson();\n    const changedFiles = results.filter((r) => r.changed).map((r) => r.file);\n    console.log(`Synced version ${version}${changedFiles.length ? ` (updated: ${changedFiles.join(\", \")})` : \"\"}`);\n    return;\n  }\n\n  if (cmd === \"check\") {\n    const { version, mismatches } = checkInSync();\n    if (mismatches.length) {\n      console.error(`Version mismatch (package.json=${version}):`);\n      for (const line of mismatches) console.error(`- ${line}`);\n      process.exit(1);\n    }\n    console.log(`Version OK (${version})`);\n    return;\n  }\n\n  usage();\n  process.exit(1);\n}\n\nmain().catch((err) => {\n  console.error(err?.stack || String(err));\n  process.exit(1);\n});\n\n"
  },
  {
    "path": "src/App.css",
    "content": ".skills-app {\n  height: 100%;\n  background: var(--bg-app);\n  border: none;\n  border-radius: 0;\n  box-shadow: none;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n}\n\n.skills-header {\n  height: 56px;\n  padding: 0 24px;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  border-bottom: 1px solid var(--border-subtle);\n  background: var(--bg-header);\n  backdrop-filter: blur(12px);\n}\n\n.header-left {\n  display: flex;\n  align-items: center;\n  gap: 24px;\n  height: 100%;\n}\n\n.brand-area {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n}\n\n/* Navigation Tabs */\n.nav-tabs {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  height: 100%;\n}\n\n.nav-tab {\n  height: 100%;\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 0 14px;\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--text-tertiary);\n  cursor: pointer;\n  border: none;\n  background: none;\n  position: relative;\n  transition: color 0.2s;\n  font-family: inherit;\n}\n\n.nav-tab:hover {\n  color: var(--text-secondary);\n}\n\n.nav-tab.active {\n  color: var(--accent-primary);\n  font-weight: 600;\n}\n\n.nav-tab.active::after {\n  content: '';\n  position: absolute;\n  bottom: 0;\n  left: 8px;\n  right: 8px;\n  height: 2px;\n  background: var(--accent-primary);\n  border-radius: 2px 2px 0 0;\n}\n\n.logo-icon {\n  width: 36px;\n  height: 36px;\n  display: block;\n  object-fit: contain;\n  border-radius: 8px;\n}\n\n.brand-text-wrap {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n.brand-text {\n  font-weight: 700;\n  font-size: 20px;\n  letter-spacing: -0.03em;\n  background: linear-gradient(to right, #2563eb, #9333ea, #ea580c);\n  background-clip: text;\n  -webkit-background-clip: text;\n  -webkit-text-fill-color: transparent;\n}\n\n.brand-subtitle {\n  font-size: 12px;\n  color: var(--text-secondary);\n}\n\n.header-actions {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.skills-main {\n  flex: 1;\n  overflow: hidden;\n  background: var(--bg-app);\n  display: flex;\n  flex-direction: column;\n  min-height: 0;\n}\n\n.dashboard-stack {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  min-height: 0;\n}\n\n.filter-bar {\n  padding: 20px 32px 0 32px;\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  gap: 16px;\n}\n\n.filter-title {\n  font-size: 14px;\n  font-weight: 500;\n  color: var(--text-secondary);\n  font-family: var(--font-mono);\n  flex-shrink: 0;\n  white-space: nowrap;\n}\n\n.filter-actions {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  min-width: 0;\n}\n\n.filter-actions .btn {\n  flex-shrink: 0;\n  white-space: nowrap;\n}\n\n.tag-filter-wrap {\n  position: relative;\n  flex-shrink: 0;\n}\n\n.tag-filter-btn {\n  min-width: 108px;\n  justify-content: center;\n  white-space: nowrap;\n}\n\n.tag-filter-btn.active {\n  border-color: rgba(37, 99, 235, 0.45);\n  color: var(--accent-primary);\n  background: var(--accent-soft-bg);\n}\n\n.tag-filter-menu {\n  position: absolute;\n  top: calc(100% + 8px);\n  right: 0;\n  width: 320px;\n  max-height: min(480px, calc(100vh - 140px));\n  display: flex;\n  flex-direction: column;\n  background: var(--bg-app);\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-lg);\n  box-shadow: var(--shadow-lg);\n  z-index: 30;\n  overflow: hidden;\n}\n\n.tag-filter-head {\n  height: 44px;\n  padding: 0 14px;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  font-size: 13px;\n  font-weight: 700;\n  color: var(--text-primary);\n}\n\n.tag-filter-head span:last-child {\n  color: var(--text-tertiary);\n  font-weight: 600;\n}\n\n.tag-filter-search {\n  height: 36px;\n  margin: 0 12px 10px;\n  padding: 0 10px;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-md);\n  background: var(--bg-panel);\n  color: var(--text-tertiary);\n}\n\n.tag-filter-search input {\n  flex: 1;\n  min-width: 0;\n  border: none;\n  outline: none;\n  background: transparent;\n  color: var(--text-primary);\n  font-family: var(--font-ui);\n  font-size: 13px;\n}\n\n.tag-filter-options {\n  padding: 0 8px 8px;\n  overflow-y: auto;\n}\n\n.tag-filter-option {\n  width: 100%;\n  height: 36px;\n  display: grid;\n  grid-template-columns: 24px 1fr auto;\n  align-items: center;\n  gap: 8px;\n  border: none;\n  background: transparent;\n  border-radius: var(--radius-md);\n  color: var(--text-secondary);\n  cursor: pointer;\n  font-size: 13px;\n  font-weight: 600;\n  text-align: left;\n  padding: 0 8px;\n}\n\n.tag-filter-option:hover,\n.tag-filter-option.selected {\n  background: var(--bg-element);\n  color: var(--text-primary);\n}\n\n.tag-check {\n  width: 18px;\n  height: 18px;\n  border: 1px solid var(--border-subtle);\n  border-radius: 5px;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--accent-primary-fg);\n  background: var(--bg-panel);\n}\n\n.tag-filter-option.selected .tag-check {\n  background: var(--accent-primary);\n  border-color: var(--accent-primary);\n}\n\n.tag-count {\n  color: var(--text-tertiary);\n  font-weight: 600;\n}\n\n.tag-filter-footer {\n  height: 48px;\n  border-top: 1px solid var(--border-subtle);\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 0 12px;\n  background: var(--bg-panel);\n}\n\n.tag-filter-footer button {\n  border: none;\n  background: transparent;\n  color: var(--text-secondary);\n  cursor: pointer;\n  font-weight: 600;\n  font-size: 13px;\n}\n\n.tag-filter-footer button:hover {\n  color: var(--accent-primary);\n}\n\n.tag-filter-footer button:disabled {\n  color: var(--text-tertiary);\n  cursor: not-allowed;\n}\n\n.sort-btn {\n  height: 36px;\n  padding: 0 12px;\n  font-weight: 400;\n  position: relative;\n  white-space: nowrap;\n}\n\n.sort-btn select {\n  position: absolute;\n  inset: 0;\n  opacity: 0;\n  cursor: pointer;\n}\n\n.search-container {\n  position: relative;\n  flex: 0 1 220px;\n}\n\n.search-input {\n  background: var(--bg-panel);\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-md);\n  height: 36px;\n  padding: 0 12px 0 36px;\n  color: var(--text-primary);\n  width: 100%;\n  min-width: 180px;\n  font-size: 13px;\n  transition: all 0.2s;\n  font-family: var(--font-ui);\n}\n\n.search-input:focus {\n  outline: none;\n  border-color: var(--accent-primary);\n  background: var(--bg-app);\n  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);\n}\n\n.search-icon-abs {\n  position: absolute;\n  left: 12px;\n  top: 50%;\n  transform: translateY(-50%);\n  width: 16px;\n  height: 16px;\n  color: var(--text-tertiary);\n  pointer-events: none;\n}\n\n.status-row {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 8px 32px 0;\n}\n\n.error {\n  color: var(--status-error);\n  font-size: 13px;\n  white-space: pre-wrap;\n}\n\n.status {\n  color: var(--status-info);\n  font-size: 13px;\n}\n\n\n.skills-list {\n  padding: 16px 32px 32px;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  overflow-y: auto;\n  flex: 1;\n  min-height: 0;\n}\n\n.discovered-banner {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  background: var(--warning-soft-bg);\n  border: 1px dashed var(--warning-soft-border);\n  border-radius: var(--radius-lg);\n  padding: 12px 20px;\n}\n\n.banner-left {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.banner-icon {\n  width: 32px;\n  height: 32px;\n  background: rgba(245, 158, 11, 0.1);\n  border-radius: var(--radius-md);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--status-warning);\n}\n\n.banner-title {\n  font-weight: 600;\n  color: var(--text-primary);\n  font-size: 14px;\n}\n\n.banner-subtitle {\n  font-size: 12px;\n  color: var(--text-secondary);\n}\n\n.skill-card {\n  background: var(--bg-app);\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-lg);\n  padding: 16px 20px;\n  display: grid;\n  grid-template-columns: 48px 1fr auto;\n  gap: 20px;\n  align-items: center;\n  transition: all 0.2s;\n  position: relative;\n}\n\n.skill-card:hover {\n  border-color: var(--border-strong);\n  background: var(--bg-panel);\n  transform: translateY(-1px);\n  box-shadow: var(--shadow-sm);\n  z-index: 1;\n}\n\n.skill-icon {\n  width: 48px;\n  height: 48px;\n  background: var(--bg-element);\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-md);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--text-secondary);\n  font-size: 20px;\n  transition: all 0.2s;\n}\n\n.skill-card:hover .skill-icon {\n  color: var(--text-primary);\n  border-color: var(--border-strong);\n  background: var(--bg-panel);\n}\n\n.skill-main {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  justify-content: center;\n}\n\n.skill-header-row {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  flex-wrap: wrap;\n}\n\n.skill-name {\n  font-weight: 600;\n  color: var(--text-primary);\n  font-size: 15px;\n  letter-spacing: -0.01em;\n}\n\n.skill-tags-inline {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  flex-wrap: wrap;\n}\n\n.skill-tag-pill {\n  height: 22px;\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  border: 1px solid var(--border-subtle);\n  border-radius: 999px;\n  background: var(--bg-element);\n  color: var(--text-secondary);\n  padding: 0 8px;\n  font-size: 11px;\n  font-weight: 600;\n  cursor: pointer;\n}\n\n.skill-tag-pill:hover {\n  color: var(--accent-primary);\n  border-color: rgba(37, 99, 235, 0.35);\n  background: var(--accent-soft-bg);\n}\n\n.skill-tag-pill.muted {\n  color: var(--text-tertiary);\n}\n\n.skill-desc {\n  font-size: 13px;\n  color: var(--text-secondary);\n  line-height: 1.5;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n  margin-top: 2px;\n}\n\n.skill-meta-row {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  margin-top: 4px;\n}\n\n.skill-source {\n  font-family: var(--font-ui);\n  font-size: 12px;\n  color: var(--text-tertiary);\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.skill-source.time {\n  white-space: nowrap;\n}\n\n.scope-badge {\n  height: 22px;\n  display: inline-flex;\n  align-items: center;\n  border: 1px solid var(--border-subtle);\n  border-radius: 6px;\n  padding: 0 8px;\n  background: var(--bg-element);\n  color: var(--text-secondary);\n  font-size: 12px;\n  cursor: pointer;\n}\n\n.scope-badge.project {\n  border-color: #2563eb;\n  color: #2563eb;\n  background: rgba(37, 99, 235, 0.08);\n}\n\n.scope-badge.global {\n  border-color: var(--status-success);\n  color: var(--status-success);\n  background: rgba(34, 197, 94, 0.08);\n}\n\n.repo-pill {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  max-width: 320px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  color: var(--text-secondary);\n  font-weight: 500;\n  padding: 2px 8px;\n  border-radius: 999px;\n  border: 1px solid var(--border-subtle);\n  background: var(--bg-element);\n  transition: all 0.2s;\n}\n\n.repo-pill.copyable {\n  cursor: pointer;\n}\n\n.repo-pill.copyable:hover {\n  color: var(--text-primary);\n  border-color: var(--border-strong);\n  background: #eef2ff;\n}\n\n.repo-pill:disabled {\n  opacity: 0.6;\n  cursor: not-allowed;\n}\n\n.repo-pill .copy-icon {\n  display: inline-flex;\n  align-items: center;\n  opacity: 0;\n  transform: translateX(-2px);\n  transition: all 0.2s;\n  color: var(--text-tertiary);\n}\n\n.repo-pill.copyable:hover .copy-icon {\n  opacity: 1;\n  transform: translateX(0);\n  color: var(--text-secondary);\n}\n\n.dot {\n  color: var(--text-tertiary);\n}\n\n.tool-matrix {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  flex-wrap: wrap;\n  margin-top: 10px;\n}\n\n.tool-matrix.collapsed {\n  flex-wrap: nowrap;\n  overflow: hidden;\n}\n\n.tool-pill {\n  height: 22px;\n  padding: 0 8px;\n  border-radius: 4px;\n  font-size: 11px;\n  font-weight: 500;\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  cursor: pointer;\n  transition: all 0.2s;\n  border: 1px solid var(--border-subtle);\n  font-family: var(--font-ui);\n  background: var(--bg-panel);\n  color: var(--text-secondary);\n}\n\n.tool-pill.active {\n  background: var(--bg-panel);\n  color: var(--status-success);\n  border-color: var(--status-success);\n}\n\n.tool-pill.active.project {\n  color: #2563eb;\n  border-color: #2563eb;\n}\n\n.tool-pill.active:hover {\n  border-color: var(--status-success);\n  transform: translateY(-1px);\n}\n\n.tool-pill.active.project:hover {\n  border-color: #2563eb;\n}\n\n.tool-pill.inactive {\n  background: var(--bg-element);\n  color: var(--text-tertiary);\n  border-color: transparent;\n}\n\n.tool-pill.inactive:hover {\n  color: var(--text-secondary);\n  background: var(--bg-element-hover);\n}\n\n.tool-pill.disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n  border: 1px dashed var(--border-strong);\n  background: transparent;\n  color: var(--text-tertiary);\n}\n\n.tool-pill.more-badge {\n  background: var(--bg-element);\n  color: var(--text-tertiary);\n  border: 1px solid var(--border-subtle);\n  font-size: 11px;\n  cursor: pointer;\n  flex-shrink: 0;\n}\n\n.tool-pill.more-badge:hover {\n  background: var(--bg-element-hover);\n  color: var(--text-secondary);\n}\n\n.tool-pill-toggle {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  padding: 4px 8px;\n  border-radius: 6px;\n  border: 1px solid var(--border-subtle);\n  background: var(--bg-element);\n  font-size: 12px;\n  color: var(--text-secondary);\n  cursor: pointer;\n  transition: all 0.2s;\n}\n\n.tool-pill-toggle input {\n  display: none;\n}\n\n.tool-pill-toggle.active {\n  background: var(--success-soft-bg);\n  border-color: var(--success-soft-border);\n  color: var(--status-success);\n}\n\n.tool-pill-toggle.disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n.add-tags-list {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n  max-height: 148px;\n  overflow: auto;\n  padding: 2px 0 2px;\n}\n\n.add-tag-pill {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  padding: 5px 10px;\n  border-radius: 999px;\n  border: 1px solid var(--border-subtle);\n  background: var(--bg-element);\n  color: var(--text-secondary);\n  font-size: 12px;\n  font-weight: 600;\n  cursor: pointer;\n  transition: all 0.2s;\n}\n\n.add-tag-pill:hover {\n  color: var(--accent-primary);\n  border-color: rgba(37, 99, 235, 0.35);\n  background: var(--accent-soft-bg);\n}\n\n.add-tag-pill.selected {\n  border-color: var(--accent-primary);\n  background: var(--accent-soft-bg);\n  color: var(--accent-primary);\n}\n\n.add-tag-check {\n  width: 14px;\n  height: 14px;\n  border-radius: 4px;\n  border: 1px solid var(--border-subtle);\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--accent-primary-fg);\n  background: var(--bg-app);\n}\n\n.add-tag-pill.selected .add-tag-check {\n  background: var(--accent-primary);\n  border-color: var(--accent-primary);\n}\n\n.status-badge {\n  width: 6px;\n  height: 6px;\n  border-radius: 50%;\n  background: var(--status-success);\n  box-shadow: 0 0 0 2px var(--success-soft-border);\n}\n\n.skill-actions-col {\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  gap: 8px;\n  align-self: center;\n}\n\n.card-btn {\n  height: 32px;\n  width: 32px;\n  padding: 0;\n  border-radius: var(--radius-md);\n  background: transparent;\n  color: var(--text-tertiary);\n  border: 1px solid transparent;\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  transition: all 0.2s;\n}\n\n.card-btn:hover {\n  color: var(--text-primary);\n  background: var(--bg-element);\n  border-color: var(--border-subtle);\n  transform: translateY(-1px);\n  box-shadow: var(--shadow-sm);\n}\n\n.card-btn.primary-action {\n  color: var(--accent-primary);\n  background: var(--accent-soft-bg);\n  border-color: var(--accent-soft-border);\n}\n\n.card-btn.tag-action.has-tags {\n  color: var(--accent-primary);\n  background: var(--accent-soft-bg);\n  border-color: var(--accent-soft-border);\n}\n\n.card-btn.primary-action:hover {\n  background: var(--accent-primary);\n  color: var(--accent-primary-fg);\n  border-color: var(--accent-primary);\n}\n\n.card-btn.danger-action:hover {\n  color: var(--status-error);\n  background: var(--danger-soft-bg);\n  border-color: var(--danger-soft-border);\n}\n\n.empty {\n  padding: 16px;\n  border-radius: var(--radius-lg);\n  border: 1px dashed var(--border-subtle);\n  color: var(--text-secondary);\n  background: var(--bg-app);\n  margin: 0;\n}\n\n.btn {\n  height: 36px;\n  padding: 0 16px;\n  border-radius: var(--radius-md);\n  font-size: 13px;\n  font-weight: 600;\n  cursor: pointer;\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  border: 1px solid transparent;\n  transition: all 0.2s;\n  position: relative;\n  overflow: hidden;\n  background: transparent;\n}\n\n.btn-primary {\n  background: var(--accent-primary);\n  color: var(--accent-primary-fg);\n  box-shadow: 0 4px 6px -1px rgba(37, 99, 235, 0.2);\n}\n\n.btn-primary:hover {\n  background: var(--accent-primary-hover);\n  transform: translateY(-1px);\n  box-shadow: 0 6px 8px -1px rgba(37, 99, 235, 0.3);\n}\n\n.btn-secondary {\n  background: transparent;\n  border-color: var(--border-subtle);\n  color: var(--text-primary);\n}\n\n.btn-secondary:hover {\n  border-color: var(--border-strong);\n  background: var(--bg-element);\n}\n\n.btn-warning {\n  background: var(--status-warning);\n  border-color: var(--status-warning);\n  color: var(--accent-primary-fg);\n  height: 32px;\n}\n\n.btn-warning:hover {\n  opacity: 0.9;\n}\n\n.btn-danger {\n  background: var(--danger-soft-bg);\n  color: var(--status-error);\n  border-color: var(--danger-soft-border);\n}\n\n.btn-danger:hover {\n  background: var(--danger-soft-border);\n  border-color: var(--status-error);\n}\n\n.btn:disabled,\n.icon-btn:disabled,\n.lang-btn:disabled,\n.card-btn:disabled {\n  opacity: 0.6;\n  cursor: not-allowed;\n}\n\n.icon-btn {\n  width: 36px;\n  height: 36px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: var(--radius-md);\n  color: var(--text-secondary);\n  cursor: pointer;\n  transition: all 0.2s;\n  border: 1px solid var(--border-subtle);\n  background: var(--bg-app);\n}\n\n.icon-btn:hover {\n  background: var(--bg-element);\n  color: var(--text-primary);\n  transform: translateY(-1px);\n}\n\n.lang-btn {\n  font-size: 12px;\n  font-weight: 600;\n  color: var(--text-secondary);\n  cursor: pointer;\n  padding: 6px 10px;\n  border-radius: var(--radius-sm);\n  background: transparent;\n  border: 1px solid var(--border-subtle);\n  font-family: var(--font-mono);\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.lang-btn:hover {\n  color: var(--text-primary);\n  background: var(--bg-element);\n  border-color: var(--text-tertiary);\n}\n\n.modal-backdrop {\n  position: fixed;\n  inset: 0;\n  background: rgba(17, 24, 39, 0.45);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 24px;\n  z-index: 999;\n}\n\n.loading-backdrop {\n  z-index: 2000;\n}\n\n.modal {\n  width: min(520px, 100%);\n  background: var(--bg-app);\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-lg);\n  box-shadow: var(--shadow-lg);\n  overflow: hidden;\n}\n\n.modal-lg {\n  width: min(720px, 100%);\n}\n\n.modal-xl {\n  width: min(880px, 100%);\n}\n\n.modal-header {\n  padding: 16px 20px;\n  border-bottom: 1px solid var(--border-subtle);\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.modal-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n.modal-close {\n  color: var(--text-tertiary);\n  cursor: pointer;\n  background: transparent;\n  border: none;\n  font-size: 18px;\n  line-height: 1;\n}\n\n.modal-close:hover {\n  color: var(--text-primary);\n}\n\n.modal-body {\n  padding: 20px;\n  color: var(--text-secondary);\n  font-size: 13px;\n  line-height: 1.5;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.modal-actions {\n  margin-top: 16px;\n  display: flex;\n  justify-content: flex-end;\n  gap: 10px;\n}\n\n.modal-footer {\n  padding: 16px 20px;\n  border-top: 1px solid var(--border-subtle);\n  display: flex;\n  justify-content: flex-end;\n  gap: 12px;\n  background: var(--bg-panel);\n}\n\n.modal-footer.space-between {\n  justify-content: space-between;\n}\n\n.modal-delete .delete-body {\n  gap: 12px;\n}\n\n.tag-delete-modal {\n  width: min(440px, 100%);\n}\n\n.tag-delete-body {\n  white-space: pre-line;\n}\n\n.scope-modal {\n  width: min(620px, 100%);\n}\n\n.scope-modal-body {\n  gap: 10px;\n}\n\n.scope-help {\n  color: var(--text-secondary);\n  font-size: 13px;\n  line-height: 1.5;\n  margin-bottom: 2px;\n}\n\n.scope-choice {\n  display: flex;\n  align-items: flex-start;\n  gap: 10px;\n  padding: 10px 12px;\n  border: 1px solid var(--border-subtle);\n  border-radius: 8px;\n  cursor: pointer;\n}\n\n.scope-choice.active {\n  border-color: var(--border-strong);\n  background: var(--bg-panel);\n}\n\n.scope-choice input {\n  margin-top: 3px;\n}\n\n.scope-choice span {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n.scope-choice small {\n  color: var(--text-tertiary);\n}\n\n.scope-inline-warning {\n  padding: 10px 12px;\n  border: 1px solid rgba(245, 158, 11, 0.35);\n  border-radius: 8px;\n  background: rgba(245, 158, 11, 0.08);\n  color: #92400e;\n  font-size: 13px;\n  line-height: 1.5;\n}\n\n.project-sync-panel {\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n  padding-top: 6px;\n}\n\n.project-sync-heading {\n  color: var(--text-secondary);\n  font-size: 12px;\n  font-weight: 600;\n}\n\n.project-path-list,\n.recent-project-list {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.project-path-row,\n.recent-project-row {\n  min-height: 34px;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 6px 8px;\n  border: 1px solid var(--border-subtle);\n  border-radius: 6px;\n  background: var(--bg-panel);\n  color: var(--text-secondary);\n}\n\n.project-path-row .mono,\n.recent-project-row .mono {\n  min-width: 0;\n  flex: 1;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.recent-project-row {\n  cursor: pointer;\n  justify-content: space-between;\n}\n\n.recent-project-row:hover {\n  border-color: var(--border-strong);\n}\n\n.project-empty {\n  color: var(--text-tertiary);\n  font-size: 13px;\n}\n\n.delete-title {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  color: var(--status-error);\n  font-weight: 600;\n  font-size: 16px;\n}\n\n.delete-desc {\n  font-size: 14px;\n  color: var(--text-secondary);\n}\n\n.delete-warning {\n  border: 1px solid rgba(220, 38, 38, 0.15);\n  background: rgba(220, 38, 38, 0.05);\n  border-radius: var(--radius-md);\n  padding: 12px 14px;\n}\n\n.delete-warning ul {\n  list-style: none;\n  padding: 0;\n  margin: 0;\n  display: grid;\n  gap: 6px;\n  font-size: 12px;\n  color: var(--text-secondary);\n}\n\n.delete-warning li::before {\n  content: '•';\n  color: var(--status-error);\n  margin-right: 8px;\n}\n\n.btn-danger-solid {\n  background: var(--status-error);\n  color: var(--accent-primary-fg);\n  box-shadow: 0 4px 6px -1px rgba(220, 38, 38, 0.2);\n}\n\n.btn-danger-solid:hover {\n  opacity: 0.9;\n  transform: translateY(-1px);\n}\n\n.processing-row {\n  display: flex;\n  gap: 12px;\n  align-items: flex-start;\n}\n\n.processing-main {\n  flex: 1;\n  min-width: 0;\n}\n\n.spinner {\n  width: 18px;\n  height: 18px;\n  border-radius: 999px;\n  border: 2px solid var(--border-subtle);\n  border-top-color: var(--accent-primary);\n  animation: spin 0.9s linear infinite;\n  margin-top: 2px;\n}\n\n.loading-modal {\n  width: min(520px, 100%);\n}\n\n.loading-content {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  padding: 32px 20px;\n  text-align: center;\n  gap: 10px;\n}\n\n.loading-cancel-btn {\n  margin-top: 8px;\n}\n\n.loader-spinner {\n  width: 24px;\n  height: 24px;\n  border: 2px solid var(--border-subtle);\n  border-top-color: var(--accent-primary);\n  border-radius: 50%;\n  animation: spin 0.8s linear infinite;\n}\n\n.loading-text {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n.loading-subtext {\n  font-size: 13px;\n  color: var(--text-tertiary);\n  font-family: var(--font-mono);\n}\n\n.loading-subtext-delayed {\n  opacity: 0;\n  animation: loadingSubtextFadeIn 0.18s ease 1.5s forwards;\n}\n\n.loading-backdrop .loading-subtext-delayed {\n  will-change: opacity, transform;\n}\n\n@keyframes loadingSubtextFadeIn {\n  from {\n    opacity: 0;\n    transform: translateY(2px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n.loading-stage {\n  font-size: 14px;\n  color: var(--text-secondary);\n  font-weight: 500;\n}\n\n.progress-bar {\n  height: 6px;\n  background: var(--bg-element);\n  border-radius: 999px;\n  width: 220px;\n  overflow: hidden;\n  margin-top: 8px;\n}\n\n.progress-fill {\n  height: 100%;\n  background: var(--accent-primary);\n  width: 60%;\n  border-radius: 999px;\n  animation: shimmer 1.5s infinite linear;\n  background: linear-gradient(\n    90deg,\n    var(--accent-primary) 0%,\n    #60a5fa 50%,\n    var(--accent-primary) 100%\n  );\n  background-size: 200% 100%;\n}\n\n@keyframes shimmer {\n  0% {\n    background-position: 100% 0;\n  }\n  100% {\n    background-position: -100% 0;\n  }\n}\n\n@keyframes spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n.tabs {\n  display: flex;\n  gap: 24px;\n  border-bottom: 1px solid var(--border-subtle);\n  margin-bottom: 12px;\n}\n\n.tab-item {\n  padding-bottom: 10px;\n  font-size: 14px;\n  color: var(--text-secondary);\n  cursor: pointer;\n  border-bottom: 2px solid transparent;\n  background: transparent;\n  border: none;\n}\n\n.tab-item:hover {\n  color: var(--text-primary);\n}\n\n.tab-item.active {\n  color: var(--accent-primary);\n  border-color: var(--accent-primary);\n  font-weight: 500;\n}\n\n.tab-item.disabled {\n  color: var(--text-tertiary);\n  cursor: not-allowed;\n}\n\n.form-group {\n  margin-bottom: 12px;\n}\n\n.label {\n  display: block;\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--text-secondary);\n  margin-bottom: 8px;\n}\n\n.input {\n  width: 100%;\n  background: var(--bg-app);\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-md);\n  padding: 10px 14px;\n  color: var(--text-primary);\n  font-family: var(--font-mono);\n  font-size: 13px;\n  transition: all 0.2s;\n}\n\n.input:focus {\n  border-color: var(--accent-primary);\n  outline: none;\n  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);\n}\n\n.input-row {\n  display: flex;\n  gap: 8px;\n  align-items: center;\n}\n\n.input-action {\n  height: 40px;\n  padding: 0 14px;\n  white-space: nowrap;\n}\n\n.helper-text {\n  font-size: 12px;\n  color: var(--text-tertiary);\n  margin-top: 6px;\n}\n\n.alert {\n  border: 1px solid var(--danger-soft-border);\n  background: var(--danger-soft-bg-strong);\n  border-radius: var(--radius-lg);\n  padding: 12px;\n  margin: 0 32px;\n}\n\n.alert-title {\n  font-weight: 700;\n  color: var(--status-error);\n}\n\n.alert-body {\n  margin-top: 8px;\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n\n.alert-item-title {\n  font-weight: 600;\n  color: var(--status-error);\n  font-size: 13px;\n}\n\n.alert-item-message {\n  white-space: pre-wrap;\n  color: var(--status-error);\n  font-size: 12px;\n  line-height: 1.5;\n}\n\n.import-summary {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.import-metrics {\n  display: flex;\n  gap: 12px;\n  font-size: 12px;\n  color: var(--text-secondary);\n}\n\n.groups {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.discovered-list {\n  gap: 14px;\n}\n\n.discovered-list .group-card {\n  background: var(--bg-panel);\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-lg);\n  padding: 18px 20px;\n  box-shadow: var(--shadow-sm);\n  transition: all 0.2s;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.discovered-list .group-card:hover {\n  border-color: var(--border-strong);\n  box-shadow: var(--shadow-lg);\n  transform: translateY(-1px);\n}\n\n.discovered-list .group-title {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  font-weight: 600;\n  color: var(--text-primary);\n  gap: 12px;\n}\n\n.discovered-list .group-select {\n  gap: 12px;\n  font-size: 15px;\n}\n\n.discovered-list .group-select input {\n  width: 18px;\n  height: 18px;\n  accent-color: var(--accent-primary);\n}\n\n.discovered-list .group-variants {\n  margin-top: 0;\n  gap: 8px;\n  padding-top: 10px;\n  border-top: 1px solid var(--border-subtle);\n}\n\n.discovered-list .variant-row {\n  grid-template-columns: 20px minmax(0, 1fr) auto;\n  align-items: center;\n  font-size: 12px;\n  color: var(--text-secondary);\n  column-gap: 12px;\n  row-gap: 6px;\n}\n\n.discovered-list .variant-spacer {\n  width: 16px;\n  height: 16px;\n}\n\n.discovered-list .variant-info {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n  min-width: 0;\n}\n\n.discovered-list .found-pill {\n  font-size: 11px;\n  color: var(--status-warning);\n  background: var(--warning-soft-bg);\n  border: 1px solid var(--warning-soft-border);\n  padding: 2px 8px;\n  border-radius: 999px;\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  width: fit-content;\n}\n\n.discovered-list .path {\n  font-family: var(--font-mono);\n  color: var(--text-tertiary);\n  font-size: 12px;\n  word-break: break-all;\n}\n\n.modal-discovered .variant-row .meta {\n  color: var(--text-tertiary);\n  font-size: 12px;\n  justify-self: end;\n}\n\n.discovered-list .badge {\n  background: var(--warning-soft-bg);\n  color: var(--status-warning);\n  border: 1px solid var(--warning-soft-border);\n  font-size: 11px;\n}\n\n.modal-discovered .modal-body {\n  gap: 20px;\n  padding: 24px 28px 20px;\n}\n\n.modal-discovered {\n  max-height: calc(100vh - 48px);\n  display: flex;\n  flex-direction: column;\n}\n\n.modal-discovered .modal-body {\n  overflow: auto;\n  flex: 1;\n  min-height: 0;\n}\n\n.modal-discovered .modal-footer {\n  margin-top: 0;\n  justify-content: flex-end;\n  padding: 20px 24px;\n  background: var(--bg-app);\n  gap: 16px;\n}\n\n.modal-discovered .modal-footer .btn {\n  height: 40px;\n  padding: 0 20px;\n}\n\n.modal-discovered .import-summary {\n  color: var(--text-secondary);\n  font-size: 14px;\n  line-height: 1.6;\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.modal-discovered .import-metrics {\n  font-size: 12px;\n  color: var(--text-tertiary);\n  display: flex;\n  gap: 16px;\n}\n\n.modal-discovered .sync-row {\n  margin: 0;\n  padding: 8px 12px;\n  border-radius: var(--radius-md);\n  background: var(--bg-panel);\n  border: 1px solid var(--border-subtle);\n  justify-content: space-between;\n}\n\n.group-card {\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-md);\n  padding: 12px;\n  background: var(--bg-element);\n}\n\n.group-title {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  font-weight: 600;\n}\n\n.group-select {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n}\n\n.badge {\n  background: var(--bg-element);\n  color: var(--text-primary);\n  border-radius: 999px;\n  padding: 2px 10px;\n  font-size: 12px;\n}\n\n.badge.danger {\n  background: var(--danger-soft-bg);\n  color: var(--status-error);\n}\n\n.group-variants {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  margin-top: 10px;\n}\n\n.variant-row {\n  display: grid;\n  grid-template-columns: 20px 120px 1fr auto;\n  gap: 12px;\n  font-size: 13px;\n  color: var(--text-secondary);\n}\n\n.sync-row {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n  margin: 8px 0;\n  color: var(--text-primary);\n}\n\n.pick-list {\n  margin-top: 12px;\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n  max-height: 360px;\n  overflow: auto;\n  padding-right: 4px;\n}\n\n.pick-search {\n  position: relative;\n  margin-top: 12px;\n}\n\n.import-search {\n  flex: none;\n  width: 100%;\n  margin-top: 4px;\n}\n\n.modal-discovered .import-search + .sync-row {\n  margin-top: 0;\n}\n\n.pick-item {\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-md);\n  padding: 14px;\n  display: flex;\n  gap: 12px;\n  background: var(--bg-panel);\n}\n\n.pick-item.disabled {\n  opacity: 0.6;\n}\n\n.pick-item.disabled .pick-item-title {\n  color: var(--text-secondary);\n}\n\n.pick-toolbar {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-top: 8px;\n  color: var(--text-secondary);\n  font-size: 13px;\n}\n\n.pick-item-checkbox {\n  display: inline-flex;\n  align-items: flex-start;\n  padding-top: 2px;\n}\n\n.pick-item-checkbox input {\n  width: 18px;\n  height: 18px;\n  accent-color: var(--accent-primary);\n}\n\n.pick-item-main {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  min-width: 0;\n}\n\n.pick-item-title {\n  font-weight: 700;\n  color: var(--text-primary);\n  font-size: 14px;\n}\n\n.pick-item-desc {\n  color: var(--text-secondary);\n  font-size: 12px;\n  line-height: 1.4;\n}\n\n.pick-item-path {\n  font-family: var(--font-mono);\n  color: var(--text-secondary);\n  font-size: 12px;\n  word-break: break-all;\n}\n\n.pick-item-reason {\n  color: var(--danger);\n  font-size: 12px;\n}\n\n.tags-page {\n  flex: 1;\n  min-height: 0;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n}\n\n.tags-page-subtitle {\n  margin-top: 4px;\n  color: var(--text-secondary);\n  font-size: 13px;\n}\n\n.tags-review-row,\n.tags-toolbar {\n  margin: 16px 32px 0;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n}\n\n.tags-review-row {\n  padding: 12px 16px;\n  border: 1px dashed var(--border-subtle);\n  border-radius: var(--radius-lg);\n  background: var(--bg-panel);\n}\n\n.tags-review-left {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  color: var(--text-secondary);\n  font-weight: 600;\n  font-size: 13px;\n}\n\n.tags-toolbar {\n  align-items: stretch;\n}\n\n.tags-search {\n  flex: 1;\n}\n\n.tags-new-row {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.tags-new-row .search-input {\n  width: 220px;\n}\n\n.tags-table {\n  margin: 16px 32px 32px;\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-lg);\n  overflow: auto;\n  background: var(--bg-app);\n}\n\n.tags-table-row {\n  min-height: 48px;\n  display: grid;\n  grid-template-columns: minmax(180px, 1fr) 100px 140px 220px;\n  align-items: center;\n  gap: 12px;\n  padding: 0 16px;\n  border-bottom: 1px solid var(--border-subtle);\n  color: var(--text-secondary);\n  font-size: 13px;\n}\n\n.tags-table-row:last-child {\n  border-bottom: none;\n}\n\n.tags-table-head {\n  min-height: 40px;\n  background: var(--bg-panel);\n  color: var(--text-tertiary);\n  font-size: 12px;\n  font-weight: 700;\n}\n\n.tags-table-name {\n  color: var(--text-primary);\n  font-weight: 700;\n}\n\n.tags-table-actions {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.tags-table-actions button {\n  border: none;\n  background: transparent;\n  color: var(--text-secondary);\n  cursor: pointer;\n  font-weight: 600;\n  font-size: 13px;\n}\n\n.tags-table-actions button:hover {\n  color: var(--accent-primary);\n}\n\n.edit-tags-modal {\n  width: min(420px, 100%);\n}\n\n.modal-subtitle {\n  margin-top: 4px;\n  color: var(--text-secondary);\n  font-size: 12px;\n}\n\n.edit-tags-search {\n  margin-top: 16px;\n}\n\n.edit-tags-list {\n  max-height: 280px;\n  overflow-y: auto;\n  padding: 0 12px;\n}\n\n.edit-tags-modal .modal-actions {\n  padding: 0 20px 18px;\n}\n\n.inline-checkbox {\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  color: var(--text-secondary);\n  font-size: 13px;\n}\n\n.settings-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n}\n\n.settings-label {\n  font-size: 13px;\n  color: var(--text-secondary);\n  font-weight: 500;\n}\n\n.mono {\n  font-family: var(--font-mono);\n  font-size: 12px;\n  word-break: break-all;\n}\n\n.settings-page {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n  overflow-y: auto;\n  align-items: center;\n}\n\n.settings-page > .detail-header {\n  width: 100%;\n  max-width: 560px;\n  margin: 0 auto;\n  border-bottom: none;\n  padding: 24px 0 0;\n}\n\n.settings-page-body {\n  padding: 16px 0 32px;\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n  width: 100%;\n  max-width: 560px;\n  color: var(--text-primary);\n}\n\n.settings-field {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.settings-select-wrap {\n  position: relative;\n}\n\n.settings-select {\n  width: 100%;\n  height: 40px;\n  padding: 0 36px 0 14px;\n  border-radius: var(--radius-md);\n  border: 1px solid var(--border-subtle);\n  background: var(--bg-app);\n  color: var(--text-primary);\n  font-size: 13px;\n  font-family: var(--font-ui);\n  appearance: none;\n  cursor: pointer;\n  transition: all 0.2s;\n}\n\n.settings-select:focus {\n  outline: none;\n  border-color: var(--accent-primary);\n  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);\n}\n\n.settings-select-caret {\n  position: absolute;\n  right: 12px;\n  top: 50%;\n  transform: translateY(-50%);\n  width: 14px;\n  height: 14px;\n  color: var(--text-tertiary);\n  pointer-events: none;\n}\n\n.settings-theme-options {\n  display: grid;\n  grid-template-columns: repeat(3, minmax(0, 1fr));\n  gap: 8px;\n}\n\n.settings-theme-btn {\n  height: 40px;\n  border-radius: var(--radius-md);\n  border: 1px solid var(--border-subtle);\n  background: var(--bg-app);\n  color: var(--text-secondary);\n  font-size: 13px;\n  font-weight: 600;\n  cursor: pointer;\n  transition: all 0.2s;\n}\n\n.settings-theme-btn:hover {\n  border-color: var(--border-strong);\n  color: var(--text-primary);\n  background: var(--bg-element);\n}\n\n.settings-theme-btn.active {\n  background: var(--accent-primary);\n  color: var(--accent-primary-fg);\n  border-color: var(--accent-primary);\n  box-shadow: var(--shadow-sm);\n}\n\n.settings-theme-btn:focus-visible {\n  outline: 2px solid var(--accent-primary);\n  outline-offset: 2px;\n}\n\n.settings-input-row {\n  display: flex;\n  gap: 8px;\n  align-items: center;\n}\n\n.settings-input {\n  flex: 1;\n  height: 40px;\n  padding: 0 14px;\n  border-radius: var(--radius-md);\n  border: 1px solid var(--border-subtle);\n  background: var(--bg-app);\n  color: var(--text-primary);\n  font-size: 13px;\n}\n\n.settings-input:focus {\n  outline: none;\n  border-color: var(--accent-primary);\n  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);\n}\n\n.settings-browse {\n  height: 40px;\n  padding: 0 14px;\n}\n\n.settings-helper {\n  font-size: 12px;\n  color: var(--text-tertiary);\n}\n\n.settings-section-title {\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--text-primary);\n  margin-top: 4px;\n}\n\n.settings-list {\n  display: flex;\n  flex-direction: column;\n}\n\n.settings-item {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 16px;\n  padding: 16px 0;\n  border-bottom: 1px solid var(--border-subtle);\n}\n\n.settings-item:last-child {\n  border-bottom: none;\n}\n\n.settings-item-info {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  min-width: 0;\n}\n\n.settings-item-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n.settings-item-desc {\n  font-size: 12px;\n  color: var(--text-tertiary);\n  line-height: 1.4;\n}\n\n.settings-toggle {\n  width: 44px;\n  height: 24px;\n  background: var(--bg-element);\n  border-radius: 999px;\n  border: 1px solid var(--border-subtle);\n  position: relative;\n  cursor: pointer;\n  transition: all 0.2s;\n  padding: 0;\n}\n\n.settings-toggle:disabled {\n  cursor: not-allowed;\n  opacity: 0.7;\n}\n\n.settings-toggle.checked {\n  background: var(--status-success);\n  border-color: var(--status-success);\n}\n\n.settings-toggle-knob {\n  width: 18px;\n  height: 18px;\n  background: var(--bg-panel);\n  border-radius: 50%;\n  position: absolute;\n  top: 2px;\n  left: 2px;\n  transition: transform 0.2s ease;\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);\n}\n\n.settings-toggle.checked .settings-toggle-knob {\n  transform: translateX(20px);\n}\n\n.settings-version {\n  margin-top: 4px;\n  padding-top: 16px;\n  border-top: 1px solid var(--border-subtle);\n  text-align: center;\n  font-size: 12px;\n  color: var(--text-tertiary);\n}\n\n.settings-update-section {\n  border-top: 1px solid var(--border-subtle);\n  padding-top: 16px;\n  margin-top: 4px;\n}\n\n.settings-version-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n}\n\n.settings-version-text {\n  font-size: 13px;\n  color: var(--text-secondary);\n}\n\n.settings-update-status {\n  font-size: 13px;\n  color: var(--text-tertiary);\n}\n\n.settings-update-ok {\n  color: var(--success, #22c55e);\n  font-size: 13px;\n}\n\n.settings-update-available {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n  margin-top: 8px;\n  padding: 8px 10px;\n  border-radius: 6px;\n  background: var(--bg-hover);\n  font-size: 13px;\n}\n\n.settings-update-error {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n  margin-top: 8px;\n  font-size: 13px;\n  color: var(--danger, #ef4444);\n}\n\n.btn-sm {\n  padding: 4px 10px;\n  font-size: 12px;\n  white-space: nowrap;\n}\n\n.btn-full {\n  width: 100%;\n}\n\n/* Update modal */\n.update-modal {\n  width: 380px;\n  padding: 24px;\n  text-align: center;\n  position: relative;\n}\n\n.update-modal-close {\n  position: absolute;\n  top: 10px;\n  right: 10px;\n}\n\n.update-modal-body {\n  margin-bottom: 20px;\n}\n\n.update-modal-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--text-primary);\n  margin-bottom: 8px;\n}\n\n.update-modal-text {\n  font-size: 14px;\n  color: var(--text-secondary);\n  line-height: 1.5;\n}\n\n.update-modal-notes {\n  margin-top: 12px;\n  padding: 10px 12px;\n  border-radius: 6px;\n  background: var(--bg-hover);\n  font-size: 13px;\n  color: var(--text-secondary);\n  text-align: left;\n  line-height: 1.6;\n  max-height: 200px;\n  overflow-y: auto;\n  word-break: break-word;\n}\n\n.update-modal-notes h3 {\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--text-primary);\n  margin: 8px 0 4px;\n}\n\n.update-modal-notes h3:first-child {\n  margin-top: 0;\n}\n\n.update-modal-notes ul {\n  margin: 4px 0;\n  padding-left: 18px;\n}\n\n.update-modal-notes li {\n  margin: 2px 0;\n}\n\n.update-modal-notes a {\n  color: var(--accent);\n  text-decoration: none;\n}\n\n.update-modal-notes p {\n  margin: 4px 0;\n}\n\n.update-modal-actions {\n  display: flex;\n  gap: 8px;\n  justify-content: center;\n}\n\n/* Explore tab */\n.explore-content {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.explore-filter {\n  margin-top: 4px;\n}\n\n.explore-list {\n  max-height: 400px;\n  overflow-y: auto;\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n.explore-skill-item {\n  padding: 10px 12px;\n  border-radius: 8px;\n  cursor: pointer;\n  transition: background 0.15s;\n}\n\n.explore-skill-item:hover {\n  background: var(--bg-hover);\n}\n\n.explore-skill-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  gap: 8px;\n}\n\n.explore-skill-name {\n  font-weight: 600;\n  font-size: 14px;\n  color: var(--text-primary);\n}\n\n.explore-skill-stats {\n  font-size: 12px;\n  color: var(--text-tertiary);\n  white-space: nowrap;\n  flex-shrink: 0;\n}\n\n.explore-skill-summary {\n  margin-top: 2px;\n  font-size: 13px;\n  color: var(--text-secondary);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.explore-source {\n  font-size: 12px;\n  color: var(--text-tertiary);\n  text-align: right;\n}\n\n\n.explore-section-title {\n  font-size: 12px;\n  font-weight: 600;\n  color: var(--text-tertiary);\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n  padding: 8px 0 4px;\n  border-top: 1px solid var(--border-subtle);\n  margin-top: 4px;\n}\n\n.explore-skill-source {\n  margin-top: 2px;\n  font-size: 12px;\n  color: var(--text-tertiary);\n}\n\n.explore-loading,\n.explore-empty {\n  padding: 32px 0;\n  text-align: center;\n  color: var(--text-tertiary);\n  font-size: 14px;\n}\n\n/* ===== Explore Page (full page) ===== */\n.explore-page {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n}\n\n.explore-hero {\n  padding: 24px 32px 16px 32px;\n  flex-shrink: 0;\n}\n\n.explore-search-row {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.explore-search-wrap {\n  flex: 1;\n  position: relative;\n}\n\n.explore-search-icon {\n  position: absolute;\n  left: 14px;\n  top: 50%;\n  transform: translateY(-50%);\n  color: var(--text-tertiary);\n  pointer-events: none;\n}\n\n.explore-search-input {\n  width: 100%;\n  height: 40px;\n  background: var(--bg-panel);\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-lg, 12px);\n  padding: 0 16px 0 40px;\n  font-size: 14px;\n  color: var(--text-primary);\n  transition: all 0.2s;\n  font-family: inherit;\n}\n\n.explore-search-input:focus {\n  outline: none;\n  border-color: var(--accent-primary);\n  box-shadow: 0 0 0 3px var(--accent-soft-bg);\n}\n\n.explore-search-input::placeholder {\n  color: var(--text-tertiary);\n}\n\n.explore-manual-btn {\n  height: 40px;\n  padding: 0 16px;\n  flex-shrink: 0;\n}\n\n.explore-source-label {\n  font-size: 11px;\n  color: var(--text-tertiary);\n  margin-top: 8px;\n  padding-left: 4px;\n}\n\n.explore-scroll {\n  flex: 1;\n  overflow-y: auto;\n  padding: 0 32px 32px 32px;\n}\n\n.explore-grid {\n  display: grid;\n  grid-template-columns: repeat(2, 1fr);\n  gap: 10px;\n  margin-bottom: 24px;\n}\n\n.explore-card {\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-lg, 12px);\n  padding: 16px;\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n  transition: all 0.2s;\n  cursor: pointer;\n  background: var(--bg-app);\n}\n\n.explore-card:hover {\n  border-color: var(--border-strong);\n  box-shadow: var(--shadow-sm);\n}\n\n.explore-card-top {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 12px;\n}\n\n.explore-card-info {\n  flex: 1;\n  min-width: 0;\n}\n\n.explore-card-name {\n  font-weight: 600;\n  font-size: 14px;\n  color: var(--text-primary);\n  margin-bottom: 2px;\n}\n\n.explore-card-author {\n  font-size: 12px;\n  color: var(--text-tertiary);\n}\n\n.explore-card-desc {\n  font-size: 13px;\n  color: var(--text-secondary);\n  line-height: 1.5;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n}\n\n.explore-card-bottom {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n}\n\n.explore-card-stats {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.explore-stat {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  font-size: 11px;\n  color: var(--text-tertiary);\n}\n\n.explore-btn-install {\n  background: var(--accent-primary);\n  color: white;\n  height: 30px;\n  padding: 0 14px;\n  font-size: 12px;\n  font-weight: 600;\n  border-radius: var(--radius-md, 8px);\n  border: none;\n  cursor: pointer;\n  transition: all 0.2s;\n  font-family: inherit;\n  flex-shrink: 0;\n}\n\n.explore-btn-install:hover {\n  filter: brightness(0.9);\n}\n\n.explore-btn-install:disabled {\n  opacity: 0.6;\n  cursor: not-allowed;\n}\n\n.explore-btn-installed {\n  background: var(--status-success-bg, #ecfdf5);\n  color: var(--status-success);\n  height: 30px;\n  padding: 0 14px;\n  font-size: 12px;\n  font-weight: 600;\n  border-radius: var(--radius-md, 8px);\n  border: 1px solid var(--status-success-border, #a7f3d0);\n  cursor: default;\n  font-family: inherit;\n  display: flex;\n  align-items: center;\n  flex-shrink: 0;\n}\n\n/* ===== Skill Detail View ===== */\n.detail-view {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n  min-height: 0;\n}\n\n.detail-header {\n  padding: 16px 32px;\n  border-bottom: 1px solid var(--border-subtle);\n  flex-shrink: 0;\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.detail-back-btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--text-secondary);\n  cursor: pointer;\n  background: none;\n  border: none;\n  padding: 4px 8px;\n  border-radius: var(--radius-sm);\n  transition: all 0.2s;\n  font-family: inherit;\n  align-self: flex-start;\n}\n\n.detail-back-btn:hover {\n  color: var(--accent-primary);\n  background: var(--accent-soft-bg);\n}\n\n.detail-skill-name {\n  font-size: 20px;\n  font-weight: 700;\n  color: var(--text-primary);\n  letter-spacing: -0.02em;\n}\n\n.detail-desc {\n  font-size: 13px;\n  color: var(--text-secondary);\n  line-height: 1.5;\n}\n\n.detail-meta {\n  display: flex;\n  align-items: center;\n  gap: 14px;\n  font-size: 12px;\n  color: var(--text-tertiary);\n  margin-top: 2px;\n}\n\n.detail-meta-item {\n  display: flex;\n  align-items: center;\n  gap: 5px;\n}\n\n.detail-meta-dot {\n  color: var(--border-strong);\n}\n\n.detail-body {\n  flex: 1;\n  display: flex;\n  overflow: hidden;\n  min-height: 0;\n}\n\n/* ── File tree sidebar ── */\n.detail-file-list {\n  width: 260px;\n  border-right: 1px solid var(--border-subtle);\n  overflow-y: auto;\n  flex-shrink: 0;\n  background: var(--bg-panel);\n  padding: 4px 0;\n}\n\n.file-list-title {\n  font-size: 11px;\n  font-weight: 600;\n  text-transform: uppercase;\n  letter-spacing: 0.06em;\n  color: var(--text-tertiary);\n  padding: 10px 16px 6px;\n}\n\n.file-tree {\n  display: flex;\n  flex-direction: column;\n}\n\n.tree-item {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 4px 12px;\n  font-size: 13px;\n  color: var(--text-secondary);\n  cursor: pointer;\n  border: none;\n  background: none;\n  width: 100%;\n  text-align: left;\n  font-family: inherit;\n  transition: background 0.12s;\n  min-height: 28px;\n}\n\n.tree-item:hover {\n  background: var(--bg-element);\n}\n\n.tree-item.tree-file.active {\n  background: var(--accent-soft-bg);\n  color: var(--accent-primary);\n  font-weight: 500;\n}\n\n.tree-chevron {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 14px;\n  flex-shrink: 0;\n  color: var(--text-tertiary);\n}\n\n.tree-icon {\n  flex-shrink: 0;\n}\n\n.tree-icon-folder {\n  color: var(--accent-primary);\n  opacity: 0.7;\n}\n\n.tree-icon-file {\n  color: var(--text-tertiary);\n}\n\n.tree-item.active .tree-icon-file {\n  color: var(--accent-primary);\n}\n\n.tree-name {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  flex: 1;\n  min-width: 0;\n}\n\n.tree-size {\n  font-size: 11px;\n  color: var(--text-tertiary);\n  flex-shrink: 0;\n  font-family: var(--font-mono);\n  margin-left: auto;\n  padding-left: 8px;\n}\n\n/* ── File content area ── */\n.detail-file-content {\n  flex: 1;\n  overflow: auto;\n  min-width: 0;\n  background: var(--bg-app);\n}\n\n.file-content-header {\n  padding: 10px 20px;\n  border-bottom: 1px solid var(--border-subtle);\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  position: sticky;\n  top: 0;\n  background: var(--bg-panel);\n  z-index: 5;\n}\n\n.file-content-path {\n  font-family: var(--font-mono);\n  font-size: 12px;\n  color: var(--text-secondary);\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.file-content-size {\n  font-size: 11px;\n  color: var(--text-tertiary);\n  font-family: var(--font-mono);\n}\n\n.file-content-body {\n  padding: 0;\n}\n\n/* Syntax highlighter overrides */\n.file-content-body pre {\n  margin: 0 !important;\n  border-radius: 0 !important;\n}\n\n.file-content-body code {\n  font-family: var(--font-mono) !important;\n}\n\n/* ── Markdown body ── */\n.markdown-body {\n  padding: 24px 32px;\n  font-size: 14px;\n  line-height: 1.7;\n  color: var(--text-primary);\n  width: 100%;\n  max-width: 860px;\n  margin: 0 auto;\n  box-sizing: border-box;\n}\n\n.markdown-body h1,\n.markdown-body h2,\n.markdown-body h3,\n.markdown-body h4,\n.markdown-body h5,\n.markdown-body h6 {\n  color: var(--text-primary);\n  margin-top: 24px;\n  margin-bottom: 12px;\n  font-weight: 600;\n  line-height: 1.3;\n}\n\n.markdown-body h1 { font-size: 24px; padding-bottom: 8px; border-bottom: 1px solid var(--border-subtle); }\n.markdown-body h2 { font-size: 20px; padding-bottom: 6px; border-bottom: 1px solid var(--border-subtle); }\n.markdown-body h3 { font-size: 16px; }\n.markdown-body h4 { font-size: 14px; }\n\n.markdown-body p {\n  margin: 0 0 12px;\n}\n\n.markdown-body ul,\n.markdown-body ol {\n  padding-left: 24px;\n  margin: 0 0 12px;\n}\n\n.markdown-body li {\n  margin-bottom: 4px;\n}\n\n.markdown-body li > ul,\n.markdown-body li > ol {\n  margin-top: 4px;\n  margin-bottom: 0;\n}\n\n.markdown-body blockquote {\n  margin: 0 0 12px;\n  padding: 4px 16px;\n  border-left: 3px solid var(--accent-primary);\n  color: var(--text-secondary);\n  background: var(--bg-element);\n  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;\n}\n\n.markdown-body pre {\n  margin: 0 0 12px !important;\n  border-radius: var(--radius-md) !important;\n  border: 1px solid var(--border-subtle);\n  overflow-x: auto;\n}\n\n.markdown-body .md-inline-code {\n  font-family: var(--font-mono);\n  font-size: 0.9em;\n  padding: 2px 6px;\n  background: var(--bg-element);\n  border-radius: var(--radius-sm);\n  color: var(--text-primary);\n  border: 1px solid var(--border-subtle);\n}\n\n.markdown-body a {\n  color: var(--accent-primary);\n  text-decoration: none;\n}\n\n.markdown-body a:hover {\n  text-decoration: underline;\n}\n\n.markdown-body hr {\n  border: none;\n  border-top: 1px solid var(--border-subtle);\n  margin: 20px 0;\n}\n\n.frontmatter-meta {\n  display: grid;\n  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));\n  gap: 10px;\n  margin: 0 0 20px;\n  padding: 12px;\n  border: 1px solid var(--border-subtle);\n  border-radius: var(--radius-md);\n  background: var(--bg-panel);\n}\n\n.frontmatter-meta-item {\n  min-width: 0;\n}\n\n.frontmatter-meta-item[data-key=\"description\"] {\n  grid-column: 1 / -1;\n}\n\n.frontmatter-meta dt {\n  margin: 0 0 4px;\n  font-size: 11px;\n  font-weight: 600;\n  line-height: 1.3;\n  color: var(--text-tertiary);\n  text-transform: uppercase;\n  overflow-wrap: normal;\n}\n\n.frontmatter-meta dd {\n  margin: 0;\n  font-size: 13px;\n  line-height: 1.55;\n  color: var(--text-primary);\n  overflow-wrap: anywhere;\n  white-space: pre-wrap;\n}\n\n.markdown-body table {\n  width: 100%;\n  border-collapse: collapse;\n  margin: 0 0 12px;\n  font-size: 13px;\n}\n\n.markdown-body th,\n.markdown-body td {\n  padding: 8px 12px;\n  border: 1px solid var(--border-subtle);\n  text-align: left;\n  vertical-align: top;\n  overflow-wrap: anywhere;\n}\n\n.markdown-body td {\n  white-space: pre-wrap;\n}\n\n.markdown-body th {\n  background: var(--bg-element);\n  font-weight: 600;\n  color: var(--text-primary);\n}\n\n.markdown-body tr:nth-child(even) {\n  background: var(--bg-panel);\n}\n\n.markdown-body img {\n  max-width: 100%;\n  border-radius: var(--radius-md);\n}\n\n/* ── Loading / spinner ── */\n.detail-loading {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 32px 16px;\n  color: var(--text-tertiary);\n  font-size: 13px;\n  gap: 8px;\n}\n\n.detail-spinner {\n  width: 16px;\n  height: 16px;\n  border: 2px solid var(--border-subtle);\n  border-top-color: var(--accent-primary);\n  border-radius: 50%;\n  animation: spin 0.8s linear infinite;\n}\n\n/* ── Clickable skill name in card ── */\n.skill-name.clickable {\n  cursor: pointer;\n  transition: color 0.15s;\n  background: none;\n  border: none;\n  padding: 0;\n  font: inherit;\n  font-weight: 600;\n  font-size: 15px;\n  letter-spacing: -0.01em;\n  color: var(--text-primary);\n  text-align: left;\n}\n\n.skill-name.clickable:hover {\n  color: var(--accent-primary);\n}\n"
  },
  {
    "path": "src/App.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'\nimport type { Update } from '@tauri-apps/plugin-updater'\nimport './App.css'\nimport { useTranslation } from 'react-i18next'\nimport { Toaster, toast } from 'sonner'\nimport Markdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport ExplorePage from './components/skills/ExplorePage'\nimport FilterBar from './components/skills/FilterBar'\nimport SkillDetailView from './components/skills/SkillDetailView'\nimport Header from './components/skills/Header'\nimport LoadingOverlay from './components/skills/LoadingOverlay'\nimport SkillsList from './components/skills/SkillsList'\nimport TagsPage from './components/skills/TagsPage'\nimport AddSkillModal from './components/skills/modals/AddSkillModal'\nimport DeleteModal from './components/skills/modals/DeleteModal'\nimport EditSkillTagsModal from './components/skills/modals/EditSkillTagsModal'\nimport GitPickModal from './components/skills/modals/GitPickModal'\nimport LocalPickModal from './components/skills/modals/LocalPickModal'\nimport ImportModal from './components/skills/modals/ImportModal'\nimport NewToolsModal from './components/skills/modals/NewToolsModal'\nimport ScopeSyncModal from './components/skills/modals/ScopeSyncModal'\nimport SharedDirModal from './components/skills/modals/SharedDirModal'\nimport SettingsPage from './components/skills/SettingsPage'\nimport type {\n  FeaturedSkillDto,\n  GitSkillCandidate,\n  InstallResultDto,\n  LocalSkillCandidate,\n  ManagedSkill,\n  OnboardingPlan,\n  OnlineSkillDto,\n  TagWithCountDto,\n  ToolOption,\n  ToolStatusDto,\n  UpdateResultDto,\n} from './components/skills/types'\n\ntype SkillScopeState = Record<\n  string,\n  {\n    scope: 'global' | 'project'\n    projects: string[]\n  }\n>\n\nfunction App() {\n  const { t, i18n } = useTranslation()\n  const language = i18n.resolvedLanguage ?? i18n.language ?? 'en'\n  const languageStorageKey = 'skills-language'\n  const themeStorageKey = 'skills-theme'\n  const skillScopeStorageKey = 'skills-project-scope-state-v1'\n  const toggleLanguage = useCallback(() => {\n    void i18n.changeLanguage(language === 'en' ? 'zh' : 'en')\n  }, [i18n, language])\n  const [themePreference, setThemePreference] = useState<'system' | 'light' | 'dark'>(\n    'system',\n  )\n  const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>('light')\n  const [plan, setPlan] = useState<OnboardingPlan | null>(null)\n  const [loading, setLoading] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n  const [selected, setSelected] = useState<Record<string, boolean>>({})\n  const [variantChoice, setVariantChoice] = useState<Record<string, string>>({})\n  const [syncTargets, setSyncTargets] = useState<Record<string, boolean>>({})\n  const [actionMessage, setActionMessage] = useState<string | null>(null)\n  const [successToastMessage, setSuccessToastMessage] = useState<string | null>(\n    null,\n  )\n  const [managedSkills, setManagedSkills] = useState<ManagedSkill[]>([])\n  const [localPath, setLocalPath] = useState('')\n  const [localName, setLocalName] = useState('')\n  const [gitUrl, setGitUrl] = useState('')\n  const [gitName, setGitName] = useState('')\n  const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)\n  const [gitCandidates, setGitCandidates] = useState<GitSkillCandidate[]>([])\n  const [gitCandidatesRepoUrl, setGitCandidatesRepoUrl] = useState<string>('')\n  const [showGitPickModal, setShowGitPickModal] = useState(false)\n  const [gitCandidateSelected, setGitCandidateSelected] = useState<\n    Record<string, boolean>\n  >({})\n  const [localCandidates, setLocalCandidates] = useState<LocalSkillCandidate[]>([])\n  const [localCandidatesBasePath, setLocalCandidatesBasePath] = useState('')\n  const [showLocalPickModal, setShowLocalPickModal] = useState(false)\n  const [localCandidateSelected, setLocalCandidateSelected] = useState<\n    Record<string, boolean>\n  >({})\n  const [loadingStartAt, setLoadingStartAt] = useState<number | null>(null)\n  const [toolStatus, setToolStatus] = useState<ToolStatusDto | null>(null)\n  const [showNewToolsModal, setShowNewToolsModal] = useState(false)\n  const [showAddModal, setShowAddModal] = useState(false)\n  const [showImportModal, setShowImportModal] = useState(false)\n  const [pendingSharedToggle, setPendingSharedToggle] = useState<{\n    skill: ManagedSkill\n    toolId: string\n    affectedToolIds?: string[]\n  } | null>(null)\n  const [updateAvailableVersion, setUpdateAvailableVersion] = useState<string | null>(null)\n  const [updateBody, setUpdateBody] = useState<string | null>(null)\n  const [updateInstalling, setUpdateInstalling] = useState(false)\n  const [updateDone, setUpdateDone] = useState(false)\n  const updateObjRef = useRef<Update | null>(null) as MutableRefObject<Update | null>\n  const [searchQuery, setSearchQuery] = useState('')\n  const [sortBy, setSortBy] = useState<'updated' | 'name'>('updated')\n  const [scopeFilter, setScopeFilter] = useState<'all' | 'global' | 'project'>('all')\n  const [activeView, setActiveView] = useState<'myskills' | 'explore' | 'detail' | 'settings' | 'tags'>('myskills')\n  const [detailSkill, setDetailSkill] = useState<ManagedSkill | null>(null)\n  const [tags, setTags] = useState<TagWithCountDto[]>([])\n  const [selectedTagIds, setSelectedTagIds] = useState<number[]>([])\n  const [includeUntagged, setIncludeUntagged] = useState(false)\n  const [tagEditorSkill, setTagEditorSkill] = useState<ManagedSkill | null>(null)\n  const [pendingDeleteTag, setPendingDeleteTag] = useState<TagWithCountDto | null>(null)\n  const [addModalTab, setAddModalTab] = useState<'local' | 'git'>('git')\n  const [addModalTagIds, setAddModalTagIds] = useState<number[]>([])\n  const [featuredSkills, setFeaturedSkills] = useState<FeaturedSkillDto[]>([])\n  const [featuredLoading, setFeaturedLoading] = useState(false)\n  const [exploreFilter, setExploreFilter] = useState('')\n  const [searchResults, setSearchResults] = useState<OnlineSkillDto[]>([])\n  const [searchLoading, setSearchLoading] = useState(false)\n  const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const [autoSelectSkillName, setAutoSelectSkillName] = useState<string | null>(null)\n  const [scopeModalSkill, setScopeModalSkill] = useState<ManagedSkill | null>(null)\n  const [recentProjects, setRecentProjects] = useState<string[]>([])\n  const [skillScopeState, setSkillScopeState] = useState<SkillScopeState>({})\n\n  const isTauri =\n    typeof window !== 'undefined' &&\n    Boolean(\n      (window as { __TAURI__?: unknown }).__TAURI__ ||\n        (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__,\n    )\n\n  const invokeTauri = useCallback(\n    async <T,>(command: string, args?: Record<string, unknown>) => {\n      if (!isTauri) {\n        throw new Error('Tauri API is not available')\n      }\n      const { invoke } = await import('@tauri-apps/api/core')\n      return invoke<T>(command, args)\n    },\n    [isTauri],\n  )\n  const formatErrorMessage = useCallback(\n    (raw: string) => {\n      if (raw.includes('CANCELLED|')) {\n        return '' // Silently ignore cancelled operations\n      }\n      if (raw.includes('skill already exists in central repo')) {\n        // Extract skill name from path like: skill already exists in central repo: \"/path/to/react-best-practices\"\n        const pathMatch = raw.match(/central repo:\\s*\"?([^\"]+)\"?/)\n        if (pathMatch) {\n          const skillName = pathMatch[1].split('/').pop() ?? ''\n          if (skillName) {\n            return t('errors.skillExistsInHubNamed', { name: skillName })\n          }\n        }\n        return t('errors.skillExistsInHub')\n      }\n      if (raw.startsWith('TARGET_EXISTS|')) {\n        return t('errors.targetExists')\n      }\n      if (raw.startsWith('TOOL_NOT_INSTALLED|')) {\n        return t('errors.toolNotInstalled')\n      }\n      if (raw.startsWith('TOOL_NOT_WRITABLE|')) {\n        const parts = raw.split('|')\n        return t('errors.toolNotWritable', { tool: parts[1] ?? '', path: parts[2] ?? '' })\n      }\n      if (raw.startsWith('PROJECT_SCOPE_UNSUPPORTED|')) {\n        const tool = raw.split('|')[1] ?? ''\n        return t('projectSync.unsupportedTool', { tool })\n      }\n      if (raw.includes('未在该仓库中发现可导入的 Skills')) {\n        return t('errors.noSkillsFoundInRepo')\n      }\n      return raw\n    },\n    [t],\n  )\n  const showActionErrors = useCallback(\n    (errors: { title: string; message: string }[]) => {\n      if (errors.length === 0) return\n      const head = errors[0]\n      const more =\n        errors.length > 1\n          ? t('errors.moreCount', { count: errors.length - 1 })\n          : ''\n      toast.error(\n        `${formatErrorMessage(`${head.title}\\n${head.message}`)}${more}`,\n        { duration: 3200 },\n      )\n    },\n    [formatErrorMessage, t],\n  )\n  const isSkillNameTaken = useCallback(\n    (name: string) =>\n      managedSkills.some((skill) => skill.name.toLowerCase() === name.toLowerCase()),\n    [managedSkills],\n  )\n\n  const formatRelative = (ms: number | null | undefined) => {\n    if (!ms) return t('relative.empty')\n    const diff = Date.now() - ms\n    if (diff < 0) return t('relative.empty')\n    const minutes = Math.floor(diff / 60000)\n    if (minutes < 1) return t('relative.justNow')\n    if (minutes < 60) {\n      return t('relative.minutesAgo', { minutes })\n    }\n    const hours = Math.floor(minutes / 60)\n    if (hours < 24) {\n      return t('relative.hoursAgo', { hours })\n    }\n    const days = Math.floor(hours / 24)\n    return t('relative.daysAgo', { days })\n  }\n\n  const getSkillSourceLabel = (skill: ManagedSkill) => {\n    const key = skill.source_type.toLowerCase()\n    if (key.includes('git') && skill.source_ref) {\n      return skill.source_ref\n    }\n    return skill.central_path\n  }\n\n  const getGithubInfo = (url: string | null | undefined) => {\n    if (!url) return null\n    const normalized = url.replace(/^git\\+/, '')\n    try {\n      const parsed = new URL(normalized)\n      if (!parsed.hostname.includes('github.com')) return null\n      const parts = parsed.pathname.split('/').filter(Boolean)\n      const owner = parts[0]\n      const repo = parts[1]?.replace(/\\.git$/, '')\n      if (!owner || !repo) return null\n      return {\n        label: `${owner}/${repo}`,\n        href: `https://github.com/${owner}/${repo}`,\n      }\n    } catch {\n      const match = normalized.match(/github\\.com\\/([^/]+)\\/([^/#?]+)/i)\n      if (!match) return null\n      const owner = match[1]\n      const repo = match[2].replace(/\\.git$/, '')\n      return {\n        label: `${owner}/${repo}`,\n        href: `https://github.com/${owner}/${repo}`,\n      }\n    }\n  }\n\n  const loadPlan = useCallback(async () => {\n    setLoading(true)\n    setLoadingStartAt(Date.now())\n    setError(null)\n    try {\n      const result = await invokeTauri<OnboardingPlan>('get_onboarding_plan')\n      setPlan(result)\n      const defaultSelected: Record<string, boolean> = {}\n      const defaultChoice: Record<string, string> = {}\n      result.groups.forEach((group) => {\n        defaultSelected[group.name] = true\n        const first = group.variants[0]\n        if (first) {\n          defaultChoice[group.name] = first.path\n        }\n      })\n      setSelected(defaultSelected)\n      setVariantChoice(defaultChoice)\n      return result\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err))\n      return null\n    } finally {\n      setLoading(false)\n      setLoadingStartAt(null)\n    }\n  }, [invokeTauri])\n\n  const loadManagedSkills = useCallback(async () => {\n    try {\n      const result = await invokeTauri<ManagedSkill[]>('get_managed_skills')\n      setManagedSkills(result)\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err))\n    }\n  }, [invokeTauri])\n\n  const loadTags = useCallback(async () => {\n    try {\n      const result = await invokeTauri<TagWithCountDto[]>('get_tags')\n      setTags(result)\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err))\n    }\n  }, [invokeTauri])\n\n  useEffect(() => {\n    if (isTauri) {\n      loadManagedSkills()\n      loadTags()\n    }\n  }, [isTauri, loadManagedSkills, loadTags])\n\n  useEffect(() => {\n    if (typeof window === 'undefined') return\n    try {\n      const raw = window.localStorage.getItem(skillScopeStorageKey)\n      if (raw) {\n        setSkillScopeState(JSON.parse(raw) as SkillScopeState)\n      }\n    } catch {\n      setSkillScopeState({})\n    }\n  }, [skillScopeStorageKey])\n\n  useEffect(() => {\n    if (typeof window === 'undefined') return\n    try {\n      window.localStorage.setItem(\n        skillScopeStorageKey,\n        JSON.stringify(skillScopeState),\n      )\n    } catch {\n      // ignore storage failures\n    }\n  }, [skillScopeState, skillScopeStorageKey])\n\n  useEffect(() => {\n    if (!isTauri) return\n    invokeTauri<string[]>('get_recent_projects')\n      .then((projects) => setRecentProjects(projects))\n      .catch(() => {})\n  }, [invokeTauri, isTauri])\n\n  useEffect(() => {\n    if (typeof window === 'undefined') return\n    const stored = window.localStorage.getItem(themeStorageKey)\n    if (stored === 'light' || stored === 'dark' || stored === 'system') {\n      setThemePreference(stored)\n    }\n  }, [themeStorageKey])\n\n  useEffect(() => {\n    if (typeof window === 'undefined') return\n    if (language !== 'en' && language !== 'zh') return\n    try {\n      window.localStorage.setItem(languageStorageKey, language)\n    } catch {\n      // ignore storage failures\n    }\n  }, [language, languageStorageKey])\n\n  useEffect(() => {\n    if (typeof window === 'undefined') return\n    const media = window.matchMedia('(prefers-color-scheme: dark)')\n    const update = () => {\n      setSystemTheme(media.matches ? 'dark' : 'light')\n    }\n    update()\n    if (media.addEventListener) {\n      media.addEventListener('change', update)\n    } else {\n      media.addListener(update)\n    }\n    return () => {\n      if (media.removeEventListener) {\n        media.removeEventListener('change', update)\n      } else {\n        media.removeListener(update)\n      }\n    }\n  }, [])\n\n  useEffect(() => {\n    if (typeof document === 'undefined') return\n    const resolvedTheme =\n      themePreference === 'system' ? systemTheme : themePreference\n    document.documentElement.dataset.theme = resolvedTheme\n    document.documentElement.style.colorScheme = resolvedTheme\n    try {\n      window.localStorage.setItem(themeStorageKey, themePreference)\n    } catch {\n      // ignore storage failures\n    }\n  }, [systemTheme, themePreference, themeStorageKey])\n\n  useEffect(() => {\n    if (!isTauri) return\n    invokeTauri<string>('get_central_repo_path')\n      .then((path) => setStoragePath(path))\n      .catch((err) => {\n        setError(err instanceof Error ? err.message : String(err))\n      })\n  }, [isTauri, invokeTauri])\n\n  useEffect(() => {\n    if (!isTauri) return\n    invokeTauri<number>('get_git_cache_cleanup_days')\n      .then((days) => setGitCacheCleanupDays(days))\n      .catch((err) => {\n        setError(err instanceof Error ? err.message : String(err))\n      })\n  }, [isTauri, invokeTauri])\n\n  useEffect(() => {\n    if (!isTauri) return\n    invokeTauri<number>('get_git_cache_ttl_secs')\n      .then((secs) => setGitCacheTtlSecs(secs))\n      .catch((err) => {\n        setError(err instanceof Error ? err.message : String(err))\n      })\n  }, [isTauri, invokeTauri])\n\n  useEffect(() => {\n    if (!isTauri) return\n    invokeTauri<string>('get_github_token')\n      .then((token) => setGithubToken(token))\n      .catch(() => {})\n  }, [isTauri, invokeTauri])\n\n  useEffect(() => {\n    if (isTauri) {\n      void loadPlan()\n    }\n  }, [isTauri, loadPlan])\n\n  useEffect(() => {\n    if (!isTauri) return\n    const ignoredVersion = localStorage.getItem('skills-ignored-update-version')\n    import('@tauri-apps/plugin-updater')\n      .then(({ check }) => check())\n      .then(async (update) => {\n        if (update && update.version !== ignoredVersion) {\n          updateObjRef.current = update\n          setUpdateAvailableVersion(update.version)\n          // Fetch full release notes from GitHub API\n          try {\n            const res = await fetch(\n              `https://api.github.com/repos/qufei1993/skills-hub/releases/tags/v${update.version}`,\n            )\n            if (res.ok) {\n              const data = await res.json()\n              setUpdateBody(data.body ?? update.body ?? null)\n            } else {\n              setUpdateBody(update.body ?? null)\n            }\n          } catch {\n            setUpdateBody(update.body ?? null)\n          }\n        }\n      })\n      .catch(() => {})\n  }, [isTauri])\n\n  const handleDismissUpdate = useCallback(() => {\n    setUpdateAvailableVersion(null)\n    setUpdateBody(null)\n  }, [])\n\n  const handleDismissUpdateForever = useCallback(() => {\n    if (updateAvailableVersion) {\n      localStorage.setItem('skills-ignored-update-version', updateAvailableVersion)\n    }\n    setUpdateAvailableVersion(null)\n    setUpdateBody(null)\n  }, [updateAvailableVersion])\n\n  const handleUpdateNow = useCallback(async () => {\n    const update = updateObjRef.current\n    if (!update) return\n    setUpdateInstalling(true)\n    try {\n      await update.downloadAndInstall()\n      setUpdateInstalling(false)\n      setUpdateDone(true)\n    } catch (err) {\n      setUpdateInstalling(false)\n      toast.error(err instanceof Error ? err.message : String(err), { duration: 3200 })\n    }\n  }, [])\n\n  useEffect(() => {\n    if (!successToastMessage) return\n    toast.success(successToastMessage, { duration: 1800 })\n    setSuccessToastMessage(null)\n  }, [successToastMessage])\n\n  useEffect(() => {\n    if (!error) return\n    const msg = formatErrorMessage(error)\n    if (msg) toast.error(msg, { duration: 2600 })\n    setError(null)\n    setActionMessage(null)\n  }, [error, formatErrorMessage])\n\n  const toolInfos = useMemo(() => toolStatus?.tools ?? [], [toolStatus])\n\n  const tools: ToolOption[] = useMemo(() => {\n    return toolInfos.map((info) => ({\n      id: info.key,\n      // Prefer i18n label if present; fallback to backend label.\n      label: t(`tools.${info.key}`, { defaultValue: info.label }),\n      supports_project_scope: info.supports_project_scope,\n    }))\n  }, [t, toolInfos])\n\n  const toolLabelById = useMemo(() => {\n    const out: Record<string, string> = {}\n    for (const tool of tools) out[tool.id] = tool.label\n    return out\n  }, [tools])\n\n  const sharedToolIdsByToolId = useMemo(() => {\n    // toolId -> all toolIds that share the same skills_dir.\n    const byDir: Record<string, string[]> = {}\n    for (const info of toolInfos) {\n      const dir = info.skills_dir\n      if (!byDir[dir]) byDir[dir] = []\n      byDir[dir].push(info.key)\n    }\n    const out: Record<string, string[]> = {}\n    for (const dir of Object.keys(byDir)) {\n      const ids = byDir[dir]\n      if (ids.length <= 1) continue\n      for (const id of ids) out[id] = ids\n    }\n    return out\n  }, [toolInfos])\n\n  const uniqueToolIdsBySkillsDir = useCallback(\n    (toolIds: string[]) => {\n      // Preserve UI order (tools array order), de-dupe by skills_dir.\n      const wanted = new Set(toolIds)\n      const seen = new Set<string>()\n      const out: string[] = []\n      for (const tool of toolInfos) {\n        if (!wanted.has(tool.key)) continue\n        if (seen.has(tool.skills_dir)) continue\n        seen.add(tool.skills_dir)\n        out.push(tool.key)\n      }\n      return out\n    },\n    [toolInfos],\n  )\n\n  const installedToolIds = useMemo(\n    () => toolStatus?.installed ?? [],\n    [toolStatus],\n  )\n  const isInstalled = useCallback(\n    (id: string) => installedToolIds.includes(id),\n    [installedToolIds],\n  )\n  const installedTools = useMemo(\n    () => tools.filter((tool) => installedToolIds.includes(tool.id)),\n    [tools, installedToolIds],\n  )\n  const toolSupportsProjectScope = useCallback(\n    (toolId: string) =>\n      tools.find((tool) => tool.id === toolId)?.supports_project_scope ?? true,\n    [tools],\n  )\n  const installedProjectToolIds = useMemo(\n    () => installedToolIds.filter((toolId) => toolSupportsProjectScope(toolId)),\n    [installedToolIds, toolSupportsProjectScope],\n  )\n\n  const getSkillProjects = useCallback(\n    (skill: ManagedSkill) => {\n      const projects = new Set<string>()\n      for (const target of skill.targets) {\n        if ((target.scope ?? 'global') === 'project' && target.project_path) {\n          projects.add(target.project_path)\n        }\n      }\n      return Array.from(projects)\n    },\n    [],\n  )\n\n  const getSkillScope = useCallback(\n    (skill: ManagedSkill): 'global' | 'project' => {\n      const hasGlobalTarget = skill.targets.some(\n        (target) => (target.scope ?? 'global') === 'global',\n      )\n      const hasProjectTarget = skill.targets.some(\n        (target) => (target.scope ?? 'global') === 'project',\n      )\n      if (hasGlobalTarget && !hasProjectTarget) return 'global'\n      if (hasProjectTarget && !hasGlobalTarget) return 'project'\n      const stored = skillScopeState[skill.id]?.scope\n      if (stored === 'global' || stored === 'project') return stored\n      return hasProjectTarget ? 'project' : 'global'\n    },\n    [skillScopeState],\n  )\n\n  const visibleSkills = useMemo(() => {\n    const query = searchQuery.trim().toLowerCase()\n    const selectedTagSet = new Set(selectedTagIds)\n    const hasTagFilter = selectedTagIds.length > 0 || includeUntagged\n    const filtered = managedSkills.filter((skill) => {\n      if (scopeFilter !== 'all' && getSkillScope(skill) !== scopeFilter) return false\n      if (hasTagFilter) {\n        const matchesSelectedTag = skill.tags.some((tag) => selectedTagSet.has(tag.id))\n        const matchesUntagged = includeUntagged && skill.tags.length === 0\n        if (!matchesSelectedTag && !matchesUntagged) return false\n      }\n      if (!query) return true\n      return (\n        skill.name.toLowerCase().includes(query) ||\n        skill.central_path.toLowerCase().includes(query) ||\n        skill.source_type.toLowerCase().includes(query) ||\n        skill.tags.some((tag) => tag.name.toLowerCase().includes(query))\n      )\n    })\n    const sorted = [...filtered].sort((a, b) => {\n      if (sortBy === 'name') {\n        return a.name.localeCompare(b.name)\n      }\n      return (b.updated_at ?? 0) - (a.updated_at ?? 0)\n    })\n    return sorted\n  }, [\n    getSkillScope,\n    includeUntagged,\n    managedSkills,\n    scopeFilter,\n    searchQuery,\n    selectedTagIds,\n    sortBy,\n  ])\n  const untaggedCount = useMemo(\n    () => managedSkills.filter((skill) => skill.tags.length === 0).length,\n    [managedSkills],\n  )\n\n  const [storagePath, setStoragePath] = useState<string>(t('notAvailable'))\n  const [gitCacheCleanupDays, setGitCacheCleanupDays] = useState<number>(30)\n  const [gitCacheTtlSecs, setGitCacheTtlSecs] = useState<number>(60)\n  const [githubToken, setGithubToken] = useState<string>('')\n  const handlePickStoragePath = useCallback(async () => {\n    try {\n      if (!isTauri) {\n        throw new Error(t('errors.notTauri'))\n      }\n      const { open } = await import('@tauri-apps/plugin-dialog')\n      const selected = await open({\n        directory: true,\n        multiple: false,\n        title: t('selectStoragePath'),\n      })\n      if (!selected || Array.isArray(selected)) return\n      const newPath = await invokeTauri<string>('set_central_repo_path', {\n        path: selected,\n      })\n      setStoragePath(newPath)\n      await loadManagedSkills()\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err))\n    }\n  }, [invokeTauri, isTauri, loadManagedSkills, t])\n  const handleGitCacheCleanupDaysChange = useCallback(\n    async (nextDays: number) => {\n      const normalized = Math.max(0, Math.min(nextDays, 3650))\n      setGitCacheCleanupDays(normalized)\n      if (!isTauri) return\n      try {\n        const updated = await invokeTauri<number>('set_git_cache_cleanup_days', {\n          days: normalized,\n        })\n        setGitCacheCleanupDays(updated)\n      } catch (err) {\n        setError(err instanceof Error ? err.message : String(err))\n      }\n    },\n    [invokeTauri, isTauri],\n  )\n  const handleGitCacheTtlSecsChange = useCallback(\n    async (nextSecs: number) => {\n      const normalized = Math.max(0, Math.min(nextSecs, 3600))\n      setGitCacheTtlSecs(normalized)\n      if (!isTauri) return\n      try {\n        const updated = await invokeTauri<number>('set_git_cache_ttl_secs', {\n          secs: normalized,\n        })\n        setGitCacheTtlSecs(updated)\n      } catch (err) {\n        setError(err instanceof Error ? err.message : String(err))\n      }\n    },\n    [invokeTauri, isTauri],\n  )\n  const handleGithubTokenChange = useCallback(\n    async (nextToken: string) => {\n      setGithubToken(nextToken)\n      if (!isTauri) return\n      try {\n        await invokeTauri('set_github_token', { token: nextToken })\n      } catch (err) {\n        setError(err instanceof Error ? err.message : String(err))\n      }\n    },\n    [invokeTauri, isTauri],\n  )\n  const handleClearGitCacheNow = useCallback(async () => {\n    if (!isTauri) {\n      setError(t('errors.notTauri'))\n      return\n    }\n    try {\n      const removed = await invokeTauri<number>('clear_git_cache_now')\n      setSuccessToastMessage(t('status.gitCacheCleared', { count: removed }))\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err))\n    }\n  }, [invokeTauri, isTauri, t])\n  const handlePickLocalPath = useCallback(async () => {\n    try {\n      if (!isTauri) {\n        throw new Error(t('errors.notTauri'))\n      }\n      const { open } = await import('@tauri-apps/plugin-dialog')\n      const selected = await open({\n        directory: true,\n        multiple: false,\n        title: t('selectLocalFolder'),\n      })\n      if (!selected || Array.isArray(selected)) return\n      setLocalPath(selected)\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err))\n    }\n  }, [isTauri, t])\n  const pendingDeleteSkill = useMemo(\n    () => managedSkills.find((skill) => skill.id === pendingDeleteId) ?? null,\n    [managedSkills, pendingDeleteId],\n  )\n  const newlyInstalledToolsText = useMemo(() => {\n    if (!toolStatus || toolStatus.newly_installed.length === 0) return ''\n    return toolStatus.newly_installed\n      .map((id) => tools.find((t) => t.id === id)?.label ?? id)\n      .join('、')\n  }, [toolStatus, tools])\n\n  const handleOpenSettings = useCallback(() => {\n    setActiveView('settings')\n  }, [])\n\n  const loadFeaturedSkills = useCallback(async () => {\n    if (featuredSkills.length > 0) return\n    setFeaturedLoading(true)\n    try {\n      const result = await invokeTauri<FeaturedSkillDto[]>('get_featured_skills')\n      setFeaturedSkills(result)\n    } catch {\n      // silent — explore tab will show empty state\n    } finally {\n      setFeaturedLoading(false)\n    }\n  }, [featuredSkills.length, invokeTauri])\n\n  const handleViewChange = useCallback(\n    (view: 'myskills' | 'explore' | 'tags') => {\n      setActiveView(view)\n      if (view === 'explore') {\n        loadFeaturedSkills()\n      }\n      if (view === 'myskills') {\n        setDetailSkill(null)\n      }\n    },\n    [loadFeaturedSkills],\n  )\n\n  const handleOpenDetail = useCallback((skill: ManagedSkill) => {\n    setDetailSkill(skill)\n    setActiveView('detail')\n  }, [])\n\n  const handleBackToList = useCallback(() => {\n    setDetailSkill(null)\n    setActiveView('myskills')\n  }, [])\n\n  const handleExploreFilterChange = useCallback(\n    (value: string) => {\n      setExploreFilter(value)\n      if (searchTimerRef.current) {\n        clearTimeout(searchTimerRef.current)\n        searchTimerRef.current = null\n      }\n      if (value.trim().length < 2) {\n        setSearchResults([])\n        setSearchLoading(false)\n        return\n      }\n      setSearchLoading(true)\n      searchTimerRef.current = setTimeout(async () => {\n        try {\n          const results = await invokeTauri<OnlineSkillDto[]>(\n            'search_skills_online',\n            { query: value.trim(), limit: 20 },\n          )\n          setSearchResults(results)\n        } catch {\n          toast.error(t('searchError'))\n          setSearchResults([])\n        } finally {\n          setSearchLoading(false)\n        }\n      }, 500)\n    },\n    [invokeTauri, t],\n  )\n\n\n  const handleOpenAdd = useCallback(() => {\n    setShowAddModal(true)\n    setAddModalTagIds([])\n  }, [])\n\n  const applySelectedAddModalTags = useCallback(\n    async (skillId: string, skillName: string) => {\n      if (addModalTagIds.length === 0) return\n      try {\n        await invokeTauri('set_skill_tags', {\n          skillId,\n          tagIds: addModalTagIds,\n        })\n      } catch {\n        toast.error(t('tagsApplyFailed', { name: skillName }))\n      }\n    },\n    [addModalTagIds, invokeTauri, t],\n  )\n\n  const handleCancelLoading = useCallback(() => {\n    void invokeTauri('cancel_current_operation').catch(() => {})\n    setLoading(false)\n    setLoadingStartAt(null)\n    setActionMessage(null)\n  }, [invokeTauri])\n\n  const handleCloseAdd = useCallback(() => {\n    if (!loading) {\n      setShowAddModal(false)\n      setAddModalTagIds([])\n    }\n  }, [loading])\n\n  const handleCloseImport = useCallback(() => {\n    if (!loading) setShowImportModal(false)\n  }, [loading])\n\n  const handleCloseSettings = useCallback(() => {\n    setActiveView('myskills')\n  }, [])\n\n  const handleThemeChange = useCallback(\n    (nextTheme: 'system' | 'light' | 'dark') => {\n      setThemePreference(nextTheme)\n    },\n    [],\n  )\n\n  const handleCloseNewTools = useCallback(() => {\n    if (!loading) setShowNewToolsModal(false)\n  }, [loading])\n\n  const handleCloseDelete = useCallback(() => {\n    if (!loading) setPendingDeleteId(null)\n  }, [loading])\n\n  const handleCloseGitPick = useCallback(() => {\n    if (!loading) setShowGitPickModal(false)\n  }, [loading])\n\n  const handleCancelGitPick = useCallback(() => {\n    if (loading) return\n    setShowGitPickModal(false)\n    setGitCandidates([])\n    setGitCandidateSelected({})\n    setGitCandidatesRepoUrl('')\n  }, [loading])\n\n  const handleCloseLocalPick = useCallback(() => {\n    if (!loading) setShowLocalPickModal(false)\n  }, [loading])\n\n  const handleCancelLocalPick = useCallback(() => {\n    if (loading) return\n    setShowLocalPickModal(false)\n    setLocalCandidates([])\n    setLocalCandidateSelected({})\n    setLocalCandidatesBasePath('')\n  }, [loading])\n\n  const handleSortChange = useCallback((value: 'updated' | 'name') => {\n    setSortBy(value)\n  }, [])\n\n  const handleSearchChange = useCallback((value: string) => {\n    setSearchQuery(value)\n  }, [])\n\n  const handleScopeFilterChange = useCallback(\n    (value: 'all' | 'global' | 'project') => {\n      setScopeFilter(value)\n    },\n    [],\n  )\n\n  const handleToggleAddModalTag = useCallback((tagId: number) => {\n    setAddModalTagIds((current) =>\n      current.includes(tagId)\n        ? current.filter((id) => id !== tagId)\n        : [...current, tagId],\n    )\n  }, [])\n\n  const handleToggleTagFilter = useCallback((tagId: number) => {\n    setSelectedTagIds((current) =>\n      current.includes(tagId)\n        ? current.filter((id) => id !== tagId)\n        : [...current, tagId],\n    )\n  }, [])\n\n  const handleToggleUntaggedFilter = useCallback(() => {\n    setIncludeUntagged((current) => !current)\n  }, [])\n\n  const handleClearTagFilters = useCallback(() => {\n    setSelectedTagIds([])\n    setIncludeUntagged(false)\n  }, [])\n\n  const handleOpenTagsPage = useCallback(() => {\n    setActiveView('tags')\n  }, [])\n\n  const handleReviewUntagged = useCallback(() => {\n    setSelectedTagIds([])\n    setIncludeUntagged(true)\n    setActiveView('myskills')\n  }, [])\n\n  const handleViewTag = useCallback((tagId: number) => {\n    setSelectedTagIds([tagId])\n    setIncludeUntagged(false)\n    setActiveView('myskills')\n  }, [])\n\n  const handleCreateTag = useCallback(\n    async (name: string) => {\n      try {\n        await invokeTauri('create_tag', { name })\n        await loadTags()\n        setSuccessToastMessage(t('tagCreated'))\n      } catch (err) {\n        setError(err instanceof Error ? err.message : String(err))\n      }\n    },\n    [invokeTauri, loadTags, t],\n  )\n\n  const handleRenameTag = useCallback(\n    async (tagId: number, name: string) => {\n      try {\n        const renamed = await invokeTauri<{ id: number; name: string }>('rename_tag', {\n          tagId,\n          name,\n        })\n        setSelectedTagIds((current) =>\n          current.includes(tagId) ? current.map((id) => (id === tagId ? renamed.id : id)) : current,\n        )\n        await loadManagedSkills()\n        await loadTags()\n        setSuccessToastMessage(t('tagRenamed'))\n      } catch (err) {\n        setError(err instanceof Error ? err.message : String(err))\n      }\n    },\n    [invokeTauri, loadManagedSkills, loadTags, t],\n  )\n\n  const handleDeleteTag = useCallback((tag: TagWithCountDto) => {\n    setPendingDeleteTag(tag)\n  }, [])\n\n  const handleCloseDeleteTag = useCallback(() => {\n    if (!loading) setPendingDeleteTag(null)\n  }, [loading])\n\n  const handleConfirmDeleteTag = useCallback(async () => {\n    if (!pendingDeleteTag) return\n    try {\n      setLoading(true)\n      setLoadingStartAt(Date.now())\n      setActionMessage(t('actions.deletingTag', { name: pendingDeleteTag.name }))\n      await invokeTauri('delete_tag', { tagId: pendingDeleteTag.id })\n      setSelectedTagIds((current) => current.filter((id) => id !== pendingDeleteTag.id))\n      await loadManagedSkills()\n      await loadTags()\n      setPendingDeleteTag(null)\n      setSuccessToastMessage(t('tagDeleted'))\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err))\n    } finally {\n      setLoading(false)\n      setLoadingStartAt(null)\n      setActionMessage(null)\n    }\n  }, [invokeTauri, loadManagedSkills, loadTags, pendingDeleteTag, t])\n\n  const handleOpenEditTags = useCallback((skill: ManagedSkill) => {\n    setTagEditorSkill(skill)\n  }, [])\n\n  const handleCloseEditTags = useCallback(() => {\n    if (!loading) setTagEditorSkill(null)\n  }, [loading])\n\n  const handleSaveSkillTags = useCallback(\n    async (skill: ManagedSkill, tagIds: number[]) => {\n      try {\n        setLoading(true)\n        setLoadingStartAt(Date.now())\n        setActionMessage(t('actions.updatingTags', { name: skill.name }))\n        await invokeTauri('set_skill_tags', { skillId: skill.id, tagIds })\n        await loadManagedSkills()\n        await loadTags()\n        setTagEditorSkill(null)\n        setSuccessToastMessage(t('tagsUpdated'))\n      } catch (err) {\n        setError(err instanceof Error ? err.message : String(err))\n      } finally {\n        setLoading(false)\n        setLoadingStartAt(null)\n        setActionMessage(null)\n      }\n    },\n    [invokeTauri, loadManagedSkills, loadTags, t],\n  )\n\n  const handleSyncTargetChange = useCallback(\n    (toolId: string, checked: boolean) => {\n      const shared = sharedToolIdsByToolId[toolId] ?? [toolId]\n      if (shared.length > 1) {\n        const others = shared.filter((id) => id !== toolId)\n        const otherLabels = others.map((id) => toolLabelById[id] ?? id).join(', ')\n        const ok = window.confirm(\n          t('sharedDirConfirm', {\n            tool: toolLabelById[toolId] ?? toolId,\n            others: otherLabels,\n          }),\n        )\n        if (!ok) return\n      }\n      setSyncTargets((prev) => {\n        const next = { ...prev }\n        for (const id of shared) next[id] = checked\n        return next\n      })\n    },\n    [sharedToolIdsByToolId, t, toolLabelById],\n  )\n\n  const handleDeletePrompt = useCallback((skillId: string) => {\n    setPendingDeleteId(skillId)\n  }, [])\n\n  const handleToggleGitCandidate = useCallback(\n    (subpath: string, checked: boolean) => {\n      setGitCandidateSelected((prev) => ({\n        ...prev,\n        [subpath]: checked,\n      }))\n    },\n    [],\n  )\n\n  const handleToggleLocalCandidate = useCallback(\n    (subpath: string, checked: boolean) => {\n      setLocalCandidateSelected((prev) => ({\n        ...prev,\n        [subpath]: checked,\n      }))\n    },\n    [],\n  )\n\n  const handleToggleGroup = useCallback((groupName: string, checked: boolean) => {\n    setSelected((prev) => ({\n      ...prev,\n      [groupName]: checked,\n    }))\n  }, [])\n\n  const handleSelectVariant = useCallback((groupName: string, path: string) => {\n    setVariantChoice((prev) => ({\n      ...prev,\n      [groupName]: path,\n    }))\n  }, [])\n\n  const handleReviewImport = useCallback(async () => {\n    if (plan) {\n      setShowImportModal(true)\n      return\n    }\n    const result = await loadPlan()\n    if (result) {\n      setShowImportModal(true)\n    }\n  }, [loadPlan, plan])\n\n  useEffect(() => {\n    const load = async () => {\n      if (!isTauri) return\n      try {\n        const status = await invokeTauri<ToolStatusDto>('get_tool_status')\n        setToolStatus(status)\n\n        // Default-select installed tools for sync targets if user hasn't toggled yet.\n        setSyncTargets((prev) => {\n          if (Object.keys(prev).length > 0) return prev\n          const next: Record<string, boolean> = {}\n          for (const t of status.tools) {\n            next[t.key] = status.installed.includes(t.key)\n          }\n          return next\n        })\n\n        if (status.newly_installed.length > 0) {\n          setShowNewToolsModal(true)\n        }\n      } catch (err) {\n        // Non-fatal; app can still work without detection.\n        console.warn(err)\n      }\n    }\n    void load()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isTauri])\n\n  const handleImport = async () => {\n    if (!plan) return\n    if (!plan.groups.some((group) => selected[group.name])) {\n      setError(t('errors.selectAtLeastOneSkill'))\n      return\n    }\n    setLoading(true)\n    setLoadingStartAt(Date.now())\n    setActionMessage(null)\n    setError(null)\n    try {\n      const collectedErrors: { title: string; message: string }[] = []\n      let successCount = 0\n      for (const group of plan.groups) {\n        if (!selected[group.name]) continue\n        const chosenPath = variantChoice[group.name] ?? group.variants[0]?.path\n        if (!chosenPath) continue\n        const chosenVariant = group.variants.find((v) => v.path === chosenPath)\n        const chosenVariantTool = chosenVariant?.tool ?? null\n        const chosenFingerprint = chosenVariant?.fingerprint ?? null\n\n        let installResult: {\n          skill_id: string\n          central_path: string\n        }\n\n        try {\n          setActionMessage(t('actions.importExisting', { name: group.name }))\n          installResult = await invokeTauri<{\n            skill_id: string\n            central_path: string\n          }>('import_existing_skill', {\n            sourcePath: chosenPath,\n            name: group.name,\n          })\n          successCount += 1\n        } catch (err) {\n          collectedErrors.push({\n            title: t('errors.importFailedTitle', { name: group.name }),\n            message: err instanceof Error ? err.message : String(err),\n          })\n          continue\n        }\n\n        const selectedInstalledIds = tools\n          .filter((tool) => syncTargets[tool.id] && isInstalled(tool.id))\n          .map((t) => t.id)\n        const targets = uniqueToolIdsBySkillsDir(selectedInstalledIds)\n          .map((id) => tools.find((t) => t.id === id))\n          .filter(Boolean) as ToolOption[]\n        for (const tool of targets) {\n          setActionMessage(\n            t('actions.syncing', { name: group.name, tool: tool.label }),\n          )\n          try {\n            const sharedToolIds = sharedToolIdsByToolId[tool.id] ?? [tool.id]\n            const hasSameContentVariant = Boolean(\n              chosenFingerprint &&\n                group.variants.some(\n                  (variant) =>\n                    sharedToolIds.includes(variant.tool) &&\n                    variant.fingerprint === chosenFingerprint,\n                ),\n            )\n            const overwrite = Boolean(\n              (chosenVariantTool &&\n                (chosenVariantTool === tool.id || sharedToolIds.includes(chosenVariantTool))) ||\n                hasSameContentVariant,\n            )\n            await invokeTauri('sync_skill_to_tool', {\n              sourcePath: installResult.central_path,\n              skillId: installResult.skill_id,\n              tool: tool.id,\n              name: group.name,\n              // 自动接管：来源目录或内容一致的已发现目录可安全替换为 Hub 管理的同步目标。\n              overwrite,\n              overwriteIfSameContent: true,\n            })\n          } catch (err) {\n            const raw = err instanceof Error ? err.message : String(err)\n            if (raw.startsWith('TARGET_EXISTS|')) {\n              const targetPath = raw.split('|')[1] ?? ''\n              collectedErrors.push({\n                title: t('errors.syncFailedTitle', {\n                  name: group.name,\n                  tool: tool.label,\n                }),\n                message: t('errors.syncTargetExistsMessage', {\n                  path: targetPath,\n                }),\n              })\n            } else {\n              collectedErrors.push({\n                title: t('errors.syncFailedTitle', {\n                  name: group.name,\n                  tool: tool.label,\n                }),\n                message: raw,\n              })\n            }\n          }\n        }\n      }\n\n      setActionMessage(t('status.importCompleted'))\n      setActionMessage(null)\n      await loadManagedSkills()\n      await loadPlan()\n      if (collectedErrors.length > 0) {\n        showActionErrors(collectedErrors)\n      } else if (successCount > 0) {\n        setSuccessToastMessage(t('status.importCompleted'))\n      }\n      setShowImportModal(false)\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err))\n    } finally {\n      setLoading(false)\n      setLoadingStartAt(null)\n    }\n  }\n\n  const handleCreateLocal = async () => {\n    if (!localPath.trim()) {\n      setError(t('errors.requireLocalPath'))\n      return\n    }\n    setLoading(true)\n    setLoadingStartAt(Date.now())\n    setError(null)\n    setActionMessage(t('actions.creatingLocalSkill'))\n    try {\n      const basePath = localPath.trim()\n      const candidates = await invokeTauri<LocalSkillCandidate[]>(\n        'list_local_skills_cmd',\n        { basePath },\n      )\n      if (candidates.length === 0) {\n        throw new Error(t('errors.noSkillsFoundLocal'))\n      }\n      if (candidates.length === 1 && candidates[0].valid) {\n        const desiredName = localName.trim() || candidates[0].name\n        if (isSkillNameTaken(desiredName)) {\n          setError(t('errors.skillAlreadyExists', { name: desiredName }))\n          return\n        }\n        const created = await invokeTauri<InstallResultDto>(\n          'install_local_selection',\n          {\n            basePath,\n            subpath: candidates[0].subpath,\n            name: localName.trim() || undefined,\n          },\n        )\n        await applySelectedAddModalTags(created.skill_id, created.name)\n        {\n          const selectedInstalledIds = tools\n            .filter((tool) => syncTargets[tool.id] && isInstalled(tool.id))\n            .map((t) => t.id)\n          const targets = uniqueToolIdsBySkillsDir(selectedInstalledIds)\n            .map((id) => tools.find((t) => t.id === id))\n            .filter(Boolean) as ToolOption[]\n          if (targets.length === 0) {\n            setError(t('errors.noSyncTargets'))\n          } else {\n            const collectedErrors: { title: string; message: string }[] = []\n            for (let i = 0; i < targets.length; i++) {\n              const tool = targets[i]\n              setActionMessage(\n                t('actions.syncStep', {\n                  index: i + 1,\n                  total: targets.length,\n                  name: created.name,\n                  tool: tool.label,\n                }),\n              )\n              try {\n                await invokeTauri('sync_skill_to_tool', {\n                  sourcePath: created.central_path,\n                  skillId: created.skill_id,\n                  tool: tool.id,\n                  name: created.name,\n                  overwriteIfSameContent: true,\n                })\n              } catch (err) {\n                const raw = err instanceof Error ? err.message : String(err)\n                collectedErrors.push({\n                  title: t('errors.syncFailedTitle', {\n                    name: created.name,\n                    tool: tool.label,\n                  }),\n                  message: raw,\n                })\n              }\n            }\n            if (collectedErrors.length > 0) showActionErrors(collectedErrors)\n          }\n        }\n        setLocalPath('')\n        setLocalName('')\n        setActionMessage(t('status.localSkillCreated'))\n        setSuccessToastMessage(t('status.localSkillCreated'))\n        setActionMessage(null)\n        setShowAddModal(false)\n        await loadManagedSkills()\n        await loadTags()\n      } else {\n        setLocalCandidatesBasePath(basePath)\n        setLocalCandidates(candidates)\n        setLocalCandidateSelected(\n          Object.fromEntries(candidates.map((c) => [c.subpath, c.valid])),\n        )\n        setShowLocalPickModal(true)\n        setActionMessage(null)\n        setLoading(false)\n        setLoadingStartAt(null)\n        return\n      }\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err))\n    } finally {\n      setLoading(false)\n      setLoadingStartAt(null)\n    }\n  }\n\n  const handleCreateGit = async () => {\n    if (!gitUrl.trim()) {\n      setError(t('errors.requireGitUrl'))\n      return\n    }\n    setLoading(true)\n    setLoadingStartAt(Date.now())\n    setError(null)\n    setActionMessage(t('actions.creatingGitSkill'))\n    try {\n      const url = gitUrl.trim()\n      const isFolderUrl = url.includes('/tree/') || url.includes('/blob/')\n\n      if (isFolderUrl) {\n        const candidates = await invokeTauri<GitSkillCandidate[]>(\n          'list_git_skills_cmd',\n          { repoUrl: url },\n        )\n        if (candidates.length === 0) {\n          throw new Error(t('errors.noSkillsFoundWithHint'))\n        }\n        if (candidates.length > 1) {\n          setGitCandidatesRepoUrl(url)\n          setGitCandidates(candidates)\n          setGitCandidateSelected(\n            Object.fromEntries(candidates.map((c) => [c.subpath, true])),\n          )\n          setShowGitPickModal(true)\n          setActionMessage(null)\n          setLoading(false)\n          setLoadingStartAt(null)\n          return\n        }\n        if (isSkillNameTaken(candidates[0].name)) {\n          setError(t('errors.skillAlreadyExists', { name: candidates[0].name }))\n          return\n        }\n        const created = await invokeTauri<InstallResultDto>(\n          'install_git_selection',\n          {\n            repoUrl: url,\n            subpath: candidates[0].subpath,\n            name: gitName.trim() || undefined,\n          },\n        )\n        await applySelectedAddModalTags(created.skill_id, created.name)\n        {\n          const selectedInstalledIds = tools\n            .filter((tool) => syncTargets[tool.id] && isInstalled(tool.id))\n            .map((t) => t.id)\n          const targets = uniqueToolIdsBySkillsDir(selectedInstalledIds)\n            .map((id) => tools.find((t) => t.id === id))\n            .filter(Boolean) as ToolOption[]\n          if (targets.length === 0) {\n            setError(t('errors.noSyncTargets'))\n          } else {\n            const collectedErrors: { title: string; message: string }[] = []\n            for (let i = 0; i < targets.length; i++) {\n              const tool = targets[i]\n              setActionMessage(\n                t('actions.syncStep', {\n                  index: i + 1,\n                  total: targets.length,\n                  name: created.name,\n                  tool: tool.label,\n                }),\n              )\n              try {\n                await invokeTauri('sync_skill_to_tool', {\n                  sourcePath: created.central_path,\n                  skillId: created.skill_id,\n                  tool: tool.id,\n                  name: created.name,\n                  overwriteIfSameContent: true,\n                })\n              } catch (err) {\n                const raw = err instanceof Error ? err.message : String(err)\n              collectedErrors.push({\n                title: t('errors.syncFailedTitle', {\n                  name: created.name,\n                  tool: tool.label,\n                }),\n                message: raw,\n              })\n              }\n            }\n            if (collectedErrors.length > 0) showActionErrors(collectedErrors)\n          }\n        }\n      } else {\n        const candidates = await invokeTauri<GitSkillCandidate[]>(\n          'list_git_skills_cmd',\n          { repoUrl: url },\n        )\n        if (candidates.length === 0) {\n          throw new Error(t('errors.noSkillsFoundWithHint'))\n        }\n        if (candidates.length === 1) {\n          if (isSkillNameTaken(candidates[0].name)) {\n            setError(t('errors.skillAlreadyExists', { name: candidates[0].name }))\n            return\n          }\n          const created = await invokeTauri<InstallResultDto>(\n            'install_git_selection',\n            {\n            repoUrl: url,\n            subpath: candidates[0].subpath,\n            name: gitName.trim() || undefined,\n            },\n          )\n          await applySelectedAddModalTags(created.skill_id, created.name)\n          {\n            const selectedInstalledIds = tools\n              .filter((tool) => syncTargets[tool.id] && isInstalled(tool.id))\n              .map((t) => t.id)\n            const targets = uniqueToolIdsBySkillsDir(selectedInstalledIds)\n              .map((id) => tools.find((t) => t.id === id))\n              .filter(Boolean) as ToolOption[]\n            if (targets.length === 0) {\n              setError(t('errors.noSyncTargets'))\n            } else {\n              const collectedErrors: { title: string; message: string }[] = []\n              for (let i = 0; i < targets.length; i++) {\n                const tool = targets[i]\n                setActionMessage(\n                  t('actions.syncStep', {\n                    index: i + 1,\n                    total: targets.length,\n                    name: created.name,\n                    tool: tool.label,\n                  }),\n                )\n                try {\n                  await invokeTauri('sync_skill_to_tool', {\n                    sourcePath: created.central_path,\n                    skillId: created.skill_id,\n                    tool: tool.id,\n                    name: created.name,\n                    overwriteIfSameContent: true,\n                  })\n                } catch (err) {\n                  const raw = err instanceof Error ? err.message : String(err)\n                  collectedErrors.push({\n                    title: t('errors.syncFailedTitle', {\n                      name: created.name,\n                      tool: tool.label,\n                    }),\n                    message: raw,\n                  })\n                }\n              }\n              if (collectedErrors.length > 0) showActionErrors(collectedErrors)\n            }\n          }\n        } else if (autoSelectSkillName) {\n          // Auto-select the matching skill from online search results.\n          // skills.sh name may differ from SKILL.md name (e.g. \"json-render-react\" vs \"react\"),\n          // so try exact match first, then containment match.\n          const target = autoSelectSkillName.toLowerCase()\n          const containMatches = candidates.filter((c) => {\n            const n = c.name.toLowerCase()\n            return target.includes(n) || n.includes(target)\n          })\n          const match =\n            candidates.find((c) => c.name.toLowerCase() === target) ??\n            (containMatches.length === 1 ? containMatches[0] : undefined)\n          setAutoSelectSkillName(null)\n          if (match) {\n            if (isSkillNameTaken(match.name)) {\n              setError(t('errors.skillAlreadyExists', { name: match.name }))\n              return\n            }\n            const created = await invokeTauri<InstallResultDto>(\n              'install_git_selection',\n              {\n                repoUrl: url,\n                subpath: match.subpath,\n                name: gitName.trim() || undefined,\n              },\n            )\n            await applySelectedAddModalTags(created.skill_id, created.name)\n            {\n              const selectedInstalledIds = tools\n                .filter((tool) => syncTargets[tool.id] && isInstalled(tool.id))\n                .map((t) => t.id)\n              const targets = uniqueToolIdsBySkillsDir(selectedInstalledIds)\n                .map((id) => tools.find((t) => t.id === id))\n                .filter(Boolean) as ToolOption[]\n              if (targets.length === 0) {\n                setError(t('errors.noSyncTargets'))\n              } else {\n                const collectedErrors: { title: string; message: string }[] = []\n                for (let i = 0; i < targets.length; i++) {\n                  const tool = targets[i]\n                  setActionMessage(\n                    t('actions.syncStep', {\n                      index: i + 1,\n                      total: targets.length,\n                      name: created.name,\n                      tool: tool.label,\n                    }),\n                  )\n                  try {\n                    await invokeTauri('sync_skill_to_tool', {\n                      sourcePath: created.central_path,\n                      skillId: created.skill_id,\n                      tool: tool.id,\n                      name: created.name,\n                      overwriteIfSameContent: true,\n                    })\n                  } catch (err) {\n                    const raw = err instanceof Error ? err.message : String(err)\n                    collectedErrors.push({\n                      title: t('errors.syncFailedTitle', {\n                        name: created.name,\n                        tool: tool.label,\n                      }),\n                      message: raw,\n                    })\n                  }\n                }\n                if (collectedErrors.length > 0) showActionErrors(collectedErrors)\n              }\n            }\n          } else {\n            // No match found, fall back to picker\n            setGitCandidatesRepoUrl(url)\n            setGitCandidates(candidates)\n            setGitCandidateSelected(\n              Object.fromEntries(candidates.map((c) => [c.subpath, true])),\n            )\n            setShowGitPickModal(true)\n            setActionMessage(null)\n            setLoading(false)\n            setLoadingStartAt(null)\n            return\n          }\n        } else {\n          setGitCandidatesRepoUrl(url)\n          setGitCandidates(candidates)\n          setGitCandidateSelected(\n            Object.fromEntries(candidates.map((c) => [c.subpath, true])),\n          )\n          setShowGitPickModal(true)\n          setActionMessage(null)\n          setLoading(false)\n          setLoadingStartAt(null)\n          return\n        }\n      }\n      setGitUrl('')\n      setGitName('')\n      setActionMessage(t('status.gitSkillCreated'))\n      setSuccessToastMessage(t('status.gitSkillCreated'))\n      setActionMessage(null)\n      setShowAddModal(false)\n      await loadManagedSkills()\n      await loadTags()\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err))\n    } finally {\n      setLoading(false)\n      setLoadingStartAt(null)\n    }\n  }\n\n  const [exploreInstallTrigger, setExploreInstallTrigger] = useState(0)\n  const exploreInstallUrlRef = useRef<string | null>(null)\n\n  const handleExploreInstall = useCallback(\n    (sourceUrl: string, skillName?: string) => {\n      setGitUrl(sourceUrl)\n      if (skillName) setAutoSelectSkillName(skillName)\n      if (toolStatus) {\n        const targets: Record<string, boolean> = {}\n        for (const id of toolStatus.installed) {\n          targets[id] = true\n        }\n        setSyncTargets(targets)\n      }\n      exploreInstallUrlRef.current = sourceUrl\n      setExploreInstallTrigger((n) => n + 1)\n    },\n    [toolStatus],\n  )\n\n  useEffect(() => {\n    if (exploreInstallTrigger > 0 && exploreInstallUrlRef.current && !loading) {\n      exploreInstallUrlRef.current = null\n      void handleCreateGit()\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [exploreInstallTrigger])\n\n  const handleInstallSelectedLocalCandidates = async () => {\n    const selected = localCandidates.filter(\n      (c) => c.valid && localCandidateSelected[c.subpath],\n    )\n    if (selected.length === 0) {\n      setError(t('errors.selectAtLeastOneSkill'))\n      return\n    }\n    if (selected.length > 1 && localName.trim()) {\n      setError(t('errors.multiSelectNoCustomName'))\n      return\n    }\n    if (selected.length > 1) {\n      const seen = new Set<string>()\n      const dup = selected.find((c) => {\n        if (seen.has(c.name)) return true\n        seen.add(c.name)\n        return false\n      })\n      if (dup) {\n        setError(t('errors.duplicateSelectedSkills', { name: dup.name }))\n        return\n      }\n    }\n    const desiredName =\n      selected.length === 1 && localName.trim()\n        ? localName.trim()\n        : selected[0].name\n    if (selected.length === 1 && isSkillNameTaken(desiredName)) {\n      setError(t('errors.skillAlreadyExists', { name: desiredName }))\n      return\n    }\n    const duplicated = selected.find((c) => isSkillNameTaken(c.name))\n    if (selected.length > 1 && duplicated) {\n      setError(t('errors.skillAlreadyExists', { name: duplicated.name }))\n      return\n    }\n\n    setLoading(true)\n    setLoadingStartAt(Date.now())\n    setError(null)\n    try {\n      const collectedErrors: { title: string; message: string }[] = []\n      for (let i = 0; i < selected.length; i++) {\n        const candidate = selected[i]\n        setActionMessage(\n          t('actions.importStep', {\n            index: i + 1,\n            total: selected.length,\n            name: candidate.name,\n          }),\n        )\n        try {\n          const created = await invokeTauri<InstallResultDto>(\n            'install_local_selection',\n            {\n              basePath: localCandidatesBasePath,\n              subpath: candidate.subpath,\n              name: localName.trim() || undefined,\n            },\n          )\n          await applySelectedAddModalTags(created.skill_id, created.name)\n          {\n            const selectedInstalledIds = tools\n              .filter((tool) => syncTargets[tool.id] && isInstalled(tool.id))\n              .map((t) => t.id)\n            const targets = uniqueToolIdsBySkillsDir(selectedInstalledIds)\n              .map((id) => tools.find((t) => t.id === id))\n              .filter(Boolean) as ToolOption[]\n            if (targets.length === 0) {\n              collectedErrors.push({\n                title: t('errors.unsyncedTitle', { name: created.name }),\n                message: t('errors.noSyncTargets'),\n              })\n            } else {\n              for (let ti = 0; ti < targets.length; ti++) {\n                const tool = targets[ti]\n                setActionMessage(\n                  t('actions.syncStep', {\n                    index: ti + 1,\n                    total: targets.length,\n                    name: created.name,\n                    tool: tool.label,\n                  }),\n                )\n                try {\n                  await invokeTauri('sync_skill_to_tool', {\n                    sourcePath: created.central_path,\n                    skillId: created.skill_id,\n                    tool: tool.id,\n                    name: created.name,\n                    overwriteIfSameContent: true,\n                  })\n                } catch (err) {\n                  const raw = err instanceof Error ? err.message : String(err)\n                  collectedErrors.push({\n                    title: t('errors.syncFailedTitle', {\n                      name: created.name,\n                      tool: tool.label,\n                    }),\n                    message: raw,\n                  })\n                }\n              }\n            }\n          }\n        } catch (err) {\n          const raw = err instanceof Error ? err.message : String(err)\n          collectedErrors.push({\n            title: t('errors.importFailedTitle', { name: candidate.name }),\n            message: raw,\n          })\n        }\n      }\n\n      setShowLocalPickModal(false)\n      setLocalCandidates([])\n      setLocalCandidateSelected({})\n      setLocalCandidatesBasePath('')\n      setLocalPath('')\n      setLocalName('')\n      setActionMessage(t('status.selectedSkillsInstalled'))\n      setSuccessToastMessage(t('status.selectedSkillsInstalled'))\n      setActionMessage(null)\n      setShowAddModal(false)\n      await loadManagedSkills()\n      await loadTags()\n      if (collectedErrors.length > 0) showActionErrors(collectedErrors)\n    } finally {\n      setLoading(false)\n      setLoadingStartAt(null)\n    }\n  }\n\n  const handleInstallSelectedCandidates = async () => {\n    const selected = gitCandidates.filter((c) => gitCandidateSelected[c.subpath])\n    if (selected.length === 0) {\n      setError(t('errors.selectAtLeastOneSkill'))\n      return\n    }\n    const duplicated = selected.find((c) => isSkillNameTaken(c.name))\n    if (duplicated) {\n      setError(t('errors.skillAlreadyExists', { name: duplicated.name }))\n      return\n    }\n    if (selected.length > 1 && gitName.trim()) {\n      setError(t('errors.multiSelectNoCustomName'))\n      return\n    }\n\n    setLoading(true)\n    setLoadingStartAt(Date.now())\n    setError(null)\n    try {\n      const collectedErrors: { title: string; message: string }[] = []\n      for (let i = 0; i < selected.length; i++) {\n        const candidate = selected[i]\n        setActionMessage(\n          t('actions.importStep', {\n            index: i + 1,\n            total: selected.length,\n            name: candidate.name,\n          }),\n        )\n        try {\n          const created = await invokeTauri<InstallResultDto>(\n            'install_git_selection',\n            {\n            repoUrl: gitCandidatesRepoUrl,\n            subpath: candidate.subpath,\n            name: gitName.trim() || undefined,\n            },\n          )\n          await applySelectedAddModalTags(created.skill_id, created.name)\n          {\n            const selectedInstalledIds = tools\n              .filter((tool) => syncTargets[tool.id] && isInstalled(tool.id))\n              .map((t) => t.id)\n            const targets = uniqueToolIdsBySkillsDir(selectedInstalledIds)\n              .map((id) => tools.find((t) => t.id === id))\n              .filter(Boolean) as ToolOption[]\n            if (targets.length === 0) {\n              collectedErrors.push({\n                title: t('errors.unsyncedTitle', { name: created.name }),\n                message: t('errors.noSyncTargets'),\n              })\n            } else {\n              for (let ti = 0; ti < targets.length; ti++) {\n                const tool = targets[ti]\n                setActionMessage(\n                  t('actions.syncStep', {\n                    index: ti + 1,\n                    total: targets.length,\n                    name: created.name,\n                    tool: tool.label,\n                  }),\n                )\n                try {\n                  await invokeTauri('sync_skill_to_tool', {\n                    sourcePath: created.central_path,\n                    skillId: created.skill_id,\n                    tool: tool.id,\n                    name: created.name,\n                    overwriteIfSameContent: true,\n                  })\n                } catch (err) {\n                  const raw = err instanceof Error ? err.message : String(err)\n                  collectedErrors.push({\n                    title: t('errors.syncFailedTitle', {\n                      name: created.name,\n                      tool: tool.label,\n                    }),\n                    message: raw,\n                  })\n                }\n              }\n            }\n          }\n        } catch (err) {\n          const raw = err instanceof Error ? err.message : String(err)\n          collectedErrors.push({\n            title: t('errors.importFailedTitle', { name: candidate.name }),\n            message: raw,\n          })\n        }\n      }\n\n      setShowGitPickModal(false)\n      setGitCandidates([])\n      setGitCandidateSelected({})\n      setGitCandidatesRepoUrl('')\n      setGitUrl('')\n      setGitName('')\n      setActionMessage(t('status.selectedSkillsInstalled'))\n      setSuccessToastMessage(t('status.selectedSkillsInstalled'))\n      setActionMessage(null)\n      setShowGitPickModal(false)\n      setGitCandidates([])\n      setGitCandidateSelected({})\n      setGitCandidatesRepoUrl('')\n      setShowAddModal(false)\n      await loadManagedSkills()\n      await loadTags()\n      if (collectedErrors.length > 0) showActionErrors(collectedErrors)\n    } finally {\n      setLoading(false)\n      setLoadingStartAt(null)\n    }\n  }\n\n  const handleDeleteManaged = async (skill: ManagedSkill) => {\n    setLoading(true)\n    setLoadingStartAt(Date.now())\n    setActionMessage(t('actions.removing', { name: skill.name }))\n    setError(null)\n    try {\n      await invokeTauri('delete_managed_skill', { skillId: skill.id })\n      setActionMessage(t('status.skillRemoved'))\n      setSuccessToastMessage(t('status.skillRemoved'))\n      setActionMessage(null)\n      setSkillScopeState((prev) => {\n        const next = { ...prev }\n        delete next[skill.id]\n        return next\n      })\n      await loadManagedSkills()\n      await loadTags()\n      setPendingDeleteId(null)\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err))\n    } finally {\n      setLoading(false)\n      setLoadingStartAt(null)\n    }\n  }\n\n  const handleSyncAllManagedToTools = useCallback(\n    async (toolIds: string[]) => {\n      if (managedSkills.length === 0) return\n      const installedIds = uniqueToolIdsBySkillsDir(\n        toolIds.filter((id) => isInstalled(id)),\n      )\n      if (installedIds.length === 0) return\n\n      setLoading(true)\n      setLoadingStartAt(Date.now())\n      setError(null)\n      try {\n        const collectedErrors: { title: string; message: string }[] = []\n        for (let si = 0; si < managedSkills.length; si++) {\n          const skill = managedSkills[si]\n          const skillScope = getSkillScope(skill)\n          const projects = getSkillProjects(skill)\n          for (let ti = 0; ti < installedIds.length; ti++) {\n            const toolId = installedIds[ti]\n            const toolLabel = tools.find((t) => t.id === toolId)?.label ?? toolId\n            if (skillScope === 'project') {\n              if (!toolSupportsProjectScope(toolId)) continue\n              if (projects.length === 0) continue\n            }\n            setActionMessage(\n              t('actions.syncStep', {\n                index: si + 1,\n                total: managedSkills.length,\n                name: skill.name,\n                tool: toolLabel,\n              }),\n            )\n            try {\n              if (skillScope === 'project') {\n                for (const projectPath of projects) {\n                  await invokeTauri('sync_skill_to_tool', {\n                    sourcePath: skill.central_path,\n                    skillId: skill.id,\n                    tool: toolId,\n                    name: skill.name,\n                    overwriteIfSameContent: true,\n                    scope: 'project',\n                    projectPath,\n                  })\n                }\n              } else {\n                await invokeTauri('sync_skill_to_tool', {\n                  sourcePath: skill.central_path,\n                  skillId: skill.id,\n                  tool: toolId,\n                  name: skill.name,\n                  overwriteIfSameContent: true,\n                  scope: 'global',\n                })\n              }\n            } catch (err) {\n              const raw = err instanceof Error ? err.message : String(err)\n              if (raw.startsWith('TOOL_NOT_INSTALLED|') || raw.startsWith('TOOL_NOT_WRITABLE|')) {\n                continue\n              }\n              collectedErrors.push({\n                title: t('errors.syncFailedTitle', {\n                  name: skill.name,\n                  tool: toolLabel,\n                }),\n                message: raw,\n              })\n            }\n          }\n        }\n        setActionMessage(t('status.syncCompleted'))\n        setSuccessToastMessage(t('status.syncCompleted'))\n        setActionMessage(null)\n        await loadManagedSkills()\n        if (collectedErrors.length > 0) showActionErrors(collectedErrors)\n      } finally {\n        setLoading(false)\n        setLoadingStartAt(null)\n      }\n    },\n    [\n      invokeTauri,\n      getSkillProjects,\n      getSkillScope,\n      isInstalled,\n      loadManagedSkills,\n      managedSkills,\n      showActionErrors,\n      t,\n      tools,\n      toolSupportsProjectScope,\n      uniqueToolIdsBySkillsDir,\n    ],\n  )\n\n  const handleSyncAllNewTools = useCallback(() => {\n    if (!toolStatus) return\n    setSyncTargets((prev) => {\n      const next = { ...prev }\n      for (const id of toolStatus.newly_installed) {\n        const shared = sharedToolIdsByToolId[id] ?? [id]\n        for (const sid of shared) next[sid] = true\n      }\n      return next\n    })\n    setShowNewToolsModal(false)\n    void handleSyncAllManagedToTools(toolStatus.newly_installed)\n  }, [handleSyncAllManagedToTools, sharedToolIdsByToolId, toolStatus])\n\n  const handleOpenScope = useCallback((skill: ManagedSkill) => {\n    setScopeModalSkill(skill)\n  }, [])\n\n  const handleCloseScope = useCallback(() => {\n    if (!loading) setScopeModalSkill(null)\n  }, [loading])\n\n  const setSkillScopeAndProjects = useCallback(\n    (skillId: string, scope: 'global' | 'project', projects: string[]) => {\n      const uniqueProjects = Array.from(new Set(projects.filter(Boolean)))\n      setSkillScopeState((prev) => ({\n        ...prev,\n        [skillId]: {\n          scope,\n          projects: uniqueProjects,\n        },\n      }))\n    },\n    [],\n  )\n\n  const handleScopeChange = useCallback(\n    async (nextScope: 'global' | 'project', nextProjects: string[]) => {\n      const skill = scopeModalSkill\n      if (!skill || loading) return\n      const projects = Array.from(new Set(nextProjects.filter(Boolean)))\n      const hasStaleTargets = skill.targets.some(\n        (target) =>\n          (target.scope ?? 'global') !== nextScope ||\n          (nextScope === 'project' &&\n            (target.scope ?? 'global') === 'project' &&\n            (!target.project_path || !projects.includes(target.project_path))),\n      )\n      const activeTargets = skill.targets.filter(\n        (target) =>\n          (target.scope ?? 'global') !== nextScope ||\n          (nextScope === 'project' &&\n            (target.scope ?? 'global') === 'project' &&\n            (!target.project_path || !projects.includes(target.project_path))),\n      )\n      const existingProjects = getSkillProjects(skill)\n      const projectsChanged =\n        projects.length !== existingProjects.length ||\n        projects.some((project) => !existingProjects.includes(project))\n      if (getSkillScope(skill) === nextScope && !hasStaleTargets && !projectsChanged) {\n        return\n      }\n\n      setLoading(true)\n      setLoadingStartAt(Date.now())\n      setError(null)\n      try {\n        const seen = new Set<string>()\n        for (const target of activeTargets) {\n          const targetScope = target.scope ?? 'global'\n          const key = `${target.tool}|${targetScope}|${target.project_path ?? ''}`\n          if (seen.has(key)) continue\n          seen.add(key)\n          await invokeTauri('unsync_skill_from_tool', {\n            skillId: skill.id,\n            tool: target.tool,\n            scope: targetScope,\n            projectPath: target.project_path ?? undefined,\n          })\n        }\n        if (nextScope === 'project' && projects.length > 0) {\n          for (const toolId of installedProjectToolIds) {\n            for (const projectPath of projects) {\n              await invokeTauri('sync_skill_to_tool', {\n                sourcePath: skill.central_path,\n                skillId: skill.id,\n                tool: toolId,\n                name: skill.name,\n                overwriteIfSameContent: true,\n                scope: 'project',\n                projectPath,\n              })\n            }\n          }\n        } else if (nextScope === 'global') {\n          for (const toolId of installedToolIds) {\n            try {\n                await invokeTauri('sync_skill_to_tool', {\n                  sourcePath: skill.central_path,\n                  skillId: skill.id,\n                  tool: toolId,\n                  name: skill.name,\n                  overwriteIfSameContent: true,\n                  scope: 'global',\n                })\n            } catch (err) {\n              const raw = err instanceof Error ? err.message : String(err)\n              if (raw.startsWith('TOOL_NOT_INSTALLED|')) continue\n              throw err\n            }\n          }\n        }\n        await loadManagedSkills()\n        if (nextScope === 'project') {\n          for (const projectPath of projects) {\n            const saved = await invokeTauri<string[]>('save_recent_project', {\n              projectPath,\n            })\n            setRecentProjects(saved)\n          }\n        }\n      } catch (err) {\n        setError(err instanceof Error ? err.message : String(err))\n        return\n      } finally {\n        setLoading(false)\n        setLoadingStartAt(null)\n      }\n\n      setSkillScopeAndProjects(\n        skill.id,\n        nextScope,\n        nextScope === 'project' ? projects : [],\n      )\n      setScopeModalSkill(null)\n    },\n    [\n      getSkillProjects,\n      getSkillScope,\n      installedToolIds,\n      installedProjectToolIds,\n      invokeTauri,\n      loadManagedSkills,\n      loading,\n      scopeModalSkill,\n      setSkillScopeAndProjects,\n    ],\n  )\n\n  const handlePickProject = useCallback(async () => {\n    if (!scopeModalSkill) return undefined\n    try {\n      if (!isTauri) throw new Error(t('errors.notTauri'))\n      const { open } = await import('@tauri-apps/plugin-dialog')\n      const selected = await open({\n        directory: true,\n        multiple: false,\n        title: t('projectSync.selectProjectTitle'),\n      })\n      if (!selected || Array.isArray(selected)) return undefined\n      return selected\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err))\n      return undefined\n    }\n  }, [isTauri, scopeModalSkill, t])\n\n  const runToggleToolForSkill = useCallback(\n    async (skill: ManagedSkill, toolId: string) => {\n      if (loading) return\n      const toolLabel = tools.find((t) => t.id === toolId)?.label ?? toolId\n      const skillScope = getSkillScope(skill)\n      const projects = getSkillProjects(skill)\n      if (skillScope === 'project') {\n        if (!toolSupportsProjectScope(toolId)) {\n          setError(t('projectSync.unsupportedTool', { tool: toolLabel }))\n          return\n        }\n        if (projects.length === 0) {\n          setError(t('projectSync.noProjectsForSync'))\n          setScopeModalSkill(skill)\n          return\n        }\n      }\n      const matchingTargets = skill.targets.filter(\n        (target) => target.tool === toolId && (target.scope ?? 'global') === skillScope,\n      )\n      const synced = matchingTargets.length > 0\n\n      setLoading(true)\n      setLoadingStartAt(Date.now())\n      setError(null)\n      try {\n        if (synced) {\n          setActionMessage(\n            t('actions.unsyncing', { name: skill.name, tool: toolLabel }),\n          )\n          if (skillScope === 'project') {\n            const targetProjects = Array.from(\n              new Set(\n                matchingTargets\n                  .map((target) => target.project_path)\n                  .filter((path): path is string => Boolean(path)),\n              ),\n            )\n            for (const projectPath of targetProjects) {\n              await invokeTauri('unsync_skill_from_tool', {\n                skillId: skill.id,\n                tool: toolId,\n                scope: 'project',\n                projectPath,\n              })\n            }\n          } else {\n            await invokeTauri('unsync_skill_from_tool', {\n              skillId: skill.id,\n              tool: toolId,\n              scope: 'global',\n            })\n          }\n        } else {\n          setActionMessage(\n            t('actions.syncing', { name: skill.name, tool: toolLabel }),\n          )\n          if (skillScope === 'project') {\n            for (const projectPath of projects) {\n              await invokeTauri('sync_skill_to_tool', {\n                sourcePath: skill.central_path,\n                skillId: skill.id,\n                tool: toolId,\n                name: skill.name,\n                overwriteIfSameContent: true,\n                scope: 'project',\n                projectPath,\n              })\n            }\n          } else {\n            await invokeTauri('sync_skill_to_tool', {\n              sourcePath: skill.central_path,\n              skillId: skill.id,\n              tool: toolId,\n              name: skill.name,\n              overwriteIfSameContent: true,\n              scope: 'global',\n            })\n          }\n        }\n        const statusText = synced\n          ? t('status.syncDisabled')\n          : t('status.syncEnabled')\n        setActionMessage(statusText)\n        setSuccessToastMessage(statusText)\n        setActionMessage(null)\n        await loadManagedSkills()\n      } catch (err) {\n        const raw = err instanceof Error ? err.message : String(err)\n        if (raw.startsWith('TARGET_EXISTS|')) {\n          const targetPath = raw.split('|')[1] ?? ''\n          setError(t('errors.targetExistsDetail', { path: targetPath }))\n        } else if (raw.startsWith('TOOL_NOT_INSTALLED|')) {\n          setError(t('errors.toolNotInstalled'))\n        } else if (raw.startsWith('TOOL_NOT_WRITABLE|')) {\n          const parts = raw.split('|')\n          setError(t('errors.toolNotWritable', { tool: parts[1] ?? '', path: parts[2] ?? '' }))\n        } else {\n          setError(raw)\n        }\n      } finally {\n        setLoading(false)\n        setLoadingStartAt(null)\n      }\n    },\n    [\n      getSkillProjects,\n      getSkillScope,\n      invokeTauri,\n      loadManagedSkills,\n      loading,\n      t,\n      tools,\n      toolSupportsProjectScope,\n    ],\n  )\n\n  const handleToggleToolForSkill = useCallback(\n    (skill: ManagedSkill, toolId: string) => {\n      if (loading) return\n      const skillScope = getSkillScope(skill)\n      const currentTarget = skill.targets.find(\n        (target) => target.tool === toolId && (target.scope ?? 'global') === skillScope,\n      )\n      const shared = currentTarget\n        ? skill.targets\n            .filter(\n              (target) =>\n                (target.scope ?? 'global') === skillScope &&\n                target.target_path === currentTarget.target_path,\n            )\n            .map((target) => target.tool)\n        : sharedToolIdsByToolId[toolId] ?? null\n      if (shared && shared.length > 1) {\n        setPendingSharedToggle({ skill, toolId, affectedToolIds: shared })\n        return\n      }\n      void runToggleToolForSkill(skill, toolId)\n    },\n    [getSkillScope, loading, runToggleToolForSkill, sharedToolIdsByToolId],\n  )\n\n  const handleUpdateManaged = useCallback(\n    async (skill: ManagedSkill) => {\n    setLoading(true)\n    setLoadingStartAt(Date.now())\n    setError(null)\n    try {\n      setActionMessage(t('actions.updating', { name: skill.name }))\n      await invokeTauri<UpdateResultDto>('update_managed_skill', { skillId: skill.id })\n      const updatedText = t('status.updated', { name: skill.name })\n      setActionMessage(updatedText)\n      setSuccessToastMessage(updatedText)\n      setActionMessage(null)\n      await loadManagedSkills()\n    } catch (err) {\n      const raw = err instanceof Error ? err.message : String(err)\n      setError(raw)\n    } finally {\n      setLoading(false)\n      setLoadingStartAt(null)\n    }\n    },\n    [invokeTauri, loadManagedSkills, t],\n  )\n\n  const handleUpdateSkill = useCallback(\n    (skill: ManagedSkill) => {\n      void handleUpdateManaged(skill)\n    },\n    [handleUpdateManaged],\n  )\n\n  const handleSharedCancel = useCallback(() => {\n    if (loading) return\n    setPendingSharedToggle(null)\n  }, [loading])\n\n  const handleSharedConfirm = useCallback(() => {\n    if (!pendingSharedToggle) return\n    const payload = pendingSharedToggle\n    setPendingSharedToggle(null)\n    void runToggleToolForSkill(payload.skill, payload.toolId)\n  }, [pendingSharedToggle, runToggleToolForSkill])\n\n  const pendingSharedLabels = useMemo(() => {\n    if (!pendingSharedToggle) return null\n    const toolId = pendingSharedToggle.toolId\n    const shared = pendingSharedToggle.affectedToolIds ?? sharedToolIdsByToolId[toolId] ?? []\n    const others = shared.filter((id) => id !== toolId)\n    return {\n      toolLabel: toolLabelById[toolId] ?? toolId,\n      otherLabels: others.map((id) => toolLabelById[id] ?? id).join(', '),\n    }\n  }, [pendingSharedToggle, sharedToolIdsByToolId, toolLabelById])\n\n  const currentScopeModalSkill = useMemo(() => {\n    if (!scopeModalSkill) return null\n    return managedSkills.find((skill) => skill.id === scopeModalSkill.id) ?? scopeModalSkill\n  }, [managedSkills, scopeModalSkill])\n\n  return (\n    <div className=\"skills-app\">\n      <Toaster\n        position=\"top-right\"\n        richColors\n        toastOptions={{ duration: 1800 }}\n      />\n      <LoadingOverlay\n        loading={loading}\n        actionMessage={actionMessage}\n        loadingStartAt={loadingStartAt}\n        onCancel={handleCancelLoading}\n        t={t}\n      />\n\n      <Header\n        language={language}\n        loading={loading}\n        activeView={activeView}\n        onToggleLanguage={toggleLanguage}\n        onOpenSettings={handleOpenSettings}\n        onViewChange={handleViewChange}\n        t={t}\n      />\n\n      <main className=\"skills-main\">\n        {activeView === 'detail' && detailSkill ? (\n          <SkillDetailView\n            skill={detailSkill}\n            onBack={handleBackToList}\n            invokeTauri={invokeTauri}\n            formatRelative={formatRelative}\n            t={t}\n          />\n        ) : activeView === 'myskills' ? (\n          <div className=\"dashboard-stack\">\n            <FilterBar\n              sortBy={sortBy}\n              searchQuery={searchQuery}\n              scopeFilter={scopeFilter}\n              tags={tags}\n              selectedTagIds={selectedTagIds}\n              includeUntagged={includeUntagged}\n              untaggedCount={untaggedCount}\n              totalCount={visibleSkills.length}\n              onSortChange={handleSortChange}\n              onSearchChange={handleSearchChange}\n              onScopeFilterChange={handleScopeFilterChange}\n              onToggleTag={handleToggleTagFilter}\n              onToggleUntagged={handleToggleUntaggedFilter}\n              onClearTags={handleClearTagFilters}\n              onManageTags={handleOpenTagsPage}\n              t={t}\n            />\n            <SkillsList\n              plan={plan}\n              visibleSkills={visibleSkills}\n              installedTools={installedTools}\n              loading={loading}\n              getGithubInfo={getGithubInfo}\n              getSkillSourceLabel={getSkillSourceLabel}\n              formatRelative={formatRelative}\n              onReviewImport={handleReviewImport}\n              onUpdateSkill={handleUpdateSkill}\n              onDeleteSkill={handleDeletePrompt}\n              onToggleTool={handleToggleToolForSkill}\n              onOpenScope={handleOpenScope}\n              onOpenDetail={handleOpenDetail}\n              onEditTags={handleOpenEditTags}\n              getSkillScope={getSkillScope}\n              getSkillProjects={getSkillProjects}\n              t={t}\n            />\n          </div>\n        ) : activeView === 'tags' ? (\n          <TagsPage\n            tags={tags}\n            untaggedCount={untaggedCount}\n            loading={loading}\n            formatRelative={formatRelative}\n            onBack={handleBackToList}\n            onReviewUntagged={handleReviewUntagged}\n            onViewTag={handleViewTag}\n            onCreateTag={handleCreateTag}\n            onRenameTag={handleRenameTag}\n            onDeleteTag={handleDeleteTag}\n            t={t}\n          />\n        ) : activeView === 'settings' ? (\n          <SettingsPage\n            isTauri={isTauri}\n            language={language}\n            storagePath={storagePath}\n            gitCacheCleanupDays={gitCacheCleanupDays}\n            gitCacheTtlSecs={gitCacheTtlSecs}\n            themePreference={themePreference}\n            onPickStoragePath={handlePickStoragePath}\n            onToggleLanguage={toggleLanguage}\n            onThemeChange={handleThemeChange}\n            onGitCacheCleanupDaysChange={handleGitCacheCleanupDaysChange}\n            onGitCacheTtlSecsChange={handleGitCacheTtlSecsChange}\n            onClearGitCacheNow={handleClearGitCacheNow}\n            githubToken={githubToken}\n            onGithubTokenChange={handleGithubTokenChange}\n            onBack={handleCloseSettings}\n            t={t}\n          />\n        ) : (\n          <ExplorePage\n            featuredSkills={featuredSkills}\n            featuredLoading={featuredLoading}\n            exploreFilter={exploreFilter}\n            searchResults={searchResults}\n            searchLoading={searchLoading}\n            managedSkills={managedSkills}\n            loading={loading}\n            onExploreFilterChange={handleExploreFilterChange}\n            onInstallSkill={handleExploreInstall}\n            onOpenManualAdd={handleOpenAdd}\n            t={t}\n          />\n        )}\n      </main>\n\n      <AddSkillModal\n        open={showAddModal}\n        loading={loading}\n        canClose={!loading}\n        addModalTab={addModalTab}\n        localPath={localPath}\n        localName={localName}\n        gitUrl={gitUrl}\n        gitName={gitName}\n        tags={tags}\n        selectedTagIds={addModalTagIds}\n        syncTargets={syncTargets}\n        installedTools={installedTools}\n        toolStatus={toolStatus}\n        onRequestClose={handleCloseAdd}\n        onTabChange={setAddModalTab}\n        onLocalPathChange={setLocalPath}\n        onPickLocalPath={handlePickLocalPath}\n        onLocalNameChange={setLocalName}\n        onGitUrlChange={setGitUrl}\n        onGitNameChange={setGitName}\n        onToggleTag={handleToggleAddModalTag}\n        onSyncTargetChange={handleSyncTargetChange}\n        onSubmit={addModalTab === 'local' ? handleCreateLocal : handleCreateGit}\n        t={t}\n      />\n\n      <EditSkillTagsModal\n        key={\n          tagEditorSkill\n            ? `${tagEditorSkill.id}-${tagEditorSkill.tags.map((tag) => tag.id).join('-')}`\n            : 'edit-tags'\n        }\n        open={Boolean(tagEditorSkill)}\n        loading={loading}\n        skill={\n          tagEditorSkill\n            ? managedSkills.find((skill) => skill.id === tagEditorSkill.id) ?? tagEditorSkill\n            : null\n        }\n        tags={tags}\n        onRequestClose={handleCloseEditTags}\n        onSave={handleSaveSkillTags}\n        t={t}\n      />\n\n      {showImportModal && plan ? (\n        <ImportModal\n          open={showImportModal}\n          loading={loading}\n          plan={plan}\n          selected={selected}\n          variantChoice={variantChoice}\n          onRequestClose={handleCloseImport}\n          onToggleGroup={handleToggleGroup}\n          onSelectVariant={handleSelectVariant}\n          onImport={handleImport}\n          t={t}\n        />\n      ) : null}\n\n      <SharedDirModal\n        open={Boolean(pendingSharedToggle)}\n        loading={loading}\n        toolLabel={pendingSharedLabels?.toolLabel ?? ''}\n        otherLabels={pendingSharedLabels?.otherLabels ?? ''}\n        onRequestClose={handleSharedCancel}\n        onConfirm={handleSharedConfirm}\n        t={t}\n      />\n\n      <ScopeSyncModal\n        key={\n          currentScopeModalSkill\n            ? `${currentScopeModalSkill.id}-${getSkillScope(currentScopeModalSkill)}`\n            : 'scope-modal'\n        }\n        open={Boolean(currentScopeModalSkill)}\n        loading={loading}\n        skill={currentScopeModalSkill}\n        scope={\n          currentScopeModalSkill ? getSkillScope(currentScopeModalSkill) : 'global'\n        }\n        projects={\n          currentScopeModalSkill ? getSkillProjects(currentScopeModalSkill) : []\n        }\n        recentProjects={recentProjects}\n        onRequestClose={handleCloseScope}\n        onScopeChange={handleScopeChange}\n        onPickProject={handlePickProject}\n        t={t}\n      />\n\n      <NewToolsModal\n        open={Boolean(showNewToolsModal && newlyInstalledToolsText)}\n        loading={loading}\n        toolsLabelText={newlyInstalledToolsText}\n        onLater={handleCloseNewTools}\n        onSyncAll={handleSyncAllNewTools}\n        t={t}\n      />\n\n      <DeleteModal\n        open={Boolean(pendingDeleteId)}\n        loading={loading}\n        skillName={pendingDeleteSkill?.name ?? null}\n        onRequestClose={handleCloseDelete}\n        onConfirm={() => {\n          if (pendingDeleteSkill) void handleDeleteManaged(pendingDeleteSkill)\n        }}\n        t={t}\n      />\n\n      {pendingDeleteTag ? (\n        <div className=\"modal-backdrop\" onClick={loading ? undefined : handleCloseDeleteTag}>\n          <div\n            className=\"modal modal-delete tag-delete-modal\"\n            onClick={(event) => event.stopPropagation()}\n          >\n            <div className=\"modal-header\">\n              <div className=\"modal-title\">{t('deleteTagTitle')}</div>\n              <button\n                className=\"modal-close\"\n                type=\"button\"\n                onClick={handleCloseDeleteTag}\n                disabled={loading}\n              >\n                ×\n              </button>\n            </div>\n            <div className=\"modal-body tag-delete-body\">\n              {t('deleteTagConfirm', {\n                name: pendingDeleteTag.name,\n                count: pendingDeleteTag.skill_count,\n              })}\n            </div>\n            <div className=\"modal-footer\">\n              <button\n                className=\"btn btn-secondary\"\n                type=\"button\"\n                onClick={handleCloseDeleteTag}\n                disabled={loading}\n              >\n                {t('cancel')}\n              </button>\n              <button\n                className=\"btn btn-danger\"\n                type=\"button\"\n                onClick={() => void handleConfirmDeleteTag()}\n                disabled={loading}\n              >\n                {t('deleteAction')}\n              </button>\n            </div>\n          </div>\n        </div>\n      ) : null}\n\n      {showLocalPickModal ? (\n        <LocalPickModal\n          open={showLocalPickModal}\n          loading={loading}\n          localCandidates={localCandidates}\n          localCandidateSelected={localCandidateSelected}\n          onRequestClose={handleCloseLocalPick}\n          onCancel={handleCancelLocalPick}\n          onToggleCandidate={handleToggleLocalCandidate}\n          onInstall={handleInstallSelectedLocalCandidates}\n          t={t}\n        />\n      ) : null}\n\n      {showGitPickModal ? (\n        <GitPickModal\n          open={showGitPickModal}\n          loading={loading}\n          gitCandidates={gitCandidates}\n          gitCandidateSelected={gitCandidateSelected}\n          onRequestClose={handleCloseGitPick}\n          onCancel={handleCancelGitPick}\n          onToggleCandidate={handleToggleGitCandidate}\n          onInstall={handleInstallSelectedCandidates}\n          t={t}\n        />\n      ) : null}\n\n      {updateAvailableVersion && (\n        <div className=\"modal-backdrop\" onClick={updateInstalling ? undefined : handleDismissUpdate}>\n          <div\n            className=\"modal update-modal\"\n            role=\"dialog\"\n            aria-modal=\"true\"\n            onClick={(e) => e.stopPropagation()}\n          >\n            {!updateInstalling && !updateDone && (\n              <button\n                className=\"modal-close update-modal-close\"\n                type=\"button\"\n                onClick={handleDismissUpdate}\n                aria-label={t('close')}\n              >\n                ✕\n              </button>\n            )}\n            <div className=\"update-modal-body\">\n              <div className=\"update-modal-title\">\n                {updateDone ? t('updateInstalledRestart') : t('updateAvailable')}\n              </div>\n              {!updateDone && (\n                <div className=\"update-modal-text\">\n                  {t('updateBannerText', { version: updateAvailableVersion })}\n                </div>\n              )}\n              {!updateDone && updateBody && (\n                <div className=\"update-modal-notes\">\n                  <Markdown remarkPlugins={[remarkGfm]}>{updateBody}</Markdown>\n                </div>\n              )}\n            </div>\n            <div className=\"update-modal-actions\">\n              {updateDone ? (\n                <button\n                  className=\"btn btn-primary\"\n                  type=\"button\"\n                  onClick={handleDismissUpdate}\n                >\n                  {t('done')}\n                </button>\n              ) : (\n                <>\n                  <button\n                    className=\"btn btn-primary\"\n                    type=\"button\"\n                    disabled={updateInstalling}\n                    onClick={handleUpdateNow}\n                  >\n                    {updateInstalling ? t('installingUpdate') : t('updateNow')}\n                  </button>\n                  {!updateInstalling && (\n                    <button\n                      className=\"btn btn-secondary\"\n                      type=\"button\"\n                      onClick={handleDismissUpdateForever}\n                    >\n                      {t('updateBannerDismiss')}\n                    </button>\n                  )}\n                </>\n              )}\n            </div>\n          </div>\n        </div>\n      )}\n      </div>\n  )\n}\n\nexport default App\n"
  },
  {
    "path": "src/components/Layout.tsx",
    "content": "import React from 'react';\nimport { Link, Outlet, useLocation } from 'react-router-dom';\nimport { Home, Settings, Box } from 'lucide-react';\nimport { clsx } from 'clsx';\nimport { useTranslation } from 'react-i18next';\n\nconst NavItem = ({ to, icon: Icon, label }: { to: string; icon: React.ElementType; label: string }) => {\n  const location = useLocation();\n  const isActive = location.pathname === to;\n  \n  return (\n    <Link\n      to={to}\n      className={clsx(\n        \"flex items-center gap-3 px-3 py-2 rounded-md transition-colors\",\n        isActive \n          ? \"bg-blue-600 text-white\" \n          : \"text-gray-400 hover:bg-gray-800 hover:text-white\"\n      )}\n    >\n      <Icon size={20} />\n      <span>{label}</span>\n    </Link>\n  );\n};\n\nexport const Layout = () => {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"flex h-screen bg-gray-900 text-white\">\n      {/* Sidebar */}\n      <aside className=\"w-64 border-r border-gray-800 flex flex-col p-4\">\n        <div className=\"flex items-center gap-2 mb-8 px-2\">\n          <div className=\"w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center\">\n            <Box className=\"text-white\" size={20} />\n          </div>\n          <span className=\"font-bold text-xl\">{t('appName')}</span>\n        </div>\n        \n        <nav className=\"flex-1 space-y-1\">\n          <NavItem to=\"/\" icon={Home} label={t('layout.navDashboard')} />\n          <NavItem to=\"/skills\" icon={Box} label={t('layout.navSkills')} />\n          <NavItem to=\"/settings\" icon={Settings} label={t('layout.navSettings')} />\n        </nav>\n\n        <div className=\"text-xs text-gray-600 px-2\">\n          {t('layout.versionLabel')}\n        </div>\n      </aside>\n\n      {/* Main Content */}\n      <main className=\"flex-1 overflow-auto bg-gray-950 p-6\">\n        <Outlet />\n      </main>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/skills/ExplorePage.tsx",
    "content": "import { memo, useMemo } from 'react'\nimport { Plus, Search, Star } from 'lucide-react'\nimport type { TFunction } from 'i18next'\nimport type { FeaturedSkillDto, ManagedSkill, OnlineSkillDto } from './types'\n\ntype ExplorePageProps = {\n  featuredSkills: FeaturedSkillDto[]\n  featuredLoading: boolean\n  exploreFilter: string\n  searchResults: OnlineSkillDto[]\n  searchLoading: boolean\n  managedSkills: ManagedSkill[]\n  loading: boolean\n  onExploreFilterChange: (value: string) => void\n  onInstallSkill: (sourceUrl: string, skillName?: string) => void\n  onOpenManualAdd: () => void\n  t: TFunction\n}\n\nfunction formatCount(n: number): string {\n  if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`\n  if (n >= 1000) return `${(n / 1000).toFixed(1)}K`\n  return String(n)\n}\n\nconst ExplorePage = ({\n  featuredSkills,\n  featuredLoading,\n  exploreFilter,\n  searchResults,\n  searchLoading,\n  managedSkills,\n  loading,\n  onExploreFilterChange,\n  onInstallSkill,\n  onOpenManualAdd,\n  t,\n}: ExplorePageProps) => {\n  const filteredSkills = useMemo(() => {\n    if (!exploreFilter.trim()) return featuredSkills\n    const lower = exploreFilter.toLowerCase()\n    return featuredSkills.filter(\n      (s) =>\n        s.name.toLowerCase().includes(lower) ||\n        s.summary.toLowerCase().includes(lower),\n    )\n  }, [featuredSkills, exploreFilter])\n\n  const deduplicatedResults = useMemo(() => {\n    const featuredNames = new Set(filteredSkills.map((s) => s.name.toLowerCase()))\n    return searchResults.filter((s) => !featuredNames.has(s.name.toLowerCase()))\n  }, [searchResults, filteredSkills])\n\n  const isSearchActive = exploreFilter.trim().length >= 2\n\n  // Check if a skill is already installed by matching name + source (case-insensitive)\n  const installedSkillKeys = useMemo(() => {\n    const keys = new Set<string>()\n    for (const skill of managedSkills) {\n      const source = (skill.source_ref ?? '')\n        .replace('https://github.com/', '')\n        .replace(/\\.git$/, '')\n        .split('/tree/')[0]\n        .toLowerCase()\n      keys.add(`${skill.name.toLowerCase()}|${source}`)\n    }\n    return keys\n  }, [managedSkills])\n\n  const isInstalled = (skillName: string, source: string) => {\n    const normalizedSource = source\n      .replace('https://github.com/', '')\n      .replace(/\\.git$/, '')\n      .split('/tree/')[0]\n      .toLowerCase()\n    return installedSkillKeys.has(`${skillName.toLowerCase()}|${normalizedSource}`)\n  }\n\n  return (\n    <div className=\"explore-page\">\n      <div className=\"explore-hero\">\n        <div className=\"explore-search-row\">\n          <div className=\"explore-search-wrap\">\n            <Search size={16} className=\"explore-search-icon\" />\n            <input\n              className=\"explore-search-input\"\n              placeholder={t('exploreFilterPlaceholder')}\n              value={exploreFilter}\n              onChange={(e) => onExploreFilterChange(e.target.value)}\n            />\n          </div>\n          <button\n            className=\"btn btn-secondary explore-manual-btn\"\n            type=\"button\"\n            onClick={onOpenManualAdd}\n            disabled={loading}\n          >\n            <Plus size={15} />\n            {t('manualAdd')}\n          </button>\n        </div>\n        <div className=\"explore-source-label\">\n          {t('exploreSourceHint')}\n        </div>\n      </div>\n\n      <div className=\"explore-scroll\">\n        {/* Featured section */}\n        {featuredLoading ? (\n          <div className=\"explore-loading\">{t('exploreLoading')}</div>\n        ) : (\n          <>\n            {isSearchActive && filteredSkills.length > 0 && (\n              <div className=\"explore-section-title\">{t('exploreFeaturedTitle')}</div>\n            )}\n            {filteredSkills.length > 0 ? (\n              <div className=\"explore-grid\">\n                {filteredSkills.map((skill) => {\n                  const installed = isInstalled(skill.name, skill.source_url)\n                  return (\n                    <div key={skill.slug} className=\"explore-card\">\n                      <div className=\"explore-card-top\">\n                        <div className=\"explore-card-info\">\n                          <div className=\"explore-card-name\">{skill.name}</div>\n                          <div className=\"explore-card-author\">\n                            {skill.source_url\n                              .replace('https://github.com/', '')\n                              .split('/tree/')[0]}\n                          </div>\n                        </div>\n                        {installed ? (\n                          <span className=\"explore-btn-installed\">\n                            {t('status.installed')}\n                          </span>\n                        ) : (\n                          <button\n                            className=\"explore-btn-install\"\n                            type=\"button\"\n                            disabled={loading}\n                            onClick={() => onInstallSkill(skill.source_url)}\n                          >\n                            {t('install')}\n                          </button>\n                        )}\n                      </div>\n                      <div className=\"explore-card-desc\">{skill.summary}</div>\n                      <div className=\"explore-card-bottom\">\n                        <div className=\"explore-card-stats\">\n                          <span className=\"explore-stat\">\n                            <Star size={12} />\n                            {formatCount(skill.stars)}\n                          </span>\n                        </div>\n                      </div>\n                    </div>\n                  )\n                })}\n              </div>\n            ) : !isSearchActive ? (\n              <div className=\"explore-empty\">{t('exploreEmpty')}</div>\n            ) : null}\n\n            {/* Online search results */}\n            {isSearchActive && (\n              <>\n                <div className=\"explore-section-title\">{t('exploreOnlineTitle')}</div>\n                {searchLoading ? (\n                  <div className=\"explore-loading\">{t('searchLoading')}</div>\n                ) : deduplicatedResults.length > 0 ? (\n                  <div className=\"explore-grid\">\n                    {deduplicatedResults.map((skill) => {\n                      const installed = isInstalled(skill.name, skill.source_url)\n                      return (\n                        <div key={skill.source} className=\"explore-card\">\n                          <div className=\"explore-card-top\">\n                            <div className=\"explore-card-info\">\n                              <div className=\"explore-card-name\">{skill.name}</div>\n                              <div className=\"explore-card-author\">{skill.source}</div>\n                            </div>\n                            {installed ? (\n                              <span className=\"explore-btn-installed\">\n                                {t('status.installed')}\n                              </span>\n                            ) : (\n                              <button\n                                className=\"explore-btn-install\"\n                                type=\"button\"\n                                disabled={loading}\n                                onClick={() => onInstallSkill(skill.source_url, skill.name)}\n                              >\n                                {t('install')}\n                              </button>\n                            )}\n                          </div>\n                          <div className=\"explore-card-bottom\">\n                            <div className=\"explore-card-stats\">\n                              <span className=\"explore-stat\">\n                                {formatCount(skill.installs)} installs\n                              </span>\n                            </div>\n                          </div>\n                        </div>\n                      )\n                    })}\n                  </div>\n                ) : (\n                  <div className=\"explore-empty\">{t('searchEmpty')}</div>\n                )}\n              </>\n            )}\n          </>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport default memo(ExplorePage)\n"
  },
  {
    "path": "src/components/skills/FilterBar.tsx",
    "content": "import { memo, useEffect, useMemo, useRef, useState } from 'react'\nimport { ArrowUpDown, Check, ChevronDown, Search, Tags } from 'lucide-react'\nimport type { TFunction } from 'i18next'\nimport type { TagWithCountDto } from './types'\n\ntype FilterBarProps = {\n  sortBy: 'updated' | 'name'\n  searchQuery: string\n  scopeFilter: 'all' | 'global' | 'project'\n  tags: TagWithCountDto[]\n  selectedTagIds: number[]\n  includeUntagged: boolean\n  untaggedCount: number\n  totalCount: number\n  onSortChange: (value: 'updated' | 'name') => void\n  onSearchChange: (value: string) => void\n  onScopeFilterChange: (value: 'all' | 'global' | 'project') => void\n  onToggleTag: (tagId: number) => void\n  onToggleUntagged: () => void\n  onClearTags: () => void\n  onManageTags: () => void\n  t: TFunction\n}\n\nconst FilterBar = ({\n  sortBy,\n  searchQuery,\n  scopeFilter,\n  tags,\n  selectedTagIds,\n  includeUntagged,\n  untaggedCount,\n  totalCount,\n  onSortChange,\n  onSearchChange,\n  onScopeFilterChange,\n  onToggleTag,\n  onToggleUntagged,\n  onClearTags,\n  onManageTags,\n  t,\n}: FilterBarProps) => {\n  const [tagMenuOpen, setTagMenuOpen] = useState(false)\n  const [tagQuery, setTagQuery] = useState('')\n  const tagMenuRef = useRef<HTMLDivElement | null>(null)\n  const scopeOptions: { value: 'all' | 'global' | 'project'; label: string }[] = [\n    { value: 'all', label: t('scope.all') },\n    { value: 'global', label: t('scope.global') },\n    { value: 'project', label: t('scope.project') },\n  ]\n  const selectedTagSet = useMemo(() => new Set(selectedTagIds), [selectedTagIds])\n  const selectedCount = selectedTagIds.length + (includeUntagged ? 1 : 0)\n  const filteredTags = useMemo(() => {\n    const query = tagQuery.trim().toLowerCase()\n    if (!query) return tags\n    return tags.filter((tag) => tag.name.toLowerCase().includes(query))\n  }, [tagQuery, tags])\n\n  useEffect(() => {\n    if (!tagMenuOpen) return\n    const handlePointerDown = (event: MouseEvent) => {\n      if (!tagMenuRef.current?.contains(event.target as Node)) {\n        setTagMenuOpen(false)\n      }\n    }\n    document.addEventListener('mousedown', handlePointerDown)\n    return () => document.removeEventListener('mousedown', handlePointerDown)\n  }, [tagMenuOpen])\n\n  return (\n    <div className=\"filter-bar\">\n      <div className=\"filter-title\">\n        {t('allSkills')}（{totalCount}）\n      </div>\n      <div className=\"filter-actions\">\n        <button className=\"btn btn-secondary sort-btn\" type=\"button\">\n          {scopeOptions.find((option) => option.value === scopeFilter)?.label ?? t('scope.all')}\n          <ChevronDown size={12} />\n          <select\n            aria-label={t('scope.filterLabel')}\n            value={scopeFilter}\n            onChange={(event) =>\n              onScopeFilterChange(event.target.value as 'all' | 'global' | 'project')\n            }\n          >\n            {scopeOptions.map((option) => (\n              <option key={option.value} value={option.value}>\n                {option.label}\n              </option>\n            ))}\n          </select>\n        </button>\n        <button className=\"btn btn-secondary sort-btn\" type=\"button\">\n          {sortBy === 'updated' ? t('sortUpdated') : t('sortName')}\n          <ArrowUpDown size={12} />\n          <select\n            aria-label={t('filterSort')}\n            value={sortBy}\n            onChange={(event) => onSortChange(event.target.value as 'updated' | 'name')}\n          >\n            <option value=\"updated\">{t('sortUpdated')}</option>\n            <option value=\"name\">{t('sortName')}</option>\n          </select>\n        </button>\n        <div className=\"tag-filter-wrap\" ref={tagMenuRef}>\n          <button\n            className={`btn btn-secondary tag-filter-btn${selectedCount > 0 ? ' active' : ''}`}\n            type=\"button\"\n            onClick={() => setTagMenuOpen((open) => !open)}\n          >\n            <Tags size={14} />\n            {selectedCount > 0\n              ? t('tagsSelected', { count: selectedCount })\n              : t('tags')}\n            <ChevronDown size={12} />\n          </button>\n          {tagMenuOpen ? (\n            <div className=\"tag-filter-menu\">\n              <div className=\"tag-filter-head\">\n                <span>{t('tags')}</span>\n                <span>{t('matchAny')}</span>\n              </div>\n              <div className=\"tag-filter-search\">\n                <Search size={15} />\n                <input\n                  value={tagQuery}\n                  onChange={(event) => setTagQuery(event.target.value)}\n                  placeholder={t('searchTags')}\n                />\n              </div>\n              <div className=\"tag-filter-options\">\n                <button\n                  className={`tag-filter-option${includeUntagged ? ' selected' : ''}`}\n                  type=\"button\"\n                  onClick={onToggleUntagged}\n                >\n                  <span className=\"tag-check\">{includeUntagged ? <Check size={14} /> : null}</span>\n                  <span>{t('untagged')}</span>\n                  <span className=\"tag-count\">{untaggedCount}</span>\n                </button>\n                {filteredTags.map((tag) => {\n                  const selected = selectedTagSet.has(tag.id)\n                  return (\n                    <button\n                      key={tag.id}\n                      className={`tag-filter-option${selected ? ' selected' : ''}`}\n                      type=\"button\"\n                      onClick={() => onToggleTag(tag.id)}\n                    >\n                      <span className=\"tag-check\">{selected ? <Check size={14} /> : null}</span>\n                      <span>{tag.name}</span>\n                      <span className=\"tag-count\">{tag.skill_count}</span>\n                    </button>\n                  )\n                })}\n              </div>\n              <div className=\"tag-filter-footer\">\n                <button type=\"button\" onClick={onClearTags} disabled={selectedCount === 0}>\n                  {t('clearAll')}\n                </button>\n                <button type=\"button\" onClick={onManageTags}>\n                  {t('manageTags')}\n                </button>\n              </div>\n            </div>\n          ) : null}\n        </div>\n        <div className=\"search-container\">\n          <Search size={16} className=\"search-icon-abs\" />\n          <input\n            className=\"search-input\"\n            value={searchQuery}\n            onChange={(event) => onSearchChange(event.target.value)}\n            placeholder={t('searchPlaceholder')}\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(FilterBar)\n"
  },
  {
    "path": "src/components/skills/Header.tsx",
    "content": "import { memo } from 'react'\nimport { Layers, Search, Settings, Tag } from 'lucide-react'\nimport type { TFunction } from 'i18next'\n\ntype HeaderProps = {\n  language: string\n  loading: boolean\n  activeView: 'myskills' | 'explore' | 'detail' | 'settings' | 'tags'\n  onToggleLanguage: () => void\n  onOpenSettings: () => void\n  onViewChange: (view: 'myskills' | 'explore' | 'tags') => void\n  t: TFunction\n}\n\nconst Header = ({\n  language,\n  activeView,\n  onToggleLanguage,\n  onOpenSettings,\n  onViewChange,\n  t,\n}: HeaderProps) => {\n  return (\n    <header className=\"skills-header\">\n      <div className=\"header-left\">\n        <div className=\"brand-area\">\n          <img className=\"logo-icon\" src=\"/logo.png\" alt=\"\" />\n          <div className=\"brand-text-wrap\">\n            <div className=\"brand-text\">{t('appName')}</div>\n          </div>\n        </div>\n        <nav className=\"nav-tabs\">\n          <button\n            className={`nav-tab${activeView === 'myskills' || activeView === 'detail' ? ' active' : ''}`}\n            type=\"button\"\n            onClick={() => onViewChange('myskills')}\n          >\n            <Layers size={16} />\n            {t('navMySkills')}\n          </button>\n          <button\n            className={`nav-tab${activeView === 'explore' ? ' active' : ''}`}\n            type=\"button\"\n            onClick={() => onViewChange('explore')}\n          >\n            <Search size={16} />\n            {t('navExplore')}\n          </button>\n          <button\n            className={`nav-tab${activeView === 'tags' ? ' active' : ''}`}\n            type=\"button\"\n            onClick={() => onViewChange('tags')}\n          >\n            <Tag size={16} />\n            {t('navTags')}\n          </button>\n        </nav>\n      </div>\n      <div className=\"header-actions\">\n        <button className=\"lang-btn\" type=\"button\" onClick={onToggleLanguage}>\n          {language === 'en' ? t('languageShort.en') : t('languageShort.zh')}\n        </button>\n        <button className={`icon-btn${activeView === 'settings' ? ' active' : ''}`} type=\"button\" onClick={onOpenSettings}>\n          <Settings size={18} />\n        </button>\n      </div>\n    </header>\n  )\n}\n\nexport default memo(Header)\n"
  },
  {
    "path": "src/components/skills/LoadingOverlay.tsx",
    "content": "import { memo } from 'react'\nimport type { TFunction } from 'i18next'\n\ntype LoadingOverlayProps = {\n  loading: boolean\n  actionMessage: string | null\n  loadingStartAt: number | null\n  onCancel?: () => void\n  t: TFunction\n}\n\nconst LoadingOverlay = ({\n  loading,\n  actionMessage,\n  loadingStartAt,\n  onCancel,\n  t,\n}: LoadingOverlayProps) => {\n  if (!loading) return null\n\n  return (\n    <div className=\"modal-backdrop loading-backdrop\">\n      <div className=\"modal loading-modal\" role=\"dialog\" aria-modal=\"true\">\n        <div className=\"loading-content\">\n          <div className=\"loader-spinner\" />\n          <div className=\"loading-text\">{t('processingTitle')}</div>\n          <div className=\"loading-stage\">\n            {actionMessage ?? t('processingTipShort')}\n          </div>\n          {loadingStartAt ? (\n            <div className=\"loading-subtext loading-subtext-delayed\">\n              {t('processingTipLong')}\n            </div>\n          ) : null}\n          <div className=\"progress-bar\">\n            <div className=\"progress-fill\" />\n          </div>\n          {onCancel ? (\n            <button\n              className=\"btn btn-secondary loading-cancel-btn\"\n              type=\"button\"\n              onClick={onCancel}\n            >\n              {t('cancel')}\n            </button>\n          ) : null}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(LoadingOverlay)\n"
  },
  {
    "path": "src/components/skills/SettingsPage.tsx",
    "content": "import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { ArrowLeft } from 'lucide-react'\nimport type { TFunction } from 'i18next'\nimport type { Update } from '@tauri-apps/plugin-updater'\n\ntype UpdateStatus = 'idle' | 'checking' | 'up-to-date' | 'available' | 'downloading' | 'done' | 'error'\n\ntype SettingsPageProps = {\n  isTauri: boolean\n  language: string\n  storagePath: string\n  gitCacheCleanupDays: number\n  gitCacheTtlSecs: number\n  themePreference: 'system' | 'light' | 'dark'\n  githubToken: string\n  onPickStoragePath: () => void\n  onToggleLanguage: () => void\n  onThemeChange: (nextTheme: 'system' | 'light' | 'dark') => void\n  onGitCacheCleanupDaysChange: (nextDays: number) => void\n  onGitCacheTtlSecsChange: (nextSecs: number) => void\n  onClearGitCacheNow: () => void\n  onGithubTokenChange: (token: string) => void\n  onBack: () => void\n  t: TFunction\n}\n\nconst SettingsPage = ({\n  isTauri,\n  language,\n  storagePath,\n  gitCacheCleanupDays,\n  gitCacheTtlSecs,\n  themePreference,\n  onPickStoragePath,\n  onToggleLanguage,\n  onThemeChange,\n  onGitCacheCleanupDaysChange,\n  onGitCacheTtlSecsChange,\n  onClearGitCacheNow,\n  githubToken,\n  onGithubTokenChange,\n  onBack,\n  t,\n}: SettingsPageProps) => {\n  const [localToken, setLocalToken] = useState(githubToken)\n  useEffect(() => {\n    setLocalToken(githubToken)\n  }, [githubToken])\n\n  const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle')\n  const [updateVersion, setUpdateVersion] = useState<string | null>(null)\n  const [updateError, setUpdateError] = useState<string | null>(null)\n  const updateRef = useRef<Update | null>(null)\n\n  const handleCheckUpdate = useCallback(async () => {\n    if (!isTauri) return\n    setUpdateStatus('checking')\n    setUpdateError(null)\n    try {\n      const { check } = await import('@tauri-apps/plugin-updater')\n      const update = await check()\n      if (update) {\n        updateRef.current = update\n        setUpdateVersion(update.version)\n        setUpdateStatus('available')\n      } else {\n        setUpdateStatus('up-to-date')\n      }\n    } catch (err) {\n      setUpdateError(err instanceof Error ? err.message : String(err))\n      setUpdateStatus('error')\n    }\n  }, [isTauri])\n\n  const handleInstallUpdate = useCallback(async () => {\n    const update = updateRef.current\n    if (!update) return\n    setUpdateStatus('downloading')\n    setUpdateError(null)\n    try {\n      await update.downloadAndInstall()\n      setUpdateStatus('done')\n    } catch (err) {\n      setUpdateError(err instanceof Error ? err.message : String(err))\n      setUpdateStatus('error')\n    }\n  }, [])\n\n  const [appVersion, setAppVersion] = useState<string | null>(null)\n  const versionText = useMemo(() => {\n    if (!isTauri) return t('notAvailable')\n    if (!appVersion) return t('unknown')\n    return `v${appVersion}`\n  }, [appVersion, isTauri, t])\n\n  const loadAppVersion = useCallback(async () => {\n    if (!isTauri) {\n      setAppVersion(null)\n      return\n    }\n    try {\n      const { getVersion } = await import('@tauri-apps/api/app')\n      const v = await getVersion()\n      setAppVersion(v)\n    } catch {\n      setAppVersion(null)\n    }\n  }, [isTauri])\n\n  useEffect(() => {\n    void loadAppVersion()\n    return () => { updateRef.current = null }\n  }, [loadAppVersion])\n\n  return (\n    <div className=\"settings-page\">\n      <div className=\"detail-header\">\n        <button className=\"detail-back-btn\" type=\"button\" onClick={onBack}>\n          <ArrowLeft size={16} />\n          {t('detail.back')}\n        </button>\n        <div className=\"detail-skill-name\">{t('settings')}</div>\n      </div>\n      <div className=\"settings-page-body\">\n        <div className=\"settings-field\">\n          <label className=\"settings-label\" htmlFor=\"settings-language\">\n            {t('interfaceLanguage')}\n          </label>\n          <div className=\"settings-select-wrap\">\n            <select\n              id=\"settings-language\"\n              className=\"settings-select\"\n              value={language}\n              onChange={(event) => {\n                if (event.target.value !== language) {\n                  onToggleLanguage()\n                }\n              }}\n            >\n              <option value=\"en\">{t('languageOptions.en')}</option>\n              <option value=\"zh\">{t('languageOptions.zh')}</option>\n            </select>\n            <svg\n              className=\"settings-select-caret\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2\"\n              aria-hidden=\"true\"\n            >\n              <path d=\"M6 9l6 6 6-6\" />\n            </svg>\n          </div>\n        </div>\n\n        <div className=\"settings-field\">\n          <label className=\"settings-label\" id=\"settings-theme-label\">\n            {t('themeMode')}\n          </label>\n          <div className=\"settings-theme-options\" role=\"group\" aria-labelledby=\"settings-theme-label\">\n            <button\n              type=\"button\"\n              className={`settings-theme-btn ${\n                themePreference === 'system' ? 'active' : ''\n              }`}\n              aria-pressed={themePreference === 'system'}\n              onClick={() => onThemeChange('system')}\n            >\n              {t('themeOptions.system')}\n            </button>\n            <button\n              type=\"button\"\n              className={`settings-theme-btn ${\n                themePreference === 'light' ? 'active' : ''\n              }`}\n              aria-pressed={themePreference === 'light'}\n              onClick={() => onThemeChange('light')}\n            >\n              {t('themeOptions.light')}\n            </button>\n            <button\n              type=\"button\"\n              className={`settings-theme-btn ${\n                themePreference === 'dark' ? 'active' : ''\n              }`}\n              aria-pressed={themePreference === 'dark'}\n              onClick={() => onThemeChange('dark')}\n            >\n              {t('themeOptions.dark')}\n            </button>\n          </div>\n        </div>\n\n        <div className=\"settings-field\">\n          <label className=\"settings-label\" htmlFor=\"settings-storage\">\n            {t('skillsStoragePath')}\n          </label>\n          <div className=\"settings-input-row\">\n            <input\n              id=\"settings-storage\"\n              className=\"settings-input mono\"\n              value={storagePath}\n              readOnly\n            />\n            <button\n              className=\"btn btn-secondary settings-browse\"\n              type=\"button\"\n              onClick={onPickStoragePath}\n            >\n              {t('browse')}\n            </button>\n          </div>\n          <div className=\"settings-helper\">{t('skillsStorageHint')}</div>\n        </div>\n\n        <div className=\"settings-field\">\n          <label className=\"settings-label\" htmlFor=\"settings-git-cache-days\">\n            {t('gitCacheCleanupDays')}\n          </label>\n          <div className=\"settings-input-row\">\n            <input\n              id=\"settings-git-cache-days\"\n              className=\"settings-input\"\n              type=\"number\"\n              min={0}\n              max={3650}\n              step={1}\n              value={gitCacheCleanupDays}\n              onChange={(event) => {\n                const next = Number(event.target.value)\n                if (!Number.isNaN(next)) {\n                  onGitCacheCleanupDaysChange(next)\n                }\n              }}\n            />\n            <button\n              className=\"btn btn-secondary settings-browse\"\n              type=\"button\"\n              onClick={onClearGitCacheNow}\n            >\n              {t('cleanNow')}\n            </button>\n          </div>\n          <div className=\"settings-helper\">{t('gitCacheCleanupHint')}</div>\n        </div>\n\n        <div className=\"settings-field\">\n          <label className=\"settings-label\" htmlFor=\"settings-git-cache-ttl\">\n            {t('gitCacheTtlSecs')}\n          </label>\n          <div className=\"settings-input-row\">\n            <input\n              id=\"settings-git-cache-ttl\"\n              className=\"settings-input\"\n              type=\"number\"\n              min={0}\n              max={3600}\n              step={1}\n              value={gitCacheTtlSecs}\n              onChange={(event) => {\n                const next = Number(event.target.value)\n                if (!Number.isNaN(next)) {\n                  onGitCacheTtlSecsChange(next)\n                }\n              }}\n            />\n          </div>\n          <div className=\"settings-helper\">{t('gitCacheTtlHint')}</div>\n        </div>\n\n        <div className=\"settings-field\">\n          <label className=\"settings-label\" htmlFor=\"settings-github-token\">\n            {t('githubToken')}\n          </label>\n          <div className=\"settings-input-row\">\n            <input\n              id=\"settings-github-token\"\n              className=\"settings-input mono\"\n              type=\"password\"\n              placeholder={t('githubTokenPlaceholder')}\n              value={localToken}\n              onChange={(e) => setLocalToken(e.target.value)}\n              onBlur={() => {\n                if (localToken !== githubToken) {\n                  onGithubTokenChange(localToken)\n                }\n              }}\n            />\n          </div>\n          <div className=\"settings-helper\">{t('githubTokenHint')}</div>\n        </div>\n\n        <div className=\"settings-field settings-update-section\">\n          <label className=\"settings-label\">{t('appUpdates')}</label>\n          <div className=\"settings-version-row\">\n            <span className=\"settings-version-text\">\n              {t('appName')} {versionText}\n            </span>\n            {isTauri && updateStatus === 'idle' && (\n              <button\n                className=\"btn btn-secondary btn-sm\"\n                type=\"button\"\n                onClick={handleCheckUpdate}\n              >\n                {t('checkForUpdates')}\n              </button>\n            )}\n            {updateStatus === 'checking' && (\n              <span className=\"settings-update-status\">{t('checkingUpdates')}</span>\n            )}\n            {updateStatus === 'up-to-date' && (\n              <span className=\"settings-update-status settings-update-ok\">{t('updateNotAvailable')}</span>\n            )}\n          </div>\n          {updateStatus === 'available' && (\n            <div className=\"settings-update-available\">\n              <span>{t('updateAvailableWithVersion', { version: updateVersion })}</span>\n              <button\n                className=\"btn btn-primary btn-sm\"\n                type=\"button\"\n                onClick={handleInstallUpdate}\n              >\n                {t('downloadAndInstall')}\n              </button>\n            </div>\n          )}\n          {updateStatus === 'downloading' && (\n            <div className=\"settings-update-status\">{t('installingUpdate')}</div>\n          )}\n          {updateStatus === 'done' && (\n            <div className=\"settings-update-ok\">{t('updateInstalledRestart')}</div>\n          )}\n          {updateStatus === 'error' && (\n            <div className=\"settings-update-error\">\n              <span>{updateError}</span>\n              <button\n                className=\"btn btn-secondary btn-sm\"\n                type=\"button\"\n                onClick={handleCheckUpdate}\n              >\n                {t('checkForUpdates')}\n              </button>\n            </div>\n          )}\n          <div className=\"settings-helper\">{t('updateHint')}</div>\n        </div>\n\n      </div>\n    </div>\n  )\n}\n\nexport default memo(SettingsPage)\n"
  },
  {
    "path": "src/components/skills/SkillCard.tsx",
    "content": "import { memo, useState } from 'react'\nimport { Box, Copy, Folder, Github, RefreshCw, Tag, Trash2 } from 'lucide-react'\nimport { toast } from 'sonner'\nimport type { TFunction } from 'i18next'\nimport type { ManagedSkill, ToolOption } from './types'\n\ntype GithubInfo = {\n  label: string\n  href: string\n}\n\ntype SkillCardProps = {\n  skill: ManagedSkill\n  installedTools: ToolOption[]\n  loading: boolean\n  getGithubInfo: (url: string | null | undefined) => GithubInfo | null\n  getSkillSourceLabel: (skill: ManagedSkill) => string\n  formatRelative: (ms: number | null | undefined) => string\n  onUpdate: (skill: ManagedSkill) => void\n  onDelete: (skillId: string) => void\n  onToggleTool: (skill: ManagedSkill, toolId: string) => void\n  onOpenScope: (skill: ManagedSkill) => void\n  onOpenDetail: (skill: ManagedSkill) => void\n  onEditTags: (skill: ManagedSkill) => void\n  getSkillScope: (skill: ManagedSkill) => 'global' | 'project'\n  getSkillProjects: (skill: ManagedSkill) => string[]\n  t: TFunction\n}\n\nconst MAX_VISIBLE_BADGES = 5\n\nconst SkillCard = ({\n  skill,\n  installedTools,\n  loading,\n  getGithubInfo,\n  getSkillSourceLabel,\n  formatRelative,\n  onUpdate,\n  onDelete,\n  onToggleTool,\n  onOpenScope,\n  onOpenDetail,\n  onEditTags,\n  getSkillScope,\n  getSkillProjects,\n  t,\n}: SkillCardProps) => {\n  const typeKey = skill.source_type.toLowerCase()\n  const iconNode = typeKey.includes('git') ? (\n    <Github size={20} />\n  ) : typeKey.includes('local') ? (\n    <Folder size={20} />\n  ) : (\n    <Box size={20} />\n  )\n  const github = getGithubInfo(skill.source_ref)\n  const copyValue = (github?.href ?? skill.source_ref ?? '').trim()\n  const skillScope = getSkillScope(skill)\n  const projectCount = getSkillProjects(skill).length\n\n  const handleCopy = async () => {\n    if (!copyValue) return\n    try {\n      await navigator.clipboard.writeText(copyValue)\n      toast.success(t('copied'))\n    } catch {\n      toast.error(t('copyFailed'))\n    }\n  }\n\n  // Split tools into synced and remaining for badge display\n  const syncedTools: { tool: ToolOption; target: (typeof skill.targets)[0] }[] = []\n  const unsyncedTools: ToolOption[] = []\n  for (const tool of installedTools) {\n    const target = skill.targets.find(\n      (tgt) => tgt.tool === tool.id && (tgt.scope ?? 'global') === skillScope,\n    )\n    if (target) {\n      syncedTools.push({ tool, target })\n    } else {\n      unsyncedTools.push(tool)\n    }\n  }\n\n  const [expanded, setExpanded] = useState(false)\n  const needsCollapse = syncedTools.length > MAX_VISIBLE_BADGES\n  const visibleSynced = expanded ? syncedTools : syncedTools.slice(0, MAX_VISIBLE_BADGES)\n  const remainingCount = syncedTools.length - MAX_VISIBLE_BADGES\n  const showUnsyncedTools = expanded || !needsCollapse\n\n  return (\n    <div className=\"skill-card\">\n      <div className=\"skill-icon\">{iconNode}</div>\n      <div className=\"skill-main\">\n        <div className=\"skill-header-row\">\n          <button\n            type=\"button\"\n            className=\"skill-name clickable\"\n            onClick={() => onOpenDetail(skill)}\n          >\n            {skill.name}\n          </button>\n          {skill.tags.length > 0 ? (\n            <div className=\"skill-tags-inline\">\n              {skill.tags.slice(0, 3).map((tag) => (\n                <button\n                  key={tag.id}\n                  className=\"skill-tag-pill\"\n                  type=\"button\"\n                  onClick={() => onEditTags(skill)}\n                >\n                  #{tag.name}\n                </button>\n              ))}\n              {skill.tags.length > 3 ? (\n                <button\n                  className=\"skill-tag-pill muted\"\n                  type=\"button\"\n                  onClick={() => onEditTags(skill)}\n                >\n                  +{skill.tags.length - 3}\n                </button>\n              ) : null}\n            </div>\n          ) : null}\n        </div>\n        {skill.description ? (\n          <div className=\"skill-desc\">{skill.description}</div>\n        ) : null}\n        <div className=\"skill-meta-row\">\n          {github ? (\n            <div className=\"skill-source\">\n              <button\n                className=\"repo-pill copyable\"\n                type=\"button\"\n                title={t('copy')}\n                aria-label={t('copy')}\n                onClick={() => void handleCopy()}\n                disabled={!copyValue}\n              >\n                {github.label}\n                <span className=\"copy-icon\" aria-hidden=\"true\">\n                  <Copy size={12} />\n                </span>\n              </button>\n            </div>\n          ) : (\n            <div className=\"skill-source\">\n              <button\n                className=\"repo-pill copyable\"\n                type=\"button\"\n                title={t('copy')}\n                aria-label={t('copy')}\n                onClick={() => void handleCopy()}\n                disabled={!copyValue}\n              >\n                <span className=\"mono\">{getSkillSourceLabel(skill)}</span>\n                <span className=\"copy-icon\" aria-hidden=\"true\">\n                  <Copy size={12} />\n                </span>\n              </button>\n            </div>\n          )}\n          <div className=\"skill-source time\">\n            <span className=\"dot\">•</span>\n            {formatRelative(skill.updated_at)}\n          </div>\n          <button\n            className={`scope-badge ${skillScope}`}\n            type=\"button\"\n            onClick={() => onOpenScope(skill)}\n          >\n            {skillScope === 'project'\n              ? t('scope.projectCount', { count: projectCount })\n              : t('scope.globalBadge')}\n          </button>\n        </div>\n        <div className={`tool-matrix${!expanded && needsCollapse ? ' collapsed' : ''}`}>\n          {visibleSynced.map(({ tool, target }) => (\n            <button\n              key={`${skill.id}-${tool.id}`}\n              type=\"button\"\n              className=\"tool-pill active\"\n              title={`${tool.label} (${target.mode ?? t('unknown')})`}\n              onClick={() => void onToggleTool(skill, tool.id)}\n            >\n              <span className=\"status-badge\" />\n              {tool.label}\n            </button>\n          ))}\n          {needsCollapse && !expanded ? (\n            <button\n              type=\"button\"\n              className=\"tool-pill more-badge\"\n              onClick={() => setExpanded(true)}\n            >\n              {t('moreTools', { count: remainingCount })}\n            </button>\n          ) : null}\n          {showUnsyncedTools &&\n            unsyncedTools.map((tool) => {\n              const disabled = false\n              return (\n                <button\n                  key={`${skill.id}-${tool.id}`}\n                  type=\"button\"\n                  className={`tool-pill ${disabled ? 'disabled' : 'inactive'}`}\n                  title={tool.label}\n                  onClick={() => {\n                    if (!disabled) void onToggleTool(skill, tool.id)\n                  }}\n                  disabled={disabled}\n                >\n                  {tool.label}\n                </button>\n              )\n            })}\n        </div>\n      </div>\n      <div className=\"skill-actions-col\">\n        <button\n          className={`card-btn tag-action${skill.tags.length > 0 ? ' has-tags' : ''}`}\n          type=\"button\"\n          onClick={() => onEditTags(skill)}\n          disabled={loading}\n          aria-label={t('editTags')}\n          title={t('editTags')}\n        >\n          <Tag size={16} />\n        </button>\n        <button\n          className=\"card-btn primary-action\"\n          type=\"button\"\n          onClick={() => onUpdate(skill)}\n          disabled={loading}\n          aria-label={t('update')}\n        >\n          <RefreshCw size={16} />\n        </button>\n        <button\n          className=\"card-btn danger-action\"\n          type=\"button\"\n          onClick={() => onDelete(skill.id)}\n          disabled={loading}\n          aria-label={t('remove')}\n        >\n          <Trash2 size={16} />\n        </button>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(SkillCard)\n"
  },
  {
    "path": "src/components/skills/SkillDetailView.tsx",
    "content": "import { memo, useCallback, useEffect, useMemo, useState } from 'react'\nimport {\n  ArrowLeft,\n  ChevronDown,\n  ChevronRight,\n  Clock,\n  File,\n  Folder,\n  FolderOpen,\n  GitBranch,\n} from 'lucide-react'\nimport { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'\nimport {\n  oneLight,\n  oneDark,\n} from 'react-syntax-highlighter/dist/esm/styles/prism'\nimport Markdown from 'react-markdown'\nimport remarkFrontmatter from 'remark-frontmatter'\nimport remarkGfm from 'remark-gfm'\nimport { toast } from 'sonner'\nimport type { TFunction } from 'i18next'\nimport type { ManagedSkill, SkillFileEntry } from './types'\n\n// ─── Types ───────────────────────────────────────────\ntype SkillDetailViewProps = {\n  skill: ManagedSkill\n  onBack: () => void\n  invokeTauri: <T>(command: string, args?: Record<string, unknown>) => Promise<T>\n  formatRelative: (ms: number | null | undefined) => string\n  t: TFunction\n}\n\ntype TreeNode = {\n  name: string\n  path: string // full relative path for files, folder prefix for dirs\n  isDir: boolean\n  size: number\n  children: TreeNode[]\n}\n\n// ─── Helpers ─────────────────────────────────────────\nfunction formatSize(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`\n}\n\nconst EXT_LANG: Record<string, string> = {\n  ts: 'typescript',\n  tsx: 'tsx',\n  js: 'javascript',\n  jsx: 'jsx',\n  py: 'python',\n  rs: 'rust',\n  go: 'go',\n  rb: 'ruby',\n  java: 'java',\n  kt: 'kotlin',\n  swift: 'swift',\n  c: 'c',\n  cpp: 'cpp',\n  h: 'c',\n  hpp: 'cpp',\n  cs: 'csharp',\n  css: 'css',\n  scss: 'scss',\n  less: 'less',\n  html: 'html',\n  xml: 'xml',\n  json: 'json',\n  yaml: 'yaml',\n  yml: 'yaml',\n  toml: 'toml',\n  sh: 'bash',\n  bash: 'bash',\n  zsh: 'bash',\n  sql: 'sql',\n  graphql: 'graphql',\n  dockerfile: 'docker',\n  lua: 'lua',\n  r: 'r',\n  dart: 'dart',\n  php: 'php',\n  pl: 'perl',\n  ex: 'elixir',\n  exs: 'elixir',\n  erl: 'erlang',\n  hs: 'haskell',\n  vim: 'vim',\n  ini: 'ini',\n  cfg: 'ini',\n  diff: 'diff',\n  patch: 'diff',\n}\n\nfunction getLang(filename: string): string {\n  const lower = filename.toLowerCase()\n  if (lower === 'dockerfile' || lower.startsWith('dockerfile.')) return 'docker'\n  if (lower === 'makefile' || lower === 'gnumakefile') return 'makefile'\n  const ext = lower.split('.').pop() ?? ''\n  return EXT_LANG[ext] ?? ''\n}\n\nfunction isMarkdown(filename: string): boolean {\n  return /\\.(md|mdx|markdown)$/i.test(filename)\n}\n\n/** Build a tree from flat file paths */\nfunction buildTree(files: SkillFileEntry[]): TreeNode[] {\n  const root: TreeNode[] = []\n\n  for (const f of files) {\n    const parts = f.path.split('/')\n    let current = root\n    for (let i = 0; i < parts.length; i++) {\n      const name = parts[i]\n      const isLast = i === parts.length - 1\n      if (isLast) {\n        current.push({\n          name,\n          path: f.path,\n          isDir: false,\n          size: f.size,\n          children: [],\n        })\n      } else {\n        let dir = current.find((n) => n.isDir && n.name === name)\n        if (!dir) {\n          dir = {\n            name,\n            path: parts.slice(0, i + 1).join('/'),\n            isDir: true,\n            size: 0,\n            children: [],\n          }\n          current.push(dir)\n        }\n        current = dir.children\n      }\n    }\n  }\n\n  // Sort: dirs first (alphabetical), then files (SKILL.md first, then alphabetical)\n  const sortNodes = (nodes: TreeNode[]) => {\n    nodes.sort((a, b) => {\n      if (a.isDir !== b.isDir) return a.isDir ? -1 : 1\n      if (!a.isDir && !b.isDir) {\n        const aSkill = a.name.toLowerCase() === 'skill.md'\n        const bSkill = b.name.toLowerCase() === 'skill.md'\n        if (aSkill !== bSkill) return aSkill ? -1 : 1\n      }\n      return a.name.localeCompare(b.name)\n    })\n    for (const n of nodes) {\n      if (n.isDir) sortNodes(n.children)\n    }\n  }\n  sortNodes(root)\n  return root\n}\n\n// ─── FileTreeNode component ─────────────────────────\ntype FileTreeNodeProps = {\n  node: TreeNode\n  depth: number\n  activeFile: string | null\n  expanded: Set<string>\n  onToggleDir: (path: string) => void\n  onSelectFile: (path: string) => void\n}\n\nconst FileTreeNode = memo(\n  ({\n    node,\n    depth,\n    activeFile,\n    expanded,\n    onToggleDir,\n    onSelectFile,\n  }: FileTreeNodeProps) => {\n    if (node.isDir) {\n      const isOpen = expanded.has(node.path)\n      return (\n        <>\n          <button\n            type=\"button\"\n            className=\"tree-item tree-dir\"\n            style={{ paddingLeft: 12 + depth * 16 }}\n            onClick={() => onToggleDir(node.path)}\n          >\n            <span className=\"tree-chevron\">\n              {isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}\n            </span>\n            {isOpen ? (\n              <FolderOpen size={14} className=\"tree-icon tree-icon-folder\" />\n            ) : (\n              <Folder size={14} className=\"tree-icon tree-icon-folder\" />\n            )}\n            <span className=\"tree-name\">{node.name}</span>\n          </button>\n          {isOpen\n            ? node.children.map((child) => (\n                <FileTreeNode\n                  key={child.path}\n                  node={child}\n                  depth={depth + 1}\n                  activeFile={activeFile}\n                  expanded={expanded}\n                  onToggleDir={onToggleDir}\n                  onSelectFile={onSelectFile}\n                />\n              ))\n            : null}\n        </>\n      )\n    }\n\n    return (\n      <button\n        type=\"button\"\n        className={`tree-item tree-file${activeFile === node.path ? ' active' : ''}`}\n        style={{ paddingLeft: 12 + depth * 16 + 18 }}\n        onClick={() => onSelectFile(node.path)}\n      >\n        <File size={14} className=\"tree-icon tree-icon-file\" />\n        <span className=\"tree-name\">{node.name}</span>\n        <span className=\"tree-size\">{formatSize(node.size)}</span>\n      </button>\n    )\n  },\n)\nFileTreeNode.displayName = 'FileTreeNode'\n\n// ─── FileContent renderer ────────────────────────────\ntype FileContentRendererProps = {\n  filename: string\n  content: string\n  isDark: boolean\n}\n\nfunction parseFrontmatter(raw: string): {\n  meta: Record<string, string> | null\n  body: string\n} {\n  if (!raw.startsWith('---')) return { meta: null, body: raw }\n  const end = raw.indexOf('\\n---', 3)\n  if (end === -1) return { meta: null, body: raw }\n  const block = raw.slice(4, end)\n  const entries: Record<string, string> = {}\n  const lines = block.split('\\n')\n  for (let i = 0; i < lines.length; i++) {\n    const line = lines[i]\n    const idx = line.indexOf(':')\n    if (idx === -1) continue\n    const key = line.slice(0, idx).trim()\n    let val = line.slice(idx + 1).trim()\n    const blockStyle = val.match(/^([>|])[-+]?$/)?.[1]\n    if (blockStyle) {\n      const blockLines: string[] = []\n      while (i + 1 < lines.length) {\n        const next = lines[i + 1]\n        if (next.trim() !== '' && !/^\\s/.test(next)) break\n        blockLines.push(next.replace(/^\\s{2}/, ''))\n        i++\n      }\n      val =\n        blockStyle === '|'\n          ? blockLines.join('\\n').trim()\n          : blockLines.map((v) => v.trim()).filter(Boolean).join(' ')\n    }\n    // strip surrounding quotes\n    if (\n      val.length >= 2 &&\n      ((val[0] === '\"' && val[val.length - 1] === '\"') ||\n        (val[0] === \"'\" && val[val.length - 1] === \"'\"))\n    ) {\n      val = val.slice(1, -1)\n    }\n    if (key) entries[key] = val\n  }\n  const keys = Object.keys(entries)\n  if (keys.length === 0) return { meta: null, body: raw }\n  const body = raw.slice(end + 4).replace(/^\\n+/, '')\n  return { meta: entries, body }\n}\n\nconst FileContentRenderer = memo(\n  ({ filename, content, isDark }: FileContentRendererProps) => {\n    if (isMarkdown(filename)) {\n      const { meta, body } = parseFrontmatter(content)\n      return (\n        <div className=\"markdown-body\">\n          {meta && (\n            <dl className=\"frontmatter-meta\">\n              {Object.entries(meta).map(([key, value]) => (\n                <div\n                  className=\"frontmatter-meta-item\"\n                  data-key={key}\n                  key={key}\n                >\n                  <dt>{key}</dt>\n                  <dd>{value}</dd>\n                </div>\n              ))}\n            </dl>\n          )}\n          <Markdown\n            remarkPlugins={[remarkFrontmatter, remarkGfm]}\n            components={{\n              code: ({ className, children, ...rest }) => {\n                const match = /language-(\\w+)/.exec(className ?? '')\n                const inline = !match\n                if (inline) {\n                  return (\n                    <code className=\"md-inline-code\" {...rest}>\n                      {children}\n                    </code>\n                  )\n                }\n                return (\n                  <SyntaxHighlighter\n                    style={isDark ? oneDark : oneLight}\n                    language={match[1]}\n                    PreTag=\"div\"\n                    customStyle={{\n                      margin: 0,\n                      borderRadius: 6,\n                      fontSize: 13,\n                    }}\n                  >\n                    {String(children).replace(/\\n$/, '')}\n                  </SyntaxHighlighter>\n                )\n              },\n            }}\n          >\n            {body}\n          </Markdown>\n        </div>\n      )\n    }\n\n    const lang = getLang(filename)\n    if (lang) {\n      return (\n        <SyntaxHighlighter\n          style={isDark ? oneDark : oneLight}\n          language={lang}\n          showLineNumbers\n          lineNumberStyle={{\n            minWidth: '3em',\n            paddingRight: '1em',\n            color: isDark ? '#636d83' : '#9ca3af',\n            userSelect: 'none',\n          }}\n          customStyle={{\n            margin: 0,\n            padding: '16px 0',\n            background: 'transparent',\n            fontSize: 13,\n            lineHeight: 1.7,\n          }}\n        >\n          {content}\n        </SyntaxHighlighter>\n      )\n    }\n\n    // Plain text with line numbers\n    return (\n      <SyntaxHighlighter\n        style={isDark ? oneDark : oneLight}\n        language=\"text\"\n        showLineNumbers\n        lineNumberStyle={{\n          minWidth: '3em',\n          paddingRight: '1em',\n          color: isDark ? '#636d83' : '#9ca3af',\n          userSelect: 'none',\n        }}\n        customStyle={{\n          margin: 0,\n          padding: '16px 0',\n          background: 'transparent',\n          fontSize: 13,\n          lineHeight: 1.7,\n        }}\n      >\n        {content}\n      </SyntaxHighlighter>\n    )\n  },\n)\nFileContentRenderer.displayName = 'FileContentRenderer'\n\n// ─── Main component ──────────────────────────────────\nconst SkillDetailView = ({\n  skill,\n  onBack,\n  invokeTauri,\n  formatRelative,\n  t,\n}: SkillDetailViewProps) => {\n  const [files, setFiles] = useState<SkillFileEntry[]>([])\n  const [activeFile, setActiveFile] = useState<string | null>(null)\n  const [fileContent, setFileContent] = useState('')\n  const [loadingFiles, setLoadingFiles] = useState(true)\n  const [loadingContent, setLoadingContent] = useState(false)\n  const [expanded, setExpanded] = useState<Set<string>>(new Set())\n\n  const isDark =\n    document.documentElement.getAttribute('data-theme') === 'dark'\n\n  const tree = useMemo(() => buildTree(files), [files])\n\n  useEffect(() => {\n    let cancelled = false\n    const load = async () => {\n      setLoadingFiles(true)\n      try {\n        const result = await invokeTauri<SkillFileEntry[]>('list_skill_files', {\n          centralPath: skill.central_path,\n        })\n        if (cancelled) return\n        setFiles(result)\n        // Start with all folders collapsed\n        setExpanded(new Set())\n        if (result.length > 0) {\n          setActiveFile(result[0].path)\n        }\n      } catch {\n        if (!cancelled) {\n          toast.error(t('detail.readError'))\n        }\n      } finally {\n        if (!cancelled) setLoadingFiles(false)\n      }\n    }\n    void load()\n    return () => {\n      cancelled = true\n    }\n  }, [invokeTauri, skill.central_path, t])\n\n  useEffect(() => {\n    if (!activeFile) return\n    let cancelled = false\n    const load = async () => {\n      setLoadingContent(true)\n      try {\n        const content = await invokeTauri<string>('read_skill_file', {\n          centralPath: skill.central_path,\n          filePath: activeFile,\n        })\n        if (!cancelled) setFileContent(content)\n      } catch (err) {\n        if (!cancelled) {\n          const msg = err instanceof Error ? err.message : String(err)\n          setFileContent(msg)\n        }\n      } finally {\n        if (!cancelled) setLoadingContent(false)\n      }\n    }\n    void load()\n    return () => {\n      cancelled = true\n    }\n  }, [activeFile, invokeTauri, skill.central_path])\n\n  const handleSelectFile = useCallback((path: string) => {\n    setActiveFile(path)\n  }, [])\n\n  const handleToggleDir = useCallback((path: string) => {\n    setExpanded((prev) => {\n      const next = new Set(prev)\n      if (next.has(path)) {\n        next.delete(path)\n      } else {\n        next.add(path)\n      }\n      return next\n    })\n  }, [])\n\n  const sourceLabel =\n    skill.source_type.toLowerCase().includes('git')\n      ? skill.source_ref?.replace(/^https?:\\/\\/(www\\.)?github\\.com\\//, '') ?? ''\n      : skill.source_ref ?? ''\n\n  const SourceIcon = skill.source_type.toLowerCase().includes('git')\n    ? GitBranch\n    : Folder\n\n  return (\n    <div className=\"detail-view\">\n      <div className=\"detail-header\">\n        <button className=\"detail-back-btn\" type=\"button\" onClick={onBack}>\n          <ArrowLeft size={16} />\n          {t('detail.back')}\n        </button>\n        <div className=\"detail-skill-name\">{skill.name}</div>\n        {skill.description ? (\n          <div className=\"detail-desc\">{skill.description}</div>\n        ) : null}\n        <div className=\"detail-meta\">\n          {sourceLabel ? (\n            <span className=\"detail-meta-item\">\n              <SourceIcon size={13} />\n              {sourceLabel}\n            </span>\n          ) : null}\n          {sourceLabel ? (\n            <span className=\"detail-meta-dot\">&middot;</span>\n          ) : null}\n          <span className=\"detail-meta-item\">\n            <Clock size={13} />\n            {formatRelative(skill.updated_at)}\n          </span>\n          <span className=\"detail-meta-dot\">&middot;</span>\n          <span className=\"detail-meta-item\">\n            <File size={13} />\n            {t('detail.fileCount', { count: files.length })}\n          </span>\n        </div>\n      </div>\n\n      <div className=\"detail-body\">\n        <div className=\"detail-file-list\">\n          <div className=\"file-list-title\">{t('detail.files')}</div>\n          {loadingFiles ? (\n            <div className=\"detail-loading\">\n              <div className=\"detail-spinner\" />\n              {t('detail.loadingFiles')}\n            </div>\n          ) : files.length === 0 ? (\n            <div className=\"detail-loading\">{t('detail.noFiles')}</div>\n          ) : (\n            <div className=\"file-tree\">\n              {tree.map((node) => (\n                <FileTreeNode\n                  key={node.path}\n                  node={node}\n                  depth={0}\n                  activeFile={activeFile}\n                  expanded={expanded}\n                  onToggleDir={handleToggleDir}\n                  onSelectFile={handleSelectFile}\n                />\n              ))}\n            </div>\n          )}\n        </div>\n\n        <div className=\"detail-file-content\">\n          {activeFile ? (\n            <>\n              <div className=\"file-content-header\">\n                <span className=\"file-content-path\">\n                  <File size={14} />\n                  {activeFile}\n                </span>\n                <span className=\"file-content-size\">\n                  {formatSize(\n                    files.find((f) => f.path === activeFile)?.size ?? 0,\n                  )}\n                </span>\n              </div>\n              {loadingContent ? (\n                <div className=\"detail-loading\" style={{ height: 200 }}>\n                  <div className=\"detail-spinner\" />\n                  {t('detail.loadingContent')}\n                </div>\n              ) : (\n                <div className=\"file-content-body\">\n                  <FileContentRenderer\n                    filename={activeFile}\n                    content={fileContent}\n                    isDark={isDark}\n                  />\n                </div>\n              )}\n            </>\n          ) : (\n            <div className=\"detail-loading\" style={{ height: 200 }}>\n              {loadingFiles ? t('detail.loadingFiles') : t('detail.noFiles')}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(SkillDetailView)\n"
  },
  {
    "path": "src/components/skills/SkillsList.tsx",
    "content": "import { memo } from 'react'\nimport { MessageCircle } from 'lucide-react'\nimport type { TFunction } from 'i18next'\nimport type { ManagedSkill, OnboardingPlan, ToolOption } from './types'\nimport SkillCard from './SkillCard'\n\ntype GithubInfo = {\n  label: string\n  href: string\n}\n\ntype SkillsListProps = {\n  plan: OnboardingPlan | null\n  visibleSkills: ManagedSkill[]\n  installedTools: ToolOption[]\n  loading: boolean\n  getGithubInfo: (url: string | null | undefined) => GithubInfo | null\n  getSkillSourceLabel: (skill: ManagedSkill) => string\n  formatRelative: (ms: number | null | undefined) => string\n  onReviewImport: () => void\n  onUpdateSkill: (skill: ManagedSkill) => void\n  onDeleteSkill: (skillId: string) => void\n  onToggleTool: (skill: ManagedSkill, toolId: string) => void\n  onOpenScope: (skill: ManagedSkill) => void\n  onOpenDetail: (skill: ManagedSkill) => void\n  onEditTags: (skill: ManagedSkill) => void\n  getSkillScope: (skill: ManagedSkill) => 'global' | 'project'\n  getSkillProjects: (skill: ManagedSkill) => string[]\n  t: TFunction\n}\n\nconst SkillsList = ({\n  plan,\n  visibleSkills,\n  installedTools,\n  loading,\n  getGithubInfo,\n  getSkillSourceLabel,\n  formatRelative,\n  onReviewImport,\n  onUpdateSkill,\n  onDeleteSkill,\n  onToggleTool,\n  onOpenScope,\n  onOpenDetail,\n  onEditTags,\n  getSkillScope,\n  getSkillProjects,\n  t,\n}: SkillsListProps) => {\n  return (\n    <div className=\"skills-list\">\n      {plan && plan.total_skills_found > 0 ? (\n        <div className=\"discovered-banner\">\n          <div className=\"banner-left\">\n            <div className=\"banner-icon\">\n              <MessageCircle size={18} />\n            </div>\n            <div className=\"banner-content\">\n              <div className=\"banner-title\">{t('discoveredTitle')}</div>\n              <div className=\"banner-subtitle\">\n                {t('discoveredCount', { count: plan.total_skills_found })}\n              </div>\n            </div>\n          </div>\n          <button\n            className=\"btn btn-warning\"\n            type=\"button\"\n            onClick={onReviewImport}\n            disabled={loading}\n          >\n            {t('reviewImport')}\n          </button>\n        </div>\n      ) : null}\n\n      {visibleSkills.length === 0 ? (\n        <div className=\"empty\">{t('skillsEmpty')}</div>\n      ) : (\n        <>\n          {visibleSkills.map((skill) => (\n            <SkillCard\n              key={skill.id}\n              skill={skill}\n              installedTools={installedTools}\n              loading={loading}\n              getGithubInfo={getGithubInfo}\n              getSkillSourceLabel={getSkillSourceLabel}\n              formatRelative={formatRelative}\n              onUpdate={onUpdateSkill}\n              onDelete={onDeleteSkill}\n              onToggleTool={onToggleTool}\n              onOpenScope={onOpenScope}\n              onOpenDetail={onOpenDetail}\n              onEditTags={onEditTags}\n              getSkillScope={getSkillScope}\n              getSkillProjects={getSkillProjects}\n              t={t}\n            />\n          ))}\n        </>\n      )}\n    </div>\n  )\n}\n\nexport default memo(SkillsList)\n"
  },
  {
    "path": "src/components/skills/TagsPage.tsx",
    "content": "import { memo, useMemo, useState } from 'react'\nimport { ArrowLeft, Plus, Search, Tag } from 'lucide-react'\nimport type { TFunction } from 'i18next'\nimport type { TagWithCountDto } from './types'\n\ntype TagsPageProps = {\n  tags: TagWithCountDto[]\n  untaggedCount: number\n  loading: boolean\n  formatRelative: (ms: number | null | undefined) => string\n  onBack: () => void\n  onReviewUntagged: () => void\n  onViewTag: (tagId: number) => void\n  onCreateTag: (name: string) => void\n  onRenameTag: (tagId: number, name: string) => void\n  onDeleteTag: (tag: TagWithCountDto) => void\n  t: TFunction\n}\n\nconst TagsPage = ({\n  tags,\n  untaggedCount,\n  loading,\n  formatRelative,\n  onBack,\n  onReviewUntagged,\n  onViewTag,\n  onCreateTag,\n  onRenameTag,\n  onDeleteTag,\n  t,\n}: TagsPageProps) => {\n  const [query, setQuery] = useState('')\n  const [newTagName, setNewTagName] = useState('')\n  const filteredTags = useMemo(() => {\n    const normalized = query.trim().toLowerCase()\n    if (!normalized) return tags\n    return tags.filter((tag) => tag.name.toLowerCase().includes(normalized))\n  }, [query, tags])\n\n  const submitNewTag = () => {\n    const name = newTagName.trim()\n    if (!name) return\n    onCreateTag(name)\n    setNewTagName('')\n  }\n\n  return (\n    <div className=\"tags-page\">\n      <div className=\"detail-header\">\n        <button className=\"btn btn-secondary\" type=\"button\" onClick={onBack}>\n          <ArrowLeft size={16} />\n          {t('back')}\n        </button>\n        <div>\n          <div className=\"detail-skill-name\">{t('tags')}</div>\n          <div className=\"tags-page-subtitle\">{t('tagsHelp')}</div>\n        </div>\n      </div>\n\n      <div className=\"tags-review-row\">\n        <div className=\"tags-review-left\">\n          <Tag size={16} />\n          <span>{t('untaggedSkillsCount', { count: untaggedCount })}</span>\n        </div>\n        <button\n          className=\"btn btn-secondary\"\n          type=\"button\"\n          onClick={onReviewUntagged}\n          disabled={untaggedCount === 0}\n        >\n          {t('review')}\n        </button>\n      </div>\n\n      <div className=\"tags-toolbar\">\n        <div className=\"search-container tags-search\">\n          <Search size={16} className=\"search-icon-abs\" />\n          <input\n            className=\"search-input\"\n            value={query}\n            onChange={(event) => setQuery(event.target.value)}\n            placeholder={t('searchTags')}\n          />\n        </div>\n        <div className=\"tags-new-row\">\n          <input\n            className=\"search-input\"\n            value={newTagName}\n            onChange={(event) => setNewTagName(event.target.value)}\n            onKeyDown={(event) => {\n              if (event.key === 'Enter') submitNewTag()\n            }}\n            placeholder={t('newTagPlaceholder')}\n          />\n          <button\n            className=\"btn btn-primary\"\n            type=\"button\"\n            onClick={submitNewTag}\n            disabled={loading || !newTagName.trim()}\n          >\n            <Plus size={14} />\n            {t('newTag')}\n          </button>\n        </div>\n      </div>\n\n      <div className=\"tags-table\">\n        <div className=\"tags-table-row tags-table-head\">\n          <span>{t('tagName')}</span>\n          <span>{t('skills')}</span>\n          <span>{t('lastUsed')}</span>\n          <span>{t('actionsLabel')}</span>\n        </div>\n        {filteredTags.length === 0 ? (\n          <div className=\"empty\">{t('tagsEmpty')}</div>\n        ) : (\n          filteredTags.map((tag) => (\n            <div className=\"tags-table-row\" key={tag.id}>\n              <span className=\"tags-table-name\">#{tag.name}</span>\n              <span>{tag.skill_count}</span>\n              <span>{formatRelative(tag.updated_at)}</span>\n              <span className=\"tags-table-actions\">\n                <button type=\"button\" onClick={() => onViewTag(tag.id)}>\n                  {t('view')}\n                </button>\n                <button\n                  type=\"button\"\n                  onClick={() => {\n                    const nextName = window.prompt(t('renameTagPrompt'), tag.name)\n                    if (nextName?.trim()) onRenameTag(tag.id, nextName)\n                  }}\n                >\n                  {t('rename')}\n                </button>\n                <button type=\"button\" onClick={() => onDeleteTag(tag)}>\n                  {t('deleteAction')}\n                </button>\n              </span>\n            </div>\n          ))\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport default memo(TagsPage)\n"
  },
  {
    "path": "src/components/skills/modals/AddSkillModal.tsx",
    "content": "import { memo } from 'react'\nimport { Check } from 'lucide-react'\nimport type { TFunction } from 'i18next'\nimport type { TagWithCountDto, ToolOption, ToolStatusDto } from '../types'\n\ntype AddSkillModalProps = {\n  open: boolean\n  loading: boolean\n  canClose: boolean\n  addModalTab: 'local' | 'git'\n  localPath: string\n  localName: string\n  gitUrl: string\n  gitName: string\n  tags: TagWithCountDto[]\n  selectedTagIds: number[]\n  syncTargets: Record<string, boolean>\n  installedTools: ToolOption[]\n  toolStatus: ToolStatusDto | null\n  onRequestClose: () => void\n  onTabChange: (tab: 'local' | 'git') => void\n  onLocalPathChange: (value: string) => void\n  onPickLocalPath: () => void\n  onLocalNameChange: (value: string) => void\n  onGitUrlChange: (value: string) => void\n  onGitNameChange: (value: string) => void\n  onToggleTag: (tagId: number) => void\n  onSyncTargetChange: (toolId: string, checked: boolean) => void\n  onSubmit: () => void\n  t: TFunction\n}\n\nconst AddSkillModal = ({\n  open,\n  loading,\n  canClose,\n  addModalTab,\n  localPath,\n  localName,\n  gitUrl,\n  gitName,\n  tags,\n  selectedTagIds,\n  syncTargets,\n  installedTools,\n  toolStatus,\n  onRequestClose,\n  onTabChange,\n  onLocalPathChange,\n  onPickLocalPath,\n  onLocalNameChange,\n  onGitUrlChange,\n  onGitNameChange,\n  onToggleTag,\n  onSyncTargetChange,\n  onSubmit,\n  t,\n}: AddSkillModalProps) => {\n  if (!open) return null\n\n  return (\n    <div\n      className=\"modal-backdrop\"\n      onClick={() => (canClose ? onRequestClose() : null)}\n    >\n      <div className=\"modal\" onClick={(e) => e.stopPropagation()}>\n        <div className=\"modal-header\">\n          <div className=\"modal-title\">{t('addSkillTitle')}</div>\n          <button\n            className=\"modal-close\"\n            type=\"button\"\n            onClick={onRequestClose}\n            aria-label={t('close')}\n            disabled={!canClose}\n          >\n            ✕\n          </button>\n        </div>\n        <div className=\"modal-body\">\n          <div className=\"tabs\">\n            <button\n              className={`tab-item${addModalTab === 'local' ? ' active' : ''}`}\n              type=\"button\"\n              onClick={() => onTabChange('local')}\n            >\n              {t('localTab')}\n            </button>\n            <button\n              className={`tab-item${addModalTab === 'git' ? ' active' : ''}`}\n              type=\"button\"\n              onClick={() => onTabChange('git')}\n            >\n              {t('gitTab')}\n            </button>\n          </div>\n\n          {addModalTab === 'local' ? (\n            <>\n              <div className=\"form-group\">\n                <label className=\"label\">{t('localFolder')}</label>\n                <div className=\"input-row\">\n                  <input\n                    className=\"input\"\n                    placeholder={t('localPathPlaceholder')}\n                    value={localPath}\n                    onChange={(event) => onLocalPathChange(event.target.value)}\n                  />\n                  <button\n                    className=\"btn btn-secondary input-action\"\n                    type=\"button\"\n                    onClick={onPickLocalPath}\n                    disabled={!canClose}\n                  >\n                    {t('browse')}\n                  </button>\n                </div>\n              </div>\n              <div className=\"form-group\">\n                <label className=\"label\">{t('optionalNamePlaceholder')}</label>\n                <input\n                  className=\"input\"\n                  placeholder={t('optionalNamePlaceholder')}\n                  value={localName}\n                  onChange={(event) => onLocalNameChange(event.target.value)}\n                />\n              </div>\n            </>\n          ) : (\n            <>\n              <div className=\"form-group\">\n                <label className=\"label\">{t('repositoryUrl')}</label>\n                <input\n                  className=\"input\"\n                  placeholder={t('gitUrlPlaceholder')}\n                  value={gitUrl}\n                  onChange={(event) => onGitUrlChange(event.target.value)}\n                />\n              </div>\n              <div className=\"form-group\">\n                <label className=\"label\">{t('optionalNamePlaceholder')}</label>\n                <input\n                  className=\"input\"\n                  placeholder={t('optionalNamePlaceholder')}\n                  value={gitName}\n                  onChange={(event) => onGitNameChange(event.target.value)}\n                />\n              </div>\n            </>\n          )}\n\n          <div className=\"form-group\">\n            <label className=\"label\">{t('addTags')}</label>\n            {tags.length > 0 ? (\n              <div className=\"add-tags-list\">\n                {tags.map((tag) => {\n                  const selected = selectedTagIds.includes(tag.id)\n                  return (\n                    <button\n                      key={tag.id}\n                      className={`add-tag-pill${selected ? ' selected' : ''}`}\n                      type=\"button\"\n                      onClick={() => onToggleTag(tag.id)}\n                    >\n                      <span className=\"add-tag-check\">\n                        {selected ? <Check size={12} /> : null}\n                      </span>\n                      <span>#{tag.name}</span>\n                    </button>\n                  )\n                })}\n              </div>\n            ) : (\n              <div className=\"helper-text\">{t('noTagsYet')}</div>\n            )}\n          </div>\n\n          <div className=\"form-group\">\n            <label className=\"label\">{t('installToTools')}</label>\n            {toolStatus ? (\n              <div className=\"tool-matrix\">\n                {installedTools.map((tool) => (\n                  <label\n                    key={tool.id}\n                    className={`tool-pill-toggle${\n                      syncTargets[tool.id] ? ' active' : ''\n                    }`}\n                  >\n                    <input\n                      type=\"checkbox\"\n                      checked={Boolean(syncTargets[tool.id])}\n                      onChange={(event) =>\n                        onSyncTargetChange(tool.id, event.target.checked)\n                      }\n                    />\n                    {syncTargets[tool.id] ? <span className=\"status-badge\" /> : null}\n                    {tool.label}\n                  </label>\n                ))}\n              </div>\n            ) : (\n              <div className=\"helper-text\">{t('detectingTools')}</div>\n            )}\n            <div className=\"helper-text\">{t('syncAfterCreate')}</div>\n          </div>\n        </div>\n        <div className=\"modal-footer\">\n          <button\n            className=\"btn btn-secondary\"\n            onClick={onRequestClose}\n            disabled={!canClose}\n          >\n            {t('cancel')}\n          </button>\n          <button\n            className=\"btn btn-primary\"\n            onClick={onSubmit}\n            disabled={loading}\n          >\n            {addModalTab === 'local' ? t('create') : t('install')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(AddSkillModal)\n"
  },
  {
    "path": "src/components/skills/modals/DeleteModal.tsx",
    "content": "import { memo } from 'react'\nimport { TriangleAlert } from 'lucide-react'\nimport type { TFunction } from 'i18next'\n\ntype DeleteModalProps = {\n  open: boolean\n  loading: boolean\n  skillName: string | null\n  onRequestClose: () => void\n  onConfirm: () => void\n  t: TFunction\n}\n\nconst DeleteModal = ({\n  open,\n  loading,\n  skillName,\n  onRequestClose,\n  onConfirm,\n  t,\n}: DeleteModalProps) => {\n  if (!open) return null\n\n  return (\n    <div className=\"modal-backdrop\" onClick={onRequestClose}>\n      <div\n        className=\"modal modal-delete\"\n        onClick={(e) => e.stopPropagation()}\n        role=\"dialog\"\n        aria-modal=\"true\"\n      >\n        <div className=\"modal-body delete-body\">\n          <div className=\"delete-title\">\n            <TriangleAlert size={20} />\n            {t('deleteTitle')}\n          </div>\n          <div className=\"delete-desc\">\n            {skillName ? (\n              <>\n                {t('delete.confirmPrefix')}\n                <strong>{skillName}</strong>\n                {t('delete.confirmSuffix')}\n              </>\n            ) : (\n              t('deleteBody')\n            )}\n          </div>\n          <div className=\"delete-warning\">\n            <ul>\n              <li>{t('delete.warningRemoveFromTools')}</li>\n              <li>{t('delete.warningDeleteFromHub')}</li>\n            </ul>\n          </div>\n        </div>\n        <div className=\"modal-footer space-between\">\n          <button\n            className=\"btn btn-secondary\"\n            onClick={onRequestClose}\n            disabled={loading}\n          >\n            {t('cancel')}\n          </button>\n          <button\n            className=\"btn btn-danger-solid\"\n            onClick={onConfirm}\n            disabled={loading}\n          >\n            {t('delete.confirmButton')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(DeleteModal)\n"
  },
  {
    "path": "src/components/skills/modals/EditSkillTagsModal.tsx",
    "content": "import { memo, useMemo, useState } from 'react'\nimport { Check, Search } from 'lucide-react'\nimport type { TFunction } from 'i18next'\nimport type { ManagedSkill, TagWithCountDto } from '../types'\n\ntype EditSkillTagsModalProps = {\n  open: boolean\n  loading: boolean\n  skill: ManagedSkill | null\n  tags: TagWithCountDto[]\n  onRequestClose: () => void\n  onSave: (skill: ManagedSkill, tagIds: number[]) => void\n  t: TFunction\n}\n\nconst EditSkillTagsModal = ({\n  open,\n  loading,\n  skill,\n  tags,\n  onRequestClose,\n  onSave,\n  t,\n}: EditSkillTagsModalProps) => {\n  const [query, setQuery] = useState('')\n  const [selectedIds, setSelectedIds] = useState<number[]>(\n    () => skill?.tags.map((tag) => tag.id) ?? [],\n  )\n  const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])\n  const filteredTags = useMemo(() => {\n    const normalized = query.trim().toLowerCase()\n    if (!normalized) return tags\n    return tags.filter((tag) => tag.name.toLowerCase().includes(normalized))\n  }, [query, tags])\n\n  if (!open || !skill) return null\n\n  const toggleTag = (tagId: number) => {\n    setSelectedIds((current) =>\n      current.includes(tagId)\n        ? current.filter((id) => id !== tagId)\n        : [...current, tagId],\n    )\n  }\n\n  return (\n    <div className=\"modal-backdrop\" onClick={loading ? undefined : onRequestClose}>\n      <div className=\"modal edit-tags-modal\" onClick={(event) => event.stopPropagation()}>\n        <div className=\"modal-header\">\n          <div>\n            <div className=\"modal-title\">{t('editTagsTitle', { name: skill.name })}</div>\n            <div className=\"modal-subtitle\">{t('editTagsHelp')}</div>\n          </div>\n          <button className=\"modal-close\" type=\"button\" onClick={onRequestClose}>\n            ×\n          </button>\n        </div>\n        <div className=\"tag-filter-search edit-tags-search\">\n          <Search size={15} />\n          <input\n            value={query}\n            onChange={(event) => setQuery(event.target.value)}\n            placeholder={t('searchTags')}\n          />\n        </div>\n        <div className=\"edit-tags-list\">\n          {filteredTags.length === 0 ? (\n            <div className=\"empty\">{t('tagsEmpty')}</div>\n          ) : (\n            filteredTags.map((tag) => {\n              const selected = selectedSet.has(tag.id)\n              return (\n                <button\n                  key={tag.id}\n                  className={`tag-filter-option${selected ? ' selected' : ''}`}\n                  type=\"button\"\n                  onClick={() => toggleTag(tag.id)}\n                >\n                  <span className=\"tag-check\">{selected ? <Check size={14} /> : null}</span>\n                  <span>{tag.name}</span>\n                  <span className=\"tag-count\">{tag.skill_count}</span>\n                </button>\n              )\n            })\n          )}\n        </div>\n        <div className=\"modal-actions\">\n          <button className=\"btn btn-secondary\" type=\"button\" onClick={onRequestClose}>\n            {t('cancel')}\n          </button>\n          <button\n            className=\"btn btn-primary\"\n            type=\"button\"\n            disabled={loading}\n            onClick={() => onSave(skill, selectedIds)}\n          >\n            {t('done')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(EditSkillTagsModal)\n"
  },
  {
    "path": "src/components/skills/modals/GitPickModal.tsx",
    "content": "import { memo, useMemo, useState } from 'react'\nimport { Search } from 'lucide-react'\nimport type { TFunction } from 'i18next'\nimport type { GitSkillCandidate } from '../types'\n\ntype GitPickModalProps = {\n  open: boolean\n  loading: boolean\n  gitCandidates: GitSkillCandidate[]\n  gitCandidateSelected: Record<string, boolean>\n  onRequestClose: () => void\n  onCancel: () => void\n  onToggleCandidate: (subpath: string, checked: boolean) => void\n  onInstall: () => void\n  t: TFunction\n}\n\nconst GitPickModal = ({\n  open,\n  loading,\n  gitCandidates,\n  gitCandidateSelected,\n  onRequestClose,\n  onCancel,\n  onToggleCandidate,\n  onInstall,\n  t,\n}: GitPickModalProps) => {\n  const [query, setQuery] = useState('')\n  const normalizedQuery = query.trim().toLowerCase()\n  const filteredCandidates = useMemo(() => {\n    if (!normalizedQuery) return gitCandidates\n    return gitCandidates.filter((c) =>\n      [c.name, c.description ?? '', c.subpath].some((value) =>\n        value.toLowerCase().includes(normalizedQuery),\n      ),\n    )\n  }, [gitCandidates, normalizedQuery])\n  const selectedCount = filteredCandidates.filter(\n    (c) => gitCandidateSelected[c.subpath],\n  ).length\n  const allVisibleSelected =\n    filteredCandidates.length > 0 &&\n    filteredCandidates.every((c) => gitCandidateSelected[c.subpath])\n\n  const toggleVisibleCandidates = (checked: boolean) => {\n    filteredCandidates.forEach((c) => onToggleCandidate(c.subpath, checked))\n  }\n\n  if (!open) return null\n\n  return (\n    <div className=\"modal-backdrop\" onClick={onRequestClose}>\n      <div className=\"modal\" onClick={(e) => e.stopPropagation()}>\n        <div className=\"modal-header\">\n          <div className=\"modal-title\">{t('gitPickTitle')}</div>\n          <button\n            className=\"modal-close\"\n            type=\"button\"\n            onClick={onRequestClose}\n            aria-label={t('close')}\n          >\n            ✕\n          </button>\n        </div>\n        <div className=\"modal-body\">\n          <p className=\"label\">{t('gitPickBody')}</p>\n          <div className=\"pick-search\">\n            <Search size={16} className=\"search-icon-abs\" />\n            <input\n              className=\"search-input\"\n              value={query}\n              onChange={(event) => setQuery(event.target.value)}\n              placeholder={t('pickSearchPlaceholder')}\n            />\n          </div>\n          <div className=\"pick-toolbar\">\n            <label className=\"inline-checkbox\">\n              <input\n                type=\"checkbox\"\n                checked={allVisibleSelected}\n                onChange={(e) => toggleVisibleCandidates(e.target.checked)}\n                disabled={filteredCandidates.length === 0}\n              />\n              {t('selectAll')}\n            </label>\n            <span className=\"pick-toolbar-count\">\n              {t('selectedCount', {\n                selected: selectedCount,\n                total: filteredCandidates.length,\n              })}\n            </span>\n          </div>\n          <div className=\"pick-list\">\n            {filteredCandidates.length === 0 ? (\n              <div className=\"empty\">{t('pickSearchEmpty')}</div>\n            ) : null}\n            {filteredCandidates.map((c) => (\n              <div className=\"pick-item\" key={c.subpath}>\n                <label className=\"pick-item-checkbox\">\n                  <input\n                    type=\"checkbox\"\n                    checked={Boolean(gitCandidateSelected[c.subpath])}\n                    onChange={(e) => onToggleCandidate(c.subpath, e.target.checked)}\n                  />\n                </label>\n                <div className=\"pick-item-main\">\n                  <div className=\"pick-item-title\">{c.name}</div>\n                  {c.description ? (\n                    <div className=\"pick-item-desc\">{c.description}</div>\n                  ) : null}\n                  <div className=\"pick-item-path\">{c.subpath}</div>\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n        <div className=\"modal-footer\">\n          <button className=\"btn btn-secondary\" onClick={onCancel} disabled={loading}>\n            {t('cancel')}\n          </button>\n          <button className=\"btn btn-primary\" onClick={onInstall} disabled={loading}>\n            {t('installSelected')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(GitPickModal)\n"
  },
  {
    "path": "src/components/skills/modals/ImportModal.tsx",
    "content": "import { memo, useMemo, useState } from 'react'\nimport { Download, Search } from 'lucide-react'\nimport type { TFunction } from 'i18next'\nimport type { OnboardingPlan } from '../types'\n\ntype ImportModalProps = {\n  open: boolean\n  loading: boolean\n  plan: OnboardingPlan\n  selected: Record<string, boolean>\n  variantChoice: Record<string, string>\n  onRequestClose: () => void\n  onToggleGroup: (groupName: string, checked: boolean) => void\n  onSelectVariant: (groupName: string, path: string) => void\n  onImport: () => void\n  t: TFunction\n}\n\nconst ImportModal = ({\n  open,\n  loading,\n  plan,\n  selected,\n  variantChoice,\n  onRequestClose,\n  onToggleGroup,\n  onSelectVariant,\n  onImport,\n  t,\n}: ImportModalProps) => {\n  const [query, setQuery] = useState('')\n  const normalizedQuery = query.trim().toLowerCase()\n  const filteredGroups = useMemo(() => {\n    if (!normalizedQuery) return plan.groups\n    return plan.groups.filter((group) => {\n      const fields = [\n        group.name,\n        ...group.variants.flatMap((variant) => [variant.path, variant.tool]),\n      ]\n      return fields.some((value) => value.toLowerCase().includes(normalizedQuery))\n    })\n  }, [normalizedQuery, plan.groups])\n  const selectedCount = filteredGroups.filter((group) => selected[group.name]).length\n  const allVisibleSelected =\n    filteredGroups.length > 0 &&\n    filteredGroups.every((group) => selected[group.name])\n\n  const toggleVisibleGroups = (checked: boolean) => {\n    filteredGroups.forEach((group) => onToggleGroup(group.name, checked))\n  }\n\n  if (!open) return null\n\n  return (\n    <div className=\"modal-backdrop\" onClick={onRequestClose}>\n      <div\n        className=\"modal modal-lg modal-discovered\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        <div className=\"modal-header\">\n          <div className=\"modal-title\">{t('importTitle')}</div>\n          <button\n            className=\"modal-close\"\n            type=\"button\"\n            onClick={onRequestClose}\n            aria-label={t('close')}\n          >\n            ✕\n          </button>\n        </div>\n        <div className=\"modal-body\">\n          <div className=\"import-summary\">\n            <div>{t('importSummary')}</div>\n            <div className=\"import-metrics\">\n              <span>{t('toolsScanned', { count: plan.total_tools_scanned })}</span>\n              <span>{t('skillsFound', { count: plan.total_skills_found })}</span>\n            </div>\n          </div>\n          <div className=\"search-container import-search\">\n            <Search size={16} className=\"search-icon-abs\" />\n            <input\n              className=\"search-input\"\n              value={query}\n              onChange={(event) => setQuery(event.target.value)}\n              placeholder={t('searchPlaceholder')}\n            />\n          </div>\n          <div className=\"sync-row\">\n            <label className=\"inline-checkbox\">\n              <input\n                type=\"checkbox\"\n                checked={allVisibleSelected}\n                onChange={(event) => toggleVisibleGroups(event.target.checked)}\n                disabled={filteredGroups.length === 0}\n              />\n              {t('selectAll')}\n            </label>\n            <span className=\"pick-toolbar-count\">\n              {t('selectedCount', {\n                selected: selectedCount,\n                total: filteredGroups.length,\n              })}\n            </span>\n          </div>\n          <div className=\"groups discovered-list\">\n            {filteredGroups.length === 0 ? (\n              <div className=\"empty\">{t('importSearchEmpty')}</div>\n            ) : null}\n            {filteredGroups.map((group) => (\n              <div className=\"group-card\" key={group.name}>\n                <div className=\"group-title\">\n                  <label className=\"group-select\">\n                    <input\n                      type=\"checkbox\"\n                      checked={Boolean(selected[group.name])}\n                      onChange={(event) =>\n                        onToggleGroup(group.name, event.target.checked)\n                      }\n                    />\n                    <span>{group.name}</span>\n                  </label>\n                  {group.has_conflict ? (\n                    <span className=\"badge danger\">{t('conflict')}</span>\n                  ) : (\n                    <span className=\"badge\">{t('consistent')}</span>\n                  )}\n                </div>\n                <div className=\"group-variants\">\n                  {group.variants.map((variant) => (\n                    <div\n                      className=\"variant-row\"\n                      key={`${group.name}-${variant.tool}-${variant.path}`}\n                    >\n                      {group.has_conflict ? (\n                        <input\n                          type=\"radio\"\n                          name={`variant-${group.name}`}\n                          checked={variantChoice[group.name] === variant.path}\n                          onChange={() => onSelectVariant(group.name, variant.path)}\n                        />\n                      ) : (\n                        <span className=\"variant-spacer\" />\n                      )}\n                      <div className=\"variant-info\">\n                        <span className=\"path\">{variant.path}</span>\n                        <span className=\"found-pill\">\n                          {t('foundIn')} {variant.tool}\n                        </span>\n                      </div>\n                      {variant.is_link ? (\n                        <span className=\"meta\">\n                          {t('linkLabel', {\n                            target: variant.link_target ?? t('unknown'),\n                          })}\n                        </span>\n                      ) : (\n                        <span className=\"meta\">{t('directory')}</span>\n                      )}\n                    </div>\n                  ))}\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n        <div className=\"modal-footer\">\n          <button\n            className=\"btn btn-primary\"\n            onClick={onImport}\n            disabled={loading || selectedCount === 0}\n          >\n            <Download size={14} />\n            {t('importAndSync')}\n          </button>\n          <button\n            className=\"btn btn-secondary\"\n            onClick={onRequestClose}\n            disabled={loading}\n          >\n            {t('close')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(ImportModal)\n"
  },
  {
    "path": "src/components/skills/modals/LocalPickModal.tsx",
    "content": "import { memo, useMemo, useState } from 'react'\nimport { Search } from 'lucide-react'\nimport type { TFunction } from 'i18next'\nimport type { LocalSkillCandidate } from '../types'\n\ntype LocalPickModalProps = {\n  open: boolean\n  loading: boolean\n  localCandidates: LocalSkillCandidate[]\n  localCandidateSelected: Record<string, boolean>\n  onRequestClose: () => void\n  onCancel: () => void\n  onToggleCandidate: (subpath: string, checked: boolean) => void\n  onInstall: () => void\n  t: TFunction\n}\n\nconst LocalPickModal = ({\n  open,\n  loading,\n  localCandidates,\n  localCandidateSelected,\n  onRequestClose,\n  onCancel,\n  onToggleCandidate,\n  onInstall,\n  t,\n}: LocalPickModalProps) => {\n  const [query, setQuery] = useState('')\n  const normalizedQuery = query.trim().toLowerCase()\n  const filteredCandidates = useMemo(() => {\n    if (!normalizedQuery) return localCandidates\n    return localCandidates.filter((c) =>\n      [c.name, c.description ?? '', c.subpath].some((value) =>\n        value.toLowerCase().includes(normalizedQuery),\n      ),\n    )\n  }, [localCandidates, normalizedQuery])\n  const selectableCandidates = filteredCandidates.filter((c) => c.valid)\n  const selectedCount = selectableCandidates.filter(\n    (c) => localCandidateSelected[c.subpath],\n  ).length\n  const selectableCount = selectableCandidates.length\n  const allVisibleSelected =\n    selectableCount > 0 &&\n    selectableCandidates.every((c) => localCandidateSelected[c.subpath])\n\n  const toggleVisibleCandidates = (checked: boolean) => {\n    selectableCandidates.forEach((c) => onToggleCandidate(c.subpath, checked))\n  }\n\n  if (!open) return null\n\n  const mapReason = (code?: string | null) => {\n    if (!code) return t('localSkillInvalid.unknown')\n    if (code === 'missing_skill_md') return t('localSkillInvalid.missingSkillMd')\n    if (code === 'invalid_frontmatter') return t('localSkillInvalid.invalidFrontmatter')\n    if (code === 'missing_name') return t('localSkillInvalid.missingName')\n    if (code === 'read_failed') return t('localSkillInvalid.readFailed')\n    return t('localSkillInvalid.unknown')\n  }\n\n  return (\n    <div className=\"modal-backdrop\" onClick={onRequestClose}>\n      <div className=\"modal\" onClick={(e) => e.stopPropagation()}>\n        <div className=\"modal-header\">\n          <div className=\"modal-title\">{t('localPickTitle')}</div>\n          <button\n            className=\"modal-close\"\n            type=\"button\"\n            onClick={onRequestClose}\n            aria-label={t('close')}\n          >\n            ✕\n          </button>\n        </div>\n        <div className=\"modal-body\">\n          <p className=\"label\">{t('localPickBody')}</p>\n          <div className=\"pick-search\">\n            <Search size={16} className=\"search-icon-abs\" />\n            <input\n              className=\"search-input\"\n              value={query}\n              onChange={(event) => setQuery(event.target.value)}\n              placeholder={t('pickSearchPlaceholder')}\n            />\n          </div>\n          <div className=\"pick-toolbar\">\n            <label className=\"inline-checkbox\">\n              <input\n                type=\"checkbox\"\n                checked={allVisibleSelected}\n                onChange={(e) => toggleVisibleCandidates(e.target.checked)}\n                disabled={selectableCount === 0}\n              />\n              {t('selectAll')}\n            </label>\n            <span className=\"pick-toolbar-count\">\n              {t('selectedCount', {\n                selected: selectedCount,\n                total: selectableCount,\n              })}\n            </span>\n          </div>\n          <div className=\"pick-list\">\n            {filteredCandidates.length === 0 ? (\n              <div className=\"empty\">{t('pickSearchEmpty')}</div>\n            ) : null}\n            {filteredCandidates.map((c) => (\n              <div\n                className={`pick-item${c.valid ? '' : ' disabled'}`}\n                key={c.subpath}\n              >\n                <label className=\"pick-item-checkbox\">\n                  <input\n                    type=\"checkbox\"\n                    checked={Boolean(localCandidateSelected[c.subpath])}\n                    onChange={(e) => onToggleCandidate(c.subpath, e.target.checked)}\n                    disabled={!c.valid}\n                  />\n                </label>\n                <div className=\"pick-item-main\">\n                  <div className=\"pick-item-title\">{c.name}</div>\n                  {c.description ? (\n                    <div className=\"pick-item-desc\">{c.description}</div>\n                  ) : null}\n                  <div className=\"pick-item-path\">{c.subpath}</div>\n                  {!c.valid ? (\n                    <div className=\"pick-item-reason\">\n                      {t('localPickInvalidReason', { reason: mapReason(c.reason) })}\n                    </div>\n                  ) : null}\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n        <div className=\"modal-footer\">\n          <button className=\"btn btn-secondary\" onClick={onCancel} disabled={loading}>\n            {t('cancel')}\n          </button>\n          <button className=\"btn btn-primary\" onClick={onInstall} disabled={loading}>\n            {t('installSelected')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(LocalPickModal)\n"
  },
  {
    "path": "src/components/skills/modals/NewToolsModal.tsx",
    "content": "import { memo } from 'react'\nimport type { TFunction } from 'i18next'\n\ntype NewToolsModalProps = {\n  open: boolean\n  loading: boolean\n  toolsLabelText: string\n  onLater: () => void\n  onSyncAll: () => void\n  t: TFunction\n}\n\nconst NewToolsModal = ({\n  open,\n  loading,\n  toolsLabelText,\n  onLater,\n  onSyncAll,\n  t,\n}: NewToolsModalProps) => {\n  if (!open) return null\n\n  return (\n    <div className=\"modal-backdrop\" onClick={onLater}>\n      <div className=\"modal\" onClick={(e) => e.stopPropagation()} role=\"dialog\" aria-modal=\"true\">\n        <div className=\"modal-header\">\n          <div className=\"modal-title\">{t('newToolsTitle')}</div>\n        </div>\n        <div className=\"modal-body\">\n          {t('newToolsBody', {\n            tools: toolsLabelText,\n          })}\n        </div>\n        <div className=\"modal-footer\">\n          <button className=\"btn btn-secondary\" onClick={onLater} disabled={loading}>\n            {t('later')}\n          </button>\n          <button className=\"btn btn-primary\" onClick={onSyncAll} disabled={loading}>\n            {t('syncAll')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(NewToolsModal)\n"
  },
  {
    "path": "src/components/skills/modals/ScopeSyncModal.tsx",
    "content": "import { memo, useMemo, useState } from 'react'\nimport { Folder, X } from 'lucide-react'\nimport type { TFunction } from 'i18next'\nimport type { ManagedSkill } from '../types'\n\ntype ScopeSyncModalProps = {\n  open: boolean\n  loading: boolean\n  skill: ManagedSkill | null\n  scope: 'global' | 'project'\n  projects: string[]\n  recentProjects: string[]\n  onRequestClose: () => void\n  onScopeChange: (scope: 'global' | 'project', projects: string[]) => void\n  onPickProject: () => Promise<string | undefined>\n  t: TFunction\n}\n\nconst ScopeSyncModal = ({\n  open,\n  loading,\n  skill,\n  scope,\n  projects,\n  recentProjects,\n  onRequestClose,\n  onScopeChange,\n  onPickProject,\n  t,\n}: ScopeSyncModalProps) => {\n  const [draftScope, setDraftScope] = useState<'global' | 'project'>(scope)\n  const [draftProjects, setDraftProjects] = useState<string[]>(projects)\n\n  const normalizedProjects = useMemo(\n    () => Array.from(new Set(projects.filter(Boolean))),\n    [projects],\n  )\n  const normalizedDraftProjects = useMemo(\n    () => Array.from(new Set(draftProjects.filter(Boolean))),\n    [draftProjects],\n  )\n  const projectListChanged =\n    normalizedProjects.length !== normalizedDraftProjects.length ||\n    normalizedProjects.some((item) => !normalizedDraftProjects.includes(item))\n  const availableRecent = recentProjects.filter(\n    (item) => !normalizedDraftProjects.includes(item),\n  )\n  const hasScopeChange = draftScope !== scope\n  const requiresProject = draftScope === 'project' && normalizedDraftProjects.length === 0\n  const addDraftProject = (projectPath: string) => {\n    setDraftProjects((prev) => Array.from(new Set([...prev, projectPath].filter(Boolean))))\n  }\n\n  if (!open || !skill) return null\n\n  return (\n    <div className=\"modal-backdrop\" onClick={loading ? undefined : onRequestClose}>\n      <div\n        className=\"modal scope-modal\"\n        role=\"dialog\"\n        aria-modal=\"true\"\n        onClick={(event) => event.stopPropagation()}\n      >\n        <div className=\"modal-header\">\n          <div className=\"modal-title\">\n            {t('projectSync.title')} · {skill.name}\n          </div>\n          <button\n            className=\"modal-close\"\n            type=\"button\"\n            onClick={onRequestClose}\n            disabled={loading}\n            aria-label={t('close')}\n          >\n            ✕\n          </button>\n        </div>\n        <div className=\"modal-body scope-modal-body\">\n          <div className=\"scope-help\">{t('projectSync.help')}</div>\n          <label className={`scope-choice${draftScope === 'global' ? ' active' : ''}`}>\n            <input\n              type=\"radio\"\n              checked={draftScope === 'global'}\n              onChange={() => setDraftScope('global')}\n              disabled={loading}\n            />\n            <span>\n              <strong>{t('scope.global')}</strong>\n              <small>{t('projectSync.globalDesc')}</small>\n            </span>\n          </label>\n          <label className={`scope-choice${draftScope === 'project' ? ' active' : ''}`}>\n            <input\n              type=\"radio\"\n              checked={draftScope === 'project'}\n              onChange={() => setDraftScope('project')}\n              disabled={loading}\n            />\n            <span>\n              <strong>{t('scope.project')}</strong>\n              <small>{t('projectSync.projectDesc')}</small>\n            </span>\n          </label>\n\n          {draftScope === 'project' ? (\n            <div className=\"project-sync-panel\">\n              <div className=\"project-sync-heading\">{t('projectSync.projectDirs')}</div>\n              {normalizedDraftProjects.length > 0 ? (\n                <div className=\"project-path-list\">\n                  {normalizedDraftProjects.map((project) => (\n                    <div className=\"project-path-row\" key={project}>\n                      <Folder size={14} />\n                      <span className=\"mono\">{project}</span>\n                      <button\n                        type=\"button\"\n                        className=\"icon-btn\"\n                        onClick={() =>\n                          setDraftProjects((prev) => prev.filter((item) => item !== project))\n                        }\n                        disabled={loading}\n                        aria-label={t('remove')}\n                      >\n                        <X size={14} />\n                      </button>\n                    </div>\n                  ))}\n                </div>\n              ) : (\n                <div className=\"project-empty\">{t('projectSync.noProjects')}</div>\n              )}\n              {requiresProject ? (\n                <div className=\"scope-inline-warning\">\n                  {t('projectSync.projectRequired')}\n                </div>\n              ) : null}\n              <button\n                type=\"button\"\n                className=\"btn btn-secondary\"\n                onClick={() => {\n                  void onPickProject().then((projectPath) => {\n                    if (projectPath) addDraftProject(projectPath)\n                  })\n                }}\n                disabled={loading}\n              >\n                {t('projectSync.addProject')}\n              </button>\n\n              {availableRecent.length > 0 ? (\n                <>\n                  <div className=\"project-sync-heading\">{t('projectSync.recentProjects')}</div>\n                  <div className=\"recent-project-list\">\n                    {availableRecent.map((project) => (\n                      <button\n                        key={project}\n                        type=\"button\"\n                        className=\"recent-project-row\"\n                        onClick={() => addDraftProject(project)}\n                        disabled={loading}\n                      >\n                        <span className=\"mono\">{project}</span>\n                        <span>{t('projectSync.addRecent')}</span>\n                      </button>\n                    ))}\n                  </div>\n                </>\n              ) : null}\n            </div>\n          ) : null}\n        </div>\n        <div className=\"modal-footer\">\n          <button\n            className=\"btn btn-secondary\"\n            type=\"button\"\n            onClick={onRequestClose}\n            disabled={loading}\n          >\n            {t('cancel')}\n          </button>\n          <button\n            className=\"btn btn-primary\"\n            type=\"button\"\n            onClick={() => onScopeChange(draftScope, normalizedDraftProjects)}\n            disabled={loading || (!hasScopeChange && !projectListChanged) || requiresProject}\n          >\n            {t('projectSync.applyScope')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(ScopeSyncModal)\n"
  },
  {
    "path": "src/components/skills/modals/SharedDirModal.tsx",
    "content": "import { memo } from 'react'\nimport type { TFunction } from 'i18next'\n\ntype SharedDirModalProps = {\n  open: boolean\n  loading: boolean\n  toolLabel: string\n  otherLabels: string\n  onRequestClose: () => void\n  onConfirm: () => void\n  t: TFunction\n}\n\nconst SharedDirModal = ({\n  open,\n  loading,\n  toolLabel,\n  otherLabels,\n  onRequestClose,\n  onConfirm,\n  t,\n}: SharedDirModalProps) => {\n  if (!open) return null\n\n  return (\n    <div className=\"modal-backdrop\" onClick={onRequestClose}>\n      <div\n        className=\"modal\"\n        onClick={(e) => e.stopPropagation()}\n        role=\"dialog\"\n        aria-modal=\"true\"\n      >\n        <div className=\"modal-header\">\n          <div className=\"modal-title\">{t('appName')}</div>\n        </div>\n        <div className=\"modal-body\">\n          {t('sharedDirConfirm', {\n            tool: toolLabel,\n            others: otherLabels,\n          })}\n        </div>\n        <div className=\"modal-footer\">\n          <button\n            className=\"btn btn-secondary\"\n            onClick={onRequestClose}\n            disabled={loading}\n          >\n            {t('cancel')}\n          </button>\n          <button className=\"btn btn-primary\" onClick={onConfirm} disabled={loading}>\n            {t('confirm')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default memo(SharedDirModal)\n"
  },
  {
    "path": "src/components/skills/types.ts",
    "content": "export type OnboardingVariant = {\n  tool: string\n  name: string\n  path: string\n  fingerprint?: string | null\n  is_link: boolean\n  link_target?: string | null\n}\n\nexport type OnboardingGroup = {\n  name: string\n  variants: OnboardingVariant[]\n  has_conflict: boolean\n}\n\nexport type OnboardingPlan = {\n  total_tools_scanned: number\n  total_skills_found: number\n  groups: OnboardingGroup[]\n}\n\nexport type ToolOption = {\n  id: string\n  label: string\n  supports_project_scope?: boolean\n}\n\nexport type TagDto = {\n  id: number\n  name: string\n}\n\nexport type TagWithCountDto = TagDto & {\n  skill_count: number\n  updated_at: number\n}\n\nexport type ManagedSkill = {\n  id: string\n  name: string\n  description?: string | null\n  source_type: string\n  source_ref?: string | null\n  central_path: string\n  created_at: number\n  updated_at: number\n  last_sync_at?: number | null\n  status: string\n  tags: TagDto[]\n  targets: {\n    tool: string\n    scope: 'global' | 'project' | string\n    project_path?: string | null\n    mode: string\n    status: string\n    target_path: string\n    synced_at?: number | null\n  }[]\n}\n\nexport type GitSkillCandidate = {\n  name: string\n  description?: string | null\n  subpath: string\n}\n\nexport type LocalSkillCandidate = {\n  name: string\n  description?: string | null\n  subpath: string\n  valid: boolean\n  reason?: string | null\n}\n\nexport type InstallResultDto = {\n  skill_id: string\n  name: string\n  central_path: string\n  content_hash?: string | null\n}\n\nexport type ToolInfoDto = {\n  key: string\n  label: string\n  installed: boolean\n  skills_dir: string\n  supports_project_scope: boolean\n}\n\nexport type ToolStatusDto = {\n  tools: ToolInfoDto[]\n  installed: string[]\n  newly_installed: string[]\n}\n\nexport type UpdateResultDto = {\n  skill_id: string\n  name: string\n  content_hash?: string | null\n  source_revision?: string | null\n  updated_targets: string[]\n}\n\nexport type FeaturedSkillDto = {\n  slug: string\n  name: string\n  summary: string\n  downloads: number\n  stars: number\n  source_url: string\n}\n\nexport type OnlineSkillDto = {\n  name: string\n  installs: number\n  source: string\n  source_url: string\n}\n\nexport type SkillFileEntry = {\n  path: string\n  size: number\n}\n"
  },
  {
    "path": "src/i18n/index.ts",
    "content": "import i18n from 'i18next'\nimport { initReactI18next } from 'react-i18next'\nimport { resources } from './resources'\n\nconst languageStorageKey = 'skills-language'\n\nconst getStoredLanguage = () => {\n  if (typeof window === 'undefined') return null\n  try {\n    const stored = window.localStorage.getItem(languageStorageKey)\n    if (stored === 'en' || stored === 'zh') return stored\n  } catch {\n    // ignore storage failures\n  }\n  return null\n}\n\nvoid i18n.use(initReactI18next).init({\n  resources,\n  lng: getStoredLanguage() ?? 'en',\n  fallbackLng: 'en',\n  interpolation: {\n    escapeValue: false,\n  },\n})\n\nexport default i18n\n"
  },
  {
    "path": "src/i18n/resources.ts",
    "content": "export const resources = {\n  en: {\n    translation: {\n      appName: 'Skills Hub',\n      unknown: 'unknown',\n      languageShort: {\n        en: 'EN',\n        zh: '中文',\n      },\n      languageOptions: {\n        en: 'English',\n        zh: '中文',\n      },\n      subtitle: 'Manage and sync your skills across tools',\n      navMySkills: 'My Skills',\n      navExplore: 'Explore',\n      navTags: 'Tags',\n      newSkill: 'New Skill',\n      manualAdd: 'Manual',\n      manualAddHint: 'Have a Git URL or local path? Click <b>Manual</b> to add directly',\n      moreTools: '+{{count}} more',\n      installSuccess: '\"{{name}}\" installed and synced to {{count}} tools',\n      settings: 'Settings',\n      language: 'Language',\n      interfaceLanguage: 'Interface Language',\n      themeMode: 'Appearance',\n      themeOptions: {\n        system: 'System',\n        light: 'Light',\n        dark: 'Dark',\n      },\n      filterSort: 'Sort',\n      allSkills: 'All Skills',\n      scope: {\n        all: 'All',\n        global: 'Global',\n        project: 'Project',\n        filterLabel: 'Scope filter',\n        globalBadge: 'Global',\n        projectCount: '{{count}} projects',\n      },\n      projectSync: {\n        title: 'Sync Scope',\n        help: 'Choose where this Skill is available.',\n        globalDesc: 'Available in all projects',\n        projectDesc: 'Available only in selected projects',\n        projectDirs: 'Project directories',\n        addProject: 'Select project directory...',\n        selectProjectTitle: 'Select project directory',\n        recentProjects: 'Recent',\n        addRecent: 'Add',\n        noProjects: 'No project directories selected.',\n        projectRequired: 'Select at least one project directory to apply project scope.',\n        noProjectsForSync: 'Select at least one project directory before syncing.',\n        unsupportedTool: '{{tool}} does not support project scope.',\n        inlineGlobalClear:\n          'Applying this will remove existing global links, then sync this skill to all currently installed tools in the selected project directories.',\n        inlineProjectClear:\n          'Applying this will remove existing project links, then sync this skill globally to all currently installed tools.',\n        applyScope: 'Apply',\n        confirmBody: 'The following global links will be removed:',\n        confirmBodyProject: 'The following project links will be removed:',\n      },\n      sortUpdated: 'Most recent',\n      sortName: 'Name A–Z',\n      searchPlaceholder: 'Search skills...',\n      tags: 'Tags',\n      tagsSelected: 'Tags: {{count}} selected',\n      matchAny: 'Match any',\n      searchTags: 'Search tags...',\n      untagged: 'Untagged',\n      noTags: 'No tags',\n      editTags: 'Edit tags',\n      clearAll: 'Clear all',\n      manageTags: 'Manage Tags...',\n      tagsHelp: 'Tags help you filter and organize skills. They do not change sync results.',\n      untaggedSkillsCount: '{{count}} skills have no tags',\n      review: 'Review',\n      newTag: 'New Tag',\n      newTagPlaceholder: 'New tag name',\n      tagName: 'Tag name',\n      skills: 'Skills',\n      lastUsed: 'Last used',\n      actionsLabel: 'Actions',\n      view: 'View',\n      rename: 'Rename',\n      deleteAction: 'Delete',\n      tagsEmpty: 'No tags found.',\n      renameTagPrompt: 'Rename tag',\n      deleteTagTitle: 'Delete tag?',\n      deleteTagConfirm:\n        'Delete \"{{name}}\" from {{count}} skills?\\nThis only removes the tag, not the skills.',\n      editTagsTitle: 'Edit Tags: {{name}}',\n      editTagsHelp: 'Choose the labels used to find this skill.',\n      tagCreated: 'Tag created.',\n      tagRenamed: 'Tag renamed.',\n      tagDeleted: 'Tag deleted.',\n      tagsUpdated: 'Tags updated.',\n      tagsApplyFailed: 'Failed to apply tags to \"{{name}}\".',\n      back: 'Back',\n      discoveredTitle: 'Discovered skills',\n      discoveredEmpty: 'Scan your tools to find existing skills to import.',\n      discoveredCount: 'Found {{count}} skills ready for review.',\n      reviewImport: 'Review & Import',\n      scanNow: 'Scan now',\n      skillsTitle: 'Managed skills',\n      skillsEmpty: 'No managed skills yet.',\n      toolsLabel: 'Tools',\n      activeTools: 'Active Tools',\n      sourceLabel: 'Source',\n      pathLabel: 'Path',\n      updatedLabel: 'Updated',\n      statusLabel: 'Status',\n      addSkillTitle: 'Add skill',\n      installToTools: 'Install to tools',\n      addTags: 'Add tags',\n      noTagsYet: 'No tags yet.',\n      syncAfterCreate: 'Sync to selected tools after creation',\n      localImportTitle: 'Import from local folder',\n      gitImportTitle: 'Import from Git repository',\n      localPathPlaceholder: 'Local folder path',\n      gitUrlPlaceholder: 'Git URL',\n      optionalNamePlaceholder: 'Optional display name',\n      create: 'Create',\n      detectingTools: 'Detecting installed tools...',\n      importTitle: 'Review discovered skills',\n      importSummary: 'Select skills to import and sync to your tools.',\n      importAndSync: 'Import & Sync',\n      close: 'Close',\n      selectAll: 'Select all',\n      selectedCount: '{{selected}} / {{total}} selected',\n      pickSearchPlaceholder: 'Search by name, description, or path...',\n      pickSearchEmpty: 'No matching skills found.',\n      importSearchEmpty: 'No matching discovered skills found.',\n      partialFailure: 'Some actions did not complete',\n      conflict: 'Conflict',\n      consistent: 'Match',\n      directory: 'Folder',\n      linkLabel: 'link → {{target}}',\n      deleteTitle: 'Remove skill?',\n      deleteBody:\n        'This will delete the managed record and remove synced links created by this app.',\n      cancel: 'Cancel',\n      confirm: 'Confirm',\n      confirmRemove: 'Remove',\n      maintenance: 'Maintenance',\n      storagePath: 'Storage path',\n      skillsStoragePath: 'Skills Storage Path',\n      skillsStorageHint: 'Local copies of Git skills will be stored here.',\n      gitCacheCleanupDays: 'Git cache cleanup (days)',\n      gitCacheCleanupHint:\n        'Remove cached Git repos not fetched within the past N days. Set to 0 to disable.',\n      gitCacheTtlSecs: 'Git cache freshness (seconds)',\n      gitCacheTtlHint:\n        'Skip git fetch when the cache was updated within this window. Set to 0 to always fetch.',\n      githubToken: 'GitHub Token',\n      githubTokenPlaceholder: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',\n      githubTokenHint:\n        'Optional. Set a GitHub personal access token to increase API rate limits from 60/hr to 5,000/hr.',\n      appUpdates: 'App updates',\n      updateHint: 'Click “Check” to look for updates.',\n      checkForUpdates: 'Check',\n      downloadAndInstall: 'Download & Install',\n      checkingUpdates: 'Checking...',\n      updateNotAvailable: \"You're up to date.\",\n      updateAvailable: 'Update available.',\n      updateAvailableWithVersion: 'Update available: v{{version}}',\n      installingUpdate: 'Installing...',\n      updateInstalledRestart: 'Update installed. Restart the app to finish.',\n      updateNow: 'Update Now',\n      restartNow: 'Restart Now',\n      updateBannerText: 'New version v{{version}} is available.',\n      updateBannerDismiss: \"Don't remind again\",\n      updatesMaintenance: 'Updates & Maintenance',\n      autoUpdateSkills: 'Auto-update Skills',\n      autoUpdateDesc: 'Automatically pull changes for Git skills every 24h',\n      storageCleanup: 'Storage Cleanup',\n      storageCleanupDesc: 'Clean up temporary download files (240MB)',\n      cleanNow: 'Clean Now',\n      browse: 'Browse',\n      done: 'Done',\n      selectLocalFolder: 'Select local folder',\n      selectStoragePath: 'Select storage folder',\n      notAvailable: 'Not available',\n      rescanTools: 'Rescan tools (soon)',\n      copy: 'Copy',\n      copied: 'Copied to clipboard',\n      copyFailed: 'Copy failed',\n      update: 'Update',\n      remove: 'Remove',\n      localTab: 'Local Folder',\n      gitTab: 'Git Repository',\n      searchTab: 'Search',\n      exploreTab: 'Explore',\n      exploreFilterPlaceholder: 'Filter or search skills online...',\n      exploreEmpty: 'No featured skills available.',\n      exploreLoading: 'Loading featured skills...',\n      exploreSource: 'Data from',\n      exploreSourceHint: 'Featured: top 300 from curated GitHub repos · Search: more results via skills.sh · Type 2+ characters to search',\n      exploreFeaturedTitle: 'Featured',\n      exploreOnlineTitle: 'Online Results',\n      searchLoading: 'Searching skills.sh...',\n      searchEmpty: 'No additional results found.',\n      searchError: 'Online search failed.',\n      repositoryUrl: 'Repository URL',\n      localFolder: 'Local Folder',\n      install: 'Install',\n      installSelected: 'Install selected',\n      processingTitle: 'Installing Skills...',\n      processingTipShort: 'Cloning repository and syncing to tools',\n      processingTipLong:\n        'Running file/network operations. First fetch may take a while depending on network; subsequent installs use cache and are faster.',\n      newToolsTitle: 'New tools detected',\n      newToolsBody:\n        'We detected newly installed tools: {{tools}}. Sync managed skills now?',\n      later: 'Later',\n      syncAll: 'Sync all managed skills',\n      gitPickTitle: 'Select skills to import',\n      gitPickBody:\n        'Multiple skills found in this repository. Choose which ones to install.',\n      localPickTitle: 'Select skills to import',\n      localPickBody: 'Multiple skills found in this folder. Choose which ones to install.',\n      localPickInvalidReason: 'Invalid: {{reason}}',\n      localSkillInvalid: {\n        missingSkillMd: 'Missing SKILL.md',\n        invalidFrontmatter: 'Invalid frontmatter',\n        missingName: 'Missing name in frontmatter',\n        readFailed: 'Failed to read SKILL.md',\n        unknown: 'Unknown issue',\n      },\n      toolsScanned: 'Tools scanned: {{count}}',\n      skillsFound: 'Skills found: {{count}}',\n      foundIn: 'Found in',\n      errors: {\n        notTauri: 'Current environment is not Tauri. Please run `npm run tauri dev`.',\n        skillExistsInHub: 'This skill already exists in Hub. No need to install again.',\n        skillExistsInHubNamed: '\"{{name}}\" already exists in Hub. Go to My Skills to update it.',\n        targetExists: 'Target folder already exists. Please remove it and try again.',\n        targetExistsDetail:\n          'Target folder already exists: {{path}}. For safety it was not overwritten.',\n        toolNotInstalled: 'The selected tool is not installed. Please refresh and retry.',\n        toolNotWritable:\n          'Cannot sync to {{tool}}: permission denied on {{path}}. Please check directory permissions or run as administrator.',\n        noSkillsFoundInRepo: 'No skills found in this repository.',\n        requireLocalPath: 'Please enter a local path.',\n        requireGitUrl: 'Please enter a Git repository URL.',\n        noSyncTargets:\n          'No sync target selected. Please check targets under “Create Skills -> Sync targets”.',\n        noSkillsFoundWithHint:\n          'No skills found in this repository (SKILL.md not detected).',\n        noSkillsFoundLocal: 'No skills found in this folder (SKILL.md not detected).',\n        skillAlreadyExists: 'Skill \"{{name}}\" already exists. Please rename it.',\n        duplicateSelectedSkills:\n          'Duplicate skill name \"{{name}}\" selected. Please select only one.',\n        selectAtLeastOneSkill: 'Please select at least one Skill.',\n        multiSelectNoCustomName:\n          'Custom name is not supported when installing multiple skills. Leave it empty or select one.',\n        syncFailedTitle: 'Sync failed: {{name}} -> {{tool}}',\n        syncTargetExistsMessage:\n          'Target folder already exists: {{path}}.\\nFor safety it was not overwritten.\\n' +\n          'You can uncheck this tool or clean the folder first, then retry.',\n        importFailedTitle: 'Import failed: {{name}}',\n        unsyncedTitle: 'Not synced: {{name}}',\n        moreCount: ' (+{{count}} more)',\n      },\n      actions: {\n        importExisting: 'Import {{name}} ...',\n        syncing: 'Sync {{name}} -> {{tool}} ...',\n        syncStep: 'Sync ({{index}}/{{total}}) {{name}} -> {{tool}} ...',\n        importStep: 'Import ({{index}}/{{total}}) {{name}} ...',\n        creatingLocalSkill: 'Creating local skill...',\n        creatingGitSkill: 'Creating Git skill...',\n        removing: 'Removing {{name}} ...',\n        updating: 'Updating {{name}} ...',\n        updatingTags: 'Updating tags for {{name}} ...',\n        deletingTag: 'Deleting tag {{name}} ...',\n        unsyncing: 'Unsync {{name}} -> {{tool}} ...',\n      },\n      status: {\n        importCompleted: 'Import completed.',\n        localSkillCreated: 'Local skill created.',\n        gitSkillCreated: 'Git skill created.',\n        selectedSkillsInstalled: 'Selected skills installed.',\n        skillRemoved: 'Skill removed.',\n        syncCompleted: 'Sync completed.',\n        syncDisabled: 'Sync disabled.',\n        syncEnabled: 'Sync enabled.',\n        updated: '{{name}} updated.',\n        gitCacheCleared: 'Git cache cleared ({{count}} removed).',\n        installed: 'Installed',\n      },\n      relative: {\n        empty: '—',\n        justNow: 'just now',\n        minutesAgo: '{{minutes}}m ago',\n        hoursAgo: '{{hours}}h ago',\n        daysAgo: '{{days}}d ago',\n      },\n      delete: {\n        confirmPrefix: 'Are you sure you want to delete ',\n        confirmSuffix: '?',\n        warningRemoveFromTools: 'Remove from synced tools',\n        warningDeleteFromHub: 'Delete local copy from Hub',\n        confirmButton: 'Yes, Delete',\n      },\n      layout: {\n        navDashboard: 'Dashboard',\n        navSkills: 'My Skills',\n        navSettings: 'Settings',\n        versionLabel: 'v0.1.0',\n      },\n      dashboard: {\n        title: 'Dashboard',\n        totalSkills: 'Total Skills',\n        toolsConnected: 'Tools Connected',\n        pendingUpdates: 'Pending Updates',\n        allGood: 'All Good',\n      },\n      detail: {\n        back: 'Back',\n        files: 'Files',\n        noFiles: 'No files found',\n        loadingFiles: 'Loading files...',\n        loadingContent: 'Loading file content...',\n        readError: 'Failed to read file',\n        fileCount: '{{count}} files',\n      },\n      sharedDirConfirm:\n        'Note: {{tool}} shares the same skills directory with {{others}}. This change will affect all of them. Continue?',\n      tools: {\n        opencode: 'OpenCode',\n        claude_code: 'Claude Code',\n        codex: 'Codex',\n        cursor: 'Cursor',\n        amp: 'Amp',\n        kimi_cli: 'Kimi Code CLI',\n        augment: 'Augment',\n        openclaw: 'OpenClaw',\n        cline: 'Cline',\n        codebuddy: 'CodeBuddy',\n        command_code: 'Command Code',\n        continue: 'Continue',\n        crush: 'Crush',\n        junie: 'Junie',\n        iflow_cli: 'iFlow CLI',\n        kiro_cli: 'Kiro CLI',\n        kode: 'Kode',\n        mcpjam: 'MCPJam',\n        mistral_vibe: 'Mistral Vibe',\n        mux: 'Mux',\n        openclaude: 'OpenClaude IDE',\n        openhands: 'OpenHands',\n        pi: 'Pi',\n        qoder: 'Qoder',\n        qoderwork: 'QoderWork',\n        qwen_code: 'Qwen Code',\n        trae: 'Trae',\n        trae_cn: 'Trae CN',\n        zencoder: 'Zencoder',\n        neovate: 'Neovate',\n        pochi: 'Pochi',\n        adal: 'AdaL',\n        kilo_code: 'Kilo Code',\n        roo_code: 'Roo Code',\n        goose: 'Goose',\n        gemini_cli: 'Gemini CLI',\n        antigravity: 'Antigravity',\n        github_copilot: 'GitHub Copilot',\n        clawdbot: 'Clawdbot',\n        droid: 'Droid',\n        windsurf: 'Windsurf',\n        moltbot: 'MoltBot',\n        hermes_agent: 'Hermes Agent',\n      },\n    },\n  },\n  zh: {\n    translation: {\n      appName: 'Skills Hub',\n      unknown: '未知',\n      languageShort: {\n        en: 'EN',\n        zh: '中文',\n      },\n      languageOptions: {\n        en: 'English',\n        zh: '中文',\n      },\n      subtitle: '统一管理并同步你的技能到各工具',\n      navMySkills: '我的 Skills',\n      navExplore: '探索',\n      navTags: '标签',\n      newSkill: '新建 Skill',\n      manualAdd: '手动添加',\n      manualAddHint: '有 Git URL 或本地路径？点击<b>手动添加</b>直接导入',\n      moreTools: '+{{count}} 个',\n      installSuccess: '\"{{name}}\" 已安装并同步到 {{count}} 个工具',\n      settings: '设置',\n      language: '语言',\n      interfaceLanguage: '界面语言',\n      themeMode: '外观模式',\n      themeOptions: {\n        system: '跟随系统',\n        light: '高亮',\n        dark: '暗色',\n      },\n      filterSort: '排序',\n      allSkills: '全部 Skills',\n      scope: {\n        all: '全部',\n        global: '全局',\n        project: '项目',\n        filterLabel: '范围过滤',\n        globalBadge: '全局',\n        projectCount: '{{count}} 个项目',\n      },\n      projectSync: {\n        title: '同步范围',\n        help: '选择这个 Skill 生效的位置。',\n        globalDesc: '在所有项目中可用',\n        projectDesc: '仅在选择的项目中可用',\n        projectDirs: '项目目录',\n        addProject: '选择项目目录...',\n        selectProjectTitle: '选择项目目录',\n        recentProjects: '最近使用',\n        addRecent: '添加',\n        noProjects: '尚未选择项目目录。',\n        projectRequired: '请至少选择一个项目目录后再应用项目范围。',\n        noProjectsForSync: '请先选择至少一个项目目录，再同步到工具。',\n        unsupportedTool: '{{tool}} 不支持项目级同步。',\n        inlineGlobalClear:\n          '应用后会清理现有全局链接，并将该 Skill 同步到选中项目目录下的所有当前已安装工具。',\n        inlineProjectClear:\n          '应用后会清理现有项目链接，并将该 Skill 全局同步到所有当前已安装工具。',\n        applyScope: '应用',\n        confirmBody: '以下全局链接将被清除：',\n        confirmBodyProject: '以下项目链接将被清除：',\n      },\n      sortUpdated: '最近更新',\n      sortName: '名称 A–Z',\n      searchPlaceholder: '搜索 skills...',\n      tags: '标签',\n      tagsSelected: '标签：已选 {{count}}',\n      matchAny: '任意匹配',\n      searchTags: '搜索标签...',\n      untagged: '无标签',\n      noTags: '无标签',\n      editTags: '编辑标签',\n      clearAll: '清空',\n      manageTags: '管理标签...',\n      tagsHelp: '标签用于筛选和整理 Skills，不会改变同步结果。',\n      untaggedSkillsCount: '{{count}} 个 Skills 还没有标签',\n      review: '查看',\n      newTag: '新建标签',\n      newTagPlaceholder: '新标签名称',\n      tagName: '标签名',\n      skills: 'Skills',\n      lastUsed: '最近使用',\n      actionsLabel: '操作',\n      view: '查看',\n      rename: '重命名',\n      deleteAction: '删除',\n      tagsEmpty: '未找到标签。',\n      renameTagPrompt: '重命名标签',\n      deleteTagTitle: '删除标签？',\n      deleteTagConfirm: '从 {{count}} 个 Skills 中删除「{{name}}」？\\n这只会移除标签，不会删除 Skills。',\n      editTagsTitle: '编辑标签：{{name}}',\n      editTagsHelp: '选择用于查找这个 Skill 的标签。',\n      tagCreated: '标签已创建。',\n      tagRenamed: '标签已重命名。',\n      tagDeleted: '标签已删除。',\n      tagsUpdated: '标签已更新。',\n      tagsApplyFailed: '无法为「{{name}}」应用标签。',\n      back: '返回',\n      discoveredTitle: '发现的 Skills',\n      discoveredEmpty: '扫描工具以导入已存在的 Skills。',\n      discoveredCount: '发现 {{count}} 个可导入 Skills。',\n      reviewImport: '查看并导入',\n      scanNow: '立即扫描',\n      skillsTitle: '托管中的 Skills',\n      skillsEmpty: '暂无托管记录。',\n      toolsLabel: '工具',\n      activeTools: '活跃工具',\n      sourceLabel: '来源',\n      pathLabel: '路径',\n      updatedLabel: '更新时间',\n      statusLabel: '状态',\n      addSkillTitle: '添加 Skill',\n      installToTools: '安装到工具',\n      addTags: '添加标签',\n      noTagsYet: '暂无标签。',\n      syncAfterCreate: '创建后同步到选中工具',\n      localImportTitle: '本地目录导入',\n      gitImportTitle: 'Git 仓库导入',\n      localPathPlaceholder: '本地目录路径',\n      gitUrlPlaceholder: 'Git URL',\n      optionalNamePlaceholder: '可选：显示名称',\n      create: '创建',\n      detectingTools: '检测已安装工具中...',\n      importTitle: '查看发现的 Skills',\n      importSummary: '选择要导入的 Skills，并同步到工具。',\n      importAndSync: '导入并同步',\n      close: '关闭',\n      selectAll: '全选',\n      selectedCount: '已选择 {{selected}} / {{total}}',\n      pickSearchPlaceholder: '按名称、描述或路径搜索...',\n      pickSearchEmpty: '未找到匹配的 Skills。',\n      importSearchEmpty: '未找到匹配的已发现 Skills。',\n      partialFailure: '部分操作未完成',\n      conflict: '冲突',\n      consistent: '一致',\n      directory: '目录',\n      linkLabel: 'link → {{target}}',\n      deleteTitle: '确认移除？',\n      deleteBody: '将删除托管记录，并清理由本应用创建的同步链接。',\n      cancel: '取消',\n      confirm: '确认',\n      confirmRemove: '确认移除',\n      maintenance: '维护',\n      storagePath: '存储路径',\n      skillsStoragePath: 'Skills 存储路径',\n      skillsStorageHint: 'Git Skills 的本地副本将存放在这里。',\n      gitCacheCleanupDays: 'Git 缓存清理（天）',\n      gitCacheCleanupHint: '启动时清理超过 N 天未使用的 Git 缓存，设为 0 表示不清理。',\n      gitCacheTtlSecs: 'Git 缓存新鲜期（秒）',\n      gitCacheTtlHint:\n        '在该时间窗口内命中缓存会跳过 fetch，设为 0 表示每次都拉取。',\n      githubToken: 'GitHub Token',\n      githubTokenPlaceholder: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',\n      githubTokenHint:\n        '可选。设置 GitHub 个人访问令牌，可将 API 速率限制从 60 次/小时提升到 5,000 次/小时。',\n      appUpdates: '应用更新',\n      updateHint: '点击“检查更新”获取最新版本。',\n      checkForUpdates: '检查更新',\n      downloadAndInstall: '下载并安装',\n      checkingUpdates: '检查中...',\n      updateNotAvailable: '已是最新版本。',\n      updateAvailable: '发现新版本。',\n      updateAvailableWithVersion: '发现新版本：v{{version}}',\n      installingUpdate: '安装中...',\n      updateInstalledRestart: '更新已安装，请重启应用完成更新。',\n      updateNow: '立即更新',\n      restartNow: '立即重启',\n      updateBannerText: '发现新版本 v{{version}}。',\n      updateBannerDismiss: '不再提示',\n      updatesMaintenance: '更新与维护',\n      autoUpdateSkills: '自动更新 Skills',\n      autoUpdateDesc: '每 24 小时自动拉取 Git Skills 更新',\n      storageCleanup: '存储清理',\n      storageCleanupDesc: '清理临时下载文件（240MB）',\n      cleanNow: '立即清理',\n      browse: '浏览',\n      done: '完成',\n      selectLocalFolder: '选择本地目录',\n      selectStoragePath: '选择存储目录',\n      notAvailable: '暂不可用',\n      rescanTools: '重新检测工具（敬请期待）',\n      copy: '复制',\n      copied: '已复制到剪贴板',\n      copyFailed: '复制失败',\n      update: '更新',\n      remove: '移除',\n      localTab: '本地目录',\n      gitTab: 'Git 仓库',\n      searchTab: '搜索',\n      exploreTab: '探索',\n      exploreFilterPlaceholder: '筛选精选或在线搜索技能...',\n      exploreEmpty: '暂无精选技能。',\n      exploreLoading: '加载精选技能中...',\n      exploreSource: '数据来自',\n      exploreSourceHint: '精选推荐：来自精选 GitHub 仓库的 Top 300 · 在线搜索：更多结果来自 skills.sh · 输入 2 个字符开始搜索',\n      exploreFeaturedTitle: '精选推荐',\n      exploreOnlineTitle: '在线搜索',\n      searchLoading: '正在搜索 skills.sh...',\n      searchEmpty: '未找到更多结果。',\n      searchError: '在线搜索失败。',\n      repositoryUrl: '仓库地址',\n      localFolder: '本地目录',\n      install: '安装',\n      installSelected: '安装选中',\n      processingTitle: '正在安装技能...',\n      processingTipShort: '正在克隆仓库并同步到工具',\n      processingTipLong: '正在执行文件/网络操作，首次获取耗时取决于网络状况，后续安装使用缓存会更快。',\n      newToolsTitle: '检测到新安装的工具',\n      newToolsBody: '已检测到新工具：{{tools}}，是否立即同步托管 Skills？',\n      later: '稍后',\n      syncAll: '同步全部托管 Skills',\n      gitPickTitle: '选择要导入的 Skill',\n      gitPickBody: '仓库内发现多个 Skills，可多选后统一安装。',\n      localPickTitle: '选择要导入的 Skill',\n      localPickBody: '目录内发现多个 Skills，可多选后统一安装。',\n      localPickInvalidReason: '不可用：{{reason}}',\n      localSkillInvalid: {\n        missingSkillMd: '缺少 SKILL.md',\n        invalidFrontmatter: 'Frontmatter 格式不合法',\n        missingName: 'Frontmatter 缺少 name',\n        readFailed: '无法读取 SKILL.md',\n        unknown: '未知问题',\n      },\n      toolsScanned: '已扫描工具数：{{count}}',\n      skillsFound: '发现 Skills 数：{{count}}',\n      foundIn: '发现于',\n      errors: {\n        notTauri: '当前环境不是 Tauri，请用 `npm run tauri dev` 启动应用。',\n        skillExistsInHub: '该 Skill 已存在于 Hub，无需重复安装。',\n        skillExistsInHubNamed: '「{{name}}」已存在于 Hub，可前往\"我的 Skills\"中更新。',\n        targetExists: '目标目录已存在，请先清理后重试。',\n        targetExistsDetail:\n          '目标目录已存在同名 Skill：{{path}}。为安全起见未覆盖。\\n你可以：先手动清理该目录后重试。',\n        toolNotInstalled: '未检测到该工具已安装，请刷新后重试。',\n        toolNotWritable:\n          '无法同步到 {{tool}}：目录 {{path}} 权限不足。请检查目录权限或以管理员身份运行。',\n        noSkillsFoundInRepo: '该仓库未发现可导入的 Skills。',\n        requireLocalPath: '请输入本地路径',\n        requireGitUrl: '请输入 Git 仓库地址',\n        noSyncTargets: '未选择任何同步目标工具，请在“创建 Skills -> 同步目标”里勾选。',\n        noSkillsFoundWithHint: '未在该仓库中发现可导入的 Skills（未找到 SKILL.md）。',\n        noSkillsFoundLocal: '未在该目录中发现可导入的 Skills（未找到 SKILL.md）。',\n        skillAlreadyExists: '技能「{{name}}」已存在，请更换名称后再安装。',\n        duplicateSelectedSkills: '所选 Skills 含有重复名称「{{name}}」，请只选择一个。',\n        selectAtLeastOneSkill: '请至少选择一个 Skill',\n        multiSelectNoCustomName:\n          '多选安装时不支持自定义名称，请将名称留空或仅选择一个 Skill。',\n        syncFailedTitle: '同步失败：{{name}} -> {{tool}}',\n        syncTargetExistsMessage:\n          '目标目录已存在同名 Skill：{{path}}。为安全起见未覆盖。\\n你可以：取消勾选该工具，或先手动清理该目录后重试。',\n        importFailedTitle: '导入失败：{{name}}',\n        unsyncedTitle: '未同步：{{name}}',\n        moreCount: '（另有{{count}}个）',\n      },\n      actions: {\n        importExisting: '导入 {{name}} ...',\n        syncing: '同步 {{name}} -> {{tool}} ...',\n        syncStep: '同步 ({{index}}/{{total}}) {{name}} -> {{tool}} ...',\n        importStep: '导入 ({{index}}/{{total}}) {{name}} ...',\n        creatingLocalSkill: '创建本地技能...',\n        creatingGitSkill: '创建 Git 技能...',\n        removing: '移除 {{name}} ...',\n        updating: '更新 {{name}} ...',\n        updatingTags: '正在更新 {{name}} 的标签...',\n        deletingTag: '正在删除标签 {{name}}...',\n        unsyncing: '取消生效：{{name}} -> {{tool}} ...',\n      },\n      status: {\n        importCompleted: '导入完成。',\n        localSkillCreated: '本地技能创建完成。',\n        gitSkillCreated: 'Git 技能创建完成。',\n        selectedSkillsInstalled: '选中技能安装完成。',\n        skillRemoved: '已删除该技能。',\n        syncCompleted: '同步完成。',\n        syncDisabled: '已取消同步。',\n        syncEnabled: '已同步到工具。',\n        updated: '{{name}} 已更新。',\n        gitCacheCleared: 'Git 缓存已清理（删除 {{count}} 项）。',\n        installed: '已安装',\n      },\n      relative: {\n        empty: '—',\n        justNow: '刚刚',\n        minutesAgo: '{{minutes}} 分钟前',\n        hoursAgo: '{{hours}} 小时前',\n        daysAgo: '{{days}} 天前',\n      },\n      delete: {\n        confirmPrefix: '确定要删除 ',\n        confirmSuffix: '？',\n        warningRemoveFromTools: '将从已同步的工具中移除',\n        warningDeleteFromHub: '删除 Hub 的本地副本',\n        confirmButton: '确认删除',\n      },\n      layout: {\n        navDashboard: '概览',\n        navSkills: '我的 Skills',\n        navSettings: '设置',\n        versionLabel: 'v0.1.0',\n      },\n      dashboard: {\n        title: '概览',\n        totalSkills: '技能总数',\n        toolsConnected: '已连接工具',\n        pendingUpdates: '待更新',\n        allGood: '全部正常',\n      },\n      detail: {\n        back: '返回',\n        files: '文件',\n        noFiles: '未找到文件',\n        loadingFiles: '加载文件中...',\n        loadingContent: '加载文件内容中...',\n        readError: '读取文件失败',\n        fileCount: '{{count}} 个文件',\n      },\n      sharedDirConfirm:\n        '提示：{{tool}} 与 {{others}} 共用同一个 skills 目录，本次修改会同时影响它们。是否继续？',\n      tools: {\n        opencode: 'OpenCode',\n        claude_code: 'Claude Code',\n        codex: 'Codex',\n        cursor: 'Cursor',\n        amp: 'Amp',\n        kimi_cli: 'Kimi Code CLI',\n        augment: 'Augment',\n        openclaw: 'OpenClaw',\n        cline: 'Cline',\n        codebuddy: 'CodeBuddy',\n        command_code: 'Command Code',\n        continue: 'Continue',\n        crush: 'Crush',\n        junie: 'Junie',\n        iflow_cli: 'iFlow CLI',\n        kiro_cli: 'Kiro CLI',\n        kode: 'Kode',\n        mcpjam: 'MCPJam',\n        mistral_vibe: 'Mistral Vibe',\n        mux: 'Mux',\n        openclaude: 'OpenClaude IDE',\n        openhands: 'OpenHands',\n        pi: 'Pi',\n        qoder: 'Qoder',\n        qoderwork: 'QoderWork',\n        qwen_code: 'Qwen Code',\n        trae: 'Trae',\n        trae_cn: 'Trae CN',\n        zencoder: 'Zencoder',\n        neovate: 'Neovate',\n        pochi: 'Pochi',\n        adal: 'AdaL',\n        kilo_code: 'Kilo Code',\n        roo_code: 'Roo Code',\n        goose: 'Goose',\n        gemini_cli: 'Gemini CLI',\n        antigravity: 'Antigravity',\n        github_copilot: 'GitHub Copilot',\n        clawdbot: 'Clawdbot',\n        droid: 'Droid',\n        windsurf: 'Windsurf',\n        moltbot: 'MoltBot',\n        hermes_agent: 'Hermes Agent',\n      },\n    },\n  },\n} as const\n"
  },
  {
    "path": "src/index.css",
    "content": "@import url(\"https://fonts.googleapis.com/css2?family=Fira+Sans:wght@300;400;500;600;700&family=Fira+Code:wght@400;500;600;700&display=swap\");\n@import \"tailwindcss\";\n\n:root {\n  --bg-app: #ffffff;\n  --bg-panel: #fcfcfc;\n  --bg-element: #f4f4f5;\n  --bg-element-hover: #e4e4e7;\n  --bg-header: rgba(255, 255, 255, 0.85);\n  --border-subtle: #e4e4e7;\n  --border-strong: #d4d4d8;\n  --text-primary: #18181b;\n  --text-secondary: #52525b;\n  --text-tertiary: #a1a1aa;\n  --accent-primary: #2563eb;\n  --accent-primary-hover: #1d4ed8;\n  --accent-primary-fg: #ffffff;\n  --accent-soft-bg: #eff6ff;\n  --accent-soft-border: #dbeafe;\n  --success-soft-bg: #ecfdf5;\n  --success-soft-border: #bbf7d0;\n  --status-success: #059669;\n  --status-warning: #d97706;\n  --status-error: #dc2626;\n  --status-info: #2563eb;\n  --warning-soft-bg: #fffbeb;\n  --warning-soft-border: #fcd34d;\n  --danger-soft-bg: #fef2f2;\n  --danger-soft-border: #fee2e2;\n  --danger-soft-bg-strong: #fff1f2;\n  --font-ui: \"Fira Sans\", system-ui, -apple-system, sans-serif;\n  --font-mono: \"Fira Code\", ui-monospace, SFMono-Regular, Menlo, Monaco,\n    Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n  --radius-sm: 4px;\n  --radius-md: 8px;\n  --radius-lg: 12px;\n  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);\n  --shadow-lg: 0 10px 20px -6px rgb(0 0 0 / 0.15);\n}\n\n:root[data-theme='dark'] {\n  --bg-app: #0b0f1a;\n  --bg-panel: #111827;\n  --bg-element: #1f2937;\n  --bg-element-hover: #2a3648;\n  --bg-header: rgba(12, 16, 28, 0.78);\n  --border-subtle: #273143;\n  --border-strong: #334155;\n  --text-primary: #f8fafc;\n  --text-secondary: #cbd5f5;\n  --text-tertiary: #94a3b8;\n  --accent-primary: #38bdf8;\n  --accent-primary-hover: #0ea5e9;\n  --accent-primary-fg: #0b0f1a;\n  --accent-soft-bg: #0b3b59;\n  --accent-soft-border: #155e75;\n  --success-soft-bg: #0f3b2d;\n  --success-soft-border: #166534;\n  --status-success: #34d399;\n  --status-warning: #f59e0b;\n  --status-error: #f87171;\n  --status-info: #38bdf8;\n  --warning-soft-bg: #2a1f0b;\n  --warning-soft-border: #8a5a0f;\n  --danger-soft-bg: #3b1111;\n  --danger-soft-border: #7f1d1d;\n  --danger-soft-bg-strong: #3b1111;\n  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.35);\n  --shadow-lg: 0 12px 24px -10px rgb(0 0 0 / 0.45);\n  color-scheme: dark;\n}\n\n* {\n  box-sizing: border-box;\n}\n\nbody {\n  margin: 0;\n  min-height: 100vh;\n  font-family: var(--font-ui);\n  background: var(--bg-app);\n  color: var(--text-primary);\n  -webkit-font-smoothing: antialiased;\n  padding: 0;\n}\n\n#root {\n  width: 100%;\n  height: 100vh;\n}\n\na {\n  color: inherit;\n  text-decoration: none;\n}\n"
  },
  {
    "path": "src/main.tsx",
    "content": "import { StrictMode } from 'react'\nimport { createRoot } from 'react-dom/client'\nimport './index.css'\nimport './i18n'\nimport App from './App.tsx'\n\ncreateRoot(document.getElementById('root')!).render(\n  <StrictMode>\n    <App />\n  </StrictMode>,\n)\n"
  },
  {
    "path": "src/pages/Dashboard.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nexport const Dashboard = () => {\n  const { t } = useTranslation();\n  return (\n    <div className=\"space-y-6\">\n      <h1 className=\"text-2xl font-bold\">{t('dashboard.title')}</h1>\n      <div className=\"grid grid-cols-1 md:grid-cols-3 gap-6\">\n        <div className=\"bg-gray-900 p-6 rounded-lg border border-gray-800\">\n          <h3 className=\"text-gray-400 text-sm font-medium\">\n            {t('dashboard.totalSkills')}\n          </h3>\n          <p className=\"text-3xl font-bold mt-2\">0</p>\n        </div>\n        <div className=\"bg-gray-900 p-6 rounded-lg border border-gray-800\">\n          <h3 className=\"text-gray-400 text-sm font-medium\">\n            {t('dashboard.toolsConnected')}\n          </h3>\n          <p className=\"text-3xl font-bold mt-2\">0</p>\n        </div>\n        <div className=\"bg-gray-900 p-6 rounded-lg border border-gray-800\">\n          <h3 className=\"text-gray-400 text-sm font-medium\">\n            {t('dashboard.pendingUpdates')}\n          </h3>\n          <p className=\"text-3xl font-bold mt-2 text-green-500\">\n            {t('dashboard.allGood')}\n          </p>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/tauri-plugin-dialog.d.ts",
    "content": "declare module '@tauri-apps/plugin-dialog' {\n  type OpenDialogOptions = {\n    directory?: boolean\n    multiple?: boolean\n    title?: string\n  }\n\n  export function open(options?: OpenDialogOptions): Promise<string | string[] | null>\n}\n"
  },
  {
    "path": "src-tauri/.gitignore",
    "content": "# Generated by Cargo\n# will have compiled files and executables\n/target/\n/gen/schemas\n"
  },
  {
    "path": "src-tauri/Cargo.toml",
    "content": "[package]\nname = \"app\"\nversion = \"0.6.0\"\ndescription = \"A Tauri App\"\nauthors = [\"you\"]\nlicense = \"\"\nrepository = \"git@github.com:qufei1993/skills-hub.git\"\nedition = \"2021\"\nrust-version = \"1.77.2\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[lib]\nname = \"app_lib\"\ncrate-type = [\"staticlib\", \"cdylib\", \"rlib\"]\n\n[build-dependencies]\ntauri-build = { version = \"2.5.3\", features = [] }\n\n[dependencies]\nserde_json = \"1.0\"\nserde = { version = \"1.0\", features = [\"derive\"] }\nlog = \"0.4\"\ntauri = { version = \"2.9.5\", features = [\"test\"] }\ntauri-plugin-dialog = \"2\"\ntauri-plugin-log = \"2\"\ntauri-plugin-opener = \"2\"\ntauri-plugin-updater = \"2\"\nanyhow = \"1.0\"\nrusqlite = { version = \"0.31\", features = [\"bundled\"] }\ndirs = \"5.0\"\nwalkdir = \"2.5\"\nsha2 = \"0.10\"\nhex = \"0.4\"\ngit2 = { version = \"0.19\", features = [\"vendored-openssl\"] }\nreqwest = { version = \"0.12\", default-features = false, features = [\"blocking\", \"json\", \"rustls-tls\"] }\njunction = \"1.1\"\nuuid = { version = \"1\", features = [\"v4\"] }\nurlencoding = \"2.1\"\n\n[dev-dependencies]\ntempfile = \"3\"\nmockito = \"1\"\n"
  },
  {
    "path": "src-tauri/build.rs",
    "content": "fn main() {\n    // 确保替换图标后，`tauri dev` 的构建会重新触发（否则 Cargo 可能不重跑 build.rs，Dock 仍显示旧图标）。\n    println!(\"cargo:rerun-if-changed=icons/icon.png\");\n    println!(\"cargo:rerun-if-changed=icons/icon.icns\");\n    println!(\"cargo:rerun-if-changed=icons/icon.ico\");\n    println!(\"cargo:rerun-if-changed=tauri.conf.json\");\n    tauri_build::build()\n}\n"
  },
  {
    "path": "src-tauri/capabilities/default.json",
    "content": "{\n  \"$schema\": \"../gen/schemas/desktop-schema.json\",\n  \"identifier\": \"default\",\n  \"description\": \"enables the default permissions\",\n  \"windows\": [\n    \"main\"\n  ],\n  \"permissions\": [\n    \"core:default\",\n    \"dialog:default\",\n    \"dialog:allow-open\",\n    \"updater:default\"\n  ]\n}\n"
  },
  {
    "path": "src-tauri/src/commands/mod.rs",
    "content": "use anyhow::Context;\nuse serde::Serialize;\nuse tauri::State;\n\nuse std::sync::Arc;\n\nuse crate::core::cache_cleanup::{\n    cleanup_git_cache_dirs, get_git_cache_cleanup_days as get_git_cache_cleanup_days_core,\n    get_git_cache_ttl_secs as get_git_cache_ttl_secs_core,\n    set_git_cache_cleanup_days as set_git_cache_cleanup_days_core,\n    set_git_cache_ttl_secs as set_git_cache_ttl_secs_core,\n};\nuse crate::core::cancel_token::CancelToken;\nuse crate::core::central_repo::{ensure_central_repo, resolve_central_repo_path};\nuse crate::core::content_hash::hash_dir;\nuse crate::core::featured_skills::{fetch_featured_skills, FeaturedSkill};\nuse crate::core::github_search::{search_github_repos, RepoSummary};\nuse crate::core::installer::{\n    install_git_skill, install_git_skill_from_selection, install_local_skill,\n    install_local_skill_from_selection, list_git_skills, list_local_skills,\n    update_managed_skill_from_source, GitSkillCandidate, InstallResult, LocalSkillCandidate,\n};\nuse crate::core::onboarding::{build_onboarding_plan, OnboardingPlan};\nuse crate::core::skill_store::{SkillStore, SkillTargetRecord};\nuse crate::core::skills_search::{\n    search_skills_online as search_skills_online_core, OnlineSkillResult,\n};\nuse crate::core::sync_engine::{\n    copy_dir_recursive, sync_dir_for_tool_with_overwrite, sync_dir_hybrid, SyncMode,\n};\nuse crate::core::tool_adapters::{\n    adapter_by_key, adapters_sharing_project_skills_dir, is_tool_installed, resolve_default_path,\n    resolve_project_path, supports_project_scope,\n};\nuse uuid::Uuid;\n\nconst RECENT_PROJECTS_SETTING: &str = \"recent_projects_v1\";\n\nfn format_anyhow_error(err: anyhow::Error) -> String {\n    let first = err.to_string();\n    // Frontend relies on these prefixes for special flows.\n    if first.starts_with(\"MULTI_SKILLS|\")\n        || first.starts_with(\"TARGET_EXISTS|\")\n        || first.starts_with(\"TOOL_NOT_INSTALLED|\")\n    {\n        return first;\n    }\n\n    // Include the full error chain (causes), not just the top context.\n    let mut full = format!(\"{:#}\", err);\n\n    // Redact noisy temp paths from clone context (we care about the cause, not the dest).\n    // Example: `clone https://... into \"/Users/.../skills-hub-git-<uuid>\"`\n    if let Some(head) = full.lines().next() {\n        if head.starts_with(\"clone \") {\n            if let Some(pos) = head.find(\" into \") {\n                let head_redacted = format!(\"{} (已省略临时目录)\", &head[..pos]);\n                let rest: String = full.lines().skip(1).collect::<Vec<_>>().join(\"\\n\");\n                full = if rest.is_empty() {\n                    head_redacted\n                } else {\n                    format!(\"{}\\n{}\", head_redacted, rest)\n                };\n            }\n        }\n    }\n\n    let root = err.root_cause().to_string();\n    let lower = full.to_lowercase();\n\n    // Heuristic-friendly messaging for GitHub clone failures.\n    if lower.contains(\"github.com\")\n        && (lower.contains(\"clone \") || lower.contains(\"remote\") || lower.contains(\"fetch\"))\n    {\n        if lower.contains(\"securetransport\") {\n            return format!(\n        \"无法从 GitHub 拉取仓库：TLS/证书校验失败（macOS SecureTransport）。\\n\\n建议：\\n- 检查网络/代理是否拦截 HTTPS\\n- 如在公司网络，可能需要安装公司根证书或使用可信代理\\n- 也可在终端确认 `git clone {}` 是否可用\\n\\n详细：{}\",\n        \"https://github.com/<owner>/<repo>\",\n        root\n      );\n        }\n        let hint = if lower.contains(\"authentication\")\n            || lower.contains(\"permission denied\")\n            || lower.contains(\"credentials\")\n        {\n            \"无法访问该仓库：可能是私有仓库/权限不足/需要鉴权。\"\n        } else if lower.contains(\"not found\") {\n            \"仓库不存在或无权限访问（GitHub 返回 not found）。\"\n        } else if lower.contains(\"failed to resolve\")\n            || lower.contains(\"could not resolve\")\n            || lower.contains(\"dns\")\n        {\n            \"无法解析 GitHub 域名（DNS）。请检查网络/代理。\"\n        } else if lower.contains(\"timed out\") || lower.contains(\"timeout\") {\n            \"连接 GitHub 超时。请检查网络/代理。\"\n        } else if lower.contains(\"connection refused\") || lower.contains(\"connection reset\") {\n            \"连接 GitHub 失败（连接被拒绝/重置）。请检查网络/代理。\"\n        } else {\n            \"无法从 GitHub 拉取仓库。请检查网络/代理，或稍后重试。\"\n        };\n\n        return format!(\"{}\\n\\n详细：{}\", hint, root);\n    }\n\n    full\n}\n\n#[derive(Debug, Serialize)]\npub struct ToolInfoDto {\n    pub key: String,\n    pub label: String,\n    pub installed: bool,\n    pub skills_dir: String,\n    pub supports_project_scope: bool,\n}\n\n#[derive(Debug, Serialize)]\npub struct ToolStatusDto {\n    pub tools: Vec<ToolInfoDto>,\n    pub installed: Vec<String>,\n    pub newly_installed: Vec<String>,\n}\n\n#[tauri::command]\npub async fn get_tool_status(store: State<'_, SkillStore>) -> Result<ToolStatusDto, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let adapters = crate::core::tool_adapters::default_tool_adapters();\n        let mut tools: Vec<ToolInfoDto> = Vec::new();\n        let mut installed: Vec<String> = Vec::new();\n\n        for adapter in &adapters {\n            let ok = is_tool_installed(adapter)?;\n            let key = adapter.id.as_key().to_string();\n            let skills_dir = resolve_default_path(adapter)?.to_string_lossy().to_string();\n            tools.push(ToolInfoDto {\n                key: key.clone(),\n                label: adapter.display_name.to_string(),\n                installed: ok,\n                skills_dir,\n                supports_project_scope: supports_project_scope(adapter),\n            });\n            if ok {\n                installed.push(key);\n            }\n        }\n\n        installed.dedup();\n\n        let prev: Vec<String> = store\n            .get_setting(\"installed_tools_v1\")?\n            .and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())\n            .unwrap_or_default();\n\n        let prev_set: std::collections::HashSet<String> = prev.into_iter().collect();\n        let newly_installed: Vec<String> = installed\n            .iter()\n            .filter(|k| !prev_set.contains(*k))\n            .cloned()\n            .collect();\n\n        // Persist current set (best effort).\n        let _ = store.set_setting(\n            \"installed_tools_v1\",\n            &serde_json::to_string(&installed).unwrap_or_else(|_| \"[]\".to_string()),\n        );\n\n        Ok::<_, anyhow::Error>(ToolStatusDto {\n            tools,\n            installed,\n            newly_installed,\n        })\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub async fn get_onboarding_plan(\n    app: tauri::AppHandle,\n    store: State<'_, SkillStore>,\n) -> Result<OnboardingPlan, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || build_onboarding_plan(&app, &store))\n        .await\n        .map_err(|err| err.to_string())?\n        .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub async fn get_git_cache_cleanup_days(store: State<'_, SkillStore>) -> Result<i64, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        Ok::<_, anyhow::Error>(get_git_cache_cleanup_days_core(&store))\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub async fn set_git_cache_cleanup_days(\n    store: State<'_, SkillStore>,\n    days: i64,\n) -> Result<i64, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || set_git_cache_cleanup_days_core(&store, days))\n        .await\n        .map_err(|err| err.to_string())?\n        .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub async fn clear_git_cache_now(app: tauri::AppHandle) -> Result<usize, String> {\n    tauri::async_runtime::spawn_blocking(move || {\n        cleanup_git_cache_dirs(&app, std::time::Duration::from_secs(0))\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub async fn get_git_cache_ttl_secs(store: State<'_, SkillStore>) -> Result<i64, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        Ok::<_, anyhow::Error>(get_git_cache_ttl_secs_core(&store))\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub async fn set_git_cache_ttl_secs(\n    store: State<'_, SkillStore>,\n    secs: i64,\n) -> Result<i64, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || set_git_cache_ttl_secs_core(&store, secs))\n        .await\n        .map_err(|err| err.to_string())?\n        .map_err(format_anyhow_error)\n}\n\n#[derive(Debug, Serialize)]\npub struct InstallResultDto {\n    pub skill_id: String,\n    pub name: String,\n    pub central_path: String,\n    pub content_hash: Option<String>,\n}\n\nfn expand_home_path(input: &str) -> Result<std::path::PathBuf, anyhow::Error> {\n    let trimmed = input.trim();\n    if trimmed.is_empty() {\n        anyhow::bail!(\"storage path is empty\");\n    }\n    if trimmed == \"~\" {\n        let home = dirs::home_dir().context(\"failed to resolve home directory\")?;\n        return Ok(home);\n    }\n    if let Some(stripped) = trimmed.strip_prefix(\"~/\") {\n        let home = dirs::home_dir().context(\"failed to resolve home directory\")?;\n        return Ok(home.join(stripped));\n    }\n    Ok(std::path::PathBuf::from(trimmed))\n}\n\nfn normalize_scope(scope: Option<&str>) -> Result<&'static str, anyhow::Error> {\n    match scope.unwrap_or(\"global\") {\n        \"global\" => Ok(\"global\"),\n        \"project\" => Ok(\"project\"),\n        other => anyhow::bail!(\"invalid scope: {}\", other),\n    }\n}\n\n#[tauri::command]\npub async fn get_recent_projects(store: State<'_, SkillStore>) -> Result<Vec<String>, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || get_recent_projects_impl(&store))\n        .await\n        .map_err(|err| err.to_string())?\n        .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub async fn save_recent_project(\n    store: State<'_, SkillStore>,\n    projectPath: String,\n) -> Result<Vec<String>, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || save_recent_project_impl(&store, &projectPath))\n        .await\n        .map_err(|err| err.to_string())?\n        .map_err(format_anyhow_error)\n}\n\nfn get_recent_projects_impl(store: &SkillStore) -> Result<Vec<String>, anyhow::Error> {\n    let projects = store\n        .get_setting(RECENT_PROJECTS_SETTING)?\n        .and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())\n        .unwrap_or_default();\n    Ok(projects)\n}\n\nfn save_recent_project_impl(\n    store: &SkillStore,\n    project_path: &str,\n) -> Result<Vec<String>, anyhow::Error> {\n    let path = expand_home_path(project_path)?;\n    if !path.is_dir() {\n        anyhow::bail!(\"projectPath must be an existing directory: {:?}\", path);\n    }\n    let normalized = path.to_string_lossy().to_string();\n    let mut projects = get_recent_projects_impl(store)?;\n    projects.retain(|item| item != &normalized);\n    projects.insert(0, normalized);\n    projects.truncate(8);\n    store.set_setting(\n        RECENT_PROJECTS_SETTING,\n        &serde_json::to_string(&projects).unwrap_or_else(|_| \"[]\".to_string()),\n    )?;\n    Ok(projects)\n}\n\n#[tauri::command]\npub async fn get_central_repo_path(\n    app: tauri::AppHandle,\n    store: State<'_, SkillStore>,\n) -> Result<String, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let path = resolve_central_repo_path(&app, &store)?;\n        ensure_central_repo(&path)?;\n        Ok::<_, anyhow::Error>(path.to_string_lossy().to_string())\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub async fn set_central_repo_path(\n    app: tauri::AppHandle,\n    store: State<'_, SkillStore>,\n    path: String,\n) -> Result<String, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let new_base = expand_home_path(&path)?;\n        if !new_base.is_absolute() {\n            anyhow::bail!(\"storage path must be absolute\");\n        }\n        ensure_central_repo(&new_base)?;\n\n        let current_base = resolve_central_repo_path(&app, &store)?;\n        let skills = store.list_skills()?;\n        if current_base == new_base {\n            store.set_setting(\"central_repo_path\", new_base.to_string_lossy().as_ref())?;\n            return Ok::<_, anyhow::Error>(new_base.to_string_lossy().to_string());\n        }\n\n        if !skills.is_empty() {\n            for skill in skills {\n                let old_path = std::path::PathBuf::from(&skill.central_path);\n                if !old_path.exists() {\n                    anyhow::bail!(\"central path not found: {:?}\", old_path);\n                }\n                let file_name = old_path\n                    .file_name()\n                    .ok_or_else(|| anyhow::anyhow!(\"invalid central path: {:?}\", old_path))?;\n                let new_path = new_base.join(file_name);\n                if new_path.exists() {\n                    anyhow::bail!(\"target path already exists: {:?}\", new_path);\n                }\n\n                if let Err(err) = std::fs::rename(&old_path, &new_path) {\n                    copy_dir_recursive(&old_path, &new_path)\n                        .with_context(|| format!(\"copy {:?} -> {:?}\", old_path, new_path))?;\n                    std::fs::remove_dir_all(&old_path)\n                        .with_context(|| format!(\"cleanup {:?}\", old_path))?;\n                    // Surface rename error in logs for troubleshooting.\n                    eprintln!(\"rename failed, fallback used: {}\", err);\n                }\n\n                let mut updated = skill.clone();\n                updated.central_path = new_path.to_string_lossy().to_string();\n                updated.updated_at = now_ms();\n                store.upsert_skill(&updated)?;\n            }\n        }\n\n        store.set_setting(\"central_repo_path\", new_base.to_string_lossy().as_ref())?;\n        Ok::<_, anyhow::Error>(new_base.to_string_lossy().to_string())\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub async fn install_local(\n    app: tauri::AppHandle,\n    store: State<'_, SkillStore>,\n    sourcePath: String,\n    name: Option<String>,\n) -> Result<InstallResultDto, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let result = install_local_skill(&app, &store, sourcePath.as_ref(), name)?;\n        Ok::<_, anyhow::Error>(to_install_dto(result))\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub async fn list_local_skills_cmd(basePath: String) -> Result<Vec<LocalSkillCandidate>, String> {\n    tauri::async_runtime::spawn_blocking(move || {\n        let path = std::path::PathBuf::from(basePath);\n        list_local_skills(&path)\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub async fn install_local_selection(\n    app: tauri::AppHandle,\n    store: State<'_, SkillStore>,\n    basePath: String,\n    subpath: String,\n    name: Option<String>,\n) -> Result<InstallResultDto, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let base = std::path::PathBuf::from(basePath);\n        let result =\n            install_local_skill_from_selection(&app, &store, base.as_ref(), &subpath, name)?;\n        Ok::<_, anyhow::Error>(to_install_dto(result))\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub async fn install_git(\n    app: tauri::AppHandle,\n    store: State<'_, SkillStore>,\n    cancel: State<'_, Arc<CancelToken>>,\n    repoUrl: String,\n    name: Option<String>,\n) -> Result<InstallResultDto, String> {\n    let store = store.inner().clone();\n    cancel.reset();\n    let cancel_token = Arc::clone(cancel.inner());\n    tauri::async_runtime::spawn_blocking(move || {\n        let result = install_git_skill(&app, &store, &repoUrl, name, Some(&cancel_token))?;\n        Ok::<_, anyhow::Error>(to_install_dto(result))\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub async fn list_git_skills_cmd(\n    app: tauri::AppHandle,\n    store: State<'_, SkillStore>,\n    repoUrl: String,\n) -> Result<Vec<GitSkillCandidate>, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || list_git_skills(&app, &store, &repoUrl))\n        .await\n        .map_err(|err| err.to_string())?\n        .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub async fn install_git_selection(\n    app: tauri::AppHandle,\n    store: State<'_, SkillStore>,\n    repoUrl: String,\n    subpath: String,\n    name: Option<String>,\n) -> Result<InstallResultDto, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let result = install_git_skill_from_selection(&app, &store, &repoUrl, &subpath, name)?;\n        Ok::<_, anyhow::Error>(to_install_dto(result))\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[derive(Debug, Serialize)]\npub struct SyncResultDto {\n    pub mode_used: String,\n    pub target_path: String,\n}\n\n#[tauri::command]\npub async fn sync_skill_dir(\n    source_path: String,\n    target_path: String,\n) -> Result<SyncResultDto, String> {\n    tauri::async_runtime::spawn_blocking(move || {\n        let result = sync_dir_hybrid(source_path.as_ref(), target_path.as_ref())?;\n        Ok::<_, anyhow::Error>(SyncResultDto {\n            mode_used: match result.mode_used {\n                SyncMode::Auto => \"auto\",\n                SyncMode::Symlink => \"symlink\",\n                SyncMode::Junction => \"junction\",\n                SyncMode::Copy => \"copy\",\n            }\n            .to_string(),\n            target_path: result.target_path.to_string_lossy().to_string(),\n        })\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\n#[allow(clippy::too_many_arguments)]\npub async fn sync_skill_to_tool(\n    store: State<'_, SkillStore>,\n    sourcePath: String,\n    skillId: String,\n    tool: String,\n    name: String,\n    overwrite: Option<bool>,\n    overwriteIfSameContent: Option<bool>,\n    scope: Option<String>,\n    projectPath: Option<String>,\n) -> Result<SyncResultDto, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let adapter = adapter_by_key(&tool).ok_or_else(|| anyhow::anyhow!(\"unknown tool\"))?;\n        let scope = normalize_scope(scope.as_deref())?;\n        if scope == \"project\" && !supports_project_scope(&adapter) {\n            anyhow::bail!(\"PROJECT_SCOPE_UNSUPPORTED|{}\", adapter.id.as_key());\n        }\n        let project_root = if scope == \"project\" {\n            let raw = projectPath\n                .as_deref()\n                .ok_or_else(|| anyhow::anyhow!(\"projectPath is required for project scope\"))?;\n            let path = expand_home_path(raw)?;\n            if !path.is_dir() {\n                anyhow::bail!(\"projectPath must be an existing directory: {:?}\", path);\n            }\n            Some(path)\n        } else {\n            None\n        };\n\n        if scope == \"global\" && !is_tool_installed(&adapter)? {\n            anyhow::bail!(\"TOOL_NOT_INSTALLED|{}\", adapter.id.as_key());\n        }\n        let tool_root = if let Some(project_root) = &project_root {\n            resolve_project_path(&adapter, project_root)?\n        } else {\n            resolve_default_path(&adapter)?\n        };\n        // Pre-check: ensure the skills directory is writable (fixes #20 — Windows OS error 5).\n        if let Err(err) = std::fs::create_dir_all(&tool_root) {\n            if err.kind() == std::io::ErrorKind::PermissionDenied {\n                anyhow::bail!(\n                    \"TOOL_NOT_WRITABLE|{}|{}\",\n                    adapter.display_name,\n                    tool_root.to_string_lossy()\n                );\n            }\n            anyhow::bail!(\"failed to create skills dir {:?}: {}\", tool_root, err);\n        }\n        let target = tool_root.join(&name);\n        let project_path_for_record = project_root\n            .as_ref()\n            .map(|path| path.to_string_lossy().to_string());\n        if let Some(existing) =\n            store.get_skill_target(&skillId, &tool, scope, project_path_for_record.as_deref())?\n        {\n            if existing.target_path == target.to_string_lossy() && target.exists() {\n                return Ok::<_, anyhow::Error>(SyncResultDto {\n                    mode_used: existing.mode,\n                    target_path: existing.target_path,\n                });\n            }\n        }\n        let overwrite = overwrite.unwrap_or(false)\n            || (overwriteIfSameContent.unwrap_or(false)\n                && target_has_same_content(sourcePath.as_ref(), &target));\n        let result =\n            sync_dir_for_tool_with_overwrite(&tool, sourcePath.as_ref(), &target, overwrite)\n                .map_err(|err| {\n                    let msg = err.to_string();\n                    if msg.contains(\"target already exists\") {\n                        anyhow::anyhow!(\"TARGET_EXISTS|{}\", target.to_string_lossy())\n                    } else if msg.contains(\"os error 5\")\n                        || msg.contains(\"Access is denied\")\n                        || msg.contains(\"Permission denied\")\n                    {\n                        anyhow::anyhow!(\n                            \"TOOL_NOT_WRITABLE|{}|{}\",\n                            adapter.display_name,\n                            tool_root.to_string_lossy()\n                        )\n                    } else {\n                        anyhow::anyhow!(msg)\n                    }\n                })?;\n\n        // Some tools share the same skills directory; keep DB records consistent across them.\n        let group = if scope == \"project\" {\n            adapters_sharing_project_skills_dir(&adapter)\n        } else {\n            crate::core::tool_adapters::adapters_sharing_skills_dir(&adapter)\n        };\n        for a in group {\n            if !is_tool_installed(&a)? {\n                continue;\n            }\n            let record = SkillTargetRecord {\n                id: Uuid::new_v4().to_string(),\n                skill_id: skillId.clone(),\n                tool: a.id.as_key().to_string(),\n                scope: scope.to_string(),\n                project_path: project_path_for_record.clone(),\n                target_path: result.target_path.to_string_lossy().to_string(),\n                mode: match result.mode_used {\n                    SyncMode::Auto => \"auto\",\n                    SyncMode::Symlink => \"symlink\",\n                    SyncMode::Junction => \"junction\",\n                    SyncMode::Copy => \"copy\",\n                }\n                .to_string(),\n                status: \"ok\".to_string(),\n                last_error: None,\n                synced_at: Some(now_ms()),\n            };\n            store.upsert_skill_target(&record)?;\n        }\n\n        Ok::<_, anyhow::Error>(SyncResultDto {\n            mode_used: match result.mode_used {\n                SyncMode::Auto => \"auto\",\n                SyncMode::Symlink => \"symlink\",\n                SyncMode::Junction => \"junction\",\n                SyncMode::Copy => \"copy\",\n            }\n            .to_string(),\n            target_path: result.target_path.to_string_lossy().to_string(),\n        })\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\nfn target_has_same_content(source: &std::path::Path, target: &std::path::Path) -> bool {\n    if !source.is_dir() || !target.is_dir() {\n        return false;\n    }\n    match (hash_dir(source), hash_dir(target)) {\n        (Ok(source_hash), Ok(target_hash)) => source_hash == target_hash,\n        _ => false,\n    }\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub async fn unsync_skill_from_tool(\n    store: State<'_, SkillStore>,\n    skillId: String,\n    tool: String,\n    scope: Option<String>,\n    projectPath: Option<String>,\n) -> Result<(), String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let scope = normalize_scope(scope.as_deref())?;\n        let project_path = if scope == \"project\" {\n            let raw = projectPath\n                .as_deref()\n                .ok_or_else(|| anyhow::anyhow!(\"projectPath is required for project scope\"))?;\n            Some(expand_home_path(raw)?.to_string_lossy().to_string())\n        } else {\n            None\n        };\n\n        // Some tools share the same skills directory; unsync should update all of them.\n        let group_tool_keys: Vec<String> = if let Some(adapter) = adapter_by_key(&tool) {\n            let group = if scope == \"project\" {\n                adapters_sharing_project_skills_dir(&adapter)\n            } else {\n                crate::core::tool_adapters::adapters_sharing_skills_dir(&adapter)\n            };\n            // If none of the group tools are installed, do nothing (treat as already not effective).\n            if scope == \"global\" {\n                let mut any_installed = false;\n                for a in &group {\n                    if is_tool_installed(a)? {\n                        any_installed = true;\n                        break;\n                    }\n                }\n                if !any_installed {\n                    return Ok::<_, anyhow::Error>(());\n                }\n            }\n            group\n                .into_iter()\n                .map(|a| a.id.as_key().to_string())\n                .collect()\n        } else {\n            vec![tool.clone()]\n        };\n\n        // Remove filesystem target once (shared dir => shared target path).\n        let mut removed = false;\n        for k in &group_tool_keys {\n            if let Some(target) =\n                store.get_skill_target(&skillId, k, scope, project_path.as_deref())?\n            {\n                if !removed {\n                    remove_path_any(&target.target_path).map_err(anyhow::Error::msg)?;\n                    removed = true;\n                }\n                store.delete_skill_target(&skillId, k, scope, project_path.as_deref())?;\n            }\n        }\n\n        Ok::<_, anyhow::Error>(())\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[derive(Debug, Serialize)]\npub struct UpdateResultDto {\n    pub skill_id: String,\n    pub name: String,\n    pub content_hash: Option<String>,\n    pub source_revision: Option<String>,\n    pub updated_targets: Vec<String>,\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub async fn update_managed_skill(\n    app: tauri::AppHandle,\n    store: State<'_, SkillStore>,\n    skillId: String,\n) -> Result<UpdateResultDto, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let res = update_managed_skill_from_source(&app, &store, &skillId)?;\n        Ok::<_, anyhow::Error>(UpdateResultDto {\n            skill_id: res.skill_id,\n            name: res.name,\n            content_hash: res.content_hash,\n            source_revision: res.source_revision,\n            updated_targets: res.updated_targets,\n        })\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub async fn search_github(\n    store: State<'_, SkillStore>,\n    query: String,\n    limit: Option<u32>,\n) -> Result<Vec<RepoSummary>, String> {\n    let store = store.inner().clone();\n    let limit = limit.unwrap_or(10) as usize;\n    tauri::async_runtime::spawn_blocking(move || {\n        let token = store.get_setting(\"github_token\")?.unwrap_or_default();\n        let token_opt = if token.is_empty() {\n            None\n        } else {\n            Some(token.as_str())\n        };\n        search_github_repos(&query, limit, token_opt)\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub async fn get_github_token(store: State<'_, SkillStore>) -> Result<String, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        Ok::<_, anyhow::Error>(store.get_setting(\"github_token\")?.unwrap_or_default())\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub async fn set_github_token(store: State<'_, SkillStore>, token: String) -> Result<(), String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let trimmed = token.trim();\n        if trimmed.is_empty() {\n            store.set_setting(\"github_token\", \"\")?;\n        } else {\n            store.set_setting(\"github_token\", trimmed)?;\n        }\n        Ok::<_, anyhow::Error>(())\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub async fn import_existing_skill(\n    app: tauri::AppHandle,\n    store: State<'_, SkillStore>,\n    sourcePath: String,\n    name: Option<String>,\n) -> Result<InstallResultDto, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let source = std::path::Path::new(&sourcePath);\n        // Validate SKILL.md exists before importing (fixes #8: prevents importing\n        // directories that were \"discovered\" but lack a valid SKILL.md).\n        if !source.join(\"SKILL.md\").exists() {\n            anyhow::bail!(\"SKILL_INVALID|missing_skill_md\");\n        }\n        let result = install_local_skill(&app, &store, source, name)?;\n        Ok::<_, anyhow::Error>(to_install_dto(result))\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[derive(Debug, Serialize)]\npub struct ManagedSkillDto {\n    pub id: String,\n    pub name: String,\n    pub description: Option<String>,\n    pub source_type: String,\n    pub source_ref: Option<String>,\n    pub central_path: String,\n    pub created_at: i64,\n    pub updated_at: i64,\n    pub last_sync_at: Option<i64>,\n    pub status: String,\n    pub tags: Vec<TagDto>,\n    pub targets: Vec<SkillTargetDto>,\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct TagDto {\n    pub id: i64,\n    pub name: String,\n}\n\n#[derive(Debug, Serialize)]\npub struct TagWithCountDto {\n    pub id: i64,\n    pub name: String,\n    pub skill_count: i64,\n    pub updated_at: i64,\n}\n\n#[derive(Debug, Serialize)]\npub struct SkillTargetDto {\n    pub tool: String,\n    pub scope: String,\n    pub project_path: Option<String>,\n    pub mode: String,\n    pub status: String,\n    pub target_path: String,\n    pub synced_at: Option<i64>,\n}\n\n#[tauri::command]\npub fn get_managed_skills(store: State<'_, SkillStore>) -> Result<Vec<ManagedSkillDto>, String> {\n    get_managed_skills_impl(store.inner())\n}\n\n#[tauri::command]\npub fn get_tags(store: State<'_, SkillStore>) -> Result<Vec<TagWithCountDto>, String> {\n    store\n        .list_tags_with_counts()\n        .map(|tags| {\n            tags.into_iter()\n                .map(|tag| TagWithCountDto {\n                    id: tag.id,\n                    name: tag.name,\n                    skill_count: tag.skill_count,\n                    updated_at: tag.updated_at,\n                })\n                .collect()\n        })\n        .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub fn create_tag(store: State<'_, SkillStore>, name: String) -> Result<TagDto, String> {\n    store\n        .create_tag(&name)\n        .map(|tag| TagDto {\n            id: tag.id,\n            name: tag.name,\n        })\n        .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub fn rename_tag(\n    store: State<'_, SkillStore>,\n    tagId: i64,\n    name: String,\n) -> Result<TagDto, String> {\n    store\n        .rename_tag(tagId, &name)\n        .map(|tag| TagDto {\n            id: tag.id,\n            name: tag.name,\n        })\n        .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub fn delete_tag(store: State<'_, SkillStore>, tagId: i64) -> Result<(), String> {\n    store.delete_tag(tagId).map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub fn get_skill_tags(\n    store: State<'_, SkillStore>,\n    skillId: String,\n) -> Result<Vec<TagDto>, String> {\n    store\n        .get_skill_tags(&skillId)\n        .map(|tags| {\n            tags.into_iter()\n                .map(|tag| TagDto {\n                    id: tag.id,\n                    name: tag.name,\n                })\n                .collect()\n        })\n        .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub fn set_skill_tags(\n    store: State<'_, SkillStore>,\n    skillId: String,\n    tagIds: Vec<i64>,\n) -> Result<(), String> {\n    store\n        .set_skill_tags(&skillId, &tagIds)\n        .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub fn get_untagged_skill_ids(store: State<'_, SkillStore>) -> Result<Vec<String>, String> {\n    store.list_untagged_skill_ids().map_err(format_anyhow_error)\n}\n\n#[tauri::command]\n#[allow(non_snake_case)]\npub async fn delete_managed_skill(\n    store: State<'_, SkillStore>,\n    skillId: String,\n) -> Result<(), String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        // 便于排查“按钮点了没反应”：确认前端确实触发了命令\n        println!(\"[delete_managed_skill] skillId={}\", skillId);\n\n        // 先删除已同步到各工具目录的副本/软链接\n        // 注意：如果先删 skills 行，会触发 skill_targets cascade，导致无法再拿到 target_path\n        let targets = store.list_skill_targets(&skillId)?;\n\n        let mut remove_failures: Vec<String> = Vec::new();\n        for target in targets {\n            if let Err(err) = remove_path_any(&target.target_path) {\n                remove_failures.push(format!(\"{}: {}\", target.target_path, err));\n            }\n        }\n\n        let record = store.get_skill_by_id(&skillId)?;\n        if let Some(skill) = record {\n            let path = std::path::PathBuf::from(skill.central_path);\n            if path.exists() {\n                std::fs::remove_dir_all(&path)?;\n            }\n            store.delete_skill(&skillId)?;\n        }\n\n        if !remove_failures.is_empty() {\n            anyhow::bail!(\n                \"已删除托管记录，但清理部分工具目录失败：\\n- {}\",\n                remove_failures.join(\"\\n- \")\n            );\n        }\n\n        Ok::<_, anyhow::Error>(())\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\nfn remove_path_any(path: &str) -> Result<(), String> {\n    let p = std::path::Path::new(path);\n    if !p.exists() {\n        return Ok(());\n    }\n\n    let meta = std::fs::symlink_metadata(p).map_err(|err| err.to_string())?;\n    let ft = meta.file_type();\n\n    // 软链接（即使指向目录）也应该用 remove_file 删除链接本身\n    if ft.is_symlink() {\n        std::fs::remove_file(p).map_err(|err| err.to_string())?;\n        return Ok(());\n    }\n\n    if ft.is_dir() {\n        std::fs::remove_dir_all(p).map_err(|err| err.to_string())?;\n        return Ok(());\n    }\n\n    std::fs::remove_file(p).map_err(|err| err.to_string())?;\n    Ok(())\n}\n\nfn to_install_dto(result: InstallResult) -> InstallResultDto {\n    InstallResultDto {\n        skill_id: result.skill_id,\n        name: result.name,\n        central_path: result.central_path.to_string_lossy().to_string(),\n        content_hash: result.content_hash,\n    }\n}\n\nfn now_ms() -> i64 {\n    let now = std::time::SystemTime::now()\n        .duration_since(std::time::SystemTime::UNIX_EPOCH)\n        .unwrap_or_default();\n    now.as_millis() as i64\n}\n\nfn get_managed_skills_impl(store: &SkillStore) -> Result<Vec<ManagedSkillDto>, String> {\n    let skills = store.list_skills().map_err(|err| err.to_string())?;\n    Ok(skills\n        .into_iter()\n        .map(|skill| {\n            let targets = store\n                .list_skill_targets(&skill.id)\n                .unwrap_or_default()\n                .into_iter()\n                .map(|target| SkillTargetDto {\n                    tool: target.tool,\n                    scope: target.scope,\n                    project_path: target.project_path,\n                    mode: target.mode,\n                    status: target.status,\n                    target_path: target.target_path,\n                    synced_at: target.synced_at,\n                })\n                .collect();\n            let tags = store\n                .get_skill_tags(&skill.id)\n                .unwrap_or_default()\n                .into_iter()\n                .map(|tag| TagDto {\n                    id: tag.id,\n                    name: tag.name,\n                })\n                .collect();\n\n            ManagedSkillDto {\n                id: skill.id,\n                name: skill.name,\n                description: skill.description,\n                source_type: skill.source_type,\n                source_ref: skill.source_ref,\n                central_path: skill.central_path,\n                created_at: skill.created_at,\n                updated_at: skill.updated_at,\n                last_sync_at: skill.last_sync_at,\n                status: skill.status,\n                tags,\n                targets,\n            }\n        })\n        .collect())\n}\n\n#[derive(Debug, Serialize)]\npub struct FeaturedSkillDto {\n    pub slug: String,\n    pub name: String,\n    pub summary: String,\n    pub downloads: u64,\n    pub stars: u64,\n    pub source_url: String,\n}\n\nimpl From<FeaturedSkill> for FeaturedSkillDto {\n    fn from(s: FeaturedSkill) -> Self {\n        Self {\n            slug: s.slug,\n            name: s.name,\n            summary: s.summary,\n            downloads: s.downloads,\n            stars: s.stars,\n            source_url: s.source_url,\n        }\n    }\n}\n\n#[tauri::command]\npub async fn get_featured_skills(\n    store: State<'_, SkillStore>,\n) -> Result<Vec<FeaturedSkillDto>, String> {\n    let store = store.inner().clone();\n    tauri::async_runtime::spawn_blocking(move || {\n        let skills = fetch_featured_skills(&store)?;\n        Ok::<_, anyhow::Error>(skills.into_iter().map(FeaturedSkillDto::from).collect())\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[derive(Debug, Serialize)]\npub struct OnlineSkillDto {\n    pub name: String,\n    pub installs: u64,\n    pub source: String,\n    pub source_url: String,\n}\n\nimpl From<OnlineSkillResult> for OnlineSkillDto {\n    fn from(r: OnlineSkillResult) -> Self {\n        Self {\n            name: r.name,\n            installs: r.installs,\n            source: r.source,\n            source_url: r.source_url,\n        }\n    }\n}\n\n#[tauri::command]\npub async fn search_skills_online(\n    query: String,\n    limit: Option<u32>,\n) -> Result<Vec<OnlineSkillDto>, String> {\n    let limit = limit.unwrap_or(20) as usize;\n    tauri::async_runtime::spawn_blocking(move || {\n        let results = search_skills_online_core(&query, limit)?;\n        Ok::<_, anyhow::Error>(results.into_iter().map(OnlineSkillDto::from).collect())\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[derive(Debug, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct SkillFileEntry {\n    pub path: String,\n    pub size: u64,\n}\n\n#[tauri::command]\npub async fn list_skill_files(central_path: String) -> Result<Vec<SkillFileEntry>, String> {\n    let path = std::path::PathBuf::from(&central_path);\n    tauri::async_runtime::spawn_blocking(move || {\n        let entries = crate::core::skill_files::list_files(&path)?;\n        Ok::<_, anyhow::Error>(\n            entries\n                .into_iter()\n                .map(|e| SkillFileEntry {\n                    path: e.path,\n                    size: e.size,\n                })\n                .collect(),\n        )\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub async fn read_skill_file(central_path: String, file_path: String) -> Result<String, String> {\n    let base = std::path::PathBuf::from(&central_path);\n    tauri::async_runtime::spawn_blocking(move || {\n        crate::core::skill_files::read_file(&base, &file_path)\n    })\n    .await\n    .map_err(|err| err.to_string())?\n    .map_err(format_anyhow_error)\n}\n\n#[tauri::command]\npub fn cancel_current_operation(cancel: State<'_, Arc<CancelToken>>) -> Result<(), String> {\n    cancel.cancel();\n    Ok(())\n}\n\n#[cfg(test)]\n#[path = \"tests/commands.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/commands/tests/commands.rs",
    "content": "use super::*;\nuse crate::core::skill_store::SkillRecord;\n\nfn make_store() -> (tempfile::TempDir, SkillStore) {\n    let dir = tempfile::tempdir().expect(\"tempdir\");\n    let store = SkillStore::new(dir.path().join(\"test.db\"));\n    store.ensure_schema().expect(\"ensure_schema\");\n    (dir, store)\n}\n\n#[test]\nfn format_anyhow_error_passthrough_prefixes() {\n    let err = anyhow::anyhow!(\"MULTI_SKILLS|abc\");\n    assert_eq!(format_anyhow_error(err), \"MULTI_SKILLS|abc\");\n}\n\n#[test]\nfn format_anyhow_error_redacts_clone_temp_path() {\n    let err = anyhow::anyhow!(\"clone https://example.com/a/b into /tmp/skills-hub-git-123\");\n    let msg = format_anyhow_error(err);\n    assert!(msg.contains(\"已省略临时目录\"));\n    assert!(!msg.contains(\"/tmp/skills-hub-git-123\"));\n}\n\n#[test]\nfn format_anyhow_error_github_hint_auth() {\n    let err = anyhow::anyhow!(\"git clone https://github.com/a/b failed: authentication failed\");\n    let msg = format_anyhow_error(err);\n    assert!(msg.contains(\"无法访问该仓库\"));\n}\n\n#[test]\nfn expand_home_path_basic() {\n    let home = dirs::home_dir().expect(\"home\");\n    assert_eq!(expand_home_path(\"~\").unwrap(), home);\n    assert_eq!(expand_home_path(\"~/abc\").unwrap(), home.join(\"abc\"));\n}\n\n#[test]\nfn expand_home_path_empty_is_error() {\n    let err = expand_home_path(\"  \").unwrap_err().to_string();\n    assert!(err.contains(\"storage path is empty\"));\n}\n\n#[test]\nfn normalize_scope_defaults_to_global_and_rejects_unknown() {\n    assert_eq!(normalize_scope(None).unwrap(), \"global\");\n    assert_eq!(normalize_scope(Some(\"global\")).unwrap(), \"global\");\n    assert_eq!(normalize_scope(Some(\"project\")).unwrap(), \"project\");\n    assert!(normalize_scope(Some(\"workspace\")).is_err());\n}\n\n#[test]\nfn recent_projects_are_deduped_ordered_and_limited() {\n    let (_dir, store) = make_store();\n    let project_root = tempfile::tempdir().unwrap();\n    let mut paths = Vec::new();\n    for i in 0..9 {\n        let path = project_root.path().join(format!(\"project-{i}\"));\n        std::fs::create_dir_all(&path).unwrap();\n        paths.push(path);\n    }\n\n    for path in &paths {\n        save_recent_project_impl(&store, path.to_string_lossy().as_ref()).unwrap();\n    }\n\n    let recent = get_recent_projects_impl(&store).unwrap();\n    assert_eq!(recent.len(), 8);\n    assert_eq!(recent[0], paths[8].to_string_lossy());\n    assert_eq!(recent[7], paths[1].to_string_lossy());\n    assert!(!recent.contains(&paths[0].to_string_lossy().to_string()));\n\n    save_recent_project_impl(&store, paths[3].to_string_lossy().as_ref()).unwrap();\n    let recent = get_recent_projects_impl(&store).unwrap();\n    assert_eq!(recent.len(), 8);\n    assert_eq!(recent[0], paths[3].to_string_lossy());\n    assert_eq!(\n        recent\n            .iter()\n            .filter(|item| *item == &paths[3].to_string_lossy())\n            .count(),\n        1\n    );\n}\n\n#[test]\nfn save_recent_project_rejects_missing_directory() {\n    let (_dir, store) = make_store();\n    let missing = tempfile::tempdir().unwrap().path().join(\"missing-project\");\n    let err = save_recent_project_impl(&store, missing.to_string_lossy().as_ref())\n        .unwrap_err()\n        .to_string();\n    assert!(err.contains(\"projectPath must be an existing directory\"));\n}\n\n#[test]\nfn remove_path_any_handles_file_dir_and_missing() {\n    let dir = tempfile::tempdir().unwrap();\n    let file = dir.path().join(\"f.txt\");\n    std::fs::write(&file, b\"1\").unwrap();\n    remove_path_any(file.to_string_lossy().as_ref()).unwrap();\n    assert!(!file.exists());\n\n    let sub = dir.path().join(\"d\");\n    std::fs::create_dir_all(&sub).unwrap();\n    remove_path_any(sub.to_string_lossy().as_ref()).unwrap();\n    assert!(!sub.exists());\n\n    remove_path_any(dir.path().join(\"missing\").to_string_lossy().as_ref()).unwrap();\n}\n\n#[test]\n#[cfg(unix)]\nfn remove_path_any_removes_symlink_only() {\n    use std::os::unix::fs::symlink;\n\n    let dir = tempfile::tempdir().unwrap();\n    let target = dir.path().join(\"real\");\n    std::fs::create_dir_all(&target).unwrap();\n    let link = dir.path().join(\"link\");\n    symlink(&target, &link).unwrap();\n\n    remove_path_any(link.to_string_lossy().as_ref()).unwrap();\n    assert!(!link.exists());\n    assert!(target.exists());\n}\n\n#[test]\nfn get_managed_skills_impl_maps_targets() {\n    let (_dir, store) = make_store();\n    let skill = SkillRecord {\n        id: \"s1\".to_string(),\n        name: \"S1\".to_string(),\n        description: None,\n        source_type: \"local\".to_string(),\n        source_ref: Some(\"/tmp/src\".to_string()),\n        source_subpath: None,\n        source_revision: None,\n        central_path: \"/tmp/central\".to_string(),\n        content_hash: None,\n        created_at: 1,\n        updated_at: 2,\n        last_sync_at: None,\n        last_seen_at: 1,\n        status: \"ok\".to_string(),\n    };\n    store.upsert_skill(&skill).unwrap();\n\n    let target = SkillTargetRecord {\n        id: \"t1\".to_string(),\n        skill_id: \"s1\".to_string(),\n        tool: \"cursor\".to_string(),\n        scope: \"global\".to_string(),\n        project_path: None,\n        target_path: \"/tmp/target\".to_string(),\n        mode: \"copy\".to_string(),\n        status: \"ok\".to_string(),\n        last_error: None,\n        synced_at: None,\n    };\n    store.upsert_skill_target(&target).unwrap();\n    let tag = store.create_tag(\"Frontend\").unwrap();\n    store.set_skill_tags(\"s1\", &[tag.id]).unwrap();\n\n    let out = get_managed_skills_impl(&store).unwrap();\n    assert_eq!(out.len(), 1);\n    assert_eq!(out[0].tags.len(), 1);\n    assert_eq!(out[0].tags[0].name, \"Frontend\");\n    assert_eq!(out[0].targets.len(), 1);\n    assert_eq!(out[0].targets[0].tool, \"cursor\");\n    assert_eq!(out[0].targets[0].scope, \"global\");\n    assert!(out[0].targets[0].project_path.is_none());\n}\n"
  },
  {
    "path": "src-tauri/src/core/cache_cleanup.rs",
    "content": "use std::path::{Path, PathBuf};\nuse std::time::{Duration, SystemTime};\n\nuse anyhow::{Context, Result};\nuse serde::Deserialize;\nuse tauri::Manager;\n\nuse super::skill_store::SkillStore;\n\nconst CACHE_DIR_NAME: &str = \"skills-hub-git-cache\";\nconst CACHE_META_FILE: &str = \".skills-hub-cache.json\";\npub const GIT_CACHE_CLEANUP_DAYS_KEY: &str = \"git_cache_cleanup_days\";\npub const DEFAULT_GIT_CACHE_CLEANUP_DAYS: i64 = 30;\nconst MAX_GIT_CACHE_CLEANUP_DAYS: i64 = 3650;\npub const GIT_CACHE_TTL_SECS_KEY: &str = \"git_cache_ttl_secs\";\npub const DEFAULT_GIT_CACHE_TTL_SECS: i64 = 60;\nconst MAX_GIT_CACHE_TTL_SECS: i64 = 3600;\n\n#[derive(Debug, Deserialize)]\nstruct RepoCacheMeta {\n    last_fetched_ms: i64,\n}\n\npub fn get_git_cache_cleanup_days(store: &SkillStore) -> i64 {\n    let raw = store.get_setting(GIT_CACHE_CLEANUP_DAYS_KEY).ok().flatten();\n    parse_cleanup_days(raw).unwrap_or(DEFAULT_GIT_CACHE_CLEANUP_DAYS)\n}\n\npub fn set_git_cache_cleanup_days(store: &SkillStore, days: i64) -> Result<i64> {\n    if !(0..=MAX_GIT_CACHE_CLEANUP_DAYS).contains(&days) {\n        anyhow::bail!(\n            \"cleanup days must be between 0 and {}\",\n            MAX_GIT_CACHE_CLEANUP_DAYS\n        );\n    }\n    store.set_setting(GIT_CACHE_CLEANUP_DAYS_KEY, &days.to_string())?;\n    Ok(days)\n}\n\npub fn get_git_cache_ttl_secs(store: &SkillStore) -> i64 {\n    let raw = store.get_setting(GIT_CACHE_TTL_SECS_KEY).ok().flatten();\n    parse_cache_ttl_secs(raw).unwrap_or(DEFAULT_GIT_CACHE_TTL_SECS)\n}\n\npub fn set_git_cache_ttl_secs(store: &SkillStore, secs: i64) -> Result<i64> {\n    if !(0..=MAX_GIT_CACHE_TTL_SECS).contains(&secs) {\n        anyhow::bail!(\n            \"cache ttl seconds must be between 0 and {}\",\n            MAX_GIT_CACHE_TTL_SECS\n        );\n    }\n    store.set_setting(GIT_CACHE_TTL_SECS_KEY, &secs.to_string())?;\n    Ok(secs)\n}\n\npub fn cleanup_git_cache_dirs<R: tauri::Runtime>(\n    app: &tauri::AppHandle<R>,\n    max_age: Duration,\n) -> Result<usize> {\n    let cache_dir = app\n        .path()\n        .app_cache_dir()\n        .context(\"failed to resolve app cache dir\")?;\n    cleanup_git_cache_dirs_in(&cache_dir, max_age)\n}\n\nfn cleanup_git_cache_dirs_in(cache_dir: &Path, max_age: Duration) -> Result<usize> {\n    let cache_root = cache_dir.join(CACHE_DIR_NAME);\n    if !cache_root.exists() {\n        return Ok(0);\n    }\n\n    let cutoff_ms = now_ms().saturating_sub(max_age.as_millis().try_into().unwrap_or(i64::MAX));\n    let cutoff_time = SystemTime::now()\n        .checked_sub(max_age)\n        .unwrap_or(SystemTime::UNIX_EPOCH);\n\n    let mut removed = 0usize;\n    let rd = match std::fs::read_dir(&cache_root) {\n        Ok(v) => v,\n        Err(err) => {\n            return Err(anyhow::anyhow!(\n                \"failed to read cache dir {:?}: {}\",\n                cache_root,\n                err\n            ));\n        }\n    };\n\n    for entry in rd.flatten() {\n        let path: PathBuf = entry.path();\n        if !path.is_dir() {\n            continue;\n        }\n\n        if !path.join(\".git\").exists() {\n            continue;\n        }\n\n        let meta_path = path.join(CACHE_META_FILE);\n        let mut should_remove = false;\n\n        if let Ok(raw) = std::fs::read_to_string(&meta_path) {\n            if let Ok(meta) = serde_json::from_str::<RepoCacheMeta>(&raw) {\n                if meta.last_fetched_ms > 0 && meta.last_fetched_ms <= cutoff_ms {\n                    should_remove = true;\n                }\n            }\n        }\n\n        if !should_remove {\n            let meta = match std::fs::metadata(&path) {\n                Ok(m) => m,\n                Err(_) => continue,\n            };\n            let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);\n            if modified <= cutoff_time {\n                should_remove = true;\n            }\n        }\n\n        if should_remove && std::fs::remove_dir_all(&path).is_ok() {\n            removed += 1;\n        }\n    }\n\n    Ok(removed)\n}\n\nfn parse_cleanup_days(raw: Option<String>) -> Option<i64> {\n    let value = raw?.trim().parse::<i64>().ok()?;\n    if !(0..=MAX_GIT_CACHE_CLEANUP_DAYS).contains(&value) {\n        None\n    } else {\n        Some(value)\n    }\n}\n\nfn parse_cache_ttl_secs(raw: Option<String>) -> Option<i64> {\n    let value = raw?.trim().parse::<i64>().ok()?;\n    if !(0..=MAX_GIT_CACHE_TTL_SECS).contains(&value) {\n        None\n    } else {\n        Some(value)\n    }\n}\n\nfn now_ms() -> i64 {\n    let now = SystemTime::now()\n        .duration_since(SystemTime::UNIX_EPOCH)\n        .unwrap_or_default();\n    now.as_millis() as i64\n}\n"
  },
  {
    "path": "src-tauri/src/core/cancel_token.rs",
    "content": "use std::sync::atomic::{AtomicBool, Ordering};\n\n/// Shared cancellation flag for long-running operations.\n/// Managed as Tauri state so both commands and core logic can access it.\n#[derive(Debug, Default)]\npub struct CancelToken {\n    cancelled: AtomicBool,\n}\n\nimpl CancelToken {\n    pub fn new() -> Self {\n        Self {\n            cancelled: AtomicBool::new(false),\n        }\n    }\n\n    /// Request cancellation.\n    pub fn cancel(&self) {\n        self.cancelled.store(true, Ordering::SeqCst);\n    }\n\n    /// Reset the flag (call before starting a new operation).\n    pub fn reset(&self) {\n        self.cancelled.store(false, Ordering::SeqCst);\n    }\n\n    /// Check if cancellation was requested.\n    pub fn is_cancelled(&self) -> bool {\n        self.cancelled.load(Ordering::SeqCst)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn default_is_not_cancelled() {\n        let token = CancelToken::new();\n        assert!(!token.is_cancelled());\n    }\n\n    #[test]\n    fn cancel_sets_flag() {\n        let token = CancelToken::new();\n        token.cancel();\n        assert!(token.is_cancelled());\n    }\n\n    #[test]\n    fn reset_clears_flag() {\n        let token = CancelToken::new();\n        token.cancel();\n        assert!(token.is_cancelled());\n        token.reset();\n        assert!(!token.is_cancelled());\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/core/central_repo.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse dirs::home_dir;\nuse tauri::Manager;\n\nuse super::skill_store::SkillStore;\n\nconst CENTRAL_DIR_NAME: &str = \".skillshub\";\n\npub fn resolve_central_repo_path<R: tauri::Runtime>(\n    app: &tauri::AppHandle<R>,\n    store: &SkillStore,\n) -> Result<PathBuf> {\n    if let Some(path) = store.get_setting(\"central_repo_path\")? {\n        return Ok(PathBuf::from(path));\n    }\n\n    if let Some(home) = home_dir() {\n        return Ok(home.join(CENTRAL_DIR_NAME));\n    }\n\n    let base = app\n        .path()\n        .app_data_dir()\n        .context(\"failed to resolve app data dir\")?;\n    Ok(base.join(CENTRAL_DIR_NAME))\n}\n\npub fn ensure_central_repo(path: &Path) -> Result<()> {\n    std::fs::create_dir_all(path).with_context(|| format!(\"create {:?}\", path))?;\n    Ok(())\n}\n\n#[cfg(test)]\n#[path = \"tests/central_repo.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/core/content_hash.rs",
    "content": "use std::path::Path;\n\nuse anyhow::{Context, Result};\nuse sha2::{Digest, Sha256};\nuse walkdir::{DirEntry, WalkDir};\n\nconst IGNORE_NAMES: [&str; 4] = [\".git\", \".DS_Store\", \"Thumbs.db\", \".gitignore\"];\n\nfn is_ignored(entry: &DirEntry) -> bool {\n    let file_name = entry.file_name().to_string_lossy();\n    IGNORE_NAMES.iter().any(|name| name == &file_name.as_ref())\n}\n\npub fn hash_dir(path: &Path) -> Result<String> {\n    let mut hasher = Sha256::new();\n\n    for entry in WalkDir::new(path)\n        .follow_links(false)\n        .into_iter()\n        .filter_entry(|entry| !is_ignored(entry))\n    {\n        let entry = entry?;\n        if is_ignored(&entry) {\n            continue;\n        }\n\n        let relative = entry\n            .path()\n            .strip_prefix(path)\n            .with_context(|| format!(\"strip prefix {:?}\", entry.path()))?;\n        hasher.update(relative.to_string_lossy().as_bytes());\n\n        if entry.file_type().is_file() {\n            let bytes = std::fs::read(entry.path())\n                .with_context(|| format!(\"read file {:?}\", entry.path()))?;\n            hasher.update(bytes);\n        }\n    }\n\n    let digest = hasher.finalize();\n    Ok(hex::encode(digest))\n}\n\n#[cfg(test)]\n#[path = \"tests/content_hash.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/core/featured_skills.rs",
    "content": "use anyhow::{Context, Result};\nuse reqwest::blocking::Client;\nuse serde::Deserialize;\n\nuse super::skill_store::SkillStore;\n\nconst FEATURED_SKILLS_URL: &str =\n    \"https://raw.githubusercontent.com/qufei1993/skills-hub/main/featured-skills.json\";\n\nconst CACHE_KEY: &str = \"featured_skills_cache\";\n\n// Bundled fallback so the app works even before the first network fetch succeeds.\nconst BUNDLED_JSON: &str = include_str!(\"../../../featured-skills.json\");\n\n#[derive(Debug, Deserialize)]\nstruct FeaturedSkillsData {\n    skills: Vec<FeaturedSkillRaw>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct FeaturedSkillRaw {\n    slug: String,\n    name: String,\n    #[serde(default)]\n    summary: String,\n    #[serde(default)]\n    downloads: u64,\n    #[serde(default)]\n    stars: u64,\n    #[serde(default)]\n    source_url: String,\n}\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct FeaturedSkill {\n    pub slug: String,\n    pub name: String,\n    pub summary: String,\n    pub downloads: u64,\n    pub stars: u64,\n    pub source_url: String,\n}\n\npub fn fetch_featured_skills(store: &SkillStore) -> Result<Vec<FeaturedSkill>> {\n    fetch_featured_skills_inner(FEATURED_SKILLS_URL, store)\n}\n\nfn fetch_featured_skills_inner(url: &str, store: &SkillStore) -> Result<Vec<FeaturedSkill>> {\n    if let Ok(json_str) = fetch_from_url(url) {\n        if let Ok(skills) = parse_and_filter(&json_str) {\n            if !skills.is_empty() {\n                let _ = store.set_setting(CACHE_KEY, &json_str);\n                return Ok(skills);\n            }\n        }\n    }\n    // Fallback to cache\n    if let Ok(Some(cached)) = store.get_setting(CACHE_KEY) {\n        if let Ok(skills) = parse_and_filter(&cached) {\n            if !skills.is_empty() {\n                return Ok(skills);\n            }\n        }\n    }\n    // Fallback to bundled JSON\n    Ok(parse_and_filter(BUNDLED_JSON).unwrap_or_default())\n}\n\nfn fetch_from_url(url: &str) -> Result<String> {\n    let client = Client::builder()\n        .timeout(std::time::Duration::from_secs(15))\n        .build()\n        .context(\"build HTTP client\")?;\n\n    let body = client\n        .get(url)\n        .header(\"User-Agent\", \"skills-hub\")\n        .send()\n        .context(\"fetch featured skills\")?\n        .error_for_status()\n        .context(\"featured skills HTTP error\")?\n        .text()\n        .context(\"read featured skills body\")?;\n\n    Ok(body)\n}\n\nfn parse_and_filter(json_str: &str) -> Result<Vec<FeaturedSkill>> {\n    let data: FeaturedSkillsData =\n        serde_json::from_str(json_str).context(\"parse featured skills JSON\")?;\n\n    Ok(data\n        .skills\n        .into_iter()\n        .filter(|s| !s.source_url.is_empty())\n        .map(|s| FeaturedSkill {\n            slug: s.slug,\n            name: s.name,\n            summary: s.summary,\n            downloads: s.downloads,\n            stars: s.stars,\n            source_url: s.source_url,\n        })\n        .collect())\n}\n\n#[cfg(test)]\n#[path = \"tests/featured_skills.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/core/git_fetcher.rs",
    "content": "use std::path::Path;\nuse std::process::Command;\nuse std::process::Stdio;\nuse std::sync::OnceLock;\nuse std::time::{Duration, Instant};\n\nuse anyhow::{Context, Result};\nuse git2::{FetchOptions, Repository};\n\nuse super::cancel_token::CancelToken;\n\npub fn clone_or_pull(\n    repo_url: &str,\n    dest: &Path,\n    branch: Option<&str>,\n    cancel: Option<&CancelToken>,\n) -> Result<String> {\n    // Prefer the system `git` binary if available. It tends to work better on macOS\n    // networks because it respects user git config (proxy/certs) and OS trust store.\n    if let Some(git_bin) = resolve_git_bin() {\n        let started = Instant::now();\n        match clone_or_pull_via_git_cli(repo_url, dest, branch, cancel) {\n            Ok(head) => {\n                log::info!(\n                    \"[git_fetcher] git-cli ok (bin={}) {}s url={}\",\n                    git_bin,\n                    started.elapsed().as_secs_f32(),\n                    repo_url\n                );\n                return Ok(head);\n            }\n            Err(err) => {\n                let allow_fallback = std::env::var(\"SKILLS_HUB_ALLOW_LIBGIT2_FALLBACK\")\n                    .ok()\n                    .map(|v| v == \"1\" || v.eq_ignore_ascii_case(\"true\"))\n                    .unwrap_or(false);\n                log::warn!(\n                    \"[git_fetcher] git-cli failed (bin={}) {}s url={} err={:#}\",\n                    git_bin,\n                    started.elapsed().as_secs_f32(),\n                    repo_url,\n                    err\n                );\n                if !allow_fallback {\n                    anyhow::bail!(\n                        \"git 命令执行失败（为避免卡死，已停止并不再回退到内置 git）。请检查系统 git/网络/代理；或设置环境变量 SKILLS_HUB_ALLOW_LIBGIT2_FALLBACK=1 允许回退。\\n{:#}\",\n                        err\n                    );\n                }\n                log::warn!(\n                    \"[git_fetcher] falling back to libgit2 (SKILLS_HUB_ALLOW_LIBGIT2_FALLBACK=1)\"\n                );\n            }\n        }\n    } else {\n        log::info!(\"[git_fetcher] system git not available; using libgit2\");\n    }\n\n    let repo = if dest.exists() {\n        let repo = Repository::open(dest).with_context(|| format!(\"open repo at {:?}\", dest))?;\n        fetch_origin(&repo)?;\n        repo\n    } else {\n        Repository::clone(repo_url, dest)\n            .with_context(|| format!(\"clone {} into {:?}\", repo_url, dest))?\n    };\n\n    // Best-effort: move working tree HEAD to the fetched remote head (so \"pull\" actually updates).\n    if let Some(branch) = branch {\n        if let Ok(obj) = repo.revparse_single(&format!(\"refs/remotes/origin/{}\", branch)) {\n            repo.checkout_tree(&obj, None)?;\n            repo.set_head_detached(obj.id())?;\n        }\n    } else {\n        let candidates = [\n            \"refs/remotes/origin/HEAD\",\n            \"refs/remotes/origin/main\",\n            \"refs/remotes/origin/master\",\n        ];\n        for r in candidates {\n            if let Ok(obj) = repo.revparse_single(r) {\n                repo.checkout_tree(&obj, None)?;\n                repo.set_head_detached(obj.id())?;\n                break;\n            }\n        }\n    }\n\n    let head = repo.head()?.target().context(\"missing HEAD target\")?;\n    Ok(head.to_string())\n}\n\npub fn clone_or_pull_sparse(\n    repo_url: &str,\n    dest: &Path,\n    branch: Option<&str>,\n    subpath: &str,\n    cancel: Option<&CancelToken>,\n) -> Result<String> {\n    let clean_subpath = subpath.trim_matches('/');\n    if clean_subpath.is_empty() {\n        anyhow::bail!(\"sparse checkout path is empty\");\n    }\n\n    if resolve_git_bin().is_none() {\n        anyhow::bail!(\"system git is required for sparse checkout\");\n    }\n\n    // Ensure parent exists so `git clone` can create dest.\n    if let Some(parent) = dest.parent() {\n        std::fs::create_dir_all(parent)\n            .with_context(|| format!(\"failed to create parent dir {:?}\", parent))?;\n    }\n\n    if dest.exists() {\n        let git_dir = dest.join(\".git\");\n        for lock_name in &[\"index.lock\", \"shallow.lock\", \"HEAD.lock\"] {\n            let lock_path = git_dir.join(lock_name);\n            if lock_path.exists() {\n                log::warn!(\"[git_fetcher] removing stale lock file: {:?}\", lock_path);\n                let _ = std::fs::remove_file(&lock_path);\n            }\n        }\n\n        let out = run_cmd_with_timeout(\n            {\n                let mut cmd = git_cmd();\n                cmd.arg(\"-C\").arg(dest).args([\n                    \"sparse-checkout\",\n                    \"set\",\n                    \"--no-cone\",\n                    clean_subpath,\n                ]);\n                cmd\n            },\n            git_fetch_timeout(),\n            format!(\"git sparse-checkout set {} in {:?}\", clean_subpath, dest),\n            cancel,\n        )?;\n        if !out.status.success() {\n            anyhow::bail!(\n                \"git sparse-checkout set failed: {}\",\n                String::from_utf8_lossy(&out.stderr)\n            );\n        }\n\n        let out = run_cmd_with_timeout(\n            {\n                let mut cmd = git_cmd();\n                cmd.arg(\"-C\").arg(dest).args([\"fetch\", \"--prune\", \"origin\"]);\n                cmd\n            },\n            git_fetch_timeout(),\n            format!(\"git fetch in {:?}\", dest),\n            cancel,\n        )?;\n        if !out.status.success() {\n            anyhow::bail!(\"git fetch failed: {}\", String::from_utf8_lossy(&out.stderr));\n        }\n\n        if let Some(branch) = branch {\n            let out = run_cmd_with_timeout(\n                {\n                    let mut cmd = git_cmd();\n                    cmd.arg(\"-C\").arg(dest).args([\n                        \"checkout\",\n                        \"-B\",\n                        branch,\n                        &format!(\"origin/{}\", branch),\n                    ]);\n                    cmd\n                },\n                git_fetch_timeout(),\n                format!(\"git checkout -B {} in {:?}\", branch, dest),\n                cancel,\n            )?;\n            if !out.status.success() {\n                anyhow::bail!(\n                    \"git checkout branch failed: {}\",\n                    String::from_utf8_lossy(&out.stderr)\n                );\n            }\n        } else {\n            let out = run_cmd_with_timeout(\n                {\n                    let mut cmd = git_cmd();\n                    cmd.arg(\"-C\")\n                        .arg(dest)\n                        .args([\"reset\", \"--hard\", \"FETCH_HEAD\"]);\n                    cmd\n                },\n                git_fetch_timeout(),\n                format!(\"git reset --hard in {:?}\", dest),\n                cancel,\n            )?;\n            if !out.status.success() {\n                anyhow::bail!(\n                    \"git reset --hard failed: {}\",\n                    String::from_utf8_lossy(&out.stderr)\n                );\n            }\n        }\n    } else {\n        let mut cmd = git_cmd();\n        cmd.arg(\"clone\").args([\n            \"--depth\",\n            \"1\",\n            \"--filter=blob:none\",\n            \"--sparse\",\n            \"--no-tags\",\n        ]);\n        if let Some(branch) = branch {\n            cmd.arg(\"--branch\").arg(branch).arg(\"--single-branch\");\n        }\n        cmd.arg(repo_url).arg(dest);\n        let out = run_cmd_with_timeout(\n            cmd,\n            git_timeout(),\n            format!(\"git clone {} into {:?}\", repo_url, dest),\n            cancel,\n        )?;\n        if !out.status.success() {\n            anyhow::bail!(\"git clone failed: {}\", String::from_utf8_lossy(&out.stderr));\n        }\n\n        let out = run_cmd_with_timeout(\n            {\n                let mut cmd = git_cmd();\n                cmd.arg(\"-C\").arg(dest).args([\n                    \"sparse-checkout\",\n                    \"set\",\n                    \"--no-cone\",\n                    clean_subpath,\n                ]);\n                cmd\n            },\n            git_fetch_timeout(),\n            format!(\"git sparse-checkout set {} in {:?}\", clean_subpath, dest),\n            cancel,\n        )?;\n        if !out.status.success() {\n            anyhow::bail!(\n                \"git sparse-checkout set failed: {}\",\n                String::from_utf8_lossy(&out.stderr)\n            );\n        }\n    }\n\n    let out = run_cmd_with_timeout(\n        {\n            let mut cmd = git_cmd();\n            cmd.arg(\"-C\").arg(dest).args([\"rev-parse\", \"HEAD\"]);\n            cmd\n        },\n        git_fetch_timeout(),\n        format!(\"git rev-parse HEAD in {:?}\", dest),\n        cancel,\n    )?;\n    if !out.status.success() {\n        anyhow::bail!(\n            \"git rev-parse failed: {}\",\n            String::from_utf8_lossy(&out.stderr)\n        );\n    }\n    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())\n}\n\nfn git_timeout() -> Duration {\n    let secs = std::env::var(\"SKILLS_HUB_GIT_TIMEOUT_SECS\")\n        .ok()\n        .and_then(|v| v.parse::<u64>().ok())\n        .unwrap_or(300);\n    Duration::from_secs(secs)\n}\n\nfn git_fetch_timeout() -> Duration {\n    let secs = std::env::var(\"SKILLS_HUB_GIT_FETCH_TIMEOUT_SECS\")\n        .ok()\n        .and_then(|v| v.parse::<u64>().ok())\n        .unwrap_or(180);\n    Duration::from_secs(secs)\n}\n\nstatic GIT_BIN: OnceLock<Option<String>> = OnceLock::new();\n\nfn resolve_git_bin() -> Option<String> {\n    GIT_BIN\n        .get_or_init(|| {\n            // Allow overriding from environment for debugging / enterprise setups.\n            for key in [\"SKILLS_HUB_GIT_BIN\", \"SKILLS_HUB_GIT_PATH\"] {\n                if let Ok(v) = std::env::var(key) {\n                    let v = v.trim().to_string();\n                    if !v.is_empty() && git_bin_works(&v) {\n                        log::info!(\"[git_fetcher] using git bin from {}: {}\", key, v);\n                        return Some(v);\n                    }\n                }\n            }\n\n            // Try PATH lookup first (works in dev; sometimes missing in macOS bundles).\n            if git_bin_works(\"git\") {\n                log::info!(\"[git_fetcher] using git bin from PATH: git\");\n                return Some(\"git\".to_string());\n            }\n\n            // Common macOS locations (system git and Homebrew).\n            for cand in [\n                \"/usr/bin/git\",\n                \"/opt/homebrew/bin/git\",\n                \"/usr/local/bin/git\",\n            ] {\n                if git_bin_works(cand) {\n                    log::info!(\"[git_fetcher] using git bin: {}\", cand);\n                    return Some(cand.to_string());\n                }\n            }\n\n            log::warn!(\"[git_fetcher] no usable git binary found\");\n            None\n        })\n        .clone()\n}\n\nfn git_bin_works(bin: &str) -> bool {\n    Command::new(bin)\n        .arg(\"--version\")\n        .stdin(Stdio::null())\n        .stdout(Stdio::null())\n        .stderr(Stdio::null())\n        .output()\n        .map(|o| o.status.success())\n        .unwrap_or(false)\n}\n\nfn git_cmd() -> Command {\n    let bin = resolve_git_bin().unwrap_or_else(|| \"git\".to_string());\n    let mut cmd = Command::new(bin);\n    // Never block on interactive auth prompts inside a GUI app.\n    cmd.env(\"GIT_TERMINAL_PROMPT\", \"0\")\n        .env(\"GIT_ASKPASS\", \"echo\");\n    // Abort stalled HTTPS transfers (helps avoid \"spinner forever\" on bad networks).\n    cmd.env(\"GIT_HTTP_LOW_SPEED_LIMIT\", \"1024\")\n        .env(\"GIT_HTTP_LOW_SPEED_TIME\", \"120\");\n    cmd\n}\n\nfn run_cmd_with_timeout(\n    mut cmd: Command,\n    timeout: Duration,\n    context: String,\n    cancel: Option<&CancelToken>,\n) -> Result<std::process::Output> {\n    cmd.stdin(Stdio::null())\n        .stdout(Stdio::piped())\n        .stderr(Stdio::piped());\n\n    let mut child = cmd.spawn().with_context(|| context.clone())?;\n    let start = Instant::now();\n    loop {\n        if cancel.is_some_and(|c| c.is_cancelled()) {\n            let _ = child.kill();\n            let _ = child.wait();\n            anyhow::bail!(\"CANCELLED|操作已被用户取消。\");\n        }\n\n        if start.elapsed() > timeout {\n            let _ = child.kill();\n            let stderr = child\n                .wait_with_output()\n                .map(|out| String::from_utf8_lossy(&out.stderr).to_string())\n                .unwrap_or_default();\n            anyhow::bail!(\n                \"git 操作超时（{}s）。请检查网络/代理是否可访问 GitHub；也可设置环境变量 SKILLS_HUB_GIT_TIMEOUT_SECS 增大超时。\\n{}\",\n                timeout.as_secs(),\n                stderr.trim()\n            );\n        }\n\n        match child.try_wait() {\n            Ok(Some(_)) => return child.wait_with_output().with_context(|| context.clone()),\n            Ok(None) => std::thread::sleep(Duration::from_millis(200)),\n            Err(err) => return Err(err).with_context(|| context.clone()),\n        }\n    }\n}\n\nfn clone_or_pull_via_git_cli(\n    repo_url: &str,\n    dest: &Path,\n    branch: Option<&str>,\n    cancel: Option<&CancelToken>,\n) -> Result<String> {\n    // Ensure parent exists so `git clone` can create dest.\n    if let Some(parent) = dest.parent() {\n        std::fs::create_dir_all(parent)\n            .with_context(|| format!(\"failed to create parent dir {:?}\", parent))?;\n    }\n\n    if dest.exists() {\n        // Remove stale lock files left by a previously crashed git process.\n        let git_dir = dest.join(\".git\");\n        for lock_name in &[\"index.lock\", \"shallow.lock\", \"HEAD.lock\"] {\n            let lock_path = git_dir.join(lock_name);\n            if lock_path.exists() {\n                log::warn!(\"[git_fetcher] removing stale lock file: {:?}\", lock_path);\n                let _ = std::fs::remove_file(&lock_path);\n            }\n        }\n\n        // Fetch updates.\n        let out = run_cmd_with_timeout(\n            {\n                let mut cmd = git_cmd();\n                cmd.arg(\"-C\").arg(dest).args([\"fetch\", \"--prune\", \"origin\"]);\n                cmd\n            },\n            git_fetch_timeout(),\n            format!(\"git fetch in {:?}\", dest),\n            cancel,\n        )?;\n        if !out.status.success() {\n            anyhow::bail!(\"git fetch failed: {}\", String::from_utf8_lossy(&out.stderr));\n        }\n\n        // Move local HEAD to fetched commit.\n        if let Some(branch) = branch {\n            let out = run_cmd_with_timeout(\n                {\n                    let mut cmd = git_cmd();\n                    cmd.arg(\"-C\").arg(dest).args([\n                        \"checkout\",\n                        \"-B\",\n                        branch,\n                        &format!(\"origin/{}\", branch),\n                    ]);\n                    cmd\n                },\n                git_fetch_timeout(),\n                format!(\"git checkout -B {} in {:?}\", branch, dest),\n                cancel,\n            )?;\n            if !out.status.success() {\n                anyhow::bail!(\n                    \"git checkout branch failed: {}\",\n                    String::from_utf8_lossy(&out.stderr)\n                );\n            }\n        } else {\n            let out = run_cmd_with_timeout(\n                {\n                    let mut cmd = git_cmd();\n                    cmd.arg(\"-C\")\n                        .arg(dest)\n                        .args([\"reset\", \"--hard\", \"FETCH_HEAD\"]);\n                    cmd\n                },\n                git_fetch_timeout(),\n                format!(\"git reset --hard in {:?}\", dest),\n                cancel,\n            )?;\n            if !out.status.success() {\n                anyhow::bail!(\n                    \"git reset --hard failed: {}\",\n                    String::from_utf8_lossy(&out.stderr)\n                );\n            }\n        }\n    } else {\n        // Clone.\n        let mut cmd = git_cmd();\n        cmd.arg(\"clone\")\n            .args([\"--depth\", \"1\", \"--filter=blob:none\", \"--no-tags\"]);\n        if let Some(branch) = branch {\n            cmd.arg(\"--branch\").arg(branch).arg(\"--single-branch\");\n        }\n        cmd.arg(repo_url).arg(dest);\n        let out = run_cmd_with_timeout(\n            cmd,\n            git_timeout(),\n            format!(\"git clone {} into {:?}\", repo_url, dest),\n            cancel,\n        )?;\n        if !out.status.success() {\n            anyhow::bail!(\"git clone failed: {}\", String::from_utf8_lossy(&out.stderr));\n        }\n    }\n\n    // Checkout desired branch if specified (best-effort; shallow clones may already be on it).\n    if let Some(branch) = branch {\n        let out = run_cmd_with_timeout(\n            {\n                let mut cmd = git_cmd();\n                cmd.arg(\"-C\").arg(dest).args([\"checkout\", branch]);\n                cmd\n            },\n            git_fetch_timeout(),\n            format!(\"git checkout {} in {:?}\", branch, dest),\n            cancel,\n        )?;\n        if !out.status.success() {\n            // Don't hard-fail; still return HEAD for caller.\n            // But include useful context for debugging.\n            let stderr = String::from_utf8_lossy(&out.stderr);\n            if !stderr.trim().is_empty() {\n                eprintln!(\"[git_fetcher] checkout warning: {}\", stderr);\n            }\n        }\n    }\n\n    // Read HEAD revision.\n    let out = run_cmd_with_timeout(\n        {\n            let mut cmd = git_cmd();\n            cmd.arg(\"-C\").arg(dest).args([\"rev-parse\", \"HEAD\"]);\n            cmd\n        },\n        git_fetch_timeout(),\n        format!(\"git rev-parse HEAD in {:?}\", dest),\n        cancel,\n    )?;\n    if !out.status.success() {\n        anyhow::bail!(\n            \"git rev-parse failed: {}\",\n            String::from_utf8_lossy(&out.stderr)\n        );\n    }\n    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())\n}\n\nfn fetch_origin(repo: &Repository) -> Result<()> {\n    let mut remote = repo.find_remote(\"origin\")?;\n    let mut opts = FetchOptions::new();\n    remote.fetch(\n        &[\"refs/heads/*:refs/remotes/origin/*\"],\n        Some(&mut opts),\n        None,\n    )?;\n    Ok(())\n}\n\n#[cfg(test)]\n#[path = \"tests/git_fetcher.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/core/github_download.rs",
    "content": "//! Download a GitHub directory via the Contents API, bypassing git clone entirely.\n//! This is much faster than cloning large repos when only a subdirectory is needed.\n\nuse std::path::Path;\n\nuse anyhow::{Context, Result};\nuse reqwest::blocking::Client;\nuse serde::Deserialize;\n\nuse super::cancel_token::CancelToken;\n\n#[derive(Debug, Deserialize)]\nstruct GithubContent {\n    name: String,\n    #[serde(rename = \"type\")]\n    content_type: String,\n    download_url: Option<String>,\n    path: String,\n}\n\n/// Download a directory from a GitHub repo using the Contents API.\n///\n/// `owner`/`repo`: repository coordinates\n/// `branch`: branch or ref (e.g. \"main\")\n/// `path`: directory path within the repo (e.g. \"skills/user/foo\")\n/// `dest`: local directory to write files into (will be created)\n/// `cancel`: optional cancellation token\npub fn download_github_directory(\n    owner: &str,\n    repo: &str,\n    branch: &str,\n    path: &str,\n    dest: &Path,\n    cancel: Option<&CancelToken>,\n    token: Option<&str>,\n) -> Result<()> {\n    let client = Client::builder()\n        .timeout(std::time::Duration::from_secs(30))\n        .build()\n        .context(\"build HTTP client\")?;\n\n    std::fs::create_dir_all(dest).with_context(|| format!(\"create directory {:?}\", dest))?;\n\n    download_dir_recursive(&client, owner, repo, branch, path, dest, cancel, token)\n}\n\n#[allow(clippy::too_many_arguments)]\nfn download_dir_recursive(\n    client: &Client,\n    owner: &str,\n    repo: &str,\n    branch: &str,\n    path: &str,\n    dest: &Path,\n    cancel: Option<&CancelToken>,\n    token: Option<&str>,\n) -> Result<()> {\n    if cancel.is_some_and(|c| c.is_cancelled()) {\n        anyhow::bail!(\"CANCELLED|操作已被用户取消。\");\n    }\n\n    let url = format!(\n        \"https://api.github.com/repos/{}/{}/contents/{}?ref={}\",\n        owner, repo, path, branch\n    );\n\n    let mut req = client\n        .get(&url)\n        .header(\"User-Agent\", \"skills-hub\")\n        .header(\"Accept\", \"application/vnd.github.v3+json\");\n    if let Some(t) = token {\n        req = req.header(\"Authorization\", format!(\"Bearer {}\", t));\n    }\n    let resp = req\n        .send()\n        .with_context(|| format!(\"request GitHub contents: {}\", url))?;\n    let resp = check_github_response(resp, &url)?;\n\n    let items: Vec<GithubContent> = resp\n        .json()\n        .with_context(|| format!(\"parse GitHub contents response: {}\", url))?;\n\n    for item in items {\n        if cancel.is_some_and(|c| c.is_cancelled()) {\n            anyhow::bail!(\"CANCELLED|操作已被用户取消。\");\n        }\n\n        let local_path = dest.join(&item.name);\n\n        match item.content_type.as_str() {\n            \"file\" => {\n                if let Some(download_url) = &item.download_url {\n                    if let Some(parent) = local_path.parent() {\n                        std::fs::create_dir_all(parent)\n                            .with_context(|| format!(\"create parent dir {:?}\", parent))?;\n                    }\n                    let mut file_req = client.get(download_url).header(\"User-Agent\", \"skills-hub\");\n                    if let Some(t) = token {\n                        file_req = file_req.header(\"Authorization\", format!(\"Bearer {}\", t));\n                    }\n                    let file_resp = file_req\n                        .send()\n                        .with_context(|| format!(\"download file: {}\", item.path))?;\n                    let file_resp = check_github_response(file_resp, &item.path)?;\n                    let bytes = file_resp\n                        .bytes()\n                        .with_context(|| format!(\"read file bytes: {}\", item.path))?;\n\n                    std::fs::write(&local_path, &bytes)\n                        .with_context(|| format!(\"write file {:?}\", local_path))?;\n                }\n            }\n            \"dir\" => {\n                download_dir_recursive(\n                    client,\n                    owner,\n                    repo,\n                    branch,\n                    &item.path,\n                    &local_path,\n                    cancel,\n                    token,\n                )?;\n            }\n            _ => {\n                // Skip symlinks, submodules, etc.\n            }\n        }\n    }\n\n    Ok(())\n}\n\n/// Check a GitHub API response for rate-limit errors and surface a helpful message.\nfn check_github_response(\n    resp: reqwest::blocking::Response,\n    context: &str,\n) -> Result<reqwest::blocking::Response> {\n    let status = resp.status();\n    if status.is_success() {\n        return Ok(resp);\n    }\n    if status.as_u16() == 403 {\n        let reset_hint = resp\n            .headers()\n            .get(\"x-ratelimit-reset\")\n            .and_then(|v| v.to_str().ok())\n            .and_then(|v| v.parse::<i64>().ok())\n            .map(|ts| {\n                let now = std::time::SystemTime::now()\n                    .duration_since(std::time::UNIX_EPOCH)\n                    .unwrap_or_default()\n                    .as_secs() as i64;\n                let wait_mins = ((ts - now).max(0) + 59) / 60; // round up\n                format!(\"RATE_LIMITED|{}\", wait_mins)\n            })\n            .unwrap_or_else(|| \"403 Forbidden\".to_string());\n        anyhow::bail!(\"{}\", reset_hint);\n    }\n    // For other errors, use the standard error_for_status logic.\n    Err(anyhow::anyhow!(\n        \"GitHub API error {} for: {}\",\n        status,\n        context\n    ))\n}\n\n/// Check if a GitHub URL with subpath can use the fast API download path.\n/// Returns Some((owner, repo, branch, subpath)) if applicable.\npub fn parse_github_api_params(\n    clone_url: &str,\n    branch: Option<&str>,\n    subpath: Option<&str>,\n) -> Option<(String, String, String, String)> {\n    // Only for GitHub URLs with a subpath\n    let subpath = subpath?;\n    if subpath.is_empty() || subpath == \".\" {\n        return None;\n    }\n\n    // Extract owner/repo from clone_url like https://github.com/owner/repo.git\n    let url = clone_url.trim_end_matches('/').trim_end_matches(\".git\");\n    let prefix = \"https://github.com/\";\n    if !url.starts_with(prefix) {\n        return None;\n    }\n    let rest = &url[prefix.len()..];\n    let parts: Vec<&str> = rest.split('/').collect();\n    if parts.len() < 2 {\n        return None;\n    }\n\n    Some((\n        parts[0].to_string(),\n        parts[1].to_string(),\n        branch.unwrap_or(\"main\").to_string(),\n        subpath.to_string(),\n    ))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn parse_github_api_params_extracts_correctly() {\n        let result = parse_github_api_params(\n            \"https://github.com/openclaw/skills.git\",\n            Some(\"main\"),\n            Some(\"skills/user/foo\"),\n        );\n        assert_eq!(\n            result,\n            Some((\n                \"openclaw\".to_string(),\n                \"skills\".to_string(),\n                \"main\".to_string(),\n                \"skills/user/foo\".to_string(),\n            ))\n        );\n    }\n\n    #[test]\n    fn parse_github_api_params_returns_none_without_subpath() {\n        let result =\n            parse_github_api_params(\"https://github.com/openclaw/skills.git\", Some(\"main\"), None);\n        assert_eq!(result, None);\n    }\n\n    #[test]\n    fn parse_github_api_params_returns_none_for_root_subpath() {\n        let result = parse_github_api_params(\n            \"https://github.com/openclaw/skills.git\",\n            Some(\"main\"),\n            Some(\".\"),\n        );\n        assert_eq!(result, None);\n    }\n\n    #[test]\n    fn parse_github_api_params_returns_none_for_non_github() {\n        let result = parse_github_api_params(\n            \"https://gitlab.com/user/repo.git\",\n            Some(\"main\"),\n            Some(\"path\"),\n        );\n        assert_eq!(result, None);\n    }\n\n    #[test]\n    fn check_github_response_passes_success() {\n        let mut server = mockito::Server::new();\n        let _m = server\n            .mock(\"GET\", \"/ok\")\n            .with_status(200)\n            .with_body(\"ok\")\n            .create();\n        let client = Client::new();\n        let resp = client.get(format!(\"{}/ok\", server.url())).send().unwrap();\n        assert!(check_github_response(resp, \"test\").is_ok());\n    }\n\n    #[test]\n    fn check_github_response_extracts_rate_limit_reset() {\n        let mut server = mockito::Server::new();\n        let reset_ts = std::time::SystemTime::now()\n            .duration_since(std::time::UNIX_EPOCH)\n            .unwrap()\n            .as_secs()\n            + 600; // 10 minutes from now\n        let _m = server\n            .mock(\"GET\", \"/limited\")\n            .with_status(403)\n            .with_header(\"x-ratelimit-reset\", &reset_ts.to_string())\n            .with_body(\"rate limited\")\n            .create();\n        let client = Client::new();\n        let resp = client\n            .get(format!(\"{}/limited\", server.url()))\n            .send()\n            .unwrap();\n        let err = check_github_response(resp, \"test\").unwrap_err();\n        let msg = format!(\"{:#}\", err);\n        assert!(msg.contains(\"RATE_LIMITED|\"), \"got: {}\", msg);\n        // Should contain a number of minutes (around 10)\n        let mins: i64 = msg\n            .strip_prefix(\"RATE_LIMITED|\")\n            .unwrap()\n            .trim()\n            .parse()\n            .unwrap();\n        assert!((9..=11).contains(&mins), \"expected ~10 mins, got {}\", mins);\n    }\n\n    #[test]\n    fn check_github_response_handles_403_without_reset_header() {\n        let mut server = mockito::Server::new();\n        let _m = server\n            .mock(\"GET\", \"/forbidden\")\n            .with_status(403)\n            .with_body(\"forbidden\")\n            .create();\n        let client = Client::new();\n        let resp = client\n            .get(format!(\"{}/forbidden\", server.url()))\n            .send()\n            .unwrap();\n        let err = check_github_response(resp, \"test\").unwrap_err();\n        let msg = format!(\"{:#}\", err);\n        assert!(msg.contains(\"403\"), \"got: {}\", msg);\n    }\n\n    #[test]\n    fn check_github_response_handles_other_errors() {\n        let mut server = mockito::Server::new();\n        let _m = server\n            .mock(\"GET\", \"/notfound\")\n            .with_status(404)\n            .with_body(\"not found\")\n            .create();\n        let client = Client::new();\n        let resp = client\n            .get(format!(\"{}/notfound\", server.url()))\n            .send()\n            .unwrap();\n        let err = check_github_response(resp, \"test\").unwrap_err();\n        let msg = format!(\"{:#}\", err);\n        assert!(msg.contains(\"404\"), \"got: {}\", msg);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/core/github_search.rs",
    "content": "use anyhow::{Context, Result};\nuse reqwest::blocking::Client;\nuse serde::Deserialize;\n\n#[derive(Debug, Deserialize)]\nstruct SearchResponse {\n    items: Vec<RepoItem>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct RepoItem {\n    full_name: String,\n    html_url: String,\n    description: Option<String>,\n    stargazers_count: u64,\n    updated_at: String,\n    clone_url: String,\n}\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct RepoSummary {\n    pub full_name: String,\n    pub html_url: String,\n    pub description: Option<String>,\n    pub stars: u64,\n    pub updated_at: String,\n    pub clone_url: String,\n}\n\npub fn search_github_repos(\n    query: &str,\n    limit: usize,\n    token: Option<&str>,\n) -> Result<Vec<RepoSummary>> {\n    search_github_repos_inner(\"https://api.github.com\", query, limit, token)\n}\n\nfn search_github_repos_inner(\n    base_url: &str,\n    query: &str,\n    limit: usize,\n    token: Option<&str>,\n) -> Result<Vec<RepoSummary>> {\n    let client = Client::new();\n    let base_url = base_url.trim_end_matches('/');\n    let url = format!(\n        \"{}/search/repositories?q={}&per_page={}\",\n        base_url,\n        urlencoding::encode(query),\n        limit.clamp(1, 50)\n    );\n\n    let mut req = client.get(url).header(\"User-Agent\", \"skills-hub\");\n    if let Some(t) = token {\n        req = req.header(\"Authorization\", format!(\"Bearer {}\", t));\n    }\n    let response = req\n        .send()\n        .context(\"GitHub search request failed\")?\n        .error_for_status()\n        .context(\"GitHub search returned error\")?;\n\n    let result: SearchResponse = response.json().context(\"parse GitHub response\")?;\n\n    Ok(result\n        .items\n        .into_iter()\n        .map(|item| RepoSummary {\n            full_name: item.full_name,\n            html_url: item.html_url,\n            description: item.description,\n            stars: item.stargazers_count,\n            updated_at: item.updated_at,\n            clone_url: item.clone_url,\n        })\n        .collect())\n}\n\n#[cfg(test)]\n#[path = \"tests/github_search.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/core/installer.rs",
    "content": "use std::path::{Path, PathBuf};\nuse std::sync::{Mutex, OnceLock};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse tauri::Manager;\nuse uuid::Uuid;\n\nuse super::cache_cleanup::get_git_cache_ttl_secs;\nuse super::cancel_token::CancelToken;\nuse super::central_repo::{ensure_central_repo, resolve_central_repo_path};\nuse super::content_hash::hash_dir;\nuse super::git_fetcher::{clone_or_pull, clone_or_pull_sparse};\nuse super::github_download::{download_github_directory, parse_github_api_params};\nuse super::skill_store::{SkillRecord, SkillStore};\nuse super::sync_engine::copy_dir_recursive;\nuse super::sync_engine::sync_dir_copy_with_overwrite;\nuse super::tool_adapters::adapter_by_key;\nuse super::tool_adapters::is_tool_installed;\n\npub struct InstallResult {\n    pub skill_id: String,\n    pub name: String,\n    pub central_path: PathBuf,\n    pub content_hash: Option<String>,\n}\n\npub fn install_local_skill<R: tauri::Runtime>(\n    app: &tauri::AppHandle<R>,\n    store: &SkillStore,\n    source_path: &Path,\n    name: Option<String>,\n) -> Result<InstallResult> {\n    if !source_path.exists() {\n        anyhow::bail!(\"source path not found: {:?}\", source_path);\n    }\n\n    let name = name.unwrap_or_else(|| {\n        source_path\n            .file_name()\n            .map(|v| v.to_string_lossy().to_string())\n            .unwrap_or_else(|| \"unnamed-skill\".to_string())\n    });\n\n    let central_dir = resolve_central_repo_path(app, store)?;\n    ensure_central_repo(&central_dir)?;\n    let central_path = central_dir.join(&name);\n\n    if central_path.exists() {\n        anyhow::bail!(\"skill already exists in central repo: {:?}\", central_path);\n    }\n\n    copy_dir_recursive(source_path, &central_path)\n        .with_context(|| format!(\"copy {:?} -> {:?}\", source_path, central_path))?;\n\n    let now = now_ms();\n    let content_hash = compute_content_hash(&central_path);\n    let description = parse_skill_md(&central_path.join(\"SKILL.md\")).and_then(|(_, desc)| desc);\n\n    let record = SkillRecord {\n        id: Uuid::new_v4().to_string(),\n        name,\n        description,\n        source_type: \"local\".to_string(),\n        source_ref: Some(source_path.to_string_lossy().to_string()),\n        source_subpath: None,\n        source_revision: None,\n        central_path: central_path.to_string_lossy().to_string(),\n        content_hash: content_hash.clone(),\n        created_at: now,\n        updated_at: now,\n        last_sync_at: None,\n        last_seen_at: now,\n        status: \"ok\".to_string(),\n    };\n\n    store.upsert_skill(&record)?;\n\n    Ok(InstallResult {\n        skill_id: record.id,\n        name: record.name,\n        central_path,\n        content_hash,\n    })\n}\n\npub fn install_git_skill<R: tauri::Runtime>(\n    app: &tauri::AppHandle<R>,\n    store: &SkillStore,\n    repo_url: &str,\n    name: Option<String>,\n    cancel: Option<&CancelToken>,\n) -> Result<InstallResult> {\n    let parsed = parse_github_url(repo_url);\n    let user_provided_name = name.is_some();\n    let mut name = name.unwrap_or_else(|| {\n        if let Some(subpath) = &parsed.subpath {\n            if subpath == \".\" {\n                derive_name_from_repo_url(&parsed.clone_url)\n            } else {\n                subpath\n                    .rsplit('/')\n                    .next()\n                    .map(|s| s.to_string())\n                    .unwrap_or_else(|| derive_name_from_repo_url(&parsed.clone_url))\n            }\n        } else {\n            derive_name_from_repo_url(&parsed.clone_url)\n        }\n    });\n\n    let central_dir = resolve_central_repo_path(app, store)?;\n    ensure_central_repo(&central_dir)?;\n    let mut central_path = central_dir.join(&name);\n\n    if central_path.exists() {\n        anyhow::bail!(\"skill already exists in central repo: {:?}\", central_path);\n    }\n\n    // Fast path: for subpath installs, prefer sparse git checkout.\n    // The old GitHub Contents API path is much slower on large repos because it performs\n    // one directory/file request at a time and can time out before we even attempt git.\n    let github_token = store.get_setting(\"github_token\")?.unwrap_or_default();\n    let github_token_opt = if github_token.is_empty() {\n        None\n    } else {\n        Some(github_token.as_str())\n    };\n    let revision;\n    if let Some((owner, repo, branch, subpath)) = parse_github_api_params(\n        &parsed.clone_url,\n        parsed.branch.as_deref(),\n        parsed.subpath.as_deref(),\n    ) {\n        log::info!(\n            \"[installer] using sparse git checkout for subpath install: {}/{} path={}\",\n            owner,\n            repo,\n            subpath\n        );\n        match clone_to_cache_subpath(\n            app,\n            store,\n            &parsed.clone_url,\n            Some(branch.as_str()),\n            &subpath,\n            cancel,\n        ) {\n            Ok((repo_dir, rev)) => {\n                let sub_src = repo_dir.join(&subpath);\n                if !sub_src.exists() {\n                    anyhow::bail!(\"subpath not found in repo: {:?}\", sub_src);\n                }\n                ensure_installable_skill_dir(&sub_src)?;\n                copy_dir_recursive(&sub_src, &central_path)\n                    .with_context(|| format!(\"copy {:?} -> {:?}\", sub_src, central_path))?;\n                revision = rev;\n            }\n            Err(err) => {\n                // Clean up partial content before fallback.\n                let _ = std::fs::remove_dir_all(&central_path);\n                let err_msg = format!(\"{:#}\", err);\n                if err_msg.contains(\"CANCELLED|\") {\n                    return Err(err);\n                }\n                log::warn!(\n                    \"[installer] sparse git checkout failed, falling back to GitHub API download: {:#}\",\n                    err\n                );\n                match download_github_directory(\n                    &owner,\n                    &repo,\n                    &branch,\n                    &subpath,\n                    &central_path,\n                    cancel,\n                    github_token_opt,\n                ) {\n                    Ok(()) => {\n                        revision = format!(\"api-download-{}\", branch);\n                    }\n                    Err(err) => {\n                        let _ = std::fs::remove_dir_all(&central_path);\n                        let err_msg = format!(\"{:#}\", err);\n                        if err_msg.contains(\"CANCELLED|\") {\n                            return Err(err);\n                        }\n                        if err_msg.contains(\"404\") || err_msg.contains(\"Not Found\") {\n                            anyhow::bail!(\n                                \"该 Skill 在 GitHub 上未找到（可能已被删除或路径已变更）。\\n请检查链接是否正确：{}/tree/{}/{}\",\n                                parsed.clone_url.trim_end_matches(\".git\"),\n                                branch,\n                                subpath\n                            );\n                        }\n                        if let Some(rest) = err_msg.strip_prefix(\"RATE_LIMITED|\") {\n                            let mins: i64 = rest.trim().parse().unwrap_or(0);\n                            if mins > 0 {\n                                anyhow::bail!(\n                                    \"GitHub API 频率限制已触发，约 {} 分钟后重置。可在设置中配置 GitHub Token 以提升限额。\",\n                                    mins\n                                );\n                            }\n                            anyhow::bail!(\n                                \"GitHub API 频率限制已触发。可在设置中配置 GitHub Token 以提升限额。\"\n                            );\n                        }\n                        if err_msg.contains(\"403\") || err_msg.contains(\"Forbidden\") {\n                            anyhow::bail!(\n                                \"GitHub API 访问被拒绝（可能触发了频率限制）。请稍后再试。\"\n                            );\n                        }\n                        return Err(err);\n                    }\n                }\n            }\n        }\n    } else {\n        // Standard git clone path (no subpath or non-GitHub URL)\n        let (repo_dir, rev) = clone_to_cache(\n            app,\n            store,\n            &parsed.clone_url,\n            parsed.branch.as_deref(),\n            cancel,\n        )?;\n\n        let copy_src = if let Some(subpath) = &parsed.subpath {\n            let sub_src = repo_dir.join(subpath);\n            if !sub_src.exists() {\n                anyhow::bail!(\"subpath not found in repo: {:?}\", sub_src);\n            }\n            ensure_installable_skill_dir(&sub_src)?;\n            sub_src\n        } else {\n            // Repo root URL: detect multi-skill repos and ask user to pick one.\n            let skill_count = count_skills_in_repo(&repo_dir);\n            if skill_count >= 2 {\n                anyhow::bail!(\n                    \"MULTI_SKILLS|该仓库包含多个 Skills，请复制具体 Skill 文件夹链接（例如 GitHub 的 /tree/<branch>/<skill-folder>），再导入。\"\n                );\n            }\n            ensure_installable_skill_dir(&repo_dir)?;\n            repo_dir.clone()\n        };\n\n        copy_dir_recursive(&copy_src, &central_path)\n            .with_context(|| format!(\"copy {:?} -> {:?}\", copy_src, central_path))?;\n        revision = rev;\n    }\n    // After download, prefer the name from SKILL.md over the derived name (fixes #28:\n    // when subpath is \"skills\", the derived name collides with tool directory names).\n    let (mut description, md_name) = match parse_skill_md(&central_path.join(\"SKILL.md\")) {\n        Some((n, d)) => (d, Some(n)),\n        None => (None, None),\n    };\n    if !user_provided_name {\n        if let Some(ref better_name) = md_name {\n            if *better_name != name {\n                let new_central = central_dir.join(better_name);\n                if !new_central.exists() {\n                    std::fs::rename(&central_path, &new_central).with_context(|| {\n                        format!(\"rename {:?} -> {:?}\", central_path, new_central)\n                    })?;\n                    name = better_name.clone();\n                    central_path = new_central;\n                }\n                // Re-read description after rename (path changed)\n                description = parse_skill_md(&central_path.join(\"SKILL.md\")).and_then(|(_, d)| d);\n            }\n        }\n    }\n\n    let now = now_ms();\n    let content_hash = compute_content_hash(&central_path);\n\n    let record = SkillRecord {\n        id: Uuid::new_v4().to_string(),\n        name,\n        description,\n        source_type: \"git\".to_string(),\n        source_ref: Some(repo_url.to_string()),\n        source_subpath: parsed.subpath.clone(),\n        source_revision: Some(revision),\n        central_path: central_path.to_string_lossy().to_string(),\n        content_hash: content_hash.clone(),\n        created_at: now,\n        updated_at: now,\n        last_sync_at: None,\n        last_seen_at: now,\n        status: \"ok\".to_string(),\n    };\n\n    store.upsert_skill(&record)?;\n\n    Ok(InstallResult {\n        skill_id: record.id,\n        name: record.name,\n        central_path,\n        content_hash,\n    })\n}\n\n#[derive(Clone, Debug)]\nstruct ParsedGitSource {\n    clone_url: String,\n    branch: Option<String>,\n    subpath: Option<String>,\n}\n\nfn parse_github_url(input: &str) -> ParsedGitSource {\n    // Supports:\n    // - https://github.com/owner/repo\n    // - https://github.com/owner/repo.git\n    // - https://github.com/owner/repo/tree/<branch>/<path>\n    // - https://github.com/owner/repo/blob/<branch>/<path>\n    let trimmed = input.trim().trim_end_matches('/');\n\n    // Convenience: allow GitHub shorthand inputs like `owner/repo` (and `owner/repo/tree/<branch>/...`).\n    // This keeps the UI friendly while still allowing local paths or other git remotes.\n    let normalized = if trimmed.starts_with(\"https://github.com/\") {\n        trimmed.to_string()\n    } else if trimmed.starts_with(\"http://github.com/\") {\n        trimmed.replacen(\"http://github.com/\", \"https://github.com/\", 1)\n    } else if trimmed.starts_with(\"github.com/\") {\n        format!(\"https://{}\", trimmed)\n    } else if looks_like_github_shorthand(trimmed) {\n        format!(\"https://github.com/{}\", trimmed)\n    } else {\n        trimmed.to_string()\n    };\n\n    let trimmed = normalized.trim_end_matches('/');\n    let gh_prefix = \"https://github.com/\";\n    if !trimmed.starts_with(gh_prefix) {\n        return ParsedGitSource {\n            clone_url: trimmed.to_string(),\n            branch: None,\n            subpath: None,\n        };\n    }\n\n    let rest = &trimmed[gh_prefix.len()..];\n    let parts: Vec<&str> = rest.split('/').collect();\n    if parts.len() < 2 {\n        return ParsedGitSource {\n            clone_url: trimmed.to_string(),\n            branch: None,\n            subpath: None,\n        };\n    }\n\n    let owner = parts[0];\n    let mut repo = parts[1].to_string();\n    if let Some(stripped) = repo.strip_suffix(\".git\") {\n        repo = stripped.to_string();\n    }\n    let clone_url = format!(\"https://github.com/{}/{}.git\", owner, repo);\n\n    if parts.len() >= 4 && (parts[2] == \"tree\" || parts[2] == \"blob\") {\n        let branch = Some(parts[3].to_string());\n        let subpath = if parts.len() > 4 {\n            Some(normalize_github_skill_subpath(&parts[4..].join(\"/\")))\n        } else {\n            None\n        };\n        return ParsedGitSource {\n            clone_url,\n            branch,\n            subpath,\n        };\n    }\n\n    ParsedGitSource {\n        clone_url,\n        branch: None,\n        subpath: None,\n    }\n}\n\nfn normalize_github_skill_subpath(subpath: &str) -> String {\n    let trimmed = subpath.trim_matches('/');\n    if trimmed.eq_ignore_ascii_case(\"SKILL.md\") {\n        return \".\".to_string();\n    }\n    trimmed\n        .strip_suffix(\"/SKILL.md\")\n        .or_else(|| trimmed.strip_suffix(\"/skill.md\"))\n        .unwrap_or(trimmed)\n        .to_string()\n}\n\nfn looks_like_github_shorthand(input: &str) -> bool {\n    if input.is_empty() {\n        return false;\n    }\n    if input.starts_with('/') || input.starts_with('~') || input.starts_with('.') {\n        return false;\n    }\n    // Avoid scp-like ssh URLs (git@github.com:owner/repo) and any explicit schemes.\n    if input.contains(\"://\") || input.contains('@') || input.contains(':') {\n        return false;\n    }\n\n    let parts: Vec<&str> = input.split('/').collect();\n    if parts.len() < 2 {\n        return false;\n    }\n\n    let owner = parts[0];\n    let repo = parts[1];\n    if owner.is_empty()\n        || repo.is_empty()\n        || owner == \".\"\n        || owner == \"..\"\n        || repo == \".\"\n        || repo == \"..\"\n    {\n        return false;\n    }\n\n    let is_safe_segment = |s: &str| {\n        s.chars()\n            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')\n    };\n    if !is_safe_segment(owner) || !is_safe_segment(repo.trim_end_matches(\".git\")) {\n        return false;\n    }\n\n    // If there are more path parts, only accept the GitHub UI patterns we can parse.\n    if parts.len() > 2 {\n        matches!(parts[2], \"tree\" | \"blob\")\n    } else {\n        true\n    }\n}\n\nfn now_ms() -> i64 {\n    let now = std::time::SystemTime::now()\n        .duration_since(std::time::SystemTime::UNIX_EPOCH)\n        .unwrap_or_default();\n    now.as_millis() as i64\n}\n\nfn derive_name_from_repo_url(repo_url: &str) -> String {\n    let mut name = repo_url\n        .split('/')\n        .next_back()\n        .unwrap_or(\"skill\")\n        .to_string();\n    if let Some(stripped) = name.strip_suffix(\".git\") {\n        name = stripped.to_string();\n    }\n    if name.is_empty() {\n        \"skill\".to_string()\n    } else {\n        name\n    }\n}\n\n/// Scan base directories used for skill discovery.\nconst SKILL_SCAN_BASES: [&str; 5] = [\n    \"skills\",\n    \"skills/.curated\",\n    \"skills/.experimental\",\n    \"skills/.system\",\n    \".claude/skills\",\n];\n\n/// Check if a directory is a valid skill (has SKILL.md or is under .claude/skills/).\nfn is_skill_dir(p: &Path) -> bool {\n    p.is_dir() && (p.join(\"SKILL.md\").exists() || is_claude_skill_dir(p))\n}\n\nfn ensure_installable_skill_dir(p: &Path) -> Result<()> {\n    if is_skill_dir(p) {\n        Ok(())\n    } else {\n        anyhow::bail!(\n            \"SKILL_INVALID|missing_skill_md|该路径不是有效 Skill 目录：未找到 SKILL.md。请粘贴具体 Skill 文件夹链接。\"\n        );\n    }\n}\n\n/// Check if a directory is a Claude plugin skill (under .claude/skills/ without SKILL.md).\nfn is_claude_skill_dir(p: &Path) -> bool {\n    // A directory under .claude/skills/ is treated as a valid skill even without SKILL.md\n    if let Some(parent) = p.parent() {\n        let parent_str = parent.to_string_lossy();\n        if parent_str.ends_with(\".claude/skills\") || parent_str.ends_with(\".claude\\\\skills\") {\n            return p.is_dir();\n        }\n    }\n    false\n}\n\n/// Try to read the description for a skill from .claude-plugin/plugin.json.\nfn read_plugin_description(repo_dir: &Path) -> Option<String> {\n    let plugin_json = repo_dir.join(\".claude-plugin/plugin.json\");\n    if !plugin_json.exists() {\n        return None;\n    }\n    let content = std::fs::read_to_string(&plugin_json).ok()?;\n    let json: serde_json::Value = serde_json::from_str(&content).ok()?;\n    json.get(\"description\")\n        .and_then(|v| v.as_str())\n        .map(|s| s.to_string())\n}\n\n/// Extract name and description for a skill directory.\n/// Prefers SKILL.md frontmatter; falls back to folder name + plugin.json description.\nfn extract_skill_info(skill_dir: &Path, repo_dir: &Path) -> (String, Option<String>) {\n    let skill_md = skill_dir.join(\"SKILL.md\");\n    if skill_md.exists() {\n        if let Some((name, desc)) = parse_skill_md(&skill_md) {\n            return (name, desc);\n        }\n    }\n    // Fallback: folder name + optional plugin.json description\n    let name = skill_dir\n        .file_name()\n        .unwrap_or_default()\n        .to_string_lossy()\n        .to_string();\n    let desc = read_plugin_description(repo_dir);\n    (name, desc)\n}\n\nfn is_hidden_dir_name(name: &str) -> bool {\n    name.starts_with('.')\n}\n\nfn is_known_root_scan_dir(name: &str) -> bool {\n    SKILL_SCAN_BASES\n        .iter()\n        .filter_map(|base| base.split('/').next())\n        .any(|base| base == name)\n}\n\nfn is_skill_container_dir_name(name: &str) -> bool {\n    let normalized = name.to_ascii_lowercase();\n    normalized.contains(\"skill\")\n}\n\nfn push_skill_dirs_from_base(out: &mut Vec<PathBuf>, base_dir: &Path) {\n    if let Ok(rd) = std::fs::read_dir(base_dir) {\n        for entry in rd.flatten() {\n            let p = entry.path();\n            if is_skill_dir(&p) {\n                out.push(p);\n            }\n        }\n    }\n}\n\nfn collect_skill_dirs(repo_dir: &Path) -> Vec<PathBuf> {\n    let mut out = Vec::new();\n\n    // 1) Fast path: known skill locations such as skills/* and .claude/skills/*.\n    for base in SKILL_SCAN_BASES {\n        push_skill_dirs_from_base(&mut out, &repo_dir.join(base));\n    }\n\n    // 2) Root-level skills: repo/my-skill/SKILL.md.\n    // 3) Root-level skill containers: repo/*skill*/my-skill/SKILL.md.\n    if let Ok(rd) = std::fs::read_dir(repo_dir) {\n        for entry in rd.flatten() {\n            let p = entry.path();\n            if !p.is_dir() {\n                continue;\n            }\n            let dir_name = entry.file_name();\n            let dir_name = dir_name.to_string_lossy();\n            if is_hidden_dir_name(&dir_name) || is_known_root_scan_dir(&dir_name) {\n                continue;\n            }\n            if p.join(\"SKILL.md\").exists() {\n                out.push(p);\n            } else if is_skill_container_dir_name(&dir_name) {\n                push_skill_dirs_from_base(&mut out, &p);\n            }\n        }\n    }\n\n    out.sort();\n    out.dedup();\n    out\n}\n\n/// Scan all skill candidates in a repo directory, returning (name, relative_subpath) pairs.\n/// Used for auto-matching when updating legacy skills with missing source_subpath.\nfn scan_skill_candidates_in_dir(repo_dir: &Path) -> Vec<(String, String)> {\n    let mut out = Vec::new();\n    for p in collect_skill_dirs(repo_dir) {\n        let (name, _) = extract_skill_info(&p, repo_dir);\n        let rel = p\n            .strip_prefix(repo_dir)\n            .unwrap_or(&p)\n            .to_string_lossy()\n            .to_string();\n        out.push((name, rel));\n    }\n    out\n}\n\n/// Count skill directories in a repo: checks both `skills/*` and root-level subdirectories.\nfn count_skills_in_repo(repo_dir: &Path) -> usize {\n    collect_skill_dirs(repo_dir).len()\n}\n\nfn compute_content_hash(path: &Path) -> Option<String> {\n    if should_compute_content_hash() {\n        hash_dir(path).ok()\n    } else {\n        None\n    }\n}\n\nfn should_compute_content_hash() -> bool {\n    if cfg!(debug_assertions) {\n        return true;\n    }\n    std::env::var(\"SKILLS_HUB_COMPUTE_HASH\")\n        .ok()\n        .map(|v| v == \"1\" || v.eq_ignore_ascii_case(\"true\"))\n        .unwrap_or(false)\n}\n\npub struct UpdateResult {\n    pub skill_id: String,\n    pub name: String,\n    #[allow(dead_code)]\n    pub central_path: PathBuf,\n    pub content_hash: Option<String>,\n    pub source_revision: Option<String>,\n    pub updated_targets: Vec<String>,\n}\n\npub fn update_managed_skill_from_source<R: tauri::Runtime>(\n    app: &tauri::AppHandle<R>,\n    store: &SkillStore,\n    skill_id: &str,\n) -> Result<UpdateResult> {\n    let record = store\n        .get_skill_by_id(skill_id)?\n        .ok_or_else(|| anyhow::anyhow!(\"skill not found\"))?;\n\n    let central_path = PathBuf::from(record.central_path.clone());\n    if !central_path.exists() {\n        anyhow::bail!(\"central path not found: {:?}\", central_path);\n    }\n    let central_parent = central_path\n        .parent()\n        .ok_or_else(|| anyhow::anyhow!(\"invalid central path\"))?\n        .to_path_buf();\n\n    let now = now_ms();\n\n    // Build new content in a sibling temp dir for safe swap.\n    let staging_dir = central_parent.join(format!(\".skills-hub-update-{}\", Uuid::new_v4()));\n    if staging_dir.exists() {\n        let _ = std::fs::remove_dir_all(&staging_dir);\n    }\n\n    let mut new_revision: Option<String> = None;\n\n    if record.source_type == \"git\" {\n        let repo_url = record\n            .source_ref\n            .as_deref()\n            .ok_or_else(|| anyhow::anyhow!(\"missing source_ref for git skill\"))?;\n        let parsed = parse_github_url(repo_url);\n\n        let (repo_dir, rev) = if let Some(subpath) = record.source_subpath.as_deref() {\n            clone_to_cache_subpath(\n                app,\n                store,\n                &parsed.clone_url,\n                parsed.branch.as_deref(),\n                subpath,\n                None,\n            )?\n        } else {\n            clone_to_cache(\n                app,\n                store,\n                &parsed.clone_url,\n                parsed.branch.as_deref(),\n                None,\n            )?\n        };\n        new_revision = Some(rev);\n\n        // Prefer stored source_subpath (from install time) over URL-parsed subpath.\n        // For legacy records where source_subpath is NULL and URL has no subpath,\n        // try to auto-match by skill name in the repo (backfill).\n        let mut resolved_subpath = record\n            .source_subpath\n            .as_deref()\n            .or(parsed.subpath.as_deref())\n            .map(|s| s.to_string());\n        if resolved_subpath.is_none() && count_skills_in_repo(&repo_dir) >= 2 {\n            // Multi-skill repo with no stored subpath: match by name\n            let candidates = scan_skill_candidates_in_dir(&repo_dir);\n            let skill_name = record.name.to_lowercase();\n            if let Some(matched) = candidates.iter().find(|c| c.0 == record.name).or_else(|| {\n                // Fuzzy: bidirectional containment (e.g. \"react-best-practices\" vs \"vercel-react-best-practices\")\n                let fuzzy: Vec<_> = candidates\n                    .iter()\n                    .filter(|c| {\n                        let cn = c.0.to_lowercase();\n                        cn.contains(&skill_name) || skill_name.contains(&cn)\n                    })\n                    .collect();\n                if fuzzy.len() == 1 {\n                    Some(fuzzy[0])\n                } else {\n                    None\n                }\n            }) {\n                resolved_subpath = Some(matched.1.clone());\n                // Backfill source_subpath for future updates\n                let mut patched = record.clone();\n                patched.source_subpath = Some(matched.1.clone());\n                let _ = store.upsert_skill(&patched);\n            }\n        }\n        let copy_src = if let Some(subpath) = &resolved_subpath {\n            repo_dir.join(subpath)\n        } else {\n            repo_dir.clone()\n        };\n        if !copy_src.exists() {\n            anyhow::bail!(\"path not found in repo: {:?}\", copy_src);\n        }\n\n        copy_dir_recursive(&copy_src, &staging_dir)\n            .with_context(|| format!(\"copy {:?} -> {:?}\", copy_src, staging_dir))?;\n    } else if record.source_type == \"local\" {\n        let source = record\n            .source_ref\n            .as_deref()\n            .ok_or_else(|| anyhow::anyhow!(\"missing source_ref for local skill\"))?;\n        let source_path = PathBuf::from(source);\n        if !source_path.exists() {\n            anyhow::bail!(\"source path not found: {:?}\", source_path);\n        }\n        copy_dir_recursive(&source_path, &staging_dir)\n            .with_context(|| format!(\"copy {:?} -> {:?}\", source_path, staging_dir))?;\n    } else {\n        anyhow::bail!(\"unsupported source_type for update: {}\", record.source_type);\n    }\n\n    // Swap: remove old dir and rename staging into place (best effort).\n    std::fs::remove_dir_all(&central_path)\n        .with_context(|| format!(\"failed to remove old central dir {:?}\", central_path))?;\n    if let Err(err) = std::fs::rename(&staging_dir, &central_path) {\n        // Fallback for cross-device rename: copy then delete staging.\n        copy_dir_recursive(&staging_dir, &central_path)\n            .with_context(|| format!(\"fallback copy {:?} -> {:?}\", staging_dir, central_path))?;\n        let _ = std::fs::remove_dir_all(&staging_dir);\n        // Still surface original rename error in logs for troubleshooting.\n        eprintln!(\"[update] rename warning: {}\", err);\n    }\n\n    let content_hash = compute_content_hash(&central_path);\n    let description = parse_skill_md(&central_path.join(\"SKILL.md\"))\n        .and_then(|(_, desc)| desc)\n        .or(record.description.clone());\n\n    // Update DB skill row.\n    let updated = SkillRecord {\n        id: record.id.clone(),\n        name: record.name.clone(),\n        description,\n        source_type: record.source_type.clone(),\n        source_ref: record.source_ref.clone(),\n        source_subpath: record.source_subpath.clone(),\n        source_revision: new_revision.clone().or(record.source_revision.clone()),\n        central_path: record.central_path.clone(),\n        content_hash: content_hash.clone(),\n        created_at: record.created_at,\n        updated_at: now,\n        last_sync_at: record.last_sync_at,\n        last_seen_at: now,\n        status: \"ok\".to_string(),\n    };\n    store.upsert_skill(&updated)?;\n\n    // If any targets are \"copy\", re-sync them so changes propagate. Symlinks update automatically.\n    // Cursor 目前不支持软链/junction，因此无论历史 mode 如何，都需要强制 copy 回灌。\n    let targets = store.list_skill_targets(skill_id)?;\n    let mut updated_targets: Vec<String> = Vec::new();\n    for t in targets {\n        // Project scoped targets live under a project root and do not require global tool install detection.\n        if t.scope == \"global\" {\n            if let Some(adapter) = adapter_by_key(&t.tool) {\n                if !is_tool_installed(&adapter).unwrap_or(false) {\n                    continue;\n                }\n            }\n        }\n        let force_copy = t.mode == \"copy\" || t.tool == \"cursor\";\n        if force_copy {\n            let target_path = PathBuf::from(&t.target_path);\n            let sync_res = sync_dir_copy_with_overwrite(&central_path, &target_path, true)?;\n            let record = super::skill_store::SkillTargetRecord {\n                id: t.id.clone(),\n                skill_id: t.skill_id.clone(),\n                tool: t.tool.clone(),\n                scope: t.scope.clone(),\n                project_path: t.project_path.clone(),\n                target_path: sync_res.target_path.to_string_lossy().to_string(),\n                mode: \"copy\".to_string(),\n                status: \"ok\".to_string(),\n                last_error: None,\n                synced_at: Some(now),\n            };\n            store.upsert_skill_target(&record)?;\n            updated_targets.push(t.tool.clone());\n        }\n    }\n\n    Ok(UpdateResult {\n        skill_id: record.id,\n        name: record.name,\n        central_path,\n        content_hash,\n        source_revision: new_revision,\n        updated_targets,\n    })\n}\n\n#[derive(Clone, Debug, serde::Serialize)]\npub struct GitSkillCandidate {\n    pub name: String,\n    pub description: Option<String>,\n    pub subpath: String,\n}\n\n#[derive(Clone, Debug, serde::Serialize)]\npub struct LocalSkillCandidate {\n    pub name: String,\n    pub description: Option<String>,\n    pub subpath: String,\n    pub valid: bool,\n    pub reason: Option<String>,\n}\n\npub fn list_git_skills<R: tauri::Runtime>(\n    app: &tauri::AppHandle<R>,\n    store: &SkillStore,\n    repo_url: &str,\n) -> Result<Vec<GitSkillCandidate>> {\n    let parsed = parse_github_url(repo_url);\n    let (repo_dir, _rev) = clone_to_cache(\n        app,\n        store,\n        &parsed.clone_url,\n        parsed.branch.as_deref(),\n        None,\n    )?;\n\n    let mut out: Vec<GitSkillCandidate> = Vec::new();\n\n    // If user provided a folder URL, treat it as a single candidate.\n    if let Some(subpath) = &parsed.subpath {\n        let dir = repo_dir.join(subpath);\n        if dir.is_dir() && (dir.join(\"SKILL.md\").exists() || is_claude_skill_dir(&dir)) {\n            let (name, desc) = extract_skill_info(&dir, &repo_dir);\n            out.push(GitSkillCandidate {\n                name,\n                description: desc,\n                subpath: subpath.to_string(),\n            });\n        } else if dir.is_dir() {\n            for p in collect_skill_dirs(&dir) {\n                let (name, desc) = extract_skill_info(&p, &repo_dir);\n                let rel = p\n                    .strip_prefix(&repo_dir)\n                    .unwrap_or(&p)\n                    .to_string_lossy()\n                    .to_string();\n                out.push(GitSkillCandidate {\n                    name,\n                    description: desc,\n                    subpath: rel,\n                });\n            }\n        }\n        out.sort_by(|a, b| a.name.cmp(&b.name));\n        out.dedup_by(|a, b| a.subpath == b.subpath);\n        return Ok(out);\n    }\n\n    // Root-level skill\n    let root_skill = repo_dir.join(\"SKILL.md\");\n    if root_skill.exists() {\n        let (name, desc) = parse_skill_md(&root_skill).unwrap_or((\"root-skill\".to_string(), None));\n        out.push(GitSkillCandidate {\n            name,\n            description: desc,\n            subpath: \".\".to_string(),\n        });\n    }\n\n    for p in collect_skill_dirs(&repo_dir) {\n        let (name, desc) = extract_skill_info(&p, &repo_dir);\n        let rel = p\n            .strip_prefix(&repo_dir)\n            .unwrap_or(&p)\n            .to_string_lossy()\n            .to_string();\n        out.push(GitSkillCandidate {\n            name,\n            description: desc,\n            subpath: rel,\n        });\n    }\n\n    out.sort_by(|a, b| a.name.cmp(&b.name));\n    out.dedup_by(|a, b| a.subpath == b.subpath);\n\n    Ok(out)\n}\n\npub fn list_local_skills(base_path: &Path) -> Result<Vec<LocalSkillCandidate>> {\n    if !base_path.exists() {\n        anyhow::bail!(\"source path not found: {:?}\", base_path);\n    }\n\n    let mut out: Vec<LocalSkillCandidate> = Vec::new();\n\n    let root_skill = base_path.join(\"SKILL.md\");\n    if root_skill.exists() {\n        match parse_skill_md_with_reason(&root_skill) {\n            Ok((name, desc)) => {\n                out.push(LocalSkillCandidate {\n                    name,\n                    description: desc,\n                    subpath: \".\".to_string(),\n                    valid: true,\n                    reason: None,\n                });\n            }\n            Err(reason) => {\n                let fallback_name = base_path\n                    .file_name()\n                    .unwrap_or_default()\n                    .to_string_lossy()\n                    .to_string();\n                out.push(LocalSkillCandidate {\n                    name: if fallback_name.is_empty() {\n                        \"root-skill\".to_string()\n                    } else {\n                        fallback_name\n                    },\n                    description: None,\n                    subpath: \".\".to_string(),\n                    valid: false,\n                    reason: Some(reason.to_string()),\n                });\n            }\n        }\n    }\n\n    for base in SKILL_SCAN_BASES {\n        let base_dir = base_path.join(base);\n        if !base_dir.exists() {\n            continue;\n        }\n        if let Ok(rd) = std::fs::read_dir(&base_dir) {\n            for entry in rd.flatten() {\n                let p = entry.path();\n                if !p.is_dir() {\n                    continue;\n                }\n                let skill_md = p.join(\"SKILL.md\");\n                let rel = p\n                    .strip_prefix(base_path)\n                    .unwrap_or(&p)\n                    .to_string_lossy()\n                    .to_string();\n                if skill_md.exists() {\n                    match parse_skill_md_with_reason(&skill_md) {\n                        Ok((name, desc)) => {\n                            out.push(LocalSkillCandidate {\n                                name,\n                                description: desc,\n                                subpath: rel,\n                                valid: true,\n                                reason: None,\n                            });\n                        }\n                        Err(reason) => {\n                            out.push(LocalSkillCandidate {\n                                name: p\n                                    .file_name()\n                                    .unwrap_or_default()\n                                    .to_string_lossy()\n                                    .to_string(),\n                                description: None,\n                                subpath: rel,\n                                valid: false,\n                                reason: Some(reason.to_string()),\n                            });\n                        }\n                    }\n                } else if is_claude_skill_dir(&p) {\n                    // .claude/skills/* directories are valid without SKILL.md\n                    let name = p\n                        .file_name()\n                        .unwrap_or_default()\n                        .to_string_lossy()\n                        .to_string();\n                    let desc = read_plugin_description(base_path);\n                    out.push(LocalSkillCandidate {\n                        name,\n                        description: desc,\n                        subpath: rel,\n                        valid: true,\n                        reason: None,\n                    });\n                } else {\n                    out.push(LocalSkillCandidate {\n                        name: p\n                            .file_name()\n                            .unwrap_or_default()\n                            .to_string_lossy()\n                            .to_string(),\n                        description: None,\n                        subpath: rel,\n                        valid: false,\n                        reason: Some(\"missing_skill_md\".to_string()),\n                    });\n                }\n            }\n        }\n    }\n\n    out.sort_by(|a, b| a.name.cmp(&b.name));\n    out.dedup_by(|a, b| a.subpath == b.subpath);\n\n    Ok(out)\n}\n\npub fn install_git_skill_from_selection<R: tauri::Runtime>(\n    app: &tauri::AppHandle<R>,\n    store: &SkillStore,\n    repo_url: &str,\n    subpath: &str,\n    name: Option<String>,\n) -> Result<InstallResult> {\n    let parsed = parse_github_url(repo_url);\n    let user_provided_name = name.is_some();\n    let mut display_name = name.unwrap_or_else(|| {\n        if subpath == \".\" {\n            derive_name_from_repo_url(&parsed.clone_url)\n        } else {\n            subpath\n                .rsplit('/')\n                .next()\n                .map(|s| s.to_string())\n                .unwrap_or_else(|| derive_name_from_repo_url(&parsed.clone_url))\n        }\n    });\n\n    let central_dir = resolve_central_repo_path(app, store)?;\n    ensure_central_repo(&central_dir)?;\n    let mut central_path = central_dir.join(&display_name);\n    if central_path.exists() {\n        anyhow::bail!(\"skill already exists in central repo: {:?}\", central_path);\n    }\n\n    let (repo_dir, revision) = clone_to_cache(\n        app,\n        store,\n        &parsed.clone_url,\n        parsed.branch.as_deref(),\n        None,\n    )?;\n\n    let copy_src = if subpath == \".\" {\n        repo_dir.clone()\n    } else {\n        repo_dir.join(subpath)\n    };\n    if !copy_src.exists() {\n        anyhow::bail!(\"path not found in repo: {:?}\", copy_src);\n    }\n    ensure_installable_skill_dir(&copy_src)?;\n\n    copy_dir_recursive(&copy_src, &central_path)\n        .with_context(|| format!(\"copy {:?} -> {:?}\", copy_src, central_path))?;\n\n    // Prefer name from SKILL.md over derived name (fixes #28).\n    let (mut description, md_name) = match parse_skill_md(&central_path.join(\"SKILL.md\")) {\n        Some((n, d)) => (d, Some(n)),\n        None => (None, None),\n    };\n    if !user_provided_name {\n        if let Some(ref better_name) = md_name {\n            if *better_name != display_name {\n                let new_central = central_dir.join(better_name);\n                if !new_central.exists() {\n                    std::fs::rename(&central_path, &new_central).with_context(|| {\n                        format!(\"rename {:?} -> {:?}\", central_path, new_central)\n                    })?;\n                    display_name = better_name.clone();\n                    central_path = new_central;\n                    description =\n                        parse_skill_md(&central_path.join(\"SKILL.md\")).and_then(|(_, d)| d);\n                }\n            }\n        }\n    }\n\n    let now = now_ms();\n    let content_hash = compute_content_hash(&central_path);\n    let source_subpath = if subpath == \".\" {\n        None\n    } else {\n        Some(subpath.to_string())\n    };\n    let record = SkillRecord {\n        id: Uuid::new_v4().to_string(),\n        name: display_name,\n        description,\n        source_type: \"git\".to_string(),\n        source_ref: Some(repo_url.to_string()),\n        source_subpath,\n        source_revision: Some(revision),\n        central_path: central_path.to_string_lossy().to_string(),\n        content_hash: content_hash.clone(),\n        created_at: now,\n        updated_at: now,\n        last_sync_at: None,\n        last_seen_at: now,\n        status: \"ok\".to_string(),\n    };\n    store.upsert_skill(&record)?;\n\n    Ok(InstallResult {\n        skill_id: record.id,\n        name: record.name,\n        central_path,\n        content_hash,\n    })\n}\n\npub fn install_local_skill_from_selection<R: tauri::Runtime>(\n    app: &tauri::AppHandle<R>,\n    store: &SkillStore,\n    base_path: &Path,\n    subpath: &str,\n    name: Option<String>,\n) -> Result<InstallResult> {\n    if !base_path.exists() {\n        anyhow::bail!(\"source path not found: {:?}\", base_path);\n    }\n\n    let selected_dir = if subpath == \".\" {\n        base_path.to_path_buf()\n    } else {\n        base_path.join(subpath)\n    };\n    if !selected_dir.exists() {\n        anyhow::bail!(\"source path not found: {:?}\", selected_dir);\n    }\n\n    let skill_md = selected_dir.join(\"SKILL.md\");\n    if !skill_md.exists() {\n        anyhow::bail!(\"SKILL_INVALID|missing_skill_md\");\n    }\n    let (parsed_name, _desc) = parse_skill_md_with_reason(&skill_md)\n        .map_err(|reason| anyhow::anyhow!(\"SKILL_INVALID|{}\", reason))?;\n\n    let display_name = name.unwrap_or(parsed_name);\n\n    install_local_skill(app, store, &selected_dir, Some(display_name))\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\nstruct RepoCacheMeta {\n    last_fetched_ms: i64,\n    head: Option<String>,\n}\n\nstatic GIT_CACHE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();\n\nfn clone_to_cache<R: tauri::Runtime>(\n    app: &tauri::AppHandle<R>,\n    store: &SkillStore,\n    clone_url: &str,\n    branch: Option<&str>,\n    cancel: Option<&CancelToken>,\n) -> Result<(PathBuf, String)> {\n    let started = std::time::Instant::now();\n    let cache_dir = app\n        .path()\n        .app_cache_dir()\n        .context(\"failed to resolve app cache dir\")?;\n    let cache_root = cache_dir.join(\"skills-hub-git-cache\");\n    std::fs::create_dir_all(&cache_root)\n        .with_context(|| format!(\"failed to create cache dir {:?}\", cache_root))?;\n\n    let repo_dir = cache_root.join(repo_cache_key(clone_url, branch, None));\n    let meta_path = repo_dir.join(\".skills-hub-cache.json\");\n\n    let lock = GIT_CACHE_LOCK.get_or_init(|| Mutex::new(()));\n    let _guard = lock.lock().unwrap_or_else(|err| err.into_inner());\n\n    if repo_dir.join(\".git\").exists() {\n        if let Ok(meta) = std::fs::read_to_string(&meta_path) {\n            if let Ok(meta) = serde_json::from_str::<RepoCacheMeta>(&meta) {\n                if let Some(head) = meta.head {\n                    let ttl_ms = get_git_cache_ttl_secs(store).saturating_mul(1000);\n                    if ttl_ms > 0 && now_ms().saturating_sub(meta.last_fetched_ms) < ttl_ms {\n                        log::info!(\n                            \"[installer] git cache hit (fresh) {}s url={} branch={:?} repo_dir={:?}\",\n                            started.elapsed().as_secs_f32(),\n                            clone_url,\n                            branch,\n                            repo_dir\n                        );\n                        return Ok((repo_dir, head));\n                    }\n                }\n            }\n        }\n    }\n\n    log::info!(\n        \"[installer] git cache miss/stale; fetching {} url={} branch={:?} repo_dir={:?}\",\n        started.elapsed().as_secs_f32(),\n        clone_url,\n        branch,\n        repo_dir\n    );\n\n    let rev = match clone_or_pull(clone_url, &repo_dir, branch, cancel) {\n        Ok(rev) => rev,\n        Err(err) => {\n            // If cache got corrupted, retry once from a clean state.\n            if repo_dir.exists() {\n                let _ = std::fs::remove_dir_all(&repo_dir);\n            }\n            clone_or_pull(clone_url, &repo_dir, branch, cancel)\n                .with_context(|| format!(\"{:#}\", err))?\n        }\n    };\n\n    let _ = std::fs::write(\n        &meta_path,\n        serde_json::to_string(&RepoCacheMeta {\n            last_fetched_ms: now_ms(),\n            head: Some(rev.clone()),\n        })\n        .unwrap_or_else(|_| \"{}\".to_string()),\n    );\n\n    log::info!(\n        \"[installer] git cache ready {}s url={} branch={:?} head={}\",\n        started.elapsed().as_secs_f32(),\n        clone_url,\n        branch,\n        rev\n    );\n    Ok((repo_dir, rev))\n}\n\nfn clone_to_cache_subpath<R: tauri::Runtime>(\n    app: &tauri::AppHandle<R>,\n    store: &SkillStore,\n    clone_url: &str,\n    branch: Option<&str>,\n    subpath: &str,\n    cancel: Option<&CancelToken>,\n) -> Result<(PathBuf, String)> {\n    let started = std::time::Instant::now();\n    let cache_dir = app\n        .path()\n        .app_cache_dir()\n        .context(\"failed to resolve app cache dir\")?;\n    let cache_root = cache_dir.join(\"skills-hub-git-cache\");\n    std::fs::create_dir_all(&cache_root)\n        .with_context(|| format!(\"failed to create cache dir {:?}\", cache_root))?;\n\n    let repo_dir = cache_root.join(repo_cache_key(clone_url, branch, Some(subpath)));\n    let meta_path = repo_dir.join(\".skills-hub-cache.json\");\n\n    let lock = GIT_CACHE_LOCK.get_or_init(|| Mutex::new(()));\n    let _guard = lock.lock().unwrap_or_else(|err| err.into_inner());\n\n    if repo_dir.join(\".git\").exists() {\n        if let Ok(meta) = std::fs::read_to_string(&meta_path) {\n            if let Ok(meta) = serde_json::from_str::<RepoCacheMeta>(&meta) {\n                if let Some(head) = meta.head {\n                    let ttl_ms = get_git_cache_ttl_secs(store).saturating_mul(1000);\n                    if ttl_ms > 0 && now_ms().saturating_sub(meta.last_fetched_ms) < ttl_ms {\n                        log::info!(\n                            \"[installer] sparse git cache hit (fresh) {}s url={} branch={:?} subpath={} repo_dir={:?}\",\n                            started.elapsed().as_secs_f32(),\n                            clone_url,\n                            branch,\n                            subpath,\n                            repo_dir\n                        );\n                        return Ok((repo_dir, head));\n                    }\n                }\n            }\n        }\n    }\n\n    log::info!(\n        \"[installer] sparse git cache miss/stale; fetching {} url={} branch={:?} subpath={} repo_dir={:?}\",\n        started.elapsed().as_secs_f32(),\n        clone_url,\n        branch,\n        subpath,\n        repo_dir\n    );\n\n    let rev = match clone_or_pull_sparse(clone_url, &repo_dir, branch, subpath, cancel) {\n        Ok(rev) => rev,\n        Err(err) => {\n            if repo_dir.exists() {\n                let _ = std::fs::remove_dir_all(&repo_dir);\n            }\n            clone_or_pull_sparse(clone_url, &repo_dir, branch, subpath, cancel)\n                .with_context(|| format!(\"{:#}\", err))?\n        }\n    };\n\n    let _ = std::fs::write(\n        &meta_path,\n        serde_json::to_string(&RepoCacheMeta {\n            last_fetched_ms: now_ms(),\n            head: Some(rev.clone()),\n        })\n        .unwrap_or_else(|_| \"{}\".to_string()),\n    );\n\n    log::info!(\n        \"[installer] sparse git cache ready {}s url={} branch={:?} subpath={} head={}\",\n        started.elapsed().as_secs_f32(),\n        clone_url,\n        branch,\n        subpath,\n        rev\n    );\n    Ok((repo_dir, rev))\n}\n\nfn repo_cache_key(clone_url: &str, branch: Option<&str>, subpath: Option<&str>) -> String {\n    use sha2::Digest;\n    let mut hasher = sha2::Sha256::new();\n    hasher.update(clone_url.as_bytes());\n    hasher.update(b\"\\n\");\n    if let Some(b) = branch {\n        hasher.update(b.as_bytes());\n    }\n    hasher.update(b\"\\n\");\n    if let Some(s) = subpath {\n        hasher.update(s.as_bytes());\n    }\n    hex::encode(hasher.finalize())\n}\n\n/// Backfill description for skills from SKILL.md.\npub fn backfill_skill_descriptions(store: &SkillStore) {\n    let skills = match store.list_skills() {\n        Ok(s) => s,\n        Err(_) => return,\n    };\n    for skill in skills {\n        let central = std::path::Path::new(&skill.central_path);\n        let skill_md = central.join(\"SKILL.md\");\n        if let Some((_, Some(desc))) = parse_skill_md(&skill_md) {\n            if skill.description.as_deref() != Some(desc.as_str()) {\n                let _ = store.update_skill_description(&skill.id, Some(&desc));\n            }\n        }\n    }\n}\n\nfn parse_skill_md(path: &Path) -> Option<(String, Option<String>)> {\n    parse_skill_md_with_reason(path).ok()\n}\n\nfn parse_skill_md_with_reason(path: &Path) -> Result<(String, Option<String>), &'static str> {\n    let text = std::fs::read_to_string(path).map_err(|_| \"read_failed\")?;\n    let lines: Vec<&str> = text.lines().collect();\n    if lines.first().map(|v| v.trim()) != Some(\"---\") {\n        return Err(\"invalid_frontmatter\");\n    }\n    let mut name: Option<String> = None;\n    let mut desc: Option<String> = None;\n    let mut found_end = false;\n    let mut i = 1usize;\n    while i < lines.len() {\n        let raw = lines[i];\n        let l = raw.trim();\n        if l == \"---\" {\n            found_end = true;\n            break;\n        }\n        if let Some(v) = l.strip_prefix(\"name:\") {\n            name = Some(clean_frontmatter_value(v));\n        } else if let Some(v) = l.strip_prefix(\"description:\") {\n            let v = v.trim();\n            if let Some(block_style) = frontmatter_block_style(v) {\n                let folded = block_style == '>';\n                let mut block_lines: Vec<String> = Vec::new();\n                while i + 1 < lines.len() {\n                    let next = lines[i + 1];\n                    if next.trim() == \"---\" {\n                        break;\n                    }\n                    if !next.trim().is_empty() && !next.starts_with(char::is_whitespace) {\n                        break;\n                    }\n                    block_lines.push(next.strip_prefix(\"  \").unwrap_or(next).to_string());\n                    i += 1;\n                }\n                let value = if folded {\n                    block_lines\n                        .iter()\n                        .map(|line| line.trim())\n                        .filter(|line| !line.is_empty())\n                        .collect::<Vec<_>>()\n                        .join(\" \")\n                } else {\n                    block_lines.join(\"\\n\").trim().to_string()\n                };\n                desc = Some(value);\n            } else {\n                desc = Some(clean_frontmatter_value(v));\n            }\n        }\n        i += 1;\n    }\n    if !found_end {\n        return Err(\"invalid_frontmatter\");\n    }\n    let name = name.ok_or(\"missing_name\")?;\n    Ok((name, desc))\n}\n\nfn clean_frontmatter_value(value: &str) -> String {\n    let value = value.trim();\n    if value.len() >= 2\n        && ((value.starts_with('\"') && value.ends_with('\"'))\n            || (value.starts_with('\\'') && value.ends_with('\\'')))\n    {\n        value[1..value.len() - 1].to_string()\n    } else {\n        value.to_string()\n    }\n}\n\nfn frontmatter_block_style(value: &str) -> Option<char> {\n    let mut chars = value.chars();\n    let style = chars.next()?;\n    if style != '|' && style != '>' {\n        return None;\n    }\n    match chars.next() {\n        None => Some(style),\n        Some('-' | '+') if chars.next().is_none() => Some(style),\n        _ => None,\n    }\n}\n\n#[cfg(test)]\n#[path = \"tests/installer.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/core/mod.rs",
    "content": "pub mod cache_cleanup;\npub mod cancel_token;\npub mod central_repo;\npub mod content_hash;\npub mod featured_skills;\npub mod git_fetcher;\npub mod github_download;\npub mod github_search;\npub mod installer;\npub mod onboarding;\npub mod skill_files;\npub mod skill_store;\npub mod skills_search;\npub mod sync_engine;\npub mod temp_cleanup;\npub mod tool_adapters;\n"
  },
  {
    "path": "src-tauri/src/core/onboarding.rs",
    "content": "use std::collections::HashMap;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::Result;\nuse serde::Serialize;\n\nuse super::central_repo::resolve_central_repo_path;\nuse super::content_hash::hash_dir;\nuse super::skill_store::SkillStore;\nuse super::tool_adapters::{default_tool_adapters, scan_tool_dir, DetectedSkill};\n\n#[derive(Clone, Debug, Serialize)]\npub struct OnboardingVariant {\n    pub tool: String,\n    pub name: String,\n    pub path: PathBuf,\n    pub fingerprint: Option<String>,\n    pub is_link: bool,\n    pub link_target: Option<PathBuf>,\n}\n\n#[derive(Clone, Debug, Serialize)]\npub struct OnboardingGroup {\n    pub name: String,\n    pub variants: Vec<OnboardingVariant>,\n    pub has_conflict: bool,\n}\n\n#[derive(Clone, Debug, Serialize)]\npub struct OnboardingPlan {\n    pub total_tools_scanned: usize,\n    pub total_skills_found: usize,\n    pub groups: Vec<OnboardingGroup>,\n}\n\npub fn build_onboarding_plan<R: tauri::Runtime>(\n    app: &tauri::AppHandle<R>,\n    store: &SkillStore,\n) -> Result<OnboardingPlan> {\n    let home =\n        dirs::home_dir().ok_or_else(|| anyhow::anyhow!(\"failed to resolve home directory\"))?;\n    let central = resolve_central_repo_path(app, store)?;\n    let managed_targets = store\n        .list_all_skill_target_paths()\n        .unwrap_or_default()\n        .into_iter()\n        .map(|(tool, path)| managed_target_key(&tool, Path::new(&path)))\n        .collect::<std::collections::HashSet<_>>();\n    build_onboarding_plan_in_home(&home, Some(&central), Some(&managed_targets))\n}\n\nfn build_onboarding_plan_in_home(\n    home: &Path,\n    exclude_root: Option<&Path>,\n    exclude_managed_targets: Option<&std::collections::HashSet<String>>,\n) -> Result<OnboardingPlan> {\n    let adapters = default_tool_adapters();\n    let mut all_detected: Vec<DetectedSkill> = Vec::new();\n    let mut scanned = 0usize;\n\n    for adapter in &adapters {\n        if !home.join(adapter.relative_detect_dir).exists() {\n            continue;\n        }\n        scanned += 1;\n        let dir = home.join(adapter.relative_skills_dir);\n        let detected = scan_tool_dir(adapter, &dir)?;\n        all_detected.extend(filter_detected(\n            detected,\n            exclude_root,\n            exclude_managed_targets,\n        ));\n    }\n\n    let mut grouped: HashMap<String, Vec<OnboardingVariant>> = HashMap::new();\n    for skill in all_detected.iter() {\n        let fingerprint = hash_dir(&skill.path).ok();\n        let entry = grouped.entry(skill.name.clone()).or_default();\n        entry.push(OnboardingVariant {\n            tool: skill.tool.as_key().to_string(),\n            name: skill.name.clone(),\n            path: skill.path.clone(),\n            fingerprint,\n            is_link: skill.is_link,\n            link_target: skill.link_target.clone(),\n        });\n    }\n\n    let groups: Vec<OnboardingGroup> = grouped\n        .into_iter()\n        .map(|(name, variants)| {\n            let mut uniq = variants\n                .iter()\n                .filter_map(|v| v.fingerprint.as_ref())\n                .collect::<std::collections::HashSet<_>>()\n                .len();\n            if uniq == 0 {\n                uniq = 1;\n            }\n            OnboardingGroup {\n                name,\n                has_conflict: uniq > 1,\n                variants,\n            }\n        })\n        .collect();\n\n    Ok(OnboardingPlan {\n        total_tools_scanned: scanned,\n        total_skills_found: all_detected.len(),\n        groups,\n    })\n}\n\nfn filter_detected(\n    detected: Vec<DetectedSkill>,\n    exclude_root: Option<&Path>,\n    exclude_managed_targets: Option<&std::collections::HashSet<String>>,\n) -> Vec<DetectedSkill> {\n    if exclude_root.is_none() && exclude_managed_targets.is_none() {\n        return detected;\n    }\n    detected\n        .into_iter()\n        .filter(|skill| {\n            if let Some(exclude_root) = exclude_root {\n                if is_under(&skill.path, exclude_root) {\n                    return false;\n                }\n                if let Some(target) = &skill.link_target {\n                    if is_under(target, exclude_root) {\n                        return false;\n                    }\n                }\n            }\n            if let Some(exclude) = exclude_managed_targets {\n                if exclude.contains(&managed_target_key(skill.tool.as_key(), &skill.path)) {\n                    return false;\n                }\n            }\n            true\n        })\n        .collect()\n}\n\nfn is_under(path: &Path, base: &Path) -> bool {\n    path.starts_with(base)\n}\n\nfn managed_target_key(tool: &str, path: &Path) -> String {\n    let tool = tool.to_ascii_lowercase();\n    let normalized = normalize_path_for_key(path);\n    format!(\"{tool}\\n{normalized}\")\n}\n\nfn normalize_path_for_key(path: &Path) -> String {\n    let normalized: PathBuf = path.components().collect();\n    let s = normalized.to_string_lossy().to_string();\n    #[cfg(windows)]\n    {\n        s.to_lowercase()\n    }\n    #[cfg(not(windows))]\n    {\n        s\n    }\n}\n\n#[cfg(test)]\n#[path = \"tests/onboarding.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/core/skill_files.rs",
    "content": "use std::path::Path;\n\nuse anyhow::{bail, Context, Result};\nuse walkdir::{DirEntry, WalkDir};\n\nconst IGNORE_NAMES: [&str; 4] = [\".git\", \".DS_Store\", \"Thumbs.db\", \".gitignore\"];\nconst MAX_FILE_SIZE: u64 = 1_048_576; // 1 MB\n\nfn is_ignored(entry: &DirEntry) -> bool {\n    let file_name = entry.file_name().to_string_lossy();\n    IGNORE_NAMES.iter().any(|name| name == &file_name.as_ref())\n}\n\npub struct FileEntry {\n    pub path: String,\n    pub size: u64,\n}\n\npub fn list_files(central_path: &Path) -> Result<Vec<FileEntry>> {\n    let mut entries: Vec<FileEntry> = Vec::new();\n\n    for entry in WalkDir::new(central_path)\n        .follow_links(false)\n        .into_iter()\n        .filter_entry(|e| !is_ignored(e))\n    {\n        let entry = entry?;\n        if !entry.file_type().is_file() || is_ignored(&entry) {\n            continue;\n        }\n\n        let relative = entry\n            .path()\n            .strip_prefix(central_path)\n            .with_context(|| format!(\"strip prefix {:?}\", entry.path()))?;\n\n        let metadata = entry.metadata()?;\n        entries.push(FileEntry {\n            path: relative.to_string_lossy().to_string(),\n            size: metadata.len(),\n        });\n    }\n\n    // Sort: SKILL.md first, then alphabetical\n    entries.sort_by(|a, b| {\n        let a_is_skill = a.path.eq_ignore_ascii_case(\"skill.md\");\n        let b_is_skill = b.path.eq_ignore_ascii_case(\"skill.md\");\n        match (a_is_skill, b_is_skill) {\n            (true, false) => std::cmp::Ordering::Less,\n            (false, true) => std::cmp::Ordering::Greater,\n            _ => a.path.to_lowercase().cmp(&b.path.to_lowercase()),\n        }\n    });\n\n    Ok(entries)\n}\n\npub fn read_file(central_path: &Path, relative_path: &str) -> Result<String> {\n    // Path traversal protection\n    if relative_path.contains(\"..\") {\n        bail!(\"Invalid file path: path traversal not allowed\");\n    }\n\n    let full_path = central_path.join(relative_path);\n    let canonical = full_path\n        .canonicalize()\n        .with_context(|| format!(\"resolve path: {:?}\", full_path))?;\n    let canonical_base = central_path\n        .canonicalize()\n        .with_context(|| format!(\"resolve base: {:?}\", central_path))?;\n\n    if !canonical.starts_with(&canonical_base) {\n        bail!(\"Invalid file path: outside skill directory\");\n    }\n\n    let metadata =\n        std::fs::metadata(&canonical).with_context(|| format!(\"read metadata: {:?}\", canonical))?;\n\n    if metadata.len() > MAX_FILE_SIZE {\n        bail!(\n            \"File too large to display ({:.1} KB, max 1 MB)\",\n            metadata.len() as f64 / 1024.0\n        );\n    }\n\n    let bytes = std::fs::read(&canonical).with_context(|| format!(\"read file: {:?}\", canonical))?;\n\n    String::from_utf8(bytes)\n        .map_err(|_| anyhow::anyhow!(\"File is not valid UTF-8 text and cannot be displayed\"))\n}\n"
  },
  {
    "path": "src-tauri/src/core/skill_store.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse rusqlite::{params, Connection};\nuse tauri::Manager;\n\nconst DB_FILE_NAME: &str = \"skills_hub.db\";\nconst LEGACY_APP_IDENTIFIERS: &[&str] = &[\"com.tauri.dev\", \"com.tauri.dev.skillshub\"];\n\n// Schema versioning: bump when making changes and add a migration step.\nconst SCHEMA_VERSION: i32 = 5;\n\n// Minimal schema for MVP: skills, skill_targets, settings, discovered_skills(optional).\nconst SCHEMA_V1: &str = r#\"\nCREATE TABLE IF NOT EXISTS skills (\n  id TEXT PRIMARY KEY,\n  name TEXT NOT NULL,\n  source_type TEXT NOT NULL,\n  source_ref TEXT NULL,\n  source_revision TEXT NULL,\n  central_path TEXT NOT NULL UNIQUE,\n  content_hash TEXT NULL,\n  created_at INTEGER NOT NULL,\n  updated_at INTEGER NOT NULL,\n  last_sync_at INTEGER NULL,\n  last_seen_at INTEGER NOT NULL,\n  status TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS skill_targets (\n  id TEXT PRIMARY KEY,\n  skill_id TEXT NOT NULL,\n  tool TEXT NOT NULL,\n  scope TEXT NOT NULL DEFAULT 'global',\n  project_path TEXT NULL,\n  target_path TEXT NOT NULL,\n  mode TEXT NOT NULL,\n  status TEXT NOT NULL,\n  last_error TEXT NULL,\n  synced_at INTEGER NULL,\n  FOREIGN KEY(skill_id) REFERENCES skills(id) ON DELETE CASCADE\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_skill_targets_unique_scope\nON skill_targets(skill_id, tool, scope, COALESCE(project_path, ''));\n\nCREATE TABLE IF NOT EXISTS settings (\n  key TEXT PRIMARY KEY,\n  value TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS discovered_skills (\n  id TEXT PRIMARY KEY,\n  tool TEXT NOT NULL,\n  found_path TEXT NOT NULL,\n  name_guess TEXT NULL,\n  fingerprint TEXT NULL,\n  found_at INTEGER NOT NULL,\n  imported_skill_id TEXT NULL,\n  FOREIGN KEY(imported_skill_id) REFERENCES skills(id) ON DELETE SET NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name);\nCREATE INDEX IF NOT EXISTS idx_skills_updated_at ON skills(updated_at);\n\nCREATE TABLE IF NOT EXISTS skill_tags (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  name TEXT NOT NULL UNIQUE COLLATE NOCASE,\n  created_at INTEGER NOT NULL,\n  updated_at INTEGER NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS skill_tag_links (\n  skill_id TEXT NOT NULL,\n  tag_id INTEGER NOT NULL,\n  created_at INTEGER NOT NULL,\n  PRIMARY KEY (skill_id, tag_id),\n  FOREIGN KEY(skill_id) REFERENCES skills(id) ON DELETE CASCADE,\n  FOREIGN KEY(tag_id) REFERENCES skill_tags(id) ON DELETE CASCADE\n);\n\"#;\n\n#[derive(Clone, Debug)]\npub struct SkillStore {\n    db_path: PathBuf,\n}\n\n#[derive(Clone, Debug)]\npub struct SkillRecord {\n    pub id: String,\n    pub name: String,\n    pub description: Option<String>,\n    pub source_type: String,\n    pub source_ref: Option<String>,\n    pub source_subpath: Option<String>,\n    pub source_revision: Option<String>,\n    pub central_path: String,\n    pub content_hash: Option<String>,\n    pub created_at: i64,\n    pub updated_at: i64,\n    pub last_sync_at: Option<i64>,\n    pub last_seen_at: i64,\n    pub status: String,\n}\n\n#[derive(Clone, Debug)]\npub struct SkillTargetRecord {\n    pub id: String,\n    pub skill_id: String,\n    pub tool: String,\n    pub scope: String,\n    pub project_path: Option<String>,\n    pub target_path: String,\n    pub mode: String,\n    pub status: String,\n    pub last_error: Option<String>,\n    pub synced_at: Option<i64>,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct TagRecord {\n    pub id: i64,\n    pub name: String,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct TagWithCountRecord {\n    pub id: i64,\n    pub name: String,\n    pub skill_count: i64,\n    pub updated_at: i64,\n}\n\nimpl SkillStore {\n    pub fn new(db_path: PathBuf) -> Self {\n        Self { db_path }\n    }\n\n    #[allow(dead_code)]\n    pub fn db_path(&self) -> &Path {\n        &self.db_path\n    }\n\n    pub fn ensure_schema(&self) -> Result<()> {\n        self.with_conn(|conn| {\n            conn.execute_batch(\"PRAGMA foreign_keys = ON;\")?;\n\n            let user_version: i32 = conn.query_row(\"PRAGMA user_version;\", [], |row| row.get(0))?;\n            if user_version == 0 {\n                conn.execute_batch(SCHEMA_V1)?;\n                // V2: add description column\n                conn.execute_batch(\"ALTER TABLE skills ADD COLUMN description TEXT NULL;\")?;\n                // V3: add source_subpath column\n                conn.execute_batch(\"ALTER TABLE skills ADD COLUMN source_subpath TEXT NULL;\")?;\n                migrate_skill_targets_to_v4(conn)?;\n                conn.pragma_update(None, \"user_version\", SCHEMA_VERSION)?;\n            } else if user_version < SCHEMA_VERSION {\n                // Incremental migrations\n                if user_version < 2 {\n                    conn.execute_batch(\"ALTER TABLE skills ADD COLUMN description TEXT NULL;\")?;\n                }\n                if user_version < 3 {\n                    conn.execute_batch(\"ALTER TABLE skills ADD COLUMN source_subpath TEXT NULL;\")?;\n                }\n                if user_version < 4 {\n                    migrate_skill_targets_to_v4(conn)?;\n                }\n                if user_version < 5 {\n                    migrate_tags_to_v5(conn)?;\n                }\n                conn.pragma_update(None, \"user_version\", SCHEMA_VERSION)?;\n            } else if user_version > SCHEMA_VERSION {\n                anyhow::bail!(\n                    \"database schema version {} is newer than app supports {}\",\n                    user_version,\n                    SCHEMA_VERSION\n                );\n            }\n\n            Ok(())\n        })\n    }\n\n    pub fn get_setting(&self, key: &str) -> Result<Option<String>> {\n        self.with_conn(|conn| {\n            let mut stmt = conn.prepare(\"SELECT value FROM settings WHERE key = ?1\")?;\n            let mut rows = stmt.query(params![key])?;\n            Ok(rows\n                .next()?\n                .map(|row| row.get::<_, String>(0))\n                .transpose()?)\n        })\n    }\n\n    pub fn set_setting(&self, key: &str, value: &str) -> Result<()> {\n        self.with_conn(|conn| {\n            conn.execute(\n                \"INSERT INTO settings (key, value) VALUES (?1, ?2)\n         ON CONFLICT(key) DO UPDATE SET value = excluded.value\",\n                params![key, value],\n            )?;\n            Ok(())\n        })\n    }\n\n    #[allow(dead_code)]\n    pub fn set_onboarding_completed(&self, completed: bool) -> Result<()> {\n        self.set_setting(\n            \"onboarding_completed\",\n            if completed { \"true\" } else { \"false\" },\n        )\n    }\n\n    pub fn upsert_skill(&self, record: &SkillRecord) -> Result<()> {\n        self.with_conn(|conn| {\n            conn.execute(\n                \"INSERT INTO skills (\n          id, name, description, source_type, source_ref, source_subpath, source_revision, central_path, content_hash,\n          created_at, updated_at, last_sync_at, last_seen_at, status\n        ) VALUES (\n          ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9,\n          ?10, ?11, ?12, ?13, ?14\n        )\n        ON CONFLICT(id) DO UPDATE SET\n          name = excluded.name,\n          description = excluded.description,\n          source_type = excluded.source_type,\n          source_ref = excluded.source_ref,\n          source_subpath = excluded.source_subpath,\n          source_revision = excluded.source_revision,\n          central_path = excluded.central_path,\n          content_hash = excluded.content_hash,\n          created_at = excluded.created_at,\n          updated_at = excluded.updated_at,\n          last_sync_at = excluded.last_sync_at,\n          last_seen_at = excluded.last_seen_at,\n          status = excluded.status\",\n                params![\n                    record.id,\n                    record.name,\n                    record.description,\n                    record.source_type,\n                    record.source_ref,\n                    record.source_subpath,\n                    record.source_revision,\n                    record.central_path,\n                    record.content_hash,\n                    record.created_at,\n                    record.updated_at,\n                    record.last_sync_at,\n                    record.last_seen_at,\n                    record.status\n                ],\n            )?;\n            Ok(())\n        })\n    }\n\n    pub fn upsert_skill_target(&self, record: &SkillTargetRecord) -> Result<()> {\n        self.with_conn(|conn| {\n            conn.execute(\n                \"INSERT INTO skill_targets (\n          id, skill_id, tool, scope, project_path, target_path, mode, status, last_error, synced_at\n        ) VALUES (\n          ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10\n        )\n        ON CONFLICT DO UPDATE SET\n          target_path = excluded.target_path,\n          mode = excluded.mode,\n          status = excluded.status,\n          last_error = excluded.last_error,\n          synced_at = excluded.synced_at\",\n                params![\n                    record.id,\n                    record.skill_id,\n                    record.tool,\n                    record.scope,\n                    record.project_path,\n                    record.target_path,\n                    record.mode,\n                    record.status,\n                    record.last_error,\n                    record.synced_at\n                ],\n            )?;\n            Ok(())\n        })\n    }\n\n    pub fn list_skills(&self) -> Result<Vec<SkillRecord>> {\n        self.with_conn(|conn| {\n            let mut stmt = conn.prepare(\n        \"SELECT id, name, description, source_type, source_ref, source_subpath, source_revision, central_path, content_hash,\n                created_at, updated_at, last_sync_at, last_seen_at, status\n         FROM skills\n         ORDER BY updated_at DESC\",\n      )?;\n            let rows = stmt.query_map([], |row| {\n                Ok(SkillRecord {\n                    id: row.get(0)?,\n                    name: row.get(1)?,\n                    description: row.get(2)?,\n                    source_type: row.get(3)?,\n                    source_ref: row.get(4)?,\n                    source_subpath: row.get(5)?,\n                    source_revision: row.get(6)?,\n                    central_path: row.get(7)?,\n                    content_hash: row.get(8)?,\n                    created_at: row.get(9)?,\n                    updated_at: row.get(10)?,\n                    last_sync_at: row.get(11)?,\n                    last_seen_at: row.get(12)?,\n                    status: row.get(13)?,\n                })\n            })?;\n\n            let mut items = Vec::new();\n            for row in rows {\n                items.push(row?);\n            }\n            Ok(items)\n        })\n    }\n\n    pub fn get_skill_by_id(&self, skill_id: &str) -> Result<Option<SkillRecord>> {\n        self.with_conn(|conn| {\n            let mut stmt = conn.prepare(\n        \"SELECT id, name, description, source_type, source_ref, source_subpath, source_revision, central_path, content_hash,\n                created_at, updated_at, last_sync_at, last_seen_at, status\n         FROM skills\n         WHERE id = ?1\n         LIMIT 1\",\n      )?;\n            let mut rows = stmt.query(params![skill_id])?;\n            if let Some(row) = rows.next()? {\n                Ok(Some(SkillRecord {\n                    id: row.get(0)?,\n                    name: row.get(1)?,\n                    description: row.get(2)?,\n                    source_type: row.get(3)?,\n                    source_ref: row.get(4)?,\n                    source_subpath: row.get(5)?,\n                    source_revision: row.get(6)?,\n                    central_path: row.get(7)?,\n                    content_hash: row.get(8)?,\n                    created_at: row.get(9)?,\n                    updated_at: row.get(10)?,\n                    last_sync_at: row.get(11)?,\n                    last_seen_at: row.get(12)?,\n                    status: row.get(13)?,\n                }))\n            } else {\n                Ok(None)\n            }\n        })\n    }\n\n    pub fn update_skill_description(\n        &self,\n        skill_id: &str,\n        description: Option<&str>,\n    ) -> Result<()> {\n        self.with_conn(|conn| {\n            conn.execute(\n                \"UPDATE skills SET description = ?1 WHERE id = ?2\",\n                params![description, skill_id],\n            )?;\n            Ok(())\n        })\n    }\n\n    pub fn delete_skill(&self, skill_id: &str) -> Result<()> {\n        self.with_conn(|conn| {\n            conn.execute(\"DELETE FROM skills WHERE id = ?1\", params![skill_id])?;\n            Ok(())\n        })\n    }\n\n    pub fn create_tag(&self, name: &str) -> Result<TagRecord> {\n        let normalized = normalize_tag_name(name)?;\n        self.with_conn(|conn| {\n            let now = now_ms();\n            conn.execute(\n                \"INSERT INTO skill_tags (name, created_at, updated_at) VALUES (?1, ?2, ?2)\",\n                params![normalized, now],\n            )\n            .with_context(|| format!(\"tag already exists: {}\", normalized))?;\n            let id = conn.last_insert_rowid();\n            Ok(TagRecord {\n                id,\n                name: normalized,\n            })\n        })\n    }\n\n    pub fn rename_tag(&self, tag_id: i64, name: &str) -> Result<TagRecord> {\n        let normalized = normalize_tag_name(name)?;\n        self.with_conn(|conn| {\n            let changed = conn\n                .execute(\n                    \"UPDATE skill_tags SET name = ?1, updated_at = ?2 WHERE id = ?3\",\n                    params![normalized, now_ms(), tag_id],\n                )\n                .with_context(|| format!(\"tag already exists: {}\", normalized))?;\n            if changed == 0 {\n                anyhow::bail!(\"tag not found: {}\", tag_id);\n            }\n            Ok(TagRecord {\n                id: tag_id,\n                name: normalized,\n            })\n        })\n    }\n\n    pub fn delete_tag(&self, tag_id: i64) -> Result<()> {\n        self.with_conn(|conn| {\n            conn.execute(\"DELETE FROM skill_tags WHERE id = ?1\", params![tag_id])?;\n            Ok(())\n        })\n    }\n\n    pub fn list_tags_with_counts(&self) -> Result<Vec<TagWithCountRecord>> {\n        self.with_conn(|conn| {\n            let mut stmt = conn.prepare(\n                \"SELECT t.id, t.name, COUNT(l.skill_id) AS skill_count,\n                        COALESCE(MAX(l.created_at), t.updated_at) AS last_used_at\n                 FROM skill_tags t\n                 LEFT JOIN skill_tag_links l ON l.tag_id = t.id\n                 GROUP BY t.id, t.name, t.updated_at\n                 ORDER BY LOWER(t.name) ASC\",\n            )?;\n            let rows = stmt.query_map([], |row| {\n                Ok(TagWithCountRecord {\n                    id: row.get(0)?,\n                    name: row.get(1)?,\n                    skill_count: row.get(2)?,\n                    updated_at: row.get(3)?,\n                })\n            })?;\n\n            let mut items = Vec::new();\n            for row in rows {\n                items.push(row?);\n            }\n            Ok(items)\n        })\n    }\n\n    pub fn get_skill_tags(&self, skill_id: &str) -> Result<Vec<TagRecord>> {\n        self.with_conn(|conn| {\n            let mut stmt = conn.prepare(\n                \"SELECT t.id, t.name\n                 FROM skill_tags t\n                 INNER JOIN skill_tag_links l ON l.tag_id = t.id\n                 WHERE l.skill_id = ?1\n                 ORDER BY LOWER(t.name) ASC\",\n            )?;\n            let rows = stmt.query_map(params![skill_id], |row| {\n                Ok(TagRecord {\n                    id: row.get(0)?,\n                    name: row.get(1)?,\n                })\n            })?;\n\n            let mut items = Vec::new();\n            for row in rows {\n                items.push(row?);\n            }\n            Ok(items)\n        })\n    }\n\n    pub fn set_skill_tags(&self, skill_id: &str, tag_ids: &[i64]) -> Result<()> {\n        self.with_conn(|conn| {\n            let now = now_ms();\n            conn.execute_batch(\"BEGIN;\")?;\n            let result = (|| -> Result<()> {\n                conn.execute(\n                    \"DELETE FROM skill_tag_links WHERE skill_id = ?1\",\n                    params![skill_id],\n                )?;\n                for tag_id in tag_ids {\n                    conn.execute(\n                        \"INSERT OR IGNORE INTO skill_tag_links (skill_id, tag_id, created_at)\n                         VALUES (?1, ?2, ?3)\",\n                        params![skill_id, tag_id, now],\n                    )?;\n                }\n                Ok(())\n            })();\n\n            match result {\n                Ok(()) => {\n                    conn.execute_batch(\"COMMIT;\")?;\n                    Ok(())\n                }\n                Err(err) => {\n                    let _ = conn.execute_batch(\"ROLLBACK;\");\n                    Err(err)\n                }\n            }\n        })\n    }\n\n    pub fn list_untagged_skill_ids(&self) -> Result<Vec<String>> {\n        self.with_conn(|conn| {\n            let mut stmt = conn.prepare(\n                \"SELECT s.id\n                 FROM skills s\n                 WHERE NOT EXISTS (\n                   SELECT 1 FROM skill_tag_links l WHERE l.skill_id = s.id\n                 )\n                 ORDER BY s.updated_at DESC\",\n            )?;\n            let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;\n            let mut items = Vec::new();\n            for row in rows {\n                items.push(row?);\n            }\n            Ok(items)\n        })\n    }\n\n    pub fn list_skill_targets(&self, skill_id: &str) -> Result<Vec<SkillTargetRecord>> {\n        self.with_conn(|conn| {\n            let mut stmt = conn.prepare(\n                \"SELECT id, skill_id, tool, scope, project_path, target_path, mode, status, last_error, synced_at\n         FROM skill_targets\n         WHERE skill_id = ?1\n         ORDER BY tool ASC, scope ASC, project_path ASC\",\n            )?;\n            let rows = stmt.query_map(params![skill_id], |row| {\n                Ok(SkillTargetRecord {\n                    id: row.get(0)?,\n                    skill_id: row.get(1)?,\n                    tool: row.get(2)?,\n                    scope: row.get(3)?,\n                    project_path: row.get(4)?,\n                    target_path: row.get(5)?,\n                    mode: row.get(6)?,\n                    status: row.get(7)?,\n                    last_error: row.get(8)?,\n                    synced_at: row.get(9)?,\n                })\n            })?;\n\n            let mut items = Vec::new();\n            for row in rows {\n                items.push(row?);\n            }\n            Ok(items)\n        })\n    }\n\n    pub fn list_all_skill_target_paths(&self) -> Result<Vec<(String, String)>> {\n        self.with_conn(|conn| {\n            let mut stmt = conn.prepare(\n                \"SELECT tool, target_path\n         FROM skill_targets\",\n            )?;\n            let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;\n\n            let mut items = Vec::new();\n            for row in rows {\n                items.push(row?);\n            }\n            Ok(items)\n        })\n    }\n\n    pub fn get_skill_target(\n        &self,\n        skill_id: &str,\n        tool: &str,\n        scope: &str,\n        project_path: Option<&str>,\n    ) -> Result<Option<SkillTargetRecord>> {\n        self.with_conn(|conn| {\n            let mut stmt = conn.prepare(\n                \"SELECT id, skill_id, tool, scope, project_path, target_path, mode, status, last_error, synced_at\n         FROM skill_targets\n         WHERE skill_id = ?1\n           AND tool = ?2\n           AND scope = ?3\n           AND ((?4 IS NULL AND project_path IS NULL) OR project_path = ?4)\",\n            )?;\n            let mut rows = stmt.query(params![skill_id, tool, scope, project_path])?;\n            if let Some(row) = rows.next()? {\n                Ok(Some(SkillTargetRecord {\n                    id: row.get(0)?,\n                    skill_id: row.get(1)?,\n                    tool: row.get(2)?,\n                    scope: row.get(3)?,\n                    project_path: row.get(4)?,\n                    target_path: row.get(5)?,\n                    mode: row.get(6)?,\n                    status: row.get(7)?,\n                    last_error: row.get(8)?,\n                    synced_at: row.get(9)?,\n                }))\n            } else {\n                Ok(None)\n            }\n        })\n    }\n\n    pub fn delete_skill_target(\n        &self,\n        skill_id: &str,\n        tool: &str,\n        scope: &str,\n        project_path: Option<&str>,\n    ) -> Result<()> {\n        self.with_conn(|conn| {\n            conn.execute(\n                \"DELETE FROM skill_targets\n                 WHERE skill_id = ?1\n                   AND tool = ?2\n                   AND scope = ?3\n                   AND ((?4 IS NULL AND project_path IS NULL) OR project_path = ?4)\",\n                params![skill_id, tool, scope, project_path],\n            )?;\n            Ok(())\n        })\n    }\n\n    fn with_conn<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {\n        let conn = Connection::open(&self.db_path)\n            .with_context(|| format!(\"failed to open db at {:?}\", self.db_path))?;\n        // Enforce foreign key constraints on every connection (rusqlite PRAGMA is per-connection).\n        conn.execute_batch(\"PRAGMA foreign_keys = ON;\")?;\n        f(&conn)\n    }\n}\n\nfn migrate_skill_targets_to_v4(conn: &Connection) -> Result<()> {\n    conn.execute_batch(\n        \"BEGIN;\n         DROP INDEX IF EXISTS idx_skill_targets_unique_scope;\n         CREATE TABLE skill_targets_new (\n           id TEXT PRIMARY KEY,\n           skill_id TEXT NOT NULL,\n           tool TEXT NOT NULL,\n           scope TEXT NOT NULL DEFAULT 'global',\n           project_path TEXT NULL,\n           target_path TEXT NOT NULL,\n           mode TEXT NOT NULL,\n           status TEXT NOT NULL,\n           last_error TEXT NULL,\n           synced_at INTEGER NULL,\n           FOREIGN KEY(skill_id) REFERENCES skills(id) ON DELETE CASCADE\n         );\n         INSERT INTO skill_targets_new (\n           id, skill_id, tool, scope, project_path, target_path, mode, status, last_error, synced_at\n         )\n         SELECT id, skill_id, tool, 'global', NULL, target_path, mode, status, last_error, synced_at\n         FROM skill_targets;\n         DROP TABLE skill_targets;\n         ALTER TABLE skill_targets_new RENAME TO skill_targets;\n         CREATE UNIQUE INDEX idx_skill_targets_unique_scope\n         ON skill_targets(skill_id, tool, scope, COALESCE(project_path, ''));\n         COMMIT;\",\n    )?;\n    Ok(())\n}\n\nfn migrate_tags_to_v5(conn: &Connection) -> Result<()> {\n    conn.execute_batch(\n        \"CREATE TABLE IF NOT EXISTS skill_tags (\n           id INTEGER PRIMARY KEY AUTOINCREMENT,\n           name TEXT NOT NULL UNIQUE COLLATE NOCASE,\n           created_at INTEGER NOT NULL,\n           updated_at INTEGER NOT NULL\n         );\n\n         CREATE TABLE IF NOT EXISTS skill_tag_links (\n           skill_id TEXT NOT NULL,\n           tag_id INTEGER NOT NULL,\n           created_at INTEGER NOT NULL,\n           PRIMARY KEY (skill_id, tag_id),\n           FOREIGN KEY(skill_id) REFERENCES skills(id) ON DELETE CASCADE,\n           FOREIGN KEY(tag_id) REFERENCES skill_tags(id) ON DELETE CASCADE\n         );\",\n    )?;\n    Ok(())\n}\n\nfn normalize_tag_name(name: &str) -> Result<String> {\n    let normalized = name.trim().to_string();\n    if normalized.is_empty() {\n        anyhow::bail!(\"tag name cannot be empty\");\n    }\n    Ok(normalized)\n}\n\nfn now_ms() -> i64 {\n    let now = std::time::SystemTime::now()\n        .duration_since(std::time::SystemTime::UNIX_EPOCH)\n        .unwrap_or_default();\n    now.as_millis() as i64\n}\n\npub fn default_db_path<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Result<PathBuf> {\n    let app_dir = app\n        .path()\n        .app_data_dir()\n        .context(\"failed to resolve app data dir\")?;\n    std::fs::create_dir_all(&app_dir)\n        .with_context(|| format!(\"failed to create app data dir {:?}\", app_dir))?;\n    Ok(app_dir.join(DB_FILE_NAME))\n}\n\npub fn migrate_legacy_db_if_needed(target_db_path: &Path) -> Result<()> {\n    let Some(data_dir) = dirs::data_dir() else {\n        return Ok(());\n    };\n\n    if let Ok(true) = db_has_any_skills(target_db_path) {\n        return Ok(());\n    }\n\n    let legacy_db_path = LEGACY_APP_IDENTIFIERS\n        .iter()\n        .map(|id| data_dir.join(id).join(DB_FILE_NAME))\n        .find(|path| path.exists());\n\n    let Some(legacy_db_path) = legacy_db_path else {\n        return Ok(());\n    };\n\n    if legacy_db_path == target_db_path {\n        return Ok(());\n    }\n\n    if let Some(parent) = target_db_path.parent() {\n        std::fs::create_dir_all(parent)\n            .with_context(|| format!(\"failed to create app data dir {:?}\", parent))?;\n    }\n\n    if target_db_path.exists() {\n        let backup = target_db_path.with_extension(format!(\n            \"bak-{}\",\n            std::time::SystemTime::now()\n                .duration_since(std::time::UNIX_EPOCH)\n                .unwrap_or_default()\n                .as_secs()\n        ));\n        std::fs::rename(target_db_path, &backup).with_context(|| {\n            format!(\n                \"failed to backup existing db {:?} -> {:?}\",\n                target_db_path, backup\n            )\n        })?;\n    }\n\n    std::fs::copy(&legacy_db_path, target_db_path).with_context(|| {\n        format!(\n            \"failed to migrate legacy db {:?} -> {:?}\",\n            legacy_db_path, target_db_path\n        )\n    })?;\n\n    Ok(())\n}\n\nfn db_has_any_skills(db_path: &Path) -> Result<bool> {\n    if !db_path.exists() {\n        return Ok(false);\n    }\n\n    let conn =\n        Connection::open(db_path).with_context(|| format!(\"failed to open db at {:?}\", db_path))?;\n    let has_table: i64 = conn.query_row(\n        \"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skills';\",\n        [],\n        |row| row.get(0),\n    )?;\n    if has_table == 0 {\n        return Ok(false);\n    }\n\n    let count: i64 = conn.query_row(\"SELECT COUNT(*) FROM skills;\", [], |row| row.get(0))?;\n    Ok(count > 0)\n}\n\n#[cfg(test)]\n#[path = \"tests/skill_store.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/core/skills_search.rs",
    "content": "use anyhow::{Context, Result};\nuse reqwest::blocking::Client;\nuse serde::Deserialize;\n\n#[derive(Debug, Deserialize)]\nstruct SkillsShResponse {\n    skills: Vec<SkillsShItem>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct SkillsShItem {\n    name: String,\n    installs: u64,\n    source: String,\n}\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct OnlineSkillResult {\n    pub name: String,\n    pub installs: u64,\n    pub source: String,\n    pub source_url: String,\n}\n\npub fn search_skills_online(query: &str, limit: usize) -> Result<Vec<OnlineSkillResult>> {\n    search_skills_online_inner(\"https://skills.sh\", query, limit)\n}\n\nfn search_skills_online_inner(\n    base_url: &str,\n    query: &str,\n    limit: usize,\n) -> Result<Vec<OnlineSkillResult>> {\n    let client = Client::new();\n    let base_url = base_url.trim_end_matches('/');\n    let url = format!(\n        \"{}/api/search?q={}&limit={}\",\n        base_url,\n        urlencoding::encode(query),\n        limit.clamp(1, 50)\n    );\n\n    let response = client\n        .get(url)\n        .header(\"User-Agent\", \"skills-hub\")\n        .send()\n        .context(\"skills.sh search request failed\")?\n        .error_for_status()\n        .context(\"skills.sh search returned error\")?;\n\n    let result: SkillsShResponse = response.json().context(\"parse skills.sh response\")?;\n\n    Ok(result\n        .skills\n        .into_iter()\n        .map(|item| {\n            let source_url = format!(\"https://github.com/{}\", item.source);\n            OnlineSkillResult {\n                name: item.name,\n                installs: item.installs,\n                source: item.source,\n                source_url,\n            }\n        })\n        .collect())\n}\n\n#[cfg(test)]\n#[path = \"tests/skills_search.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/core/sync_engine.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\n\n#[allow(dead_code)]\n#[derive(Clone, Debug)]\npub enum SyncMode {\n    Auto,\n    Symlink,\n    Junction,\n    Copy,\n}\n\n#[derive(Clone, Debug)]\npub struct SyncOutcome {\n    pub mode_used: SyncMode,\n    pub target_path: PathBuf,\n    pub replaced: bool,\n}\n\npub fn sync_dir_hybrid(source: &Path, target: &Path) -> Result<SyncOutcome> {\n    if target.exists() {\n        if is_same_link(target, source) {\n            return Ok(SyncOutcome {\n                mode_used: SyncMode::Symlink,\n                target_path: target.to_path_buf(),\n                replaced: false,\n            });\n        }\n        anyhow::bail!(\"target already exists: {:?}\", target);\n    }\n\n    ensure_parent_dir(target)?;\n\n    if try_link_dir(source, target).is_ok() {\n        return Ok(SyncOutcome {\n            mode_used: SyncMode::Symlink,\n            target_path: target.to_path_buf(),\n            replaced: false,\n        });\n    }\n\n    #[cfg(windows)]\n    if try_junction(source, target).is_ok() {\n        return Ok(SyncOutcome {\n            mode_used: SyncMode::Junction,\n            target_path: target.to_path_buf(),\n            replaced: false,\n        });\n    }\n\n    copy_dir_recursive(source, target)?;\n    Ok(SyncOutcome {\n        mode_used: SyncMode::Copy,\n        target_path: target.to_path_buf(),\n        replaced: false,\n    })\n}\n\npub fn sync_dir_hybrid_with_overwrite(\n    source: &Path,\n    target: &Path,\n    overwrite: bool,\n) -> Result<SyncOutcome> {\n    let mut did_replace = false;\n    if std::fs::symlink_metadata(target).is_ok() {\n        if is_same_link(target, source) {\n            return Ok(SyncOutcome {\n                mode_used: SyncMode::Symlink,\n                target_path: target.to_path_buf(),\n                replaced: false,\n            });\n        }\n\n        if overwrite {\n            std::fs::remove_dir_all(target)\n                .with_context(|| format!(\"remove existing target {:?}\", target))?;\n            did_replace = true;\n        } else {\n            anyhow::bail!(\"target already exists: {:?}\", target);\n        }\n    }\n\n    // reuse normal flow\n    sync_dir_hybrid(source, target).map(|mut out| {\n        out.replaced = did_replace;\n        out\n    })\n}\n\npub fn sync_dir_copy_with_overwrite(\n    source: &Path,\n    target: &Path,\n    overwrite: bool,\n) -> Result<SyncOutcome> {\n    let mut did_replace = false;\n    if std::fs::symlink_metadata(target).is_ok() {\n        if overwrite {\n            remove_path_any(target)\n                .with_context(|| format!(\"remove existing target {:?}\", target))?;\n            did_replace = true;\n        } else {\n            anyhow::bail!(\"target already exists: {:?}\", target);\n        }\n    }\n\n    ensure_parent_dir(target)?;\n    copy_dir_recursive(source, target)?;\n\n    Ok(SyncOutcome {\n        mode_used: SyncMode::Copy,\n        target_path: target.to_path_buf(),\n        replaced: did_replace,\n    })\n}\n\npub fn sync_dir_for_tool_with_overwrite(\n    tool_key: &str,\n    source: &Path,\n    target: &Path,\n    overwrite: bool,\n) -> Result<SyncOutcome> {\n    // Cursor 目前不支持软链/junction：强制使用 copy，避免同步后在 Cursor 内不可用。\n    if tool_key.eq_ignore_ascii_case(\"cursor\") {\n        return sync_dir_copy_with_overwrite(source, target, overwrite);\n    }\n    sync_dir_hybrid_with_overwrite(source, target, overwrite)\n}\n\nfn ensure_parent_dir(path: &Path) -> Result<()> {\n    if let Some(parent) = path.parent() {\n        std::fs::create_dir_all(parent).with_context(|| format!(\"create dir {:?}\", parent))?;\n    }\n    Ok(())\n}\n\nfn remove_path_any(path: &Path) -> Result<()> {\n    let meta = match std::fs::symlink_metadata(path) {\n        Ok(meta) => meta,\n        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),\n        Err(err) => return Err(err).with_context(|| format!(\"stat {:?}\", path)),\n    };\n    let ft = meta.file_type();\n\n    // 软链接（即使指向目录）也应该用 remove_file 删除链接本身\n    if ft.is_symlink() {\n        std::fs::remove_file(path).with_context(|| format!(\"remove symlink {:?}\", path))?;\n        return Ok(());\n    }\n    if ft.is_dir() {\n        std::fs::remove_dir_all(path).with_context(|| format!(\"remove dir {:?}\", path))?;\n        return Ok(());\n    }\n    std::fs::remove_file(path).with_context(|| format!(\"remove file {:?}\", path))?;\n    Ok(())\n}\n\nfn is_same_link(link_path: &Path, target: &Path) -> bool {\n    if let Ok(existing) = std::fs::read_link(link_path) {\n        return existing == target;\n    }\n    false\n}\n\nfn try_link_dir(source: &Path, target: &Path) -> Result<()> {\n    #[cfg(unix)]\n    {\n        std::os::unix::fs::symlink(source, target)\n            .with_context(|| format!(\"symlink {:?} -> {:?}\", target, source))?;\n        Ok(())\n    }\n\n    #[cfg(windows)]\n    {\n        std::os::windows::fs::symlink_dir(source, target)\n            .with_context(|| format!(\"symlink {:?} -> {:?}\", target, source))?;\n        return Ok(());\n    }\n\n    #[cfg(not(any(unix, windows)))]\n    anyhow::bail!(\"symlink not supported on this platform\");\n}\n\n#[cfg(windows)]\nfn try_junction(source: &Path, target: &Path) -> Result<()> {\n    junction::create(source, target)\n        .with_context(|| format!(\"junction {:?} -> {:?}\", target, source))?;\n    Ok(())\n}\n\nfn should_skip_copy(entry: &walkdir::DirEntry) -> bool {\n    entry.file_name() == \".git\"\n}\n\npub fn copy_dir_recursive(source: &Path, target: &Path) -> Result<()> {\n    let profile = std::env::var(\"SKILLS_HUB_PROFILE_IO\")\n        .ok()\n        .map(|v| v == \"1\" || v.eq_ignore_ascii_case(\"true\"))\n        .unwrap_or(false);\n    let started = std::time::Instant::now();\n    let mut copied_files: u64 = 0;\n    let mut copied_bytes: u64 = 0;\n\n    for entry in walkdir::WalkDir::new(source)\n        .follow_links(false)\n        .into_iter()\n        .filter_entry(|entry| !should_skip_copy(entry))\n    {\n        let entry = entry?;\n        if should_skip_copy(&entry) {\n            continue;\n        }\n        let relative = entry.path().strip_prefix(source)?;\n        let target_path = target.join(relative);\n\n        if entry.file_type().is_dir() {\n            std::fs::create_dir_all(&target_path)\n                .with_context(|| format!(\"create dir {:?}\", target_path))?;\n        } else if entry.file_type().is_file() {\n            if let Some(parent) = target_path.parent() {\n                std::fs::create_dir_all(parent)?;\n            }\n            let bytes = std::fs::copy(entry.path(), &target_path)\n                .with_context(|| format!(\"copy file {:?} -> {:?}\", entry.path(), target_path))?;\n            if profile {\n                copied_files += 1;\n                copied_bytes = copied_bytes.saturating_add(bytes);\n            }\n        }\n    }\n    if profile {\n        log::info!(\n            \"[sync_engine] copy_dir_recursive {} files, {} bytes in {}s (src={:?} dst={:?})\",\n            copied_files,\n            copied_bytes,\n            started.elapsed().as_secs_f32(),\n            source,\n            target\n        );\n    }\n    Ok(())\n}\n\n#[cfg(test)]\n#[path = \"tests/sync_engine.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/core/temp_cleanup.rs",
    "content": "use std::path::{Path, PathBuf};\nuse std::time::{Duration, SystemTime};\n\nuse anyhow::{Context, Result};\nuse tauri::Manager;\n\nconst TEMP_PREFIX: &str = \"skills-hub-git-\";\nconst TEMP_MARKER: &str = \".skills-hub-git-temp\";\n\n#[allow(dead_code)]\npub fn mark_temp_dir(dir: &Path) -> Result<()> {\n    let marker = dir.join(TEMP_MARKER);\n    if marker.exists() {\n        return Ok(());\n    }\n    std::fs::write(&marker, b\"skills-hub-git-temp-v1\\n\")\n        .with_context(|| format!(\"failed to write marker {:?}\", marker))?;\n    Ok(())\n}\n\npub fn cleanup_old_git_temp_dirs<R: tauri::Runtime>(\n    app: &tauri::AppHandle<R>,\n    max_age: Duration,\n) -> Result<usize> {\n    let cache_dir = app\n        .path()\n        .app_cache_dir()\n        .context(\"failed to resolve app cache dir\")?;\n\n    cleanup_old_git_temp_dirs_in(&cache_dir, max_age)\n}\n\nfn cleanup_old_git_temp_dirs_in(cache_dir: &Path, max_age: Duration) -> Result<usize> {\n    if !cache_dir.exists() {\n        return Ok(0);\n    }\n\n    let cutoff = SystemTime::now()\n        .checked_sub(max_age)\n        .unwrap_or(SystemTime::UNIX_EPOCH);\n\n    let mut removed = 0usize;\n    let rd = match std::fs::read_dir(cache_dir) {\n        Ok(v) => v,\n        Err(err) => {\n            // No permission / missing dir is not fatal.\n            return Err(anyhow::anyhow!(\n                \"failed to read cache dir {:?}: {}\",\n                cache_dir,\n                err\n            ));\n        }\n    };\n\n    for entry in rd.flatten() {\n        let path: PathBuf = entry.path();\n        if !path.is_dir() {\n            continue;\n        }\n        let name = entry.file_name().to_string_lossy().to_string();\n        if !name.starts_with(TEMP_PREFIX) {\n            continue;\n        }\n\n        // Safety: only delete directories we have explicitly marked.\n        if !path.join(TEMP_MARKER).exists() {\n            continue;\n        }\n\n        let meta = match std::fs::metadata(&path) {\n            Ok(m) => m,\n            Err(_) => continue,\n        };\n        let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);\n        if modified > cutoff {\n            continue;\n        }\n\n        // Best-effort delete; do not fail the whole cleanup.\n        if std::fs::remove_dir_all(&path).is_ok() {\n            removed += 1;\n        }\n    }\n\n    Ok(removed)\n}\n\n#[cfg(test)]\n#[path = \"tests/temp_cleanup.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/core/tests/central_repo.rs",
    "content": "use std::path::PathBuf;\n\nuse crate::core::central_repo::{ensure_central_repo, resolve_central_repo_path};\nuse crate::core::skill_store::SkillStore;\n\nfn make_store() -> (tempfile::TempDir, SkillStore) {\n    let dir = tempfile::tempdir().expect(\"tempdir\");\n    let store = SkillStore::new(dir.path().join(\"test.db\"));\n    store.ensure_schema().expect(\"ensure_schema\");\n    (dir, store)\n}\n\n#[test]\nfn resolve_uses_setting_when_present() {\n    let (dir, store) = make_store();\n    let app = tauri::test::mock_app();\n    let expected = dir.path().join(\"central\");\n    store\n        .set_setting(\"central_repo_path\", expected.to_string_lossy().as_ref())\n        .unwrap();\n\n    let got = resolve_central_repo_path(app.handle(), &store).unwrap();\n    assert_eq!(got, expected);\n}\n\n#[test]\nfn ensure_central_repo_creates_dir() {\n    let dir = tempfile::tempdir().expect(\"tempdir\");\n    let p: PathBuf = dir.path().join(\"a/b/c\");\n    assert!(!p.exists());\n    ensure_central_repo(&p).unwrap();\n    assert!(p.exists());\n}\n"
  },
  {
    "path": "src-tauri/src/core/tests/content_hash.rs",
    "content": "use std::fs;\n\nuse crate::core::content_hash::hash_dir;\n\n#[test]\nfn hash_changes_with_content_and_ignores_git_dir() {\n    let dir = tempfile::tempdir().expect(\"tempdir\");\n    let root = dir.path();\n\n    fs::create_dir_all(root.join(\"sub\")).unwrap();\n    fs::write(root.join(\"a.txt\"), b\"hello\").unwrap();\n    fs::write(root.join(\"sub/b.txt\"), b\"world\").unwrap();\n\n    let h1 = hash_dir(root).unwrap();\n\n    fs::create_dir_all(root.join(\".git\")).unwrap();\n    fs::write(root.join(\".git/ignored\"), b\"ignored\").unwrap();\n    let h2 = hash_dir(root).unwrap();\n    assert_eq!(h1, h2, \"应忽略 .git 内容\");\n\n    fs::write(root.join(\"a.txt\"), b\"hello2\").unwrap();\n    let h3 = hash_dir(root).unwrap();\n    assert_ne!(h2, h3);\n}\n"
  },
  {
    "path": "src-tauri/src/core/tests/featured_skills.rs",
    "content": "use super::fetch_featured_skills_inner;\nuse crate::core::skill_store::SkillStore;\n\nfn temp_store() -> SkillStore {\n    let dir = tempfile::tempdir().unwrap();\n    let db_path = dir.path().join(\"test.db\");\n    let store = SkillStore::new(db_path);\n    store.ensure_schema().unwrap();\n    // Leak the tempdir so it stays alive for the duration of the test.\n    std::mem::forget(dir);\n    store\n}\n\nfn json_payload() -> String {\n    r#\"{\n  \"updated_at\": \"2026-01-01T00:00:00Z\",\n  \"skills\": [\n    {\n      \"slug\": \"foo\",\n      \"name\": \"Foo Skill\",\n      \"summary\": \"Does foo\",\n      \"downloads\": 100,\n      \"stars\": 10,\n      \"source_url\": \"https://github.com/openclaw/skills/tree/main/skills/user/foo\"\n    },\n    {\n      \"slug\": \"bar\",\n      \"name\": \"Bar Skill\",\n      \"summary\": \"Does bar\",\n      \"downloads\": 50,\n      \"stars\": 5,\n      \"source_url\": \"\"\n    }\n  ]\n}\"#\n    .to_string()\n}\n\n#[test]\nfn parses_and_filters_empty_source_url() {\n    let mut server = mockito::Server::new();\n    let _m = server\n        .mock(\"GET\", \"/featured.json\")\n        .with_status(200)\n        .with_header(\"content-type\", \"application/json\")\n        .with_body(json_payload())\n        .create();\n\n    let store = temp_store();\n    let url = format!(\"{}/featured.json\", server.url());\n    let skills = fetch_featured_skills_inner(&url, &store).unwrap();\n\n    assert_eq!(skills.len(), 1);\n    assert_eq!(skills[0].slug, \"foo\");\n    assert_eq!(skills[0].downloads, 100);\n}\n\n#[test]\nfn falls_back_to_cache_on_http_failure() {\n    let store = temp_store();\n\n    // Pre-populate cache\n    store\n        .set_setting(\"featured_skills_cache\", &json_payload())\n        .unwrap();\n\n    let mut server = mockito::Server::new();\n    let _m = server\n        .mock(\"GET\", \"/featured.json\")\n        .with_status(500)\n        .with_body(\"error\")\n        .create();\n\n    let url = format!(\"{}/featured.json\", server.url());\n    let skills = fetch_featured_skills_inner(&url, &store).unwrap();\n    assert_eq!(skills.len(), 1);\n    assert_eq!(skills[0].slug, \"foo\");\n}\n\n#[test]\nfn falls_back_to_bundled_on_total_failure() {\n    let store = temp_store();\n\n    let mut server = mockito::Server::new();\n    let _m = server\n        .mock(\"GET\", \"/featured.json\")\n        .with_status(500)\n        .with_body(\"error\")\n        .create();\n\n    let url = format!(\"{}/featured.json\", server.url());\n    let skills = fetch_featured_skills_inner(&url, &store).unwrap();\n    // Should not panic — returns bundled data (may or may not be empty\n    // depending on the current bundled JSON, but should not error).\n    let _ = skills.len();\n}\n\n#[test]\nfn falls_back_to_bundled_on_malformed_json() {\n    let store = temp_store();\n\n    let mut server = mockito::Server::new();\n    let _m = server\n        .mock(\"GET\", \"/featured.json\")\n        .with_status(200)\n        .with_body(\"not json\")\n        .create();\n\n    let url = format!(\"{}/featured.json\", server.url());\n    // No cache, malformed body → falls back to bundled JSON gracefully\n    let skills = fetch_featured_skills_inner(&url, &store).unwrap();\n    let _ = skills.len();\n}\n"
  },
  {
    "path": "src-tauri/src/core/tests/git_fetcher.rs",
    "content": "use std::fs;\n\nuse crate::core::git_fetcher::{clone_or_pull, clone_or_pull_sparse};\n\nfn commit_file(repo: &git2::Repository, path: &str, content: &[u8], msg: &str) -> git2::Oid {\n    let workdir = repo.workdir().expect(\"workdir\");\n    let file_path = workdir.join(path);\n    if let Some(parent) = file_path.parent() {\n        fs::create_dir_all(parent).unwrap();\n    }\n    fs::write(&file_path, content).unwrap();\n\n    let mut index = repo.index().unwrap();\n    index.add_path(std::path::Path::new(path)).unwrap();\n    let tree_id = index.write_tree().unwrap();\n    let tree = repo.find_tree(tree_id).unwrap();\n\n    let sig = git2::Signature::now(\"t\", \"t@example.com\").unwrap();\n    let parents = match repo.head() {\n        Ok(head) => vec![repo.find_commit(head.target().unwrap()).unwrap()],\n        Err(_) => vec![],\n    };\n    let parent_refs: Vec<&git2::Commit> = parents.iter().collect();\n    repo.commit(Some(\"HEAD\"), &sig, &sig, msg, &tree, parent_refs.as_slice())\n        .unwrap()\n}\n\n#[test]\nfn clone_then_pull_updates_head() {\n    let origin_dir = tempfile::tempdir().unwrap();\n    let origin = git2::Repository::init(origin_dir.path()).unwrap();\n    let _c1 = commit_file(&origin, \"a.txt\", b\"v1\", \"c1\");\n    let c2 = commit_file(&origin, \"a.txt\", b\"v2\", \"c2\");\n\n    let dest_dir = tempfile::tempdir().unwrap();\n    let dest = dest_dir.path().join(\"clone\");\n\n    let h1 = clone_or_pull(\n        origin_dir.path().to_string_lossy().as_ref(),\n        &dest,\n        None,\n        None,\n    )\n    .unwrap();\n    assert_eq!(h1, c2.to_string(), \"首次 clone 应指向最新提交\");\n\n    let c3 = commit_file(&origin, \"b.txt\", b\"v3\", \"c3\");\n    let h2 = clone_or_pull(\n        origin_dir.path().to_string_lossy().as_ref(),\n        &dest,\n        None,\n        None,\n    )\n    .unwrap();\n    assert_eq!(h2, c3.to_string(), \"再次调用应更新到最新提交\");\n}\n\n#[test]\nfn sparse_clone_only_materializes_requested_subpath() {\n    let origin_dir = tempfile::tempdir().unwrap();\n    let origin = git2::Repository::init(origin_dir.path()).unwrap();\n    let _ = commit_file(&origin, \"skills/a/SKILL.md\", b\"---\\nname: A\\n---\\n\", \"c1\");\n    let _ = commit_file(&origin, \"skills/b/SKILL.md\", b\"---\\nname: B\\n---\\n\", \"c2\");\n\n    let dest_dir = tempfile::tempdir().unwrap();\n    let dest = dest_dir.path().join(\"clone\");\n\n    let head = match clone_or_pull_sparse(\n        origin_dir.path().to_string_lossy().as_ref(),\n        &dest,\n        None,\n        \"skills/a\",\n        None,\n    ) {\n        Ok(head) => head,\n        Err(err) if format!(\"{:#}\", err).contains(\"system git is required\") => return,\n        Err(err) => panic!(\"sparse clone failed: {:#}\", err),\n    };\n\n    assert!(!head.is_empty());\n    assert!(dest.join(\"skills/a/SKILL.md\").exists());\n    assert!(\n        !dest.join(\"skills/b/SKILL.md\").exists(),\n        \"未请求的子目录不应被检出到工作区\"\n    );\n}\n"
  },
  {
    "path": "src-tauri/src/core/tests/github_search.rs",
    "content": "use mockito::Matcher;\n\nuse super::search_github_repos_inner;\n\nfn json_one_repo() -> String {\n    r#\"{\n  \"items\": [\n    {\n      \"full_name\": \"o/r\",\n      \"html_url\": \"https://example.com/o/r\",\n      \"description\": \"d\",\n      \"stargazers_count\": 123,\n      \"updated_at\": \"2020-01-01T00:00:00Z\",\n      \"clone_url\": \"https://example.com/o/r.git\"\n    }\n  ]\n}\"#\n    .to_string()\n}\n\n#[test]\nfn limit_is_clamped() {\n    let mut server = mockito::Server::new();\n\n    let _m1 = server\n        .mock(\"GET\", \"/search/repositories\")\n        .match_query(Matcher::AllOf(vec![\n            Matcher::UrlEncoded(\"q\".into(), \"hello\".into()),\n            Matcher::UrlEncoded(\"per_page\".into(), \"1\".into()),\n        ]))\n        .with_status(200)\n        .with_header(\"content-type\", \"application/json\")\n        .with_body(json_one_repo())\n        .create();\n\n    let out = search_github_repos_inner(&server.url(), \"hello\", 0, None).unwrap();\n    assert_eq!(out.len(), 1);\n\n    let _m2 = server\n        .mock(\"GET\", \"/search/repositories\")\n        .match_query(Matcher::AllOf(vec![\n            Matcher::UrlEncoded(\"q\".into(), \"hello\".into()),\n            Matcher::UrlEncoded(\"per_page\".into(), \"50\".into()),\n        ]))\n        .with_status(200)\n        .with_header(\"content-type\", \"application/json\")\n        .with_body(json_one_repo())\n        .create();\n\n    let _ = search_github_repos_inner(&server.url(), \"hello\", 999, None).unwrap();\n}\n\n#[test]\nfn maps_fields() {\n    let mut server = mockito::Server::new();\n    let _m = server\n        .mock(\"GET\", \"/search/repositories\")\n        .match_query(Matcher::AllOf(vec![\n            Matcher::UrlEncoded(\"q\".into(), \"x\".into()),\n            Matcher::UrlEncoded(\"per_page\".into(), \"2\".into()),\n        ]))\n        .with_status(200)\n        .with_header(\"content-type\", \"application/json\")\n        .with_body(json_one_repo())\n        .create();\n\n    let out = search_github_repos_inner(&server.url(), \"x\", 2, None).unwrap();\n    assert_eq!(out[0].full_name, \"o/r\");\n    assert_eq!(out[0].stars, 123);\n}\n\n#[test]\nfn http_error_has_context() {\n    let mut server = mockito::Server::new();\n    let _m = server\n        .mock(\"GET\", \"/search/repositories\")\n        .with_status(500)\n        .with_body(\"oops\")\n        .create();\n\n    let err = search_github_repos_inner(&server.url(), \"x\", 2, None).unwrap_err();\n    let msg = format!(\"{:#}\", err);\n    assert!(msg.contains(\"GitHub search returned error\"), \"{msg}\");\n}\n"
  },
  {
    "path": "src-tauri/src/core/tests/installer.rs",
    "content": "use std::fs;\nuse std::path::{Path, PathBuf};\n\nuse crate::core::skill_store::{SkillRecord, SkillStore, SkillTargetRecord};\n\nfn make_store() -> (tempfile::TempDir, SkillStore) {\n    let dir = tempfile::tempdir().expect(\"tempdir\");\n    let store = SkillStore::new(dir.path().join(\"test.db\"));\n    store.ensure_schema().expect(\"ensure_schema\");\n    (dir, store)\n}\n\nfn set_central_path(store: &SkillStore, central: &Path) {\n    store\n        .set_setting(\"central_repo_path\", central.to_string_lossy().as_ref())\n        .unwrap();\n}\n\nfn init_git_repo(dir: &Path) -> git2::Repository {\n    let repo = git2::Repository::init(dir).unwrap();\n    let sig = git2::Signature::now(\"t\", \"t@example.com\").unwrap();\n\n    let mut index = repo.index().unwrap();\n    index\n        .add_all([\"*\"].iter(), git2::IndexAddOption::DEFAULT, None)\n        .unwrap();\n    let tree_id = index.write_tree().unwrap();\n    {\n        let tree = repo.find_tree(tree_id).unwrap();\n        repo.commit(Some(\"HEAD\"), &sig, &sig, \"init\", &tree, &[])\n            .unwrap();\n    }\n    repo\n}\n\nfn commit_all(repo: &git2::Repository, msg: &str) -> git2::Oid {\n    let sig = git2::Signature::now(\"t\", \"t@example.com\").unwrap();\n    let mut index = repo.index().unwrap();\n    index\n        .add_all([\"*\"].iter(), git2::IndexAddOption::DEFAULT, None)\n        .unwrap();\n    let tree_id = index.write_tree().unwrap();\n    let tree = repo.find_tree(tree_id).unwrap();\n\n    let parent = repo\n        .head()\n        .ok()\n        .and_then(|h| h.target())\n        .and_then(|oid| repo.find_commit(oid).ok());\n    match parent {\n        Some(p) => repo\n            .commit(Some(\"HEAD\"), &sig, &sig, msg, &tree, &[&p])\n            .unwrap(),\n        None => repo\n            .commit(Some(\"HEAD\"), &sig, &sig, msg, &tree, &[])\n            .unwrap(),\n    }\n}\n\n#[test]\nfn parses_github_urls() {\n    let p = super::parse_github_url(\"https://github.com/owner/repo\");\n    assert_eq!(p.clone_url, \"https://github.com/owner/repo.git\");\n    assert!(p.branch.is_none());\n    assert!(p.subpath.is_none());\n\n    let p = super::parse_github_url(\"anthropics/skills\");\n    assert_eq!(p.clone_url, \"https://github.com/anthropics/skills.git\");\n    assert!(p.branch.is_none());\n    assert!(p.subpath.is_none());\n\n    let p = super::parse_github_url(\"github.com/owner/repo\");\n    assert_eq!(p.clone_url, \"https://github.com/owner/repo.git\");\n    assert!(p.branch.is_none());\n    assert!(p.subpath.is_none());\n\n    let p = super::parse_github_url(\"https://github.com/owner/repo/tree/main/skills/x\");\n    assert_eq!(p.clone_url, \"https://github.com/owner/repo.git\");\n    assert_eq!(p.branch.as_deref(), Some(\"main\"));\n    assert_eq!(p.subpath.as_deref(), Some(\"skills/x\"));\n\n    let p = super::parse_github_url(\"owner/repo/tree/main/skills/x\");\n    assert_eq!(p.clone_url, \"https://github.com/owner/repo.git\");\n    assert_eq!(p.branch.as_deref(), Some(\"main\"));\n    assert_eq!(p.subpath.as_deref(), Some(\"skills/x\"));\n\n    let p = super::parse_github_url(\"https://github.com/owner/repo/blob/main/skills/x/SKILL.md\");\n    assert_eq!(p.clone_url, \"https://github.com/owner/repo.git\");\n    assert_eq!(p.branch.as_deref(), Some(\"main\"));\n    assert_eq!(p.subpath.as_deref(), Some(\"skills/x\"));\n\n    let p = super::parse_github_url(\"https://github.com/owner/repo/blob/main/SKILL.md\");\n    assert_eq!(p.clone_url, \"https://github.com/owner/repo.git\");\n    assert_eq!(p.branch.as_deref(), Some(\"main\"));\n    assert_eq!(p.subpath.as_deref(), Some(\".\"));\n\n    let p = super::parse_github_url(\"/local/path/to/repo\");\n    assert_eq!(p.clone_url, \"/local/path/to/repo\");\n}\n\n#[test]\nfn parses_skill_md_frontmatter() {\n    let dir = tempfile::tempdir().unwrap();\n    let p = dir.path().join(\"SKILL.md\");\n    fs::write(\n        &p,\n        r#\"---\nname: \"My Skill\"\ndescription: \"Desc\"\n---\n\nbody\n\"#,\n    )\n    .unwrap();\n\n    let (name, desc) = super::parse_skill_md(&p).unwrap();\n    assert_eq!(name, \"My Skill\");\n    assert_eq!(desc.as_deref(), Some(\"Desc\"));\n}\n\n#[test]\nfn parses_skill_md_frontmatter_literal_description() {\n    let dir = tempfile::tempdir().unwrap();\n    let p = dir.path().join(\"SKILL.md\");\n    fs::write(\n        &p,\n        r#\"---\nname: technical-writer\ndescription: |\n  Creates clear documentation, API references, guides, and\n  technical content for developers and users.\nauthor: awesome-llm-apps\n---\n\nbody\n\"#,\n    )\n    .unwrap();\n\n    let (name, desc) = super::parse_skill_md(&p).unwrap();\n    assert_eq!(name, \"technical-writer\");\n    assert_eq!(\n        desc.as_deref(),\n        Some(\"Creates clear documentation, API references, guides, and\\ntechnical content for developers and users.\")\n    );\n}\n\n#[test]\nfn parses_skill_md_frontmatter_folded_chomp_description() {\n    let dir = tempfile::tempdir().unwrap();\n    let p = dir.path().join(\"SKILL.md\");\n    fs::write(\n        &p,\n        r#\"---\nname: fireworks-tech-graph\ndescription: >-\n  Use when the user wants to create any technical diagram - architecture, data\n  flow, flowchart, sequence, agent/memory, or concept map - and export as\n  SVG+PNG.\n---\n\nbody\n\"#,\n    )\n    .unwrap();\n\n    let (name, desc) = super::parse_skill_md(&p).unwrap();\n    assert_eq!(name, \"fireworks-tech-graph\");\n    assert_eq!(\n        desc.as_deref(),\n        Some(\n            \"Use when the user wants to create any technical diagram - architecture, data flow, flowchart, sequence, agent/memory, or concept map - and export as SVG+PNG.\"\n        )\n    );\n}\n\n#[test]\nfn backfill_skill_descriptions_replaces_stale_frontmatter_marker() {\n    let (_dir, store) = make_store();\n    let central = tempfile::tempdir().unwrap();\n    fs::write(\n        central.path().join(\"SKILL.md\"),\n        r#\"---\nname: fireworks-tech-graph\ndescription: >-\n  Correct folded description.\n---\n\"#,\n    )\n    .unwrap();\n\n    store\n        .upsert_skill(&SkillRecord {\n            id: \"fireworks\".to_string(),\n            name: \"fireworks-tech-graph\".to_string(),\n            description: Some(\">-\".to_string()),\n            source_type: \"local\".to_string(),\n            source_ref: None,\n            source_subpath: None,\n            source_revision: None,\n            central_path: central.path().to_string_lossy().to_string(),\n            content_hash: None,\n            created_at: 1,\n            updated_at: 1,\n            last_sync_at: None,\n            last_seen_at: 1,\n            status: \"ok\".to_string(),\n        })\n        .unwrap();\n\n    super::backfill_skill_descriptions(&store);\n\n    let skill = store.get_skill_by_id(\"fireworks\").unwrap().unwrap();\n    assert_eq!(\n        skill.description.as_deref(),\n        Some(\"Correct folded description.\")\n    );\n}\n\n#[test]\nfn installs_local_skill_and_updates_from_source() {\n    let app = tauri::test::mock_app();\n    let (_dir, store) = make_store();\n\n    let central_root = tempfile::tempdir().unwrap();\n    set_central_path(&store, central_root.path());\n\n    let source = tempfile::tempdir().unwrap();\n    fs::write(source.path().join(\"SKILL.md\"), b\"---\\nname: x\\n---\\n\").unwrap();\n    fs::write(source.path().join(\"a.txt\"), b\"v1\").unwrap();\n\n    let res = super::install_local_skill(\n        app.handle(),\n        &store,\n        source.path(),\n        Some(\"local1\".to_string()),\n    )\n    .unwrap();\n    assert!(res.central_path.exists());\n\n    let skill = store.get_skill_by_id(&res.skill_id).unwrap().unwrap();\n    assert_eq!(skill.name, \"local1\");\n\n    // add a copy target so update will resync it\n    let target_root = tempfile::tempdir().unwrap();\n    let target = target_root.path().join(\"target\");\n    let t = SkillTargetRecord {\n        id: \"t1\".to_string(),\n        skill_id: res.skill_id.clone(),\n        tool: \"unknown_tool\".to_string(),\n        scope: \"global\".to_string(),\n        project_path: None,\n        target_path: target.to_string_lossy().to_string(),\n        mode: \"copy\".to_string(),\n        status: \"ok\".to_string(),\n        last_error: None,\n        synced_at: None,\n    };\n    store.upsert_skill_target(&t).unwrap();\n\n    fs::write(source.path().join(\"a.txt\"), b\"v2\").unwrap();\n    let up = super::update_managed_skill_from_source(app.handle(), &store, &res.skill_id).unwrap();\n    assert_eq!(up.skill_id, res.skill_id);\n    assert!(up.updated_targets.contains(&\"unknown_tool\".to_string()));\n    assert!(PathBuf::from(\n        store\n            .get_skill_by_id(&res.skill_id)\n            .unwrap()\n            .unwrap()\n            .central_path\n    )\n    .exists());\n    assert!(\n        target.join(\"a.txt\").exists(),\n        \"目标路径应存在并包含同步后的文件\"\n    );\n    assert_eq!(fs::read(target.join(\"a.txt\")).unwrap(), b\"v2\");\n\n    let err = match super::install_local_skill(\n        app.handle(),\n        &store,\n        source.path(),\n        Some(\"local1\".to_string()),\n    ) {\n        Ok(_) => panic!(\"expected error\"),\n        Err(e) => e,\n    };\n    assert!(format!(\"{:#}\", err).contains(\"skill already exists\"));\n}\n\n#[test]\nfn lists_and_installs_git_skills_without_network() {\n    let app = tauri::test::mock_app();\n    let (_dir, store) = make_store();\n    let central_root = tempfile::tempdir().unwrap();\n    set_central_path(&store, central_root.path());\n\n    let repo_dir = tempfile::tempdir().unwrap();\n    fs::write(repo_dir.path().join(\"SKILL.md\"), \"---\\nname: Root\\n---\\n\").unwrap();\n    fs::create_dir_all(repo_dir.path().join(\"skills/a\")).unwrap();\n    fs::write(\n        repo_dir.path().join(\"skills/a/SKILL.md\"),\n        \"---\\nname: A\\n---\\n\",\n    )\n    .unwrap();\n    let repo = init_git_repo(repo_dir.path());\n    commit_all(&repo, \"add skills\");\n\n    let candidates = super::list_git_skills(\n        app.handle(),\n        &store,\n        repo_dir.path().to_string_lossy().as_ref(),\n    )\n    .unwrap();\n    let subpaths: Vec<String> = candidates.into_iter().map(|c| c.subpath).collect();\n    assert!(subpaths.contains(&\".\".to_string()));\n    assert!(subpaths.iter().any(|s| s.ends_with(\"skills/a\")));\n\n    let res = super::install_git_skill_from_selection(\n        app.handle(),\n        &store,\n        repo_dir.path().to_string_lossy().as_ref(),\n        \"skills/a\",\n        None,\n    )\n    .unwrap();\n    assert!(res.central_path.exists());\n}\n\n#[test]\nfn install_git_skill_errors_on_multi_skills_repo_root() {\n    let app = tauri::test::mock_app();\n    let (_dir, store) = make_store();\n    let central_root = tempfile::tempdir().unwrap();\n    set_central_path(&store, central_root.path());\n\n    let repo_dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(repo_dir.path().join(\"skills/a\")).unwrap();\n    fs::create_dir_all(repo_dir.path().join(\"skills/b\")).unwrap();\n    fs::write(\n        repo_dir.path().join(\"skills/a/SKILL.md\"),\n        \"---\\nname: A\\n---\\n\",\n    )\n    .unwrap();\n    fs::write(\n        repo_dir.path().join(\"skills/b/SKILL.md\"),\n        \"---\\nname: B\\n---\\n\",\n    )\n    .unwrap();\n    let repo = init_git_repo(repo_dir.path());\n    commit_all(&repo, \"multi skills\");\n\n    let err = match super::install_git_skill(\n        app.handle(),\n        &store,\n        repo_dir.path().to_string_lossy().as_ref(),\n        None,\n        None,\n    ) {\n        Ok(_) => panic!(\"expected error\"),\n        Err(e) => e,\n    };\n    assert!(format!(\"{:#}\", err).contains(\"MULTI_SKILLS|\"));\n}\n\n#[test]\nfn lists_local_skills_with_invalid_entries() {\n    let dir = tempfile::tempdir().unwrap();\n    let base = dir.path();\n    fs::create_dir_all(base.join(\"skills/a\")).unwrap();\n    fs::create_dir_all(base.join(\"skills/b\")).unwrap();\n    fs::create_dir_all(base.join(\"skills/c\")).unwrap();\n    fs::create_dir_all(base.join(\"skills/d\")).unwrap();\n\n    fs::write(base.join(\"skills/a/SKILL.md\"), \"---\\nname: A\\n---\\n\").unwrap();\n    fs::write(base.join(\"skills/c/SKILL.md\"), \"name: C\\n\").unwrap();\n    fs::write(base.join(\"skills/d/SKILL.md\"), \"---\\ndescription: D\\n---\\n\").unwrap();\n\n    let list = super::list_local_skills(base).unwrap();\n\n    let find = |subpath: &str| list.iter().find(|c| c.subpath == subpath).cloned();\n\n    let a = find(\"skills/a\").expect(\"skills/a\");\n    assert!(a.valid);\n    assert_eq!(a.name, \"A\");\n\n    let b = find(\"skills/b\").expect(\"skills/b\");\n    assert!(!b.valid);\n    assert_eq!(b.reason.as_deref(), Some(\"missing_skill_md\"));\n\n    let c = find(\"skills/c\").expect(\"skills/c\");\n    assert!(!c.valid);\n    assert_eq!(c.reason.as_deref(), Some(\"invalid_frontmatter\"));\n\n    let d = find(\"skills/d\").expect(\"skills/d\");\n    assert!(!d.valid);\n    assert_eq!(d.reason.as_deref(), Some(\"missing_name\"));\n}\n\n#[test]\nfn install_local_selection_validates_skill_md() {\n    let app = tauri::test::mock_app();\n    let (_dir, store) = make_store();\n\n    let central_root = tempfile::tempdir().unwrap();\n    set_central_path(&store, central_root.path());\n\n    let base = tempfile::tempdir().unwrap();\n    fs::create_dir_all(base.path().join(\"skills/a\")).unwrap();\n    fs::create_dir_all(base.path().join(\"skills/b\")).unwrap();\n    fs::write(\n        base.path().join(\"skills/a/SKILL.md\"),\n        \"---\\nname: Local A\\n---\\n\",\n    )\n    .unwrap();\n\n    let res = super::install_local_skill_from_selection(\n        app.handle(),\n        &store,\n        base.path(),\n        \"skills/a\",\n        None,\n    )\n    .unwrap();\n    assert!(res.central_path.exists());\n    let skill = store.get_skill_by_id(&res.skill_id).unwrap().unwrap();\n    assert_eq!(skill.name, \"Local A\");\n\n    let err = match super::install_local_skill_from_selection(\n        app.handle(),\n        &store,\n        base.path(),\n        \"skills/b\",\n        None,\n    ) {\n        Ok(_) => panic!(\"expected error\"),\n        Err(e) => e,\n    };\n    assert!(format!(\"{:#}\", err).contains(\"SKILL_INVALID|missing_skill_md\"));\n}\n\n/// Issue #28: when a git subpath is \"skills\", the derived name should be replaced by the\n/// SKILL.md name to avoid path duplication (e.g. `~/.claude/skills/skills/`).\n#[test]\nfn install_git_skill_uses_skill_md_name_over_subpath_skills() {\n    let app = tauri::test::mock_app();\n    let (_dir, store) = make_store();\n    let central_root = tempfile::tempdir().unwrap();\n    set_central_path(&store, central_root.path());\n\n    // Build a repo with skills/<folder> where the folder is named \"skills\" (simulating\n    // a URL like https://github.com/owner/repo/tree/main/skills).\n    let repo_dir = tempfile::tempdir().unwrap();\n    let skills_dir = repo_dir.path().join(\"skills\");\n    fs::create_dir_all(&skills_dir).unwrap();\n    fs::write(\n        skills_dir.join(\"SKILL.md\"),\n        \"---\\nname: my-real-skill\\ndescription: A real skill\\n---\\n\",\n    )\n    .unwrap();\n    fs::write(skills_dir.join(\"helper.txt\"), b\"data\").unwrap();\n    let repo = init_git_repo(repo_dir.path());\n    commit_all(&repo, \"add skill in skills dir\");\n\n    // install_git_skill_from_selection with subpath \"skills\" (no user-provided name)\n    let res = super::install_git_skill_from_selection(\n        app.handle(),\n        &store,\n        repo_dir.path().to_string_lossy().as_ref(),\n        \"skills\",\n        None,\n    )\n    .unwrap();\n\n    // The name should be \"my-real-skill\" from SKILL.md, NOT \"skills\" from the subpath.\n    assert_eq!(res.name, \"my-real-skill\");\n    assert!(res.central_path.ends_with(\"my-real-skill\"));\n    assert!(res.central_path.join(\"SKILL.md\").exists());\n\n    let skill = store.get_skill_by_id(&res.skill_id).unwrap().unwrap();\n    assert_eq!(skill.name, \"my-real-skill\");\n    assert_eq!(skill.description.as_deref(), Some(\"A real skill\"));\n}\n\n#[test]\nfn install_git_skill_rejects_container_subpath_without_skill_md() {\n    let app = tauri::test::mock_app();\n    let (_dir, store) = make_store();\n    let central_root = tempfile::tempdir().unwrap();\n    set_central_path(&store, central_root.path());\n\n    let repo_dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(\n        repo_dir\n            .path()\n            .join(\"awesome_agent_skills/technical-writer\"),\n    )\n    .unwrap();\n    fs::write(\n        repo_dir\n            .path()\n            .join(\"awesome_agent_skills/technical-writer/SKILL.md\"),\n        \"---\\nname: technical-writer\\n---\\n\",\n    )\n    .unwrap();\n    let repo = init_git_repo(repo_dir.path());\n    commit_all(&repo, \"add container skill\");\n\n    let err = match super::install_git_skill_from_selection(\n        app.handle(),\n        &store,\n        repo_dir.path().to_string_lossy().as_ref(),\n        \"awesome_agent_skills\",\n        None,\n    ) {\n        Ok(_) => panic!(\"expected invalid skill path\"),\n        Err(e) => e,\n    };\n    assert!(format!(\"{:#}\", err).contains(\"SKILL_INVALID|missing_skill_md\"));\n}\n\n#[test]\nfn install_git_skill_selection_accepts_specific_child_under_container() {\n    let app = tauri::test::mock_app();\n    let (_dir, store) = make_store();\n    let central_root = tempfile::tempdir().unwrap();\n    set_central_path(&store, central_root.path());\n\n    let repo_dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(\n        repo_dir\n            .path()\n            .join(\"awesome_agent_skills/technical-writer\"),\n    )\n    .unwrap();\n    fs::write(\n        repo_dir\n            .path()\n            .join(\"awesome_agent_skills/technical-writer/SKILL.md\"),\n        \"---\\nname: technical-writer\\ndescription: docs\\n---\\n\",\n    )\n    .unwrap();\n    let repo = init_git_repo(repo_dir.path());\n    commit_all(&repo, \"add container skill\");\n\n    let res = super::install_git_skill_from_selection(\n        app.handle(),\n        &store,\n        repo_dir.path().to_string_lossy().as_ref(),\n        \"awesome_agent_skills/technical-writer\",\n        None,\n    )\n    .unwrap();\n\n    assert_eq!(res.name, \"technical-writer\");\n    assert!(res.central_path.join(\"SKILL.md\").exists());\n}\n\n/// Issue #28: when user explicitly provides a name, SKILL.md should NOT override it.\n#[test]\nfn install_git_skill_respects_user_provided_name() {\n    let app = tauri::test::mock_app();\n    let (_dir, store) = make_store();\n    let central_root = tempfile::tempdir().unwrap();\n    set_central_path(&store, central_root.path());\n\n    let repo_dir = tempfile::tempdir().unwrap();\n    let skills_dir = repo_dir.path().join(\"skills\");\n    fs::create_dir_all(&skills_dir).unwrap();\n    fs::write(skills_dir.join(\"SKILL.md\"), \"---\\nname: md-name\\n---\\n\").unwrap();\n    let repo = init_git_repo(repo_dir.path());\n    commit_all(&repo, \"add skill\");\n\n    let res = super::install_git_skill_from_selection(\n        app.handle(),\n        &store,\n        repo_dir.path().to_string_lossy().as_ref(),\n        \"skills\",\n        Some(\"user-custom-name\".to_string()),\n    )\n    .unwrap();\n\n    // User-provided name takes priority.\n    assert_eq!(res.name, \"user-custom-name\");\n}\n\n/// Issue #28: install_git_skill (non-selection variant) also uses SKILL.md name.\n#[test]\nfn install_git_skill_derives_name_from_skill_md() {\n    let app = tauri::test::mock_app();\n    let (_dir, store) = make_store();\n    let central_root = tempfile::tempdir().unwrap();\n    set_central_path(&store, central_root.path());\n\n    let repo_dir = tempfile::tempdir().unwrap();\n    fs::write(\n        repo_dir.path().join(\"SKILL.md\"),\n        \"---\\nname: proper-name\\ndescription: desc\\n---\\n\",\n    )\n    .unwrap();\n    let repo = init_git_repo(repo_dir.path());\n    commit_all(&repo, \"init\");\n\n    // The repo name (derived from path) will be something like a temp dir name.\n    // After install, the name should be \"proper-name\" from SKILL.md.\n    let res = super::install_git_skill(\n        app.handle(),\n        &store,\n        repo_dir.path().to_string_lossy().as_ref(),\n        None,\n        None,\n    )\n    .unwrap();\n\n    assert_eq!(res.name, \"proper-name\");\n    assert!(res.central_path.ends_with(\"proper-name\"));\n}\n\n/// Issue #18: repos with skills in root-level subdirectories (no `skills/` parent)\n/// should be detected as multi-skill repos.\n#[test]\nfn install_git_skill_detects_root_level_multi_skills() {\n    let app = tauri::test::mock_app();\n    let (_dir, store) = make_store();\n    let central_root = tempfile::tempdir().unwrap();\n    set_central_path(&store, central_root.path());\n\n    // Build a repo with skills directly in root subdirectories (no skills/ parent)\n    let repo_dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(repo_dir.path().join(\"skill-a\")).unwrap();\n    fs::create_dir_all(repo_dir.path().join(\"skill-b\")).unwrap();\n    fs::write(\n        repo_dir.path().join(\"skill-a/SKILL.md\"),\n        \"---\\nname: Skill A\\n---\\n\",\n    )\n    .unwrap();\n    fs::write(\n        repo_dir.path().join(\"skill-b/SKILL.md\"),\n        \"---\\nname: Skill B\\n---\\n\",\n    )\n    .unwrap();\n    let repo = init_git_repo(repo_dir.path());\n    commit_all(&repo, \"add root-level skills\");\n\n    // install_git_skill should detect multiple skills and bail with MULTI_SKILLS\n    let err = match super::install_git_skill(\n        app.handle(),\n        &store,\n        repo_dir.path().to_string_lossy().as_ref(),\n        None,\n        None,\n    ) {\n        Ok(_) => panic!(\"expected MULTI_SKILLS error\"),\n        Err(e) => e,\n    };\n    assert!(format!(\"{:#}\", err).contains(\"MULTI_SKILLS|\"));\n}\n\n/// Issue #18: list_git_skills should discover skills in root-level subdirectories.\n#[test]\nfn list_git_skills_finds_root_level_skills() {\n    let app = tauri::test::mock_app();\n    let (_dir, store) = make_store();\n    let central_root = tempfile::tempdir().unwrap();\n    set_central_path(&store, central_root.path());\n\n    let repo_dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(repo_dir.path().join(\"my-skill-1\")).unwrap();\n    fs::create_dir_all(repo_dir.path().join(\"my-skill-2\")).unwrap();\n    fs::create_dir_all(repo_dir.path().join(\"not-a-skill\")).unwrap();\n    fs::write(\n        repo_dir.path().join(\"my-skill-1/SKILL.md\"),\n        \"---\\nname: First\\n---\\n\",\n    )\n    .unwrap();\n    fs::write(\n        repo_dir.path().join(\"my-skill-2/SKILL.md\"),\n        \"---\\nname: Second\\n---\\n\",\n    )\n    .unwrap();\n    // not-a-skill has no SKILL.md — should NOT be discovered\n    let repo = init_git_repo(repo_dir.path());\n    commit_all(&repo, \"add root-level skills\");\n\n    let candidates = super::list_git_skills(\n        app.handle(),\n        &store,\n        repo_dir.path().to_string_lossy().as_ref(),\n    )\n    .unwrap();\n\n    let names: Vec<String> = candidates.iter().map(|c| c.name.clone()).collect();\n    assert!(names.contains(&\"First\".to_string()), \"should find First\");\n    assert!(names.contains(&\"Second\".to_string()), \"should find Second\");\n    // \"not-a-skill\" should NOT appear\n    assert!(\n        !candidates.iter().any(|c| c.subpath.contains(\"not-a-skill\")),\n        \"should not find not-a-skill\"\n    );\n}\n\n#[test]\nfn list_git_skills_finds_root_skill_container_layout() {\n    let app = tauri::test::mock_app();\n    let (_dir, store) = make_store();\n    let central_root = tempfile::tempdir().unwrap();\n    set_central_path(&store, central_root.path());\n\n    let repo_dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(repo_dir.path().join(\"custom-agent-skills/technical-writer\")).unwrap();\n    fs::write(\n        repo_dir\n            .path()\n            .join(\"custom-agent-skills/technical-writer/SKILL.md\"),\n        \"---\\nname: technical-writer\\ndescription: docs\\n---\\n\",\n    )\n    .unwrap();\n    let repo = init_git_repo(repo_dir.path());\n    commit_all(&repo, \"add container skill\");\n\n    let candidates = super::list_git_skills(\n        app.handle(),\n        &store,\n        repo_dir.path().to_string_lossy().as_ref(),\n    )\n    .unwrap();\n\n    let candidate = candidates\n        .iter()\n        .find(|c| c.name == \"technical-writer\")\n        .expect(\"technical-writer should be discovered\");\n    assert_eq!(candidate.subpath, \"custom-agent-skills/technical-writer\");\n    assert_eq!(candidate.description.as_deref(), Some(\"docs\"));\n}\n\n#[test]\nfn collect_skill_dirs_finds_skills_under_explicit_container() {\n    let dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(dir.path().join(\"technical-writer\")).unwrap();\n    fs::create_dir_all(dir.path().join(\"not-a-skill\")).unwrap();\n    fs::write(\n        dir.path().join(\"technical-writer/SKILL.md\"),\n        \"---\\nname: technical-writer\\n---\\n\",\n    )\n    .unwrap();\n\n    let dirs = super::collect_skill_dirs(dir.path());\n    let rels: Vec<String> = dirs\n        .iter()\n        .map(|p| {\n            p.strip_prefix(dir.path())\n                .unwrap_or(p)\n                .to_string_lossy()\n                .to_string()\n        })\n        .collect();\n    assert_eq!(rels, vec![\"technical-writer\".to_string()]);\n}\n\n#[test]\nfn collect_skill_dirs_finds_multiple_skills_under_explicit_container() {\n    let dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(dir.path().join(\"technical-writer\")).unwrap();\n    fs::create_dir_all(dir.path().join(\"python-expert\")).unwrap();\n    fs::create_dir_all(dir.path().join(\"not-a-skill\")).unwrap();\n    fs::write(\n        dir.path().join(\"technical-writer/SKILL.md\"),\n        \"---\\nname: technical-writer\\n---\\n\",\n    )\n    .unwrap();\n    fs::write(\n        dir.path().join(\"python-expert/SKILL.md\"),\n        \"---\\nname: python-expert\\n---\\n\",\n    )\n    .unwrap();\n\n    let dirs = super::collect_skill_dirs(dir.path());\n    let rels: Vec<String> = dirs\n        .iter()\n        .map(|p| {\n            p.strip_prefix(dir.path())\n                .unwrap_or(p)\n                .to_string_lossy()\n                .to_string()\n        })\n        .collect();\n    assert_eq!(\n        rels,\n        vec![\"python-expert\".to_string(), \"technical-writer\".to_string()]\n    );\n}\n\n#[test]\nfn collect_skill_dirs_scans_named_skill_containers_but_not_generic_dirs() {\n    let dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(dir.path().join(\"agent-pack/hidden-skill\")).unwrap();\n    fs::create_dir_all(dir.path().join(\"agent-skills/visible-skill\")).unwrap();\n    fs::write(\n        dir.path().join(\"agent-pack/hidden-skill/SKILL.md\"),\n        \"---\\nname: hidden\\n---\\n\",\n    )\n    .unwrap();\n    fs::write(\n        dir.path().join(\"agent-skills/visible-skill/SKILL.md\"),\n        \"---\\nname: visible\\n---\\n\",\n    )\n    .unwrap();\n\n    let dirs = super::collect_skill_dirs(dir.path());\n    let rels: Vec<String> = dirs\n        .iter()\n        .map(|p| {\n            p.strip_prefix(dir.path())\n                .unwrap_or(p)\n                .to_string_lossy()\n                .to_string()\n        })\n        .collect();\n    assert_eq!(rels, vec![\"agent-skills/visible-skill\".to_string()]);\n}\n\n#[test]\nfn collect_skill_dirs_deduplicates_known_root_containers() {\n    let dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(dir.path().join(\"skills/technical-writer\")).unwrap();\n    fs::write(\n        dir.path().join(\"skills/technical-writer/SKILL.md\"),\n        \"---\\nname: technical-writer\\n---\\n\",\n    )\n    .unwrap();\n\n    let dirs = super::collect_skill_dirs(dir.path());\n    assert_eq!(dirs.len(), 1);\n    assert!(dirs[0].ends_with(\"skills/technical-writer\"));\n}\n"
  },
  {
    "path": "src-tauri/src/core/tests/onboarding.rs",
    "content": "use std::fs;\n\nuse super::build_onboarding_plan_in_home;\n\n#[test]\nfn groups_by_name_and_detects_conflicts_by_fingerprint() {\n    let home = tempfile::tempdir().unwrap();\n\n    // Cursor installed\n    fs::create_dir_all(home.path().join(\".cursor\")).unwrap();\n    fs::create_dir_all(home.path().join(\".cursor/skills/foo\")).unwrap();\n    fs::write(home.path().join(\".cursor/skills/foo/a.txt\"), b\"cursor\").unwrap();\n\n    // Codex installed\n    fs::create_dir_all(home.path().join(\".codex\")).unwrap();\n    fs::create_dir_all(home.path().join(\".codex/skills/foo\")).unwrap();\n    fs::write(home.path().join(\".codex/skills/foo/a.txt\"), b\"codex\").unwrap();\n\n    // Codex .system should be ignored\n    fs::create_dir_all(home.path().join(\".codex/skills/.system\")).unwrap();\n    fs::write(home.path().join(\".codex/skills/.system/SKILL.md\"), b\"x\").unwrap();\n\n    let plan = build_onboarding_plan_in_home(home.path(), None, None).unwrap();\n    assert_eq!(plan.total_tools_scanned, 2);\n    assert_eq!(plan.total_skills_found, 2);\n    assert_eq!(plan.groups.len(), 1);\n    assert_eq!(plan.groups[0].name, \"foo\");\n    assert!(plan.groups[0].has_conflict, \"同名但内容不同应冲突\");\n    assert_eq!(plan.groups[0].variants.len(), 2);\n}\n\n#[test]\n#[cfg(unix)]\nfn excludes_central_repo_path() {\n    use std::os::unix::fs::symlink;\n\n    let home = tempfile::tempdir().unwrap();\n\n    // Cursor installed\n    std::fs::create_dir_all(home.path().join(\".cursor\")).unwrap();\n    std::fs::create_dir_all(home.path().join(\".cursor/skills\")).unwrap();\n\n    let central = home.path().join(\"central\");\n    std::fs::create_dir_all(central.join(\"skill-a\")).unwrap();\n\n    let link_path = home.path().join(\".cursor/skills/skill-a\");\n    symlink(central.join(\"skill-a\"), &link_path).unwrap();\n\n    let plan = build_onboarding_plan_in_home(home.path(), Some(&central), None).unwrap();\n    assert_eq!(plan.total_skills_found, 0);\n}\n\n#[test]\nfn excludes_managed_skill_targets() {\n    let home = tempfile::tempdir().unwrap();\n\n    // Cursor installed\n    fs::create_dir_all(home.path().join(\".cursor\")).unwrap();\n    fs::create_dir_all(home.path().join(\".cursor/skills/foo\")).unwrap();\n    fs::write(home.path().join(\".cursor/skills/foo/a.txt\"), b\"cursor\").unwrap();\n\n    let mut exclude = std::collections::HashSet::new();\n    exclude.insert(super::managed_target_key(\n        \"cursor\",\n        &home.path().join(\".cursor/skills/foo\"),\n    ));\n\n    let plan = build_onboarding_plan_in_home(home.path(), None, Some(&exclude)).unwrap();\n    assert_eq!(plan.total_skills_found, 0);\n}\n"
  },
  {
    "path": "src-tauri/src/core/tests/skill_store.rs",
    "content": "use std::path::PathBuf;\n\nuse crate::core::skill_store::{SkillRecord, SkillStore, SkillTargetRecord};\nuse rusqlite::Connection;\n\nfn make_store() -> (tempfile::TempDir, SkillStore) {\n    let dir = tempfile::tempdir().expect(\"tempdir\");\n    let db = dir.path().join(\"test.db\");\n    let store = SkillStore::new(db);\n    store.ensure_schema().expect(\"ensure_schema\");\n    (dir, store)\n}\n\nfn make_skill(id: &str, name: &str, central_path: &str, updated_at: i64) -> SkillRecord {\n    SkillRecord {\n        id: id.to_string(),\n        name: name.to_string(),\n        description: None,\n        source_type: \"local\".to_string(),\n        source_ref: Some(\"/tmp/source\".to_string()),\n        source_subpath: None,\n        source_revision: None,\n        central_path: central_path.to_string(),\n        content_hash: None,\n        created_at: 1,\n        updated_at,\n        last_sync_at: None,\n        last_seen_at: 1,\n        status: \"ok\".to_string(),\n    }\n}\n\n#[test]\nfn schema_is_idempotent() {\n    let (_dir, store) = make_store();\n    store.ensure_schema().expect(\"ensure_schema again\");\n}\n\n#[test]\nfn migrates_v3_targets_to_global_scope() {\n    let dir = tempfile::tempdir().expect(\"tempdir\");\n    let db = dir.path().join(\"test.db\");\n    let conn = Connection::open(&db).unwrap();\n    conn.execute_batch(\n        \"CREATE TABLE skills (\n          id TEXT PRIMARY KEY,\n          name TEXT NOT NULL,\n          description TEXT NULL,\n          source_type TEXT NOT NULL,\n          source_ref TEXT NULL,\n          source_subpath TEXT NULL,\n          source_revision TEXT NULL,\n          central_path TEXT NOT NULL UNIQUE,\n          content_hash TEXT NULL,\n          created_at INTEGER NOT NULL,\n          updated_at INTEGER NOT NULL,\n          last_sync_at INTEGER NULL,\n          last_seen_at INTEGER NOT NULL,\n          status TEXT NOT NULL\n        );\n        CREATE TABLE skill_targets (\n          id TEXT PRIMARY KEY,\n          skill_id TEXT NOT NULL,\n          tool TEXT NOT NULL,\n          target_path TEXT NOT NULL,\n          mode TEXT NOT NULL,\n          status TEXT NOT NULL,\n          last_error TEXT NULL,\n          synced_at INTEGER NULL,\n          UNIQUE(skill_id, tool),\n          FOREIGN KEY(skill_id) REFERENCES skills(id) ON DELETE CASCADE\n        );\n        CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);\n        CREATE TABLE discovered_skills (\n          id TEXT PRIMARY KEY,\n          tool TEXT NOT NULL,\n          found_path TEXT NOT NULL,\n          name_guess TEXT NULL,\n          fingerprint TEXT NULL,\n          found_at INTEGER NOT NULL,\n          imported_skill_id TEXT NULL,\n          FOREIGN KEY(imported_skill_id) REFERENCES skills(id) ON DELETE SET NULL\n        );\n        INSERT INTO skills (\n          id, name, description, source_type, source_ref, source_subpath, source_revision,\n          central_path, content_hash, created_at, updated_at, last_sync_at, last_seen_at, status\n        ) VALUES (\n          's1', 'S1', NULL, 'local', NULL, NULL, NULL,\n          '/central/s1', NULL, 1, 2, NULL, 1, 'ok'\n        );\n        INSERT INTO skill_targets (\n          id, skill_id, tool, target_path, mode, status, last_error, synced_at\n        ) VALUES (\n          't1', 's1', 'cursor', '/target/s1', 'copy', 'ok', NULL, 3\n        );\n        PRAGMA user_version = 3;\",\n    )\n    .unwrap();\n    drop(conn);\n\n    let store = SkillStore::new(db);\n    store.ensure_schema().unwrap();\n\n    let target = store\n        .get_skill_target(\"s1\", \"cursor\", \"global\", None)\n        .unwrap()\n        .unwrap();\n    assert_eq!(target.target_path, \"/target/s1\");\n    assert_eq!(target.scope, \"global\");\n    assert!(target.project_path.is_none());\n}\n\n#[test]\nfn settings_roundtrip_and_update() {\n    let (_dir, store) = make_store();\n\n    assert_eq!(store.get_setting(\"missing\").unwrap(), None);\n    store.set_setting(\"k\", \"v1\").unwrap();\n    assert_eq!(store.get_setting(\"k\").unwrap().as_deref(), Some(\"v1\"));\n    store.set_setting(\"k\", \"v2\").unwrap();\n    assert_eq!(store.get_setting(\"k\").unwrap().as_deref(), Some(\"v2\"));\n\n    store.set_onboarding_completed(true).unwrap();\n    assert_eq!(\n        store\n            .get_setting(\"onboarding_completed\")\n            .unwrap()\n            .as_deref(),\n        Some(\"true\")\n    );\n    store.set_onboarding_completed(false).unwrap();\n    assert_eq!(\n        store\n            .get_setting(\"onboarding_completed\")\n            .unwrap()\n            .as_deref(),\n        Some(\"false\")\n    );\n}\n\n#[test]\nfn skills_upsert_list_get_delete() {\n    let (_dir, store) = make_store();\n\n    let a = make_skill(\"a\", \"A\", \"/central/a\", 10);\n    let b = make_skill(\"b\", \"B\", \"/central/b\", 20);\n    store.upsert_skill(&a).unwrap();\n    store.upsert_skill(&b).unwrap();\n\n    let listed = store.list_skills().unwrap();\n    assert_eq!(listed.len(), 2);\n    assert_eq!(listed[0].id, \"b\");\n    assert_eq!(listed[1].id, \"a\");\n\n    let got = store.get_skill_by_id(\"a\").unwrap().unwrap();\n    assert_eq!(got.name, \"A\");\n\n    let mut a2 = a.clone();\n    a2.name = \"A2\".to_string();\n    a2.updated_at = 30;\n    store.upsert_skill(&a2).unwrap();\n    assert_eq!(store.get_skill_by_id(\"a\").unwrap().unwrap().name, \"A2\");\n    assert_eq!(store.list_skills().unwrap()[0].id, \"a\");\n\n    store.delete_skill(\"a\").unwrap();\n    assert!(store.get_skill_by_id(\"a\").unwrap().is_none());\n}\n\n#[test]\nfn skill_targets_upsert_unique_constraint_and_list_order() {\n    let (_dir, store) = make_store();\n    let skill = make_skill(\"s1\", \"S1\", \"/central/s1\", 1);\n    store.upsert_skill(&skill).unwrap();\n\n    let t1 = SkillTargetRecord {\n        id: \"t1\".to_string(),\n        skill_id: \"s1\".to_string(),\n        tool: \"cursor\".to_string(),\n        scope: \"global\".to_string(),\n        project_path: None,\n        target_path: \"/target/1\".to_string(),\n        mode: \"copy\".to_string(),\n        status: \"ok\".to_string(),\n        last_error: None,\n        synced_at: None,\n    };\n    store.upsert_skill_target(&t1).unwrap();\n    assert_eq!(\n        store\n            .get_skill_target(\"s1\", \"cursor\", \"global\", None)\n            .unwrap()\n            .unwrap()\n            .target_path,\n        \"/target/1\"\n    );\n\n    let mut t1b = t1.clone();\n    t1b.id = \"t2\".to_string();\n    t1b.target_path = \"/target/2\".to_string();\n    store.upsert_skill_target(&t1b).unwrap();\n    assert_eq!(\n        store\n            .get_skill_target(\"s1\", \"cursor\", \"global\", None)\n            .unwrap()\n            .unwrap()\n            .id,\n        \"t1\",\n        \"unique(skill_id, tool) 冲突时应更新现有行而不是替换 id\"\n    );\n    assert_eq!(\n        store\n            .get_skill_target(\"s1\", \"cursor\", \"global\", None)\n            .unwrap()\n            .unwrap()\n            .target_path,\n        \"/target/2\"\n    );\n\n    let t2 = SkillTargetRecord {\n        id: \"t3\".to_string(),\n        skill_id: \"s1\".to_string(),\n        tool: \"claude_code\".to_string(),\n        scope: \"global\".to_string(),\n        project_path: None,\n        target_path: \"/target/cc\".to_string(),\n        mode: \"copy\".to_string(),\n        status: \"ok\".to_string(),\n        last_error: None,\n        synced_at: None,\n    };\n    store.upsert_skill_target(&t2).unwrap();\n\n    let targets = store.list_skill_targets(\"s1\").unwrap();\n    assert_eq!(targets.len(), 2);\n    assert_eq!(targets[0].tool, \"claude_code\");\n    assert_eq!(targets[1].tool, \"cursor\");\n\n    store\n        .delete_skill_target(\"s1\", \"cursor\", \"global\", None)\n        .unwrap();\n    assert!(store\n        .get_skill_target(\"s1\", \"cursor\", \"global\", None)\n        .unwrap()\n        .is_none());\n}\n\n#[test]\nfn project_targets_coexist_by_project_path_and_delete_precisely() {\n    let (_dir, store) = make_store();\n    let skill = make_skill(\"s1\", \"S1\", \"/central/s1\", 1);\n    store.upsert_skill(&skill).unwrap();\n\n    let global = SkillTargetRecord {\n        id: \"global\".to_string(),\n        skill_id: \"s1\".to_string(),\n        tool: \"cursor\".to_string(),\n        scope: \"global\".to_string(),\n        project_path: None,\n        target_path: \"/global/cursor/s1\".to_string(),\n        mode: \"copy\".to_string(),\n        status: \"ok\".to_string(),\n        last_error: None,\n        synced_at: Some(1),\n    };\n    let project_a = SkillTargetRecord {\n        id: \"project-a\".to_string(),\n        skill_id: \"s1\".to_string(),\n        tool: \"cursor\".to_string(),\n        scope: \"project\".to_string(),\n        project_path: Some(\"/projects/a\".to_string()),\n        target_path: \"/projects/a/.agents/skills/s1\".to_string(),\n        mode: \"symlink\".to_string(),\n        status: \"ok\".to_string(),\n        last_error: None,\n        synced_at: Some(2),\n    };\n    let project_b = SkillTargetRecord {\n        id: \"project-b\".to_string(),\n        skill_id: \"s1\".to_string(),\n        tool: \"cursor\".to_string(),\n        scope: \"project\".to_string(),\n        project_path: Some(\"/projects/b\".to_string()),\n        target_path: \"/projects/b/.agents/skills/s1\".to_string(),\n        mode: \"symlink\".to_string(),\n        status: \"ok\".to_string(),\n        last_error: None,\n        synced_at: Some(3),\n    };\n\n    store.upsert_skill_target(&global).unwrap();\n    store.upsert_skill_target(&project_a).unwrap();\n    store.upsert_skill_target(&project_b).unwrap();\n\n    assert_eq!(store.list_skill_targets(\"s1\").unwrap().len(), 3);\n    assert_eq!(\n        store\n            .get_skill_target(\"s1\", \"cursor\", \"global\", None)\n            .unwrap()\n            .unwrap()\n            .target_path,\n        \"/global/cursor/s1\"\n    );\n    assert_eq!(\n        store\n            .get_skill_target(\"s1\", \"cursor\", \"project\", Some(\"/projects/a\"))\n            .unwrap()\n            .unwrap()\n            .target_path,\n        \"/projects/a/.agents/skills/s1\"\n    );\n    assert_eq!(\n        store\n            .get_skill_target(\"s1\", \"cursor\", \"project\", Some(\"/projects/b\"))\n            .unwrap()\n            .unwrap()\n            .target_path,\n        \"/projects/b/.agents/skills/s1\"\n    );\n\n    let mut updated_project_a = project_a.clone();\n    updated_project_a.id = \"project-a-new-id\".to_string();\n    updated_project_a.target_path = \"/projects/a/.agents/skills/s1-updated\".to_string();\n    store.upsert_skill_target(&updated_project_a).unwrap();\n\n    let got_project_a = store\n        .get_skill_target(\"s1\", \"cursor\", \"project\", Some(\"/projects/a\"))\n        .unwrap()\n        .unwrap();\n    assert_eq!(got_project_a.id, \"project-a\");\n    assert_eq!(\n        got_project_a.target_path,\n        \"/projects/a/.agents/skills/s1-updated\"\n    );\n    assert_eq!(store.list_skill_targets(\"s1\").unwrap().len(), 3);\n\n    store\n        .delete_skill_target(\"s1\", \"cursor\", \"project\", Some(\"/projects/a\"))\n        .unwrap();\n\n    assert!(store\n        .get_skill_target(\"s1\", \"cursor\", \"project\", Some(\"/projects/a\"))\n        .unwrap()\n        .is_none());\n    assert!(store\n        .get_skill_target(\"s1\", \"cursor\", \"project\", Some(\"/projects/b\"))\n        .unwrap()\n        .is_some());\n    assert!(store\n        .get_skill_target(\"s1\", \"cursor\", \"global\", None)\n        .unwrap()\n        .is_some());\n}\n\n#[test]\nfn deleting_skill_cascades_targets() {\n    let (_dir, store) = make_store();\n    let skill = make_skill(\"s1\", \"S1\", \"/central/s1\", 1);\n    store.upsert_skill(&skill).unwrap();\n\n    let t = SkillTargetRecord {\n        id: \"t1\".to_string(),\n        skill_id: \"s1\".to_string(),\n        tool: \"cursor\".to_string(),\n        scope: \"global\".to_string(),\n        project_path: None,\n        target_path: \"/target/1\".to_string(),\n        mode: \"copy\".to_string(),\n        status: \"ok\".to_string(),\n        last_error: None,\n        synced_at: None,\n    };\n    store.upsert_skill_target(&t).unwrap();\n    assert_eq!(store.list_skill_targets(\"s1\").unwrap().len(), 1);\n\n    store.delete_skill(\"s1\").unwrap();\n    assert_eq!(store.list_skill_targets(\"s1\").unwrap().len(), 0);\n}\n\n#[test]\nfn tags_can_be_created_renamed_linked_and_deleted() {\n    let (_dir, store) = make_store();\n    let skill = make_skill(\"s1\", \"S1\", \"/central/s1\", 1);\n    store.upsert_skill(&skill).unwrap();\n\n    let frontend = store.create_tag(\" Frontend \").unwrap();\n    assert_eq!(frontend.name, \"Frontend\");\n    assert!(store.create_tag(\"frontend\").is_err());\n\n    let docs = store.create_tag(\"Docs\").unwrap();\n    store.set_skill_tags(\"s1\", &[frontend.id, docs.id]).unwrap();\n    store\n        .set_skill_tags(\"s1\", &[frontend.id, frontend.id, docs.id])\n        .unwrap();\n\n    let linked = store.get_skill_tags(\"s1\").unwrap();\n    assert_eq!(linked.len(), 2);\n    assert_eq!(linked[0].name, \"Docs\");\n    assert_eq!(linked[1].name, \"Frontend\");\n\n    let renamed = store.rename_tag(frontend.id, \"UI\").unwrap();\n    assert_eq!(renamed.name, \"UI\");\n    assert!(store.rename_tag(renamed.id, \"docs\").is_err());\n\n    let tags = store.list_tags_with_counts().unwrap();\n    assert_eq!(tags.len(), 2);\n    assert_eq!(tags[0].name, \"Docs\");\n    assert_eq!(tags[0].skill_count, 1);\n    assert_eq!(tags[1].name, \"UI\");\n    assert_eq!(tags[1].skill_count, 1);\n\n    store.delete_tag(docs.id).unwrap();\n    let linked = store.get_skill_tags(\"s1\").unwrap();\n    assert_eq!(linked.len(), 1);\n    assert_eq!(linked[0].name, \"UI\");\n}\n\n#[test]\nfn tag_links_are_removed_when_skill_is_deleted_and_untagged_is_counted() {\n    let (_dir, store) = make_store();\n    let tagged = make_skill(\"tagged\", \"Tagged\", \"/central/tagged\", 2);\n    let untagged = make_skill(\"untagged\", \"Untagged\", \"/central/untagged\", 1);\n    store.upsert_skill(&tagged).unwrap();\n    store.upsert_skill(&untagged).unwrap();\n\n    let tag = store.create_tag(\"Frontend\").unwrap();\n    store.set_skill_tags(\"tagged\", &[tag.id]).unwrap();\n\n    assert_eq!(store.list_untagged_skill_ids().unwrap(), vec![\"untagged\"]);\n\n    store.delete_skill(\"tagged\").unwrap();\n    assert!(store.get_skill_tags(\"tagged\").unwrap().is_empty());\n    assert_eq!(store.list_tags_with_counts().unwrap()[0].skill_count, 0);\n}\n\n#[test]\nfn description_stored_and_retrieved() {\n    let (_dir, store) = make_store();\n    let mut skill = make_skill(\"d1\", \"D1\", \"/central/d1\", 1);\n    skill.description = Some(\"A test skill description\".to_string());\n    store.upsert_skill(&skill).unwrap();\n\n    let got = store.get_skill_by_id(\"d1\").unwrap().unwrap();\n    assert_eq!(got.description.as_deref(), Some(\"A test skill description\"));\n}\n\n#[test]\nfn description_null_by_default() {\n    let (_dir, store) = make_store();\n    let skill = make_skill(\"d2\", \"D2\", \"/central/d2\", 1);\n    store.upsert_skill(&skill).unwrap();\n\n    let got = store.get_skill_by_id(\"d2\").unwrap().unwrap();\n    assert!(got.description.is_none());\n}\n\n#[test]\nfn update_skill_description_backfills() {\n    let (_dir, store) = make_store();\n    let skill = make_skill(\"d3\", \"D3\", \"/central/d3\", 1);\n    store.upsert_skill(&skill).unwrap();\n\n    assert!(store\n        .get_skill_by_id(\"d3\")\n        .unwrap()\n        .unwrap()\n        .description\n        .is_none());\n\n    store\n        .update_skill_description(\"d3\", Some(\"backfilled\"))\n        .unwrap();\n    assert_eq!(\n        store\n            .get_skill_by_id(\"d3\")\n            .unwrap()\n            .unwrap()\n            .description\n            .as_deref(),\n        Some(\"backfilled\")\n    );\n}\n\n#[test]\nfn error_context_includes_db_path() {\n    let store = SkillStore::new(PathBuf::from(\"/this/path/should/not/exist/test.db\"));\n    let err = store.ensure_schema().unwrap_err();\n    let msg = format!(\"{:#}\", err);\n    assert!(msg.contains(\"failed to open db at\"), \"{msg}\");\n}\n"
  },
  {
    "path": "src-tauri/src/core/tests/skills_search.rs",
    "content": "use mockito::Matcher;\n\nuse super::search_skills_online_inner;\n\nfn json_response() -> String {\n    r#\"{\n  \"skills\": [\n    {\n      \"name\": \"react-expert\",\n      \"installs\": 203000,\n      \"source\": \"vercel-labs/agent-skills\"\n    },\n    {\n      \"name\": \"vue-master\",\n      \"installs\": 57000,\n      \"source\": \"vuejs/vue-skills\"\n    }\n  ],\n  \"count\": 2\n}\"#\n    .to_string()\n}\n\nfn json_empty() -> String {\n    r#\"{\"skills\": [], \"count\": 0}\"#.to_string()\n}\n\n#[test]\nfn parses_search_results() {\n    let mut server = mockito::Server::new();\n    let _m = server\n        .mock(\"GET\", \"/api/search\")\n        .match_query(Matcher::AllOf(vec![\n            Matcher::UrlEncoded(\"q\".into(), \"react\".into()),\n            Matcher::UrlEncoded(\"limit\".into(), \"20\".into()),\n        ]))\n        .with_status(200)\n        .with_header(\"content-type\", \"application/json\")\n        .with_body(json_response())\n        .create();\n\n    let out = search_skills_online_inner(&server.url(), \"react\", 20).unwrap();\n    assert_eq!(out.len(), 2);\n    assert_eq!(out[0].name, \"react-expert\");\n    assert_eq!(out[0].installs, 203000);\n    assert_eq!(out[0].source, \"vercel-labs/agent-skills\");\n    assert_eq!(\n        out[0].source_url,\n        \"https://github.com/vercel-labs/agent-skills\"\n    );\n}\n\n#[test]\nfn source_url_is_constructed_from_source() {\n    let mut server = mockito::Server::new();\n    let _m = server\n        .mock(\"GET\", \"/api/search\")\n        .match_query(Matcher::AllOf(vec![\n            Matcher::UrlEncoded(\"q\".into(), \"vue\".into()),\n            Matcher::UrlEncoded(\"limit\".into(), \"5\".into()),\n        ]))\n        .with_status(200)\n        .with_header(\"content-type\", \"application/json\")\n        .with_body(json_response())\n        .create();\n\n    let out = search_skills_online_inner(&server.url(), \"vue\", 5).unwrap();\n    assert_eq!(out[1].source_url, \"https://github.com/vuejs/vue-skills\");\n}\n\n#[test]\nfn http_error_returns_error() {\n    let mut server = mockito::Server::new();\n    let _m = server\n        .mock(\"GET\", \"/api/search\")\n        .with_status(500)\n        .with_body(\"internal error\")\n        .create();\n\n    let err = search_skills_online_inner(&server.url(), \"test\", 10).unwrap_err();\n    let msg = format!(\"{:#}\", err);\n    assert!(msg.contains(\"skills.sh search returned error\"), \"{}\", msg);\n}\n\n#[test]\nfn empty_results() {\n    let mut server = mockito::Server::new();\n    let _m = server\n        .mock(\"GET\", \"/api/search\")\n        .match_query(Matcher::AllOf(vec![\n            Matcher::UrlEncoded(\"q\".into(), \"nonexistent\".into()),\n            Matcher::UrlEncoded(\"limit\".into(), \"10\".into()),\n        ]))\n        .with_status(200)\n        .with_header(\"content-type\", \"application/json\")\n        .with_body(json_empty())\n        .create();\n\n    let out = search_skills_online_inner(&server.url(), \"nonexistent\", 10).unwrap();\n    assert!(out.is_empty());\n}\n"
  },
  {
    "path": "src-tauri/src/core/tests/sync_engine.rs",
    "content": "use std::fs;\n\nuse crate::core::sync_engine::{\n    copy_dir_recursive, sync_dir_for_tool_with_overwrite, sync_dir_hybrid,\n    sync_dir_hybrid_with_overwrite, SyncMode,\n};\n\n#[test]\nfn copy_dir_recursive_skips_git_dir() {\n    let src_dir = tempfile::tempdir().unwrap();\n    let dst_dir = tempfile::tempdir().unwrap();\n\n    fs::create_dir_all(src_dir.path().join(\".git\")).unwrap();\n    fs::create_dir_all(src_dir.path().join(\"sub\")).unwrap();\n    fs::write(src_dir.path().join(\"sub/a.txt\"), b\"ok\").unwrap();\n    fs::write(src_dir.path().join(\".git/secret\"), b\"no\").unwrap();\n\n    copy_dir_recursive(src_dir.path(), dst_dir.path()).unwrap();\n    assert!(dst_dir.path().join(\"sub/a.txt\").exists());\n    assert!(!dst_dir.path().join(\".git\").exists());\n}\n\n#[test]\nfn hybrid_sync_creates_link_and_is_idempotent_when_same_link() {\n    let src_dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(src_dir.path().join(\"s\")).unwrap();\n    fs::write(src_dir.path().join(\"s/a.txt\"), b\"ok\").unwrap();\n\n    let dst_dir = tempfile::tempdir().unwrap();\n    let target = dst_dir.path().join(\"t\");\n\n    let out = sync_dir_hybrid(src_dir.path(), &target).unwrap();\n    assert!(matches!(\n        out.mode_used,\n        SyncMode::Symlink | SyncMode::Junction | SyncMode::Copy\n    ));\n\n    if let Ok(link) = fs::read_link(&target) {\n        assert_eq!(link, src_dir.path());\n        let out2 = sync_dir_hybrid(src_dir.path(), &target).unwrap();\n        assert!(matches!(out2.mode_used, SyncMode::Symlink));\n    }\n}\n\n#[test]\nfn hybrid_sync_with_overwrite_replaces_existing() {\n    let src_dir = tempfile::tempdir().unwrap();\n    fs::write(src_dir.path().join(\"a.txt\"), b\"src\").unwrap();\n\n    let dst_dir = tempfile::tempdir().unwrap();\n    let target = dst_dir.path().join(\"t\");\n    fs::create_dir_all(&target).unwrap();\n    fs::write(target.join(\"old.txt\"), b\"old\").unwrap();\n\n    let err = sync_dir_hybrid_with_overwrite(src_dir.path(), &target, false).unwrap_err();\n    assert!(format!(\"{:#}\", err).contains(\"target already exists\"));\n\n    let out = sync_dir_hybrid_with_overwrite(src_dir.path(), &target, true).unwrap();\n    assert!(out.replaced);\n}\n\n#[test]\nfn cursor_sync_forces_copy() {\n    let src_dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(src_dir.path().join(\"s\")).unwrap();\n    fs::write(src_dir.path().join(\"s/a.txt\"), b\"ok\").unwrap();\n\n    let dst_dir = tempfile::tempdir().unwrap();\n    let target = dst_dir.path().join(\"t\");\n\n    let out = sync_dir_for_tool_with_overwrite(\"cursor\", src_dir.path(), &target, false).unwrap();\n    assert!(matches!(out.mode_used, SyncMode::Copy));\n    assert!(target.join(\"s/a.txt\").exists());\n    assert_eq!(fs::read(target.join(\"s/a.txt\")).unwrap(), b\"ok\");\n}\n\n#[cfg(unix)]\n#[test]\nfn copy_overwrite_replaces_broken_symlink_target() {\n    use std::os::unix::fs::symlink;\n\n    let src_dir = tempfile::tempdir().unwrap();\n    fs::create_dir_all(src_dir.path().join(\"s\")).unwrap();\n    fs::write(src_dir.path().join(\"s/a.txt\"), b\"ok\").unwrap();\n\n    let dst_dir = tempfile::tempdir().unwrap();\n    let target = dst_dir.path().join(\"t\");\n\n    // Create a broken symlink at the target path.\n    symlink(dst_dir.path().join(\"missing\"), &target).unwrap();\n\n    let out = crate::core::sync_engine::sync_dir_copy_with_overwrite(src_dir.path(), &target, true)\n        .unwrap();\n\n    assert!(matches!(out.mode_used, SyncMode::Copy));\n    assert!(target.join(\"s/a.txt\").exists());\n    assert_eq!(fs::read(target.join(\"s/a.txt\")).unwrap(), b\"ok\");\n}\n"
  },
  {
    "path": "src-tauri/src/core/tests/temp_cleanup.rs",
    "content": "use std::fs;\nuse std::time::Duration;\n\nuse super::{cleanup_old_git_temp_dirs_in, mark_temp_dir};\n\n#[test]\nfn cleanup_removes_only_marked_prefixed_dirs() {\n    let dir = tempfile::tempdir().unwrap();\n    let cache = dir.path();\n\n    let d1 = cache.join(\"skills-hub-git-1\");\n    let d2 = cache.join(\"skills-hub-git-2\");\n    let d3 = cache.join(\"other-3\");\n\n    fs::create_dir_all(&d1).unwrap();\n    fs::create_dir_all(&d2).unwrap();\n    fs::create_dir_all(&d3).unwrap();\n\n    mark_temp_dir(&d1).unwrap();\n    mark_temp_dir(&d3).unwrap();\n\n    let removed = cleanup_old_git_temp_dirs_in(cache, Duration::from_secs(0)).unwrap();\n    assert_eq!(removed, 1);\n    assert!(!d1.exists());\n    assert!(d2.exists(), \"未标记的不应删除\");\n    assert!(d3.exists(), \"前缀不匹配的不应删除\");\n}\n"
  },
  {
    "path": "src-tauri/src/core/tests/tool_adapters.rs",
    "content": "use std::fs;\n\nuse crate::core::tool_adapters::{\n    adapter_by_key, adapters_sharing_project_skills_dir, adapters_sharing_skills_dir,\n    project_relative_skills_dir, resolve_project_path, scan_tool_dir, supports_project_scope,\n    ToolAdapter, ToolId,\n};\n\n#[test]\nfn adapter_by_key_finds_known_tool() {\n    let a = adapter_by_key(\"codex\").unwrap();\n    assert_eq!(a.id, ToolId::Codex);\n}\n\n#[test]\nfn adapter_by_key_finds_new_tools() {\n    assert!(adapter_by_key(\"kimi_cli\").is_some());\n    assert!(adapter_by_key(\"augment\").is_some());\n    assert!(adapter_by_key(\"openclaw\").is_some());\n    assert!(adapter_by_key(\"command_code\").is_some());\n    assert!(adapter_by_key(\"qwen_code\").is_some());\n    assert!(adapter_by_key(\"hermes_agent\").is_some());\n}\n\n#[test]\nfn adapters_sharing_skills_dir_groups_amp_and_kimi() {\n    let amp = adapter_by_key(\"amp\").unwrap();\n    let group = adapters_sharing_skills_dir(&amp);\n    let keys: std::collections::HashSet<&'static str> =\n        group.into_iter().map(|a| a.id.as_key()).collect();\n    assert!(keys.contains(\"amp\"));\n    assert!(keys.contains(\"kimi_cli\"));\n}\n\n#[test]\nfn project_relative_skills_dir_maps_supported_agents() {\n    let shared_agents = [\n        (\"cursor\", \".agents/skills\"),\n        (\"codex\", \".agents/skills\"),\n        (\"opencode\", \".agents/skills\"),\n        (\"gemini_cli\", \".agents/skills\"),\n        (\"github_copilot\", \".agents/skills\"),\n        (\"amp\", \".agents/skills\"),\n        (\"kimi_cli\", \".agents/skills\"),\n        (\"antigravity\", \".agents/skills\"),\n        (\"cline\", \".agents/skills\"),\n    ];\n\n    for (key, expected) in shared_agents {\n        let adapter = adapter_by_key(key).unwrap();\n        assert_eq!(project_relative_skills_dir(&adapter), expected, \"{key}\");\n        assert!(supports_project_scope(&adapter), \"{key}\");\n    }\n\n    let claude = adapter_by_key(\"claude_code\").unwrap();\n    assert_eq!(project_relative_skills_dir(&claude), \".claude/skills\");\n\n    let openclaw = adapter_by_key(\"openclaw\").unwrap();\n    assert_eq!(project_relative_skills_dir(&openclaw), \"skills\");\n\n    let windsurf = adapter_by_key(\"windsurf\").unwrap();\n    assert_eq!(project_relative_skills_dir(&windsurf), \".windsurf/skills\");\n\n    let qwen = adapter_by_key(\"qwen_code\").unwrap();\n    assert_eq!(project_relative_skills_dir(&qwen), \".qwen/skills\");\n\n    let hermes = adapter_by_key(\"hermes_agent\").unwrap();\n    assert_eq!(project_relative_skills_dir(&hermes), \".hermes/skills\");\n    assert!(!supports_project_scope(&hermes));\n}\n\n#[test]\nfn project_path_resolution_uses_project_specific_mapping() {\n    let dir = tempfile::tempdir().unwrap();\n    let amp = adapter_by_key(\"amp\").unwrap();\n    let opencode = adapter_by_key(\"opencode\").unwrap();\n    let openclaw = adapter_by_key(\"openclaw\").unwrap();\n\n    assert_eq!(\n        resolve_project_path(&amp, dir.path()).unwrap(),\n        dir.path().join(\".agents/skills\")\n    );\n    assert_eq!(\n        resolve_project_path(&opencode, dir.path()).unwrap(),\n        dir.path().join(\".agents/skills\")\n    );\n    assert_eq!(\n        resolve_project_path(&openclaw, dir.path()).unwrap(),\n        dir.path().join(\"skills\")\n    );\n}\n\n#[test]\nfn adapters_sharing_project_skills_dir_groups_agents_tools() {\n    let cursor = adapter_by_key(\"cursor\").unwrap();\n    let group = adapters_sharing_project_skills_dir(&cursor);\n    let keys: std::collections::HashSet<&'static str> =\n        group.into_iter().map(|a| a.id.as_key()).collect();\n\n    assert!(keys.contains(\"cursor\"));\n    assert!(keys.contains(\"codex\"));\n    assert!(keys.contains(\"opencode\"));\n    assert!(keys.contains(\"gemini_cli\"));\n    assert!(keys.contains(\"github_copilot\"));\n    assert!(keys.contains(\"amp\"));\n    assert!(keys.contains(\"kimi_cli\"));\n    assert!(keys.contains(\"antigravity\"));\n    assert!(keys.contains(\"cline\"));\n    assert!(!keys.contains(\"claude_code\"));\n    assert!(!keys.contains(\"windsurf\"));\n}\n\n#[test]\nfn scan_tool_dir_skips_codex_system_and_includes_symlink_dir() {\n    let dir = tempfile::tempdir().unwrap();\n\n    fs::create_dir_all(dir.path().join(\"a\")).unwrap();\n    fs::create_dir_all(dir.path().join(\".system\")).unwrap();\n    fs::write(dir.path().join(\"not-a-dir\"), b\"x\").unwrap();\n\n    #[cfg(unix)]\n    {\n        std::os::unix::fs::symlink(dir.path().join(\"a\"), dir.path().join(\"link-a\")).unwrap();\n    }\n\n    let tool = ToolAdapter {\n        id: ToolId::Codex,\n        display_name: \"Codex\",\n        relative_skills_dir: \"ignored\",\n        relative_detect_dir: \"ignored\",\n    };\n\n    let out = scan_tool_dir(&tool, dir.path()).unwrap();\n    let names: Vec<String> = out.iter().map(|s| s.name.clone()).collect();\n\n    assert!(names.contains(&\"a\".to_string()));\n    assert!(!names.contains(&\".system\".to_string()));\n\n    #[cfg(unix)]\n    {\n        let link = out.iter().find(|s| s.name == \"link-a\").unwrap();\n        assert!(link.is_link);\n        assert!(link.link_target.is_some());\n    }\n}\n\n#[test]\nfn scan_tool_dir_skips_app_support_path() {\n    let dir = tempfile::tempdir().unwrap();\n    let root = dir\n        .path()\n        .join(\"Library/Application Support/com.tauri.dev/skills\");\n    std::fs::create_dir_all(root.join(\"foo\")).unwrap();\n\n    let tool = ToolAdapter {\n        id: ToolId::Cursor,\n        display_name: \"Cursor\",\n        relative_skills_dir: \"ignored\",\n        relative_detect_dir: \"ignored\",\n    };\n\n    let out = scan_tool_dir(&tool, &root).unwrap();\n    assert!(out.is_empty());\n}\n"
  },
  {
    "path": "src-tauri/src/core/tool_adapters/mod.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub enum ToolId {\n    Cursor,\n    ClaudeCode,\n    Codex,\n    OpenCode,\n    Antigravity,\n    Amp,\n    KimiCli,\n    Augment,\n    OpenClaw,\n    Copaw,\n    Cline,\n    CodeBuddy,\n    CommandCode,\n    Continue,\n    Crush,\n    Junie,\n    IflowCli,\n    KiroCli,\n    Kode,\n    McpJam,\n    MistralVibe,\n    Mux,\n    OpenClaude,\n    OpenHands,\n    Pi,\n    Qoder,\n    QoderWork,\n    QwenCode,\n    Trae,\n    TraeCn,\n    Zencoder,\n    Neovate,\n    Pochi,\n    AdaL,\n    KiloCode,\n    RooCode,\n    Goose,\n    GeminiCli,\n    GithubCopilot,\n    Clawdbot,\n    Droid,\n    Windsurf,\n    Moltbot,\n    HermesAgent,\n}\n\nimpl ToolId {\n    pub fn as_key(&self) -> &'static str {\n        match self {\n            ToolId::Cursor => \"cursor\",\n            ToolId::ClaudeCode => \"claude_code\",\n            ToolId::Codex => \"codex\",\n            ToolId::OpenCode => \"opencode\",\n            ToolId::Antigravity => \"antigravity\",\n            ToolId::Amp => \"amp\",\n            ToolId::KimiCli => \"kimi_cli\",\n            ToolId::Augment => \"augment\",\n            ToolId::OpenClaw => \"openclaw\",\n            ToolId::Copaw => \"copaw\",\n            ToolId::Cline => \"cline\",\n            ToolId::CodeBuddy => \"codebuddy\",\n            ToolId::CommandCode => \"command_code\",\n            ToolId::Continue => \"continue\",\n            ToolId::Crush => \"crush\",\n            ToolId::Junie => \"junie\",\n            ToolId::IflowCli => \"iflow_cli\",\n            ToolId::KiroCli => \"kiro_cli\",\n            ToolId::Kode => \"kode\",\n            ToolId::McpJam => \"mcpjam\",\n            ToolId::MistralVibe => \"mistral_vibe\",\n            ToolId::Mux => \"mux\",\n            ToolId::OpenClaude => \"openclaude\",\n            ToolId::OpenHands => \"openhands\",\n            ToolId::Pi => \"pi\",\n            ToolId::Qoder => \"qoder\",\n            ToolId::QoderWork => \"qoderwork\",\n            ToolId::QwenCode => \"qwen_code\",\n            ToolId::Trae => \"trae\",\n            ToolId::TraeCn => \"trae_cn\",\n            ToolId::Zencoder => \"zencoder\",\n            ToolId::Neovate => \"neovate\",\n            ToolId::Pochi => \"pochi\",\n            ToolId::AdaL => \"adal\",\n            ToolId::KiloCode => \"kilo_code\",\n            ToolId::RooCode => \"roo_code\",\n            ToolId::Goose => \"goose\",\n            ToolId::GeminiCli => \"gemini_cli\",\n            ToolId::GithubCopilot => \"github_copilot\",\n            ToolId::Clawdbot => \"clawdbot\",\n            ToolId::Droid => \"droid\",\n            ToolId::Windsurf => \"windsurf\",\n            ToolId::Moltbot => \"moltbot\",\n            ToolId::HermesAgent => \"hermes_agent\",\n        }\n    }\n}\n\n#[derive(Clone, Debug)]\npub struct ToolAdapter {\n    pub id: ToolId,\n    pub display_name: &'static str,\n    /// Global skill directory under user home (aligned with add-skill docs).\n    pub relative_skills_dir: &'static str,\n    /// Directory used to detect whether the tool is installed (aligned with add-skill docs).\n    pub relative_detect_dir: &'static str,\n}\n\n#[derive(Clone, Debug)]\npub struct DetectedSkill {\n    pub tool: ToolId,\n    pub name: String,\n    pub path: PathBuf,\n    pub is_link: bool,\n    pub link_target: Option<PathBuf>,\n}\n\npub fn default_tool_adapters() -> Vec<ToolAdapter> {\n    vec![\n        ToolAdapter {\n            id: ToolId::Cursor,\n            display_name: \"Cursor\",\n            relative_skills_dir: \".cursor/skills\",\n            relative_detect_dir: \".cursor\",\n        },\n        ToolAdapter {\n            id: ToolId::ClaudeCode,\n            display_name: \"Claude Code\",\n            relative_skills_dir: \".claude/skills\",\n            relative_detect_dir: \".claude\",\n        },\n        ToolAdapter {\n            id: ToolId::Codex,\n            display_name: \"Codex\",\n            relative_skills_dir: \".codex/skills\",\n            relative_detect_dir: \".codex\",\n        },\n        ToolAdapter {\n            id: ToolId::OpenCode,\n            display_name: \"OpenCode\",\n            // add-skill global path: ~/.config/opencode/skills/\n            relative_skills_dir: \".config/opencode/skills\",\n            relative_detect_dir: \".config/opencode\",\n        },\n        ToolAdapter {\n            id: ToolId::Antigravity,\n            display_name: \"Antigravity\",\n            // add-skill global path: ~/.gemini/antigravity/skills/\n            relative_skills_dir: \".gemini/antigravity/skills\",\n            relative_detect_dir: \".gemini/antigravity\",\n        },\n        ToolAdapter {\n            id: ToolId::Amp,\n            display_name: \"Amp\",\n            // add-skill global path: ~/.config/agents/skills/\n            relative_skills_dir: \".config/agents/skills\",\n            relative_detect_dir: \".config/agents\",\n        },\n        ToolAdapter {\n            id: ToolId::KimiCli,\n            display_name: \"Kimi Code CLI\",\n            // add-skill global path: ~/.config/agents/skills/\n            // NOTE: Shares the same skills directory with Amp.\n            relative_skills_dir: \".config/agents/skills\",\n            relative_detect_dir: \".config/agents\",\n        },\n        ToolAdapter {\n            id: ToolId::Augment,\n            display_name: \"Augment\",\n            // add-skill global path: ~/.augment/skills/\n            relative_skills_dir: \".augment/skills\",\n            relative_detect_dir: \".augment\",\n        },\n        ToolAdapter {\n            id: ToolId::OpenClaw,\n            display_name: \"OpenClaw\",\n            // add-skill global path: ~/.openclaw/skills/\n            relative_skills_dir: \".openclaw/skills\",\n            relative_detect_dir: \".openclaw\",\n        },\n        ToolAdapter {\n            id: ToolId::Copaw,\n            display_name: \"Copaw\",\n            // add-skill global path: ~/.copaw/skill_pool/\n            relative_skills_dir: \".copaw/skill_pool\",\n            relative_detect_dir: \".copaw\",\n        },\n        ToolAdapter {\n            id: ToolId::Cline,\n            display_name: \"Cline\",\n            // add-skill global path: ~/.agents/skills/\n            relative_skills_dir: \".agents/skills\",\n            relative_detect_dir: \".agents\",\n        },\n        ToolAdapter {\n            id: ToolId::CodeBuddy,\n            display_name: \"CodeBuddy\",\n            // add-skill global path: ~/.codebuddy/skills/\n            relative_skills_dir: \".codebuddy/skills\",\n            relative_detect_dir: \".codebuddy\",\n        },\n        ToolAdapter {\n            id: ToolId::CommandCode,\n            display_name: \"Command Code\",\n            // add-skill global path: ~/.commandcode/skills/\n            relative_skills_dir: \".commandcode/skills\",\n            relative_detect_dir: \".commandcode\",\n        },\n        ToolAdapter {\n            id: ToolId::Continue,\n            display_name: \"Continue\",\n            // add-skill global path: ~/.continue/skills/\n            relative_skills_dir: \".continue/skills\",\n            relative_detect_dir: \".continue\",\n        },\n        ToolAdapter {\n            id: ToolId::Crush,\n            display_name: \"Crush\",\n            // add-skill global path: ~/.config/crush/skills/\n            relative_skills_dir: \".config/crush/skills\",\n            relative_detect_dir: \".config/crush\",\n        },\n        ToolAdapter {\n            id: ToolId::Junie,\n            display_name: \"Junie\",\n            // add-skill global path: ~/.junie/skills/\n            relative_skills_dir: \".junie/skills\",\n            relative_detect_dir: \".junie\",\n        },\n        ToolAdapter {\n            id: ToolId::IflowCli,\n            display_name: \"iFlow CLI\",\n            // add-skill global path: ~/.iflow/skills/\n            relative_skills_dir: \".iflow/skills\",\n            relative_detect_dir: \".iflow\",\n        },\n        ToolAdapter {\n            id: ToolId::KiroCli,\n            display_name: \"Kiro CLI\",\n            // add-skill global path: ~/.kiro/skills/\n            relative_skills_dir: \".kiro/skills\",\n            relative_detect_dir: \".kiro\",\n        },\n        ToolAdapter {\n            id: ToolId::Kode,\n            display_name: \"Kode\",\n            // add-skill global path: ~/.kode/skills/\n            relative_skills_dir: \".kode/skills\",\n            relative_detect_dir: \".kode\",\n        },\n        ToolAdapter {\n            id: ToolId::McpJam,\n            display_name: \"MCPJam\",\n            // add-skill global path: ~/.mcpjam/skills/\n            relative_skills_dir: \".mcpjam/skills\",\n            relative_detect_dir: \".mcpjam\",\n        },\n        ToolAdapter {\n            id: ToolId::MistralVibe,\n            display_name: \"Mistral Vibe\",\n            // add-skill global path: ~/.vibe/skills/\n            relative_skills_dir: \".vibe/skills\",\n            relative_detect_dir: \".vibe\",\n        },\n        ToolAdapter {\n            id: ToolId::Mux,\n            display_name: \"Mux\",\n            // add-skill global path: ~/.mux/skills/\n            relative_skills_dir: \".mux/skills\",\n            relative_detect_dir: \".mux\",\n        },\n        ToolAdapter {\n            id: ToolId::OpenClaude,\n            display_name: \"OpenClaude IDE\",\n            // add-skill global path: ~/.openclaude/skills/\n            relative_skills_dir: \".openclaude/skills\",\n            relative_detect_dir: \".openclaude\",\n        },\n        ToolAdapter {\n            id: ToolId::OpenHands,\n            display_name: \"OpenHands\",\n            // add-skill global path: ~/.openhands/skills/\n            relative_skills_dir: \".openhands/skills\",\n            relative_detect_dir: \".openhands\",\n        },\n        ToolAdapter {\n            id: ToolId::Pi,\n            display_name: \"Pi\",\n            // add-skill global path: ~/.pi/agent/skills/\n            relative_skills_dir: \".pi/agent/skills\",\n            relative_detect_dir: \".pi\",\n        },\n        ToolAdapter {\n            id: ToolId::Qoder,\n            display_name: \"Qoder\",\n            // add-skill global path: ~/.qoder/skills/\n            relative_skills_dir: \".qoder/skills\",\n            relative_detect_dir: \".qoder\",\n        },\n        ToolAdapter {\n            id: ToolId::QoderWork,\n            display_name: \"QoderWork\",\n            // add-skill global path: ~/.qoderwork/skills/\n            relative_skills_dir: \".qoderwork/skills\",\n            relative_detect_dir: \".qoderwork\",\n        },\n        ToolAdapter {\n            id: ToolId::QwenCode,\n            display_name: \"Qwen Code\",\n            // add-skill global path: ~/.qwen/skills/\n            relative_skills_dir: \".qwen/skills\",\n            relative_detect_dir: \".qwen\",\n        },\n        ToolAdapter {\n            id: ToolId::Trae,\n            display_name: \"Trae\",\n            // add-skill global path: ~/.trae/skills/\n            relative_skills_dir: \".trae/skills\",\n            relative_detect_dir: \".trae\",\n        },\n        ToolAdapter {\n            id: ToolId::TraeCn,\n            display_name: \"Trae CN\",\n            // add-skill global path: ~/.trae-cn/skills/\n            relative_skills_dir: \".trae-cn/skills\",\n            relative_detect_dir: \".trae-cn\",\n        },\n        ToolAdapter {\n            id: ToolId::Zencoder,\n            display_name: \"Zencoder\",\n            // add-skill global path: ~/.zencoder/skills/\n            relative_skills_dir: \".zencoder/skills\",\n            relative_detect_dir: \".zencoder\",\n        },\n        ToolAdapter {\n            id: ToolId::Neovate,\n            display_name: \"Neovate\",\n            // add-skill global path: ~/.neovate/skills/\n            relative_skills_dir: \".neovate/skills\",\n            relative_detect_dir: \".neovate\",\n        },\n        ToolAdapter {\n            id: ToolId::Pochi,\n            display_name: \"Pochi\",\n            // add-skill global path: ~/.pochi/skills/\n            relative_skills_dir: \".pochi/skills\",\n            relative_detect_dir: \".pochi\",\n        },\n        ToolAdapter {\n            id: ToolId::AdaL,\n            display_name: \"AdaL\",\n            // add-skill global path: ~/.adal/skills/\n            relative_skills_dir: \".adal/skills\",\n            relative_detect_dir: \".adal\",\n        },\n        ToolAdapter {\n            id: ToolId::KiloCode,\n            display_name: \"Kilo Code\",\n            // add-skill global path: ~/.kilocode/skills/\n            relative_skills_dir: \".kilocode/skills\",\n            relative_detect_dir: \".kilocode\",\n        },\n        ToolAdapter {\n            id: ToolId::RooCode,\n            display_name: \"Roo Code\",\n            // add-skill global path: ~/.roo/skills/\n            relative_skills_dir: \".roo/skills\",\n            relative_detect_dir: \".roo\",\n        },\n        ToolAdapter {\n            id: ToolId::Goose,\n            display_name: \"Goose\",\n            // add-skill global path: ~/.config/goose/skills/\n            relative_skills_dir: \".config/goose/skills\",\n            relative_detect_dir: \".config/goose\",\n        },\n        ToolAdapter {\n            id: ToolId::GeminiCli,\n            display_name: \"Gemini CLI\",\n            // add-skill global path: ~/.gemini/skills/\n            relative_skills_dir: \".gemini/skills\",\n            relative_detect_dir: \".gemini\",\n        },\n        ToolAdapter {\n            id: ToolId::GithubCopilot,\n            display_name: \"GitHub Copilot\",\n            // add-skill global path: ~/.copilot/skills/\n            relative_skills_dir: \".copilot/skills\",\n            relative_detect_dir: \".copilot\",\n        },\n        ToolAdapter {\n            id: ToolId::Clawdbot,\n            display_name: \"Clawdbot\",\n            // add-skill global path: ~/.clawdbot/skills/\n            relative_skills_dir: \".clawdbot/skills\",\n            relative_detect_dir: \".clawdbot\",\n        },\n        ToolAdapter {\n            id: ToolId::Droid,\n            display_name: \"Droid\",\n            // add-skill global path: ~/.factory/skills/\n            relative_skills_dir: \".factory/skills\",\n            relative_detect_dir: \".factory\",\n        },\n        ToolAdapter {\n            id: ToolId::Windsurf,\n            display_name: \"Windsurf\",\n            // add-skill global path: ~/.codeium/windsurf/skills/\n            relative_skills_dir: \".codeium/windsurf/skills\",\n            relative_detect_dir: \".codeium/windsurf\",\n        },\n        ToolAdapter {\n            id: ToolId::Moltbot,\n            display_name: \"MoltBot\",\n            // add-skill global path: ~/.moltbot/skills/\n            relative_skills_dir: \".moltbot/skills\",\n            relative_detect_dir: \".moltbot\",\n        },\n        ToolAdapter {\n            id: ToolId::HermesAgent,\n            display_name: \"Hermes Agent\",\n            // Hermes stores managed skills under HERMES_HOME/skills; default HERMES_HOME is ~/.hermes.\n            relative_skills_dir: \".hermes/skills\",\n            relative_detect_dir: \".hermes\",\n        },\n    ]\n}\n\n/// Tools can share the same global skills directory (e.g. Amp and Kimi Code CLI).\n/// Use this to coordinate UI warnings and avoid duplicate filesystem operations.\npub fn adapters_sharing_skills_dir(adapter: &ToolAdapter) -> Vec<ToolAdapter> {\n    default_tool_adapters()\n        .into_iter()\n        .filter(|a| a.relative_skills_dir == adapter.relative_skills_dir)\n        .collect()\n}\n\npub fn adapters_sharing_project_skills_dir(adapter: &ToolAdapter) -> Vec<ToolAdapter> {\n    let relative = project_relative_skills_dir(adapter);\n    default_tool_adapters()\n        .into_iter()\n        .filter(|a| project_relative_skills_dir(a) == relative)\n        .collect()\n}\n\npub fn adapter_by_key(key: &str) -> Option<ToolAdapter> {\n    default_tool_adapters()\n        .into_iter()\n        .find(|adapter| adapter.id.as_key() == key)\n}\n\npub fn resolve_default_path(adapter: &ToolAdapter) -> Result<PathBuf> {\n    let home = dirs::home_dir().context(\"failed to resolve home directory\")?;\n    Ok(home.join(adapter.relative_skills_dir))\n}\n\npub fn resolve_project_path(adapter: &ToolAdapter, project_root: &Path) -> Result<PathBuf> {\n    Ok(project_root.join(project_relative_skills_dir(adapter)))\n}\n\npub fn supports_project_scope(adapter: &ToolAdapter) -> bool {\n    adapter.id != ToolId::HermesAgent\n}\n\npub fn project_relative_skills_dir(adapter: &ToolAdapter) -> &'static str {\n    match adapter.id {\n        ToolId::Amp | ToolId::KimiCli => \".agents/skills\",\n        ToolId::Antigravity => \".agents/skills\",\n        ToolId::Augment => \".augment/skills\",\n        ToolId::ClaudeCode => \".claude/skills\",\n        ToolId::OpenClaw => \"skills\",\n        ToolId::Cline => \".agents/skills\",\n        ToolId::CodeBuddy => \".codebuddy/skills\",\n        ToolId::Codex => \".agents/skills\",\n        ToolId::CommandCode => \".commandcode/skills\",\n        ToolId::Continue => \".continue/skills\",\n        ToolId::Crush => \".crush/skills\",\n        ToolId::Cursor => \".agents/skills\",\n        ToolId::Droid => \".factory/skills\",\n        ToolId::GeminiCli => \".agents/skills\",\n        ToolId::GithubCopilot => \".agents/skills\",\n        ToolId::Goose => \".goose/skills\",\n        ToolId::Junie => \".junie/skills\",\n        ToolId::IflowCli => \".iflow/skills\",\n        ToolId::KiloCode => \".kilocode/skills\",\n        ToolId::KiroCli => \".kiro/skills\",\n        ToolId::Kode => \".kode/skills\",\n        ToolId::McpJam => \".mcpjam/skills\",\n        ToolId::MistralVibe => \".vibe/skills\",\n        ToolId::Mux => \".mux/skills\",\n        ToolId::OpenCode => \".agents/skills\",\n        ToolId::OpenHands => \".openhands/skills\",\n        ToolId::Pi => \".pi/skills\",\n        ToolId::Qoder => \".qoder/skills\",\n        ToolId::QwenCode => \".qwen/skills\",\n        ToolId::RooCode => \".roo/skills\",\n        ToolId::Trae | ToolId::TraeCn => \".trae/skills\",\n        ToolId::Windsurf => \".windsurf/skills\",\n        ToolId::Zencoder => \".zencoder/skills\",\n        ToolId::Neovate => \".neovate/skills\",\n        ToolId::Pochi => \".pochi/skills\",\n        ToolId::AdaL => \".adal/skills\",\n        ToolId::Copaw\n        | ToolId::OpenClaude\n        | ToolId::QoderWork\n        | ToolId::Clawdbot\n        | ToolId::Moltbot\n        | ToolId::HermesAgent => adapter.relative_skills_dir,\n    }\n}\n\npub fn resolve_detect_path(adapter: &ToolAdapter) -> Result<PathBuf> {\n    let home = dirs::home_dir().context(\"failed to resolve home directory\")?;\n    Ok(home.join(adapter.relative_detect_dir))\n}\n\npub fn is_tool_installed(adapter: &ToolAdapter) -> Result<bool> {\n    Ok(resolve_detect_path(adapter)?.exists())\n}\n\npub fn scan_tool_dir(tool: &ToolAdapter, dir: &Path) -> Result<Vec<DetectedSkill>> {\n    let mut results = Vec::new();\n    if !dir.exists() {\n        return Ok(results);\n    }\n\n    let ignore_hint = \"Application Support/com.tauri.dev/skills\";\n\n    for entry in std::fs::read_dir(dir).with_context(|| format!(\"read dir {:?}\", dir))? {\n        let entry = entry?;\n        let path = entry.path();\n        let file_type = entry.file_type()?;\n        let is_dir = file_type.is_dir() || (file_type.is_symlink() && path.is_dir());\n        if !is_dir {\n            continue;\n        }\n\n        let name = entry.file_name().to_string_lossy().to_string();\n        if tool.id == ToolId::Codex && name == \".system\" {\n            continue;\n        }\n        let (is_link, link_target) = detect_link(&path);\n        if path.to_string_lossy().contains(ignore_hint)\n            || link_target\n                .as_ref()\n                .map(|p| p.to_string_lossy().contains(ignore_hint))\n                .unwrap_or(false)\n        {\n            continue;\n        }\n        results.push(DetectedSkill {\n            tool: tool.id.clone(),\n            name,\n            path,\n            is_link,\n            link_target,\n        });\n    }\n\n    Ok(results)\n}\n\nfn detect_link(path: &Path) -> (bool, Option<PathBuf>) {\n    match std::fs::symlink_metadata(path) {\n        Ok(metadata) if metadata.file_type().is_symlink() => {\n            let target = std::fs::read_link(path).ok();\n            (true, target)\n        }\n        _ => {\n            let target = std::fs::read_link(path).ok();\n            if target.is_some() {\n                (true, target)\n            } else {\n                (false, None)\n            }\n        }\n    }\n}\n\n#[cfg(test)]\n#[path = \"../tests/tool_adapters.rs\"]\nmod tests;\n"
  },
  {
    "path": "src-tauri/src/lib.rs",
    "content": "mod commands;\nmod core;\n\nuse std::sync::Arc;\n\nuse core::cancel_token::CancelToken;\nuse core::skill_store::{default_db_path, migrate_legacy_db_if_needed, SkillStore};\nuse tauri::Manager;\nuse tauri_plugin_log::{Target, TargetKind};\n\n#[cfg_attr(mobile, tauri::mobile_entry_point)]\npub fn run() {\n    tauri::Builder::default()\n        .plugin(tauri_plugin_dialog::init())\n        .plugin(tauri_plugin_opener::init())\n        .plugin(tauri_plugin_updater::Builder::new().build())\n        .setup(|app| {\n            app.handle().plugin(\n                tauri_plugin_log::Builder::default()\n                    .level(log::LevelFilter::Info)\n                    .targets([\n                        Target::new(TargetKind::LogDir { file_name: None }),\n                        #[cfg(desktop)]\n                        Target::new(TargetKind::Stdout),\n                    ])\n                    .build(),\n            )?;\n\n            let db_path = default_db_path(app.handle()).map_err(tauri::Error::from)?;\n            migrate_legacy_db_if_needed(&db_path).map_err(tauri::Error::from)?;\n            let store = SkillStore::new(db_path);\n            store.ensure_schema().map_err(tauri::Error::from)?;\n            app.manage(store.clone());\n            app.manage(Arc::new(CancelToken::new()));\n\n            // Backfill description for skills that were installed before V2 schema.\n            core::installer::backfill_skill_descriptions(&store);\n\n            // Best-effort cleanup of our own old git temp directories.\n            // Safety:\n            // - Only deletes directories that match prefix `skills-hub-git-*`\n            // - And contain our marker file `.skills-hub-git-temp`\n            // - And are older than the max age.\n            let handle = app.handle().clone();\n            let store_for_cleanup = store.clone();\n            tauri::async_runtime::spawn(async move {\n                let removed = core::temp_cleanup::cleanup_old_git_temp_dirs(\n                    &handle,\n                    std::time::Duration::from_secs(24 * 60 * 60),\n                )\n                .unwrap_or(0);\n                if removed > 0 {\n                    log::info!(\"cleaned up {} old git temp dirs\", removed);\n                }\n\n                let cleanup_days =\n                    core::cache_cleanup::get_git_cache_cleanup_days(&store_for_cleanup);\n                if cleanup_days > 0 {\n                    let max_age =\n                        std::time::Duration::from_secs(cleanup_days as u64 * 24 * 60 * 60);\n                    let removed =\n                        core::cache_cleanup::cleanup_git_cache_dirs(&handle, max_age).unwrap_or(0);\n                    if removed > 0 {\n                        log::info!(\"cleaned up {} git cache dirs\", removed);\n                    }\n                }\n            });\n\n            Ok(())\n        })\n        .invoke_handler(tauri::generate_handler![\n            commands::get_central_repo_path,\n            commands::set_central_repo_path,\n            commands::get_recent_projects,\n            commands::save_recent_project,\n            commands::get_tool_status,\n            commands::get_git_cache_cleanup_days,\n            commands::get_git_cache_ttl_secs,\n            commands::set_git_cache_cleanup_days,\n            commands::set_git_cache_ttl_secs,\n            commands::clear_git_cache_now,\n            commands::get_onboarding_plan,\n            commands::install_local,\n            commands::list_local_skills_cmd,\n            commands::install_local_selection,\n            commands::install_git,\n            commands::list_git_skills_cmd,\n            commands::install_git_selection,\n            commands::sync_skill_dir,\n            commands::sync_skill_to_tool,\n            commands::unsync_skill_from_tool,\n            commands::update_managed_skill,\n            commands::search_github,\n            commands::get_github_token,\n            commands::set_github_token,\n            commands::import_existing_skill,\n            commands::get_managed_skills,\n            commands::get_tags,\n            commands::create_tag,\n            commands::rename_tag,\n            commands::delete_tag,\n            commands::get_skill_tags,\n            commands::set_skill_tags,\n            commands::get_untagged_skill_ids,\n            commands::delete_managed_skill,\n            commands::get_featured_skills,\n            commands::search_skills_online,\n            commands::list_skill_files,\n            commands::read_skill_file,\n            commands::cancel_current_operation\n        ])\n        .on_window_event(|window, event| {\n            if let tauri::WindowEvent::CloseRequested { api, .. } = event {\n                api.prevent_close();\n                let _ = window.hide();\n            }\n        })\n        .build(tauri::generate_context!())\n        .expect(\"error while running tauri application\")\n        .run(|app, event| {\n            if matches!(event, tauri::RunEvent::Resumed) {\n                if let Some(window) = app.get_webview_window(\"main\") {\n                    let _ = window.show();\n                    let _ = window.set_focus();\n                }\n            }\n        });\n}\n"
  },
  {
    "path": "src-tauri/src/main.rs",
    "content": "// Prevents additional console window on Windows in release, DO NOT REMOVE!!\n#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nfn main() {\n    app_lib::run();\n}\n"
  },
  {
    "path": "src-tauri/tauri.conf.json",
    "content": "{\n  \"$schema\": \"../node_modules/@tauri-apps/cli/config.schema.json\",\n  \"productName\": \"Skills Hub\",\n  \"version\": \"0.6.0\",\n  \"identifier\": \"com.qufei1993.skillshub\",\n  \"build\": {\n    \"frontendDist\": \"../dist\",\n    \"devUrl\": \"http://localhost:5173\",\n    \"beforeDevCommand\": \"npm run dev\",\n    \"beforeBuildCommand\": \"npm run build\"\n  },\n  \"app\": {\n    \"windows\": [\n      {\n        \"title\": \"Skills Hub\",\n        \"width\": 960,\n        \"height\": 680,\n        \"resizable\": true,\n        \"fullscreen\": false\n      }\n    ],\n    \"security\": {\n      \"csp\": null\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"targets\": \"all\",\n    \"createUpdaterArtifacts\": true,\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.icns\",\n      \"icons/icon.ico\"\n    ]\n  },\n  \"plugins\": {\n    \"updater\": {\n      \"active\": true,\n      \"dialog\": false,\n      \"endpoints\": [\n        \"https://github.com/qufei1993/skills-hub/releases/latest/download/updater.json\"\n      ],\n      \"pubkey\": \"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI1QjEyQ0U0RkM5NjZCMjQKUldRa2E1Yjg1Q3l4dFM0TGl4N3k3KytFalRiajhFZmo4QkE5ek5HVXM1MjRDZWVORWhibnR6Vk8K\"\n    }\n  }\n}\n"
  },
  {
    "path": "tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2023\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2023\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"vite/client\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ]\n}\n"
  },
  {
    "path": "tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2023\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"types\": [\"node\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\nimport tailwindcss from '@tailwindcss/vite'\n\n// https://vite.dev/config/\nexport default defineConfig({\n  plugins: [react(), tailwindcss()],\n  server: {\n    port: 5173,\n    strictPort: true,\n  },\n})\n"
  }
]