From 6c0d4f4f16e103e263eec0a42942aa3c2e37ec7a Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:21:09 +0800 Subject: [PATCH] feat(skillhub-cli): add skillhub CLI with interactive multi-agent support - Interactive agent selection for install/uninstall/list - Namespace search selection for unspecified namespace - --skill-version option to skip interactive selection - --from option for local/github source installation - Remove deprecated add command (use install --from instead) - Path-based grouping for uninstall results --- skillhub-cli/package.json | 35 + skillhub-cli/pnpm-lock.yaml | 2642 ++++++++++++++++++ skillhub-cli/src/cli.ts | 126 + skillhub-cli/src/commands/archive.ts | 39 + skillhub-cli/src/commands/check.ts | 135 + skillhub-cli/src/commands/delete.ts | 40 + skillhub-cli/src/commands/download.ts | 59 + skillhub-cli/src/commands/explore.ts | 278 ++ skillhub-cli/src/commands/hide.ts | 88 + skillhub-cli/src/commands/init.ts | 46 + skillhub-cli/src/commands/inspect.ts | 136 + skillhub-cli/src/commands/install.ts | 759 +++++ skillhub-cli/src/commands/list.ts | 142 + skillhub-cli/src/commands/login.ts | 34 + skillhub-cli/src/commands/logout.ts | 13 + skillhub-cli/src/commands/me.ts | 89 + skillhub-cli/src/commands/namespaces.ts | 35 + skillhub-cli/src/commands/notifications.ts | 78 + skillhub-cli/src/commands/publish.ts | 84 + skillhub-cli/src/commands/rating.ts | 71 + skillhub-cli/src/commands/report.ts | 40 + skillhub-cli/src/commands/resolve.ts | 177 ++ skillhub-cli/src/commands/reviews.ts | 43 + skillhub-cli/src/commands/search.ts | 54 + skillhub-cli/src/commands/star.ts | 37 + skillhub-cli/src/commands/sync.ts | 155 + skillhub-cli/src/commands/transfer.ts | 38 + skillhub-cli/src/commands/uninstall.ts | 334 +++ skillhub-cli/src/commands/update.ts | 99 + skillhub-cli/src/commands/versions.ts | 101 + skillhub-cli/src/commands/whoami.ts | 30 + skillhub-cli/src/core/agent-detector.ts | 89 + skillhub-cli/src/core/api-client.ts | 131 + skillhub-cli/src/core/auth-token.ts | 39 + skillhub-cli/src/core/config.ts | 34 + skillhub-cli/src/core/installer.ts | 144 + skillhub-cli/src/core/interactive-search.ts | 249 ++ skillhub-cli/src/core/skill-discovery.ts | 86 + skillhub-cli/src/core/skill-lock.ts | 106 + skillhub-cli/src/core/skill-name.ts | 12 + skillhub-cli/src/core/source-parser.ts | 69 + skillhub-cli/src/schema/routes.ts | 46 + skillhub-cli/src/utils/install-helpers.ts | 253 ++ skillhub-cli/src/utils/logger.ts | 25 + skillhub-cli/src/utils/prompts.ts | 449 +++ skillhub-cli/src/utils/search-multiselect.ts | 297 ++ skillhub-cli/src/utils/telemetry.ts | 99 + skillhub-cli/tests/api-client.test.ts | 179 ++ skillhub-cli/tests/commands.test.ts | 191 ++ skillhub-cli/tests/install.test.ts | 224 ++ skillhub-cli/tests/installer.test.ts | 35 + skillhub-cli/tests/prompts.test.ts | 45 + skillhub-cli/tests/skill-lock.test.ts | 223 ++ skillhub-cli/tests/skill-name.test.ts | 34 + skillhub-cli/tests/source-parser.test.ts | 84 + skillhub-cli/tests/uninstall.test.ts | 112 + skillhub-cli/tsconfig.json | 18 + skillhub-cli/unbuild.config.ts | 10 + skillhub-cli/vitest.config.ts | 8 + 59 files changed, 9328 insertions(+) create mode 100644 skillhub-cli/package.json create mode 100644 skillhub-cli/pnpm-lock.yaml create mode 100644 skillhub-cli/src/cli.ts create mode 100644 skillhub-cli/src/commands/archive.ts create mode 100644 skillhub-cli/src/commands/check.ts create mode 100644 skillhub-cli/src/commands/delete.ts create mode 100644 skillhub-cli/src/commands/download.ts create mode 100644 skillhub-cli/src/commands/explore.ts create mode 100644 skillhub-cli/src/commands/hide.ts create mode 100644 skillhub-cli/src/commands/init.ts create mode 100644 skillhub-cli/src/commands/inspect.ts create mode 100644 skillhub-cli/src/commands/install.ts create mode 100644 skillhub-cli/src/commands/list.ts create mode 100644 skillhub-cli/src/commands/login.ts create mode 100644 skillhub-cli/src/commands/logout.ts create mode 100644 skillhub-cli/src/commands/me.ts create mode 100644 skillhub-cli/src/commands/namespaces.ts create mode 100644 skillhub-cli/src/commands/notifications.ts create mode 100644 skillhub-cli/src/commands/publish.ts create mode 100644 skillhub-cli/src/commands/rating.ts create mode 100644 skillhub-cli/src/commands/report.ts create mode 100644 skillhub-cli/src/commands/resolve.ts create mode 100644 skillhub-cli/src/commands/reviews.ts create mode 100644 skillhub-cli/src/commands/search.ts create mode 100644 skillhub-cli/src/commands/star.ts create mode 100644 skillhub-cli/src/commands/sync.ts create mode 100644 skillhub-cli/src/commands/transfer.ts create mode 100644 skillhub-cli/src/commands/uninstall.ts create mode 100644 skillhub-cli/src/commands/update.ts create mode 100644 skillhub-cli/src/commands/versions.ts create mode 100644 skillhub-cli/src/commands/whoami.ts create mode 100644 skillhub-cli/src/core/agent-detector.ts create mode 100644 skillhub-cli/src/core/api-client.ts create mode 100644 skillhub-cli/src/core/auth-token.ts create mode 100644 skillhub-cli/src/core/config.ts create mode 100644 skillhub-cli/src/core/installer.ts create mode 100644 skillhub-cli/src/core/interactive-search.ts create mode 100644 skillhub-cli/src/core/skill-discovery.ts create mode 100644 skillhub-cli/src/core/skill-lock.ts create mode 100644 skillhub-cli/src/core/skill-name.ts create mode 100644 skillhub-cli/src/core/source-parser.ts create mode 100644 skillhub-cli/src/schema/routes.ts create mode 100644 skillhub-cli/src/utils/install-helpers.ts create mode 100644 skillhub-cli/src/utils/logger.ts create mode 100644 skillhub-cli/src/utils/prompts.ts create mode 100644 skillhub-cli/src/utils/search-multiselect.ts create mode 100644 skillhub-cli/src/utils/telemetry.ts create mode 100644 skillhub-cli/tests/api-client.test.ts create mode 100644 skillhub-cli/tests/commands.test.ts create mode 100644 skillhub-cli/tests/install.test.ts create mode 100644 skillhub-cli/tests/installer.test.ts create mode 100644 skillhub-cli/tests/prompts.test.ts create mode 100644 skillhub-cli/tests/skill-lock.test.ts create mode 100644 skillhub-cli/tests/skill-name.test.ts create mode 100644 skillhub-cli/tests/source-parser.test.ts create mode 100644 skillhub-cli/tests/uninstall.test.ts create mode 100644 skillhub-cli/tsconfig.json create mode 100644 skillhub-cli/unbuild.config.ts create mode 100644 skillhub-cli/vitest.config.ts diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json new file mode 100644 index 00000000..2d46e762 --- /dev/null +++ b/skillhub-cli/package.json @@ -0,0 +1,35 @@ +{ + "name": "@motovis/skillhub", + "version": "1.0.0", + "type": "module", + "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", + "bin": { + "skillhub": "dist/cli.mjs" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "unbuild", + "dev": "unbuild --stub", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clack/prompts": "^1.2.0", + "chalk": "^5.3.0", + "commander": "^14.0.3", + "ora": "^9.3.0", + "picocolors": "^1.1.1", + "semver": "^7.7.4", + "undici": "^7.24.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/semver": "^7.5.8", + "typescript": "^5.6.0", + "unbuild": "^3.0.0", + "vitest": "^3.0.0" + } +} diff --git a/skillhub-cli/pnpm-lock.yaml b/skillhub-cli/pnpm-lock.yaml new file mode 100644 index 00000000..bb051a69 --- /dev/null +++ b/skillhub-cli/pnpm-lock.yaml @@ -0,0 +1,2642 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@clack/prompts': + specifier: ^1.2.0 + version: 1.2.0 + chalk: + specifier: ^5.3.0 + version: 5.6.2 + commander: + specifier: ^14.0.3 + version: 14.0.3 + ora: + specifier: ^9.3.0 + version: 9.3.0 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + semver: + specifier: ^7.7.4 + version: 7.7.4 + undici: + specifier: ^7.24.0 + version: 7.24.7 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 + '@types/semver': + specifier: ^7.5.8 + version: 7.7.1 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + unbuild: + specifier: ^3.0.0 + version: 3.6.1(typescript@5.9.3) + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1) + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@clack/core@1.2.0': + resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==} + + '@clack/prompts@1.2.0': + resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==} + + '@colordx/core@5.0.3': + resolution: {integrity: sha512-xBQ0MYRTNNxW3mS2sJtlQTT7C3Sasqgh1/PsHva7fyDb5uqYY+gv9V0utDdX8X80mqzbGz3u/IDJdn2d/uW09g==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/plugin-alias@5.1.1': + resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-commonjs@28.0.9': + resolution: {integrity: sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-json@6.1.0': + resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@6.0.3': + resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + baseline-browser-mapping@2.10.13: + resolution: {integrity: sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==} + engines: {node: '>=6.0.0'} + hasBin: true + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + + caniuse-lite@1.0.30001784: + resolution: {integrity: sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + css-declaration-sorter@7.3.1: + resolution: {integrity: sha512-gz6x+KkgNCjxq3Var03pRYLhyNfwhkKF1g/yoLgDNtFvVu0/fOLV9C8fFEZRjACp/XQLumjAYo7JVjzH3wLbxA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.0.9 + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssnano-preset-default@7.0.12: + resolution: {integrity: sha512-B3Eoouzw/sl2zANI0AL9KbacummJTCww+fkHaDBMZad/xuVx8bUduPLly6hKVQAlrmvYkS1jB1CVQEKm3gn0AA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + cssnano-utils@5.0.1: + resolution: {integrity: sha512-ZIP71eQgG9JwjVZsTPSqhc6GHgEr53uJ7tK5///VfyWj6Xp2DBmixWHqJgPno+PqATzn48pL42ww9x5SSGmhZg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + cssnano@7.1.4: + resolution: {integrity: sha512-T9PNS7y+5Nc9Qmu9mRONqfxG1RVY7Vuvky0XN6MZ+9hqplesTEwnj9r0ROtVuSwUVfaDhVlavuzWIVLUgm4hkQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defu@6.1.6: + resolution: {integrity: sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + electron-to-chromium@1.5.331: + resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-string-truncated-width@1.2.1: + resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==} + + fast-string-width@1.1.0: + resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==} + + fast-wrap-ansi@0.1.6: + resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + knitwork@1.3.0: + resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mkdist@2.4.1: + resolution: {integrity: sha512-Ezk0gi04GJBkqMfsksICU5Rjoemc4biIekwgrONWVPor2EO/N9nBgN6MZXAf7Yw4mDDhrNyKbdETaHNevfumKg==} + hasBin: true + peerDependencies: + sass: ^1.92.1 + typescript: '>=5.9.2' + vue: ^3.5.21 + vue-sfc-transformer: ^0.1.1 + vue-tsc: ^1.8.27 || ^2.0.21 || ^3.0.0 + peerDependenciesMeta: + sass: + optional: true + typescript: + optional: true + vue: + optional: true + vue-sfc-transformer: + optional: true + vue-tsc: + optional: true + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + ora@9.3.0: + resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==} + engines: {node: '>=20'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + postcss-calc@10.1.1: + resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==} + engines: {node: ^18.12 || ^20.9 || >=22.0} + peerDependencies: + postcss: ^8.4.38 + + postcss-colormin@7.0.7: + resolution: {integrity: sha512-sBQ628lSj3VQpDquQel8Pen5mmjFPsO4pH9lDLaHB1AVkMRHtkl0pRB5DCWznc9upWsxint/kV+AveSj7W1tew==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-convert-values@7.0.9: + resolution: {integrity: sha512-l6uATQATZaCa0bckHV+r6dLXfWtUBKXxO3jK+AtxxJJtgMPD+VhhPCCx51I4/5w8U5uHV67g3w7PXj+V3wlMlg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-comments@7.0.6: + resolution: {integrity: sha512-Sq+Fzj1Eg5/CPf1ERb0wS1Im5cvE2gDXCE+si4HCn1sf+jpQZxDI4DXEp8t77B/ImzDceWE2ebJQFXdqZ6GRJw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-duplicates@7.0.2: + resolution: {integrity: sha512-eTonaQvPZ/3i1ASDHOKkYwAybiM45zFIc7KXils4mQmHLqIswXD9XNOKEVxtTFnsmwYzF66u4LMgSr0abDlh5w==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-empty@7.0.1: + resolution: {integrity: sha512-cFrJKZvcg/uxB6Ijr4l6qmn3pXQBna9zyrPC+sK0zjbkDUZew+6xDltSF7OeB7rAtzaaMVYSdbod+sZOCWnMOg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-discard-overridden@7.0.1: + resolution: {integrity: sha512-7c3MMjjSZ/qYrx3uc1940GSOzN1Iqjtlqe8uoSg+qdVPYyRb0TILSqqmtlSFuE4mTDECwsm397Ya7iXGzfF7lg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-merge-longhand@7.0.5: + resolution: {integrity: sha512-Kpu5v4Ys6QI59FxmxtNB/iHUVDn9Y9sYw66D6+SZoIk4QTz1prC4aYkhIESu+ieG1iylod1f8MILMs1Em3mmIw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-merge-rules@7.0.8: + resolution: {integrity: sha512-BOR1iAM8jnr7zoQSlpeBmCsWV5Uudi/+5j7k05D0O/WP3+OFMPD86c1j/20xiuRtyt45bhxw/7hnhZNhW2mNFA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-font-values@7.0.1: + resolution: {integrity: sha512-2m1uiuJeTplll+tq4ENOQSzB8LRnSUChBv7oSyFLsJRtUgAAJGP6LLz0/8lkinTgxrmJSPOEhgY1bMXOQ4ZXhQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-gradients@7.0.2: + resolution: {integrity: sha512-fVY3AB8Um7SJR5usHqTY2Ngf9qh8IRN+FFzrBP0ONJy6yYXsP7xyjK2BvSAIrpgs1cST+H91V0TXi3diHLYJtw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-params@7.0.6: + resolution: {integrity: sha512-YOn02gC68JijlaXVuKvFSCvQOhTpblkcfDre2hb/Aaa58r2BIaK4AtE/cyZf2wV7YKAG+UlP9DT+By0ry1E4VQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-minify-selectors@7.0.6: + resolution: {integrity: sha512-lIbC0jy3AAwDxEgciZlBullDiMBeBCT+fz5G8RcA9MWqh/hfUkpOI3vNDUNEZHgokaoiv0juB9Y8fGcON7rU/A==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-nested@7.0.2: + resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-normalize-charset@7.0.1: + resolution: {integrity: sha512-sn413ofhSQHlZFae//m9FTOfkmiZ+YQXsbosqOWRiVQncU2BA3daX3n0VF3cG6rGLSFVc5Di/yns0dFfh8NFgQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-display-values@7.0.1: + resolution: {integrity: sha512-E5nnB26XjSYz/mGITm6JgiDpAbVuAkzXwLzRZtts19jHDUBFxZ0BkXAehy0uimrOjYJbocby4FVswA/5noOxrQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-positions@7.0.1: + resolution: {integrity: sha512-pB/SzrIP2l50ZIYu+yQZyMNmnAcwyYb9R1fVWPRxm4zcUFCY2ign7rcntGFuMXDdd9L2pPNUgoODDk91PzRZuQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-repeat-style@7.0.1: + resolution: {integrity: sha512-NsSQJ8zj8TIDiF0ig44Byo3Jk9e4gNt9x2VIlJudnQQ5DhWAHJPF4Tr1ITwyHio2BUi/I6Iv0HRO7beHYOloYQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-string@7.0.1: + resolution: {integrity: sha512-QByrI7hAhsoze992kpbMlJSbZ8FuCEc1OT9EFbZ6HldXNpsdpZr+YXC5di3UEv0+jeZlHbZcoCADgb7a+lPmmQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-timing-functions@7.0.1: + resolution: {integrity: sha512-bHifyuuSNdKKsnNJ0s8fmfLMlvsQwYVxIoUBnowIVl2ZAdrkYQNGVB4RxjfpvkMjipqvbz0u7feBZybkl/6NJg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-unicode@7.0.6: + resolution: {integrity: sha512-z6bwTV84YW6ZvvNoaNLuzRW4/uWxDKYI1iIDrzk6D2YTL7hICApy+Q1LP6vBEsljX8FM7YSuV9qI79XESd4ddQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-url@7.0.1: + resolution: {integrity: sha512-sUcD2cWtyK1AOL/82Fwy1aIVm/wwj5SdZkgZ3QiUzSzQQofrbq15jWJ3BA7Z+yVRwamCjJgZJN0I9IS7c6tgeQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-normalize-whitespace@7.0.1: + resolution: {integrity: sha512-vsbgFHMFQrJBJKrUFJNZ2pgBeBkC2IvvoHjz1to0/0Xk7sII24T0qFOiJzG6Fu3zJoq/0yI4rKWi7WhApW+EFA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-ordered-values@7.0.2: + resolution: {integrity: sha512-AMJjt1ECBffF7CEON/Y0rekRLS6KsePU6PRP08UqYW4UGFRnTXNrByUzYK1h8AC7UWTZdQ9O3Oq9kFIhm0SFEw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-reduce-initial@7.0.6: + resolution: {integrity: sha512-G6ZyK68AmrPdMB6wyeA37ejnnRG2S8xinJrZJnOv+IaRKf6koPAVbQsiC7MfkmXaGmF1UO+QCijb27wfpxuRNg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-reduce-transforms@7.0.1: + resolution: {integrity: sha512-MhyEbfrm+Mlp/36hvZ9mT9DaO7dbncU0CvWI8V93LRkY6IYlu38OPg3FObnuKTUxJ4qA8HpurdQOo5CyqqO76g==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-svgo@7.1.1: + resolution: {integrity: sha512-zU9H9oEDrUFKa0JB7w+IYL7Qs9ey1mZyjhbf0KLxwJDdDRtoPvCmaEfknzqfHj44QS9VD6c5sJnBAVYTLRg/Sg==} + engines: {node: ^18.12.0 || ^20.9.0 || >= 18} + peerDependencies: + postcss: ^8.4.32 + + postcss-unique-selectors@7.0.5: + resolution: {integrity: sha512-3QoYmEt4qg/rUWDn6Tc8+ZVPmbp4G1hXDtCNWDx0st8SjtCbRcxRXDDM1QrEiXGG3A45zscSJFb4QH90LViyxg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + pretty-bytes@7.1.0: + resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} + engines: {node: '>=20'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + rollup-plugin-dts@6.4.1: + resolution: {integrity: sha512-l//F3Zf7ID5GoOfLfD8kroBjQKEKpy1qfhtAdnpibFZMffPaylrg1CoDC2vGkPeTeyxUe4bVFCln2EFuL7IGGg==} + engines: {node: '>=20'} + peerDependencies: + rollup: ^3.29.4 || ^4 + typescript: ^4.5 || ^5.0 || ^6.0 + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stdin-discarder@0.3.1: + resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + stylehacks@7.0.8: + resolution: {integrity: sha512-I3f053GBLIiS5Fg6OMFhq/c+yW+5Hc2+1fgq7gElDMMSqwlRb3tBf2ef6ucLStYRpId4q//bQO1FjcyNyy4yDQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.32 + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svgo@4.0.1: + resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} + engines: {node: '>=16'} + hasBin: true + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + unbuild@3.6.1: + resolution: {integrity: sha512-+U5CdtrdjfWkZhuO4N9l5UhyiccoeMEXIc2Lbs30Haxb+tRwB3VwB8AoZRxlAzORXunenSo+j6lh45jx+xkKgg==} + hasBin: true + peerDependencies: + typescript: ^5.9.2 + peerDependenciesMeta: + typescript: + optional: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.24.7: + resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} + engines: {node: '>=20.18.1'} + + untyped@2.0.0: + resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + optional: true + + '@babel/helper-validator-identifier@7.28.5': + optional: true + + '@clack/core@1.2.0': + dependencies: + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + + '@clack/prompts@1.2.0': + dependencies: + '@clack/core': 1.2.0 + fast-string-width: 1.1.0 + fast-wrap-ansi: 0.1.6 + sisteransi: 1.0.5 + + '@colordx/core@5.0.3': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/plugin-alias@5.1.1(rollup@4.60.1)': + optionalDependencies: + rollup: 4.60.1 + + '@rollup/plugin-commonjs@28.0.9(rollup@4.60.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.5.0(picomatch@4.0.4) + is-reference: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.1 + + '@rollup/plugin-json@6.1.0(rollup@4.60.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + optionalDependencies: + rollup: 4.60.1 + + '@rollup/plugin-node-resolve@16.0.3(rollup@4.60.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.11 + optionalDependencies: + rollup: 4.60.1 + + '@rollup/plugin-replace@6.0.3(rollup@4.60.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.60.1 + + '@rollup/pluginutils@5.3.0(rollup@4.60.1)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.1 + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + + '@types/resolve@1.20.2': {} + + '@types/semver@7.7.1': {} + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn@8.16.0: {} + + ansi-regex@6.2.2: {} + + assertion-error@2.0.1: {} + + autoprefixer@10.4.27(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001784 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + baseline-browser-mapping@2.10.13: {} + + boolbase@1.0.0: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.13 + caniuse-lite: 1.0.30001784 + electron-to-chromium: 1.5.331 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + cac@6.7.14: {} + + caniuse-api@3.0.0: + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001784 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + + caniuse-lite@1.0.30001784: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@5.6.2: {} + + check-error@2.1.3: {} + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@3.4.0: {} + + commander@11.1.0: {} + + commander@14.0.3: {} + + commondir@1.0.1: {} + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + + css-declaration-sorter@7.3.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + cssesc@3.0.0: {} + + cssnano-preset-default@7.0.12(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + css-declaration-sorter: 7.3.1(postcss@8.5.8) + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 + postcss-calc: 10.1.1(postcss@8.5.8) + postcss-colormin: 7.0.7(postcss@8.5.8) + postcss-convert-values: 7.0.9(postcss@8.5.8) + postcss-discard-comments: 7.0.6(postcss@8.5.8) + postcss-discard-duplicates: 7.0.2(postcss@8.5.8) + postcss-discard-empty: 7.0.1(postcss@8.5.8) + postcss-discard-overridden: 7.0.1(postcss@8.5.8) + postcss-merge-longhand: 7.0.5(postcss@8.5.8) + postcss-merge-rules: 7.0.8(postcss@8.5.8) + postcss-minify-font-values: 7.0.1(postcss@8.5.8) + postcss-minify-gradients: 7.0.2(postcss@8.5.8) + postcss-minify-params: 7.0.6(postcss@8.5.8) + postcss-minify-selectors: 7.0.6(postcss@8.5.8) + postcss-normalize-charset: 7.0.1(postcss@8.5.8) + postcss-normalize-display-values: 7.0.1(postcss@8.5.8) + postcss-normalize-positions: 7.0.1(postcss@8.5.8) + postcss-normalize-repeat-style: 7.0.1(postcss@8.5.8) + postcss-normalize-string: 7.0.1(postcss@8.5.8) + postcss-normalize-timing-functions: 7.0.1(postcss@8.5.8) + postcss-normalize-unicode: 7.0.6(postcss@8.5.8) + postcss-normalize-url: 7.0.1(postcss@8.5.8) + postcss-normalize-whitespace: 7.0.1(postcss@8.5.8) + postcss-ordered-values: 7.0.2(postcss@8.5.8) + postcss-reduce-initial: 7.0.6(postcss@8.5.8) + postcss-reduce-transforms: 7.0.1(postcss@8.5.8) + postcss-svgo: 7.1.1(postcss@8.5.8) + postcss-unique-selectors: 7.0.5(postcss@8.5.8) + + cssnano-utils@5.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + cssnano@7.1.4(postcss@8.5.8): + dependencies: + cssnano-preset-default: 7.0.12(postcss@8.5.8) + lilconfig: 3.1.3 + postcss: 8.5.8 + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deepmerge@4.3.1: {} + + defu@6.1.6: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + electron-to-chromium@1.5.331: {} + + entities@4.5.0: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + exsolve@1.0.8: {} + + fast-string-truncated-width@1.2.1: {} + + fast-string-width@1.1.0: + dependencies: + fast-string-truncated-width: 1.2.1 + + fast-wrap-ansi@0.1.6: + dependencies: + fast-string-width: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.60.1 + + fraction.js@5.3.4: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-east-asian-width@1.5.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hookable@5.5.3: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-interactive@2.0.0: {} + + is-module@1.0.0: {} + + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.8 + + is-unicode-supported@2.1.0: {} + + jiti@1.21.7: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: + optional: true + + js-tokens@9.0.1: {} + + knitwork@1.3.0: {} + + lilconfig@3.1.3: {} + + lodash.memoize@4.1.2: {} + + lodash.uniq@4.5.0: {} + + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + mimic-function@5.0.1: {} + + mkdist@2.4.1(typescript@5.9.3): + dependencies: + autoprefixer: 10.4.27(postcss@8.5.8) + citty: 0.1.6 + cssnano: 7.1.4(postcss@8.5.8) + defu: 6.1.6 + esbuild: 0.25.12 + jiti: 1.21.7 + mlly: 1.8.2 + pathe: 2.0.3 + pkg-types: 2.3.0 + postcss: 8.5.8 + postcss-nested: 7.0.2(postcss@8.5.8) + semver: 7.7.4 + tinyglobby: 0.2.15 + optionalDependencies: + typescript: 5.9.3 + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-releases@2.0.37: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + ora@9.3.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.1 + string-width: 8.2.0 + + path-parse@1.0.7: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + postcss-calc@10.1.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + + postcss-colormin@7.0.7(postcss@8.5.8): + dependencies: + '@colordx/core': 5.0.3 + browserslist: 4.28.2 + caniuse-api: 3.0.0 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-convert-values@7.0.9(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-discard-comments@7.0.6(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + postcss-discard-duplicates@7.0.2(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + postcss-discard-empty@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + postcss-discard-overridden@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + postcss-merge-longhand@7.0.5(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + stylehacks: 7.0.8(postcss@8.5.8) + + postcss-merge-rules@7.0.8(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + postcss-minify-font-values@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-minify-gradients@7.0.2(postcss@8.5.8): + dependencies: + '@colordx/core': 5.0.3 + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-minify-params@7.0.6(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-minify-selectors@7.0.6(postcss@8.5.8): + dependencies: + cssesc: 3.0.0 + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + postcss-nested@7.0.2(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + postcss-normalize-charset@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + postcss-normalize-display-values@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-positions@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-repeat-style@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-string@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-timing-functions@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-unicode@7.0.6(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-url@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-normalize-whitespace@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-ordered-values@7.0.2(postcss@8.5.8): + dependencies: + cssnano-utils: 5.0.1(postcss@8.5.8) + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-reduce-initial@7.0.6(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + postcss: 8.5.8 + + postcss-reduce-transforms@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-svgo@7.1.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + svgo: 4.0.1 + + postcss-unique-selectors@7.0.5(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-bytes@7.1.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rollup-plugin-dts@6.4.1(rollup@4.60.1)(typescript@5.9.3): + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + convert-source-map: 2.0.0 + magic-string: 0.30.21 + rollup: 4.60.1 + typescript: 5.9.3 + optionalDependencies: + '@babel/code-frame': 7.29.0 + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + sax@1.6.0: {} + + scule@1.3.0: {} + + semver@7.7.4: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + stdin-discarder@0.3.1: {} + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + stylehacks@7.0.8(postcss@8.5.8): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + supports-preserve-symlinks-flag@1.0.0: {} + + svgo@4.0.1: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.2.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + unbuild@3.6.1(typescript@5.9.3): + dependencies: + '@rollup/plugin-alias': 5.1.1(rollup@4.60.1) + '@rollup/plugin-commonjs': 28.0.9(rollup@4.60.1) + '@rollup/plugin-json': 6.1.0(rollup@4.60.1) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.1) + '@rollup/plugin-replace': 6.0.3(rollup@4.60.1) + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.6 + esbuild: 0.25.12 + fix-dts-default-cjs-exports: 1.0.1 + hookable: 5.5.3 + jiti: 2.6.1 + magic-string: 0.30.21 + mkdist: 2.4.1(typescript@5.9.3) + mlly: 1.8.2 + pathe: 2.0.3 + pkg-types: 2.3.0 + pretty-bytes: 7.1.0 + rollup: 4.60.1 + rollup-plugin-dts: 6.4.1(rollup@4.60.1)(typescript@5.9.3) + scule: 1.3.0 + tinyglobby: 0.2.15 + untyped: 2.0.0 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - sass + - vue + - vue-sfc-transformer + - vue-tsc + + undici-types@6.21.0: {} + + undici@7.24.7: {} + + untyped@2.0.0: + dependencies: + citty: 0.1.6 + defu: 6.1.6 + jiti: 2.6.1 + knitwork: 1.3.0 + scule: 1.3.0 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite-node@3.2.4(@types/node@22.19.15)(jiti@2.6.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + jiti: 2.6.1 + + vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1) + vite-node: 3.2.4(@types/node@22.19.15)(jiti@2.6.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yoctocolors@2.1.2: {} diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts new file mode 100644 index 00000000..d42e1752 --- /dev/null +++ b/skillhub-cli/src/cli.ts @@ -0,0 +1,126 @@ +import { Command } from "commander"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function getPackageVersion(): string { + try { + const pkgPath = resolve(__dirname, "../package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + return pkg.version; + } catch { + return "0.1.0"; + } +} + +export async function createCli(): Promise { + const program = new Command(); + const version = getPackageVersion(); + + program + .name("skillhub") + .description("CLI for SkillHub — publish, search, and manage agent skills") + .version(version) + .option("--registry ", "Registry API base URL", "http://localhost:8080") + .option("--json", "Output results as JSON"); + + const [ + { registerLogin }, + { registerLogout }, + { registerWhoami }, + { registerPublish }, + { registerNamespaces }, + { registerInstall }, + { registerDownload }, + { registerList }, + { registerStar }, + { registerInit }, + { registerMe }, + { registerReviews }, + { registerNotifications }, + { registerDelete }, + { registerVersions }, + { registerReport }, + { registerResolve }, + { registerRating, registerRate }, + { registerArchive }, + { registerUpdate }, + { registerCheck }, + { registerUninstall }, + { registerSync }, + { registerInspect }, + { registerExplore }, + { registerTransfer }, + { registerHide }, + ] = await Promise.all([ + import("./commands/login.js"), + import("./commands/logout.js"), + import("./commands/whoami.js"), + import("./commands/publish.js"), + import("./commands/namespaces.js"), + import("./commands/install.js"), + import("./commands/download.js"), + import("./commands/list.js"), + import("./commands/star.js"), + import("./commands/init.js"), + import("./commands/me.js"), + import("./commands/reviews.js"), + import("./commands/notifications.js"), + import("./commands/delete.js"), + import("./commands/versions.js"), + import("./commands/report.js"), + import("./commands/resolve.js"), + import("./commands/rating.js"), + import("./commands/archive.js"), + import("./commands/update.js"), + import("./commands/check.js"), + import("./commands/uninstall.js"), + import("./commands/sync.js"), + import("./commands/inspect.js"), + import("./commands/explore.js"), + import("./commands/transfer.js"), + import("./commands/hide.js"), + ]); + + registerLogin(program); + registerLogout(program); + registerWhoami(program); + registerPublish(program); + registerNamespaces(program); + registerInstall(program); + registerDownload(program); + registerList(program); + registerStar(program); + registerInit(program); + registerMe(program); + registerReviews(program); + registerNotifications(program); + registerDelete(program); + registerVersions(program); + registerReport(program); + registerResolve(program); + registerRating(program); + registerRate(program); + registerArchive(program); + registerUpdate(program); + registerCheck(program); + registerUninstall(program); + registerSync(program); + registerInspect(program); + registerExplore(program); + registerTransfer(program); + registerHide(program); + + return program; +} + +export async function main() { + const program = await createCli(); + program.parse(); +} + +main(); diff --git a/skillhub-cli/src/commands/archive.ts b/skillhub-cli/src/commands/archive.ts new file mode 100644 index 00000000..46052e72 --- /dev/null +++ b/skillhub-cli/src/commands/archive.ts @@ -0,0 +1,39 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig } from "../core/config.js"; +import { success, error } from "../utils/logger.js"; +import { parseSkillName } from "../core/skill-name.js"; + +export function registerArchive(program: Command) { + program + .command("archive ") + .description("Archive a skill you own") + .option("-y, --yes", "Skip confirmation") + .action(async (slug: string, opts: { yes?: boolean }) => { + const { namespace, slug: skillSlug } = parseSkillName(slug); + if (!opts.yes) { + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => + rl.question(`Archive ${skillSlug} from ${namespace}? [y/N] `, r) + ); + rl.close(); + if (answer.toLowerCase() !== "y") { + console.log("Cancelled."); + return; + } + } + + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + await client.post(`/api/v1/skills/${namespace}/${skillSlug}/archive`); + success(`Archived ${skillSlug}`); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/check.ts b/skillhub-cli/src/commands/check.ts new file mode 100644 index 00000000..e8fb840e --- /dev/null +++ b/skillhub-cli/src/commands/check.ts @@ -0,0 +1,135 @@ +import { Command } from "commander"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { getAllAgents } from "../core/agent-detector.js"; +import { getAllLockedSkills, getSkillLockPath } from "../core/skill-lock.js"; +import { success, error, info, warn, dim } from "../utils/logger.js"; + +interface CheckResult { + name: string; + status: "ok" | "missing" | "orphaned"; + source?: string; + location?: string; +} + +function findInstalledSkills(scope: "local" | "global"): Map { + const skillsMap = new Map(); + const agents = getAllAgents(); + + for (const agent of agents) { + const baseDir = scope === "global" + ? join(homedir(), agent.globalSkillsDir || agent.skillsDir) + : join(process.cwd(), agent.skillsDir); + + if (!existsSync(baseDir)) continue; + + try { + for (const entry of readdirSync(baseDir)) { + const skillPath = join(baseDir, entry); + if (statSync(skillPath).isDirectory() && existsSync(join(skillPath, "SKILL.md"))) { + const existing = skillsMap.get(entry) || []; + existing.push(agent.name); + skillsMap.set(entry, existing); + } + } + } catch {} + } + + return skillsMap; +} + +export function registerCheck(program: Command) { + program + .command("check") + .description("Check installed skills against lock file") + .option("--global", "Check global scope skills") + .option("--json", "Output results as JSON") + .action(async (opts: { global?: boolean; json?: boolean }) => { + const scope = opts.global ? "global" : "local"; + const lockPath = getSkillLockPath(); + + if (!existsSync(lockPath)) { + if (opts.json) { + console.log(JSON.stringify({ error: "No lock file found" }, null, 2)); + } else { + warn("No skillhub.lock found. Have you installed any skills?"); + } + return; + } + + const lockedSkills = await getAllLockedSkills(); + const installedSkills = findInstalledSkills(scope); + + const results: CheckResult[] = []; + + for (const [name, entry] of Object.entries(lockedSkills)) { + const installedLocations = installedSkills.get(name); + if (installedLocations && installedLocations.length > 0) { + results.push({ + name, + status: "ok", + source: entry.source, + location: installedLocations.join(", "), + }); + } else { + results.push({ + name, + status: "missing", + source: entry.source, + }); + } + } + + for (const [name, locations] of installedSkills.entries()) { + if (!lockedSkills[name]) { + results.push({ + name, + status: "orphaned", + location: locations.join(", "), + }); + } + } + + if (opts.json) { + console.log(JSON.stringify(results, null, 2)); + return; + } + + console.log(""); + info(`SkillHub Lock Check (${scope} scope):`); + console.log(""); + + if (results.length === 0) { + dim(" No skills found."); + console.log(""); + return; + } + + let ok = 0, missing = 0, orphaned = 0; + + for (const r of results) { + if (r.status === "ok") { + ok++; + success(` ✓ ${r.name}`); + dim(` Source: ${r.source}`); + dim(` Location: ${r.location}`); + } else if (r.status === "missing") { + missing++; + error(` ✗ ${r.name}`); + dim(` Source: ${r.source}`); + dim(` Status: NOT INSTALLED`); + } else if (r.status === "orphaned") { + orphaned++; + warn(` ! ${r.name}`); + dim(` Location: ${r.location}`); + dim(` Status: NOT IN LOCK FILE`); + } + } + + console.log(""); + dim(`Lock file: ${lockPath}`); + dim(`Summary: ${ok} OK, ${missing} missing, ${orphaned} orphaned`); + console.log(""); + }); +} diff --git a/skillhub-cli/src/commands/delete.ts b/skillhub-cli/src/commands/delete.ts new file mode 100644 index 00000000..dd338c6b --- /dev/null +++ b/skillhub-cli/src/commands/delete.ts @@ -0,0 +1,40 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig } from "../core/config.js"; +import { success, error } from "../utils/logger.js"; +import { parseSkillName } from "../core/skill-name.js"; + +export function registerDelete(program: Command) { + program + .command("delete ") + .aliases(["del", "unpublish"]) + .description("Delete a skill you own") + .option("-y, --yes", "Skip confirmation") + .action(async (slug: string, opts: { yes?: boolean }) => { + const { namespace, slug: skillSlug } = parseSkillName(slug); + if (!opts.yes) { + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => + rl.question(`Delete ${skillSlug} from ${namespace}? This cannot be undone. [y/N] `, r) + ); + rl.close(); + if (answer.toLowerCase() !== "y") { + console.log("Cancelled."); + return; + } + } + + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + await client.delete(`/api/v1/skills/${namespace}/${skillSlug}`); + success(`Deleted ${skillSlug} from ${namespace}`); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts new file mode 100644 index 00000000..23763156 --- /dev/null +++ b/skillhub-cli/src/commands/download.ts @@ -0,0 +1,59 @@ +import { Command } from "commander"; +import { createWriteStream } from "node:fs"; +import { resolve } from "node:path"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes } from "../schema/routes.js"; +import { loadConfig } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import { success, error } from "../utils/logger.js"; +import { parseSkillName } from "../core/skill-name.js"; +import ora from "ora"; + +export function registerDownload(program: Command) { + program + .command("download ") + .description("Download a skill package to local directory") + .option("-v, --skill-version ", "Specific version") + .option("--tag ", "Tag to download", "latest") + .option("--output ", "Output directory") + .action(async (slug: string, opts: Record) => { + const { namespace, slug: skillSlug } = parseSkillName(slug); + const config = loadConfig(); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + const outputDir = opts.output ? resolve(process.cwd(), opts.output) : process.cwd(); + + try { + const spinner = ora(`Downloading ${skillSlug} from ${namespace}`).start(); + + let downloadUrl = `${ApiRoutes.skillDownload.replace("{namespace}", namespace).replace("{slug}", skillSlug)}`; + if (opts["skill-version"]) { + downloadUrl = `/api/v1/skills/${namespace}/${skillSlug}/versions/${opts["skill-version"]}/download`; + } else if (opts.tag) { + downloadUrl = `/api/v1/skills/${namespace}/${skillSlug}/tags/${opts.tag}/download`; + } + + const { request } = await import("undici"); + const url = new URL(downloadUrl, config.registry); + const { statusCode, body } = await request(url.toString(), { + method: "GET", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + + if (statusCode >= 400) { + spinner.fail(`Download failed: HTTP ${statusCode}`); + process.exit(1); + } + + const outPath = resolve(outputDir, `${skillSlug}.zip`); + const fileStream = createWriteStream(outPath); + await body.pipe(fileStream); + + spinner.succeed(`Downloaded ${skillSlug} to ${outPath}`); + } catch (e: any) { + error(`Download failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/explore.ts b/skillhub-cli/src/commands/explore.ts new file mode 100644 index 00000000..9944e720 --- /dev/null +++ b/skillhub-cli/src/commands/explore.ts @@ -0,0 +1,278 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfig } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import { ApiRoutes } from "../schema/routes.js"; +import { info, dim } from "../utils/logger.js"; +import * as readline from "readline"; +import { searchSkills, type SearchSkill } from "../core/interactive-search.js"; + +const HIDE_CURSOR = "\x1b[?25l"; +const SHOW_CURSOR = "\x1b[?25h"; +const CLEAR_DOWN = "\x1b[J"; +const MOVE_UP = (n: number) => `\x1b[${n}A`; +const MOVE_TO_COL = (n: number) => `\x1b[${n}G`; + +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const DIM = "\x1b[38;5;102m"; +const TEXT = "\x1b[38;5;145m"; +const CYAN = "\x1b[36m"; +const YELLOW = "\x1b[33m"; +const GREEN = "\x1b[32m"; + +function formatInstalls(count: number): string { + if (!count || count <= 0) return ""; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`; + return `${count} install${count === 1 ? "" : "s"}`; +} + +interface SkillDetail { + starCount: number; + downloadCount: number; + version: string; +} + +async function fetchSkillDetail(client: ApiClient, namespace: string, name: string): Promise { + try { + const detail = await client.get( + `${ApiRoutes.skillDetail.replace("{namespace}", namespace).replace("{slug}", name)}` + ); + return detail; + } catch { + return null; + } +} + +async function runInteractiveSearch( + client: ApiClient, + initialQuery: string = "" +): Promise { + const MAX_VISIBLE = 8; + let query = initialQuery; + let results: SearchSkill[] = []; + let selectedIndex = 0; + let loading = false; + let lastRenderedLines = 0; + let debounceTimer: ReturnType | null = null; + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const width = process.stdout.columns || 80; + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdout.write(HIDE_CURSOR); + + function render(): void { + if (lastRenderedLines > 0) { + process.stdout.write(MOVE_UP(lastRenderedLines) + MOVE_TO_COL(1)); + } + process.stdout.write(CLEAR_DOWN); + + const lines: string[] = []; + + const cursor = `${BOLD}_${RESET}`; + const searchLine = `${TEXT}Search skills:${RESET} ${query}${cursor}`; + lines.push(searchLine); + lines.push(""); + + if (!query || query.length < 2) { + lines.push(`${DIM}Start typing to search (min 2 chars)${RESET}`); + } else if (results.length === 0 && loading) { + lines.push(`${DIM}Searching...${RESET}`); + } else if (results.length === 0) { + lines.push(`${DIM}No skills found${RESET}`); + } else { + const visible = results.slice(0, MAX_VISIBLE); + for (let i = 0; i < visible.length; i++) { + const skill = visible[i]!; + const isSelected = i === selectedIndex; + const arrow = isSelected ? `${BOLD}>${RESET}` : " "; + const name = isSelected ? `${BOLD}${skill.name}${RESET}` : `${TEXT}${skill.name}${RESET}`; + const nsBadge = skill.namespace !== "global" ? ` ${YELLOW}[${skill.namespace}]${RESET}` : ""; + const versionBadge = skill.version ? ` ${DIM}v${skill.version}${RESET}` : ""; + const loadingIndicator = loading && i === 0 ? ` ${DIM}...${RESET}` : ""; + + lines.push(` ${arrow} ${name}${nsBadge}${versionBadge}${loadingIndicator}`); + } + } + + lines.push(""); + lines.push(`${DIM}up/down navigate | enter select | esc cancel${RESET}`); + + for (const line of lines) { + process.stdout.write(line + "\n"); + } + + lastRenderedLines = lines.length; + } + + function triggerSearch(q: string): void { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + + loading = false; + + if (!q || q.length < 2) { + results = []; + selectedIndex = 0; + render(); + return; + } + + loading = true; + render(); + + const debounceMs = Math.max(150, 350 - q.length * 50); + + debounceTimer = setTimeout(async () => { + try { + results = await searchSkills(client, q); + selectedIndex = 0; + } catch { + results = []; + } finally { + loading = false; + debounceTimer = null; + render(); + } + }, debounceMs); + } + + if (initialQuery) { + triggerSearch(initialQuery); + } + render(); + + return new Promise((resolve) => { + function cleanup(): void { + process.stdin.removeListener("keypress", handleKeypress); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdout.write(SHOW_CURSOR); + process.stdin.pause(); + rl.close(); + } + + function handleKeypress(_ch: string | undefined, key: readline.Key): void { + if (!key) return; + + if (key.name === "escape" || (key.ctrl && key.name === "c")) { + cleanup(); + resolve(null); + return; + } + + if (key.name === "return") { + cleanup(); + resolve(results[selectedIndex] ? `${results[selectedIndex].namespace}/${results[selectedIndex].name}` : null); + return; + } + + if (key.name === "up" || key.name === "k") { + selectedIndex = Math.max(0, selectedIndex - 1); + render(); + return; + } + + if (key.name === "down" || key.name === "j") { + selectedIndex = Math.min(Math.max(0, results.length - 1), selectedIndex + 1); + render(); + return; + } + + if (key.name === "backspace") { + if (query.length > 0) { + query = query.slice(0, -1); + triggerSearch(query); + } + return; + } + + if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) { + const char = key.sequence; + if (char >= " " && char <= "~") { + query += char; + triggerSearch(query); + } + } + } + + process.stdin.on("keypress", handleKeypress); + }); +} + +export function registerExplore(program: Command) { + program + .command("explore") + .aliases(["find", "find-skills", "search"]) + .description("Browse or search skills from the registry") + .argument("[query]", "Search query for finding skills") + .option("-n, --limit ", "Max results", "20") + .action(async (query: string | undefined, opts: { limit: string }) => { + const config = loadConfig(); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + try { + if (!query) { + const selected = await runInteractiveSearch(client, ""); + if (!selected) { + console.log("\nCancelled."); + return; + } + info(`\nSelected: ${selected}`); + dim("Run: skillhub install " + selected); + return; + } + + const results = await searchSkills(client, query, parseInt(opts.limit, 10)); + + if (results.length === 0) { + console.log(`${DIM}No skills found for "${query}"${RESET}`); + return; + } + + const maxResults = Math.min(results.length, 6); + + const detailPromises = results.slice(0, maxResults).map((s) => + fetchSkillDetail(client, s.namespace, s.name) + ); + const details = await Promise.all(detailPromises); + + console.log(`${DIM}Install with${RESET} skillhub install `); + console.log(); + + for (let i = 0; i < maxResults; i++) { + const skill = results[i]!; + const detail = details[i]; + const slug = `${skill.namespace}--${skill.name}`; + const nsBadge = skill.namespace !== "global" ? ` ${YELLOW}[${skill.namespace}]${RESET}` : ""; + const stars = detail?.starCount ? ` ${YELLOW}⭐ ${detail.starCount}${RESET}` : ""; + const downloads = detail?.downloadCount ? ` ${CYAN}↓ ${formatInstalls(detail.downloadCount)}${RESET}` : ""; + + console.log(`${TEXT}${skill.name}${RESET}${nsBadge}${stars}${downloads}`); + console.log(`${DIM}└ skillhub install ${skill.namespace}/${skill.name}${RESET}`); + if (skill.summary) { + console.log(`${DIM} ${skill.summary.slice(0, 60)}${RESET}`); + } + console.log(); + } + + dim("Tip: Use skillhub explore without args for interactive mode"); + console.log(""); + } catch (e: any) { + console.log(`Error: ${e.message}`); + } + }); +} diff --git a/skillhub-cli/src/commands/hide.ts b/skillhub-cli/src/commands/hide.ts new file mode 100644 index 00000000..7863c43c --- /dev/null +++ b/skillhub-cli/src/commands/hide.ts @@ -0,0 +1,88 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig } from "../core/config.js"; +import { success, error } from "../utils/logger.js"; +import { parseSkillName } from "../core/skill-name.js"; + +export function registerHide(program: Command) { + const hideCmd = program + .command("hide ") + .description("Hide a skill (admin only)") + .option("-y, --yes", "Skip confirmation") + .action(async (slug: string, opts: { yes?: boolean }) => { + const { namespace, slug: skillSlug } = parseSkillName(slug); + if (!opts.yes) { + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => + rl.question(`Hide ${skillSlug} from ${namespace}? [y/N] `, r) + ); + rl.close(); + if (answer.toLowerCase() !== "y") { + console.log("Cancelled."); + return; + } + } + + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + + const detail = await client.get<{ id: number }>( + `/api/v1/skills/${namespace}/${skillSlug}` + ); + + await client.post(`/api/v1/admin/skills/${detail.id}/hide`, { + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }); + + success(`Hidden ${skillSlug}`); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); + + hideCmd + .command("unhide ") + .description("Unhide a skill (admin only)") + .option("-y, --yes", "Skip confirmation") + .action(async (slug: string, opts: { yes?: boolean }) => { + const { namespace, slug: skillSlug } = parseSkillName(slug); + if (!opts.yes) { + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => + rl.question(`Unhide ${skillSlug} from ${namespace}? [y/N] `, r) + ); + rl.close(); + if (answer.toLowerCase() !== "y") { + console.log("Cancelled."); + return; + } + } + + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + + const detail = await client.get<{ id: number }>( + `/api/v1/skills/${namespace}/${skillSlug}` + ); + + await client.post(`/api/v1/admin/skills/${detail.id}/unhide`, { + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }); + + success(`Unhidden ${skillSlug}`); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/init.ts b/skillhub-cli/src/commands/init.ts new file mode 100644 index 00000000..0ddafed0 --- /dev/null +++ b/skillhub-cli/src/commands/init.ts @@ -0,0 +1,46 @@ +import { Command } from "commander"; +import { mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { success, error } from "../utils/logger.js"; + +export function registerInit(program: Command) { + program + .command("init [name]") + .description("Create a new SKILL.md template") + .action((name?: string) => { + const dir = name ? resolve(process.cwd(), name) : process.cwd(); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const skillMd = join(dir, "SKILL.md"); + if (existsSync(skillMd)) { + error("SKILL.md already exists"); + process.exit(1); + } + + const slug = name || "my-skill"; + const content = `--- +name: ${slug} +description: What this skill does and when to use it +--- + +# ${slug} + +Instructions for the agent to follow when this skill is activated. + +## When to Use + +Describe the scenarios where this skill should be used. + +## Steps + +1. First, do this +2. Then, do that +`; + + writeFileSync(skillMd, content); + success(`Created SKILL.md at ${skillMd}`); + }); +} diff --git a/skillhub-cli/src/commands/inspect.ts b/skillhub-cli/src/commands/inspect.ts new file mode 100644 index 00000000..8acbea8e --- /dev/null +++ b/skillhub-cli/src/commands/inspect.ts @@ -0,0 +1,136 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes } from "../schema/routes.js"; +import { loadConfig } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import { parseSkillName } from "../core/skill-name.js"; +import { info, dim, error } from "../utils/logger.js"; + +interface SkillDetailResponse { + id: number; + namespace: string; + slug: string; + displayName: string; + ownerDisplayName: string; + summary: string; + visibility: string; + status: string; + starCount: number; + downloadCount: number; + labels: Array<{ slug: string; name: string }>; + publishedVersion?: { version: string }; +} + +interface NamespaceInfo { + slug: string; + displayName: string; + currentUserRole: string; + status: string; +} + +function printSkillDetail(detail: SkillDetailResponse) { + console.log(""); + info(`${detail.displayName} (${detail.slug})`); + dim(`Namespace: ${detail.namespace}`); + dim(`Version: ${detail.publishedVersion?.version || "N/A"}`); + dim(`Author: ${detail.ownerDisplayName}`); + dim(`Stars: ${detail.starCount} Downloads: ${detail.downloadCount}`); + if (detail.summary) console.log(`\n${detail.summary}`); + dim(`Status: ${detail.status}`); + if (detail.labels && detail.labels.length > 0) { + dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`); + } + console.log(""); +} + +function printInspectHeader(detail: SkillDetailResponse) { + console.log(""); + info(`=== ${detail.displayName} ===`); + dim(`Namespace: ${detail.namespace}`); + dim(`Slug: ${detail.slug}`); + dim(`Version: ${detail.publishedVersion?.version || "N/A"}`); + dim(`Author: ${detail.ownerDisplayName}`); + console.log(""); + info("Summary:"); + console.log(` ${detail.summary || "N/A"}`); + console.log(""); + dim(`Stars: ${detail.starCount} · Downloads: ${detail.downloadCount}`); + dim(`Visibility: ${detail.visibility} · Status: ${detail.status}`); + if (detail.labels && detail.labels.length > 0) { + console.log(""); + dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`); + } + console.log(""); +} + +export function registerInspect(program: Command) { + program + .command("inspect ") + .aliases(["info", "view"]) + .description("View skill metadata without installing") + .option("--namespace ", "Search in specific namespace (searches all if not specified)") + .action(async (slug: string, opts: { namespace?: string }) => { + const config = loadConfig(); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + const isJson = program.opts().json; + const { namespace: defaultNs, slug: parsedSlug } = parseSkillName(slug, ""); + const targetNamespace = opts.namespace || defaultNs; + + if (targetNamespace) { + const detail = await client.get( + `${ApiRoutes.skillDetail.replace("{namespace}", targetNamespace).replace("{slug}", parsedSlug)}` + ); + if (isJson) { + console.log(JSON.stringify(detail, null, 2)); + } else { + printSkillDetail(detail); + } + return; + } + + const namespaces = await client.get(ApiRoutes.meNamespaces); + + if (!namespaces || namespaces.length === 0) { + error("No namespaces found. You may need to log in."); + process.exit(1); + } + + const searchPromises = namespaces.map(async (ns) => { + try { + const detail = await client.get( + `${ApiRoutes.skillDetail.replace("{namespace}", ns.slug).replace("{slug}", parsedSlug)}` + ); + return { found: true, detail, namespace: ns.slug }; + } catch { + return { found: false, detail: null, namespace: ns.slug }; + } + }); + + const results = await Promise.all(searchPromises); + const matches = results.filter((r) => r.found && r.detail).map((r) => r.detail!); + + if (matches.length === 0) { + error(`Skill not found: ${parsedSlug}`); + if (namespaces.length > 1) { + dim(`Tried namespaces: ${namespaces.map((n) => n.slug).join(", ")}`); + } + process.exit(1); + } + + if (isJson) { + if (matches.length === 1) { + console.log(JSON.stringify(matches[0], null, 2)); + } else { + console.log(JSON.stringify(matches, null, 2)); + } + } else if (matches.length === 1) { + printSkillDetail(matches[0]); + } else { + for (const detail of matches) { + printInspectHeader(detail); + } + } + }); +} diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts new file mode 100644 index 00000000..76c8bca2 --- /dev/null +++ b/skillhub-cli/src/commands/install.ts @@ -0,0 +1,759 @@ +import { Command } from "commander"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createWriteStream, existsSync, mkdirSync } from "node:fs"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfig } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import { discoverSkills } from "../core/skill-discovery.js"; +import { installSkill } from "../core/installer.js"; +import { getAllAgents, detectInstalledAgents, getUniversalAgents, getNonUniversalAgents, isUniversalAgent } from "../core/agent-detector.js"; +import { parseSource, getCloneUrl } from "../core/source-parser.js"; +import { addToLock } from "../core/skill-lock.js"; +import { success, error, info, dim } from "../utils/logger.js"; +import { multiSelect, sectionMultiSelect } from "../utils/prompts.js"; +import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; +import { runInteractiveSearch, searchSkills } from "../core/interactive-search.js"; +import type { SkillVersionItem } from "./versions.js"; + +interface SkillTag { + id: number; + tagName: string; + versionId: number; + createdAt: string; +} +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import ora from "ora"; +import { execSync } from "node:child_process"; +import { finished } from "node:stream/promises"; + +export type SourceType = "auto" | "registry" | "git" | "local"; + +function detectSourceType(arg: string): SourceType { + if (arg.startsWith(".") || arg.startsWith("/") || arg.startsWith("~")) { + return "local"; + } + if (arg.includes("github.com") || arg.includes("gitlab.com") || arg.includes("://") || arg.endsWith(".git")) { + return "git"; + } + if (/^[\w-]+\/[\w-]+$/.test(arg)) { + return "registry"; + } + return "registry"; +} + +function getInstallSpinner(sourceType: SourceType, arg: string): string { + if (sourceType === "registry") { + return `Fetching ${arg}`; + } + return `Resolving ${arg}`; +} + +async function selectAgentsInteractive(isGlobal: boolean): Promise { + const universalAgents = getUniversalAgents(); + const nonUniversalAgents = getNonUniversalAgents(); + + const lockedSection = { + title: "Universal (.agents/skills)", + items: universalAgents.map((a) => ({ + value: a.key, + label: a.name, + })), + }; + + const selectableItems = nonUniversalAgents.map((a) => ({ + value: a.key, + label: a.name, + hint: isGlobal ? (a.globalSkillsDir || a.skillsDir) : a.skillsDir, + })); + + const result = await searchMultiselect({ + message: "Which agents do you want to install to?", + items: selectableItems, + lockedSection, + }); + + if (result === cancelSymbol) { + return null; + } + + return result as string[]; +} + +async function selectInstallMode(): Promise<"symlink" | "copy" | null> { + const result = await searchMultiselect({ + message: "Installation method?", + items: [ + { value: "symlink", label: "Symlink (Recommended)", hint: "single source of truth" }, + { value: "copy", label: "Copy to all agents", hint: "independent copies" }, + ], + initialSelected: ["symlink"], + }); + + if (result === cancelSymbol) { + return null; + } + + if ((result as string[]).includes("symlink")) { + return "symlink"; + } + return "copy"; +} + +function buildAgentSummary(targetAgents: { key: string; name: string; skillsDir: string }[], mode: "symlink" | "copy"): string[] { + const lines: string[] = []; + const universal = targetAgents.filter((a) => isUniversalAgent(a)); + const symlinked = targetAgents.filter((a) => !isUniversalAgent(a)); + + if (mode === "symlink") { + if (universal.length > 0) { + lines.push(` universal: ${universal.map((a) => a.name).join(", ")}`); + } + if (symlinked.length > 0) { + lines.push(` symlink → ${symlinked.map((a) => a.name).join(", ")}`); + } + } else { + lines.push(` copy → ${targetAgents.map((a) => a.name).join(", ")}`); + } + + return lines; +} + +export function registerInstall(program: Command) { + program + .command("install ") + .alias("i") + .description("Install skills from registry, git repositories, or local paths") + .option("-a, --add ", "Install from GitHub or local path (alias for --from)") + .option("--from ", "Install from GitHub or local path (alias for -a)") + .option("--agent ", "Target specific agents") + .option("-g, --global", "Install to global scope") + .option("-y, --yes", "Skip all prompts") + .option("--copy", "Copy instead of symlink") + .option("--list", "List available skills without installing") + .option("-v, --skill-version ", "Install specific version (non-interactive)") + .option("--tag ", "Install specific tag (non-interactive, resolves to version)") + .action(async (source: string, opts: Record) => { + const fromSource = (opts.from || opts.add) as string | undefined; + + let effectiveSource: SourceType; + let installSource = source; + + if (fromSource) { + effectiveSource = detectSourceType(fromSource); + installSource = fromSource; + } else { + effectiveSource = detectSourceType(source); + } + + const spinner = ora(getInstallSpinner(effectiveSource, installSource)).start(); + + try { + if (effectiveSource === "registry") { + await installFromRegistry(source, opts, spinner); + } else { + await installFromGit(source, installSource, effectiveSource, opts, spinner); + } + } catch (e: any) { + spinner.fail(e.message); + process.exit(1); + } + }); +} + +async function installFromRegistry(slug: string, opts: Record, spinner: any) { + let ns = "global"; + let actualSlug = slug; + let userSpecifiedNamespace = false; + + if (slug.includes("/") && !slug.startsWith("/")) { + const parts = slug.split("/"); + if (parts.length === 2) { + ns = parts[0]; + actualSlug = parts[1]; + userSpecifiedNamespace = true; + } + } + + const config = loadConfig(); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + if (!userSpecifiedNamespace) { + const results = await searchSkills(client, actualSlug, 50); + + // Deduplicate by namespace/name + const seen = new Set(); + const uniqueResults = results.filter(r => { + const key = `${r.namespace}/${r.name}`; + if (!seen.has(key)) { + seen.add(key); + return true; + } + return false; + }); + + if (uniqueResults.length === 0) { + spinner.fail(`Skill not found: ${actualSlug}`); + process.exit(1); + } + + if (uniqueResults.length === 1) { + ns = uniqueResults[0].namespace; + actualSlug = uniqueResults[0].name; + } else { + const selected = await runInteractiveSearch(client, actualSlug); + if (!selected) { + console.log("Cancelled."); + return; + } + const [selectedNs, selectedName] = selected.split("/", 2); + ns = selectedNs; + actualSlug = selectedName; + } + } + + spinner.text = `Fetching ${ns}/${actualSlug}`; + + // Fetch versions and tags for selection + const [versionsResp, tagsResp] = await Promise.all([ + client.get<{ items: SkillVersionItem[] }>(`/api/v1/skills/${ns}/${actualSlug}/versions`), + client.get(`/api/v1/skills/${ns}/${actualSlug}/tags`).catch(() => [] as SkillTag[]), + ]); + + const versions = versionsResp.items || []; + + // Map tags to versions by versionId + const versionTagsMap = new Map(); + for (const tag of tagsResp || []) { + if (!versionTagsMap.has(tag.versionId)) { + versionTagsMap.set(tag.versionId, []); + } + versionTagsMap.get(tag.versionId)!.push(tag.tagName); + } + + // Present version selection + let selectedVersion: string = "latest"; + if (opts.yes && opts["skill-version"]) { + // Non-interactive: use command-line version if provided + selectedVersion = opts["skill-version"] as string; + } else if (opts.yes && opts.tag) { + // Non-interactive: resolve tag to version + for (const [vid, tags] of versionTagsMap) { + if (tags.includes(opts.tag as string)) { + const v = versions.find((ver) => ver.id === vid); + if (v) { + selectedVersion = v.version; + break; + } + } + } + if (!selectedVersion) { + // Fallback: use latest if tag not found + selectedVersion = versions[0]?.version || "latest"; + } + } else { + // Interactive: show version selection + const picked = await p.select({ + message: "Select version", + options: versions.map((v) => ({ + value: v.version, + label: `v${v.version}`, + hint: versionTagsMap.get(v.id)?.join(", ") || "", + })), + }); + + if (p.isCancel(picked)) { + console.log("Cancelled."); + return; + } + + selectedVersion = picked as string; + } + + const baseUrl = config.registry.replace(/\/$/, ""); + const downloadUrl = `${baseUrl}/api/v1/skills/${ns}/${actualSlug}/versions/${selectedVersion}/download`; + const tmpDir = await mkdtemp(join(tmpdir(), "skillhub-install-")); + const zipPath = join(tmpDir, `${actualSlug}.zip`); + + spinner.text = "Downloading"; + + const { request } = await import("undici"); + const { statusCode, body } = await request(downloadUrl, { + method: "GET", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + + if (statusCode >= 400) { + spinner.fail(`Skill not found: ${ns}/${actualSlug}`); + await rm(tmpDir, { recursive: true, force: true }); + process.exit(1); + } + + const fileStream = createWriteStream(zipPath); + await finished(body.pipe(fileStream)); + + spinner.text = "Extracting"; + const extractDir = join(tmpDir, "extracted"); + mkdirSync(extractDir, { recursive: true }); + execSync(`unzip -o "${zipPath}" -d "${extractDir}"`, { stdio: "pipe" }); + + const skills = discoverSkills(extractDir); + if (skills.length === 0) { + spinner.fail("No SKILL.md found in package"); + process.exit(1); + } + + spinner.succeed(`Found ${skills.length} skill(s) in ${ns}/${actualSlug}`); + + if (opts.list) { + for (const s of skills) { + info(`${s.name}`); + dim(` ${s.description}`); + } + return; + } + + let selectedSkills = skills; + if (!opts.yes && skills.length > 1) { + const selected = await searchMultiselect({ + message: "Select skills to install", + items: skills.map((s) => ({ value: s.name, label: s.name, hint: s.description })), + }); + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + selectedSkills = skills.filter((s) => (selected as string[]).includes(s.name)); + } + + let isGlobal = !!opts.global; + + let targetAgents = opts.agent + ? getAllAgents().filter((a) => (opts.agent as string[]).includes(a.key)) + : detectInstalledAgents(); + + if (targetAgents.length === 0) { + const claude = getAllAgents().find((a) => a.key === "claude-code"); + if (claude) targetAgents.push(claude); + } + + let mode: "symlink" | "copy" = opts.copy ? "copy" : "symlink"; + + if (!opts.yes && !opts.agent) { + const selected = await selectAgentsInteractive(isGlobal); + if (!selected) { + console.log("Cancelled."); + return; + } + targetAgents = getAllAgents().filter((a) => selected.includes(a.key)); + } + + const supportsGlobal = targetAgents.some((a) => a.globalSkillsDir); + + if (opts.global === undefined && !opts.yes && supportsGlobal) { + const scope = await p.select({ + message: "Installation scope", + options: [ + { + value: false, + label: "Project", + hint: "Install in current directory (committed with your project)", + }, + { + value: true, + label: "Global", + hint: "Install in home directory (available across all projects)", + }, + ], + }); + + if (p.isCancel(scope)) { + console.log("Cancelled."); + return; + } + + isGlobal = scope as boolean; + } + + // Only prompt for install mode when there are multiple unique target directories. + // When all selected agents share the same skillsDir, symlink vs copy is meaningless. + const uniqueDirs = new Set(targetAgents.map((a) => + isGlobal ? (a.globalSkillsDir || a.skillsDir) : a.skillsDir + )); + + if (uniqueDirs.size <= 1) { + // Single target directory — default to copy (no symlink needed) + mode = 'copy'; + } else if (!opts.yes) { + const selectedMode = await selectInstallMode(); + if (selectedMode === null) { + console.log("Cancelled."); + return; + } + mode = selectedMode; + } + + const cwd = process.cwd(); + const summaryLines: string[] = []; + + for (const skill of selectedSkills) { + if (summaryLines.length > 0) summaryLines.push(""); + const canonicalPath = isGlobal + ? `~/.agents/skills/${skill.name}` + : `./.agents/skills/${skill.name}`; + summaryLines.push(`${pc.cyan(canonicalPath)}`); + for (const line of buildAgentSummary(targetAgents, mode)) { + summaryLines.push(` ${line}`); + } + } + summaryLines.push(""); + summaryLines.push(`${pc.dim("Mode:")} ${mode}`); + summaryLines.push(`${pc.dim("Scope:")} ${isGlobal ? "global" : "project"}`); + + console.log(""); + p.note(summaryLines.join("\n"), "Installation Summary"); + + if (!opts.yes) { + const confirmed = await p.confirm({ message: "Proceed with installation?" }); + + if (p.isCancel(confirmed) || !confirmed) { + console.log("Cancelled."); + return; + } + } + + spinner.start("Installing skills..."); + + let installed = 0; + let failed = 0; + const results: { skill: string; agent: string; success: boolean; path: string; error?: string }[] = []; + + for (const skill of selectedSkills) { + for (const agent of targetAgents) { + const result = installSkill( + skill.dir, + skill.name, + agent.key, + isGlobal ? agent.globalSkillsDir || agent.skillsDir : agent.skillsDir, + mode, + isGlobal, + ); + results.push({ + skill: skill.name, + agent: agent.name, + success: result.success, + path: result.path || "", + error: result.error, + }); + if (result.success) { + installed++; + await addToLock(skill.name, { + source: `${ns}/${slug}`, + sourceType: "registry", + sourceUrl: `${config.registry}/api/v1/skills/${ns}/${slug}`, + namespace: ns, + slug: skill.name, + version: "latest", + }); + } else { + failed++; + error(`Failed to install ${skill.name} to ${agent.name}: ${result.error}`); + } + } + } + + spinner.stop("Installation complete"); + + console.log(""); + const successful = results.filter((r) => r.success); + + if (successful.length > 0) { + const resultLines: string[] = []; + for (const skill of selectedSkills) { + const skillResults = results.filter((r) => r.skill === skill.name && r.success); + if (skillResults.length > 0) { + resultLines.push(`${pc.green("✓")} ${skill.name}`); + for (const r of skillResults) { + resultLines.push(` ${pc.dim("→")} ${r.agent}: ${r.path}`); + } + } + } + p.note(resultLines.join("\n"), `Installed ${successful.length} skill(s)`); + } + + if (failed > 0) { + p.log.error(pc.red(`Failed to install ${failed}`)); + for (const r of results.filter((r) => !r.success)) { + p.log.message(`${pc.red("✗")} ${r.skill} → ${r.agent}: ${pc.dim(r.error || "unknown error")}`); + } + } + + console.log(""); + p.outro(pc.green("Done!") + pc.dim(" Review skills before use; they run with full agent permissions.")); + + await rm(tmpDir, { recursive: true, force: true }); +} + +async function installFromGit(skillName: string, source: string, sourceType: SourceType, opts: Record, spinner: any) { + let skillsDir: string; + + const parsed = parseSource(source); + + if (parsed.skillFilter) { + opts.skill = opts.skill || []; + if (!Array.isArray(opts.skill)) { + opts.skill = [opts.skill as string]; + } + if (!opts.skill.includes(parsed.skillFilter)) { + opts.skill.push(parsed.skillFilter); + } + } + + // If skillName is a skill identifier (not a path), use it to filter + if (skillName && !skillName.startsWith(".") && !skillName.startsWith("/") && !skillName.startsWith("~")) { + opts.skill = opts.skill || []; + if (!Array.isArray(opts.skill)) { + opts.skill = [opts.skill as string]; + } + if (!opts.skill.includes(skillName)) { + opts.skill.push(skillName); + } + } + + if (parsed.type === "local") { + skillsDir = parsed.localPath!; + spinner.text = "Scanning local directory"; + } else { + const cloneUrl = getCloneUrl(parsed); + spinner.text = `Cloning ${cloneUrl}`; + const tmpDir = await mkdtemp(join(tmpdir(), "skillhub-install-")); + const refArg = parsed.ref ? `--branch ${parsed.ref}` : ""; + const depth = parsed.ref ? "" : "--depth 1"; + execSync(`git clone ${depth} ${refArg} ${cloneUrl} ${tmpDir}`, { stdio: "pipe" }); + skillsDir = tmpDir; + + process.on("exit", () => { rm(tmpDir, { recursive: true, force: true }).catch(() => {}); }); + } + + spinner.text = "Discovering skills"; + const skills = discoverSkills(skillsDir); + + if (skills.length === 0) { + spinner.fail("No skills found. Ensure the directory contains SKILL.md files."); + process.exit(1); + } + + spinner.succeed(`Found ${skills.length} skill(s)`); + + if (opts.list) { + for (const s of skills) { + info(`${s.name}`); + dim(` ${s.description}`); + } + return; + } + + let selectedSkills = skills; + if (opts.skill) { + const skillNames = opts.skill as string[]; + if (skillNames.includes("*")) { + selectedSkills = skills; + } else { + selectedSkills = skills.filter((s) => skillNames.includes(s.name)); + if (selectedSkills.length === 0) { + error(`No matching skills for: ${skillNames.join(", ")}`); + info("Available: " + skills.map((s) => s.name).join(", ")); + process.exit(1); + } + } + } else if (!opts.yes && skills.length > 1) { + const selected = await searchMultiselect({ + message: "Select skills to install", + items: skills.map((s) => ({ value: s.name, label: s.name, hint: s.description })), + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + selectedSkills = skills.filter((s) => (selected as string[]).includes(s.name)); + } + + let isGlobal = !!opts.global; + + let targetAgents = opts.agent + ? getAllAgents().filter((a) => (opts.agent as string[]).includes(a.key)) + : detectInstalledAgents(); + + if (targetAgents.length === 0) { + const all = getAllAgents(); + if (!opts.yes) { + info("No agents detected. Installing to Claude Code by default."); + } + const claude = all.find((a) => a.key === "claude-code"); + if (claude) targetAgents.push(claude); + else targetAgents.push(all[0]); + } + + let mode: "symlink" | "copy" = opts.copy ? "copy" : "symlink"; + + if (!opts.yes && !opts.agent) { + const selected = await selectAgentsInteractive(isGlobal); + if (!selected) { + console.log("Cancelled."); + return; + } + targetAgents = getAllAgents().filter((a) => selected.includes(a.key)); + } + + const supportsGlobal = targetAgents.some((a) => a.globalSkillsDir); + + if (opts.global === undefined && !opts.yes && supportsGlobal) { + const scope = await p.select({ + message: "Installation scope", + options: [ + { + value: false, + label: "Project", + hint: "Install in current directory (committed with your project)", + }, + { + value: true, + label: "Global", + hint: "Install in home directory (available across all projects)", + }, + ], + }); + + if (p.isCancel(scope)) { + console.log("Cancelled."); + return; + } + + isGlobal = scope as boolean; + } + + // Only prompt for install mode when there are multiple unique target directories. + // When all selected agents share the same skillsDir, symlink vs copy is meaningless. + const uniqueDirs = new Set(targetAgents.map((a) => + isGlobal ? (a.globalSkillsDir || a.skillsDir) : a.skillsDir + )); + + if (uniqueDirs.size <= 1) { + // Single target directory — default to copy (no symlink needed) + mode = 'copy'; + } else if (!opts.yes) { + const selectedMode = await selectInstallMode(); + if (selectedMode === null) { + console.log("Cancelled."); + return; + } + mode = selectedMode; + } + + const cwd = process.cwd(); + const summaryLines: string[] = []; + + for (const skill of selectedSkills) { + if (summaryLines.length > 0) summaryLines.push(""); + const canonicalPath = isGlobal + ? `~/.agents/skills/${skill.name}` + : `./.agents/skills/${skill.name}`; + summaryLines.push(`${pc.cyan(canonicalPath)}`); + for (const line of buildAgentSummary(targetAgents, mode)) { + summaryLines.push(` ${line}`); + } + } + summaryLines.push(""); + summaryLines.push(`${pc.dim("Mode:")} ${mode}`); + summaryLines.push(`${pc.dim("Scope:")} ${isGlobal ? "global" : "project"}`); + + console.log(""); + p.note(summaryLines.join("\n"), "Installation Summary"); + + if (!opts.yes) { + const confirmed = await p.confirm({ message: "Proceed with installation?" }); + + if (p.isCancel(confirmed) || !confirmed) { + console.log("Cancelled."); + return; + } + } + + spinner.start("Installing skills..."); + + let installed = 0; + let failed = 0; + const results: { skill: string; agent: string; success: boolean; path: string; error?: string }[] = []; + + for (const skill of selectedSkills) { + for (const agent of targetAgents) { + const result = installSkill( + skill.dir, + skill.name, + agent.key, + isGlobal ? agent.globalSkillsDir || agent.skillsDir : agent.skillsDir, + mode, + isGlobal, + ); + results.push({ + skill: skill.name, + agent: agent.name, + success: result.success, + path: result.path || "", + error: result.error, + }); + if (result.success) { + installed++; + const sourceUrl = parsed.type === "local" + ? (parsed.localPath as string) + : getCloneUrl(parsed); + await addToLock(skill.name, { + source: source, + sourceType: parsed.type === "local" ? "local" : "git", + sourceUrl: sourceUrl, + ref: parsed.ref, + namespace: "global", + slug: skill.name, + version: parsed.ref || "main", + }); + } else { + failed++; + error(`Failed to install ${skill.name} to ${agent.name}: ${result.error}`); + } + } + } + + spinner.stop("Installation complete"); + + console.log(""); + const successful = results.filter((r) => r.success); + + if (successful.length > 0) { + const resultLines: string[] = []; + for (const skill of selectedSkills) { + const skillResults = results.filter((r) => r.skill === skill.name && r.success); + if (skillResults.length > 0) { + resultLines.push(`${pc.green("✓")} ${skill.name}`); + for (const r of skillResults) { + resultLines.push(` ${pc.dim("→")} ${r.agent}: ${r.path}`); + } + } + } + p.note(resultLines.join("\n"), `Installed ${successful.length} skill(s)`); + } + + if (failed > 0) { + p.log.error(pc.red(`Failed to install ${failed}`)); + for (const r of results.filter((r) => !r.success)) { + p.log.message(`${pc.red("✗")} ${r.skill} → ${r.agent}: ${pc.dim(r.error || "unknown error")}`); + } + } + + console.log(""); + p.outro(pc.green("Done!") + pc.dim(" Review skills before use; they run with full agent permissions.")); +} diff --git a/skillhub-cli/src/commands/list.ts b/skillhub-cli/src/commands/list.ts new file mode 100644 index 00000000..8b24796b --- /dev/null +++ b/skillhub-cli/src/commands/list.ts @@ -0,0 +1,142 @@ +import { Command } from "commander"; +import { existsSync, readdirSync, lstatSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { getAllAgents, isUniversalAgent, getUniversalAgents, getNonUniversalAgents } from "../core/agent-detector.js"; +import { info, dim } from "../utils/logger.js"; +import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; + +interface ListOptions { + global?: boolean; + project?: boolean; + agent?: string[]; + all?: boolean; +} + +export function registerList(program: Command) { + program + .command("list") + .alias("ls") + .description("List installed skills") + .option("-g, --global", "List global skills only") + .option("-p, --project", "List project skills only") + .option("-a, --all", "List all skills (both global and project)") + .option("--agent ", "Filter by specific agents") + .action(async (opts: ListOptions) => { + let scopeGlobal: boolean | null = null; + + if (opts.global) { + scopeGlobal = true; + } else if (opts.project) { + scopeGlobal = false; + } else { + const scopeSelection = await p.select({ + message: "Which scope to list?", + options: [ + { value: "all", label: "All (global + project)" }, + { value: "global", label: "Global only" }, + { value: "project", label: "Project only" }, + ], + }); + + if (p.isCancel(scopeSelection)) { + console.log("Cancelled."); + return; + } + + if (scopeSelection === "global") { + scopeGlobal = true; + } else if (scopeSelection === "project") { + scopeGlobal = false; + } + } + + const universalAgents = getUniversalAgents(); + const nonUniversalAgents = getNonUniversalAgents(); + + const universalSection = { + title: "Universal (.agents/skills)", + items: universalAgents.map((a) => ({ + value: a.key, + label: a.name, + })), + }; + + const selectableItems = nonUniversalAgents.map((a) => ({ + value: a.key, + label: a.name, + })); + + const agentSelection = await searchMultiselect({ + message: "Which agents to list from?", + items: selectableItems, + lockedSection: universalSection, + }); + + if (agentSelection === cancelSymbol) { + console.log("Cancelled."); + return; + } + + const selectedAgents = agentSelection as string[]; + const agents = getAllAgents().filter((a) => selectedAgents.includes(a.key)); + + if (agents.length === 0) { + console.log("No agents selected."); + return; + } + + console.log(""); + + let found = false; + for (const agent of agents) { + const showProject = scopeGlobal === null || scopeGlobal === false; + const showGlobal = scopeGlobal === null || scopeGlobal === true; + + if (showProject) { + const projectDir = join(process.cwd(), agent.skillsDir); + const skills = getSkillsInDir(projectDir); + if (skills.length > 0) { + found = true; + info(`\n${agent.name} (project):`); + for (const s of skills) { + dim(` ${s}`); + } + } + } + + if (showGlobal && agent.globalSkillsDir) { + const globalDir = join(homedir(), agent.globalSkillsDir); + const skills = getSkillsInDir(globalDir); + if (skills.length > 0) { + found = true; + info(`\n${agent.name} (global):`); + for (const s of skills) { + dim(` ${s}`); + } + } + } + } + + if (!found) { + dim("No skills installed for selected agents and scope."); + } + + console.log(""); + }); +} + +function getSkillsInDir(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir).filter((f) => { + const full = join(dir, f); + try { + const stat = lstatSync(full); + return stat.isDirectory() && existsSync(join(full, "SKILL.md")); + } catch { + return false; + } + }); +} diff --git a/skillhub-cli/src/commands/login.ts b/skillhub-cli/src/commands/login.ts new file mode 100644 index 00000000..9b7d8376 --- /dev/null +++ b/skillhub-cli/src/commands/login.ts @@ -0,0 +1,34 @@ +import { Command } from "commander"; +import { createInterface } from "node:readline"; +import { stdin, stdout } from "node:process"; +import { writeToken } from "../core/auth-token.js"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes, WhoamiResponse } from "../schema/routes.js"; +import { success, error, info } from "../utils/logger.js"; + +export function registerLogin(program: Command) { + program + .command("login") + .description("Authenticate with SkillHub registry") + .option("--token ", "Auth token (skipped prompt)") + .option("--registry ", "Registry URL override") + .action(async (opts: { token?: string; registry?: string }) => { + const rl = createInterface({ input: stdin, output: stdout }); + const ask = (q: string) => new Promise((r) => rl.question(q, r)); + + const token = opts.token || (await ask("Enter your SkillHub token: ")); + rl.close(); + + const registry = opts.registry || "http://localhost:8080"; + const client = new ApiClient({ baseUrl: registry, token }); + + try { + const resp = await client.get(ApiRoutes.whoami); + await writeToken(token); + success(`Authenticated as ${resp.user.displayName} (@${resp.user.handle})`); + } catch (e: any) { + error(`Authentication failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/logout.ts b/skillhub-cli/src/commands/logout.ts new file mode 100644 index 00000000..94c2e582 --- /dev/null +++ b/skillhub-cli/src/commands/logout.ts @@ -0,0 +1,13 @@ +import { Command } from "commander"; +import { removeToken } from "../core/auth-token.js"; +import { success } from "../utils/logger.js"; + +export function registerLogout(program: Command) { + program + .command("logout") + .description("Remove stored authentication token") + .action(async () => { + await removeToken(); + success("Logged out successfully"); + }); +} diff --git a/skillhub-cli/src/commands/me.ts b/skillhub-cli/src/commands/me.ts new file mode 100644 index 00000000..9fb52ba1 --- /dev/null +++ b/skillhub-cli/src/commands/me.ts @@ -0,0 +1,89 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfig } from "../core/config.js"; +import { requireToken } from "../core/auth-token.js"; +import { error, info, dim } from "../utils/logger.js"; + +export interface MeSkillItem { + id: number; + namespace: string; + slug: string; + displayName: string; + status: string; + starCount: number; + downloadCount: number; + headlineVersion?: { version: string }; + publishedVersion?: { version: string }; +} + +export interface MeSkillsResponse { + items: MeSkillItem[]; + total: number; + page: number; + size: number; +} + +export function registerMe(program: Command) { + const me = program.command("me").description("View your skills and stars"); + + me + .command("skills") + .alias("ls") + .description("List your published skills") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + const resp = await client.get("/api/v1/me/skills"); + const skills = resp.items || []; + const isJson = program.opts().json; + if (isJson) { + console.log(JSON.stringify(resp, null, 2)); + } else { + if (skills.length === 0) { + console.log("No skills published yet."); + return; + } + for (const s of skills) { + const version = s.headlineVersion?.version || s.publishedVersion?.version || "unknown"; + info(`${s.displayName} (${s.slug})`); + dim(` ${s.namespace} · v${version} · ⭐ ${s.starCount} · ↓ ${s.downloadCount} · ${s.status}`); + } + } + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); + + me + .command("stars") + .description("List your starred skills") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + const resp = await client.get("/api/v1/me/stars"); + const skills = resp.items || []; + const isJson = program.opts().json; + if (isJson) { + console.log(JSON.stringify(resp, null, 2)); + } else { + if (skills.length === 0) { + console.log("No starred skills."); + return; + } + for (const s of skills) { + const version = s.headlineVersion?.version || s.publishedVersion?.version || "unknown"; + info(`${s.displayName} (${s.slug})`); + dim(` ${s.namespace} · v${version} · ⭐ ${s.starCount}`); + } + } + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/namespaces.ts b/skillhub-cli/src/commands/namespaces.ts new file mode 100644 index 00000000..8359c5ee --- /dev/null +++ b/skillhub-cli/src/commands/namespaces.ts @@ -0,0 +1,35 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes, NamespaceResponse } from "../schema/routes.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig } from "../core/config.js"; +import { error } from "../utils/logger.js"; + +export function registerNamespaces(program: Command) { + program + .command("namespaces") + .description("List namespaces you have access to") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + const namespaces = await client.get(ApiRoutes.meNamespaces); + const isJson = program.opts().json; + if (isJson) { + console.log(JSON.stringify(namespaces, null, 2)); + } else { + if (!namespaces || namespaces.length === 0) { + console.log("No namespaces found."); + return; + } + for (const ns of namespaces) { + console.log(`${ns.slug} — ${ns.displayName} [${ns.currentUserRole}] (${ns.status})`); + } + } + } catch (e: any) { + error(`Failed to list namespaces: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/notifications.ts b/skillhub-cli/src/commands/notifications.ts new file mode 100644 index 00000000..2fd73ec8 --- /dev/null +++ b/skillhub-cli/src/commands/notifications.ts @@ -0,0 +1,78 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfig } from "../core/config.js"; +import { requireToken } from "../core/auth-token.js"; +import { success, error, info, dim } from "../utils/logger.js"; + +export interface Notification { + id: number; + title: string; + message: string; + read: boolean; + createdAt: string; +} + +export function registerNotifications(program: Command) { + const cmd = program + .command("notifications") + .alias("notif") + .description("Manage notifications"); + + cmd + .command("list") + .alias("ls") + .description("List notifications") + .option("--unread", "Show unread only") + .action(async (opts: { unread?: boolean }) => { + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + const notifs = await client.get("/api/v1/notifications"); + const filtered = opts.unread ? notifs.filter((n) => !n.read) : notifs; + if (filtered.length === 0) { + console.log(opts.unread ? "No unread notifications." : "No notifications."); + return; + } + for (const n of filtered) { + info(`${n.read ? "✓" : "○"} ${n.title}`); + dim(` ${n.message} · ${n.createdAt}`); + } + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); + + cmd + .command("read ") + .description("Mark notification as read") + .action(async (id: string) => { + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + await client.put(`/api/v1/notifications/${id}/read`); + success(`Marked notification ${id} as read`); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); + + cmd + .command("read-all") + .description("Mark all notifications as read") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + await client.put("/api/v1/notifications/read-all"); + success("All notifications marked as read"); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/publish.ts b/skillhub-cli/src/commands/publish.ts new file mode 100644 index 00000000..5ea575ba --- /dev/null +++ b/skillhub-cli/src/commands/publish.ts @@ -0,0 +1,84 @@ +import { Command } from "commander"; +import { stat, readFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { FormData } from "undici"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes, PublishResponse } from "../schema/routes.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig } from "../core/config.js"; +import { success, error, info } from "../utils/logger.js"; +import ora from "ora"; +import semver from "semver"; + +export function registerPublish(program: Command) { + program + .command("publish [path]") + .description("Publish a skill to SkillHub registry") + .option("--namespace ", "Target namespace (default: global)") + .option("--slug ", "Skill slug") + .option("-v, --skill-version ", "Version (semver)") + .option("--name ", "Display name") + .option("--changelog ", "Changelog text") + .option("--tag ", "Comma-separated tags (e.g. beta,stable)", "latest") + .action(async (path: string | undefined, opts: Record) => { + const folder = path ? resolve(process.cwd(), path) : process.cwd(); + const folderStat = await stat(folder).catch(() => null); + if (!folderStat || !folderStat.isDirectory()) { + error("Path must be a directory containing SKILL.md"); + process.exit(1); + } + + const slug = opts.slug || basename(folder); + const version = opts["skill-version"] || opts.ver; + if (!version || !semver.valid(version)) { + error("--skill-version must be a valid semver (e.g. 1.0.0)"); + process.exit(1); + } + + const namespace = opts.namespace || "global"; + const changelog = opts.changelog || ""; + const tags = opts.tag.split(",").map((t: string) => t.trim()).filter(Boolean); + + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + + const spinner = ora(`Publishing ${slug}@${version} to ${namespace}`).start(); + + const skillMdPath = resolve(folder, "SKILL.md"); + const skillMdStat = await stat(skillMdPath).catch(() => null); + if (!skillMdStat) { + spinner.fail("SKILL.md not found in directory"); + process.exit(1); + } + + const skillMdContent = await readFile(skillMdPath, "utf-8"); + + const form = new FormData(); + form.set("payload", JSON.stringify({ + slug, + displayName: opts.name || slug, + version, + changelog, + acceptLicenseTerms: true, + tags, + })); + const blob = new Blob([Buffer.from(skillMdContent)], { type: "text/markdown" }); + form.append("files", blob, "SKILL.md"); + + const result = await client.postForm( + ApiRoutes.skills, + form, + { namespace } + ); + + spinner.succeed(`Published ${slug}@${version} (${result.skillId})`); + info(`Namespace: ${result.namespace}`); + info(`Status: ${result.status}`); + } catch (e: any) { + error(`Publish failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/rating.ts b/skillhub-cli/src/commands/rating.ts new file mode 100644 index 00000000..583d4f6a --- /dev/null +++ b/skillhub-cli/src/commands/rating.ts @@ -0,0 +1,71 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfig } from "../core/config.js"; +import { requireToken } from "../core/auth-token.js"; +import { success, error, info, dim } from "../utils/logger.js"; +import { parseSkillName } from "../core/skill-name.js"; + +export function registerRating(program: Command) { + program + .command("rating ") + .description("View your rating for a skill") + .action(async (slug: string) => { + try { + const { namespace, slug: skillSlug } = parseSkillName(slug); + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + + const detail = await client.get<{ id: number }>( + `/api/v1/skills/${namespace}/${skillSlug}` + ); + + const rating = await client.get<{ score: number; rated: boolean }>( + `/api/v1/skills/${detail.id}/rating` + ); + + if (rating.rated) { + info(`${skillSlug}: ${"★".repeat(rating.score)}${"☆".repeat(5 - rating.score)} (${rating.score}/5)`); + } else { + info(`${skillSlug}: Not rated yet`); + dim("Use: skillhub rate "); + } + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} + +export function registerRate(program: Command) { + program + .command("rate ") + .description("Rate a skill (1-5)") + .action(async (slug: string, scoreStr: string) => { + const score = parseInt(scoreStr, 10); + if (isNaN(score) || score < 1 || score > 5) { + error("Score must be between 1 and 5"); + process.exit(1); + } + + try { + const { namespace, slug: skillSlug } = parseSkillName(slug); + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + + const detail = await client.get<{ id: number }>( + `/api/v1/skills/${namespace}/${skillSlug}` + ); + + await client.put(`/api/v1/skills/${detail.id}/rating`, { + body: JSON.stringify({ score }), + headers: { "Content-Type": "application/json" }, + }); + success(`Rated ${skillSlug}: ${"★".repeat(score)}${"☆".repeat(5 - score)}`); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/report.ts b/skillhub-cli/src/commands/report.ts new file mode 100644 index 00000000..91c3e14c --- /dev/null +++ b/skillhub-cli/src/commands/report.ts @@ -0,0 +1,40 @@ +import { Command } from "commander"; +import { createInterface } from "node:readline"; +import { ApiClient } from "../core/api-client.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig } from "../core/config.js"; +import { success, error } from "../utils/logger.js"; +import { parseSkillName } from "../core/skill-name.js"; + +export function registerReport(program: Command) { + program + .command("report ") + .description("Report a skill for review") + .option("--reason ", "Report reason") + .action(async (slug: string, opts: { reason?: string }) => { + try { + const { namespace, slug: skillSlug } = parseSkillName(slug); + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + + let reason = opts.reason; + if (!reason) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + reason = await new Promise((r) => + rl.question("Report reason: ", r) + ); + rl.close(); + } + + await client.post(`/api/v1/skills/${namespace}/${skillSlug}/reports`, { + body: JSON.stringify({ reason }), + headers: { "Content-Type": "application/json" }, + }); + success(`Report submitted for ${skillSlug}`); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/resolve.ts b/skillhub-cli/src/commands/resolve.ts new file mode 100644 index 00000000..412ed63c --- /dev/null +++ b/skillhub-cli/src/commands/resolve.ts @@ -0,0 +1,177 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfig } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import { success, error, info, dim } from "../utils/logger.js"; +import { parseSkillName } from "../core/skill-name.js"; +import { runInteractiveSearch, searchSkills } from "../core/interactive-search.js"; + +export interface ResolveResponse { + skillId: number; + namespace: string; + slug: string; + version: string; + versionId: number; + fingerprint: string; + matched: string; + downloadUrl: string; +} + +interface VersionSearchResult { + namespace: string; + name: string; + exists: boolean; +} + +async function resolveWithVersion( + client: ApiClient, + namespace: string, + slug: string, + version: string +): Promise { + try { + const result = await client.get( + `/api/v1/skills/${namespace}/${slug}/resolve?version=${version}` + ); + return result; + } catch (e: any) { + const status = e.status || e.statusCode; + if (status === 404 || status === 400) return null; + throw e; + } +} + +export function registerResolve(program: Command) { + program + .command("resolve ") + .description("Resolve the latest version of a skill") + .option("-v, --skill-version ", "Specific version") + .option("--tag ", "Tag to resolve (default: latest, ignored if --skill-version)") + .option("--hash ", "Content hash") + .action(async (slug: string, opts: Record) => { + try { + const { namespace, slug: skillSlug } = parseSkillName(slug); + const config = loadConfig(); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + let targetNamespace = namespace; + let targetSlug = skillSlug; + const specifiedVersion = opts.skillVersion; + + // Case 1: User specified a version + if (specifiedVersion) { + if (namespace && namespace !== "global") { + const result = await resolveWithVersion(client, namespace, skillSlug, specifiedVersion); + if (result) { + printResolveResult(result); + return; + } + error(`Version ${specifiedVersion} not found for ${namespace}/${skillSlug}`); + error(`Please check if the version number is correct.`); + process.exit(1); + } + + const results = await searchSkills(client, skillSlug, 50); + const seen = new Set(); + const uniqueResults = results.filter((r) => { + const key = `${r.namespace}/${r.name}`; + if (!seen.has(key)) { + seen.add(key); + return true; + } + return false; + }); + + if (uniqueResults.length === 0) { + error(`Skill not found: ${skillSlug}`); + process.exit(1); + } + + const resolvePromises = uniqueResults.map(async (r) => ({ + ...r, + result: await resolveWithVersion(client, r.namespace, r.name, specifiedVersion), + })); + const resolvedResults = await Promise.all(resolvePromises); + const matches = resolvedResults.filter((r) => r.result !== null); + + if (matches.length === 0) { + error(`Version ${specifiedVersion} not found for ${skillSlug}`); + error(`Please check if the version number is correct.`); + process.exit(1); + } + + if (matches.length === 1) { + // Only one match, auto-select + printResolveResult(matches[0].result!); + return; + } + + // Multiple matches, list them for user to choose manually + info(`Found multiple skills with version ${specifiedVersion}:`); + for (const m of matches) { + console.log(` ${m.namespace}/${m.name}`); + } + dim(`\nUse: resolve / --skill-version ${specifiedVersion}`); + process.exit(1); + } + + // Case 2: No version specified (original behavior) + if (namespace === "global") { + const results = await searchSkills(client, skillSlug, 50); + + const seen = new Set(); + const uniqueResults = results.filter((r) => { + const key = `${r.namespace}/${r.name}`; + if (!seen.has(key)) { + seen.add(key); + return true; + } + return false; + }); + + if (uniqueResults.length === 0) { + error(`Skill not found: ${skillSlug}`); + process.exit(1); + } + + if (uniqueResults.length === 1) { + targetNamespace = uniqueResults[0].namespace; + targetSlug = uniqueResults[0].name; + } else { + const selected = await runInteractiveSearch(client, skillSlug); + if (!selected) { + info("Cancelled."); + return; + } + const [ns, name] = selected.split("/", 2); + targetNamespace = ns; + targetSlug = name; + } + } + + const params = new URLSearchParams(); + if (opts.tag) { + params.set("tag", opts.tag); + } + if (opts.hash) params.set("hash", opts.hash); + + const qs = params.toString(); + const path = `/api/v1/skills/${targetNamespace}/${targetSlug}/resolve${qs ? "?" + qs : ""}`; + const result = await client.get(path); + printResolveResult(result); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} + +function printResolveResult(result: ResolveResponse) { + info(`${result.slug}@${result.version}`); + dim(`Namespace: ${result.namespace}`); + dim(`Version ID: ${result.versionId}`); + dim(`Fingerprint: ${result.fingerprint}`); + dim(`Matched: ${result.matched}`); + dim(`Download URL: ${result.downloadUrl}`); +} diff --git a/skillhub-cli/src/commands/reviews.ts b/skillhub-cli/src/commands/reviews.ts new file mode 100644 index 00000000..ba38ca14 --- /dev/null +++ b/skillhub-cli/src/commands/reviews.ts @@ -0,0 +1,43 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig } from "../core/config.js"; +import { success, error, info, dim } from "../utils/logger.js"; + +export interface ReviewSubmission { + id: number; + skillSlug: string; + skillDisplayName: string; + namespace: string; + version: string; + status: string; + createdAt: string; +} + +export function registerReviews(program: Command) { + const reviews = program.command("reviews").description("Manage skill reviews"); + + reviews + .command("my") + .alias("submissions") + .description("List your review submissions") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + const submissions = await client.get("/api/v1/reviews/my-submissions"); + if (!submissions || submissions.length === 0) { + console.log("No review submissions."); + return; + } + for (const r of submissions) { + info(`${r.skillDisplayName} (${r.skillSlug})`); + dim(` ${r.namespace} · v${r.version} · ${r.status} · ${r.createdAt}`); + } + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/search.ts b/skillhub-cli/src/commands/search.ts new file mode 100644 index 00000000..47e4b482 --- /dev/null +++ b/skillhub-cli/src/commands/search.ts @@ -0,0 +1,54 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes, SearchResponse } from "../schema/routes.js"; +import { loadConfig } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import { error, dim } from "../utils/logger.js"; + +export function registerSearch(program: Command) { + program + .command("search ") + .description("[Deprecated: use 'explore' instead] Search for skills on SkillHub") + .option("-n, --limit ", "Max results", "20") + .option("--namespace ", "Filter by namespace") + .action(async (query: string[], opts: { limit: string; namespace?: string }) => { + const config = loadConfig(); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + const isJson = program.opts().json; + + try { + let searchUrl = `${ApiRoutes.search}?q=${encodeURIComponent(query.join(" "))}&limit=${opts.limit}`; + if (opts.namespace) { + searchUrl += `&namespace=${encodeURIComponent(opts.namespace)}`; + } + const result = await client.get(searchUrl); + if (!result.results || result.results.length === 0) { + if (isJson) { + console.log(JSON.stringify({ results: [] })); + } else { + console.log("No skills found."); + } + return; + } + + if (isJson) { + console.log(JSON.stringify(result, null, 2)); + } else { + const hasNamespaceFilter = !!opts.namespace; + for (const s of result.results) { + const ns = s.namespace ? `[${s.namespace}] ` : ''; + console.log(`${ns}${s.slug} (${s.version}) — ${s.displayName}`); + if (s.summary) console.log(` ${s.summary}`); + } + if (hasNamespaceFilter) { + dim(`\nTip: remove --namespace filter to search all namespaces`); + } + } + } catch (e: any) { + error(`Search failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/star.ts b/skillhub-cli/src/commands/star.ts new file mode 100644 index 00000000..067e2c1a --- /dev/null +++ b/skillhub-cli/src/commands/star.ts @@ -0,0 +1,37 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes } from "../schema/routes.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig } from "../core/config.js"; +import { success, error } from "../utils/logger.js"; +import { parseSkillName } from "../core/skill-name.js"; + +export function registerStar(program: Command) { + program + .command("star ") + .description("Star a skill") + .option("--unstar", "Remove star") + .action(async (slug: string, opts: { unstar: boolean }) => { + try { + const { namespace, slug: skillSlug } = parseSkillName(slug); + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + + const detailPath = ApiRoutes.skillDetail.replace("{namespace}", namespace).replace("{slug}", skillSlug); + const detail = await client.get<{ id: number }>(detailPath); + + const starPath = `/api/v1/skills/${detail.id}/star`; + if (opts.unstar) { + await client.delete(starPath); + success(`Unstarred ${skillSlug}`); + } else { + await client.put(starPath); + success(`Starred ${skillSlug}`); + } + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/sync.ts b/skillhub-cli/src/commands/sync.ts new file mode 100644 index 00000000..4cc7afea --- /dev/null +++ b/skillhub-cli/src/commands/sync.ts @@ -0,0 +1,155 @@ +import { Command } from "commander"; +import { stat, readFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { FormData } from "undici"; +import { existsSync } from "node:fs"; +import { discoverSkills } from "../core/skill-discovery.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig } from "../core/config.js"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes } from "../schema/routes.js"; +import { info, dim, success, error } from "../utils/logger.js"; +import semver from "semver"; + +interface SyncResult { + name: string; + slug: string; + namespace: string; + success: boolean; + message?: string; +} + +export function registerSync(program: Command) { + program + .command("sync [path]") + .description("Scan and publish all skills from a directory") + .option("--namespace ", "Target namespace", "global") + .option("--all", "Include all skills (even with changes)") + .option("-y, --yes", "Skip confirmation") + .action(async (path: string | undefined, opts: { namespace: string; all?: boolean; yes?: boolean }) => { + const scanPath = path ? resolve(path) : process.cwd(); + + if (!existsSync(scanPath)) { + error(`Directory not found: ${scanPath}`); + process.exit(1); + } + + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + + info(`Scanning ${scanPath} for skills...`); + const skills = discoverSkills(scanPath); + + if (skills.length === 0) { + console.log("No skills found. Ensure directories contain SKILL.md files."); + return; + } + + console.log(""); + info(`Found ${skills.length} skill(s):`); + for (const skill of skills) { + console.log(` - ${skill.name} (${skill.description})`); + } + console.log(""); + + if (!opts.yes) { + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => + rl.question(`Publish ${skills.length} skill(s) to ${opts.namespace}? [y/N] `, r) + ); + rl.close(); + if (answer.toLowerCase() !== "y") { + console.log("Cancelled."); + return; + } + } + + const results: SyncResult[] = []; + console.log(""); + + for (const skill of skills) { + const slug = skill.name; + + try { + info(`Publishing ${slug}...`); + + const version = generateVersion(); + + const skillMdPath = resolve(skill.dir, "SKILL.md"); + const skillMdStat = await stat(skillMdPath); + if (!skillMdStat) { + error(`SKILL.md not found in ${skill.dir}`); + results.push({ name: skill.name, slug, namespace: opts.namespace, success: false, message: "SKILL.md not found" }); + continue; + } + + const skillMdContent = await readFile(skillMdPath, "utf-8"); + + const form = new FormData(); + form.set("payload", JSON.stringify({ + slug, + displayName: skill.name, + version, + changelog: "Synced from local directory", + acceptLicenseTerms: true, + tags: ["latest"], + })); + const blob = new Blob([Buffer.from(skillMdContent)], { type: "text/markdown" }); + form.append("files", blob, "SKILL.md"); + + const publishResponse = await client.postForm<{ ok: boolean; skillId: string; versionId: string }>( + ApiRoutes.skills, + form, + { namespace: opts.namespace } + ); + + if (publishResponse.ok) { + success(`Published ${slug}@${version}`); + results.push({ name: skill.name, slug, namespace: opts.namespace, success: true }); + } else { + error(`Failed to publish ${slug}`); + results.push({ name: skill.name, slug, namespace: opts.namespace, success: false, message: "Server returned ok=false" }); + } + } catch (e: any) { + error(`Failed to publish ${slug}: ${e.message}`); + results.push({ name: skill.name, slug, namespace: opts.namespace, success: false, message: e.message }); + } + } + + console.log(""); + info("=== Sync Summary ==="); + const successCount = results.filter((r) => r.success).length; + const failCount = results.filter((r) => !r.success).length; + console.log(` Total: ${results.length}`); + console.log(` Success: ${successCount}`); + console.log(` Failed: ${failCount}`); + + if (failCount > 0) { + console.log(""); + dim("Failed skills:"); + for (const r of results.filter((r) => !r.success)) { + console.log(` - ${r.slug}: ${r.message}`); + } + } + + console.log(""); + } catch (e: any) { + error(`Sync failed: ${e.message}`); + process.exit(1); + } + }); +} + +function generateVersion(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + const seconds = String(now.getSeconds()).padStart(2, "0"); + return `${year}${month}${day}.${hours}${minutes}${seconds}`; +} diff --git a/skillhub-cli/src/commands/transfer.ts b/skillhub-cli/src/commands/transfer.ts new file mode 100644 index 00000000..b54684e1 --- /dev/null +++ b/skillhub-cli/src/commands/transfer.ts @@ -0,0 +1,38 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes } from "../schema/routes.js"; +import { requireToken } from "../core/auth-token.js"; +import { loadConfig } from "../core/config.js"; +import { success, error } from "../utils/logger.js"; + +export function registerTransfer(program: Command) { + program + .command("transfer ") + .description("Transfer ownership of a namespace to another user") + .option("-y, --yes", "Skip confirmation") + .action(async (namespace: string, newOwnerId: string, opts: { yes?: boolean }) => { + if (!opts.yes) { + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((r) => + rl.question(`Transfer ownership of ${namespace} to ${newOwnerId}? [y/N] `, r) + ); + rl.close(); + if (answer.toLowerCase() !== "y") { + console.log("Cancelled."); + return; + } + } + + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + await client.post(ApiRoutes.namespaceTransferOwnership.replace("{namespace}", namespace), { body: JSON.stringify({ newOwnerId }) }); + success(`Ownership of ${namespace} transferred to ${newOwnerId}`); + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} \ No newline at end of file diff --git a/skillhub-cli/src/commands/uninstall.ts b/skillhub-cli/src/commands/uninstall.ts new file mode 100644 index 00000000..a7249b6e --- /dev/null +++ b/skillhub-cli/src/commands/uninstall.ts @@ -0,0 +1,334 @@ +import { Command } from "commander"; +import { existsSync, readdirSync, statSync, unlinkSync, rmdirSync, lstatSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { getAllAgents, isUniversalAgent, getUniversalAgents, getNonUniversalAgents, type AgentInfo } from "../core/agent-detector.js"; +import { success, info, dim } from "../utils/logger.js"; +import { removeFromLock } from "../core/skill-lock.js"; +import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; +import * as p from "@clack/prompts"; + +function removeDir(path: string) { + try { + const stat = lstatSync(path); + if (stat.isSymbolicLink()) { + unlinkSync(path); + } else if (stat.isDirectory()) { + for (const entry of readdirSync(path)) { + removeDir(join(path, entry)); + } + rmdirSync(path); + } else { + unlinkSync(path); + } + } catch {} +} + +async function uninstallSkill( + name: string, + agent: AgentInfo, + scope: "local" | "global", + yes: boolean +): Promise { + const home = homedir(); + let baseDir: string; + + if (scope === "global") { + if (isUniversalAgent(agent)) { + baseDir = join(home, ".agents/skills"); + } else { + baseDir = join(home, agent.globalSkillsDir || agent.skillsDir); + } + } else { + baseDir = join(process.cwd(), agent.skillsDir); + } + + const skillPath = join(baseDir, name); + + if (!existsSync(skillPath)) return false; + if (!statSync(skillPath).isDirectory()) return false; + + if (!yes) { + const confirmed = await p.confirm({ + message: `Uninstall ${name} from ${agent.name}?`, + initialValue: false, + }); + if (!confirmed) return false; + } + + removeDir(skillPath); + return true; +} + +function getSkillPath(skillName: string, agent: AgentInfo, scope: "global" | "local"): string | null { + const home = homedir(); + let baseDir: string; + + if (scope === "global") { + if (isUniversalAgent(agent)) { + baseDir = join(home, ".agents/skills"); + } else { + baseDir = join(home, agent.globalSkillsDir || agent.skillsDir); + } + } else { + baseDir = join(process.cwd(), agent.skillsDir); + } + + const skillPath = join(baseDir, skillName); + if (existsSync(skillPath) && statSync(skillPath).isDirectory()) { + return skillPath; + } + return null; +} + +function discoverInstalledSkills(scope: "local" | "global", agent?: AgentInfo): string[] { + const skills: string[] = []; + const agents = agent ? [agent] : getAllAgents(); + + for (const a of agents) { + const skillPath = getSkillPath("*", a, scope); + if (!skillPath) continue; + + const baseDir = skillPath.replace(/\/[^/]+$/, ""); + try { + for (const entry of readdirSync(baseDir)) { + const fullPath = join(baseDir, entry); + if (statSync(fullPath).isDirectory() && existsSync(join(fullPath, "SKILL.md"))) { + skills.push(entry); + } + } + } catch {} + } + + return [...new Set(skills)]; +} + +function findAgentsWithSkill(skillName: string, scope: "global" | "local", agents: AgentInfo[]): AgentInfo[] { + return agents.filter((a) => getSkillPath(skillName, a, scope) !== null); +} + +export function registerUninstall(program: Command) { + program + .command("uninstall [name]") + .alias("un") + .description("Uninstall a skill or all skills from local agent") + .option("-g, --global", "Uninstall from global scope") + .option("-a, --agent ", "Uninstall from specific agents") + .option("-y, --yes", "Skip confirmation") + .option("--all", "Uninstall all installed skills") + .action(async (name: string | undefined, opts: { global?: boolean; agent?: string[]; yes?: boolean; all?: boolean }) => { + let scope: "global" | "local" = opts.global ? "global" : "local"; + let scopeAll = false; + + if (!opts.global && !opts.agent) { + const scopeSelection = await p.select({ + message: "Which scope to uninstall from?", + options: [ + { value: "all", label: "All (global + project)" }, + { value: "global", label: "Global only" }, + { value: "project", label: "Project only" }, + ], + }); + + if (p.isCancel(scopeSelection)) { + console.log("Cancelled."); + return; + } + + if (scopeSelection === "global") { + scope = "global"; + } else if (scopeSelection === "project") { + scope = "local"; + } else if (scopeSelection === "all") { + scopeAll = true; + } + } + + const allAgents = getAllAgents(); + + if (opts.all) { + const skills = discoverInstalledSkills(scope); + + if (skills.length === 0) { + dim("No skills installed."); + return; + } + + const selected = await searchMultiselect({ + message: "Select skills to uninstall", + items: skills.map((s) => ({ value: s, label: s })), + required: true, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + const selectedSkills = selected as string[]; + let uninstalled = 0; + + for (const skill of selectedSkills) { + const agentsWithSkill = findAgentsWithSkill(skill, scope, allAgents); + for (const agent of agentsWithSkill) { + const ok = await uninstallSkill(skill, agent, scope, true); + if (ok) uninstalled++; + } + await removeFromLock(skill); + } + + success(`Uninstalled ${uninstalled} skill(s).`); + return; + } + + if (!name) { + const skills = discoverInstalledSkills(scope); + + if (skills.length === 0) { + dim("No skills installed."); + return; + } + + const selected = await searchMultiselect({ + message: "Select skills to uninstall", + items: skills.map((s) => ({ value: s, label: s })), + required: true, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + const selectedSkills = selected as string[]; + let uninstalled = 0; + + for (const skill of selectedSkills) { + const agentsWithSkill = findAgentsWithSkill(skill, scope, allAgents); + for (const agent of agentsWithSkill) { + const ok = await uninstallSkill(skill, agent, scope, !!opts.yes); + if (ok) uninstalled++; + } + await removeFromLock(skill); + } + + success(`Uninstalled ${uninstalled} skill(s).`); + return; + } + + let agentsWithSkill = findAgentsWithSkill(name, scope, allAgents); + + if (agentsWithSkill.length === 0 && !scopeAll) { + agentsWithSkill = findAgentsWithSkill(name, scope === "global" ? "local" : "global", allAgents); + if (agentsWithSkill.length > 0) { + const otherScope = scope === "global" ? "project" : "global"; + dim(`Skill "${name}" not found in ${scope}, but found in ${otherScope}.`); + } else { + info(`Skill "${name}" not found.`); + return; + } + } + + if (agentsWithSkill.length === 0 && scopeAll) { + agentsWithSkill = [ + ...findAgentsWithSkill(name, "global", allAgents), + ...findAgentsWithSkill(name, "local", allAgents), + ]; + if (agentsWithSkill.length === 0) { + info(`Skill "${name}" not found.`); + return; + } + } + + const universalAgents = getUniversalAgents(); + const nonUniversalAgents = getNonUniversalAgents(); + + const universalSection = { + title: "Universal (.agents/skills)", + items: universalAgents + .filter((a) => agentsWithSkill.some((w) => w.key === a.key)) + .map((a) => ({ + value: a.key, + label: a.name, + })), + }; + + const selectableItems = nonUniversalAgents + .filter((a) => agentsWithSkill.some((w) => w.key === a.key)) + .map((a) => ({ + value: a.key, + label: a.name, + })); + + if (selectableItems.length === 0 && universalSection.items.length === 0) { + info(`Skill "${name}" not found.`); + return; + } + + const selected = await searchMultiselect({ + message: `Uninstall ${name} from which agents?`, + items: selectableItems, + lockedSection: universalSection.items.length > 0 ? universalSection : undefined, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + const selectedAgentKeys = selected as string[]; + const pathToAgents = new Map(); + + for (const agentKey of selectedAgentKeys) { + const agent = allAgents.find((a) => a.key === agentKey); + if (agent) { + if (scopeAll) { + const okGlobal = await uninstallSkill(name, agent, "global", !!opts.yes); + const okLocal = await uninstallSkill(name, agent, "local", !!opts.yes); + if (okGlobal) { + const skillPath = getSkillPath(name, agent, "global"); + if (skillPath) { + const agents = pathToAgents.get(skillPath) || []; + agents.push(agent.name); + pathToAgents.set(skillPath, agents); + } + } + if (okLocal) { + const skillPath = getSkillPath(name, agent, "local"); + if (skillPath) { + const agents = pathToAgents.get(skillPath) || []; + agents.push(agent.name); + pathToAgents.set(skillPath, agents); + } + } + } else { + const ok = await uninstallSkill(name, agent, scope, !!opts.yes); + if (ok) { + const skillPath = getSkillPath(name, agent, scope); + if (skillPath) { + const agents = pathToAgents.get(skillPath) || []; + agents.push(agent.name); + pathToAgents.set(skillPath, agents); + } + } + } + } + } + + if (pathToAgents.size > 0) { + const lines: string[] = []; + for (const [path, agents] of pathToAgents) { + if (agents.length > 1) { + lines.push(` ${agents.join(", ")} (${path})`); + } else { + lines.push(` ${agents[0]} (${path})`); + } + } + success(`Uninstalled ${name} from ${selectedAgentKeys.length} agent(s):`); + console.log(lines.join("\n")); + await removeFromLock(name); + } else { + info(`Skill "${name}" not found.`); + } + }); +} diff --git a/skillhub-cli/src/commands/update.ts b/skillhub-cli/src/commands/update.ts new file mode 100644 index 00000000..974342fb --- /dev/null +++ b/skillhub-cli/src/commands/update.ts @@ -0,0 +1,99 @@ +import { Command } from "commander"; +import { success, error, info, warn } from "../utils/logger.js"; +import { getAllLockedSkills, getSkillLockPath } from "../core/skill-lock.js"; +import { existsSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; + +function getCliCommand(): string { + const cliPath = process.argv[1]; + if (cliPath && cliPath.endsWith("cli.mjs")) { + return `node "${cliPath}"`; + } + return "node dist/cli.mjs"; +} + +export function registerUpdate(program: Command) { + program + .command("update [slug]") + .alias("up") + .description("Update installed skills from their source") + .option("-a, --all", "Update all installed skills") + .option("-g, --global", "Update global scope skills") + .action(async (slug: string | undefined, opts: Record) => { + const lockPath = getSkillLockPath(); + + if (!existsSync(lockPath)) { + error("No skillhub.lock found. Have you installed any skills?"); + process.exit(1); + } + + const lockedSkills = await getAllLockedSkills(); + const allSkillNames = Object.keys(lockedSkills); + + if (allSkillNames.length === 0) { + error("No skills in lock file."); + process.exit(1); + } + + let skillsToUpdate: string[] = []; + + if (opts.all) { + skillsToUpdate = allSkillNames; + } else if (slug) { + skillsToUpdate = [slug]; + } else { + const selected = await searchMultiselect({ + message: "Select skills to update", + items: allSkillNames.map((name) => ({ + value: name, + label: name, + hint: lockedSkills[name].sourceType, + })), + required: true, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + skillsToUpdate = selected as string[]; + } + + const scope = opts.global ? "--global" : ""; + const cliCmd = getCliCommand(); + + let updated = 0; + let failed = 0; + + for (const name of skillsToUpdate) { + const entry = lockedSkills[name]; + if (!entry) { + warn(`Skill not found in lock: ${name}`); + continue; + } + + try { + info(`Updating ${name} from ${entry.source}...`); + const source = entry.sourceType === "registry" + ? entry.source + : entry.sourceUrl; + + const cmd = `${cliCmd} install ${source} ${scope}`.trim(); + execSync(cmd, { stdio: "inherit" }); + updated++; + } catch (e: any) { + error(`Failed to update ${name}: ${e.message}`); + failed++; + } + } + + console.log(""); + if (failed === 0) { + success(`Updated ${updated} skill(s)`); + } else { + warn(`Updated ${updated}, failed ${failed}`); + } + }); +} diff --git a/skillhub-cli/src/commands/versions.ts b/skillhub-cli/src/commands/versions.ts new file mode 100644 index 00000000..bc0e9de5 --- /dev/null +++ b/skillhub-cli/src/commands/versions.ts @@ -0,0 +1,101 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { readToken } from "../core/auth-token.js"; +import { loadConfig } from "../core/config.js"; +import { error, info, dim, success } from "../utils/logger.js"; +import { parseSkillName } from "../core/skill-name.js"; +import { searchSkills, runInteractiveSearch } from "../core/interactive-search.js"; + +export interface SkillVersionItem { + id: number; + version: string; + status: string; + changelog: string | null; + fileCount: number; + totalSize: number; + publishedAt: string; + downloadAvailable: boolean; +} + +export interface VersionsResponse { + items: SkillVersionItem[]; + total: number; + page: number; + size: number; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function registerVersions(program: Command) { + program + .command("versions ") + .description("List skill versions") + .action(async (slug: string) => { + try { + const { namespace, slug: skillSlug } = parseSkillName(slug); + const config = loadConfig(); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + let targetNamespace = namespace; + let targetSlug = skillSlug; + + if (namespace === "global") { + const results = await searchSkills(client, skillSlug, 50); + + const seen = new Set(); + const uniqueResults = results.filter((r) => { + const key = `${r.namespace}/${r.name}`; + if (!seen.has(key)) { + seen.add(key); + return true; + } + return false; + }); + + if (uniqueResults.length === 0) { + error(`Skill not found: ${skillSlug}`); + process.exit(1); + } + + if (uniqueResults.length === 1) { + targetNamespace = uniqueResults[0].namespace; + targetSlug = uniqueResults[0].name; + } else { + const selected = await runInteractiveSearch(client, skillSlug); + if (!selected) { + info("Cancelled."); + return; + } + const [ns, name] = selected.split("/", 2); + targetNamespace = ns; + targetSlug = name; + } + } + + const resp = await client.get( + `/api/v1/skills/${targetNamespace}/${targetSlug}/versions` + ); + const versions = resp.items || []; + if (versions.length === 0) { + console.log("No versions found."); + return; + } + + if (targetNamespace !== "global") { + success(`${targetNamespace}/${targetSlug}`); + } + for (const v of versions) { + info(`v${v.version}`); + dim(` ${v.status} · ${v.fileCount} files · ${formatBytes(v.totalSize)} · ${v.publishedAt}`); + } + } catch (e: any) { + error(`Failed: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/commands/whoami.ts b/skillhub-cli/src/commands/whoami.ts new file mode 100644 index 00000000..3aa2954a --- /dev/null +++ b/skillhub-cli/src/commands/whoami.ts @@ -0,0 +1,30 @@ +import { Command } from "commander"; +import { ApiClient } from "../core/api-client.js"; +import { ApiRoutes, WhoamiResponse } from "../schema/routes.js"; +import { requireToken } from "../core/auth-token.js"; +import { success, error } from "../utils/logger.js"; +import { loadConfig } from "../core/config.js"; + +export function registerWhoami(program: Command) { + program + .command("whoami") + .description("Show current authenticated user") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfig(); + const client = new ApiClient({ baseUrl: config.registry, token }); + const resp = await client.get(ApiRoutes.whoami); + const isJson = program.opts().json; + if (isJson) { + console.log(JSON.stringify(resp, null, 2)); + } else { + console.log(`Handle: ${resp.user.handle}`); + console.log(`Display Name: ${resp.user.displayName}`); + } + } catch (e: any) { + error(`Not authenticated: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/skillhub-cli/src/core/agent-detector.ts b/skillhub-cli/src/core/agent-detector.ts new file mode 100644 index 00000000..9ba153aa --- /dev/null +++ b/skillhub-cli/src/core/agent-detector.ts @@ -0,0 +1,89 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface AgentInfo { + key: string; + name: string; + skillsDir: string; + globalSkillsDir?: string; +} + +const home = homedir(); + +const AGENTS: AgentInfo[] = [ + // Universal agents (.agents/skills) + { key: "amp", name: "Amp", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills" }, + { key: "antigravity", name: "Antigravity", skillsDir: ".agents/skills", globalSkillsDir: ".gemini/antigravity/skills" }, + { key: "cline", name: "Cline", skillsDir: ".agents/skills" }, + { key: "codex", name: "Codex", skillsDir: ".agents/skills", globalSkillsDir: ".codex/skills" }, + { key: "cursor", name: "Cursor", skillsDir: ".agents/skills", globalSkillsDir: ".cursor/skills" }, + { key: "deepagents", name: "Deep Agents", skillsDir: ".agents/skills", globalSkillsDir: ".deepagents/agent/skills" }, + { key: "firebender", name: "Firebender", skillsDir: ".agents/skills", globalSkillsDir: ".firebender/skills" }, + { key: "gemini-cli", name: "Gemini CLI", skillsDir: ".agents/skills", globalSkillsDir: ".gemini/skills" }, + { key: "github-copilot", name: "GitHub Copilot", skillsDir: ".agents/skills", globalSkillsDir: ".copilot/skills" }, + { key: "kimi-cli", name: "Kimi Code CLI", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills" }, + { key: "kilo", name: "Kilo Code", skillsDir: ".agents/skills", globalSkillsDir: ".kilocode/skills" }, + { key: "mux", name: "Mux", skillsDir: ".agents/skills" }, + { key: "opencode", name: "OpenCode", skillsDir: ".agents/skills", globalSkillsDir: ".config/opencode/skills" }, + { key: "replit", name: "Replit", skillsDir: ".agents/skills" }, + { key: "warp", name: "Warp", skillsDir: ".agents/skills" }, + + // Agent-specific path agents + { key: "claude-code", name: "Claude Code", skillsDir: ".claude/skills", globalSkillsDir: ".claude/skills" }, + { key: "augment", name: "Augment", skillsDir: ".augment/skills" }, + { key: "bob", name: "IBM Bob", skillsDir: ".bob/skills" }, + { key: "openclaw", name: "OpenClaw", skillsDir: "skills", globalSkillsDir: ".openclaw/skills" }, + { key: "codebuddy", name: "CodeBuddy", skillsDir: ".codebuddy/skills" }, + { key: "continue", name: "Continue", skillsDir: ".continue/skills" }, + { key: "cortex", name: "Cortex Code", skillsDir: ".cortex/skills", globalSkillsDir: ".snowflake/cortex/skills" }, + { key: "crush", name: "Crush", skillsDir: ".crush/skills", globalSkillsDir: ".config/crush/skills" }, + { key: "droid", name: "Droid", skillsDir: ".factory/skills" }, + { key: "goose", name: "Goose", skillsDir: ".goose/skills", globalSkillsDir: ".config/goose/skills" }, + { key: "junie", name: "Junie", skillsDir: ".junie/skills" }, + { key: "iflow-cli", name: "iFlow CLI", skillsDir: ".iflow/skills" }, + { key: "kode", name: "Kode", skillsDir: ".kode/skills" }, + { key: "mcpjam", name: "MCPJam", skillsDir: ".mcpjam/skills" }, + { key: "mistral-vibe", name: "Mistral Vibe", skillsDir: ".vibe/skills" }, + { key: "openhands", name: "OpenHands", skillsDir: ".openhands/skills" }, + { key: "pi", name: "Pi", skillsDir: ".pi/skills", globalSkillsDir: ".pi/agent/skills" }, + { key: "qoder", name: "Qoder", skillsDir: ".qoder/skills" }, + { key: "qwen-code", name: "Qwen Code", skillsDir: ".qwen/skills" }, + { key: "roo", name: "Roo Code", skillsDir: ".roo/skills" }, + { key: "trae", name: "Trae", skillsDir: ".trae/skills" }, + { key: "trae-cn", name: "Trae CN", skillsDir: ".trae/skills", globalSkillsDir: ".trae-cn/skills" }, + { key: "windsurf", name: "Windsurf", skillsDir: ".windsurf/skills", globalSkillsDir: ".codeium/windsurf/skills" }, + { key: "zencoder", name: "Zencoder", skillsDir: ".zencoder/skills" }, + { key: "neovate", name: "Neovate", skillsDir: ".neovate/skills" }, + { key: "pochi", name: "Pochi", skillsDir: ".pochi/skills" }, + { key: "adal", name: "AdaL", skillsDir: ".adal/skills" }, +]; + +export function getAllAgents(): AgentInfo[] { + return AGENTS; +} + +export function detectInstalledAgents(): AgentInfo[] { + return AGENTS.filter((agent) => { + const globalPath = agent.globalSkillsDir ? join(home, agent.globalSkillsDir) : join(home, agent.skillsDir); + return existsSync(globalPath); + }); +} + +export function getAgentByKey(key: string): AgentInfo | undefined { + return AGENTS.find((a) => a.key === key); +} + +const UNIVERSAL_PATH = ".agents/skills"; + +export function isUniversalAgent(agent: AgentInfo): boolean { + return agent.skillsDir === UNIVERSAL_PATH; +} + +export function getUniversalAgents(): AgentInfo[] { + return AGENTS.filter((a) => isUniversalAgent(a)); +} + +export function getNonUniversalAgents(): AgentInfo[] { + return AGENTS.filter((a) => !isUniversalAgent(a)); +} \ No newline at end of file diff --git a/skillhub-cli/src/core/api-client.ts b/skillhub-cli/src/core/api-client.ts new file mode 100644 index 00000000..d87a0142 --- /dev/null +++ b/skillhub-cli/src/core/api-client.ts @@ -0,0 +1,131 @@ +import { request, FormData as UndiciFormData } from "undici"; + +export interface ApiClientOptions { + baseUrl: string; + token?: string; +} + +interface NativeApiResponse { + code: number; + msg: string; + data: T; + timestamp: string; +} + +export class ApiClient { + constructor(private options: ApiClientOptions) {} + + /** + * Unwrap Native API response format: + * { code: 0, msg: "success", data: T } -> returns T + * { code: non-zero, msg: "error", data: null } -> throws ApiError + * + * Pass-through Compat API format (no code field): + * { user: {...} } -> returns as-is + * { results: [...] } -> returns as-is + */ + private unwrapResponse(data: unknown): T { + // Check if it's a Native API response (has code field) + if (typeof data === "object" && data !== null && "code" in data) { + const native = data as NativeApiResponse; + if (native.code !== 0) { + throw new ApiError(native.code, native); + } + return native.data as T; + } + // Otherwise it's a Compat API response, return as-is + return data as T; + } + + async get(path: string): Promise { + const url = new URL(path, this.options.baseUrl); + const { statusCode, body } = await request(url.toString(), { + method: "GET", + headers: this.headers(), + }); + const data = await body.json(); + if (statusCode >= 400) { + throw new ApiError(statusCode, data); + } + return this.unwrapResponse(data); + } + + async postForm(path: string, form: UndiciFormData, queryParams?: Record): Promise { + const url = new URL(path, this.options.baseUrl); + if (queryParams) { + for (const [k, v] of Object.entries(queryParams)) { + url.searchParams.set(k, v); + } + } + const { statusCode, body } = await request(url.toString(), { + method: "POST", + headers: { + ...this.headers(), + }, + body: form, + }); + const data = await body.json(); + if (statusCode >= 400) { + throw new ApiError(statusCode, data); + } + return this.unwrapResponse(data); + } + + async post(path: string, opts?: { body?: string; headers?: Record }): Promise { + const url = new URL(path, this.options.baseUrl); + const { statusCode, body } = await request(url.toString(), { + method: "POST", + headers: { ...this.headers(), ...opts?.headers }, + body: opts?.body, + }); + const data = await body.json(); + if (statusCode >= 400) { + throw new ApiError(statusCode, data); + } + return this.unwrapResponse(data); + } + + async put(path: string, opts?: { body?: string; headers?: Record }): Promise { + const url = new URL(path, this.options.baseUrl); + const { statusCode, body } = await request(url.toString(), { + method: "PUT", + headers: { ...this.headers(), ...opts?.headers }, + body: opts?.body, + }); + const data = await body.json(); + if (statusCode >= 400) { + throw new ApiError(statusCode, data); + } + return this.unwrapResponse(data); + } + + async delete(path: string): Promise { + const url = new URL(path, this.options.baseUrl); + const { statusCode, body } = await request(url.toString(), { + method: "DELETE", + headers: this.headers(), + }); + const data = await body.json(); + if (statusCode >= 400) { + throw new ApiError(statusCode, data); + } + return this.unwrapResponse(data); + } + + private headers(): Record { + const h: Record = {}; + if (this.options.token) { + h["Authorization"] = `Bearer ${this.options.token}`; + } + return h; + } +} + +export class ApiError extends Error { + constructor( + public statusCode: number, + public body: unknown, + ) { + super(`API error ${statusCode}: ${JSON.stringify(body)}`); + } +} diff --git a/skillhub-cli/src/core/auth-token.ts b/skillhub-cli/src/core/auth-token.ts new file mode 100644 index 00000000..3ab11183 --- /dev/null +++ b/skillhub-cli/src/core/auth-token.ts @@ -0,0 +1,39 @@ +import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; +import { mkdir } from "node:fs/promises"; + +const TOKEN_DIR = join(homedir(), ".skillhub"); +const TOKEN_FILE = join(TOKEN_DIR, "token"); + +export async function readToken(): Promise { + if (!existsSync(TOKEN_FILE)) return null; + return readFileSync(TOKEN_FILE, "utf-8").trim(); +} + +export async function writeToken(token: string): Promise { + if (!existsSync(TOKEN_DIR)) { + await mkdir(TOKEN_DIR, { recursive: true }); + } + writeFileSync(TOKEN_FILE, token); + try { + chmodSync(TOKEN_FILE, 0o600); + } catch { + // Permission change not critical + } +} + +export async function removeToken(): Promise { + if (existsSync(TOKEN_FILE)) { + const { unlinkSync } = await import("node:fs"); + unlinkSync(TOKEN_FILE); + } +} + +export async function requireToken(): Promise { + const token = await readToken(); + if (!token) { + throw new Error("Not authenticated. Run `skillhub login` first."); + } + return token; +} diff --git a/skillhub-cli/src/core/config.ts b/skillhub-cli/src/core/config.ts new file mode 100644 index 00000000..c60a76da --- /dev/null +++ b/skillhub-cli/src/core/config.ts @@ -0,0 +1,34 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const CONFIG_DIR = join(homedir(), ".skillhub"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +export interface CliConfig { + registry: string; + dir?: string; +} + +const DEFAULT_CONFIG: CliConfig = { + registry: "http://localhost:8080", +}; + +export function loadConfig(): CliConfig { + if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG }; + try { + const raw = readFileSync(CONFIG_FILE, "utf-8"); + return { ...DEFAULT_CONFIG, ...JSON.parse(raw) }; + } catch { + return { ...DEFAULT_CONFIG }; + } +} + +export function saveConfig(config: Partial): void { + const existing = loadConfig(); + const merged = { ...existing, ...config }; + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2)); +} diff --git a/skillhub-cli/src/core/installer.ts b/skillhub-cli/src/core/installer.ts new file mode 100644 index 00000000..890e9a50 --- /dev/null +++ b/skillhub-cli/src/core/installer.ts @@ -0,0 +1,144 @@ +import { mkdirSync, symlinkSync, copyFileSync, readdirSync, lstatSync, unlinkSync, existsSync } from "node:fs"; +import { join, dirname, relative } from "node:path"; +import { homedir, platform } from "node:os"; + +export interface SkillInstallResult { + skillName: string; + agentKey: string; + path: string; + mode: "symlink" | "copy"; + success: boolean; + error?: string; +} + +const UNIVERSAL_PATH = ".agents/skills"; + +function isUniversalAgent(skillsDir: string): boolean { + return skillsDir === UNIVERSAL_PATH; +} + +function getCanonicalBase(isGlobal: boolean, cwd: string): string { + const home = homedir(); + return isGlobal ? join(home, UNIVERSAL_PATH) : join(cwd, UNIVERSAL_PATH); +} + +function getAgentBaseDir(skillsDir: string, isGlobal: boolean, cwd: string): string { + const home = homedir(); + if (isGlobal) { + return join(home, skillsDir); + } + return join(cwd, skillsDir); +} + +function removePath(path: string): void { + try { + const stat = lstatSync(path); + if (stat.isSymbolicLink()) { + unlinkSync(path); + } else if (stat.isDirectory()) { + for (const entry of readdirSync(path)) { + removePath(join(path, entry)); + } + if (platform() !== "win32") { + try { unlinkSync(path); } catch { } + } + } else { + unlinkSync(path); + } + } catch { } +} + +function ensureDir(path: string): void { + if (!existsSync(path)) { + mkdirSync(path, { recursive: true }); + } +} + +function createSymlink(target: string, linkPath: string): boolean { + try { + if (target === linkPath) { + return true; + } + + removePath(linkPath); + + const linkDir = dirname(linkPath); + const resolvedLinkDir = linkDir.startsWith("~") ? join(homedir(), linkDir.slice(1)) : linkDir; + ensureDir(resolvedLinkDir); + + const relativePath = relative(resolvedLinkDir, target); + const symlinkType = platform() === "win32" ? "junction" : "dir"; + + symlinkSync(relativePath, linkPath, symlinkType); + return true; + } catch { + return false; + } +} + +export function installSkill( + skillDir: string, + skillName: string, + agentKey: string, + targetDir: string, + mode: "symlink" | "copy", + isGlobal: boolean, +): SkillInstallResult { + const cwd = process.cwd(); + const canonicalBase = getCanonicalBase(isGlobal, cwd); + const canonicalDir = join(canonicalBase, skillName); + const agentBase = getAgentBaseDir(targetDir, isGlobal, cwd); + const agentDir = join(agentBase, skillName); + + const agentIsUniversal = isUniversalAgent(targetDir); + + try { + if (mode === "copy") { + const copyDestDir = dirname(agentDir); + const resolvedCopyDestDir = copyDestDir.startsWith("~") ? join(homedir(), copyDestDir.slice(1)) : copyDestDir; + ensureDir(resolvedCopyDestDir); + removePath(agentDir); + mkdirSync(agentDir, { recursive: true }); + copyDir(skillDir, agentDir); + return { skillName, agentKey, path: agentDir, mode, success: true }; + } + + ensureDir(dirname(canonicalDir)); + removePath(canonicalDir); + mkdirSync(canonicalDir, { recursive: true }); + copyDir(skillDir, canonicalDir); + + if (isGlobal && agentIsUniversal) { + return { skillName, agentKey, path: canonicalDir, mode, success: true }; + } + + const symlinkCreated = createSymlink(canonicalDir, agentDir); + + if (!symlinkCreated) { + const agentLinkDir = dirname(agentDir); + const resolvedAgentLinkDir = agentLinkDir.startsWith("~") ? join(homedir(), agentLinkDir.slice(1)) : agentLinkDir; + ensureDir(resolvedAgentLinkDir); + removePath(agentDir); + mkdirSync(agentDir, { recursive: true }); + copyDir(skillDir, agentDir); + return { skillName, agentKey, path: agentDir, mode, success: true }; + } + + return { skillName, agentKey, path: agentDir, mode, success: true }; + } catch (e: any) { + return { skillName, agentKey, path: agentDir, mode, success: false, error: e.message }; + } +} + +function copyDir(src: string, dest: string) { + mkdirSync(dest, { recursive: true }); + for (const entry of readdirSync(src)) { + const srcPath = join(src, entry); + const destPath = join(dest, entry); + if (lstatSync(srcPath).isDirectory()) { + copyDir(srcPath, destPath); + } else { + copyFileSync(srcPath, destPath); + } + } +} diff --git a/skillhub-cli/src/core/interactive-search.ts b/skillhub-cli/src/core/interactive-search.ts new file mode 100644 index 00000000..c36d929e --- /dev/null +++ b/skillhub-cli/src/core/interactive-search.ts @@ -0,0 +1,249 @@ +import { ApiClient } from "./api-client.js"; +import { ApiRoutes, SearchResponse } from "../schema/routes.js"; +import * as readline from "readline"; +import { dim, info } from "../utils/logger.js"; + +const HIDE_CURSOR = "\x1b[?25l"; +const SHOW_CURSOR = "\x1b[?25h"; +const CLEAR_DOWN = "\x1b[J"; +const MOVE_UP = (n: number) => `\x1b[${n}A`; +const MOVE_TO_COL = (n: number) => `\x1b[${n}G`; + +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const TEXT = "\x1b[38;5;145m"; +const YELLOW = "\x1b[33m"; +const DIM = "\x1b[38;5;102m"; + +export interface SearchSkill { + name: string; + slug: string; + namespace: string; + version?: string; + summary?: string; + installs?: number; +} + +interface SkillDetail { + starCount: number; + downloadCount: number; + version: string; +} + +export function parseNamespace(slug: string): { namespace: string; name: string } { + const parts = slug.split("--"); + if (parts.length >= 2) { + return { namespace: parts[0], name: parts.slice(1).join("--") }; + } + return { namespace: "global", name: slug }; +} + +function formatInstalls(count: number): string { + if (!count || count <= 0) return ""; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`; + return `${count} install${count === 1 ? "" : "s"}`; +} + +async function fetchSkillDetail(client: ApiClient, namespace: string, name: string): Promise { + try { + const detail = await client.get( + `${ApiRoutes.skillDetail.replace("{namespace}", namespace).replace("{slug}", name)}` + ); + return detail; + } catch { + return null; + } +} + +export async function searchSkills( + client: ApiClient, + query: string, + limit: number = 10 +): Promise { + const result = await client.get( + `${ApiRoutes.search}?q=${encodeURIComponent(query)}&limit=${limit}` + ); + + if (!result.results || result.results.length === 0) { + return []; + } + + return result.results.map((s) => { + const { namespace, name } = parseNamespace(s.slug); + return { + name, + slug: s.slug, + namespace, + version: s.version, + summary: s.summary, + installs: (s as any).installCount || 0, + }; + }).sort((a, b) => (b.installs || 0) - (a.installs || 0)); +} + +export async function runInteractiveSearch( + client: ApiClient, + initialQuery: string = "" +): Promise { + const MAX_VISIBLE = 8; + let query = initialQuery; + let results: SearchSkill[] = []; + let selectedIndex = 0; + let loading = false; + let lastRenderedLines = 0; + let debounceTimer: ReturnType | null = null; + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const width = process.stdout.columns || 80; + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdout.write(HIDE_CURSOR); + + function render(): void { + if (lastRenderedLines > 0) { + process.stdout.write(MOVE_UP(lastRenderedLines) + MOVE_TO_COL(1)); + } + process.stdout.write(CLEAR_DOWN); + + const lines: string[] = []; + + const cursor = `${BOLD}_${RESET}`; + const searchLine = `${TEXT}Select namespace:${RESET} ${query}${cursor}`; + lines.push(searchLine); + lines.push(""); + + if (!query || query.length < 2) { + lines.push(`${DIM}Start typing to search (min 2 chars)${RESET}`); + } else if (results.length === 0 && loading) { + lines.push(`${DIM}Searching...${RESET}`); + } else if (results.length === 0) { + lines.push(`${DIM}No skills found${RESET}`); + } else { + const visible = results.slice(0, MAX_VISIBLE); + for (let i = 0; i < visible.length; i++) { + const skill = visible[i]!; + const isSelected = i === selectedIndex; + const arrow = isSelected ? `${BOLD}>${RESET}` : " "; + const name = isSelected ? `${BOLD}${skill.name}${RESET}` : `${TEXT}${skill.name}${RESET}`; + const nsBadge = skill.namespace !== "global" ? ` ${YELLOW}[${skill.namespace}]${RESET}` : ""; + const versionBadge = skill.version ? ` ${DIM}v${skill.version}${RESET}` : ""; + + lines.push(` ${arrow} ${name}${nsBadge}${versionBadge}`); + } + } + + lines.push(""); + lines.push(`${DIM}up/down navigate | enter select | esc cancel${RESET}`); + + for (const line of lines) { + process.stdout.write(line + "\n"); + } + + lastRenderedLines = lines.length; + } + + function triggerSearch(q: string): void { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + + loading = false; + + if (!q || q.length < 2) { + results = []; + selectedIndex = 0; + render(); + return; + } + + loading = true; + render(); + + const debounceMs = Math.max(150, 350 - q.length * 50); + + debounceTimer = setTimeout(async () => { + try { + results = await searchSkills(client, q); + selectedIndex = 0; + } catch { + results = []; + } finally { + loading = false; + debounceTimer = null; + render(); + } + }, debounceMs); + } + + if (initialQuery) { + triggerSearch(initialQuery); + } + render(); + + return new Promise((resolve) => { + function cleanup(): void { + process.stdin.removeListener("keypress", handleKeypress); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdout.write(SHOW_CURSOR); + process.stdin.pause(); + rl.close(); + } + + function handleKeypress(_ch: string | undefined, key: readline.Key): void { + if (!key) return; + + if (key.name === "escape" || (key.ctrl && key.name === "c")) { + cleanup(); + resolve(null); + return; + } + + if (key.name === "return") { + cleanup(); + resolve(results[selectedIndex] ? `${results[selectedIndex].namespace}/${results[selectedIndex].name}` : null); + return; + } + + if (key.name === "up" || key.name === "k") { + selectedIndex = Math.max(0, selectedIndex - 1); + render(); + return; + } + + if (key.name === "down" || key.name === "j") { + selectedIndex = Math.min(Math.max(0, results.length - 1), selectedIndex + 1); + render(); + return; + } + + if (key.name === "backspace") { + if (query.length > 0) { + query = query.slice(0, -1); + triggerSearch(query); + } + return; + } + + if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) { + const char = key.sequence; + if (char >= " " && char <= "~") { + query += char; + triggerSearch(query); + } + } + } + + process.stdin.on("keypress", handleKeypress); + }); +} diff --git a/skillhub-cli/src/core/skill-discovery.ts b/skillhub-cli/src/core/skill-discovery.ts new file mode 100644 index 00000000..2ca96814 --- /dev/null +++ b/skillhub-cli/src/core/skill-discovery.ts @@ -0,0 +1,86 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +export interface DiscoveredSkill { + name: string; + description: string; + dir: string; +} + +const SKILL_DIRS = [ + "skills", + ".agents/skills", + ".claude/skills", + ".augment/skills", + ".cursor/skills", + ".codex/skills", +]; + +export function discoverSkills(rootDir: string): DiscoveredSkill[] { + const skills: DiscoveredSkill[] = []; + + for (const subDir of SKILL_DIRS) { + const fullPath = join(rootDir, subDir); + if (!existsSync(fullPath)) continue; + skills.push(...scanDir(fullPath)); + } + + if (skills.length === 0) { + skills.push(...scanDir(rootDir)); + } + + // Also check for SKILL.md directly in rootDir (for registry downloads) + if (skills.length === 0) { + const rootSkillMd = join(rootDir, "SKILL.md"); + if (existsSync(rootSkillMd)) { + try { + const content = readFileSync(rootSkillMd, "utf-8"); + const name = extractFrontmatterField(content, "name"); + const description = extractFrontmatterField(content, "description"); + if (name) { + skills.push({ name, description: description || name, dir: rootDir }); + } + } catch { + // Skip unreadable files + } + } + } + + return skills; +} + +function scanDir(dir: string): DiscoveredSkill[] { + const skills: DiscoveredSkill[] = []; + if (!existsSync(dir)) return skills; + + try { + for (const entry of readdirSync(dir)) { + const entryPath = join(dir, entry); + const stat = statSync(entryPath); + if (!stat.isDirectory()) continue; + + const skillMd = join(entryPath, "SKILL.md"); + if (!existsSync(skillMd)) continue; + + const content = readFileSync(skillMd, "utf-8"); + const name = extractFrontmatterField(content, "name"); + const description = extractFrontmatterField(content, "description"); + + if (name) { + skills.push({ name, description: description || name, dir: entryPath }); + } + } + } catch { + // Skip unreadable directories + } + + return skills; +} + +function extractFrontmatterField(content: string, field: string): string | undefined { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return undefined; + const frontmatter = match[1]; + const fieldMatch = frontmatter.match(new RegExp(`^${field}:\\s*(.+)$`, "m")); + return fieldMatch ? fieldMatch[1].trim().replace(/^["']|["']$/g, "") : undefined; +} diff --git a/skillhub-cli/src/core/skill-lock.ts b/skillhub-cli/src/core/skill-lock.ts new file mode 100644 index 00000000..8244c52b --- /dev/null +++ b/skillhub-cli/src/core/skill-lock.ts @@ -0,0 +1,106 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +const LOCK_FILE_VERSION = 1; +const LOCK_DIR = join(homedir(), ".skillhub"); +const LOCK_FILE = join(LOCK_DIR, "lock.json"); + +export interface SkillLockEntry { + source: string; + sourceType: "git" | "registry" | "local"; + sourceUrl: string; + ref?: string; + namespace: string; + slug: string; + version: string; + fingerprint?: string; + installedAt: string; + updatedAt: string; +} + +export interface SkillLockFile { + version: number; + skills: Record; + lastSelectedAgents?: string[]; +} + +function createEmptyLock(): SkillLockFile { + return { + version: LOCK_FILE_VERSION, + skills: {}, + }; +} + +export function getSkillLockPath(): string { + return LOCK_FILE; +} + +export async function readSkillLock(): Promise { + if (!existsSync(LOCK_FILE)) { + return createEmptyLock(); + } + try { + const content = readFileSync(LOCK_FILE, "utf-8"); + const lock = JSON.parse(content) as SkillLockFile; + if (typeof lock.version !== "number" || !lock.skills) { + return createEmptyLock(); + } + return lock; + } catch { + return createEmptyLock(); + } +} + +export async function writeSkillLock(lock: SkillLockFile): Promise { + if (!existsSync(LOCK_DIR)) { + mkdirSync(LOCK_DIR, { recursive: true }); + } + writeFileSync(LOCK_FILE, JSON.stringify(lock, null, 2)); +} + +export async function addToLock( + name: string, + entry: Omit +): Promise { + const lock = await readSkillLock(); + const now = new Date().toISOString(); + const existing = lock.skills[name]; + lock.skills[name] = { + ...entry, + installedAt: existing?.installedAt ?? now, + updatedAt: now, + }; + await writeSkillLock(lock); +} + +export async function removeFromLock(name: string): Promise { + const lock = await readSkillLock(); + if (!(name in lock.skills)) { + return false; + } + delete lock.skills[name]; + await writeSkillLock(lock); + return true; +} + +export async function getFromLock(name: string): Promise { + const lock = await readSkillLock(); + return lock.skills[name] ?? null; +} + +export async function getAllLockedSkills(): Promise> { + const lock = await readSkillLock(); + return lock.skills; +} + +export async function getLastSelectedAgents(): Promise { + const lock = await readSkillLock(); + return lock.lastSelectedAgents; +} + +export async function saveLastSelectedAgents(agents: string[]): Promise { + const lock = await readSkillLock(); + lock.lastSelectedAgents = agents; + await writeSkillLock(lock); +} diff --git a/skillhub-cli/src/core/skill-name.ts b/skillhub-cli/src/core/skill-name.ts new file mode 100644 index 00000000..fa2c924c --- /dev/null +++ b/skillhub-cli/src/core/skill-name.ts @@ -0,0 +1,12 @@ +export interface ParsedSkillName { + namespace: string; + slug: string; +} + +export function parseSkillName(input: string, defaultNamespace = "global"): ParsedSkillName { + const parts = input.split("/"); + if (parts.length >= 2) { + return { namespace: parts[0], slug: parts.slice(1).join("/") }; + } + return { namespace: defaultNamespace, slug: input }; +} diff --git a/skillhub-cli/src/core/source-parser.ts b/skillhub-cli/src/core/source-parser.ts new file mode 100644 index 00000000..cd64589d --- /dev/null +++ b/skillhub-cli/src/core/source-parser.ts @@ -0,0 +1,69 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const DEFAULT_GITHUB_HOST = "github.com"; + +function getGitHubHost(): string { + return process.env.GITHUB_MIRROR || DEFAULT_GITHUB_HOST; +} + +function isGitHubHost(hostname: string): boolean { + const ghHost = getGitHubHost(); + return hostname === ghHost || hostname.endsWith(`.${ghHost}`); +} + +export interface ParsedSource { + type: "local" | "github" | "gitlab" | "url"; + owner?: string; + repo?: string; + ref?: string; + subpath?: string; + localPath?: string; + cloneUrl?: string; + skillFilter?: string; +} + +export function parseSource(input: string): ParsedSource { + if (input.startsWith(".") || input.startsWith("/") || /^[a-zA-Z]:\\/.test(input)) { + const localPath = resolve(process.cwd(), input); + if (!existsSync(localPath)) { + throw new Error(`Local path not found: ${localPath}`); + } + return { type: "local", localPath }; + } + + if (input.startsWith("http://") || input.startsWith("https://")) { + const url = new URL(input); + if (isGitHubHost(url.hostname)) { + const [, owner, repo, , ref] = url.pathname.split("/"); + return { type: "github", owner, repo: repo?.replace(/\.git$/, ""), ref, cloneUrl: input }; + } + if (url.hostname.includes("gitlab.com")) { + const [, owner, repo] = url.pathname.split("/"); + return { type: "gitlab", owner, repo, cloneUrl: input }; + } + return { type: "url", cloneUrl: input }; + } + + const parts = input.split("/"); + if (parts.length === 2) { + const atIndex = parts[1].indexOf("@"); + if (atIndex > 0) { + const repo = parts[1].substring(0, atIndex); + const skillFilter = parts[1].substring(atIndex + 1); + return { type: "github", owner: parts[0], repo, skillFilter }; + } + return { type: "github", owner: parts[0], repo: parts[1] }; + } + + throw new Error(`Invalid source format: ${input}. Use owner/repo, local path, URL, or registry namespace/slug.`); +} + +export function getCloneUrl(source: ParsedSource): string { + if (source.cloneUrl) return source.cloneUrl; + if (source.type === "github" && source.owner && source.repo) { + const ghHost = getGitHubHost(); + return `https://${ghHost}/${source.owner}/${source.repo}.git`; + } + throw new Error("Cannot determine clone URL"); +} diff --git a/skillhub-cli/src/schema/routes.ts b/skillhub-cli/src/schema/routes.ts new file mode 100644 index 00000000..1bbdeaec --- /dev/null +++ b/skillhub-cli/src/schema/routes.ts @@ -0,0 +1,46 @@ +export const ApiRoutes = { + whoami: "/api/v1/whoami", + skills: "/api/v1/skills", + search: "/api/v1/search", + meNamespaces: "/api/v1/me/namespaces", + skillDetail: "/api/v1/skills/{namespace}/{slug}", + skillStar: "/api/v1/skills/{namespace}/{slug}/star", + skillVersions: "/api/v1/skills/{namespace}/{slug}/versions", + skillDownload: "/api/v1/skills/{namespace}/{slug}/download", + skillResolve: "/api/v1/skills/{namespace}/{slug}/resolve", + namespaceTransferOwnership: "/api/v1/namespaces/{namespace}/transfer-ownership", +} as const; + +export interface PublishResponse { + skillId: string; + namespace: string; + slug: string; + version: string; + status: string; +} + +export interface WhoamiResponse { + user: { + handle: string; + displayName: string; + image: string | null; + }; +} + +export interface NamespaceResponse { + id: number; + slug: string; + displayName: string; + currentUserRole: string; + status: string; +} + +export interface SearchResponse { + results: Array<{ + slug: string; + displayName: string; + summary: string; + version: string; + namespace?: string; // Namespace where the skill is published + }>; +} diff --git a/skillhub-cli/src/utils/install-helpers.ts b/skillhub-cli/src/utils/install-helpers.ts new file mode 100644 index 00000000..e09f6e16 --- /dev/null +++ b/skillhub-cli/src/utils/install-helpers.ts @@ -0,0 +1,253 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { homedir } from "node:os"; +import { sep } from "node:path"; + +export function riskLabel(risk: string): string { + switch (risk) { + case "critical": + return pc.red(pc.bold("Critical Risk")); + case "high": + return pc.red("High Risk"); + case "medium": + return pc.yellow("Med Risk"); + case "low": + return pc.green("Low Risk"); + case "safe": + return pc.green("Safe"); + default: + return pc.dim("--"); + } +} + +export function socketLabel(audit: { alerts?: number } | undefined): string { + if (!audit) return pc.dim("--"); + const count = audit.alerts ?? 0; + return count > 0 ? pc.red(`${count} alert${count !== 1 ? "s" : ""}`) : pc.green("0 alerts"); +} + +export function padEnd(str: string, width: number): string { + const visible = str.replace(/\x1b\[[0-9;]*m/g, ""); + const pad = Math.max(0, width - visible.length); + return str + " ".repeat(pad); +} + +export interface AuditSkill { + slug: string; + displayName: string; +} + +export interface AuditData { + ath?: { risk: string }; + socket?: { alerts?: number }; + snyk?: { risk: string }; +} + +export type AuditResponse = Record; + +export function buildSecurityLines( + auditData: AuditResponse | null, + skills: AuditSkill[], + _source: string +): string[] { + if (!auditData) return []; + + const hasAny = skills.some((s) => { + const data = auditData[s.slug]; + return data && Object.keys(data).length > 0; + }); + if (!hasAny) return []; + + const nameWidth = Math.min(Math.max(...skills.map((s) => s.displayName.length)), 36); + + const lines: string[] = []; + const header = + padEnd("", nameWidth + 2) + + padEnd(pc.dim("Gen"), 18) + + padEnd(pc.dim("Socket"), 18) + + pc.dim("Snyk"); + lines.push(header); + + for (const skill of skills) { + const data = auditData[skill.slug]; + const name = + skill.displayName.length > nameWidth + ? skill.displayName.slice(0, nameWidth - 1) + "\u2026" + : skill.displayName; + + const ath = data?.ath ? riskLabel(data.ath.risk) : pc.dim("--"); + const socket = data?.socket ? socketLabel(data.socket) : pc.dim("--"); + const snyk = data?.snyk ? riskLabel(data.snyk.risk) : pc.dim("--"); + + lines.push(padEnd(pc.cyan(name), nameWidth + 2) + padEnd(ath, 18) + padEnd(socket, 18) + snyk); + } + + lines.push(""); + lines.push(`${pc.dim("Details:")} ${pc.dim(`https://skills.sh/${_source}`)}`); + + return lines; +} + +export function shortenPath(fullPath: string, cwd: string): string { + const home = homedir(); + if (fullPath === home || fullPath.startsWith(home + sep)) { + return "~" + fullPath.slice(home.length); + } + if (fullPath === cwd || fullPath.startsWith(cwd + sep)) { + return "." + fullPath.slice(cwd.length); + } + return fullPath; +} + +export function formatList(items: string[], maxShow: number = 5): string { + if (items.length <= maxShow) { + return items.join(", "); + } + const shown = items.slice(0, maxShow); + const remaining = items.length - maxShow; + return `${shown.join(", ")} +${remaining} more`; +} + +export interface AgentInfo { + key: string; + name: string; + skillsDir: string; + globalSkillsDir?: string; +} + +export function splitAgentsByType( + agentTypes: string[], + agents: Record +): { universal: string[]; symlinked: string[] } { + const universal: string[] = []; + const symlinked: string[] = []; + + for (const a of agentTypes) { + const agent = agents[a]; + if (agent) { + if (agent.skillsDir === ".agents/skills") { + universal.push(agent.name); + } else { + symlinked.push(agent.name); + } + } + } + + return { universal, symlinked }; +} + +export function buildAgentSummaryLines( + targetAgents: string[], + installMode: string, + agents: Record +): string[] { + const lines: string[] = []; + const { universal, symlinked } = splitAgentsByType(targetAgents, agents); + + if (installMode === "symlink") { + if (universal.length > 0) { + lines.push(` ${pc.green("universal:")} ${formatList(universal)}`); + } + if (symlinked.length > 0) { + lines.push(` ${pc.dim("symlink →")} ${formatList(symlinked)}`); + } + } else { + const allNames = targetAgents.map((a) => agents[a]?.name || a); + lines.push(` ${pc.dim("copy →")} ${formatList(allNames)}`); + } + + return lines; +} + +export function ensureUniversalAgents( + targetAgents: string[], + getUniversalAgentsFn: () => string[] +): string[] { + const universalAgents = getUniversalAgentsFn(); + const result = [...targetAgents]; + + for (const ua of universalAgents) { + if (!result.includes(ua)) { + result.push(ua); + } + } + + return result; +} + +export interface InstallResult { + agent: string; + symlinkFailed?: boolean; +} + +export function buildResultLines( + results: InstallResult[], + targetAgents: string[], + agents: Record +): string[] { + const lines: string[] = []; + const { universal, symlinked } = splitAgentsByType(targetAgents, agents); + + const successfulSymlinks = results + .filter((r) => !r.symlinkFailed && !universal.includes(r.agent)) + .map((r) => r.agent); + const failedSymlinks = results.filter((r) => r.symlinkFailed).map((r) => r.agent); + + if (universal.length > 0) { + lines.push(` ${pc.green("universal:")} ${formatList(universal)}`); + } + if (successfulSymlinks.length > 0) { + lines.push(` ${pc.dim("symlinked:")} ${formatList(successfulSymlinks)}`); + } + if (failedSymlinks.length > 0) { + lines.push(` ${pc.yellow("copied:")} ${formatList(failedSymlinks)}`); + } + + return lines; +} + +export function isCancelled(value: unknown): value is symbol { + return typeof value === "symbol"; +} + +export async function interactiveSelect(opts: { + message: string; + options: Array<{ value: T; label: string; hint?: string }>; +}): Promise { + const selected = await p.select({ + message: opts.message, + options: opts.options as p.Option[], + }); + return selected as T | symbol; +} + +export async function interactiveConfirm(message: string): Promise { + const confirmed = await p.confirm({ message }); + return confirmed as boolean | symbol; +} + +export async function interactiveMultiSelect(opts: { + message: string; + options: Array<{ value: T; label: string; hint?: string }>; + initialValues?: T[]; + required?: boolean; +}): Promise { + return p.multiselect({ + message: `${opts.message} ${pc.dim("(space to toggle)")}`, + options: opts.options as p.Option[], + initialValues: opts.initialValues as T[], + required: opts.required, + }) as Promise; +} + +export function getCanonicalPath(skillName: string, isGlobal: boolean, agents: Record): string { + const universalAgents = Object.values(agents).filter((a) => a.skillsDir === ".agents/skills"); + if (universalAgents.length > 0) { + return `~/.agents/skills/${skillName}`; + } + if (isGlobal) { + const home = homedir(); + return `${home}/.agents/skills/${skillName}`; + } + return `.agents/skills/${skillName}`; +} \ No newline at end of file diff --git a/skillhub-cli/src/utils/logger.ts b/skillhub-cli/src/utils/logger.ts new file mode 100644 index 00000000..f9177de4 --- /dev/null +++ b/skillhub-cli/src/utils/logger.ts @@ -0,0 +1,25 @@ +import chalk from "chalk"; + +export function log(msg: string) { + console.log(msg); +} + +export function success(msg: string) { + console.log(chalk.green(msg)); +} + +export function error(msg: string) { + console.error(chalk.red(msg)); +} + +export function warn(msg: string) { + console.warn(chalk.yellow(msg)); +} + +export function info(msg: string) { + console.log(chalk.cyan(msg)); +} + +export function dim(msg: string) { + console.log(chalk.dim(msg)); +} diff --git a/skillhub-cli/src/utils/prompts.ts b/skillhub-cli/src/utils/prompts.ts new file mode 100644 index 00000000..90f86f7a --- /dev/null +++ b/skillhub-cli/src/utils/prompts.ts @@ -0,0 +1,449 @@ +import * as readline from "readline"; +import { Writable } from "stream"; + +const silentOutput = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, +}); + +const S_STEP_ACTIVE = "\x1b[32m◆\x1b[0m"; +const S_STEP_CANCEL = "\x1b[31m■\x1b[0m"; +const S_STEP_SUBMIT = "\x1b[32m◇\x1b[0m"; +const S_RADIO_ACTIVE = "\x1b[32m●\x1b[0m"; +const S_RADIO_INACTIVE = "\x1b[2m○\x1b[0m"; +const S_BULLET = "\x1b[32m•\x1b[0m"; +const S_BAR = "\x1b[2m│\x1b[0m"; +const S_BAR_H = "\x1b[2m─\x1b[0m"; +const S_ESC = "\x1b["; +const S_BOLD = "\x1b[1m"; +const S_DIM = "\x1b[2m"; +const S_UNDERLINE = "\x1b[4m"; +const S_INVERSE = "\x1b[7m"; +const S_RESET = "\x1b[0m"; +const S_CYAN = "\x1b[36m"; +const S_GREEN = "\x1b[32m"; +const S_YELLOW = "\x1b[33m"; +const S_RED = "\x1b[31m"; + +const bold = (s: string) => `${S_BOLD}${s}${S_RESET}`; +const dim = (s: string) => `${S_DIM}${s}${S_RESET}`; +const cyan = (s: string) => `${S_CYAN}${s}${S_RESET}`; +const green = (s: string) => `${S_GREEN}${s}${S_RESET}`; +const yellow = (s: string) => `${S_YELLOW}${s}${S_RESET}`; +const red = (s: string) => `${S_RED}${s}${S_RESET}`; + +function moveUp(n: number): string { + return `${S_ESC}${n}A`; +} + +function clearLine(): string { + return `${S_ESC}2K`; +} + +function clearRender(lastHeight: number): void { + if (lastHeight > 0) { + process.stdout.write(moveUp(lastHeight)); + for (let i = 0; i < lastHeight; i++) { + process.stdout.write(clearLine() + (i < lastHeight - 1 ? moveUp(1) + "\x1b[G" : "\n")); + } + process.stdout.write(moveUp(lastHeight)); + } +} + +export interface SelectItem { + value: string; + label: string; + hint?: string; +} + +export interface SelectSection { + title: string; + items: SelectItem[]; + locked?: boolean; +} + +export async function multiSelect( + message: string, + items: SelectItem[] +): Promise { + return new Promise((resolve) => { + console.log(""); + console.log(message); + console.log(dim("(输入数字选择,逗号分隔,如 1,3,5,输入 a 全选,n 取消)")); + console.log(""); + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const num = (i + 1).toString().padStart(2, " "); + console.log(` [${num}] ${item.label}`); + } + console.log(""); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question("请选择: ", (answer) => { + rl.close(); + console.log(""); + + const trimmed = answer.trim().toLowerCase(); + + if (trimmed === "n" || trimmed === "no") { + resolve(null); + return; + } + + if (trimmed === "a" || trimmed === "all") { + resolve(items.map((i) => i.value)); + return; + } + + const selected: string[] = []; + const parts = trimmed.split(",").map((s) => s.trim()); + + for (const part of parts) { + const num = parseInt(part, 10); + if (!isNaN(num) && num >= 1 && num <= items.length) { + selected.push(items[num - 1].value); + } + } + + if (selected.length === 0) { + console.log("未选择任何 skill"); + resolve(null); + return; + } + + resolve(selected); + }); + }); +} + +export async function sectionMultiSelect( + message: string, + sections: SelectSection[] +): Promise { + return new Promise((resolve) => { + console.log(""); + console.log(message); + console.log(dim("(输入数字选择,逗号分隔,如 1,3,5,输入 a 全选,n 取消)")); + console.log(""); + + let selectableIdx = 0; + for (const section of sections) { + if (section.locked) { + console.log(` ${section.title} ${"[always included]"}`); + for (const item of section.items) { + console.log(` ● ${item.label}${item.hint ? ` ${item.hint}` : ""}`); + } + } else { + console.log(` ${section.title}`); + for (const item of section.items) { + selectableIdx++; + console.log(` [${selectableIdx.toString().padStart(2, " ")}] ${item.label}${item.hint ? ` ${item.hint}` : ""}`); + } + } + } + console.log(""); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question("请选择: ", (answer) => { + rl.close(); + console.log(""); + + const trimmed = answer.trim().toLowerCase(); + + if (trimmed === "n" || trimmed === "no") { + resolve(null); + return; + } + + const selected: string[] = []; + const parts = trimmed.split(",").map((s) => s.trim()); + + selectableIdx = 0; + for (const section of sections) { + if (section.locked) { + selected.push(...section.items.map((i) => i.value)); + } else { + for (const item of section.items) { + selectableIdx++; + if (parts.includes(selectableIdx.toString())) { + selected.push(item.value); + } + } + } + } + + if (selected.length === 0) { + console.log("未选择任何项"); + resolve(null); + return; + } + + resolve(selected); + }); + }); +} + +export const cancelSymbol = Symbol("cancel"); + +export interface InteractiveSelectOptions { + message: string; + items: SelectItem[]; + initialSelected?: string[]; + lockedSection?: SelectSection; + hint?: string; +} + +export async function interactiveMultiSelect( + options: InteractiveSelectOptions +): Promise { + const { + message, + items, + initialSelected = [], + lockedSection, + hint = "↑↓ move, space select, enter confirm", + } = options; + + const selectableItems = items; + + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: silentOutput, + terminal: false, + }); + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + readline.emitKeypressEvents(process.stdin, rl); + + let query = ""; + let cursor = 0; + const selected = new Set(initialSelected); + let lastRenderHeight = 0; + + const lockedValues = lockedSection ? lockedSection.items.map((i) => i.value) : []; + + const filter = (item: SelectItem, q: string): boolean => { + if (!q) return true; + const lowerQ = q.toLowerCase(); + return ( + item.label.toLowerCase().includes(lowerQ) || + item.value.toLowerCase().includes(lowerQ) + ); + }; + + const getFiltered = (): SelectItem[] => { + return selectableItems.filter((item) => filter(item, query)); + }; + + const render = (state: "active" | "submit" | "cancel" = "active"): void => { + clearRender(lastRenderHeight); + + const lines: string[] = []; + const filtered = getFiltered(); + + const icon = + state === "active" ? S_STEP_ACTIVE : state === "cancel" ? S_STEP_CANCEL : S_STEP_SUBMIT; + lines.push(`${icon} ${bold(message)}`); + + if (state === "active") { + if (lockedSection && lockedSection.items.length > 0) { + lines.push(`${S_BAR}`); + const lockedTitle = `${bold(lockedSection.title)} ${dim("── always included")}`; + lines.push(`${S_BAR} ${S_BAR_H}${S_BAR_H} ${lockedTitle} ${S_BAR_H.repeat(12)}`); + for (const item of lockedSection.items) { + lines.push(`${S_BAR} ${S_BULLET} ${bold(item.label)}`); + } + lines.push(`${S_BAR}`); + lines.push( + `${S_BAR} ${S_BAR_H}${S_BAR_H} ${bold("Additional agents")} ${S_BAR_H.repeat(29)}` + ); + } + + const searchLine = `${S_BAR} ${dim("Search:")} ${query}${S_INVERSE} ${S_RESET}`; + lines.push(searchLine); + + lines.push(`${S_BAR} ${dim(hint)}`); + lines.push(`${S_BAR}`); + + const maxVisible = 10; + const visibleStart = Math.max( + 0, + Math.min(cursor - Math.floor(maxVisible / 2), filtered.length - maxVisible) + ); + const visibleEnd = Math.min(filtered.length, visibleStart + maxVisible); + const visibleItems = filtered.slice(visibleStart, visibleEnd); + + if (filtered.length === 0) { + lines.push(`${S_BAR} ${dim("No matches found")}`); + } else { + for (let i = 0; i < visibleItems.length; i++) { + const item = visibleItems[i]!; + const actualIndex = visibleStart + i; + const isSelected = selected.has(item.value); + const isCursor = actualIndex === cursor; + + const radio = isSelected ? S_RADIO_ACTIVE : S_RADIO_INACTIVE; + const label = isCursor ? `${S_UNDERLINE}${item.label}${S_RESET}` : item.label; + const hintStr = item.hint ? dim(` (${item.hint})`) : ""; + + const prefix = isCursor ? `${cyan("❯")}` : " "; + lines.push(`${S_BAR} ${prefix} ${radio} ${label}${hintStr}`); + } + + const hiddenBefore = visibleStart; + const hiddenAfter = filtered.length - visibleEnd; + if (hiddenBefore > 0 || hiddenAfter > 0) { + const parts: string[] = []; + if (hiddenBefore > 0) parts.push(`↑ ${hiddenBefore} more`); + if (hiddenAfter > 0) parts.push(`↓ ${hiddenAfter} more`); + lines.push(`${S_BAR} ${dim(parts.join(" "))}`); + } + } + + lines.push(`${S_BAR}`); + const allSelectedLabels = [ + ...(lockedSection ? lockedSection.items.map((i) => i.label) : []), + ...items.filter((item) => selected.has(item.value)).map((item) => item.label), + ]; + if (allSelectedLabels.length === 0) { + lines.push(`${S_BAR} ${dim("Selected: (none)")}`); + } else { + const summary = + allSelectedLabels.length <= 3 + ? allSelectedLabels.join(", ") + : `${allSelectedLabels.slice(0, 3).join(", ")} +${allSelectedLabels.length - 3} more`; + lines.push(`${S_BAR} ${green("Selected:")} ${summary}`); + } + + lines.push(`${dim("└")}`); + } else if (state === "submit") { + const allSelectedLabels = [ + ...(lockedSection ? lockedSection.items.map((i) => i.label) : []), + ...items.filter((item) => selected.has(item.value)).map((item) => item.label), + ]; + lines.push(`${S_BAR} ${dim(allSelectedLabels.join(", "))}`); + } else if (state === "cancel") { + lines.push(`${S_BAR} ${red("Cancelled")}`); + } + + process.stdout.write(lines.join("\n") + "\n"); + lastRenderHeight = lines.length; + }; + + const cleanup = (): void => { + process.stdin.removeListener("keypress", keypressHandler); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + rl.close(); + }; + + const submit = (): void => { + render("submit"); + cleanup(); + process.stdout.write("\n"); + resolve([...lockedValues, ...Array.from(selected)]); + }; + + const cancel = (): void => { + render("cancel"); + cleanup(); + process.stdout.write("\n"); + resolve(cancelSymbol); + }; + + const keypressHandler = (_str: string, key: readline.Key): void => { + if (!key) return; + + const filtered = getFiltered(); + + if (key.name === "return") { + submit(); + return; + } + + if (key.name === "escape" || (key.ctrl && key.name === "c")) { + cancel(); + return; + } + + if (key.name === "up") { + cursor = Math.max(0, cursor - 1); + render(); + return; + } + + if (key.name === "down") { + cursor = Math.min(filtered.length - 1, cursor + 1); + render(); + return; + } + + if (key.name === "space") { + const item = filtered[cursor]; + if (item) { + if (selected.has(item.value)) { + selected.delete(item.value); + } else { + selected.add(item.value); + } + } + render(); + return; + } + + if (key.name === "backspace") { + query = query.slice(0, -1); + cursor = 0; + render(); + return; + } + + if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) { + query += key.sequence; + cursor = 0; + render(); + return; + } + }; + + process.stdin.on("keypress", keypressHandler); + + render(); + }); +} + +export async function interactiveSelect( + message: string, + items: SelectItem[] +): Promise { + const options: InteractiveSelectOptions = { + message, + items, + }; + + const result = await interactiveMultiSelect(options); + + if (result === cancelSymbol) { + return cancelSymbol; + } + + if (result.length === 0) { + return cancelSymbol; + } + + return result[0]; +} diff --git a/skillhub-cli/src/utils/search-multiselect.ts b/skillhub-cli/src/utils/search-multiselect.ts new file mode 100644 index 00000000..a208c262 --- /dev/null +++ b/skillhub-cli/src/utils/search-multiselect.ts @@ -0,0 +1,297 @@ +import * as readline from 'readline'; +import { Writable } from 'stream'; +import pc from 'picocolors'; + +// Silent writable stream to prevent readline from echoing input +const silentOutput = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, +}); + +export interface SearchItem { + value: T; + label: string; + hint?: string; +} + +export interface LockedSection { + title: string; + items: SearchItem[]; +} + +export interface SearchMultiselectOptions { + message: string; + items: SearchItem[]; + maxVisible?: number; + initialSelected?: T[]; + /** If true, require at least one item to be selected before submitting */ + required?: boolean; + /** Locked section shown above the searchable list - items are always selected and can't be toggled */ + lockedSection?: LockedSection; +} + +const S_STEP_ACTIVE = pc.green('◆'); +const S_STEP_CANCEL = pc.red('■'); +const S_STEP_SUBMIT = pc.green('◇'); +const S_RADIO_ACTIVE = pc.green('●'); +const S_RADIO_INACTIVE = pc.dim('○'); +const S_CHECKBOX_LOCKED = pc.green('✓'); +const S_BULLET = pc.green('•'); +const S_BAR = pc.dim('│'); +const S_BAR_H = pc.dim('─'); + +export const cancelSymbol = Symbol('cancel'); + +/** + * Interactive search multiselect prompt. + * Allows users to filter a long list by typing and select multiple items. + * Optionally supports a "locked" section that displays always-selected items. + */ +export async function searchMultiselect( + options: SearchMultiselectOptions +): Promise { + const { + message, + items, + maxVisible = 8, + initialSelected = [], + required = false, + lockedSection, + } = options; + + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: silentOutput, + terminal: false, + }); + + // Enable raw mode for keypress detection + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + readline.emitKeypressEvents(process.stdin, rl); + + let query = ''; + let cursor = 0; + const selected = new Set(initialSelected); + let lastRenderHeight = 0; + + // Locked items are always included in the result + const lockedValues = lockedSection ? lockedSection.items.map((i) => i.value) : []; + + const filter = (item: SearchItem, q: string): boolean => { + if (!q) return true; + const lowerQ = q.toLowerCase(); + return ( + item.label.toLowerCase().includes(lowerQ) || + String(item.value).toLowerCase().includes(lowerQ) + ); + }; + + const getFiltered = (): SearchItem[] => { + return items.filter((item) => filter(item, query)); + }; + + const clearRender = (): void => { + if (lastRenderHeight > 0) { + // Move up and clear each line + process.stdout.write(`\x1b[${lastRenderHeight}A`); + for (let i = 0; i < lastRenderHeight; i++) { + process.stdout.write('\x1b[2K\x1b[1B'); + } + process.stdout.write(`\x1b[${lastRenderHeight}A`); + } + }; + + const render = (state: 'active' | 'submit' | 'cancel' = 'active'): void => { + clearRender(); + + const lines: string[] = []; + const filtered = getFiltered(); + + // Header + const icon = + state === 'active' ? S_STEP_ACTIVE : state === 'cancel' ? S_STEP_CANCEL : S_STEP_SUBMIT; + lines.push(`${icon} ${pc.bold(message)}`); + + if (state === 'active') { + // Locked section (universal agents) + if (lockedSection && lockedSection.items.length > 0) { + lines.push(`${S_BAR}`); + const lockedTitle = `${pc.bold(lockedSection.title)} ${pc.dim('── always included')}`; + lines.push(`${S_BAR} ${S_BAR_H}${S_BAR_H} ${lockedTitle} ${S_BAR_H.repeat(12)}`); + for (const item of lockedSection.items) { + lines.push(`${S_BAR} ${S_BULLET} ${pc.bold(item.label)}`); + } + lines.push(`${S_BAR}`); + lines.push( + `${S_BAR} ${S_BAR_H}${S_BAR_H} ${pc.bold('Additional agents')} ${S_BAR_H.repeat(29)}` + ); + } + + // Search input + const searchLine = `${S_BAR} ${pc.dim('Search:')} ${query}${pc.inverse(' ')}`; + lines.push(searchLine); + + // Hint + lines.push(`${S_BAR} ${pc.dim('↑↓ move, space select, enter confirm')}`); + lines.push(`${S_BAR}`); + + // Items + const visibleStart = Math.max( + 0, + Math.min(cursor - Math.floor(maxVisible / 2), filtered.length - maxVisible) + ); + const visibleEnd = Math.min(filtered.length, visibleStart + maxVisible); + const visibleItems = filtered.slice(visibleStart, visibleEnd); + + if (filtered.length === 0) { + lines.push(`${S_BAR} ${pc.dim('No matches found')}`); + } else { + for (let i = 0; i < visibleItems.length; i++) { + const item = visibleItems[i]!; + const actualIndex = visibleStart + i; + const isSelected = selected.has(item.value); + const isCursor = actualIndex === cursor; + + const radio = isSelected ? S_RADIO_ACTIVE : S_RADIO_INACTIVE; + const label = isCursor ? pc.underline(item.label) : item.label; + const hint = item.hint ? pc.dim(` (${item.hint})`) : ''; + + const prefix = isCursor ? pc.cyan('❯') : ' '; + lines.push(`${S_BAR} ${prefix} ${radio} ${label}${hint}`); + } + + // Show count if more items + const hiddenBefore = visibleStart; + const hiddenAfter = filtered.length - visibleEnd; + if (hiddenBefore > 0 || hiddenAfter > 0) { + const parts: string[] = []; + if (hiddenBefore > 0) parts.push(`↑ ${hiddenBefore} more`); + if (hiddenAfter > 0) parts.push(`↓ ${hiddenAfter} more`); + lines.push(`${S_BAR} ${pc.dim(parts.join(' '))}`); + } + } + + // Selected summary (include locked items) + lines.push(`${S_BAR}`); + const allSelectedLabels = [ + ...(lockedSection ? lockedSection.items.map((i) => i.label) : []), + ...items.filter((item) => selected.has(item.value)).map((item) => item.label), + ]; + if (allSelectedLabels.length === 0) { + lines.push(`${S_BAR} ${pc.dim('Selected: (none)')}`); + } else { + const summary = + allSelectedLabels.length <= 3 + ? allSelectedLabels.join(', ') + : `${allSelectedLabels.slice(0, 3).join(', ')} +${allSelectedLabels.length - 3} more`; + lines.push(`${S_BAR} ${pc.green('Selected:')} ${summary}`); + } + + lines.push(`${pc.dim('└')}`); + } else if (state === 'submit') { + // Final state - show what was selected (including locked) + const allSelectedLabels = [ + ...(lockedSection ? lockedSection.items.map((i) => i.label) : []), + ...items.filter((item) => selected.has(item.value)).map((item) => item.label), + ]; + lines.push(`${S_BAR} ${pc.dim(allSelectedLabels.join(', '))}`); + } else if (state === 'cancel') { + lines.push(`${S_BAR} ${pc.strikethrough(pc.dim('Cancelled'))}`); + } + + process.stdout.write(lines.join('\n') + '\n'); + lastRenderHeight = lines.length; + }; + + const cleanup = (): void => { + process.stdin.removeListener('keypress', keypressHandler); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + rl.close(); + }; + + const submit = (): void => { + // If required and no locked items, don't allow submitting with no selection + if (required && selected.size === 0 && lockedValues.length === 0) { + return; + } + render('submit'); + cleanup(); + // Include locked values in the result + resolve([...lockedValues, ...Array.from(selected)]); + }; + + const cancel = (): void => { + render('cancel'); + cleanup(); + resolve(cancelSymbol); + }; + + // Handle keypresses + const keypressHandler = (_str: string, key: readline.Key): void => { + if (!key) return; + + const filtered = getFiltered(); + + if (key.name === 'return') { + submit(); + return; + } + + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + cancel(); + return; + } + + if (key.name === 'up') { + cursor = Math.max(0, cursor - 1); + render(); + return; + } + + if (key.name === 'down') { + cursor = Math.min(filtered.length - 1, cursor + 1); + render(); + return; + } + + if (key.name === 'space') { + const item = filtered[cursor]; + if (item) { + if (selected.has(item.value)) { + selected.delete(item.value); + } else { + selected.add(item.value); + } + } + render(); + return; + } + + if (key.name === 'backspace') { + query = query.slice(0, -1); + cursor = 0; + render(); + return; + } + + // Regular character input + if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) { + query += key.sequence; + cursor = 0; + render(); + return; + } + }; + + process.stdin.on('keypress', keypressHandler); + + // Initial render + render(); + }); +} diff --git a/skillhub-cli/src/utils/telemetry.ts b/skillhub-cli/src/utils/telemetry.ts new file mode 100644 index 00000000..3aa760ec --- /dev/null +++ b/skillhub-cli/src/utils/telemetry.ts @@ -0,0 +1,99 @@ +/** + * Telemetry utility for anonymous usage tracking. + * + * Respects the following environment variables: + * - DISABLE_TELEMETRY=1 (or 'true') + * - DO_NOT_TRACK=1 + * + * Telemetry is automatically disabled in CI environments. + */ + +export interface TelemetryEvent { + command: string; + args?: string[]; + options?: Record; +} + +export interface TelemetryConfig { + enabled: boolean; + reason?: string; +} + +/** + * Check if telemetry should be disabled. + * Respects user preferences and CI environments. + */ +export function isTelemetryDisabled(): TelemetryConfig { + // Check CI environment + if (process.env.CI === 'true' || process.env.CONTINUOUS_INTEGRATION === 'true') { + return { enabled: false, reason: 'CI environment' }; + } + + // Check explicit opt-out flags + const disableTelemetry = process.env.DISABLE_TELEMETRY; + const doNotTrack = process.env.DO_NOT_TRACK; + + if (disableTelemetry === '1' || disableTelemetry === 'true') { + return { enabled: false, reason: 'DISABLE_TELEMETRY is set' }; + } + + if (doNotTrack === '1' || doNotTrack === 'true') { + return { enabled: false, reason: 'DO_NOT_TRACK is set' }; + } + + // Check Node.js built-in doNotTrack + if (process.env.NODE_OPTIONS?.includes('do-not-track')) { + return { enabled: false, reason: 'NODE_OPTIONS includes do-not-track' }; + } + + return { enabled: true }; +} + +/** + * Track a command execution event. + * Currently a stub - actual tracking implementation would send to a telemetry endpoint. + */ +export function trackEvent(event: TelemetryEvent): void { + const { enabled, reason } = isTelemetryDisabled(); + + if (!enabled) { + // Silently skip tracking + return; + } + + // TODO: Implement actual telemetry tracking + // For now, this is a stub that could be expanded to: + // - Send events to a configured endpoint + // - Batch events and send periodically + // - Store events locally if offline + // + // Example implementation: + // if (process.env.SKILLHUB_TELEMETRY_URL) { + // fetch(process.env.SKILLHUB_TELEMETRY_URL, { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ event, timestamp: Date.now() }) + // }); + // } +} + +/** + * Track a command execution. + * Call this at the start of each command. + */ +export function trackCommand(command: string, args?: string[], options?: Record): void { + trackEvent({ command, args, options }); +} + +/** + * Get telemetry status for display (e.g., in --help or version output). + */ +export function getTelemetryStatus(): string { + const { enabled, reason } = isTelemetryDisabled(); + + if (!enabled) { + return `Telemetry: Disabled (${reason})`; + } + + return 'Telemetry: Enabled (anonymous usage collection)'; +} diff --git a/skillhub-cli/tests/api-client.test.ts b/skillhub-cli/tests/api-client.test.ts new file mode 100644 index 00000000..fd6b2a32 --- /dev/null +++ b/skillhub-cli/tests/api-client.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("undici", () => ({ + request: vi.fn(), + FormData: class FormData { + private _data = new Map(); + set(k: string, v: string) { this._data.set(k, v); } + append(k: string, v: unknown) { this._data.set(k, String(v)); } + }, +})); + +import { ApiClient, ApiError } from "../src/core/api-client.js"; +import { request } from "undici"; + +const mockRequest = request as ReturnType; + +function mockResponse(statusCode: number, body: unknown) { + mockRequest.mockResolvedValueOnce({ + statusCode, + body: { json: async () => body }, + }); +} + +describe("ApiClient", () => { + let client: ApiClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new ApiClient({ baseUrl: "http://localhost:8080" }); + }); + + describe("ApiResponse unwrapping", () => { + it("unwraps Native API success response", async () => { + mockResponse(200, { + code: 0, + msg: "success", + data: { id: 1, name: "test" }, + timestamp: "2026-01-01T00:00:00Z", + }); + + const result = await client.get<{ id: number; name: string }>("/api/v1/test"); + + expect(result).toEqual({ id: 1, name: "test" }); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it("throws ApiError on Native API error response", async () => { + mockResponse(200, { + code: 403, + msg: "Forbidden", + data: null, + timestamp: "2026-01-01T00:00:00Z", + }); + + await expect(client.get("/api/v1/test")).rejects.toThrow(ApiError); + }); + + it("returns raw response for Compat layer (no code/data)", async () => { + mockResponse(200, { + user: { handle: "test-user", displayName: "Test", image: null }, + }); + + const result = await client.get<{ user: { handle: string } }>("/api/v1/whoami"); + + expect(result).toEqual({ + user: { handle: "test-user", displayName: "Test", image: null }, + }); + }); + + it("returns raw response for Compat search results", async () => { + mockResponse(200, { + results: [ + { slug: "test-skill", displayName: "Test", summary: "A test", version: "1.0.0" }, + ], + }); + + const result = await client.get<{ results: Array<{ slug: string }> }>("/api/v1/search"); + + expect(result.results).toHaveLength(1); + expect(result.results[0].slug).toBe("test-skill"); + }); + + it("returns raw response for Compat publish result", async () => { + mockResponse(200, { ok: true, skillId: "1", versionId: "1" }); + + const result = await client.postForm<{ ok: boolean; skillId: string }>("/api/v1/skills", {} as any); + + expect(result.ok).toBe(true); + expect(result.skillId).toBe("1"); + }); + }); + + describe("HTTP error handling", () => { + it("throws ApiError on HTTP 404", async () => { + mockResponse(404, { code: 404, msg: "Not found", data: null }); + + await expect(client.get("/api/v1/nonexistent")).rejects.toThrow(ApiError); + }); + + it("throws ApiError on HTTP 500", async () => { + mockResponse(500, { code: 500, msg: "Internal error", data: null }); + + await expect(client.get("/api/v1/test")).rejects.toThrow(ApiError); + }); + }); + + describe("Authorization header", () => { + it("includes Bearer token when provided", async () => { + mockResponse(200, { code: 0, data: {}, msg: "ok" }); + + const clientWithToken = new ApiClient({ + baseUrl: "http://localhost:8080", + token: "sk_test123", + }); + + await clientWithToken.get("/api/v1/test"); + + expect(mockRequest).toHaveBeenCalledWith( + "http://localhost:8080/api/v1/test", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer sk_test123", + }), + }) + ); + }); + + it("omits Authorization header when no token", async () => { + mockResponse(200, { results: [] }); + + await client.get("/api/v1/search"); + + const callArgs = mockRequest.mock.calls[0][1]; + expect(callArgs.headers).not.toHaveProperty("Authorization"); + }); + }); + + describe("POST method", () => { + it("unwraps Native API POST response", async () => { + mockResponse(200, { + code: 0, + data: { id: 1 }, + msg: "created", + }); + + const result = await client.post<{ id: number }>("/api/v1/test", { body: "{}" }); + + expect(result).toEqual({ id: 1 }); + }); + + it("returns raw Compat POST response", async () => { + mockResponse(200, { ok: true, skillId: "2" }); + + const result = await client.post<{ ok: boolean }>("/api/v1/skills", { body: "{}" }); + + expect(result.ok).toBe(true); + }); + }); + + describe("PUT method", () => { + it("unwraps Native API PUT response", async () => { + mockResponse(200, { code: 0, data: { updated: true }, msg: "ok" }); + + const result = await client.put<{ updated: boolean }>("/api/v1/test", { body: "{}" }); + + expect(result.updated).toBe(true); + }); + }); + + describe("DELETE method", () => { + it("unwraps Native API DELETE response", async () => { + mockResponse(200, { code: 0, data: { deleted: true }, msg: "ok" }); + + const result = await client.delete<{ deleted: boolean }>("/api/v1/test"); + + expect(result.deleted).toBe(true); + }); + }); +}); diff --git a/skillhub-cli/tests/commands.test.ts b/skillhub-cli/tests/commands.test.ts new file mode 100644 index 00000000..b33b9a7f --- /dev/null +++ b/skillhub-cli/tests/commands.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect } from "vitest"; +import { Command } from "commander"; +import { registerInspect } from "../src/commands/inspect.js"; +import { registerWhoami } from "../src/commands/whoami.js"; +import { registerLogin } from "../src/commands/login.js"; +import { registerPublish } from "../src/commands/publish.js"; +import { registerMe } from "../src/commands/me.js"; +import { registerVersions } from "../src/commands/versions.js"; +import { registerNotifications } from "../src/commands/notifications.js"; +import { registerReviews } from "../src/commands/reviews.js"; +import { registerNamespaces } from "../src/commands/namespaces.js"; +import { registerResolve } from "../src/commands/resolve.js"; +import { registerRating, registerRate } from "../src/commands/rating.js"; +import { registerStar } from "../src/commands/star.js"; +import { registerDelete } from "../src/commands/delete.js"; +import { registerArchive } from "../src/commands/archive.js"; +import { registerReport } from "../src/commands/report.js"; +import { registerSearch } from "../src/commands/search.js"; +import { registerInstall } from "../src/commands/install.js"; +import { registerDownload } from "../src/commands/download.js"; +import { registerInit } from "../src/commands/init.js"; +import { registerList } from "../src/commands/list.js"; +import { registerLogout } from "../src/commands/logout.js"; +import { registerUninstall } from "../src/commands/uninstall.js"; +import { registerSync } from "../src/commands/sync.js"; + +describe("Command registrations", () => { + function getCommandNames(program: Command): string[] { + return program.commands.map((c) => c.name()); + } + + it("registers inspect command", () => { + const program = new Command(); + registerInspect(program); + const names = getCommandNames(program); + expect(names).toContain("inspect"); + }); + + it("registers whoami command", () => { + const program = new Command(); + registerWhoami(program); + expect(getCommandNames(program)).toContain("whoami"); + }); + + it("registers login command", () => { + const program = new Command(); + registerLogin(program); + expect(getCommandNames(program)).toContain("login"); + }); + + it("registers publish command with correct options", () => { + const program = new Command(); + registerPublish(program); + const cmd = program.commands.find((c) => c.name() === "publish"); + expect(cmd).toBeDefined(); + const opts = cmd!.options.map((o) => o.flags); + expect(opts).toContain("--namespace "); + expect(opts).toContain("--slug "); + expect(opts).toContain("-v, --skill-version "); + expect(opts).not.toContain("--version "); + }); + + it("registers me command with skills and stars subcommands", () => { + const program = new Command(); + registerMe(program); + const cmd = program.commands.find((c) => c.name() === "me"); + expect(cmd).toBeDefined(); + const subNames = cmd!.commands.map((c) => c.name()); + expect(subNames).toContain("skills"); + expect(subNames).toContain("stars"); + }); + + it("registers versions command", () => { + const program = new Command(); + registerVersions(program); + expect(getCommandNames(program)).toContain("versions"); + }); + + it("registers notifications command with subcommands", () => { + const program = new Command(); + registerNotifications(program); + const cmd = program.commands.find((c) => c.name() === "notifications"); + expect(cmd).toBeDefined(); + const subNames = cmd!.commands.map((c) => c.name()); + expect(subNames).toContain("list"); + expect(subNames).toContain("read"); + expect(subNames).toContain("read-all"); + }); + + it("registers reviews command with subcommands", () => { + const program = new Command(); + registerReviews(program); + const cmd = program.commands.find((c) => c.name() === "reviews"); + expect(cmd).toBeDefined(); + const subNames = cmd!.commands.map((c) => c.name()); + expect(subNames).toContain("my"); + }); + + it("registers namespaces command", () => { + const program = new Command(); + registerNamespaces(program); + expect(getCommandNames(program)).toContain("namespaces"); + }); + + it("registers resolve command", () => { + const program = new Command(); + registerResolve(program); + expect(getCommandNames(program)).toContain("resolve"); + }); + + it("registers rating and rate commands", () => { + const program = new Command(); + registerRating(program); + registerRate(program); + const names = getCommandNames(program); + expect(names).toContain("rating"); + expect(names).toContain("rate"); + }); + + it("registers star command", () => { + const program = new Command(); + registerStar(program); + expect(getCommandNames(program)).toContain("star"); + }); + + it("registers delete command", () => { + const program = new Command(); + registerDelete(program); + expect(getCommandNames(program)).toContain("delete"); + }); + + it("registers archive command", () => { + const program = new Command(); + registerArchive(program); + expect(getCommandNames(program)).toContain("archive"); + }); + + it("registers report command", () => { + const program = new Command(); + registerReport(program); + expect(getCommandNames(program)).toContain("report"); + }); + + it("registers search command", () => { + const program = new Command(); + registerSearch(program); + expect(getCommandNames(program)).toContain("search"); + }); + + it("registers install command", () => { + const program = new Command(); + registerInstall(program); + expect(getCommandNames(program)).toContain("install"); + }); + + it("registers download command", () => { + const program = new Command(); + registerDownload(program); + expect(getCommandNames(program)).toContain("download"); + }); + + it("registers init command", () => { + const program = new Command(); + registerInit(program); + expect(getCommandNames(program)).toContain("init"); + }); + + it("registers list command", () => { + const program = new Command(); + registerList(program); + expect(getCommandNames(program)).toContain("list"); + }); + + it("registers uninstall command", () => { + const program = new Command(); + registerUninstall(program); + expect(getCommandNames(program)).toContain("uninstall"); + }); + + it("registers logout command", () => { + const program = new Command(); + registerLogout(program); + expect(getCommandNames(program)).toContain("logout"); + }); + + it("registers sync command", () => { + const program = new Command(); + registerSync(program); + expect(getCommandNames(program)).toContain("sync"); + }); +}); diff --git a/skillhub-cli/tests/install.test.ts b/skillhub-cli/tests/install.test.ts new file mode 100644 index 00000000..eec77ccf --- /dev/null +++ b/skillhub-cli/tests/install.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const mockSuccess = vi.fn(); +const mockError = vi.fn(); +const mockInfo = vi.fn(); + +vi.mock("../src/utils/logger.js", () => ({ + success: mockSuccess, + error: mockError, + info: mockInfo, + dim: vi.fn(), +})); + +describe("install command", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), "install-test-" + Date.now()); + mkdirSync(tempDir, { recursive: true }); + mockSuccess.mockClear(); + mockError.mockClear(); + mockInfo.mockClear(); + }); + + afterEach(() => { + try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + vi.restoreAllMocks(); + }); + + describe("source auto-detection", () => { + it("should detect git source: owner/repo format", () => { + const source = "vercel-labs/agent-skills"; + const isGitSource = /^[\w-]+\/[\w-]+$/.test(source); + expect(isGitSource).toBe(true); + }); + + it("should detect git source: GitHub URL", () => { + const source = "https://github.com/vercel-labs/agent-skills"; + expect(source.includes("github.com")).toBe(true); + }); + + it("should detect git source: GitLab URL", () => { + const source = "https://gitlab.com/vercel-labs/agent-skills"; + expect(source.includes("gitlab.com")).toBe(true); + }); + + it("should detect local source: relative path", () => { + const source = "./my-skill"; + const isLocal = source.startsWith(".") || source.startsWith("/") || source.startsWith("~"); + expect(isLocal).toBe(true); + }); + + it("should detect local source: absolute path", () => { + const source = "/Users/me/skills/my-skill"; + expect(source.startsWith("/")).toBe(true); + }); + + it("should detect registry source: plain slug", () => { + const source = "my-skill"; + const isGit = /^[\w-]+\/[\w-]+$/.test(source) || source.includes("github.com") || source.includes("gitlab.com"); + const isLocal = source.startsWith(".") || source.startsWith("/") || source.startsWith("~"); + expect(isGit || isLocal).toBe(false); + }); + + it("should detect registry source: namespace--slug format", () => { + const source = "global--my-skill"; + const isScoped = source.includes("--"); + expect(isScoped).toBe(true); + }); + }); + + describe("--list option", () => { + it("should list skills without installing", () => { + const skills = [ + { name: "skill-one", description: "First skill" }, + { name: "skill-two", description: "Second skill" }, + ]; + expect(skills.length).toBe(2); + expect(skills[0].name).toBe("skill-one"); + }); + }); + + describe("registry install", () => { + it("should construct correct download URL", () => { + const ns = "global"; + const slug = "my-skill"; + const downloadUrl = `/api/v1/skills/${ns}/${slug}/download`; + expect(downloadUrl).toBe("/api/v1/skills/global/my-skill/download"); + }); + + it("should use default namespace when not specified", () => { + const defaultNs = "global"; + expect(defaultNs).toBe("global"); + }); + }); + + describe("git install", () => { + it("should parse owner/repo correctly", () => { + const input = "vercel-labs/skills"; + const parts = input.split("/"); + expect(parts[0]).toBe("vercel-labs"); + expect(parts[1]).toBe("skills"); + }); + + it("should construct GitHub clone URL", () => { + const owner = "vercel-labs"; + const repo = "skills"; + const cloneUrl = `https://github.com/${owner}/${repo}.git`; + expect(cloneUrl).toBe("https://github.com/vercel-labs/skills.git"); + }); + }); + + describe("agent selection", () => { + it("should detect installed agents", () => { + const allAgents = [ + { key: "claude-code", name: "Claude Code" }, + { key: "cursor", name: "Cursor" }, + ]; + expect(allAgents.length).toBe(2); + }); + + it("should filter by specified agent keys", () => { + const allAgents = [ + { key: "claude-code", name: "Claude Code" }, + { key: "cursor", name: "Cursor" }, + ]; + const selected = allAgents.filter((a) => ["claude-code"].includes(a.key)); + expect(selected.length).toBe(1); + expect(selected[0].key).toBe("claude-code"); + }); + }); + + describe("install modes", () => { + it("should support symlink mode (default)", () => { + const mode = "symlink"; + expect(mode).toBe("symlink"); + }); + + it("should support copy mode with --copy flag", () => { + const useCopy = true; + const mode = useCopy ? "copy" : "symlink"; + expect(mode).toBe("copy"); + }); + + it("should support global scope with --global flag", () => { + const isGlobal = true; + expect(isGlobal).toBe(true); + }); + + it("should support project scope (default)", () => { + const isGlobal = false; + expect(isGlobal).toBe(false); + }); + }); +}); + +describe("--skill option", () => { + it("should select all skills when '*' is specified", () => { + const allSkills = [ + { name: "skill-one", description: "First" }, + { name: "skill-two", description: "Second" }, + { name: "skill-three", description: "Third" }, + ]; + const skillNames = ["*"] as string[]; + + let selectedSkills; + if (skillNames.includes("*")) { + selectedSkills = allSkills; + } + + expect(selectedSkills).toEqual(allSkills); + expect(selectedSkills.length).toBe(3); + }); + + it("should filter skills by exact name match", () => { + const allSkills = [ + { name: "skill-one", description: "First" }, + { name: "skill-two", description: "Second" }, + { name: "skill-three", description: "Third" }, + ]; + const skillNames = ["skill-one", "skill-three"] as string[]; + + const selectedSkills = allSkills.filter((s) => skillNames.includes(s.name)); + + expect(selectedSkills.length).toBe(2); + expect(selectedSkills[0].name).toBe("skill-one"); + expect(selectedSkills[1].name).toBe("skill-three"); + }); + + it("should return empty array when no skills match", () => { + const allSkills = [ + { name: "skill-one", description: "First" }, + { name: "skill-two", description: "Second" }, + ]; + const skillNames = ["non-existent"] as string[]; + + const selectedSkills = allSkills.filter((s) => skillNames.includes(s.name)); + + expect(selectedSkills.length).toBe(0); + }); + + it("should handle case-sensitive name matching", () => { + const allSkills = [ + { name: "OpenSpec", description: "OpenSpec skill" }, + { name: "openspec", description: "lowercase" }, + ]; + const skillNames = ["openspec"] as string[]; + + const selectedSkills = allSkills.filter((s) => skillNames.includes(s.name)); + + expect(selectedSkills.length).toBe(1); + expect(selectedSkills[0].name).toBe("openspec"); + }); +}); + +describe("add command alias", () => { + it("should be equivalent to install --source git", () => { + const installSource = "git"; + expect(installSource).toBe("git"); + }); +}); diff --git a/skillhub-cli/tests/installer.test.ts b/skillhub-cli/tests/installer.test.ts new file mode 100644 index 00000000..36582a40 --- /dev/null +++ b/skillhub-cli/tests/installer.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { installSkill } from "../src/core/installer.js"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +describe("installSkill", () => { + let tempDir: string; + let skillDir: string; + let targetDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), "installer-test-" + Date.now()); + skillDir = join(tempDir, "source-skill"); + targetDir = tempDir; + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "# Test Skill\n"); + }); + + afterEach(() => { + try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + }); + + it("should return correct mode when mode is 'symlink'", () => { + const result = installSkill(skillDir, "test-skill", "claude-code", targetDir, "symlink", false); + expect(result.mode).toBe("symlink"); + expect(result.success).toBe(true); + }); + + it("should return correct mode when mode is 'copy'", () => { + const result = installSkill(skillDir, "test-skill", "claude-code", targetDir, "copy", false); + expect(result.mode).toBe("copy"); + expect(result.success).toBe(true); + }); +}); diff --git a/skillhub-cli/tests/prompts.test.ts b/skillhub-cli/tests/prompts.test.ts new file mode 100644 index 00000000..abd4f23f --- /dev/null +++ b/skillhub-cli/tests/prompts.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from "vitest"; + +describe("multiSelect parsing", () => { + it("parses comma-separated numbers", () => { + const input = "1,3,5"; + const parts = input.split(",").map((s) => s.trim()); + const indices = parts.map((p) => parseInt(p, 10) - 1); + const items = [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + { value: "c", label: "C" }, + { value: "d", label: "D" }, + { value: "e", label: "E" }, + ]; + const selected = indices.filter((i) => i >= 0 && i < items.length).map((i) => items[i].value); + expect(selected).toEqual(["a", "c", "e"]); + }); + + it("parses 'a' for all", () => { + const trimmed = "a"; + const items = [{ value: "a", label: "A" }, { value: "b", label: "B" }]; + if (trimmed === "a" || trimmed === "all") { + const selected = items.map((i) => i.value); + expect(selected).toEqual(["a", "b"]); + } + }); + + it("parses 'n' for none", () => { + const trimmed = "n"; + let result: string[] | null = null; + if (trimmed === "n" || trimmed === "no") { + result = null; + } + expect(result).toBe(null); + }); + + it("ignores out-of-range numbers", () => { + const input = "1,10,2"; + const parts = input.split(",").map((s) => s.trim()); + const items = [{ value: "a", label: "A" }, { value: "b", label: "B" }]; + const indices = parts.map((p) => parseInt(p, 10) - 1); + const selected = indices.filter((i) => i >= 0 && i < items.length).map((i) => items[i].value); + expect(selected).toEqual(["a", "b"]); + }); +}); diff --git a/skillhub-cli/tests/skill-lock.test.ts b/skillhub-cli/tests/skill-lock.test.ts new file mode 100644 index 00000000..a2946086 --- /dev/null +++ b/skillhub-cli/tests/skill-lock.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const LOCK_FILE_VERSION = 1; + +interface SkillLockEntry { + source: string; + sourceType: "git" | "registry" | "local"; + sourceUrl: string; + ref?: string; + namespace: string; + slug: string; + version: string; + fingerprint?: string; + installedAt: string; + updatedAt: string; +} + +interface SkillLockFile { + version: number; + skills: Record; + lastSelectedAgents?: string[]; +} + +function getSkillLockPath(dir: string): string { + return join(dir, "lock.json"); +} + +async function readSkillLock(dir: string): Promise { + const lockPath = getSkillLockPath(dir); + if (!existsSync(lockPath)) { + return { version: LOCK_FILE_VERSION, skills: {} }; + } + const content = readFileSync(lockPath, "utf-8"); + return JSON.parse(content); +} + +async function writeSkillLock(dir: string, lock: SkillLockFile): Promise { + const lockPath = getSkillLockPath(dir); + mkdirSync(dir, { recursive: true }); + writeFileSync(lockPath, JSON.stringify(lock, null, 2)); +} + +async function addToLock(dir: string, name: string, entry: SkillLockEntry): Promise { + const lock = await readSkillLock(dir); + const now = new Date().toISOString(); + const existing = lock.skills[name]; + lock.skills[name] = { + ...entry, + installedAt: existing?.installedAt ?? entry.installedAt ?? now, + updatedAt: now, + }; + await writeSkillLock(dir, lock); +} + +async function removeFromLock(dir: string, name: string): Promise { + const lock = await readSkillLock(dir); + if (!(name in lock.skills)) return false; + delete lock.skills[name]; + await writeSkillLock(dir, lock); + return true; +} + +async function getFromLock(dir: string, name: string): Promise { + const lock = await readSkillLock(dir); + return lock.skills[name] ?? null; +} + +describe("skill-lock", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), "skill-lock-test-" + Date.now()); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + }); + + describe("read/write", () => { + it("should read empty lock file", async () => { + const lock = await readSkillLock(tempDir); + expect(lock.version).toBe(LOCK_FILE_VERSION); + expect(lock.skills).toEqual({}); + }); + + it("should write and read lock file", async () => { + const entry: SkillLockEntry = { + source: "owner/repo", + sourceType: "git", + sourceUrl: "https://github.com/owner/repo", + namespace: "global", + slug: "my-skill", + version: "1.0.0", + installedAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + await addToLock(tempDir, "my-skill", entry); + + const lock = await readSkillLock(tempDir); + expect(Object.keys(lock.skills)).toContain("my-skill"); + expect(lock.skills["my-skill"].source).toBe("owner/repo"); + }); + }); + + describe("addToLock", () => { + it("should add new entry", async () => { + const entry: SkillLockEntry = { + source: "owner/repo", + sourceType: "git", + sourceUrl: "https://github.com/owner/repo", + namespace: "global", + slug: "new-skill", + version: "1.0.0", + installedAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + await addToLock(tempDir, "new-skill", entry); + + const result = await getFromLock(tempDir, "new-skill"); + expect(result).not.toBeNull(); + expect(result!.slug).toBe("new-skill"); + }); + + it("should preserve installedAt on update", async () => { + const entry1: SkillLockEntry = { + source: "owner/repo", + sourceType: "git", + sourceUrl: "https://github.com/owner/repo", + namespace: "global", + slug: "skill", + version: "1.0.0", + installedAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + await addToLock(tempDir, "skill", entry1); + + const savedFirst = await getFromLock(tempDir, "skill"); + expect(savedFirst!.installedAt).toBe("2024-01-01T00:00:00Z"); + + const entry2: SkillLockEntry = { + ...entry1, + version: "1.1.0", + }; + await addToLock(tempDir, "skill", entry2); + + const result = await getFromLock(tempDir, "skill"); + expect(result!.installedAt).toBe("2024-01-01T00:00:00Z"); + expect(result!.version).toBe("1.1.0"); + expect(result!.updatedAt).not.toBe("2024-01-01T00:00:00Z"); + }); + }); + + describe("removeFromLock", () => { + it("should remove existing entry", async () => { + const entry: SkillLockEntry = { + source: "owner/repo", + sourceType: "git", + sourceUrl: "https://github.com/owner/repo", + namespace: "global", + slug: "to-remove", + version: "1.0.0", + installedAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + await addToLock(tempDir, "to-remove", entry); + + const removed = await removeFromLock(tempDir, "to-remove"); + expect(removed).toBe(true); + + const result = await getFromLock(tempDir, "to-remove"); + expect(result).toBeNull(); + }); + + it("should return false for non-existent entry", async () => { + const removed = await removeFromLock(tempDir, "non-existent"); + expect(removed).toBe(false); + }); + }); + + describe("getFromLock", () => { + it("should return null for non-existent key", async () => { + const result = await getFromLock(tempDir, "non-existent"); + expect(result).toBeNull(); + }); + + it("should return entry for existing key", async () => { + const entry: SkillLockEntry = { + source: "global/my-skill", + sourceType: "registry", + sourceUrl: "https://registry.example.com/global/my-skill", + namespace: "global", + slug: "my-skill", + version: "2.0.0", + installedAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + await addToLock(tempDir, "my-skill", entry); + + const result = await getFromLock(tempDir, "my-skill"); + expect(result).not.toBeNull(); + expect(result!.version).toBe("2.0.0"); + expect(result!.sourceType).toBe("registry"); + }); + }); + + describe("lastSelectedAgents", () => { + it("should persist lastSelectedAgents", async () => { + const lock: SkillLockFile = { + version: LOCK_FILE_VERSION, + skills: {}, + lastSelectedAgents: ["claude-code", "cursor"], + }; + await writeSkillLock(tempDir, lock); + + const read = await readSkillLock(tempDir); + expect(read.lastSelectedAgents).toEqual(["claude-code", "cursor"]); + }); + }); +}); diff --git a/skillhub-cli/tests/skill-name.test.ts b/skillhub-cli/tests/skill-name.test.ts new file mode 100644 index 00000000..ba8d5e28 --- /dev/null +++ b/skillhub-cli/tests/skill-name.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { parseSkillName } from "../src/core/skill-name.js"; + +describe("parseSkillName", () => { + it("should parse namespace/slug format", () => { + const result = parseSkillName("global/test"); + expect(result.namespace).toBe("global"); + expect(result.slug).toBe("test"); + }); + + it("should use default namespace for plain slug", () => { + const result = parseSkillName("test"); + expect(result.namespace).toBe("global"); + expect(result.slug).toBe("test"); + }); + + it("should allow custom default namespace", () => { + const result = parseSkillName("test", "vision2group"); + expect(result.namespace).toBe("vision2group"); + expect(result.slug).toBe("test"); + }); + + it("should handle team/namespace format", () => { + const result = parseSkillName("vision2group/test-publish"); + expect(result.namespace).toBe("vision2group"); + expect(result.slug).toBe("test-publish"); + }); + + it("should handle slug with multiple slashes (use first two parts)", () => { + const result = parseSkillName("a/b/c"); + expect(result.namespace).toBe("a"); + expect(result.slug).toBe("b/c"); + }); +}); diff --git a/skillhub-cli/tests/source-parser.test.ts b/skillhub-cli/tests/source-parser.test.ts new file mode 100644 index 00000000..3e5ab8d6 --- /dev/null +++ b/skillhub-cli/tests/source-parser.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi } from "vitest"; + +// local fs mock +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), +})); + +it("parses local path", async () => { + vi.resetModules(); + vi.doMock("node:fs", () => ({ existsSync: vi.fn().mockReturnValue(true) })); + const mod = await import("../src/core/source-parser"); + const res = mod.parseSource("/abs/path"); + expect(res.type).toBe("local"); + expect(res.localPath).toBe("/abs/path"); +}); + +it("parses github url from github.com", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const res = mod.parseSource("https://github.com/owner/repo.git"); + expect(res.type).toBe("github"); + expect(res.owner).toBe("owner"); + expect(res.repo).toBe("repo"); + expect(res.cloneUrl).toBe("https://github.com/owner/repo.git"); +}); + +it("parses shorthand owner/repo", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const res = mod.parseSource("alice/awesome"); + expect(res.type).toBe("github"); + expect(res.owner).toBe("alice"); + expect(res.repo).toBe("awesome"); +}); + +it("throws on invalid source format", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + expect(() => mod.parseSource("invalid")).toThrow(); +}); + +it("getCloneUrl uses cloneUrl when provided", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const url = mod.getCloneUrl({ type: "github", owner: "a", repo: "b", cloneUrl: "https://example.com/a/b.git" } as any); + expect(url).toBe("https://example.com/a/b.git"); +}); + +it("getCloneUrl builds default for github without cloneUrl", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const url = mod.getCloneUrl({ type: "github", owner: "x", repo: "y" } as any); + expect(url).toBe("https://github.com/x/y.git"); +}); + +it("parses @skill syntax: owner/repo@skillname", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const res = mod.parseSource("vercel-labs/skills@openspec"); + expect(res.type).toBe("github"); + expect(res.owner).toBe("vercel-labs"); + expect(res.repo).toBe("skills"); + expect(res.skillFilter).toBe("openspec"); +}); + +it("parses @skill syntax with branch: owner/repo@skillname", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const res = mod.parseSource("owner/repo@something"); + expect(res.type).toBe("github"); + expect(res.owner).toBe("owner"); + expect(res.repo).toBe("repo"); + expect(res.skillFilter).toBe("something"); +}); + +it("does not confuse @ in path with @skill syntax", async () => { + vi.resetModules(); + const mod = await import("../src/core/source-parser"); + const res = mod.parseSource("https://github.com/owner/repo"); + expect(res.type).toBe("github"); + expect(res.owner).toBe("owner"); + expect(res.repo).toBe("repo"); + expect(res.skillFilter).toBeUndefined(); +}); diff --git a/skillhub-cli/tests/uninstall.test.ts b/skillhub-cli/tests/uninstall.test.ts new file mode 100644 index 00000000..504a931d --- /dev/null +++ b/skillhub-cli/tests/uninstall.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const mockSuccess = vi.fn(); +const mockError = vi.fn(); +const mockInfo = vi.fn(); + +vi.mock("../src/utils/logger.js", () => ({ + success: mockSuccess, + error: mockError, + info: mockInfo, + dim: vi.fn(), +})); + +describe("uninstall command", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), "uninstall-test-" + Date.now()); + mkdirSync(tempDir, { recursive: true }); + mockSuccess.mockClear(); + mockError.mockClear(); + mockInfo.mockClear(); + }); + + afterEach(() => { + try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + vi.restoreAllMocks(); + }); + + describe("removeDir utility", () => { + it("should handle file removal", () => { + const testFile = join(tempDir, "test-file.txt"); + writeFileSync(testFile, "content"); + expect(existsSync(testFile)).toBe(true); + }); + + it("should handle directory removal recursively", () => { + const testSubDir = join(tempDir, "subdir", "nested"); + mkdirSync(testSubDir, { recursive: true }); + writeFileSync(join(testSubDir, "file.txt"), "content"); + expect(existsSync(testSubDir)).toBe(true); + }); + }); + + describe("--all flag", () => { + it("should discover all installed skills", async () => { + const skill1 = join(tempDir, ".claude", "skills", "skill-one"); + const skill2 = join(tempDir, ".claude", "skills", "skill-two"); + mkdirSync(skill1, { recursive: true }); + mkdirSync(skill2, { recursive: true }); + writeFileSync(join(skill1, "SKILL.md"), "# Skill One\n"); + writeFileSync(join(skill2, "SKILL.md"), "# Skill Two\n"); + + expect(existsSync(skill1)).toBe(true); + expect(existsSync(skill2)).toBe(true); + }); + }); + + describe("--agent filter", () => { + it("should filter by specific agent", () => { + const claudeDir = join(tempDir, ".claude", "skills", "shared-skill"); + const cursorDir = join(tempDir, ".agents", "skills", "shared-skill"); + mkdirSync(claudeDir, { recursive: true }); + mkdirSync(cursorDir, { recursive: true }); + writeFileSync(join(claudeDir, "SKILL.md"), "# Shared Skill\n"); + writeFileSync(join(cursorDir, "SKILL.md"), "# Shared Skill\n"); + + expect(existsSync(claudeDir)).toBe(true); + expect(existsSync(cursorDir)).toBe(true); + }); + }); + + describe("--global flag", () => { + it("should target global scope only", () => { + const globalSkill = join(tempDir, ".claude", "skills", "global-skill"); + mkdirSync(globalSkill, { recursive: true }); + writeFileSync(join(globalSkill, "SKILL.md"), "# Global Skill\n"); + + expect(existsSync(globalSkill)).toBe(true); + }); + }); +}); + +describe("source parser", () => { + describe("parseSource", () => { + it("should identify git source: owner/repo", () => { + const source = "vercel-labs/agent-skills"; + const pattern = /^[\w-]+\/[\w-]+/; + expect(pattern.test(source)).toBe(true); + }); + + it("should identify git source: GitHub URL", () => { + const source = "https://github.com/vercel-labs/agent-skills"; + expect(source.startsWith("https://github.com/")).toBe(true); + }); + + it("should identify registry source: slug", () => { + const source = "my-skill"; + const isGit = /^[\w-]+\/[\w-]+/.test(source) || source.startsWith("https://github.com/"); + expect(isGit).toBe(false); + }); + + it("should identify registry source: namespace--slug", () => { + const source = "global--my-skill"; + const parts = source.split("--"); + expect(parts.length >= 2).toBe(true); + }); + }); +}); diff --git a/skillhub-cli/tsconfig.json b/skillhub-cli/tsconfig.json new file mode 100644 index 00000000..ab8d1ce7 --- /dev/null +++ b/skillhub-cli/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/skillhub-cli/unbuild.config.ts b/skillhub-cli/unbuild.config.ts new file mode 100644 index 00000000..dee0b39f --- /dev/null +++ b/skillhub-cli/unbuild.config.ts @@ -0,0 +1,10 @@ +import { defineBuildConfig } from "unbuild"; + +export default defineBuildConfig({ + entries: ["src/cli"], + outDir: "dist", + clean: true, + rollup: { + emitCJS: false, + }, +}); diff --git a/skillhub-cli/vitest.config.ts b/skillhub-cli/vitest.config.ts new file mode 100644 index 00000000..3f824fb9 --- /dev/null +++ b/skillhub-cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, +});