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 01/68] 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 000000000..2d46e7621 --- /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 000000000..bb051a695 --- /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 000000000..d42e17529 --- /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 000000000..46052e725 --- /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 000000000..e8fb840e6 --- /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 000000000..dd338c6bc --- /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 000000000..23763156a --- /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 000000000..9944e7201 --- /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 000000000..7863c43cf --- /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 000000000..0ddafed08 --- /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 000000000..8acbea8e2 --- /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 000000000..76c8bca2a --- /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 000000000..8b24796b4 --- /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 000000000..9b7d83762 --- /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 000000000..94c2e5827 --- /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 000000000..9fb52ba1e --- /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 000000000..8359c5eeb --- /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 000000000..2fd73ec86 --- /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 000000000..5ea575ba7 --- /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 000000000..583d4f6ad --- /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 000000000..91c3e14c1 --- /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 000000000..412ed63c4 --- /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 000000000..ba38ca140 --- /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 000000000..47e4b482d --- /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 000000000..067e2c1a1 --- /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 000000000..4cc7afea8 --- /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 000000000..b54684e1d --- /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 000000000..a7249b6e8 --- /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 000000000..974342fb0 --- /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 000000000..bc0e9de51 --- /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 000000000..3aa2954a3 --- /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 000000000..9ba153aaa --- /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 000000000..d87a01426 --- /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 000000000..3ab111835 --- /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 000000000..c60a76da1 --- /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 000000000..890e9a50e --- /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 000000000..c36d929e0 --- /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 000000000..2ca968143 --- /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 000000000..8244c52b7 --- /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 000000000..fa2c924c4 --- /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 000000000..cd64589d0 --- /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 000000000..1bbdeaec9 --- /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 000000000..e09f6e168 --- /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 000000000..f9177de43 --- /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 000000000..90f86f7ae --- /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 000000000..a208c2623 --- /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 000000000..3aa760ec1 --- /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 000000000..fd6b2a324 --- /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 000000000..b33b9a7fb --- /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 000000000..eec77ccf5 --- /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 000000000..36582a407 --- /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 000000000..abd4f23fa --- /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 000000000..a29460865 --- /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 000000000..ba8d5e284 --- /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 000000000..3e5ab8d66 --- /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 000000000..504a931d3 --- /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 000000000..ab8d1ce7d --- /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 000000000..dee0b39f3 --- /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 000000000..3f824fb95 --- /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", + }, +}); From c8e58e04766de49f28f875f8703c6034c3606624 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:32:49 +0800 Subject: [PATCH 02/68] fix(resolve): strip 'v' prefix from version for API compatibility --- skillhub-cli/src/commands/resolve.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skillhub-cli/src/commands/resolve.ts b/skillhub-cli/src/commands/resolve.ts index 412ed63c4..348f564b1 100644 --- a/skillhub-cli/src/commands/resolve.ts +++ b/skillhub-cli/src/commands/resolve.ts @@ -57,7 +57,8 @@ export function registerResolve(program: Command) { let targetNamespace = namespace; let targetSlug = skillSlug; - const specifiedVersion = opts.skillVersion; + // Strip 'v' prefix if present (both 1.0.0 and v1.0.0 should work) + const specifiedVersion = opts.skillVersion?.replace(/^v/, ""); // Case 1: User specified a version if (specifiedVersion) { From a85beea6b94f1b3f5ceb71eebdd998422a9200ed Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:50:28 +0800 Subject: [PATCH 03/68] fix(install): change install method from multiselect to single select --- skillhub-cli/src/commands/install.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 76c8bca2a..1a9bfbe34 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -83,23 +83,27 @@ async function selectAgentsInteractive(isGlobal: boolean): Promise { - const result = await searchMultiselect({ + const result = await p.select({ message: "Installation method?", - items: [ - { value: "symlink", label: "Symlink (Recommended)", hint: "single source of truth" }, - { value: "copy", label: "Copy to all agents", hint: "independent copies" }, + options: [ + { + 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) { + if (p.isCancel(result)) { return null; } - if ((result as string[]).includes("symlink")) { - return "symlink"; - } - return "copy"; + return result as "symlink" | "copy"; } function buildAgentSummary(targetAgents: { key: string; name: string; skillsDir: string }[], mode: "symlink" | "copy"): string[] { From ffdf8ae30e9e397c2c2239de3b0b2366d2f8630b Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:19:58 +0800 Subject: [PATCH 04/68] fix(skillhub-cli): improve publish output with default namespace hint and fixed status - Show '(default)' suffix when namespace is global (user didn't specify --namespace) - Display 'Status: Published' on success instead of 'undefined' - Keep output clean and informative for user feedback --- skillhub-cli/src/commands/publish.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/skillhub-cli/src/commands/publish.ts b/skillhub-cli/src/commands/publish.ts index 5ea575ba7..5799d64db 100644 --- a/skillhub-cli/src/commands/publish.ts +++ b/skillhub-cli/src/commands/publish.ts @@ -29,9 +29,17 @@ export function registerPublish(program: Command) { } 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)"); + let version = opts["skill-version"] || opts.ver; + if (!version) { + const now = new Date(); + const yyyymmdd = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate(); + const hhmmss = now.getHours() * 10000 + now.getMinutes() * 100 + now.getSeconds(); + version = `${yyyymmdd}.${hhmmss}`; + } + // Allow timestamp format (YYYYMMDD.HHMMSS) or standard semver + const isValidVersion = semver.valid(version) || /^\d{8}\.\d+$/.test(version); + if (!isValidVersion) { + error("--skill-version must be a valid semver (e.g. 1.0.0) or timestamp (e.g. 20260414.123045)"); process.exit(1); } @@ -74,8 +82,10 @@ export function registerPublish(program: Command) { ); spinner.succeed(`Published ${slug}@${version} (${result.skillId})`); - info(`Namespace: ${result.namespace}`); - info(`Status: ${result.status}`); + const actualNamespace = result.namespace || namespace; + const isDefaultNamespace = actualNamespace === namespace && !opts.namespace; + info(`Namespace: ${actualNamespace}${isDefaultNamespace ? " (default)" : ""}`); + info(`Status: Published`); } catch (e: any) { error(`Publish failed: ${e.message}`); process.exit(1); From c86d99f2270c769d626aa45218084f77bf940d93 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:12:37 +0800 Subject: [PATCH 05/68] feat(explore): add --sort parameter for hot/newest/downloads ordering - Use /api/v1/skills (compat API) when query is empty to get full stats - Map sort options: hot=rating, newest=newest, downloads=downloads - Remove vim-style k/j navigation to fix 'k' key input issue --- skillhub-cli/package.json | 2 +- skillhub-cli/src/commands/explore.ts | 24 ++++--- skillhub-cli/src/core/interactive-search.ts | 71 ++++++++++++++++++--- skillhub-cli/src/schema/routes.ts | 23 ++++++- 4 files changed, 98 insertions(+), 22 deletions(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index 2d46e7621..8d7e55d22 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,5 +1,5 @@ { - "name": "@motovis/skillhub", + "name": "@iflytek/skillhub", "version": "1.0.0", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", diff --git a/skillhub-cli/src/commands/explore.ts b/skillhub-cli/src/commands/explore.ts index 9944e7201..dba7576c6 100644 --- a/skillhub-cli/src/commands/explore.ts +++ b/skillhub-cli/src/commands/explore.ts @@ -47,7 +47,8 @@ async function fetchSkillDetail(client: ApiClient, namespace: string, name: stri async function runInteractiveSearch( client: ApiClient, - initialQuery: string = "" + initialQuery: string = "", + sort: string = "newest" ): Promise { const MAX_VISIBLE = 8; let query = initialQuery; @@ -136,7 +137,7 @@ async function runInteractiveSearch( debounceTimer = setTimeout(async () => { try { - results = await searchSkills(client, q); + results = await searchSkills(client, q, 10, sort); selectedIndex = 0; } catch { results = []; @@ -179,13 +180,13 @@ async function runInteractiveSearch( return; } - if (key.name === "up" || key.name === "k") { + if (key.name === "up") { selectedIndex = Math.max(0, selectedIndex - 1); render(); return; } - if (key.name === "down" || key.name === "j") { + if (key.name === "down") { selectedIndex = Math.min(Math.max(0, results.length - 1), selectedIndex + 1); render(); return; @@ -219,14 +220,17 @@ export function registerExplore(program: Command) { .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 }) => { + .option("-s, --sort ", "Sort by: hot, newest, downloads (default: interactive mode)") + .action(async (query: string | undefined, opts: { limit: string; sort?: string }) => { const config = loadConfig(); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + const sortMap: Record = { hot: "rating", newest: "newest", downloads: "downloads" }; + const apiSort = sortMap[opts.sort || "newest"] || "newest"; try { - if (!query) { - const selected = await runInteractiveSearch(client, ""); + if (!query && !opts.sort) { + const selected = await runInteractiveSearch(client, "", apiSort); if (!selected) { console.log("\nCancelled."); return; @@ -236,14 +240,14 @@ export function registerExplore(program: Command) { return; } - const results = await searchSkills(client, query, parseInt(opts.limit, 10)); + const results = await searchSkills(client, query || "", parseInt(opts.limit, 10), apiSort); if (results.length === 0) { - console.log(`${DIM}No skills found for "${query}"${RESET}`); + console.log(`${DIM}No skills found${RESET}`); return; } - const maxResults = Math.min(results.length, 6); + const maxResults = Math.min(results.length, parseInt(opts.limit, 10)); const detailPromises = results.slice(0, maxResults).map((s) => fetchSkillDetail(client, s.namespace, s.name) diff --git a/skillhub-cli/src/core/interactive-search.ts b/skillhub-cli/src/core/interactive-search.ts index c36d929e0..ed86a755c 100644 --- a/skillhub-cli/src/core/interactive-search.ts +++ b/skillhub-cli/src/core/interactive-search.ts @@ -1,5 +1,5 @@ import { ApiClient } from "./api-client.js"; -import { ApiRoutes, SearchResponse } from "../schema/routes.js"; +import { ApiRoutes, SearchResponse, SkillsListResponse } from "../schema/routes.js"; import * as readline from "readline"; import { dim, info } from "../utils/logger.js"; @@ -59,17 +59,56 @@ async function fetchSkillDetail(client: ApiClient, namespace: string, name: stri export async function searchSkills( client: ApiClient, query: string, - limit: number = 10 + limit: number = 10, + sort?: string ): Promise { + if (!query) { + const params = new URLSearchParams({ limit: limit.toString() }); + if (sort && sort !== "newest") { + params.set("sort", sort); + } + const result = await client.get( + `${ApiRoutes.skills}?${params.toString()}` + ); + if (!result.items || result.items.length === 0) { + return []; + } + const skills = result.items.map((s) => { + const { namespace, name } = parseNamespace(s.slug); + return { + name, + slug: s.slug, + namespace, + version: s.latestVersion?.version || "", + summary: s.summary, + installs: s.stats?.downloads || 0, + stars: s.stats?.stars || 0, + rating: 0, + updatedAt: s.updatedAt || 0, + }; + }); + if (sort === "downloads") { + return skills.sort((a, b) => b.installs - a.installs); + } else if (sort === "rating") { + return skills.sort((a, b) => b.rating - a.rating || b.stars - a.stars); + } else { + return skills.sort((a, b) => b.updatedAt - a.updatedAt); + } + } + + const params = new URLSearchParams({ q: query, limit: limit.toString() }); + if (sort) { + params.set("sort", sort); + } const result = await client.get( - `${ApiRoutes.search}?q=${encodeURIComponent(query)}&limit=${limit}` + `${ApiRoutes.search}?${params.toString()}` ); if (!result.results || result.results.length === 0) { return []; } - return result.results.map((s) => { + const skills = result.results.map((s) => { const { namespace, name } = parseNamespace(s.slug); return { name, @@ -77,14 +116,26 @@ export async function searchSkills( namespace, version: s.version, summary: s.summary, - installs: (s as any).installCount || 0, + installs: s.downloadCount || 0, + stars: s.starCount || 0, + rating: s.ratingAvg || 0, + updatedAt: s.updatedAt ? new Date(s.updatedAt).getTime() : 0, }; - }).sort((a, b) => (b.installs || 0) - (a.installs || 0)); + }); + + if (sort === "downloads") { + return skills.sort((a, b) => b.installs - a.installs); + } else if (sort === "rating") { + return skills.sort((a, b) => b.rating - a.rating || b.stars - a.stars); + } else { + return skills.sort((a, b) => b.updatedAt - a.updatedAt); + } } export async function runInteractiveSearch( client: ApiClient, - initialQuery: string = "" + initialQuery: string = "", + sort: string = "newest" ): Promise { const MAX_VISIBLE = 8; let query = initialQuery; @@ -172,7 +223,7 @@ export async function runInteractiveSearch( debounceTimer = setTimeout(async () => { try { - results = await searchSkills(client, q); + results = await searchSkills(client, q, 10, sort); selectedIndex = 0; } catch { results = []; @@ -215,13 +266,13 @@ export async function runInteractiveSearch( return; } - if (key.name === "up" || key.name === "k") { + if (key.name === "up") { selectedIndex = Math.max(0, selectedIndex - 1); render(); return; } - if (key.name === "down" || key.name === "j") { + if (key.name === "down") { selectedIndex = Math.min(Math.max(0, results.length - 1), selectedIndex + 1); render(); return; diff --git a/skillhub-cli/src/schema/routes.ts b/skillhub-cli/src/schema/routes.ts index 1bbdeaec9..662242f7c 100644 --- a/skillhub-cli/src/schema/routes.ts +++ b/skillhub-cli/src/schema/routes.ts @@ -41,6 +41,27 @@ export interface SearchResponse { displayName: string; summary: string; version: string; - namespace?: string; // Namespace where the skill is published + namespace?: string; + downloadCount?: number; + starCount?: number; + ratingAvg?: number; + updatedAt?: string; }>; } + +export interface SkillsListResponse { + items: Array<{ + slug: string; + displayName: string; + summary: string; + updatedAt: number; + stats: { + downloads?: number; + stars?: number; + }; + latestVersion?: { + version: string; + }; + }>; + nextCursor: string | null; +} From a7b0485e379df2d27189e2a40d9f5131f3ad0642 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:17:36 +0800 Subject: [PATCH 06/68] fix(cli package.json): package name changed from @motovis/skillhub to @seasoning/skillhub. --- skillhub-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index 8d7e55d22..5d6c9d68c 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,5 +1,5 @@ { - "name": "@iflytek/skillhub", + "name": "@seasoning/skillhub", "version": "1.0.0", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", From e15ad7b35bfa750717d52da6f8c9ab0efebe5144 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:19:53 +0800 Subject: [PATCH 07/68] feat(explore): add --sort parameter and fix k key input issue Features: - Add --sort option: hot (rating), newest (date), downloads (download count) - Use /api/v1/skills (compat API) for empty queries to get full download stats - Sort mapping: hot->rating, newest->newest, downloads->downloads Bug fixes: - Remove vim-style k/j navigation keys to fix 'k' character input issue - Fix undefined namespace/status display in publish output Testing: - Verify --sort downloads: openspec(102) > test-publish(46) > openspec[vision2group](11) > test(9) - Verify --sort hot: test-publish(2 stars) ranked first - Verify --sort newest: most recently updated first - Interactive mode: k key now inputs correctly From 679333c76fb82a844631f9e2062492507391327b Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:02:32 +0800 Subject: [PATCH 08/68] feat(cli): show tags and changelog after successful publish - Always display tags in publish output - Show changelog when --changelog flag is provided Co-authored-by: Qwen-Coder --- skillhub-cli/src/commands/publish.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/skillhub-cli/src/commands/publish.ts b/skillhub-cli/src/commands/publish.ts index 5799d64db..8f62822b5 100644 --- a/skillhub-cli/src/commands/publish.ts +++ b/skillhub-cli/src/commands/publish.ts @@ -86,6 +86,10 @@ export function registerPublish(program: Command) { const isDefaultNamespace = actualNamespace === namespace && !opts.namespace; info(`Namespace: ${actualNamespace}${isDefaultNamespace ? " (default)" : ""}`); info(`Status: Published`); + info(`Tags: ${tags.join(", ")}`); + if (changelog) { + info(`Changelog: ${changelog}`); + } } catch (e: any) { error(`Publish failed: ${e.message}`); process.exit(1); From 98b04259d89cdb98ee43bec39a575e92afd73f6c Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:12:32 +0800 Subject: [PATCH 09/68] fix: change package name to @motovis/skillhub for npm org scope --- skillhub-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index 5d6c9d68c..2d46e7621 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,5 +1,5 @@ { - "name": "@seasoning/skillhub", + "name": "@motovis/skillhub", "version": "1.0.0", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", From de7be0fc7d2c91b557711f88ea50224efb9bfaa4 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:15:36 +0800 Subject: [PATCH 10/68] fix: add .npmignore to exclude tests and tmp from published package --- skillhub-cli/.npmignore | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 skillhub-cli/.npmignore diff --git a/skillhub-cli/.npmignore b/skillhub-cli/.npmignore new file mode 100644 index 000000000..0217fc47e --- /dev/null +++ b/skillhub-cli/.npmignore @@ -0,0 +1,7 @@ +tmp/ +还未发布 +tests/ +vitest.config.ts +unbuild.config.ts +tsconfig.json +*.test.ts From 8c87cc5fe4f09c1989e801d6539bafdcf938a06d Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:56:42 +0800 Subject: [PATCH 11/68] fix: add shebang to cli.ts for npm bin to work properly --- skillhub-cli/src/cli.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index d42e17529..ca06afa03 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env node import { Command } from "commander"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; From af6ebf30588516fc96e3318f4d040052d025623b Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:16:33 +0800 Subject: [PATCH 12/68] fix(cli): handle 302 redirect in download and fix --skill-version option parsing - undici does not auto-follow HTTP redirects, manually follow 302/307/308 to MinIO presigned URL before piping response body - Commander.js parses --skill-version as skillVersion (camelCase), not skill-version (kebab-case), fix option access in install and download commands --- skillhub-cli/src/commands/download.ts | 15 ++++++++++++--- skillhub-cli/src/commands/install.ts | 18 ++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts index 23763156a..3b8d38ad3 100644 --- a/skillhub-cli/src/commands/download.ts +++ b/skillhub-cli/src/commands/download.ts @@ -28,18 +28,27 @@ export function registerDownload(program: Command) { 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`; + if (opts.skillVersion) { + downloadUrl = `/api/v1/skills/${namespace}/${skillSlug}/versions/${opts.skillVersion}/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(), { + let response = await request(url.toString(), { method: "GET", headers: token ? { Authorization: `Bearer ${token}` } : {}, }); + if (response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308) { + const location = response.headers.location; + if (!location) { + spinner.fail(`Redirect response has no Location header`); + process.exit(1); + } + response = await request(location, { method: "GET" }); + } + const { statusCode, body } = response; if (statusCode >= 400) { spinner.fail(`Download failed: HTTP ${statusCode}`); diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 1a9bfbe34..7d1415f82 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -240,9 +240,8 @@ async function installFromRegistry(slug: string, opts: Record= 400) { spinner.fail(`Skill not found: ${ns}/${actualSlug}`); From c9644e8032219c43b79027c11c22ecd27a3b0ff3 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:31:30 +0800 Subject: [PATCH 13/68] chore: bump version to 1.0.2 --- skillhub-cli/package.json | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index 2d46e7621..403fcbf77 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,15 +1,11 @@ { - "name": "@motovis/skillhub", - "version": "1.0.0", + "name": "motovis-skillhub", + "version": "1.0.2", "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", From 626490e55473c6443053d7515165223e34f8387a Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:49:40 +0800 Subject: [PATCH 14/68] feat(cli): support SKILLHUB_REGISTRY env var for registry URL Priority: SKILLHUB_REGISTRY env var > ~/.skillhub/config.json > default --- skillhub-cli/src/core/config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/skillhub-cli/src/core/config.ts b/skillhub-cli/src/core/config.ts index c60a76da1..bbfd3009a 100644 --- a/skillhub-cli/src/core/config.ts +++ b/skillhub-cli/src/core/config.ts @@ -15,6 +15,16 @@ const DEFAULT_CONFIG: CliConfig = { }; export function loadConfig(): CliConfig { + const envRegistry = process.env.SKILLHUB_REGISTRY; + if (envRegistry) { + if (!existsSync(CONFIG_FILE)) return { registry: envRegistry }; + try { + const raw = readFileSync(CONFIG_FILE, "utf-8"); + return { registry: envRegistry, ...JSON.parse(raw) }; + } catch { + return { registry: envRegistry }; + } + } if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG }; try { const raw = readFileSync(CONFIG_FILE, "utf-8"); From c970a76410111487bb952f0fb9a7d4a3045f908f Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:51:29 +0800 Subject: [PATCH 15/68] chore: bump version to 1.0.3 --- skillhub-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index 403fcbf77..65425aa0e 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.0.2", + "version": "1.0.3", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { From 616319fe3533d61dfbba203f90e6a6a2951b47f3 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:18:20 +0800 Subject: [PATCH 16/68] fix(cli): replace execSync unzip with unzipper for cross-platform support - Add @types/unzipper for TypeScript support - Fix location header type assertion (string | string[] -> string) - Bump version to 1.0.5 --- skillhub-cli/package.json | 6 +- skillhub-cli/pnpm-lock.yaml | 116 ++++++++++++++++++++++++++ skillhub-cli/src/commands/download.ts | 2 +- skillhub-cli/src/commands/install.ts | 9 +- 4 files changed, 127 insertions(+), 6 deletions(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index 65425aa0e..e6bbc3e2d 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.0.3", + "version": "1.0.5", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { @@ -19,11 +19,13 @@ "ora": "^9.3.0", "picocolors": "^1.1.1", "semver": "^7.7.4", - "undici": "^7.24.0" + "undici": "^7.24.0", + "unzipper": "^0.12.3" }, "devDependencies": { "@types/node": "^22.0.0", "@types/semver": "^7.5.8", + "@types/unzipper": "^0.10.11", "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 index bb051a695..4a105fa44 100644 --- a/skillhub-cli/pnpm-lock.yaml +++ b/skillhub-cli/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: undici: specifier: ^7.24.0 version: 7.24.7 + unzipper: + specifier: ^0.12.3 + version: 0.12.3 devDependencies: '@types/node': specifier: ^22.0.0 @@ -36,6 +39,9 @@ importers: '@types/semver': specifier: ^7.5.8 version: 7.7.1 + '@types/unzipper': + specifier: ^0.10.11 + version: 0.10.11 typescript: specifier: ^5.6.0 version: 5.9.3 @@ -603,6 +609,9 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/unzipper@0.10.11': + resolution: {integrity: sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -657,6 +666,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -722,6 +734,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + css-declaration-sorter@7.3.1: resolution: {integrity: sha512-gz6x+KkgNCjxq3Var03pRYLhyNfwhkKF1g/yoLgDNtFvVu0/fOLV9C8fFEZRjACp/XQLumjAYo7JVjzH3wLbxA==} engines: {node: ^14 || ^16 || >=18} @@ -803,6 +818,9 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + electron-to-chromium@1.5.331: resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} @@ -864,6 +882,10 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + engines: {node: '>=14.14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -876,6 +898,9 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -883,6 +908,9 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -901,6 +929,9 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -915,6 +946,9 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + knitwork@1.3.0: resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} @@ -980,6 +1014,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} @@ -1200,6 +1237,12 @@ packages: resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} engines: {node: '>=20'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -1221,6 +1264,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -1261,6 +1307,9 @@ packages: resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -1329,10 +1378,17 @@ packages: resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} engines: {node: '>=20.18.1'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + untyped@2.0.0: resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} hasBin: true + unzipper@0.12.3: + resolution: {integrity: sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -1764,6 +1820,10 @@ snapshots: '@types/semver@7.7.1': {} + '@types/unzipper@0.10.11': + dependencies: + '@types/node': 22.19.15 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -1823,6 +1883,8 @@ snapshots: baseline-browser-mapping@2.10.13: {} + bluebird@3.7.2: {} + boolbase@1.0.0: {} browserslist@4.28.2: @@ -1880,6 +1942,8 @@ snapshots: convert-source-map@2.0.0: {} + core-util-is@1.0.3: {} + css-declaration-sorter@7.3.1(postcss@8.5.8): dependencies: postcss: 8.5.8 @@ -1982,6 +2046,10 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + electron-to-chromium@1.5.331: {} entities@4.5.0: {} @@ -2080,6 +2148,12 @@ snapshots: fraction.js@5.3.4: {} + fs-extra@11.3.4: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fsevents@2.3.3: optional: true @@ -2087,12 +2161,16 @@ snapshots: get-east-asian-width@1.5.0: {} + graceful-fs@4.2.11: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 hookable@5.5.3: {} + inherits@2.0.4: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -2107,6 +2185,8 @@ snapshots: is-unicode-supported@2.1.0: {} + isarray@1.0.0: {} + jiti@1.21.7: {} jiti@2.6.1: {} @@ -2116,6 +2196,12 @@ snapshots: js-tokens@9.0.1: {} + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + knitwork@1.3.0: {} lilconfig@3.1.3: {} @@ -2170,6 +2256,8 @@ snapshots: nanoid@3.3.11: {} + node-int64@0.4.0: {} + node-releases@2.0.37: {} nth-check@2.1.1: @@ -2382,6 +2470,18 @@ snapshots: pretty-bytes@7.1.0: {} + process-nextick-args@2.0.1: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -2435,6 +2535,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 + safe-buffer@5.1.2: {} + sax@1.6.0: {} scule@1.3.0: {} @@ -2460,6 +2562,10 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -2543,6 +2649,8 @@ snapshots: undici@7.24.7: {} + universalify@2.0.1: {} + untyped@2.0.0: dependencies: citty: 0.1.6 @@ -2551,6 +2659,14 @@ snapshots: knitwork: 1.3.0 scule: 1.3.0 + unzipper@0.12.3: + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.4 + graceful-fs: 4.2.11 + node-int64: 0.4.0 + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts index 3b8d38ad3..8e963e6a5 100644 --- a/skillhub-cli/src/commands/download.ts +++ b/skillhub-cli/src/commands/download.ts @@ -46,7 +46,7 @@ export function registerDownload(program: Command) { spinner.fail(`Redirect response has no Location header`); process.exit(1); } - response = await request(location, { method: "GET" }); + response = await request(location as string, { method: "GET" }); } const { statusCode, body } = response; diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 7d1415f82..840ecf572 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -2,7 +2,7 @@ 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 { createWriteStream, createReadStream, 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"; @@ -12,6 +12,7 @@ import { getAllAgents, detectInstalledAgents, getUniversalAgents, getNonUniversa 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 unzipper from "unzipper"; import { multiSelect, sectionMultiSelect } from "../utils/prompts.js"; import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; import { runInteractiveSearch, searchSkills } from "../core/interactive-search.js"; @@ -296,7 +297,7 @@ async function installFromRegistry(slug: string, opts: Record Date: Thu, 16 Apr 2026 14:46:55 +0800 Subject: [PATCH 17/68] fix(cli): fix addToLock namespace doubling, update fallback path, and download stream completion - install: use actualSlug instead of raw slug in addToLock to prevent namespace doubling (vision2group/vision2group/slug) - update: remove hardcoded 'node dist/cli.mjs' fallback, rely on process.argv[1] which is always reliable - download: use finished() to ensure stream write completes before proceeding --- skillhub-cli/src/commands/download.ts | 3 ++- skillhub-cli/src/commands/install.ts | 4 ++-- skillhub-cli/src/commands/update.ts | 5 +---- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts index 8e963e6a5..8b6e27d7a 100644 --- a/skillhub-cli/src/commands/download.ts +++ b/skillhub-cli/src/commands/download.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { createWriteStream } from "node:fs"; import { resolve } from "node:path"; +import { finished } from "node:stream/promises"; import { ApiClient } from "../core/api-client.js"; import { ApiRoutes } from "../schema/routes.js"; import { loadConfig } from "../core/config.js"; @@ -57,7 +58,7 @@ export function registerDownload(program: Command) { const outPath = resolve(outputDir, `${skillSlug}.zip`); const fileStream = createWriteStream(outPath); - await body.pipe(fileStream); + await finished(body.pipe(fileStream)); spinner.succeed(`Downloaded ${skillSlug} to ${outPath}`); } catch (e: any) { diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 840ecf572..1217308e1 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -468,9 +468,9 @@ async function installFromRegistry(slug: string, opts: Record Date: Thu, 16 Apr 2026 15:47:51 +0800 Subject: [PATCH 18/68] chore: bump version to 1.0.6 --- skillhub-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index e6bbc3e2d..7965631aa 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.0.5", + "version": "1.0.6", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { From 5cf44c5a984229e00427bcc29d7e390e19355374 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:26:41 +0800 Subject: [PATCH 19/68] feat(cli): custom grouped help page with aliases and examples --- skillhub-cli/package.json | 2 +- skillhub-cli/src/cli.ts | 114 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index 7965631aa..96da3812c 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.0.6", + "version": "1.0.7", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index ca06afa03..4e4c736fc 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -8,6 +8,11 @@ import { dirname } from "node:path"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +// ANSI helpers (zero-dependency) +const bold = (s: string) => `\x1b[1m${s}\x1b[0m`; +const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; +const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`; + function getPackageVersion(): string { try { const pkgPath = resolve(__dirname, "../package.json"); @@ -18,6 +23,108 @@ function getPackageVersion(): string { } } +interface HelpEntry { + cmd: string; + desc: string; + alias?: string; +} + +function formatSection(header: string, entries: HelpEntry[]): string { + const displayWidth = (e: HelpEntry) => e.cmd.length + (e.alias ? e.alias.length + 3 : 0); + const maxW = entries.reduce((max, e) => Math.max(max, displayWidth(e)), 0); + const col = Math.max(maxW + 4, 28); + const lines = [bold(header)]; + for (const e of entries) { + const aliasPart = e.alias ? dim(` (${e.alias})`) : ""; + const pad = " ".repeat(Math.max(col - displayWidth(e), 2)); + lines.push(` ${cyan(e.cmd)}${aliasPart}${pad}${e.desc}`); + } + return lines.join("\n"); +} + +function buildTopLevelHelp(version: string): string { + const sections: string[] = []; + + sections.push(`${bold("skillhub")} ${dim(`v${version}`)}`); + sections.push(dim("CLI for SkillHub — publish, search, and manage agent skills")); + sections.push(""); + + sections.push(formatSection("Auth", [ + { cmd: "login", desc: "Authenticate with SkillHub registry" }, + { cmd: "logout", desc: "Remove stored authentication token" }, + { cmd: "whoami", desc: "Show current authenticated user" }, + ])); + sections.push(""); + + sections.push(formatSection("Discover", [ + { cmd: "explore", desc: "Browse or search skills from the registry", alias: "find, find-skills, search" }, + { cmd: "search ", desc: dim("[Deprecated: use 'explore' instead]") }, + ])); + sections.push(""); + + sections.push(formatSection("Install & Manage", [ + { cmd: "install ", desc: "Install from registry, git, or local path", alias: "i" }, + { cmd: "download ", desc: "Download a skill package to local directory" }, + { cmd: "update [slug]", desc: "Update installed skills from their source", alias: "up" }, + { cmd: "uninstall [name]", desc: "Uninstall a skill from local agent", alias: "un" }, + { cmd: "list", desc: "List installed skills", alias: "ls" }, + { cmd: "check", desc: "Check installed skills against lock file" }, + ])); + sections.push(""); + + sections.push(formatSection("Publish", [ + { cmd: "init [name]", desc: "Create a new SKILL.md template" }, + { cmd: "publish [path]", desc: "Publish a skill to SkillHub registry" }, + { cmd: "sync [path]", desc: "Scan and publish all skills from a directory" }, + { cmd: "delete ", desc: "Delete a skill you own", alias: "del, unpublish" }, + { cmd: "archive ", desc: "Archive a skill you own" }, + { cmd: "versions ", desc: "List skill versions" }, + ])); + sections.push(""); + + sections.push(formatSection("Info & Review", [ + { cmd: "inspect ", desc: "View skill metadata without installing", alias: "info, view" }, + { cmd: "resolve ", desc: "Resolve the latest version of a skill" }, + { cmd: "rating ", desc: "View your rating for a skill" }, + { cmd: "rate ", desc: "Rate a skill (1-5)" }, + { cmd: "star ", desc: "Star a skill" }, + { cmd: "report ", desc: "Report a skill for review" }, + ])); + sections.push(""); + + sections.push(formatSection("Account", [ + { cmd: "me skills", desc: "List your published skills", alias: "me ls" }, + { cmd: "me stars", desc: "List your starred skills" }, + { cmd: "namespaces", desc: "List namespaces you have access to" }, + { cmd: "notifications", desc: "Manage notifications", alias: "notif" }, + { cmd: "reviews my", desc: "List your review submissions", alias: "reviews submissions" }, + ])); + sections.push(""); + + sections.push(formatSection("Admin", [ + { cmd: "hide ", desc: "Hide a skill (admin only)" }, + { cmd: "unhide ", desc: "Unhide a skill (admin only)" }, + { cmd: "transfer ", desc: "Transfer namespace ownership" }, + ])); + sections.push(""); + + sections.push(bold("Examples")); + sections.push(dim(" skillhub install vision2group/fork-workflow Install a skill")); + sections.push(dim(" skillhub explore Browse available skills")); + sections.push(dim(" skillhub publish Publish current directory")); + sections.push(dim(" skillhub me skills List your published skills")); + sections.push(dim(" skillhub update --global Update all global skills")); + sections.push(""); + + sections.push(bold("Global Options")); + sections.push(` ${cyan("--registry ")} Registry API base URL`); + sections.push(` ${cyan("--json")} Output results as JSON`); + sections.push(` ${cyan("--help")} Show help for a command`); + sections.push(` ${cyan("--version")} Show version number`); + + return sections.join("\n"); +} + export async function createCli(): Promise { const program = new Command(); const version = getPackageVersion(); @@ -29,6 +136,13 @@ export async function createCli(): Promise { .option("--registry ", "Registry API base URL", "http://localhost:8080") .option("--json", "Output results as JSON"); + const customHelp = buildTopLevelHelp(version); + const originalHelpInformation = program.helpInformation.bind(program); + program.helpInformation = () => { + if (program.parent) return originalHelpInformation(); + return customHelp; + }; + const [ { registerLogin }, { registerLogout }, From e93adaaaaa1e16b31698b006cb01d50bcbe7c4c8 Mon Sep 17 00:00:00 2001 From: eldenring_saver <49091147+Rsweater@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:45:02 +0000 Subject: [PATCH 20/68] feat(web): replace clawhub with skillhub CLI in frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace all `npx clawhub` references with `npx motovis-skillhub` / `skillhub` - Update namespace separator from `--` to `/` in install commands - Change env var CLAWHUB_REGISTRY to SKILLHUB_REGISTRY - Add vite dev server plugin to serve /registry/skill.md - Update i18n strings (zh/en): ClawHub CLI → SkillHub CLI - Update skill.md/skill.md.template with new CLI commands - Update quick-start.tsx syntax highlighting and env config - Update e2e test assertions Co-Authored-By: Claude Opus 4.6 --- web/src/docs/skill.md | 46 ++++---- web/src/docs/skill.md.template | 46 ++++---- .../features/skill/install-command.test.ts | 6 +- web/src/features/skill/install-command.tsx | 4 +- web/src/i18n/locales/en.json | 14 +-- web/src/i18n/locales/zh.json | 14 +-- web/src/shared/components/quick-start.tsx | 16 ++- web/vite.config.ts | 101 ++++++++++++++---- 8 files changed, 150 insertions(+), 97 deletions(-) diff --git a/web/src/docs/skill.md b/web/src/docs/skill.md index 18ea1e88c..e692b7d22 100644 --- a/web/src/docs/skill.md +++ b/web/src/docs/skill.md @@ -1,13 +1,13 @@ --- name: skillhub-registry -description: Use this when you need to search, inspect, install, or publish agent skills against a SkillHub registry. SkillHub is a skill registry with a ClawHub-compatible API layer, so prefer the `clawhub` CLI for registry operations instead of making raw HTTP calls. +description: Use this when you need to search, inspect, install, or publish agent skills against a SkillHub registry. SkillHub is a skill registry with a ClawHub-compatible API layer, so prefer the `skillhub` CLI for registry operations instead of making raw HTTP calls. --- # SkillHub Registry Use this skill when you need to work with a SkillHub registry: search skills, inspect metadata, install a package, or publish a new version. -> Important: Prefer the `clawhub` CLI for registry workflows. SkillHub exposes a ClawHub-compatible API surface and a discovery endpoint at `/.well-known/clawhub.json`, so the CLI is the safest path for auth, resolution, and download behavior. Only fall back to raw HTTP when debugging the server itself. +> Important: Prefer the `skillhub` CLI for registry workflows. SkillHub exposes a ClawHub-compatible API surface and a discovery endpoint at `/.well-known/clawhub.json`, so the CLI is the safest path for auth, resolution, and download behavior. Only fall back to raw HTTP when debugging the server itself. ## What SkillHub Is @@ -16,8 +16,8 @@ SkillHub is an enterprise-oriented skill registry. It stores versioned skill pac Key facts: - Internal coordinates use `@{namespace}/{skill_slug}`. -- If using the clawhub CLI, the compatible format is `{namespace}--{skill_slug}`. -- ClawHub-compatible clients use a `{namespace}--{skill_slug}` slug instead. +- If using the skillhub CLI, the compatible format is `{namespace}--{skill_slug}`. +- ClawHub-compatible clients use a `{namespace}--{skill_slug}` slug format. - `latest` always means the latest published version, never draft or pending review. - Public skills in `@global` can be downloaded anonymously. - If no namespace is specified, it defaults to `@global`. @@ -26,23 +26,23 @@ Key facts: ## Configure The CLI -Point `clawhub` at the SkillHub base URL: +Point `skillhub` at the SkillHub base URL: ```bash -export CLAWHUB_REGISTRY=https://skillhub.your-company.com +export SKILLHUB_REGISTRY=https://skillhub.your-company.com ``` Alternatively, use the `--registry` parameter every time, for example: ```bash -npx clawhub install my-skill --registry https://skillhub.your-company.com +npx motovis-skillhub install my-skill --registry https://skillhub.your-company.com ``` If you need authenticated access, provide an API token: ```bash -clawhub login --token sk_your_api_token_here +skillhub login --token sk_your_api_token_here ``` Optional local check: @@ -61,23 +61,23 @@ Expected response: SkillHub has two naming forms: -| SkillHub coordinate | Canonical slug for `clawhub` | +| SkillHub coordinate | CLI format | |---|---| | `@global/my-skill` | `my-skill` | -| `@team-name/my-skill` | `team-name--my-skill` | +| `@team-name/my-skill` | `team-name/my-skill` | Rules: -- `--` is the namespace separator in the compatibility layer. -- If there is no `--`, the skill is treated as `@global/...`. +- `/` is the namespace separator in CLI commands. +- If no namespace is specified, the skill is treated as `@global/...`. - `latest` resolves to the latest published version only. Examples: ```bash -npx clawhub install my-skill -npx clawhub install my-skill@1.2.0 -npx clawhub install team-name--my-skill +npx motovis-skillhub install my-skill +npx motovis-skillhub install my-skill@1.2.0 +npx motovis-skillhub install team-name/my-skill ``` ## Common Workflows @@ -85,28 +85,28 @@ npx clawhub install team-name--my-skill ### Search ```bash -npx clawhub search email +npx motovis-skillhub search email ``` Use an empty query when you want a broad listing: ```bash -npx clawhub search "" +npx motovis-skillhub search "" ``` ### Inspect A Skill ```bash -npx clawhub info my-skill -npx clawhub info team-name--my-skill +npx motovis-skillhub info my-skill +npx motovis-skillhub info team-name/my-skill ``` ### Install ```bash -npx clawhub install my-skill -npx clawhub install my-skill@1.2.0 -npx clawhub install team-name--my-skill +npx motovis-skillhub install my-skill +npx motovis-skillhub install my-skill@1.2.0 +npx motovis-skillhub install team-name/my-skill ``` ### Publish @@ -114,7 +114,7 @@ npx clawhub install team-name--my-skill Prepare a skill package directory, then publish it: ```bash -npx clawhub publish ./my-skill +npx motovis-skillhub publish ./my-skill ``` Publishing requires authentication and sufficient permissions in the target namespace. diff --git a/web/src/docs/skill.md.template b/web/src/docs/skill.md.template index 1d13200f9..c636b0a8e 100644 --- a/web/src/docs/skill.md.template +++ b/web/src/docs/skill.md.template @@ -1,13 +1,13 @@ --- name: skillhub-registry -description: Use this when you need to search, inspect, install, or publish agent skills against a SkillHub registry. SkillHub is a skill registry with a ClawHub-compatible API layer, so prefer the `clawhub` CLI for registry operations instead of making raw HTTP calls. +description: Use this when you need to search, inspect, install, or publish agent skills against a SkillHub registry. SkillHub is a skill registry with a ClawHub-compatible API layer, so prefer the `skillhub` CLI for registry operations instead of making raw HTTP calls. --- # SkillHub Registry Use this skill when you need to work with a SkillHub registry: search skills, inspect metadata, install a package, or publish a new version. -> Important: Prefer the `clawhub` CLI for registry workflows. SkillHub exposes a ClawHub-compatible API surface and a discovery endpoint at `/.well-known/clawhub.json`, so the CLI is the safest path for auth, resolution, and download behavior. Only fall back to raw HTTP when debugging the server itself. +> Important: Prefer the `skillhub` CLI for registry workflows. SkillHub exposes a ClawHub-compatible API surface and a discovery endpoint at `/.well-known/clawhub.json`, so the CLI is the safest path for auth, resolution, and download behavior. Only fall back to raw HTTP when debugging the server itself. ## What SkillHub Is @@ -16,8 +16,8 @@ SkillHub is an enterprise-oriented skill registry. It stores versioned skill pac Key facts: - Internal coordinates use `@{namespace}/{skill_slug}`. -- If using the clawhub CLI, the compatible format is `{namespace}--{skill_slug}`. -- ClawHub-compatible clients use a `{namespace}--{skill_slug}` slug instead. +- If using the skillhub CLI, the compatible format is `{namespace}--{skill_slug}`. +- ClawHub-compatible clients use a `{namespace}--{skill_slug}` slug format. - `latest` always means the latest published version, never draft or pending review. - Public skills in `@global` can be downloaded anonymously. - If no namespace is specified, it defaults to `@global`. @@ -26,23 +26,23 @@ Key facts: ## Configure The CLI -Point `clawhub` at the SkillHub base URL: +Point `skillhub` at the SkillHub base URL: ```bash -export CLAWHUB_REGISTRY=${SKILLHUB_PUBLIC_BASE_URL} +export SKILLHUB_REGISTRY=${SKILLHUB_PUBLIC_BASE_URL} ``` Alternatively, use the `--registry` parameter every time, for example: ```bash -npx clawhub install my-skill --registry ${SKILLHUB_PUBLIC_BASE_URL} +npx motovis-skillhub install my-skill --registry ${SKILLHUB_PUBLIC_BASE_URL} ``` If you need authenticated access, provide an API token: ```bash -clawhub login --token sk_your_api_token_here +skillhub login --token sk_your_api_token_here ``` Optional local check: @@ -61,23 +61,23 @@ Expected response: SkillHub has two naming forms: -| SkillHub coordinate | Canonical slug for `clawhub` | +| SkillHub coordinate | CLI format | |---|---| | `@global/my-skill` | `my-skill` | -| `@team-name/my-skill` | `team-name--my-skill` | +| `@team-name/my-skill` | `team-name/my-skill` | Rules: -- `--` is the namespace separator in the compatibility layer. -- If there is no `--`, the skill is treated as `@global/...`. +- `/` is the namespace separator in CLI commands. +- If no namespace is specified, the skill is treated as `@global/...`. - `latest` resolves to the latest published version only. Examples: ```bash -npx clawhub install my-skill -npx clawhub install my-skill@1.2.0 -npx clawhub install team-name--my-skill +npx motovis-skillhub install my-skill +npx motovis-skillhub install my-skill@1.2.0 +npx motovis-skillhub install team-name/my-skill ``` ## Common Workflows @@ -85,28 +85,28 @@ npx clawhub install team-name--my-skill ### Search ```bash -npx clawhub search email +npx motovis-skillhub search email ``` Use an empty query when you want a broad listing: ```bash -npx clawhub search "" +npx motovis-skillhub search "" ``` ### Inspect A Skill ```bash -npx clawhub info my-skill -npx clawhub info team-name--my-skill +npx motovis-skillhub info my-skill +npx motovis-skillhub info team-name/my-skill ``` ### Install ```bash -npx clawhub install my-skill -npx clawhub install my-skill@1.2.0 -npx clawhub install team-name--my-skill +npx motovis-skillhub install my-skill +npx motovis-skillhub install my-skill@1.2.0 +npx motovis-skillhub install team-name/my-skill ``` ### Publish @@ -114,7 +114,7 @@ npx clawhub install team-name--my-skill Prepare a skill package directory, then publish it: ```bash -npx clawhub publish ./my-skill +npx motovis-skillhub publish ./my-skill ``` Publishing requires authentication and sufficient permissions in the target namespace. diff --git a/web/src/features/skill/install-command.test.ts b/web/src/features/skill/install-command.test.ts index 1f4daf504..cecd209c3 100644 --- a/web/src/features/skill/install-command.test.ts +++ b/web/src/features/skill/install-command.test.ts @@ -51,14 +51,14 @@ describe('install-command', () => { it('uses the plain slug for the global namespace', () => { expect(buildInstallTarget('global', 'my-skill')).toBe('my-skill') expect(buildInstallCommand('global', 'my-skill', 'https://skill.xfyun.cn')).toBe( - 'npx clawhub install my-skill --registry https://skill.xfyun.cn', + 'npx motovis-skillhub install my-skill --registry https://skill.xfyun.cn', ) }) it('prefixes non-global namespaces in the install target', () => { - expect(buildInstallTarget('team-alpha', 'my-skill')).toBe('team-alpha--my-skill') + expect(buildInstallTarget('team-alpha', 'my-skill')).toBe('team-alpha/my-skill') expect(buildInstallCommand('team-alpha', 'my-skill', 'https://skill.xfyun.cn')).toBe( - 'npx clawhub install team-alpha--my-skill --registry https://skill.xfyun.cn', + 'npx motovis-skillhub install team-alpha/my-skill --registry https://skill.xfyun.cn', ) }) diff --git a/web/src/features/skill/install-command.tsx b/web/src/features/skill/install-command.tsx index f9989abb3..c1330ed9e 100644 --- a/web/src/features/skill/install-command.tsx +++ b/web/src/features/skill/install-command.tsx @@ -11,7 +11,7 @@ interface InstallCommandProps { } export function buildInstallTarget(namespace: string, slug: string): string { - return namespace === 'global' ? slug : `${namespace}--${slug}` + return namespace === 'global' ? slug : `${namespace}/${slug}` } export function getBaseUrl(): string { @@ -30,7 +30,7 @@ export function getBaseUrl(): string { export function buildInstallCommand(namespace: string, slug: string, baseUrl: string): string { const installTarget = buildInstallTarget(namespace, slug) - return `npx clawhub install ${installTarget} --registry ${baseUrl}` + return `npx motovis-skillhub install ${installTarget} --registry ${baseUrl}` } export function InstallCommand({ namespace, slug }: InstallCommandProps) { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 501e5781c..6f095b4f6 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -112,22 +112,22 @@ }, "human": { "description": "Use the CLI tool to install Skills", - "command": "npx clawhub search " + "command": "npx motovis-skillhub search " }, "steps": { "configureEnv": { "title": "1. Configure Environment Variables", - "description": "Configure ClawHub CLI to connect to SkillHub" + "description": "Configure SkillHub CLI to connect to Registry" }, "installSkills": { "title": "2. Install Skills", "description": "Search and install the skills you need", - "code": "# Search skills\nclawhub search \n\n# Install skill\nclawhub install " + "code": "# Search skills\nskillhub search \n\n# Install skill\nskillhub install " }, "publishSkills": { "title": "3. Publish Skills", "description": "Share your skills with the team", - "code": "# Publish skill\nclawhub publish\n\n# Or use web interface\n# Click \"Publish Skill\"" + "code": "# Publish skill\nskillhub publish\n\n# Or use web interface\n# Click \"Publish Skill\"" } } } @@ -150,17 +150,17 @@ "steps": { "configureEnv": { "title": "1. Configure Environment Variables", - "description": "Configure ClawHub CLI to connect to SkillHub" + "description": "Configure SkillHub CLI to connect to Registry" }, "installSkills": { "title": "2. Install Skills", "description": "Search and install the skills you need", - "code": "# Search skills\nclawhub search \n\n# Install skill\nclawhub install " + "code": "# Search skills\nskillhub search \n\n# Install skill\nskillhub install " }, "publishSkills": { "title": "3. Publish Skills", "description": "Share your skills with the team", - "code": "# Publish skill\nclawhub publish\n\n# Or use web interface\n# Click \"Publish Skill\"" + "code": "# Publish skill\nskillhub publish\n\n# Or use web interface\n# Click \"Publish Skill\"" } } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ebdc169b0..14fd66fca 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -112,22 +112,22 @@ }, "human": { "description": "使用CLI工具安装Skills", - "command": "npx clawhub search " + "command": "npx motovis-skillhub search " }, "steps": { "configureEnv": { "title": "1. 配置环境变量", - "description": "设置 ClawHub CLI 连接到 SkillHub" + "description": "设置 SkillHub CLI 连接到 Registry" }, "installSkills": { "title": "2. 安装技能", "description": "搜索并安装你需要的技能", - "code": "# 搜索技能\nclawhub search \n\n# 安装技能\nclawhub install " + "code": "# 搜索技能\nskillhub search \n\n# 安装技能\nskillhub install " }, "publishSkills": { "title": "3. 发布技能", "description": "分享你的技能给团队使用", - "code": "# 发布技能\nclawhub publish\n\n# 或使用网页界面\n# 点击\"发布技能\"" + "code": "# 发布技能\nskillhub publish\n\n# 或使用网页界面\n# 点击\"发布技能\"" } } } @@ -150,17 +150,17 @@ "steps": { "configureEnv": { "title": "1. 配置环境变量", - "description": "设置 ClawHub CLI 连接到 SkillHub" + "description": "设置 SkillHub CLI 连接到 Registry" }, "installSkills": { "title": "2. 安装技能", "description": "搜索并安装你需要的技能", - "code": "# 搜索技能\nclawhub search \n\n# 安装技能\nclawhub install " + "code": "# 搜索技能\nskillhub search \n\n# 安装技能\nskillhub install " }, "publishSkills": { "title": "3. 发布技能", "description": "分享你的技能给团队使用", - "code": "# 发布技能\nclawhub publish\n\n# 或使用网页界面\n# 点击\"发布技能\"" + "code": "# 发布技能\nskillhub publish\n\n# 或使用网页界面\n# 点击\"发布技能\"" } } } diff --git a/web/src/shared/components/quick-start.tsx b/web/src/shared/components/quick-start.tsx index daedcb05e..2a0cd18c1 100644 --- a/web/src/shared/components/quick-start.tsx +++ b/web/src/shared/components/quick-start.tsx @@ -64,11 +64,11 @@ function CodeLine({ line }: { line: string }) { ) } - if (line.startsWith('clawhub')) { + if (line.startsWith('skillhub')) { return ( <> - clawhub - {line.slice(7)} + skillhub + {line.slice(8)} ) } @@ -132,19 +132,17 @@ export function QuickStartSection({ variant = 'page', ns = 'landing' }: QuickSta const baseUrl = useMemo(() => getAppBaseUrl(), []) const envCode = `# Linux/macOS -export CLAWHUB_SITE=${baseUrl} -export CLAWHUB_REGISTRY=${baseUrl} +export SKILLHUB_REGISTRY=${baseUrl} # Windows PowerShell -$env:CLAWHUB_SITE = '${baseUrl}' -$env:CLAWHUB_REGISTRY = '${baseUrl}'` +$env:SKILLHUB_REGISTRY = '${baseUrl}'` const installCode = t(`${ns}.quickStart.steps.installSkills.code`, { - defaultValue: '# 搜索技能\nclawhub search \n\n# 安装技能\nclawhub install ', + defaultValue: '# 搜索技能\nskillhub search \n\n# 安装技能\nskillhub install ', }) const publishCode = t(`${ns}.quickStart.steps.publishSkills.code`, { - defaultValue: '# 发布技能\nclawhub publish\n\n# 或使用网页界面\n# 点击"发布技能"', + defaultValue: '# 发布技能\nskillhub publish\n\n# 或使用网页界面\n# 点击"发布技能"', }) const steps: CodeBlockProps[] = [ diff --git a/web/vite.config.ts b/web/vite.config.ts index aff7ad67e..27d6bce56 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,32 +1,87 @@ -import { defineConfig } from 'vite' +import fs from 'fs' +import { defineConfig, loadEnv, type ProxyOptions } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' -export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), +function forwardOriginalHostToBackend(): NonNullable { + return (proxy) => { + proxy.on('proxyReq', (proxyReq, req) => { + const host = req.headers.host + if (host) { + proxyReq.setHeader('X-Forwarded-Host', host) + const proto = req.headers['x-forwarded-proto'] + proxyReq.setHeader( + 'X-Forwarded-Proto', + typeof proto === 'string' ? proto : 'http', + ) + } + }) + } +} + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + // Override with VITE_BACKEND_URL in .env.local + const backend = env.VITE_BACKEND_URL || 'http://localhost:8080' + + return { + plugins: [ + react(), + { + name: 'serve-skill-md', + configureServer(server) { + server.middlewares.use('/registry/skill.md', (_req, res) => { + const template = fs.readFileSync( + path.resolve(__dirname, 'src/docs/skill.md.template'), + 'utf-8', + ) + const origin = `http://localhost:${server.config.server.port || 8181}` + res.setHeader('Content-Type', 'text/plain; charset=utf-8') + res.end( + template.replace(/\$\{SKILLHUB_PUBLIC_BASE_URL\}/g, origin), + ) + }) + }, + }, + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, }, - }, - test: { - exclude: ['**/node_modules/**', '**/e2e/**'], - }, - server: { - port: 3000, - watch: { - usePolling: true, - interval: 150, + test: { + exclude: ['**/node_modules/**', '**/e2e/**'], }, - proxy: { - '/api': { - target: 'http://localhost:8080', - changeOrigin: true, + server: { + // Listen on 0.0.0.0 so LAN / other machines can reach dev (still need firewall rules for true public IP). + host: true, + port: 8181, + watch: { + usePolling: true, + interval: 150, }, - '/oauth2': { - target: 'http://localhost:8080', - changeOrigin: true, + proxy: { + '/api': { + target: backend, + changeOrigin: true, + configure: forwardOriginalHostToBackend(), + }, + '/oauth2': { + target: backend, + changeOrigin: true, + configure: forwardOriginalHostToBackend(), + }, + '/login/oauth2': { + target: backend, + changeOrigin: true, + configure: forwardOriginalHostToBackend(), + }, + '/actuator': { + target: backend, + changeOrigin: true, + configure: forwardOriginalHostToBackend(), + }, }, }, - }, + } }) From 90809c47ab62c17ffe133be0c6cb8afd3e60e4b8 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:49:23 +0800 Subject: [PATCH 21/68] fix(cli): stop spinner before version selection to prevent UI conflict The ora spinner was still running during p.select() interactive prompt, causing the spinner to continuously overwrite the terminal and block the version selection UI. The user would see "Fetching..." stuck until pressing Enter. Stop spinner before p.select() and restart after selection completes. --- skillhub-cli/src/commands/install.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 1217308e1..c8e27706b 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -260,6 +260,8 @@ async function installFromRegistry(slug: string, opts: Record ({ @@ -275,6 +277,7 @@ async function installFromRegistry(slug: string, opts: Record Date: Mon, 20 Apr 2026 09:37:59 +0800 Subject: [PATCH 22/68] fix(cli): improve install spinner messages and fix interactive search conflict - Stop spinner before runInteractiveSearch to prevent terminal UI conflict (same issue as version selection p.select) - Make spinner messages more specific: include skill name, version, path instead of generic "Fetching"/"Downloading"/"Extracting" --- skillhub-cli/src/commands/install.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index c8e27706b..8ca71a063 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -47,7 +47,7 @@ function detectSourceType(arg: string): SourceType { function getInstallSpinner(sourceType: SourceType, arg: string): string { if (sourceType === "registry") { - return `Fetching ${arg}`; + return `Searching registry for ${arg}`; } return `Resolving ${arg}`; } @@ -209,20 +209,20 @@ async function installFromRegistry(slug: string, opts: Record(`/api/v1/skills/${ns}/${actualSlug}/versions`), client.get(`/api/v1/skills/${ns}/${actualSlug}/tags`).catch(() => [] as SkillTag[]), @@ -277,7 +277,7 @@ async function installFromRegistry(slug: string, opts: Record Date: Mon, 20 Apr 2026 10:42:12 +0800 Subject: [PATCH 23/68] chore(cli): update .npmignore and add release scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove "还未发布" from .npmignore, add src/ to excludes - Add scripts/notify-feishu.sh for Feishu bot release notifications - Add scripts/release-cli.sh for one-click CLI release workflow --- scripts/notify-feishu.sh | 91 ++++++++++++++++++++++++++++++++++++++++ scripts/release-cli.sh | 60 ++++++++++++++++++++++++++ skillhub-cli/.npmignore | 2 +- 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100755 scripts/notify-feishu.sh create mode 100755 scripts/release-cli.sh diff --git a/scripts/notify-feishu.sh b/scripts/notify-feishu.sh new file mode 100755 index 000000000..384087da8 --- /dev/null +++ b/scripts/notify-feishu.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Send skillhub-cli release notification to Feishu group via bot webhook +# Usage: FEISHU_WEBHOOK_URL=xxx ./scripts/notify-feishu.sh [prev_version] +set -euo pipefail + +WEBHOOK_URL="${FEISHU_WEBHOOK_URL:-}" +VERSION="${1:-}" +PREV_VERSION="${2:-}" + +if [ -z "$WEBHOOK_URL" ]; then + echo "Error: FEISHU_WEBHOOK_URL is not set" + echo "Usage: FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxx $0 [prev_version]" + exit 1 +fi + +if [ -z "$VERSION" ]; then + echo "Error: version is required" + echo "Usage: $0 [prev_version]" + exit 1 +fi + +# Generate changelog from git log +if [ -n "$PREV_VERSION" ]; then + RANGE="skillhub-cli/v${PREV_VERSION}..HEAD" +else + # If no prev version, use last 20 commits touching skillhub-cli/ + RANGE="HEAD~20..HEAD" +fi + +# Extract changelog lines, escape for JSON +CHANGELOG=$(git log "$RANGE" --oneline --no-merges -- skillhub-cli/ 2>/dev/null | head -20 | \ + sed 's/\\/\\\\/g; s/"/\\"/g; s/$/\\n/' | tr -d '\n' | sed 's/\\n$//') + +if [ -z "$CHANGELOG" ]; then + CHANGELOG="详见 npm 页面" +fi + +BODY=$(cat < 1.0.8 +# ./scripts/release-cli.sh minor # 1.0.7 -> 1.1.0 +# ./scripts/release-cli.sh patch 1.0.6 # with prev_version for changelog +set -euo pipefail + +BUMP="${1:-patch}" +PREV_VERSION="${2:-}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +CLI_DIR="$PROJECT_DIR/skillhub-cli" + +echo "=== skillhub-cli release ===" +echo "" + +# Step 1: Build +echo "[1/5] Building..." +cd "$CLI_DIR" +pnpm build +echo "Build OK" +echo "" + +# Step 2: Run tests +echo "[2/5] Running tests..." +if pnpm test; then + echo "Tests OK" +else + echo "Tests FAILED. Aborting release." + exit 1 +fi +echo "" + +# Step 3: Bump version +OLD_VERSION=$(node -p "require('./package.json').version") +npm version "$BUMP" --no-git-tag-version -m "chore(cli): release v%s" +NEW_VERSION=$(node -p "require('./package.json').version") +echo "[3/5] Version bumped: $OLD_VERSION -> $NEW_VERSION" +echo "" + +# Step 4: Publish +echo "[4/5] Publishing to npm..." +npm publish --access public +echo "Published motovis-skillhub@$NEW_VERSION" +echo "" + +# Step 5: Notify via Feishu +echo "[5/5] Sending Feishu notification..." +if [ -n "${FEISHU_WEBHOOK_URL:-}" ]; then + "$SCRIPT_DIR/notify-feishu.sh" "$NEW_VERSION" "$PREV_VERSION" +else + echo "FEISHU_WEBHOOK_URL not set, skipping notification" + echo "To send manually: FEISHU_WEBHOOK_URL=xxx $SCRIPT_DIR/notify-feishu.sh $NEW_VERSION $PREV_VERSION" +fi +echo "" + +echo "=== Release complete: v$NEW_VERSION ===" diff --git a/skillhub-cli/.npmignore b/skillhub-cli/.npmignore index 0217fc47e..c59f40937 100644 --- a/skillhub-cli/.npmignore +++ b/skillhub-cli/.npmignore @@ -1,7 +1,7 @@ tmp/ -还未发布 tests/ vitest.config.ts unbuild.config.ts tsconfig.json *.test.ts +src/ From 32395ee5c200a46506bba2d663e72ee68afffc38 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:51:22 +0800 Subject: [PATCH 24/68] refactor(cli): use dynamic isUniversalForScope instead of static isUniversalAgent - Add isUniversalForScope(agent, isGlobal) and getAgentTargetDir(agent, isGlobal) to agent-detector.ts - An agent is 'universal' when its target install dir equals the canonical .agents/skills directory - This fixes incorrect symlink skipping for agents like Codex/Cursor whose globalSkillsDir differs from .agents/skills (e.g. .codex/skills, .cursor/skills) - Update installer.ts to use dynamic check via optional AgentInfo parameter - Update install.ts to use getAgentTargetDir and pass agent info to installSkill - Update uninstall.ts to use isUniversalForScope for correct path resolution - Clean up unused isUniversalAgent import in list.ts --- skillhub-cli/src/commands/install.ts | 26 +++--- skillhub-cli/src/commands/list.ts | 2 +- skillhub-cli/src/commands/uninstall.ts | 6 +- skillhub-cli/src/core/agent-detector.ts | 117 ++++++++++++++++++------ skillhub-cli/src/core/installer.ts | 16 ++-- 5 files changed, 112 insertions(+), 55 deletions(-) diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 8ca71a063..a20d9ef60 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -8,7 +8,7 @@ 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 { getAllAgents, detectInstalledAgents, getUniversalAgents, getNonUniversalAgents, isUniversalForScope, getAgentTargetDir, type AgentInfo } 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"; @@ -107,10 +107,10 @@ async function selectInstallMode(): Promise<"symlink" | "copy" | null> { return result as "symlink" | "copy"; } -function buildAgentSummary(targetAgents: { key: string; name: string; skillsDir: string }[], mode: "symlink" | "copy"): string[] { +function buildAgentSummary(targetAgents: AgentInfo[], mode: "symlink" | "copy", isGlobal: boolean): string[] { const lines: string[] = []; - const universal = targetAgents.filter((a) => isUniversalAgent(a)); - const symlinked = targetAgents.filter((a) => !isUniversalAgent(a)); + const universal = targetAgents.filter((a) => isUniversalForScope(a, isGlobal)); + const symlinked = targetAgents.filter((a) => !isUniversalForScope(a, isGlobal)); if (mode === "symlink") { if (universal.length > 0) { @@ -400,9 +400,7 @@ async function installFromRegistry(slug: string, opts: Record - isGlobal ? (a.globalSkillsDir || a.skillsDir) : a.skillsDir - )); + const uniqueDirs = new Set(targetAgents.map((a) => getAgentTargetDir(a, isGlobal))); if (uniqueDirs.size <= 1) { // Single target directory — default to copy (no symlink needed) @@ -425,7 +423,7 @@ async function installFromRegistry(slug: string, opts: Record - isGlobal ? (a.globalSkillsDir || a.skillsDir) : a.skillsDir - )); + const uniqueDirs = new Set(targetAgents.map((a) => getAgentTargetDir(a, isGlobal))); if (uniqueDirs.size <= 1) { // Single target directory — default to copy (no symlink needed) @@ -684,7 +681,7 @@ async function installFromGit(skillName: string, source: string, sourceType: Sou ? `~/.agents/skills/${skill.name}` : `./.agents/skills/${skill.name}`; summaryLines.push(`${pc.cyan(canonicalPath)}`); - for (const line of buildAgentSummary(targetAgents, mode)) { + for (const line of buildAgentSummary(targetAgents, mode, isGlobal)) { summaryLines.push(` ${line}`); } } @@ -716,9 +713,10 @@ async function installFromGit(skillName: string, source: string, sourceType: Sou skill.dir, skill.name, agent.key, - isGlobal ? agent.globalSkillsDir || agent.skillsDir : agent.skillsDir, + getAgentTargetDir(agent, isGlobal), mode, isGlobal, + agent, ); results.push({ skill: skill.name, diff --git a/skillhub-cli/src/commands/list.ts b/skillhub-cli/src/commands/list.ts index 8b24796b4..7a4acabaa 100644 --- a/skillhub-cli/src/commands/list.ts +++ b/skillhub-cli/src/commands/list.ts @@ -2,7 +2,7 @@ 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 { getAllAgents, 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"; diff --git a/skillhub-cli/src/commands/uninstall.ts b/skillhub-cli/src/commands/uninstall.ts index a7249b6e8..0675c2c75 100644 --- a/skillhub-cli/src/commands/uninstall.ts +++ b/skillhub-cli/src/commands/uninstall.ts @@ -2,7 +2,7 @@ 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 { getAllAgents, isUniversalForScope, 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"; @@ -34,7 +34,7 @@ async function uninstallSkill( let baseDir: string; if (scope === "global") { - if (isUniversalAgent(agent)) { + if (isUniversalForScope(agent, true)) { baseDir = join(home, ".agents/skills"); } else { baseDir = join(home, agent.globalSkillsDir || agent.skillsDir); @@ -65,7 +65,7 @@ function getSkillPath(skillName: string, agent: AgentInfo, scope: "global" | "lo let baseDir: string; if (scope === "global") { - if (isUniversalAgent(agent)) { + if (isUniversalForScope(agent, true)) { baseDir = join(home, ".agents/skills"); } else { baseDir = join(home, agent.globalSkillsDir || agent.skillsDir); diff --git a/skillhub-cli/src/core/agent-detector.ts b/skillhub-cli/src/core/agent-detector.ts index 9ba153aaa..e3a67f5be 100644 --- a/skillhub-cli/src/core/agent-detector.ts +++ b/skillhub-cli/src/core/agent-detector.ts @@ -7,15 +7,19 @@ export interface AgentInfo { name: string; skillsDir: string; globalSkillsDir?: string; + /** Whether to show this agent in the Universal section of interactive prompts. + * Agents with skillsDir === ".agents/skills" are universal by default, + * but some (like "replit" for cloud environments) should be hidden. */ + showInUniversalList?: boolean; } const home = homedir(); const AGENTS: AgentInfo[] = [ - // Universal agents (.agents/skills) + // Universal agents (.agents/skills) — share the canonical .agents/skills directory { 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: "cline", name: "Cline", skillsDir: ".agents/skills", globalSkillsDir: ".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" }, @@ -23,40 +27,44 @@ const AGENTS: AgentInfo[] = [ { 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" }, + { key: "warp", name: "Warp", skillsDir: ".agents/skills", globalSkillsDir: ".agents/skills" }, + // Universal agents hidden from the interactive list + { key: "replit", name: "Replit", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills", showInUniversalList: false }, + { key: "universal", name: "Universal", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills", showInUniversalList: false }, - // Agent-specific path agents + // Agent-specific path agents (non-universal) { 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: "augment", name: "Augment", skillsDir: ".augment/skills", globalSkillsDir: ".augment/skills" }, + { key: "bob", name: "IBM Bob", skillsDir: ".bob/skills", globalSkillsDir: ".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: "codebuddy", name: "CodeBuddy", skillsDir: ".codebuddy/skills", globalSkillsDir: ".codebuddy/skills" }, + { key: "command-code", name: "Command Code", skillsDir: ".commandcode/skills", globalSkillsDir: ".commandcode/skills" }, + { key: "continue", name: "Continue", skillsDir: ".continue/skills", globalSkillsDir: ".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: "droid", name: "Droid", skillsDir: ".factory/skills", globalSkillsDir: ".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: "junie", name: "Junie", skillsDir: ".junie/skills", globalSkillsDir: ".junie/skills" }, + { key: "iflow-cli", name: "iFlow CLI", skillsDir: ".iflow/skills", globalSkillsDir: ".iflow/skills" }, + { key: "kilo", name: "Kilo Code", skillsDir: ".kilocode/skills", globalSkillsDir: ".kilocode/skills" }, + { key: "kiro-cli", name: "Kiro CLI", skillsDir: ".kiro/skills", globalSkillsDir: ".kiro/skills" }, + { key: "kode", name: "Kode", skillsDir: ".kode/skills", globalSkillsDir: ".kode/skills" }, + { key: "mcpjam", name: "MCPJam", skillsDir: ".mcpjam/skills", globalSkillsDir: ".mcpjam/skills" }, + { key: "mistral-vibe", name: "Mistral Vibe", skillsDir: ".vibe/skills", globalSkillsDir: ".vibe/skills" }, + { key: "mux", name: "Mux", skillsDir: ".mux/skills", globalSkillsDir: ".mux/skills" }, + { key: "openhands", name: "OpenHands", skillsDir: ".openhands/skills", globalSkillsDir: ".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: "qoder", name: "Qoder", skillsDir: ".qoder/skills", globalSkillsDir: ".qoder/skills" }, + { key: "qwen-code", name: "Qwen Code", skillsDir: ".qwen/skills", globalSkillsDir: ".qwen/skills" }, + { key: "roo", name: "Roo Code", skillsDir: ".roo/skills", globalSkillsDir: ".roo/skills" }, + { key: "trae", name: "Trae", skillsDir: ".trae/skills", globalSkillsDir: ".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" }, + { key: "zencoder", name: "Zencoder", skillsDir: ".zencoder/skills", globalSkillsDir: ".zencoder/skills" }, + { key: "neovate", name: "Neovate", skillsDir: ".neovate/skills", globalSkillsDir: ".neovate/skills" }, + { key: "pochi", name: "Pochi", skillsDir: ".pochi/skills", globalSkillsDir: ".pochi/skills" }, + { key: "adal", name: "AdaL", skillsDir: ".adal/skills", globalSkillsDir: ".adal/skills" }, ]; export function getAllAgents(): AgentInfo[] { @@ -74,16 +82,65 @@ export function getAgentByKey(key: string): AgentInfo | undefined { return AGENTS.find((a) => a.key === key); } -const UNIVERSAL_PATH = ".agents/skills"; +const CANONICAL_SKILLS_DIR = ".agents/skills"; +/** + * Check if an agent uses the canonical .agents/skills directory at the project level. + * Used for UI grouping (Universal section in interactive prompts). + */ export function isUniversalAgent(agent: AgentInfo): boolean { - return agent.skillsDir === UNIVERSAL_PATH; + return agent.skillsDir === CANONICAL_SKILLS_DIR; } +/** + * Get the target installation directory for an agent in the given scope. + * In global scope, uses globalSkillsDir if defined, otherwise falls back to skillsDir. + * In project scope, always uses skillsDir. + */ +export function getAgentTargetDir(agent: AgentInfo, isGlobal: boolean): string { + return isGlobal + ? (agent.globalSkillsDir || agent.skillsDir) + : agent.skillsDir; +} + +/** + * Dynamically determine if an agent is "universal" for the given scope. + * An agent is universal when its target installation directory equals the canonical + * .agents/skills directory — meaning no symlink is needed because the canonical + * location IS the agent's own directory. + * + * This differs from isUniversalAgent() which only checks project-level skillsDir. + * For example, Codex has skillsDir=".agents/skills" (universal at project level) + * but globalSkillsDir=".codex/skills" (NOT universal at global level — needs symlink). + */ +export function isUniversalForScope(agent: AgentInfo, isGlobal: boolean): boolean { + return getAgentTargetDir(agent, isGlobal) === CANONICAL_SKILLS_DIR; +} + +/** + * Returns universal agents that should appear in the interactive selection list. + * Excludes agents with showInUniversalList === false (e.g. replit, which is cloud-only). + */ export function getUniversalAgents(): AgentInfo[] { - return AGENTS.filter((a) => isUniversalAgent(a)); + return AGENTS.filter((a) => isUniversalAgent(a) && a.showInUniversalList !== false); } export function getNonUniversalAgents(): AgentInfo[] { return AGENTS.filter((a) => !isUniversalAgent(a)); -} \ No newline at end of file +} + +/** + * Ensure that all universal agents are included in the target agent list. + * This guarantees that skills are always installed to ~/.agents/skills (the canonical location), + * making them available to any agent that reads from that directory. + */ +export function ensureUniversalAgents(targetAgents: AgentInfo[]): AgentInfo[] { + const universalAgents = getUniversalAgents(); + const result = [...targetAgents]; + for (const ua of universalAgents) { + if (!result.some((a) => a.key === ua.key)) { + result.push(ua); + } + } + return result; +} diff --git a/skillhub-cli/src/core/installer.ts b/skillhub-cli/src/core/installer.ts index 890e9a50e..20941f78f 100644 --- a/skillhub-cli/src/core/installer.ts +++ b/skillhub-cli/src/core/installer.ts @@ -1,6 +1,7 @@ import { mkdirSync, symlinkSync, copyFileSync, readdirSync, lstatSync, unlinkSync, existsSync } from "node:fs"; import { join, dirname, relative } from "node:path"; import { homedir, platform } from "node:os"; +import { isUniversalForScope, type AgentInfo } from "./agent-detector.js"; export interface SkillInstallResult { skillName: string; @@ -11,15 +12,11 @@ export interface SkillInstallResult { error?: string; } -const UNIVERSAL_PATH = ".agents/skills"; - -function isUniversalAgent(skillsDir: string): boolean { - return skillsDir === UNIVERSAL_PATH; -} +const CANONICAL_SKILLS_DIR = ".agents/skills"; function getCanonicalBase(isGlobal: boolean, cwd: string): string { const home = homedir(); - return isGlobal ? join(home, UNIVERSAL_PATH) : join(cwd, UNIVERSAL_PATH); + return isGlobal ? join(home, CANONICAL_SKILLS_DIR) : join(cwd, CANONICAL_SKILLS_DIR); } function getAgentBaseDir(skillsDir: string, isGlobal: boolean, cwd: string): string { @@ -83,6 +80,7 @@ export function installSkill( targetDir: string, mode: "symlink" | "copy", isGlobal: boolean, + agent?: AgentInfo, ): SkillInstallResult { const cwd = process.cwd(); const canonicalBase = getCanonicalBase(isGlobal, cwd); @@ -90,7 +88,11 @@ export function installSkill( const agentBase = getAgentBaseDir(targetDir, isGlobal, cwd); const agentDir = join(agentBase, skillName); - const agentIsUniversal = isUniversalAgent(targetDir); + // Use dynamic scope-aware universal check if agent info is available, + // otherwise fall back to static targetDir check + const agentIsUniversal = agent + ? isUniversalForScope(agent, isGlobal) + : targetDir === CANONICAL_SKILLS_DIR; try { if (mode === "copy") { From 335395367550351ac980febbcbd0e2cffc9c0811 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:04:28 +0800 Subject: [PATCH 25/68] fix(cli): determine scope before agent selection for correct universal grouping - Move scope selection before agent selection in both installFromRegistry and installFromGit flows - Use isUniversalForScope in selectAgentsInteractive so that the Universal locked section correctly reflects the actual scope: - Project: agents with skillsDir='.agents/skills' (Cline, Codex, Cursor, etc.) - Global: agents with globalSkillsDir='.agents/skills' (Cline, Warp only) - Clean up unused getUniversalAgents/getNonUniversalAgents imports --- skillhub-cli/src/commands/install.ts | 112 +++++++++++++++------------ 1 file changed, 61 insertions(+), 51 deletions(-) diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index a20d9ef60..7d00fff03 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -8,7 +8,7 @@ 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, isUniversalForScope, getAgentTargetDir, type AgentInfo } from "../core/agent-detector.js"; +import { getAllAgents, detectInstalledAgents, isUniversalForScope, getAgentTargetDir, type AgentInfo } 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"; @@ -53,11 +53,16 @@ function getInstallSpinner(sourceType: SourceType, arg: string): string { } async function selectAgentsInteractive(isGlobal: boolean): Promise { - const universalAgents = getUniversalAgents(); - const nonUniversalAgents = getNonUniversalAgents(); + const allAgents = getAllAgents(); + // Use scope-aware universal check: an agent is "universal" when its target + // install dir equals the canonical .agents/skills directory for the given scope. + const universalAgents = allAgents.filter((a) => isUniversalForScope(a, isGlobal)); + const nonUniversalAgents = allAgents.filter((a) => !isUniversalForScope(a, isGlobal)); + + const canonicalLabel = isGlobal ? "Universal (~/.agents/skills)" : "Universal (.agents/skills)"; const lockedSection = { - title: "Universal (.agents/skills)", + title: canonicalLabel, items: universalAgents.map((a) => ({ value: a.key, label: a.name, @@ -351,27 +356,10 @@ async function installFromRegistry(slug: string, opts: Record (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); + // Determine scope first, so that agent selection can use the correct + // universal/non-universal grouping based on the actual scope. + const allAgents = getAllAgents(); + const supportsGlobal = allAgents.some((a) => a.globalSkillsDir); if (opts.global === undefined && !opts.yes && supportsGlobal) { const scope = await p.select({ @@ -398,6 +386,26 @@ async function installFromRegistry(slug: string, opts: Record (opts.agent as string[]).includes(a.key)) + : detectInstalledAgents(); + + if (targetAgents.length === 0) { + const claude = allAgents.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 = allAgents.filter((a) => selected.includes(a.key)); + } + // 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) => getAgentTargetDir(a, isGlobal))); @@ -604,32 +612,10 @@ async function installFromGit(skillName: string, source: string, sourceType: Sou 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); + // Determine scope first, so that agent selection can use the correct + // universal/non-universal grouping based on the actual scope. + const allAgents = getAllAgents(); + const supportsGlobal = allAgents.some((a) => a.globalSkillsDir); if (opts.global === undefined && !opts.yes && supportsGlobal) { const scope = await p.select({ @@ -656,6 +642,30 @@ async function installFromGit(skillName: string, source: string, sourceType: Sou isGlobal = scope as boolean; } + let targetAgents = opts.agent + ? allAgents.filter((a) => (opts.agent as string[]).includes(a.key)) + : detectInstalledAgents(); + + if (targetAgents.length === 0) { + if (!opts.yes) { + info("No agents detected. Installing to Claude Code by default."); + } + const claude = allAgents.find((a) => a.key === "claude-code"); + if (claude) targetAgents.push(claude); + else targetAgents.push(allAgents[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 = allAgents.filter((a) => selected.includes(a.key)); + } + // 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) => getAgentTargetDir(a, isGlobal))); From aaf80dbfd838fe22d6f4452307b36f837bb869b1 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:36:09 +0800 Subject: [PATCH 26/68] fix(cli): sort agent lists alphabetically and fix spinner residue - Sort universal and non-universal agent lists by name in selectAgentsInteractive - Exclude agents with showInUniversalList===false from locked section - Replace spinner.stop() with spinner.succeed() before interactive prompts to prevent 'Fetching' text from appearing as the step title - Also fix spinner.stop -> spinner.succeed for 'Installation complete' --- skillhub-cli/src/commands/install.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 7d00fff03..a96615f6a 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -57,8 +57,13 @@ async function selectAgentsInteractive(isGlobal: boolean): Promise isUniversalForScope(a, isGlobal)); - const nonUniversalAgents = allAgents.filter((a) => !isUniversalForScope(a, isGlobal)); + // Exclude agents with showInUniversalList === false from the locked section. + const universalAgents = allAgents + .filter((a) => isUniversalForScope(a, isGlobal) && a.showInUniversalList !== false) + .sort((a, b) => a.name.localeCompare(b.name)); + const nonUniversalAgents = allAgents + .filter((a) => !isUniversalForScope(a, isGlobal)) + .sort((a, b) => a.name.localeCompare(b.name)); const canonicalLabel = isGlobal ? "Universal (~/.agents/skills)" : "Universal (.agents/skills)"; const lockedSection = { @@ -214,7 +219,7 @@ async function installFromRegistry(slug: string, opts: Record r.success); @@ -756,7 +761,7 @@ async function installFromGit(skillName: string, source: string, sourceType: Sou } } - spinner.stop("Installation complete"); + spinner.succeed("Installation complete"); console.log(""); const successful = results.filter((r) => r.success); From 86b8de1fbcf22e1e21548509add714365ec6cf91 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:55:54 +0800 Subject: [PATCH 27/68] feat(cli): merge install results by path for cleaner output Group agents that share the same install path into a single line. When more than 5 agents share a path, show first 5 names + count. Sort agent names alphabetically within each group. Extract buildInstallResultLines utility function to DRY up duplicate output logic in installFromRegistry and installFromGit. --- skillhub-cli/src/commands/install.ts | 80 +++++++++++++++++++++------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index a96615f6a..43bb113bf 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -52,6 +52,64 @@ function getInstallSpinner(sourceType: SourceType, arg: string): string { return `Resolving ${arg}`; } +interface InstallResult { + skill: string; + agent: string; + success: boolean; + path: string; + error?: string; +} + +/** + * Build grouped install result lines: agents sharing the same install path + * are merged into one line for cleaner output. + * + * Example: + * ✓ fork-workflow + * → Amp, Cline, Codex, Cursor, Deep Agents +7: .agents/skills/fork-workflow + * → Claude Code: .claude/skills/fork-workflow + */ +function buildInstallResultLines( + selectedSkills: { name: string }[], + results: InstallResult[], +): string[] { + const MAX_NAMES = 5; + const resultLines: string[] = []; + + for (const skill of selectedSkills) { + const skillResults = results.filter((r) => r.skill === skill.name && r.success); + if (skillResults.length === 0) continue; + + resultLines.push(`${pc.green("✓")} ${skill.name}`); + + // Group agents by install path + const pathGroups = new Map(); + for (const r of skillResults) { + const agents = pathGroups.get(r.path) || []; + agents.push(r.agent); + pathGroups.set(r.path, agents); + } + + // Sort groups: put the group with the most agents first + const sortedGroups = [...pathGroups.entries()].sort((a, b) => b[1].length - a[1].length); + + for (const [path, agents] of sortedGroups) { + const sorted = agents.sort((a, b) => a.localeCompare(b)); + let label: string; + if (sorted.length <= MAX_NAMES) { + label = sorted.join(", "); + } else { + const shown = sorted.slice(0, MAX_NAMES).join(", "); + const extra = sorted.length - MAX_NAMES; + label = `${shown} ${pc.dim(`+${extra}`)}`; + } + resultLines.push(` ${pc.dim("→")} ${label}: ${pc.dim(path)}`); + } + } + + return resultLines; +} + async function selectAgentsInteractive(isGlobal: boolean): Promise { const allAgents = getAllAgents(); @@ -503,16 +561,7 @@ async function installFromRegistry(slug: string, opts: Record 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}`); - } - } - } + const resultLines = buildInstallResultLines(selectedSkills, results); p.note(resultLines.join("\n"), `Installed ${successful.length} skill(s)`); } @@ -767,16 +816,7 @@ async function installFromGit(skillName: string, source: string, sourceType: Sou 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}`); - } - } - } + const resultLines = buildInstallResultLines(selectedSkills, results); p.note(resultLines.join("\n"), `Installed ${successful.length} skill(s)`); } From b6301cb9a9531ffae586b6333498a8bee4681597 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:49:01 +0800 Subject: [PATCH 28/68] chore(cli): bump version to 1.1.0 --- skillhub-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index 96da3812c..09ee421f6 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.0.7", + "version": "1.1.0", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { From e5da7397fa57983c26c67c8a3c81130f4a36cb9f Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:46:00 +0800 Subject: [PATCH 29/68] refactor(cli): sort all list outputs and agent names alphabetically - update.ts: sort skill selection list alphabetically - check.ts: sort results by status then name, sort agent names in locations - install.ts: sort agent names in buildAgentSummary, sort --list output - sync.ts: sort discovered skills by name before display Ensures consistent alphabetical ordering across all CLI commands for better user experience and predictability. --- skillhub-cli/src/commands/check.ts | 11 +++++++++-- skillhub-cli/src/commands/install.ts | 14 +++++++++----- skillhub-cli/src/commands/sync.ts | 3 ++- skillhub-cli/src/commands/update.ts | 2 +- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/skillhub-cli/src/commands/check.ts b/skillhub-cli/src/commands/check.ts index e8fb840e6..9580d8b4c 100644 --- a/skillhub-cli/src/commands/check.ts +++ b/skillhub-cli/src/commands/check.ts @@ -70,7 +70,7 @@ export function registerCheck(program: Command) { name, status: "ok", source: entry.source, - location: installedLocations.join(", "), + location: installedLocations.sort((a, b) => a.localeCompare(b)).join(", "), }); } else { results.push({ @@ -86,11 +86,18 @@ export function registerCheck(program: Command) { results.push({ name, status: "orphaned", - location: locations.join(", "), + location: locations.sort((a, b) => a.localeCompare(b)).join(", "), }); } } + // Sort results: ok → missing → orphaned, then alphabetically by name + results.sort((a, b) => { + const order = { ok: 0, missing: 1, orphaned: 2 }; + const diff = order[a.status] - order[b.status]; + return diff !== 0 ? diff : a.name.localeCompare(b.name); + }); + if (opts.json) { console.log(JSON.stringify(results, null, 2)); return; diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 43bb113bf..4a272a4f8 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -180,15 +180,17 @@ function buildAgentSummary(targetAgents: AgentInfo[], mode: "symlink" | "copy", const universal = targetAgents.filter((a) => isUniversalForScope(a, isGlobal)); const symlinked = targetAgents.filter((a) => !isUniversalForScope(a, isGlobal)); + const sortNames = (agents: AgentInfo[]) => agents.map((a) => a.name).sort((a, b) => a.localeCompare(b)); + if (mode === "symlink") { if (universal.length > 0) { - lines.push(` universal: ${universal.map((a) => a.name).join(", ")}`); + lines.push(` universal: ${sortNames(universal).join(", ")}`); } if (symlinked.length > 0) { - lines.push(` symlink → ${symlinked.map((a) => a.name).join(", ")}`); + lines.push(` symlink → ${sortNames(symlinked).join(", ")}`); } } else { - lines.push(` copy → ${targetAgents.map((a) => a.name).join(", ")}`); + lines.push(` copy → ${sortNames(targetAgents).join(", ")}`); } return lines; @@ -397,7 +399,8 @@ async function installFromRegistry(slug: string, opts: Record a.name.localeCompare(b.name)); + for (const s of sorted) { info(`${s.name}`); dim(` ${s.description}`); } @@ -630,7 +633,8 @@ async function installFromGit(skillName: string, source: string, sourceType: Sou spinner.succeed(`Found ${skills.length} skill(s)`); if (opts.list) { - for (const s of skills) { + const sorted = [...skills].sort((a, b) => a.name.localeCompare(b.name)); + for (const s of sorted) { info(`${s.name}`); dim(` ${s.description}`); } diff --git a/skillhub-cli/src/commands/sync.ts b/skillhub-cli/src/commands/sync.ts index 4cc7afea8..9ded9ae62 100644 --- a/skillhub-cli/src/commands/sync.ts +++ b/skillhub-cli/src/commands/sync.ts @@ -49,7 +49,8 @@ export function registerSync(program: Command) { console.log(""); info(`Found ${skills.length} skill(s):`); - for (const skill of skills) { + const sortedSkills = [...skills].sort((a, b) => a.name.localeCompare(b.name)); + for (const skill of sortedSkills) { console.log(` - ${skill.name} (${skill.description})`); } console.log(""); diff --git a/skillhub-cli/src/commands/update.ts b/skillhub-cli/src/commands/update.ts index 736863e42..78c5ca352 100644 --- a/skillhub-cli/src/commands/update.ts +++ b/skillhub-cli/src/commands/update.ts @@ -26,7 +26,7 @@ export function registerUpdate(program: Command) { } const lockedSkills = await getAllLockedSkills(); - const allSkillNames = Object.keys(lockedSkills); + const allSkillNames = Object.keys(lockedSkills).sort((a, b) => a.localeCompare(b)); if (allSkillNames.length === 0) { error("No skills in lock file."); From 04bc62fed4c6f884c15343ba69432d7672a1d505 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:46:21 +0800 Subject: [PATCH 30/68] feat(cli): add scope-aware universal grouping and improve output formatting - list.ts: use isUniversalForScope for dynamic grouping based on scope - list.ts: group output by skill name to avoid duplicate path listings - uninstall.ts: use isUniversalForScope for dynamic grouping - uninstall.ts: add printUninstallResults for consistent formatted output - .npmignore: exclude development files from npm package - package.json: bump version to 1.1.1 --- skillhub-cli/.npmignore | 7 ++ skillhub-cli/package.json | 2 +- skillhub-cli/src/commands/list.ts | 93 +++++++++++++++++++------- skillhub-cli/src/commands/uninstall.ts | 93 +++++++++++++++++++++----- 4 files changed, 151 insertions(+), 44 deletions(-) diff --git a/skillhub-cli/.npmignore b/skillhub-cli/.npmignore index c59f40937..f8d3a0daf 100644 --- a/skillhub-cli/.npmignore +++ b/skillhub-cli/.npmignore @@ -5,3 +5,10 @@ unbuild.config.ts tsconfig.json *.test.ts src/ +.claude/ +.omc/ +AGENTS.md +README.md +Makefile +docs/ +scripts/ \ No newline at end of file diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index 09ee421f6..b9735d4a9 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.1.0", + "version": "1.1.1", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { diff --git a/skillhub-cli/src/commands/list.ts b/skillhub-cli/src/commands/list.ts index 7a4acabaa..85a4f8be8 100644 --- a/skillhub-cli/src/commands/list.ts +++ b/skillhub-cli/src/commands/list.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { existsSync, readdirSync, lstatSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { getAllAgents, getUniversalAgents, getNonUniversalAgents } from "../core/agent-detector.js"; +import { getAllAgents, isUniversalForScope } 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"; @@ -53,11 +53,20 @@ export function registerList(program: Command) { } } - const universalAgents = getUniversalAgents(); - const nonUniversalAgents = getNonUniversalAgents(); + // Determine scope for dynamic universal grouping + const isGlobal = scopeGlobal === true; + const allAgents = getAllAgents(); + const universalAgents = allAgents + .filter((a) => isUniversalForScope(a, isGlobal) && a.showInUniversalList !== false) + .sort((a, b) => a.name.localeCompare(b.name)); + const nonUniversalAgents = allAgents + .filter((a) => !isUniversalForScope(a, isGlobal)) + .sort((a, b) => a.name.localeCompare(b.name)); + + const canonicalLabel = isGlobal ? "Universal (~/.agents/skills)" : "Universal (.agents/skills)"; const universalSection = { - title: "Universal (.agents/skills)", + title: canonicalLabel, items: universalAgents.map((a) => ({ value: a.key, label: a.name, @@ -81,7 +90,7 @@ export function registerList(program: Command) { } const selectedAgents = agentSelection as string[]; - const agents = getAllAgents().filter((a) => selectedAgents.includes(a.key)); + const agents = allAgents.filter((a) => selectedAgents.includes(a.key)); if (agents.length === 0) { console.log("No agents selected."); @@ -90,44 +99,76 @@ export function registerList(program: Command) { console.log(""); - let found = false; + // Collect all skill entries grouped by (skillName, path) -> agentNames + const skillMap = new Map>(); + const home = homedir(); + const cwd = process.cwd(); + 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}`); - } - } + const projectDir = join(cwd, agent.skillsDir); + collectSkills(skillMap, projectDir, agent.name, cwd, true); } 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}`); - } - } + const globalDir = join(home, agent.globalSkillsDir); + collectSkills(skillMap, globalDir, agent.name, home, false); } } - if (!found) { + if (skillMap.size === 0) { dim("No skills installed for selected agents and scope."); + } else { + // Output grouped by skill, then by path with agent names merged + const sortedSkills = [...skillMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [skillName, pathGroups] of sortedSkills) { + info(`${skillName}`); + const sortedPaths = [...pathGroups.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [displayPath, agentNames] of sortedPaths) { + const sorted = agentNames.sort((a, b) => a.localeCompare(b)); + const label = sorted.length <= 5 + ? sorted.join(", ") + : sorted.slice(0, 5).join(", ") + ` ${pc.dim(`+${sorted.length - 5}`)}`; + dim(` ${pc.dim("→")} ${label}: ${displayPath}`); + } + } } console.log(""); }); } +/** + * Collect skills from a directory into the skillMap. + * skillMap: skillName -> (displayPath -> agentNames[]) + */ +function collectSkills( + skillMap: Map>, + dir: string, + agentName: string, + baseForRelative: string, + isProject: boolean, +) { + if (!existsSync(dir)) return; + const skills = getSkillsInDir(dir); + for (const skillName of skills) { + const displayPath = isProject + ? dir.replace(baseForRelative, ".") + : dir.replace(baseForRelative, "~"); + let pathGroups = skillMap.get(skillName); + if (!pathGroups) { + pathGroups = new Map(); + skillMap.set(skillName, pathGroups); + } + const agents = pathGroups.get(displayPath) || []; + agents.push(agentName); + pathGroups.set(displayPath, agents); + } +} + function getSkillsInDir(dir: string): string[] { if (!existsSync(dir)) return []; return readdirSync(dir).filter((f) => { @@ -138,5 +179,5 @@ function getSkillsInDir(dir: string): string[] { } catch { return false; } - }); + }).sort((a, b) => a.localeCompare(b)); } diff --git a/skillhub-cli/src/commands/uninstall.ts b/skillhub-cli/src/commands/uninstall.ts index 0675c2c75..88541177a 100644 --- a/skillhub-cli/src/commands/uninstall.ts +++ b/skillhub-cli/src/commands/uninstall.ts @@ -2,11 +2,12 @@ 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, isUniversalForScope, getUniversalAgents, getNonUniversalAgents, type AgentInfo } from "../core/agent-detector.js"; +import { getAllAgents, isUniversalForScope, 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"; +import pc from "picocolors"; function removeDir(path: string) { try { @@ -100,7 +101,7 @@ function discoverInstalledSkills(scope: "local" | "global", agent?: AgentInfo): } catch {} } - return [...new Set(skills)]; + return [...new Set(skills)].sort((a, b) => a.localeCompare(b)); } function findAgentsWithSkill(skillName: string, scope: "global" | "local", agents: AgentInfo[]): AgentInfo[] { @@ -145,6 +146,7 @@ export function registerUninstall(program: Command) { } const allAgents = getAllAgents(); + const isGlobal = scope === "global"; if (opts.all) { const skills = discoverInstalledSkills(scope); @@ -166,18 +168,19 @@ export function registerUninstall(program: Command) { } const selectedSkills = selected as string[]; - let uninstalled = 0; + const results: { skill: string; agent: string; path: string; ok: boolean }[] = []; 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++; + const skillPath = getSkillPath(skill, agent, scope); + results.push({ skill, agent: agent.name, path: skillPath || "", ok }); } await removeFromLock(skill); } - success(`Uninstalled ${uninstalled} skill(s).`); + printUninstallResults(results); return; } @@ -201,18 +204,19 @@ export function registerUninstall(program: Command) { } const selectedSkills = selected as string[]; - let uninstalled = 0; + const results: { skill: string; agent: string; path: string; ok: boolean }[] = []; 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++; + const skillPath = getSkillPath(skill, agent, scope); + results.push({ skill, agent: agent.name, path: skillPath || "", ok }); } await removeFromLock(skill); } - success(`Uninstalled ${uninstalled} skill(s).`); + printUninstallResults(results); return; } @@ -240,11 +244,17 @@ export function registerUninstall(program: Command) { } } - const universalAgents = getUniversalAgents(); - const nonUniversalAgents = getNonUniversalAgents(); + // Dynamic universal grouping based on scope + const universalAgents = allAgents + .filter((a) => isUniversalForScope(a, isGlobal) && a.showInUniversalList !== false) + .sort((a, b) => a.name.localeCompare(b.name)); + const nonUniversalAgents = allAgents + .filter((a) => !isUniversalForScope(a, isGlobal)) + .sort((a, b) => a.name.localeCompare(b.name)); + const canonicalLabel = isGlobal ? "Universal (~/.agents/skills)" : "Universal (.agents/skills)"; const universalSection = { - title: "Universal (.agents/skills)", + title: canonicalLabel, items: universalAgents .filter((a) => agentsWithSkill.some((w) => w.key === a.key)) .map((a) => ({ @@ -317,12 +327,13 @@ export function registerUninstall(program: Command) { 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})`); - } + const sortedEntries = [...pathToAgents.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [path, agents] of sortedEntries) { + const sorted = agents.sort((a, b) => a.localeCompare(b)); + const label = sorted.length <= 5 + ? sorted.join(", ") + : sorted.slice(0, 5).join(", ") + ` ${pc.dim(`+${sorted.length - 5}`)}`; + lines.push(` ${pc.dim("→")} ${label}: ${pc.dim(path)}`); } success(`Uninstalled ${name} from ${selectedAgentKeys.length} agent(s):`); console.log(lines.join("\n")); @@ -332,3 +343,51 @@ export function registerUninstall(program: Command) { } }); } + +/** + * Print uninstall results grouped by skill and path (consistent with install output format). + */ +function printUninstallResults(results: { skill: string; agent: string; path: string; ok: boolean }[]) { + const successful = results.filter((r) => r.ok); + if (successful.length === 0) { + info("No skills were uninstalled."); + return; + } + + // Group by skill + const skillGroups = new Map(); + for (const r of successful) { + let group = skillGroups.get(r.skill); + if (!group) { + group = []; + skillGroups.set(r.skill, group); + } + group.push({ agent: r.agent, path: r.path }); + } + + const lines: string[] = []; + const sortedSkills = [...skillGroups.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [skillName, entries] of sortedSkills) { + lines.push(`${pc.green("✓")} ${skillName}`); + + // Group by path + const pathGroups = new Map(); + for (const e of entries) { + const agents = pathGroups.get(e.path) || []; + agents.push(e.agent); + pathGroups.set(e.path, agents); + } + + const sortedPaths = [...pathGroups.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [path, agents] of sortedPaths) { + const sorted = agents.sort((a, b) => a.localeCompare(b)); + const label = sorted.length <= 5 + ? sorted.join(", ") + : sorted.slice(0, 5).join(", ") + ` ${pc.dim(`+${sorted.length - 5}`)}`; + lines.push(` ${pc.dim("→")} ${label}: ${pc.dim(path)}`); + } + } + + p.note(lines.join("\n"), `Uninstalled ${successful.length} skill(s)`); +} From 3cf4d64cf583746d931d9508b8e8fb72c3bab90b Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:53:35 +0800 Subject: [PATCH 31/68] chore(cli): bump version to 1.1.2 --- skillhub-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index b9735d4a9..f126c925f 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.1.1", + "version": "1.1.2", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { From ee18161d5674581b0b8cc61c34b40bcc82702ae6 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:24:44 +0800 Subject: [PATCH 32/68] feat(cli): improve list and check commands with better filtering list.ts: - Remove locked universal section, all agents are now equal - Users manually select which agents to list from - No default selections, cleaner UX check.ts: - Add agent filtering via --agent flag or interactive selection - Add status filtering via --status flag or interactive selection - Default to showing only OK + Missing (not Orphaned) - Interactive prompts for scope, agents, and statuses - Show agent names and scope prefix in output - Smart defaults reduce noise from orphaned skills --- skillhub-cli/src/commands/check.ts | 215 ++++++++++++++++++++++++----- skillhub-cli/src/commands/list.ts | 24 +--- 2 files changed, 185 insertions(+), 54 deletions(-) diff --git a/skillhub-cli/src/commands/check.ts b/skillhub-cli/src/commands/check.ts index 9580d8b4c..2240efd9e 100644 --- a/skillhub-cli/src/commands/check.ts +++ b/skillhub-cli/src/commands/check.ts @@ -2,9 +2,11 @@ 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 { getAllAgents, type AgentInfo } from "../core/agent-detector.js"; import { getAllLockedSkills, getSkillLockPath } from "../core/skill-lock.js"; import { success, error, info, warn, dim } from "../utils/logger.js"; +import * as p from "@clack/prompts"; +import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; interface CheckResult { name: string; @@ -13,11 +15,15 @@ interface CheckResult { location?: string; } -function findInstalledSkills(scope: "local" | "global"): Map { +function findInstalledSkills( + scope: "local" | "global", + agents?: AgentInfo[] +): Map { const skillsMap = new Map(); - const agents = getAllAgents(); + const allAgents = getAllAgents(); + const targetAgents = agents || allAgents; - for (const agent of agents) { + for (const agent of targetAgents) { const baseDir = scope === "global" ? join(homedir(), agent.globalSkillsDir || agent.skillsDir) : join(process.cwd(), agent.skillsDir); @@ -44,9 +50,118 @@ export function registerCheck(program: Command) { .command("check") .description("Check installed skills against lock file") .option("--global", "Check global scope skills") + .option("--local", "Check local (project) scope skills") + .option("--all", "Check both global and local scopes") + .option("--agent ", "Filter by specific agents") + .option("--status ", "Filter by status (ok, missing, orphaned)") .option("--json", "Output results as JSON") - .action(async (opts: { global?: boolean; json?: boolean }) => { - const scope = opts.global ? "global" : "local"; + .action(async (opts: { + global?: boolean; + local?: boolean; + all?: boolean; + agent?: string[]; + status?: string[]; + json?: boolean; + }) => { + // Determine scopes + let scopes: ("local" | "global")[] = []; + + if (opts.all) { + scopes = ["local", "global"]; + } else if (opts.global) { + scopes = ["global"]; + } else if (opts.local) { + scopes = ["local"]; + } else { + // Interactive scope selection + const scopeSelection = await p.select({ + message: "Which scope to check?", + options: [ + { value: "all", label: "All (global + project)" }, + { value: "global", label: "Global only" }, + { value: "local", label: "Project only" }, + ], + }); + + if (p.isCancel(scopeSelection)) { + console.log("Cancelled."); + return; + } + + if (scopeSelection === "all") { + scopes = ["local", "global"]; + } else if (scopeSelection === "global") { + scopes = ["global"]; + } else { + scopes = ["local"]; + } + } + + // Determine agents to check + let targetAgents: AgentInfo[] | undefined; + if (opts.agent && opts.agent.length > 0) { + const allAgents = getAllAgents(); + targetAgents = allAgents.filter((a) => opts.agent!.includes(a.key)); + } else if (!opts.agent) { + // Interactive agent selection + const allAgents = getAllAgents(); + const agentItems = allAgents + .map((a) => ({ + value: a.key, + label: a.name, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const selected = await searchMultiselect({ + message: "Which agents to check?", + items: agentItems, + required: false, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + if (selected && selected.length > 0) { + targetAgents = allAgents.filter((a) => (selected as string[]).includes(a.key)); + } + } + + // Determine which statuses to show + let showOk = false; + let showMissing = false; + let showOrphaned = false; + + if (opts.status && opts.status.length > 0) { + // Command line flags + showOk = opts.status.includes("ok"); + showMissing = opts.status.includes("missing"); + showOrphaned = opts.status.includes("orphaned"); + } else { + // Interactive status selection (default: ok + missing only) + const statusSelection = await p.multiselect({ + message: "Which statuses to show?", + options: [ + { value: "ok", label: "OK (installed and in lock file)" }, + { value: "missing", label: "Missing (in lock file but not installed)" }, + { value: "orphaned", label: "Orphaned (installed but not in lock file)" }, + ], + required: false, + initialValues: ["ok", "missing"], + }); + + if (p.isCancel(statusSelection)) { + console.log("Cancelled."); + return; + } + + const selected = statusSelection as string[]; + showOk = selected.includes("ok"); + showMissing = selected.includes("missing"); + showOrphaned = selected.includes("orphaned"); + } + const lockPath = getSkillLockPath(); if (!existsSync(lockPath)) { @@ -59,63 +174,93 @@ export function registerCheck(program: Command) { } const lockedSkills = await getAllLockedSkills(); - const installedSkills = findInstalledSkills(scope); + const allResults: CheckResult[] = []; - const results: CheckResult[] = []; + // Check each scope + for (const scope of scopes) { + const installedSkills = findInstalledSkills(scope, targetAgents); - 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.sort((a, b) => a.localeCompare(b)).join(", "), - }); - } else { - results.push({ - name, - status: "missing", - source: entry.source, - }); + for (const [name, entry] of Object.entries(lockedSkills)) { + const installedLocations = installedSkills.get(name); + if (installedLocations && installedLocations.length > 0) { + allResults.push({ + name, + status: "ok", + source: entry.source, + location: `${scope}: ${installedLocations.sort((a, b) => a.localeCompare(b)).join(", ")}`, + }); + } + } + + for (const [name, locations] of installedSkills.entries()) { + if (!lockedSkills[name]) { + allResults.push({ + name, + status: "orphaned", + location: `${scope}: ${locations.sort((a, b) => a.localeCompare(b)).join(", ")}`, + }); + } } } - for (const [name, locations] of installedSkills.entries()) { - if (!lockedSkills[name]) { - results.push({ + // Mark missing skills (not found in any scope) + const checkedNames = new Set(); + for (const r of allResults) { + if (r.status !== "orphaned") { + checkedNames.add(r.name); + } + } + + for (const [name, entry] of Object.entries(lockedSkills)) { + if (!checkedNames.has(name)) { + allResults.push({ name, - status: "orphaned", - location: locations.sort((a, b) => a.localeCompare(b)).join(", "), + status: "missing", + source: entry.source, }); } } // Sort results: ok → missing → orphaned, then alphabetically by name - results.sort((a, b) => { + allResults.sort((a, b) => { const order = { ok: 0, missing: 1, orphaned: 2 }; const diff = order[a.status] - order[b.status]; return diff !== 0 ? diff : a.name.localeCompare(b.name); }); if (opts.json) { - console.log(JSON.stringify(results, null, 2)); + console.log(JSON.stringify(allResults, null, 2)); return; } + // Filter results by selected statuses + const filteredResults = allResults.filter((r) => { + if (r.status === "ok") return showOk; + if (r.status === "missing") return showMissing; + if (r.status === "orphaned") return showOrphaned; + return false; + }); + + const scopeLabel = scopes.length === 2 ? "all scopes" : `${scopes[0]} scope`; + const agentLabel = targetAgents + ? ` (${targetAgents.map((a) => a.name).sort((a, b) => a.localeCompare(b)).join(", ")})` + : ""; + console.log(""); - info(`SkillHub Lock Check (${scope} scope):`); + info(`SkillHub Lock Check (${scopeLabel})${agentLabel}:`); console.log(""); - if (results.length === 0) { - dim(" No skills found."); + if (filteredResults.length === 0) { + dim(" No matching skills found."); console.log(""); return; } - let ok = 0, missing = 0, orphaned = 0; + let ok = 0, + missing = 0, + orphaned = 0; - for (const r of results) { + for (const r of filteredResults) { if (r.status === "ok") { ok++; success(` ✓ ${r.name}`); diff --git a/skillhub-cli/src/commands/list.ts b/skillhub-cli/src/commands/list.ts index 85a4f8be8..c9cf1fc58 100644 --- a/skillhub-cli/src/commands/list.ts +++ b/skillhub-cli/src/commands/list.ts @@ -57,31 +57,17 @@ export function registerList(program: Command) { const isGlobal = scopeGlobal === true; const allAgents = getAllAgents(); - const universalAgents = allAgents - .filter((a) => isUniversalForScope(a, isGlobal) && a.showInUniversalList !== false) - .sort((a, b) => a.name.localeCompare(b.name)); - const nonUniversalAgents = allAgents - .filter((a) => !isUniversalForScope(a, isGlobal)) - .sort((a, b) => a.name.localeCompare(b.name)); - - const canonicalLabel = isGlobal ? "Universal (~/.agents/skills)" : "Universal (.agents/skills)"; - const universalSection = { - title: canonicalLabel, - items: universalAgents.map((a) => ({ + // All agents are selectable - no locked section + const selectableItems = allAgents + .map((a) => ({ value: a.key, label: a.name, - })), - }; - - const selectableItems = nonUniversalAgents.map((a) => ({ - value: a.key, - label: a.name, - })); + })) + .sort((a, b) => a.label.localeCompare(b.label)); const agentSelection = await searchMultiselect({ message: "Which agents to list from?", items: selectableItems, - lockedSection: universalSection, }); if (agentSelection === cancelSymbol) { From d6b9f026b498ed85e33a6435260c9644ff13c97b Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:17:55 +0800 Subject: [PATCH 33/68] revert(web): restore vite.config.ts to use port 3000 Revert the vite.config.ts changes from e93adaa that changed the dev server port from 3000 to 8181 and added local dev features (env loading, serve-skill-md plugin, proxy headers). These were local development configs that broke CI by changing the port away from 3000 which the Makefile health check expects. --- web/vite.config.ts | 101 +++++++++++---------------------------------- 1 file changed, 23 insertions(+), 78 deletions(-) diff --git a/web/vite.config.ts b/web/vite.config.ts index 27d6bce56..aff7ad67e 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,87 +1,32 @@ -import fs from 'fs' -import { defineConfig, loadEnv, type ProxyOptions } from 'vite' +import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' -function forwardOriginalHostToBackend(): NonNullable { - return (proxy) => { - proxy.on('proxyReq', (proxyReq, req) => { - const host = req.headers.host - if (host) { - proxyReq.setHeader('X-Forwarded-Host', host) - const proto = req.headers['x-forwarded-proto'] - proxyReq.setHeader( - 'X-Forwarded-Proto', - typeof proto === 'string' ? proto : 'http', - ) - } - }) - } -} - -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), '') - // Override with VITE_BACKEND_URL in .env.local - const backend = env.VITE_BACKEND_URL || 'http://localhost:8080' - - return { - plugins: [ - react(), - { - name: 'serve-skill-md', - configureServer(server) { - server.middlewares.use('/registry/skill.md', (_req, res) => { - const template = fs.readFileSync( - path.resolve(__dirname, 'src/docs/skill.md.template'), - 'utf-8', - ) - const origin = `http://localhost:${server.config.server.port || 8181}` - res.setHeader('Content-Type', 'text/plain; charset=utf-8') - res.end( - template.replace(/\$\{SKILLHUB_PUBLIC_BASE_URL\}/g, origin), - ) - }) - }, - }, - ], - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - }, +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), }, - test: { - exclude: ['**/node_modules/**', '**/e2e/**'], + }, + test: { + exclude: ['**/node_modules/**', '**/e2e/**'], + }, + server: { + port: 3000, + watch: { + usePolling: true, + interval: 150, }, - server: { - // Listen on 0.0.0.0 so LAN / other machines can reach dev (still need firewall rules for true public IP). - host: true, - port: 8181, - watch: { - usePolling: true, - interval: 150, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, }, - proxy: { - '/api': { - target: backend, - changeOrigin: true, - configure: forwardOriginalHostToBackend(), - }, - '/oauth2': { - target: backend, - changeOrigin: true, - configure: forwardOriginalHostToBackend(), - }, - '/login/oauth2': { - target: backend, - changeOrigin: true, - configure: forwardOriginalHostToBackend(), - }, - '/actuator': { - target: backend, - changeOrigin: true, - configure: forwardOriginalHostToBackend(), - }, + '/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, }, }, - } + }, }) From 2543cd52e776c19085e2248dcf92be0da20d00fe Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:10:07 +0800 Subject: [PATCH 34/68] fix(cli): make --registry parameter work for all commands Fixed the issue where the --registry command-line parameter was defined but not actually used by any commands. Changes: - Added loadConfigFromProgram() helper in config.ts to read registry from program.opts() - Updated all 21 command files to use loadConfigFromProgram() instead of loadConfig() - Ensured correct priority: CLI args > env vars > config file > defaults Priority order: 1. --registry (command-line flag) - highest priority 2. SKILLHUB_REGISTRY (environment variable) 3. ~/.skillhub/config.json (config file) 4. http://localhost:8080 (default value) This allows users to temporarily override the registry without modifying environment variables or config files, providing better flexibility for: - Testing against different registries - Multi-project/multi-environment setups - CI/CD automation scripts Updated commands: - install, download, update, check, sync, uninstall - explore, search - publish, delete, archive, versions - inspect, resolve, rating, rate, star, report, reviews - whoami, me, namespaces, notifications - transfer, hide, unhide Fixes issue where `npx motovis-skillhub install --registry ` would ignore the --registry parameter and use env vars or defaults instead. --- skillhub-cli/src/commands/archive.ts | 4 +-- skillhub-cli/src/commands/delete.ts | 4 +-- skillhub-cli/src/commands/download.ts | 4 +-- skillhub-cli/src/commands/explore.ts | 4 +-- skillhub-cli/src/commands/hide.ts | 6 ++-- skillhub-cli/src/commands/inspect.ts | 4 +-- skillhub-cli/src/commands/install.ts | 24 +++++++++---- skillhub-cli/src/commands/me.ts | 6 ++-- skillhub-cli/src/commands/namespaces.ts | 4 +-- skillhub-cli/src/commands/notifications.ts | 8 ++--- skillhub-cli/src/commands/publish.ts | 4 +-- skillhub-cli/src/commands/rating.ts | 6 ++-- skillhub-cli/src/commands/report.ts | 4 +-- skillhub-cli/src/commands/resolve.ts | 4 +-- skillhub-cli/src/commands/reviews.ts | 4 +-- skillhub-cli/src/commands/search.ts | 4 +-- skillhub-cli/src/commands/star.ts | 4 +-- skillhub-cli/src/commands/sync.ts | 4 +-- skillhub-cli/src/commands/transfer.ts | 4 +-- skillhub-cli/src/commands/versions.ts | 4 +-- skillhub-cli/src/commands/whoami.ts | 4 +-- skillhub-cli/src/core/config.ts | 42 ++++++++++++++++------ 22 files changed, 94 insertions(+), 62 deletions(-) diff --git a/skillhub-cli/src/commands/archive.ts b/skillhub-cli/src/commands/archive.ts index 46052e725..6741a78d0 100644 --- a/skillhub-cli/src/commands/archive.ts +++ b/skillhub-cli/src/commands/archive.ts @@ -1,7 +1,7 @@ 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 { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error } from "../utils/logger.js"; import { parseSkillName } from "../core/skill-name.js"; @@ -27,7 +27,7 @@ export function registerArchive(program: Command) { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); await client.post(`/api/v1/skills/${namespace}/${skillSlug}/archive`); success(`Archived ${skillSlug}`); diff --git a/skillhub-cli/src/commands/delete.ts b/skillhub-cli/src/commands/delete.ts index dd338c6bc..a535ec290 100644 --- a/skillhub-cli/src/commands/delete.ts +++ b/skillhub-cli/src/commands/delete.ts @@ -1,7 +1,7 @@ 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 { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error } from "../utils/logger.js"; import { parseSkillName } from "../core/skill-name.js"; @@ -28,7 +28,7 @@ export function registerDelete(program: Command) { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); await client.delete(`/api/v1/skills/${namespace}/${skillSlug}`); success(`Deleted ${skillSlug} from ${namespace}`); diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts index 8b6e27d7a..54db14a69 100644 --- a/skillhub-cli/src/commands/download.ts +++ b/skillhub-cli/src/commands/download.ts @@ -4,7 +4,7 @@ import { resolve } from "node:path"; import { finished } from "node:stream/promises"; import { ApiClient } from "../core/api-client.js"; import { ApiRoutes } from "../schema/routes.js"; -import { loadConfig } from "../core/config.js"; +import { loadConfig, loadConfigFromProgram } 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"; @@ -19,7 +19,7 @@ export function registerDownload(program: Command) { .option("--output ", "Output directory") .action(async (slug: string, opts: Record) => { const { namespace, slug: skillSlug } = parseSkillName(slug); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); diff --git a/skillhub-cli/src/commands/explore.ts b/skillhub-cli/src/commands/explore.ts index dba7576c6..cd60d57f6 100644 --- a/skillhub-cli/src/commands/explore.ts +++ b/skillhub-cli/src/commands/explore.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { ApiClient } from "../core/api-client.js"; -import { loadConfig } from "../core/config.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { readToken } from "../core/auth-token.js"; import { ApiRoutes } from "../schema/routes.js"; import { info, dim } from "../utils/logger.js"; @@ -222,7 +222,7 @@ export function registerExplore(program: Command) { .option("-n, --limit ", "Max results", "20") .option("-s, --sort ", "Sort by: hot, newest, downloads (default: interactive mode)") .action(async (query: string | undefined, opts: { limit: string; sort?: string }) => { - const config = loadConfig(); + const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); const sortMap: Record = { hot: "rating", newest: "newest", downloads: "downloads" }; diff --git a/skillhub-cli/src/commands/hide.ts b/skillhub-cli/src/commands/hide.ts index 7863c43cf..d647e3f2f 100644 --- a/skillhub-cli/src/commands/hide.ts +++ b/skillhub-cli/src/commands/hide.ts @@ -1,7 +1,7 @@ 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 { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error } from "../utils/logger.js"; import { parseSkillName } from "../core/skill-name.js"; @@ -27,7 +27,7 @@ export function registerHide(program: Command) { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); const detail = await client.get<{ id: number }>( @@ -67,7 +67,7 @@ export function registerHide(program: Command) { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); const detail = await client.get<{ id: number }>( diff --git a/skillhub-cli/src/commands/inspect.ts b/skillhub-cli/src/commands/inspect.ts index 8acbea8e2..f68ca0eeb 100644 --- a/skillhub-cli/src/commands/inspect.ts +++ b/skillhub-cli/src/commands/inspect.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { ApiClient } from "../core/api-client.js"; import { ApiRoutes } from "../schema/routes.js"; -import { loadConfig } from "../core/config.js"; +import { loadConfig, loadConfigFromProgram } 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"; @@ -70,7 +70,7 @@ export function registerInspect(program: Command) { .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 config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 4a272a4f8..7062e52bb 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createWriteStream, createReadStream, existsSync, mkdirSync } from "node:fs"; import { ApiClient } from "../core/api-client.js"; -import { loadConfig } from "../core/config.js"; +import { loadConfigFromProgram } from "../core/config.js"; import { readToken } from "../core/auth-token.js"; import { discoverSkills } from "../core/skill-discovery.js"; import { installSkill } from "../core/installer.js"; @@ -227,9 +227,9 @@ export function registerInstall(program: Command) { try { if (effectiveSource === "registry") { - await installFromRegistry(source, opts, spinner); + await installFromRegistry(source, opts, spinner, program); } else { - await installFromGit(source, installSource, effectiveSource, opts, spinner); + await installFromGit(source, installSource, effectiveSource, opts, spinner, program); } } catch (e: any) { spinner.fail(e.message); @@ -238,7 +238,12 @@ export function registerInstall(program: Command) { }); } -async function installFromRegistry(slug: string, opts: Record, spinner: any) { +async function installFromRegistry( + slug: string, + opts: Record, + spinner: any, + program: Command +) { let ns = "global"; let actualSlug = slug; let userSpecifiedNamespace = false; @@ -252,7 +257,7 @@ async function installFromRegistry(slug: string, opts: Record, spinner: any) { +async function installFromGit( + skillName: string, + source: string, + sourceType: SourceType, + opts: Record, + spinner: any, + program: Command +) { let skillsDir: string; const parsed = parseSource(source); diff --git a/skillhub-cli/src/commands/me.ts b/skillhub-cli/src/commands/me.ts index 9fb52ba1e..015e5ed32 100644 --- a/skillhub-cli/src/commands/me.ts +++ b/skillhub-cli/src/commands/me.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { ApiClient } from "../core/api-client.js"; -import { loadConfig } from "../core/config.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { requireToken } from "../core/auth-token.js"; import { error, info, dim } from "../utils/logger.js"; @@ -33,7 +33,7 @@ export function registerMe(program: Command) { .action(async () => { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); const resp = await client.get("/api/v1/me/skills"); const skills = resp.items || []; @@ -63,7 +63,7 @@ export function registerMe(program: Command) { .action(async () => { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); const resp = await client.get("/api/v1/me/stars"); const skills = resp.items || []; diff --git a/skillhub-cli/src/commands/namespaces.ts b/skillhub-cli/src/commands/namespaces.ts index 8359c5eeb..942bdb10b 100644 --- a/skillhub-cli/src/commands/namespaces.ts +++ b/skillhub-cli/src/commands/namespaces.ts @@ -2,7 +2,7 @@ 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 { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { error } from "../utils/logger.js"; export function registerNamespaces(program: Command) { @@ -12,7 +12,7 @@ export function registerNamespaces(program: Command) { .action(async () => { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); const namespaces = await client.get(ApiRoutes.meNamespaces); const isJson = program.opts().json; diff --git a/skillhub-cli/src/commands/notifications.ts b/skillhub-cli/src/commands/notifications.ts index 2fd73ec86..fe341ee6e 100644 --- a/skillhub-cli/src/commands/notifications.ts +++ b/skillhub-cli/src/commands/notifications.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { ApiClient } from "../core/api-client.js"; -import { loadConfig } from "../core/config.js"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { requireToken } from "../core/auth-token.js"; import { success, error, info, dim } from "../utils/logger.js"; @@ -26,7 +26,7 @@ export function registerNotifications(program: Command) { .action(async (opts: { unread?: boolean }) => { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); 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; @@ -50,7 +50,7 @@ export function registerNotifications(program: Command) { .action(async (id: string) => { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); await client.put(`/api/v1/notifications/${id}/read`); success(`Marked notification ${id} as read`); @@ -66,7 +66,7 @@ export function registerNotifications(program: Command) { .action(async () => { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); await client.put("/api/v1/notifications/read-all"); success("All notifications marked as read"); diff --git a/skillhub-cli/src/commands/publish.ts b/skillhub-cli/src/commands/publish.ts index 8f62822b5..0ccd41f71 100644 --- a/skillhub-cli/src/commands/publish.ts +++ b/skillhub-cli/src/commands/publish.ts @@ -5,7 +5,7 @@ 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 { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error, info } from "../utils/logger.js"; import ora from "ora"; import semver from "semver"; @@ -49,7 +49,7 @@ export function registerPublish(program: Command) { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); const spinner = ora(`Publishing ${slug}@${version} to ${namespace}`).start(); diff --git a/skillhub-cli/src/commands/rating.ts b/skillhub-cli/src/commands/rating.ts index 583d4f6ad..92fcd1732 100644 --- a/skillhub-cli/src/commands/rating.ts +++ b/skillhub-cli/src/commands/rating.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { ApiClient } from "../core/api-client.js"; -import { loadConfig } from "../core/config.js"; +import { loadConfig, loadConfigFromProgram } 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"; @@ -13,7 +13,7 @@ export function registerRating(program: Command) { try { const { namespace, slug: skillSlug } = parseSkillName(slug); const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); const detail = await client.get<{ id: number }>( @@ -51,7 +51,7 @@ export function registerRate(program: Command) { try { const { namespace, slug: skillSlug } = parseSkillName(slug); const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); const detail = await client.get<{ id: number }>( diff --git a/skillhub-cli/src/commands/report.ts b/skillhub-cli/src/commands/report.ts index 91c3e14c1..f20c6d870 100644 --- a/skillhub-cli/src/commands/report.ts +++ b/skillhub-cli/src/commands/report.ts @@ -2,7 +2,7 @@ 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 { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error } from "../utils/logger.js"; import { parseSkillName } from "../core/skill-name.js"; @@ -15,7 +15,7 @@ export function registerReport(program: Command) { try { const { namespace, slug: skillSlug } = parseSkillName(slug); const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); let reason = opts.reason; diff --git a/skillhub-cli/src/commands/resolve.ts b/skillhub-cli/src/commands/resolve.ts index 348f564b1..6d83ce2a4 100644 --- a/skillhub-cli/src/commands/resolve.ts +++ b/skillhub-cli/src/commands/resolve.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { ApiClient } from "../core/api-client.js"; -import { loadConfig } from "../core/config.js"; +import { loadConfig, loadConfigFromProgram } 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"; @@ -51,7 +51,7 @@ export function registerResolve(program: Command) { .action(async (slug: string, opts: Record) => { try { const { namespace, slug: skillSlug } = parseSkillName(slug); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); diff --git a/skillhub-cli/src/commands/reviews.ts b/skillhub-cli/src/commands/reviews.ts index ba38ca140..911777e0a 100644 --- a/skillhub-cli/src/commands/reviews.ts +++ b/skillhub-cli/src/commands/reviews.ts @@ -1,7 +1,7 @@ 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 { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error, info, dim } from "../utils/logger.js"; export interface ReviewSubmission { @@ -24,7 +24,7 @@ export function registerReviews(program: Command) { .action(async () => { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); const submissions = await client.get("/api/v1/reviews/my-submissions"); if (!submissions || submissions.length === 0) { diff --git a/skillhub-cli/src/commands/search.ts b/skillhub-cli/src/commands/search.ts index 47e4b482d..70c96ddda 100644 --- a/skillhub-cli/src/commands/search.ts +++ b/skillhub-cli/src/commands/search.ts @@ -1,7 +1,7 @@ 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 { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { readToken } from "../core/auth-token.js"; import { error, dim } from "../utils/logger.js"; @@ -12,7 +12,7 @@ export function registerSearch(program: Command) { .option("-n, --limit ", "Max results", "20") .option("--namespace ", "Filter by namespace") .action(async (query: string[], opts: { limit: string; namespace?: string }) => { - const config = loadConfig(); + const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); diff --git a/skillhub-cli/src/commands/star.ts b/skillhub-cli/src/commands/star.ts index 067e2c1a1..26651965a 100644 --- a/skillhub-cli/src/commands/star.ts +++ b/skillhub-cli/src/commands/star.ts @@ -2,7 +2,7 @@ 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 { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error } from "../utils/logger.js"; import { parseSkillName } from "../core/skill-name.js"; @@ -15,7 +15,7 @@ export function registerStar(program: Command) { try { const { namespace, slug: skillSlug } = parseSkillName(slug); const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); const detailPath = ApiRoutes.skillDetail.replace("{namespace}", namespace).replace("{slug}", skillSlug); diff --git a/skillhub-cli/src/commands/sync.ts b/skillhub-cli/src/commands/sync.ts index 9ded9ae62..7f87bbb86 100644 --- a/skillhub-cli/src/commands/sync.ts +++ b/skillhub-cli/src/commands/sync.ts @@ -5,7 +5,7 @@ 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 { loadConfig, loadConfigFromProgram } 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"; @@ -36,7 +36,7 @@ export function registerSync(program: Command) { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); info(`Scanning ${scanPath} for skills...`); diff --git a/skillhub-cli/src/commands/transfer.ts b/skillhub-cli/src/commands/transfer.ts index b54684e1d..92c983a9d 100644 --- a/skillhub-cli/src/commands/transfer.ts +++ b/skillhub-cli/src/commands/transfer.ts @@ -2,7 +2,7 @@ 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 { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error } from "../utils/logger.js"; export function registerTransfer(program: Command) { @@ -26,7 +26,7 @@ export function registerTransfer(program: Command) { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); 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}`); diff --git a/skillhub-cli/src/commands/versions.ts b/skillhub-cli/src/commands/versions.ts index bc0e9de51..4177e2be0 100644 --- a/skillhub-cli/src/commands/versions.ts +++ b/skillhub-cli/src/commands/versions.ts @@ -1,7 +1,7 @@ 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 { loadConfig, loadConfigFromProgram } 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"; @@ -37,7 +37,7 @@ export function registerVersions(program: Command) { .action(async (slug: string) => { try { const { namespace, slug: skillSlug } = parseSkillName(slug); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); diff --git a/skillhub-cli/src/commands/whoami.ts b/skillhub-cli/src/commands/whoami.ts index 3aa2954a3..dbb77ad28 100644 --- a/skillhub-cli/src/commands/whoami.ts +++ b/skillhub-cli/src/commands/whoami.ts @@ -3,7 +3,7 @@ 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"; +import { loadConfig, loadConfigFromProgram } from "../core/config.js"; export function registerWhoami(program: Command) { program @@ -12,7 +12,7 @@ export function registerWhoami(program: Command) { .action(async () => { try { const token = await requireToken(); - const config = loadConfig(); + const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); const resp = await client.get(ApiRoutes.whoami); const isJson = program.opts().json; diff --git a/skillhub-cli/src/core/config.ts b/skillhub-cli/src/core/config.ts index bbfd3009a..fd42523d8 100644 --- a/skillhub-cli/src/core/config.ts +++ b/skillhub-cli/src/core/config.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import type { Command } from "commander"; const CONFIG_DIR = join(homedir(), ".skillhub"); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); @@ -14,24 +15,43 @@ const DEFAULT_CONFIG: CliConfig = { registry: "http://localhost:8080", }; -export function loadConfig(): CliConfig { +export function loadConfig(overrides?: Partial): CliConfig { + // Priority: overrides > env > config file > defaults const envRegistry = process.env.SKILLHUB_REGISTRY; - if (envRegistry) { - if (!existsSync(CONFIG_FILE)) return { registry: envRegistry }; + const baseConfig: CliConfig = envRegistry + ? { registry: envRegistry } + : { ...DEFAULT_CONFIG }; + + if (existsSync(CONFIG_FILE)) { try { const raw = readFileSync(CONFIG_FILE, "utf-8"); - return { registry: envRegistry, ...JSON.parse(raw) }; + Object.assign(baseConfig, JSON.parse(raw)); } catch { - return { registry: envRegistry }; + // Use base config if file is invalid } } - 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 }; + + // Apply overrides (e.g., from command-line options) + if (overrides) { + Object.assign(baseConfig, overrides); + } + + return baseConfig; +} + +/** + * Helper function to load config with command-line options from Commander.js program + * Use this in command actions to get config that respects --registry flag + */ +export function loadConfigFromProgram(program: Command): CliConfig { + const opts = program.opts(); + const overrides: Partial = {}; + + if (opts.registry) { + overrides.registry = opts.registry as string; } + + return loadConfig(overrides); } export function saveConfig(config: Partial): void { From 6a6df19f4be53a385e3066d2864a96b6ab431cc5 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:12:01 +0800 Subject: [PATCH 35/68] chore(cli): bump version to 1.1.3 for --registry parameter fix --- skillhub-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index f126c925f..9d16fcdd6 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.1.2", + "version": "1.1.3", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { From f5caed955e726aa1cbf62f39c8b0a5a4407b313c Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:11:32 +0800 Subject: [PATCH 36/68] chore: remove local scripts from git tracking Remove scripts/notify-feishu.sh and scripts/release-cli.sh from git tracking as they are local utility files. Added to .gitignore to prevent future accidental commits. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 ++ scripts/notify-feishu.sh | 91 ---------------------------------------- scripts/release-cli.sh | 60 -------------------------- 3 files changed, 4 insertions(+), 151 deletions(-) delete mode 100755 scripts/notify-feishu.sh delete mode 100755 scripts/release-cli.sh diff --git a/.gitignore b/.gitignore index 2eae3701e..2563c091b 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,7 @@ CLAUDE.md # Local config file .mcp.json + +# Local scripts (personal utilities) +scripts/notify-feishu.sh +scripts/release-cli.sh diff --git a/scripts/notify-feishu.sh b/scripts/notify-feishu.sh deleted file mode 100755 index 384087da8..000000000 --- a/scripts/notify-feishu.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/bin/bash -# Send skillhub-cli release notification to Feishu group via bot webhook -# Usage: FEISHU_WEBHOOK_URL=xxx ./scripts/notify-feishu.sh [prev_version] -set -euo pipefail - -WEBHOOK_URL="${FEISHU_WEBHOOK_URL:-}" -VERSION="${1:-}" -PREV_VERSION="${2:-}" - -if [ -z "$WEBHOOK_URL" ]; then - echo "Error: FEISHU_WEBHOOK_URL is not set" - echo "Usage: FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxx $0 [prev_version]" - exit 1 -fi - -if [ -z "$VERSION" ]; then - echo "Error: version is required" - echo "Usage: $0 [prev_version]" - exit 1 -fi - -# Generate changelog from git log -if [ -n "$PREV_VERSION" ]; then - RANGE="skillhub-cli/v${PREV_VERSION}..HEAD" -else - # If no prev version, use last 20 commits touching skillhub-cli/ - RANGE="HEAD~20..HEAD" -fi - -# Extract changelog lines, escape for JSON -CHANGELOG=$(git log "$RANGE" --oneline --no-merges -- skillhub-cli/ 2>/dev/null | head -20 | \ - sed 's/\\/\\\\/g; s/"/\\"/g; s/$/\\n/' | tr -d '\n' | sed 's/\\n$//') - -if [ -z "$CHANGELOG" ]; then - CHANGELOG="详见 npm 页面" -fi - -BODY=$(cat < 1.0.8 -# ./scripts/release-cli.sh minor # 1.0.7 -> 1.1.0 -# ./scripts/release-cli.sh patch 1.0.6 # with prev_version for changelog -set -euo pipefail - -BUMP="${1:-patch}" -PREV_VERSION="${2:-}" - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -CLI_DIR="$PROJECT_DIR/skillhub-cli" - -echo "=== skillhub-cli release ===" -echo "" - -# Step 1: Build -echo "[1/5] Building..." -cd "$CLI_DIR" -pnpm build -echo "Build OK" -echo "" - -# Step 2: Run tests -echo "[2/5] Running tests..." -if pnpm test; then - echo "Tests OK" -else - echo "Tests FAILED. Aborting release." - exit 1 -fi -echo "" - -# Step 3: Bump version -OLD_VERSION=$(node -p "require('./package.json').version") -npm version "$BUMP" --no-git-tag-version -m "chore(cli): release v%s" -NEW_VERSION=$(node -p "require('./package.json').version") -echo "[3/5] Version bumped: $OLD_VERSION -> $NEW_VERSION" -echo "" - -# Step 4: Publish -echo "[4/5] Publishing to npm..." -npm publish --access public -echo "Published motovis-skillhub@$NEW_VERSION" -echo "" - -# Step 5: Notify via Feishu -echo "[5/5] Sending Feishu notification..." -if [ -n "${FEISHU_WEBHOOK_URL:-}" ]; then - "$SCRIPT_DIR/notify-feishu.sh" "$NEW_VERSION" "$PREV_VERSION" -else - echo "FEISHU_WEBHOOK_URL not set, skipping notification" - echo "To send manually: FEISHU_WEBHOOK_URL=xxx $SCRIPT_DIR/notify-feishu.sh $NEW_VERSION $PREV_VERSION" -fi -echo "" - -echo "=== Release complete: v$NEW_VERSION ===" From 8815c1596c8a33988678d50cf30db764940cc20e Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:19:38 +0800 Subject: [PATCH 37/68] fix(cli): human-readable API error messages ApiError previously displayed raw JSON like: API error 403: {"code":403,"msg":"Access denied..."} Now extracts the msg field from the API response body and displays: Access denied to skill: find-skills Run `skillhub login` to authenticate. Changes: - Add extractHumanMessage() to pull msg/message/error from response - ApiError.message now shows human text instead of raw JSON - 401/403 responses append login hint - whoami: remove redundant 'Not authenticated:' prefix --- skillhub-cli/src/commands/whoami.ts | 2 +- skillhub-cli/src/core/api-client.ts | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/skillhub-cli/src/commands/whoami.ts b/skillhub-cli/src/commands/whoami.ts index dbb77ad28..936026089 100644 --- a/skillhub-cli/src/commands/whoami.ts +++ b/skillhub-cli/src/commands/whoami.ts @@ -23,7 +23,7 @@ export function registerWhoami(program: Command) { console.log(`Display Name: ${resp.user.displayName}`); } } catch (e: any) { - error(`Not authenticated: ${e.message}`); + error(e.message); process.exit(1); } }); diff --git a/skillhub-cli/src/core/api-client.ts b/skillhub-cli/src/core/api-client.ts index d87a01426..bf94edc95 100644 --- a/skillhub-cli/src/core/api-client.ts +++ b/skillhub-cli/src/core/api-client.ts @@ -121,11 +121,31 @@ export class ApiClient { } } +function extractHumanMessage(body: unknown): string | null { + if (typeof body !== "object" || body === null) return null; + + const b = body as Record; + + // Native API: { code, msg, data } — "msg" is authoritative + if (typeof b.msg === "string" && b.msg.length > 0) return b.msg; + if (typeof b.message === "string" && b.message.length > 0) return b.message; + if (typeof b.error === "string" && b.error.length > 0) return b.error; + + return null; +} + export class ApiError extends Error { constructor( public statusCode: number, public body: unknown, ) { - super(`API error ${statusCode}: ${JSON.stringify(body)}`); + const msg = extractHumanMessage(body); + let detail = msg ?? `HTTP ${statusCode}`; + + if (statusCode === 401 || statusCode === 403) { + detail += "\nRun `skillhub login` to authenticate."; + } + + super(detail); } } From d96ef86fac3b3684b0082ec44cfd5c147c238b0a Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:54:50 +0800 Subject: [PATCH 38/68] fix(cli): replace process.exit(1) with process.exitCode = 1 process.exit() forcefully kills the process, preventing libuv from cleanly closing HTTP connections (undici). On Windows this triggers an assertion failure: UV_HANDLE_CLOSING. Using process.exitCode = 1 lets Node.js exit gracefully after the event loop drains, eliminating the assertion error while still returning exit code 1. --- skillhub-cli/src/commands/archive.ts | 2 +- skillhub-cli/src/commands/delete.ts | 2 +- skillhub-cli/src/commands/download.ts | 6 +++--- skillhub-cli/src/commands/hide.ts | 4 ++-- skillhub-cli/src/commands/init.ts | 2 +- skillhub-cli/src/commands/inspect.ts | 4 ++-- skillhub-cli/src/commands/install.ts | 14 +++++++------- skillhub-cli/src/commands/login.ts | 2 +- skillhub-cli/src/commands/me.ts | 4 ++-- skillhub-cli/src/commands/namespaces.ts | 2 +- skillhub-cli/src/commands/notifications.ts | 6 +++--- skillhub-cli/src/commands/publish.ts | 8 ++++---- skillhub-cli/src/commands/rating.ts | 6 +++--- skillhub-cli/src/commands/report.ts | 2 +- skillhub-cli/src/commands/resolve.ts | 12 ++++++------ skillhub-cli/src/commands/reviews.ts | 2 +- skillhub-cli/src/commands/search.ts | 2 +- skillhub-cli/src/commands/star.ts | 2 +- skillhub-cli/src/commands/sync.ts | 4 ++-- skillhub-cli/src/commands/transfer.ts | 2 +- skillhub-cli/src/commands/update.ts | 4 ++-- skillhub-cli/src/commands/versions.ts | 4 ++-- skillhub-cli/src/commands/whoami.ts | 2 +- 23 files changed, 49 insertions(+), 49 deletions(-) diff --git a/skillhub-cli/src/commands/archive.ts b/skillhub-cli/src/commands/archive.ts index 6741a78d0..c720b962d 100644 --- a/skillhub-cli/src/commands/archive.ts +++ b/skillhub-cli/src/commands/archive.ts @@ -33,7 +33,7 @@ export function registerArchive(program: Command) { success(`Archived ${skillSlug}`); } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/delete.ts b/skillhub-cli/src/commands/delete.ts index a535ec290..94c534337 100644 --- a/skillhub-cli/src/commands/delete.ts +++ b/skillhub-cli/src/commands/delete.ts @@ -34,7 +34,7 @@ export function registerDelete(program: Command) { success(`Deleted ${skillSlug} from ${namespace}`); } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts index 54db14a69..d3d389ca3 100644 --- a/skillhub-cli/src/commands/download.ts +++ b/skillhub-cli/src/commands/download.ts @@ -45,7 +45,7 @@ export function registerDownload(program: Command) { const location = response.headers.location; if (!location) { spinner.fail(`Redirect response has no Location header`); - process.exit(1); + process.exitCode = 1; } response = await request(location as string, { method: "GET" }); } @@ -53,7 +53,7 @@ export function registerDownload(program: Command) { if (statusCode >= 400) { spinner.fail(`Download failed: HTTP ${statusCode}`); - process.exit(1); + process.exitCode = 1; } const outPath = resolve(outputDir, `${skillSlug}.zip`); @@ -63,7 +63,7 @@ export function registerDownload(program: Command) { spinner.succeed(`Downloaded ${skillSlug} to ${outPath}`); } catch (e: any) { error(`Download failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/hide.ts b/skillhub-cli/src/commands/hide.ts index d647e3f2f..dd1373b89 100644 --- a/skillhub-cli/src/commands/hide.ts +++ b/skillhub-cli/src/commands/hide.ts @@ -42,7 +42,7 @@ export function registerHide(program: Command) { success(`Hidden ${skillSlug}`); } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); @@ -82,7 +82,7 @@ export function registerHide(program: Command) { success(`Unhidden ${skillSlug}`); } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/init.ts b/skillhub-cli/src/commands/init.ts index 0ddafed08..25d9077ab 100644 --- a/skillhub-cli/src/commands/init.ts +++ b/skillhub-cli/src/commands/init.ts @@ -17,7 +17,7 @@ export function registerInit(program: Command) { const skillMd = join(dir, "SKILL.md"); if (existsSync(skillMd)) { error("SKILL.md already exists"); - process.exit(1); + process.exitCode = 1; } const slug = name || "my-skill"; diff --git a/skillhub-cli/src/commands/inspect.ts b/skillhub-cli/src/commands/inspect.ts index f68ca0eeb..399ab437a 100644 --- a/skillhub-cli/src/commands/inspect.ts +++ b/skillhub-cli/src/commands/inspect.ts @@ -94,7 +94,7 @@ export function registerInspect(program: Command) { if (!namespaces || namespaces.length === 0) { error("No namespaces found. You may need to log in."); - process.exit(1); + process.exitCode = 1; } const searchPromises = namespaces.map(async (ns) => { @@ -116,7 +116,7 @@ export function registerInspect(program: Command) { if (namespaces.length > 1) { dim(`Tried namespaces: ${namespaces.map((n) => n.slug).join(", ")}`); } - process.exit(1); + process.exitCode = 1; } if (isJson) { diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 7062e52bb..03a64a6db 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -233,7 +233,7 @@ export function registerInstall(program: Command) { } } catch (e: any) { spinner.fail(e.message); - process.exit(1); + process.exitCode = 1; } }); } @@ -277,7 +277,7 @@ async function installFromRegistry( if (uniqueResults.length === 0) { spinner.fail(`Skill not found: ${actualSlug}`); - process.exit(1); + process.exitCode = 1; } if (uniqueResults.length === 1) { @@ -373,7 +373,7 @@ async function installFromRegistry( if (!location) { spinner.fail(`Redirect response has no Location header`); await rm(tmpDir, { recursive: true, force: true }); - process.exit(1); + process.exitCode = 1; } response = await request(location as string, { method: "GET" }); } @@ -382,7 +382,7 @@ async function installFromRegistry( if (statusCode >= 400) { spinner.fail(`Skill not found: ${ns}/${actualSlug}`); await rm(tmpDir, { recursive: true, force: true }); - process.exit(1); + process.exitCode = 1; } const fileStream = createWriteStream(zipPath); @@ -398,7 +398,7 @@ async function installFromRegistry( const skills = discoverSkills(extractDir); if (skills.length === 0) { spinner.fail("No SKILL.md found in package"); - process.exit(1); + process.exitCode = 1; } spinner.succeed(`Found ${skills.length} skill(s) in ${ns}/${actualSlug}`); @@ -639,7 +639,7 @@ async function installFromGit( if (skills.length === 0) { spinner.fail("No skills found. Ensure the directory contains SKILL.md files."); - process.exit(1); + process.exitCode = 1; } spinner.succeed(`Found ${skills.length} skill(s)`); @@ -663,7 +663,7 @@ async function installFromGit( if (selectedSkills.length === 0) { error(`No matching skills for: ${skillNames.join(", ")}`); info("Available: " + skills.map((s) => s.name).join(", ")); - process.exit(1); + process.exitCode = 1; } } } else if (!opts.yes && skills.length > 1) { diff --git a/skillhub-cli/src/commands/login.ts b/skillhub-cli/src/commands/login.ts index 9b7d83762..a236b19f2 100644 --- a/skillhub-cli/src/commands/login.ts +++ b/skillhub-cli/src/commands/login.ts @@ -28,7 +28,7 @@ export function registerLogin(program: Command) { success(`Authenticated as ${resp.user.displayName} (@${resp.user.handle})`); } catch (e: any) { error(`Authentication failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/me.ts b/skillhub-cli/src/commands/me.ts index 015e5ed32..b1fce52d2 100644 --- a/skillhub-cli/src/commands/me.ts +++ b/skillhub-cli/src/commands/me.ts @@ -53,7 +53,7 @@ export function registerMe(program: Command) { } } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); @@ -83,7 +83,7 @@ export function registerMe(program: Command) { } } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/namespaces.ts b/skillhub-cli/src/commands/namespaces.ts index 942bdb10b..6607070bf 100644 --- a/skillhub-cli/src/commands/namespaces.ts +++ b/skillhub-cli/src/commands/namespaces.ts @@ -29,7 +29,7 @@ export function registerNamespaces(program: Command) { } } catch (e: any) { error(`Failed to list namespaces: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/notifications.ts b/skillhub-cli/src/commands/notifications.ts index fe341ee6e..7b02675b2 100644 --- a/skillhub-cli/src/commands/notifications.ts +++ b/skillhub-cli/src/commands/notifications.ts @@ -40,7 +40,7 @@ export function registerNotifications(program: Command) { } } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); @@ -56,7 +56,7 @@ export function registerNotifications(program: Command) { success(`Marked notification ${id} as read`); } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); @@ -72,7 +72,7 @@ export function registerNotifications(program: Command) { success("All notifications marked as read"); } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/publish.ts b/skillhub-cli/src/commands/publish.ts index 0ccd41f71..26e02aec9 100644 --- a/skillhub-cli/src/commands/publish.ts +++ b/skillhub-cli/src/commands/publish.ts @@ -25,7 +25,7 @@ export function registerPublish(program: Command) { const folderStat = await stat(folder).catch(() => null); if (!folderStat || !folderStat.isDirectory()) { error("Path must be a directory containing SKILL.md"); - process.exit(1); + process.exitCode = 1; } const slug = opts.slug || basename(folder); @@ -40,7 +40,7 @@ export function registerPublish(program: Command) { const isValidVersion = semver.valid(version) || /^\d{8}\.\d+$/.test(version); if (!isValidVersion) { error("--skill-version must be a valid semver (e.g. 1.0.0) or timestamp (e.g. 20260414.123045)"); - process.exit(1); + process.exitCode = 1; } const namespace = opts.namespace || "global"; @@ -58,7 +58,7 @@ export function registerPublish(program: Command) { const skillMdStat = await stat(skillMdPath).catch(() => null); if (!skillMdStat) { spinner.fail("SKILL.md not found in directory"); - process.exit(1); + process.exitCode = 1; } const skillMdContent = await readFile(skillMdPath, "utf-8"); @@ -92,7 +92,7 @@ export function registerPublish(program: Command) { } } catch (e: any) { error(`Publish failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/rating.ts b/skillhub-cli/src/commands/rating.ts index 92fcd1732..12c825761 100644 --- a/skillhub-cli/src/commands/rating.ts +++ b/skillhub-cli/src/commands/rating.ts @@ -32,7 +32,7 @@ export function registerRating(program: Command) { } } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } @@ -45,7 +45,7 @@ export function registerRate(program: Command) { const score = parseInt(scoreStr, 10); if (isNaN(score) || score < 1 || score > 5) { error("Score must be between 1 and 5"); - process.exit(1); + process.exitCode = 1; } try { @@ -65,7 +65,7 @@ export function registerRate(program: Command) { success(`Rated ${skillSlug}: ${"★".repeat(score)}${"☆".repeat(5 - score)}`); } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/report.ts b/skillhub-cli/src/commands/report.ts index f20c6d870..52da128d6 100644 --- a/skillhub-cli/src/commands/report.ts +++ b/skillhub-cli/src/commands/report.ts @@ -34,7 +34,7 @@ export function registerReport(program: Command) { success(`Report submitted for ${skillSlug}`); } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/resolve.ts b/skillhub-cli/src/commands/resolve.ts index 6d83ce2a4..3f14c9e57 100644 --- a/skillhub-cli/src/commands/resolve.ts +++ b/skillhub-cli/src/commands/resolve.ts @@ -70,7 +70,7 @@ export function registerResolve(program: Command) { } error(`Version ${specifiedVersion} not found for ${namespace}/${skillSlug}`); error(`Please check if the version number is correct.`); - process.exit(1); + process.exitCode = 1; } const results = await searchSkills(client, skillSlug, 50); @@ -86,7 +86,7 @@ export function registerResolve(program: Command) { if (uniqueResults.length === 0) { error(`Skill not found: ${skillSlug}`); - process.exit(1); + process.exitCode = 1; } const resolvePromises = uniqueResults.map(async (r) => ({ @@ -99,7 +99,7 @@ export function registerResolve(program: Command) { if (matches.length === 0) { error(`Version ${specifiedVersion} not found for ${skillSlug}`); error(`Please check if the version number is correct.`); - process.exit(1); + process.exitCode = 1; } if (matches.length === 1) { @@ -114,7 +114,7 @@ export function registerResolve(program: Command) { console.log(` ${m.namespace}/${m.name}`); } dim(`\nUse: resolve / --skill-version ${specifiedVersion}`); - process.exit(1); + process.exitCode = 1; } // Case 2: No version specified (original behavior) @@ -133,7 +133,7 @@ export function registerResolve(program: Command) { if (uniqueResults.length === 0) { error(`Skill not found: ${skillSlug}`); - process.exit(1); + process.exitCode = 1; } if (uniqueResults.length === 1) { @@ -163,7 +163,7 @@ export function registerResolve(program: Command) { printResolveResult(result); } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/reviews.ts b/skillhub-cli/src/commands/reviews.ts index 911777e0a..745c6113d 100644 --- a/skillhub-cli/src/commands/reviews.ts +++ b/skillhub-cli/src/commands/reviews.ts @@ -37,7 +37,7 @@ export function registerReviews(program: Command) { } } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/search.ts b/skillhub-cli/src/commands/search.ts index 70c96ddda..fb7a3cc59 100644 --- a/skillhub-cli/src/commands/search.ts +++ b/skillhub-cli/src/commands/search.ts @@ -48,7 +48,7 @@ export function registerSearch(program: Command) { } } catch (e: any) { error(`Search failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/star.ts b/skillhub-cli/src/commands/star.ts index 26651965a..2ff16ea4e 100644 --- a/skillhub-cli/src/commands/star.ts +++ b/skillhub-cli/src/commands/star.ts @@ -31,7 +31,7 @@ export function registerStar(program: Command) { } } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/sync.ts b/skillhub-cli/src/commands/sync.ts index 7f87bbb86..cfb13002b 100644 --- a/skillhub-cli/src/commands/sync.ts +++ b/skillhub-cli/src/commands/sync.ts @@ -31,7 +31,7 @@ export function registerSync(program: Command) { if (!existsSync(scanPath)) { error(`Directory not found: ${scanPath}`); - process.exit(1); + process.exitCode = 1; } try { @@ -139,7 +139,7 @@ export function registerSync(program: Command) { console.log(""); } catch (e: any) { error(`Sync failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/transfer.ts b/skillhub-cli/src/commands/transfer.ts index 92c983a9d..cf79e3473 100644 --- a/skillhub-cli/src/commands/transfer.ts +++ b/skillhub-cli/src/commands/transfer.ts @@ -32,7 +32,7 @@ export function registerTransfer(program: Command) { success(`Ownership of ${namespace} transferred to ${newOwnerId}`); } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } \ No newline at end of file diff --git a/skillhub-cli/src/commands/update.ts b/skillhub-cli/src/commands/update.ts index 78c5ca352..8fbbf3766 100644 --- a/skillhub-cli/src/commands/update.ts +++ b/skillhub-cli/src/commands/update.ts @@ -22,7 +22,7 @@ export function registerUpdate(program: Command) { if (!existsSync(lockPath)) { error("No skillhub.lock found. Have you installed any skills?"); - process.exit(1); + process.exitCode = 1; } const lockedSkills = await getAllLockedSkills(); @@ -30,7 +30,7 @@ export function registerUpdate(program: Command) { if (allSkillNames.length === 0) { error("No skills in lock file."); - process.exit(1); + process.exitCode = 1; } let skillsToUpdate: string[] = []; diff --git a/skillhub-cli/src/commands/versions.ts b/skillhub-cli/src/commands/versions.ts index 4177e2be0..87c79eabb 100644 --- a/skillhub-cli/src/commands/versions.ts +++ b/skillhub-cli/src/commands/versions.ts @@ -59,7 +59,7 @@ export function registerVersions(program: Command) { if (uniqueResults.length === 0) { error(`Skill not found: ${skillSlug}`); - process.exit(1); + process.exitCode = 1; } if (uniqueResults.length === 1) { @@ -95,7 +95,7 @@ export function registerVersions(program: Command) { } } catch (e: any) { error(`Failed: ${e.message}`); - process.exit(1); + process.exitCode = 1; } }); } diff --git a/skillhub-cli/src/commands/whoami.ts b/skillhub-cli/src/commands/whoami.ts index 936026089..43cf73d12 100644 --- a/skillhub-cli/src/commands/whoami.ts +++ b/skillhub-cli/src/commands/whoami.ts @@ -24,7 +24,7 @@ export function registerWhoami(program: Command) { } } catch (e: any) { error(e.message); - process.exit(1); + process.exitCode = 1; } }); } From d4ea4856624217172691252db8bfa4b8139ee83b Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:57:00 +0800 Subject: [PATCH 39/68] fix(cli): fix registry config priority for login and all commands - Remove hardcoded default from Commander --registry option that overrode config file settings in ALL commands - Login command now uses loadConfigFromProgram() like other commands instead of hardcoded http://localhost:8080 - Improved login error messages with registry URL context --- skillhub-cli/.npmignore | 1 + skillhub-cli/package.json | 2 +- skillhub-cli/src/cli.ts | 30 ++++-- skillhub-cli/src/commands/config.ts | 155 +++++++++++++++++++++++++++ skillhub-cli/src/commands/explore.ts | 19 +++- skillhub-cli/src/commands/login.ts | 14 ++- skillhub-cli/src/core/api-client.ts | 8 ++ 7 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 skillhub-cli/src/commands/config.ts diff --git a/skillhub-cli/.npmignore b/skillhub-cli/.npmignore index f8d3a0daf..fe3e5ff23 100644 --- a/skillhub-cli/.npmignore +++ b/skillhub-cli/.npmignore @@ -5,6 +5,7 @@ unbuild.config.ts tsconfig.json *.test.ts src/ +.agents/ .claude/ .omc/ AGENTS.md diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index 9d16fcdd6..ef491bf76 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.1.3", + "version": "1.2.0", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index 4e4c736fc..5367e3926 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -58,7 +58,6 @@ function buildTopLevelHelp(version: string): string { sections.push(formatSection("Discover", [ { cmd: "explore", desc: "Browse or search skills from the registry", alias: "find, find-skills, search" }, - { cmd: "search ", desc: dim("[Deprecated: use 'explore' instead]") }, ])); sections.push(""); @@ -108,12 +107,26 @@ function buildTopLevelHelp(version: string): string { ])); sections.push(""); + sections.push(formatSection("Configuration", [ + { cmd: "config list", desc: "Show current registry configuration" }, + { cmd: "config set ", desc: "Set configuration (e.g., registry URL)" }, + { cmd: "config get ", desc: "Get configuration value" }, + { cmd: "config show-env-instructions", desc: "Show environment variable setup guide" }, + ])); + sections.push(""); + sections.push(bold("Examples")); - sections.push(dim(" skillhub install vision2group/fork-workflow Install a skill")); - sections.push(dim(" skillhub explore Browse available skills")); - sections.push(dim(" skillhub publish Publish current directory")); - sections.push(dim(" skillhub me skills List your published skills")); - sections.push(dim(" skillhub update --global Update all global skills")); + sections.push(dim(" skillhub install vision2group/fork-workflow Install a skill from registry")); + sections.push(dim(" skillhub install find-skills --from https://... Install from GitHub or local path")); + sections.push(dim(" skillhub explore Interactive skill search")); + sections.push(dim(" skillhub explore --hot Browse popular skills")); + sections.push(dim(" skillhub config list Show current configuration")); + sections.push(dim(" skillhub --registry explore One-time registry override")); + sections.push(dim(" skillhub publish Publish current directory")); + sections.push(dim(" skillhub me skills List your published skills")); + sections.push(dim(" skillhub update Update installed skills")); + sections.push(""); + sections.push(dim("Run 'skillhub --help' for command-specific options.")); sections.push(""); sections.push(bold("Global Options")); @@ -133,7 +146,7 @@ export async function createCli(): Promise { .name("skillhub") .description("CLI for SkillHub — publish, search, and manage agent skills") .version(version) - .option("--registry ", "Registry API base URL", "http://localhost:8080") + .option("--registry ", "Registry API base URL") .option("--json", "Output results as JSON"); const customHelp = buildTopLevelHelp(version); @@ -171,6 +184,7 @@ export async function createCli(): Promise { { registerExplore }, { registerTransfer }, { registerHide }, + { registerConfig }, ] = await Promise.all([ import("./commands/login.js"), import("./commands/logout.js"), @@ -199,6 +213,7 @@ export async function createCli(): Promise { import("./commands/explore.js"), import("./commands/transfer.js"), import("./commands/hide.js"), + import("./commands/config.js"), ]); registerLogin(program); @@ -229,6 +244,7 @@ export async function createCli(): Promise { registerExplore(program); registerTransfer(program); registerHide(program); + registerConfig(program); return program; } diff --git a/skillhub-cli/src/commands/config.ts b/skillhub-cli/src/commands/config.ts new file mode 100644 index 000000000..e518a707c --- /dev/null +++ b/skillhub-cli/src/commands/config.ts @@ -0,0 +1,155 @@ +import { Command } from "commander"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { success, error, info, dim } from "../utils/logger.js"; + +const CONFIG_DIR = join(homedir(), ".skillhub"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`; +const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; +const green = (s: string) => `\x1b[32m${s}\x1b[0m`; + +export function registerConfig(program: Command) { + const configCmd = program + .command("config") + .description("Manage SkillHub CLI configuration") + .addHelpCommand(false); + + configCmd + .command("list") + .description("List current configuration") + .action(() => { + const env = process.env.SKILLHUB_REGISTRY; + let fileConfig: { registry?: string } = {}; + if (existsSync(CONFIG_FILE)) { + try { + fileConfig = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + } catch { + // Invalid config file, ignore + } + } + + info("Current configuration:\n"); + + info(` ${cyan("Environment")}`); + info(` SKILLHUB_REGISTRY: ${env || dim("not set")}`); + + info(`\n ${cyan("Config file")}`); + info(` ~/.skillhub/config.json: ${fileConfig.registry || dim("not set")}`); + + info(`\n ${cyan("Default")}`); + info(` http://localhost:8080\n`); + + const active = env || fileConfig.registry || "http://localhost:8080"; + const source = env + ? green("environment variable") + : fileConfig.registry + ? yellow("config file") + : dim("default"); + + success(`Active registry: ${active}`); + info(`Source: ${source}`); + }); + + configCmd + .command("set ") + .description("Set a configuration value (stored in ~/.skillhub/config.json)") + .action((key: string, value: string) => { + if (key === "registry") { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + + let config: Record = {}; + if (existsSync(CONFIG_FILE)) { + try { + config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + } catch { + // Invalid config file, start fresh + } + } + + config.registry = value; + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + success(`Registry set to: ${value}`); + info(`Config file: ${CONFIG_FILE}`); + info(`\n💡 You can also use environment variable for current session:`); + info(` ` + cyan(`export SKILLHUB_REGISTRY="${value}"`)); + info(`\n💡 Or use --registry flag for one-time override:`); + info(` ` + cyan(`skillhub --registry ${value} `)); + } else { + error(`Unknown config key: ${key}. Supported keys: registry`); + process.exitCode = 1; + } + }); + + configCmd + .command("get ") + .description("Get a configuration value") + .action((key: string) => { + if (key === "registry") { + const value = process.env.SKILLHUB_REGISTRY || + (existsSync(CONFIG_FILE) ? (() => { + try { + return JSON.parse(readFileSync(CONFIG_FILE, "utf-8")).registry; + } catch { + return null; + } + })() : null) || + "http://localhost:8080"; + success(value); + } else { + error(`Unknown config key: ${key}. Supported keys: registry`); + process.exitCode = 1; + } + }); + + configCmd + .command("show-env-instructions") + .description("Show how to set SKILLHUB_REGISTRY environment variable") + .action(() => { + info(`${yellow("Environment variable setup for SKILLHUB_REGISTRY:\n")}`); + + info(`${cyan("🔹 Temporary (current session only):")}\n`); + + info(` ${green("Linux/macOS:")}`); + info(` ${cyan(`export SKILLHUB_REGISTRY="http://:"`)}`); + info(` ${dim("# Example: export SKILLHUB_REGISTRY=\"http://192.168.1.100:8080\"")}\n`); + + info(` ${green("Windows CMD:")}`); + info(` ${cyan(`set SKILLHUB_REGISTRY=http://:`)}`); + info(` ${dim("# Example: set SKILLHUB_REGISTRY=http://192.168.1.100:8080")}\n`); + + info(` ${green("Windows PowerShell:")}`); + info(` ${cyan(`$env:SKILLHUB_REGISTRY="http://:"`)}`); + info(` ${dim("# Example: $env:SKILLHUB_REGISTRY='http://192.168.1.100:8080'")}\n`); + + info(`${cyan("🔹 Permanent (survives terminal restart):")}\n`); + + info(` ${green("Linux/macOS (~/.bashrc or ~/.zshrc):")}`); + info(` ${cyan(`echo 'export SKILLHUB_REGISTRY="http://:"' >> ~/.bashrc`)}`); + info(` ${cyan(`source ~/.bashrc`)}`); + info(` ${dim("# Add to ~/.bashrc for bash, ~/.zshrc for zsh")}\n`); + + info(` ${green("Windows (User environment variable):")}`); + info(` ${cyan(`setx SKILLHUB_REGISTRY "http://:"`)}`); + info(` ${dim("# Restart terminal after running this command")}\n`); + + info(` ${green("PowerShell (User profile):")}`); + info(` ${cyan(`[System.Environment]::SetEnvironmentVariable('SKILLHUB_REGISTRY', 'http://:', 'User')`)}`); + info(` ${dim("# Restart PowerShell after running this command")}\n`); + + info(`${cyan("📋 Configuration priority (highest to lowest):")}`); + info(` 1. ${green("--registry flag")} (one-time, per command)`); + info(` 2. ${green("SKILLHUB_REGISTRY")} (environment variable)`); + info(` 3. ${green("~/.skillhub/config.json")} (config file)`); + info(` 4. ${dim("http://localhost:8080")} (default)\n`); + + info(`${cyan("💡 Quick examples:")}`); + info(` skillhub config set registry http://192.168.1.100:8080`); + info(` skillhub --registry http://192.168.1.100:8080 explore`); + info(` skillhub config list\n`); + }); +} diff --git a/skillhub-cli/src/commands/explore.ts b/skillhub-cli/src/commands/explore.ts index cd60d57f6..b9e44d84b 100644 --- a/skillhub-cli/src/commands/explore.ts +++ b/skillhub-cli/src/commands/explore.ts @@ -221,15 +221,28 @@ export function registerExplore(program: Command) { .argument("[query]", "Search query for finding skills") .option("-n, --limit ", "Max results", "20") .option("-s, --sort ", "Sort by: hot, newest, downloads (default: interactive mode)") - .action(async (query: string | undefined, opts: { limit: string; sort?: string }) => { + .option("--hot", "Sort by popularity (shorthand for --sort hot)") + .option("--newest", "Sort by newest first (shorthand for --sort newest)") + .option("--downloads", "Sort by download count (shorthand for --sort downloads)") + .action(async (query: string | undefined, opts: { limit: string; sort?: string; hot?: boolean; newest?: boolean; downloads?: boolean }) => { const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); const sortMap: Record = { hot: "rating", newest: "newest", downloads: "downloads" }; - const apiSort = sortMap[opts.sort || "newest"] || "newest"; + + // Resolve sort priority: explicit --sort > shorthand flags > default + let effectiveSort = opts.sort; + if (!effectiveSort) { + if (opts.hot) effectiveSort = "hot"; + else if (opts.newest) effectiveSort = "newest"; + else if (opts.downloads) effectiveSort = "downloads"; + } + const apiSort = sortMap[effectiveSort || "newest"] || "newest"; try { - if (!query && !opts.sort) { + // Enter interactive mode only if no query AND no sort option (explicit or shorthand) + const hasSortOption = opts.sort || opts.hot || opts.newest || opts.downloads; + if (!query && !hasSortOption) { const selected = await runInteractiveSearch(client, "", apiSort); if (!selected) { console.log("\nCancelled."); diff --git a/skillhub-cli/src/commands/login.ts b/skillhub-cli/src/commands/login.ts index a236b19f2..4e72f621c 100644 --- a/skillhub-cli/src/commands/login.ts +++ b/skillhub-cli/src/commands/login.ts @@ -4,6 +4,7 @@ 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 { loadConfigFromProgram } from "../core/config.js"; import { success, error, info } from "../utils/logger.js"; export function registerLogin(program: Command) { @@ -19,15 +20,22 @@ export function registerLogin(program: Command) { 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 }); + const config = loadConfigFromProgram(program); + const registry = opts.registry || config.registry; try { + const client = new ApiClient({ baseUrl: registry, token }); 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}`); + const detail = e.message || e.code || e.constructor?.name || String(e); + error(`Authentication failed: ${detail}`); + if (e.statusCode) { + info(`Registry: ${registry} (HTTP ${e.statusCode})`); + } else { + info(`Registry: ${registry} — connection or network error`); + } process.exitCode = 1; } }); diff --git a/skillhub-cli/src/core/api-client.ts b/skillhub-cli/src/core/api-client.ts index bf94edc95..cd5850a8b 100644 --- a/skillhub-cli/src/core/api-client.ts +++ b/skillhub-cli/src/core/api-client.ts @@ -146,6 +146,14 @@ export class ApiError extends Error { detail += "\nRun `skillhub login` to authenticate."; } + // Enhanced error messages for connection issues + if (statusCode === 0 || detail.includes("ECONNREFUSED") || detail.includes("ENOTFOUND")) { + detail += "\n\n💡 Connection failed. Check your registry configuration:\n"; + detail += " - Run 'skillhub config list' to see current configuration\n"; + detail += " - Run 'skillhub config show-env-instructions' for setup guide\n"; + detail += " - Or use: skillhub --registry "; + } + super(detail); } } From acea44162576c0194d92aa3de0a5ec8a3dfd46cc Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:40:02 +0800 Subject: [PATCH 40/68] feat(cli): enhance inspect/install commands and reorganize help - Add --details option to inspect for version history and tags - Normalize version input (strip 'v' prefix) in install command - Skip version selection when --skill-version or --tag is specified - Reorganize help: merge Discover & Info sections - Move SkillVersionItem/VersionsResponse types to schema/routes.ts - Remove versions command registration from CLI (file retained for now) - Fix error handling to return after setting exit code - Update tests to reflect command changes --- skillhub-cli/package.json | 2 +- skillhub-cli/src/cli.ts | 24 ++---- skillhub-cli/src/commands/inspect.ts | 117 +++++++++++++++++++++++--- skillhub-cli/src/commands/install.ts | 80 ++++++++++++++++-- skillhub-cli/src/commands/versions.ts | 38 ++++++++- skillhub-cli/src/schema/routes.ts | 18 ++++ skillhub-cli/tests/commands.test.ts | 7 -- 7 files changed, 242 insertions(+), 44 deletions(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index ef491bf76..c60f4c33c 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.2.0", + "version": "1.2.3", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index 5367e3926..82a4bb53e 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -56,13 +56,19 @@ function buildTopLevelHelp(version: string): string { ])); sections.push(""); - sections.push(formatSection("Discover", [ + sections.push(formatSection("Discover & Info", [ { cmd: "explore", desc: "Browse or search skills from the registry", alias: "find, find-skills, search" }, + { cmd: "inspect ", desc: "View skill metadata and versions", alias: "info, view" }, + { cmd: "resolve ", desc: "Resolve the latest version of a skill" }, + { cmd: "rating ", desc: "View your rating for a skill" }, + { cmd: "rate ", desc: "Rate a skill (1-5)" }, + { cmd: "star ", desc: "Star a skill" }, + { cmd: "report ", desc: "Report a skill for review" }, ])); sections.push(""); sections.push(formatSection("Install & Manage", [ - { cmd: "install ", desc: "Install from registry, git, or local path", alias: "i" }, + { cmd: "install ", desc: "Install from registry, git, or local path", alias: "i" }, { cmd: "download ", desc: "Download a skill package to local directory" }, { cmd: "update [slug]", desc: "Update installed skills from their source", alias: "up" }, { cmd: "uninstall [name]", desc: "Uninstall a skill from local agent", alias: "un" }, @@ -77,17 +83,6 @@ function buildTopLevelHelp(version: string): string { { cmd: "sync [path]", desc: "Scan and publish all skills from a directory" }, { cmd: "delete ", desc: "Delete a skill you own", alias: "del, unpublish" }, { cmd: "archive ", desc: "Archive a skill you own" }, - { cmd: "versions ", desc: "List skill versions" }, - ])); - sections.push(""); - - sections.push(formatSection("Info & Review", [ - { cmd: "inspect ", desc: "View skill metadata without installing", alias: "info, view" }, - { cmd: "resolve ", desc: "Resolve the latest version of a skill" }, - { cmd: "rating ", desc: "View your rating for a skill" }, - { cmd: "rate ", desc: "Rate a skill (1-5)" }, - { cmd: "star ", desc: "Star a skill" }, - { cmd: "report ", desc: "Report a skill for review" }, ])); sections.push(""); @@ -171,7 +166,6 @@ export async function createCli(): Promise { { registerReviews }, { registerNotifications }, { registerDelete }, - { registerVersions }, { registerReport }, { registerResolve }, { registerRating, registerRate }, @@ -200,7 +194,6 @@ export async function createCli(): Promise { 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"), @@ -230,7 +223,6 @@ export async function createCli(): Promise { registerReviews(program); registerNotifications(program); registerDelete(program); - registerVersions(program); registerReport(program); registerResolve(program); registerRating(program); diff --git a/skillhub-cli/src/commands/inspect.ts b/skillhub-cli/src/commands/inspect.ts index 399ab437a..3037658ea 100644 --- a/skillhub-cli/src/commands/inspect.ts +++ b/skillhub-cli/src/commands/inspect.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { ApiClient } from "../core/api-client.js"; import { ApiRoutes } from "../schema/routes.js"; -import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { loadConfigFromProgram } 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"; @@ -28,7 +28,38 @@ interface NamespaceInfo { status: string; } -function printSkillDetail(detail: SkillDetailResponse) { +interface SkillVersionItem { + id: number; + version: string; + status: string; + changelog: string | null; + fileCount: number; + totalSize: number; + publishedAt: string; + downloadAvailable: boolean; +} + +interface VersionsResponse { + items: SkillVersionItem[]; + total: number; + page: number; + size: number; +} + +interface SkillTag { + id: number; + tagName: string; + versionId: number; + createdAt: string; +} + +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`; +} + +function printSkillDetail(detail: SkillDetailResponse, versions?: SkillVersionItem[], tags?: SkillTag[]) { console.log(""); info(`${detail.displayName} (${detail.slug})`); dim(`Namespace: ${detail.namespace}`); @@ -40,10 +71,29 @@ function printSkillDetail(detail: SkillDetailResponse) { if (detail.labels && detail.labels.length > 0) { dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`); } + + if (versions && versions.length > 0) { + console.log(""); + info("Versions:"); + const versionTagsMap = new Map(); + if (tags) { + for (const tag of tags) { + if (!versionTagsMap.has(tag.versionId)) { + versionTagsMap.set(tag.versionId, []); + } + versionTagsMap.get(tag.versionId)!.push(tag.tagName); + } + } + for (const v of versions) { + const tagStr = versionTagsMap.get(v.id)?.join(", ") || ""; + dim(` v${v.version} ${v.status} · ${v.fileCount} files · ${formatBytes(v.totalSize)} · ${v.publishedAt}${tagStr ? " · tags: " + tagStr : ""}`); + } + } + console.log(""); } -function printInspectHeader(detail: SkillDetailResponse) { +function printInspectHeader(detail: SkillDetailResponse, versions?: SkillVersionItem[], tags?: SkillTag[]) { console.log(""); info(`=== ${detail.displayName} ===`); dim(`Namespace: ${detail.namespace}`); @@ -60,6 +110,25 @@ function printInspectHeader(detail: SkillDetailResponse) { console.log(""); dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`); } + + if (versions && versions.length > 0) { + console.log(""); + info("Versions:"); + const versionTagsMap = new Map(); + if (tags) { + for (const tag of tags) { + if (!versionTagsMap.has(tag.versionId)) { + versionTagsMap.set(tag.versionId, []); + } + versionTagsMap.get(tag.versionId)!.push(tag.tagName); + } + } + for (const v of versions) { + const tagStr = versionTagsMap.get(v.id)?.join(", ") || ""; + dim(` v${v.version} ${v.status} · ${v.fileCount} files · ${formatBytes(v.totalSize)} · ${v.publishedAt}${tagStr ? " · tags: " + tagStr : ""}`); + } + } + console.log(""); } @@ -69,7 +138,8 @@ export function registerInspect(program: Command) { .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 }) => { + .option("--details", "Show all versions with tags") + .action(async (slug: string, opts: { namespace?: string; details?: boolean }) => { const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); @@ -78,14 +148,29 @@ export function registerInspect(program: Command) { const { namespace: defaultNs, slug: parsedSlug } = parseSkillName(slug, ""); const targetNamespace = opts.namespace || defaultNs; + async function fetchVersionsAndTags(ns: string, skillSlug: string) { + if (!opts.details) return { versions: undefined, tags: undefined }; + try { + const [versionsResp, tagsResp] = await Promise.all([ + client.get(`/api/v1/skills/${ns}/${skillSlug}/versions`), + client.get(`/api/v1/skills/${ns}/${skillSlug}/tags`).catch(() => [] as SkillTag[]), + ]); + return { versions: versionsResp.items || [], tags: tagsResp || [] }; + } catch { + return { versions: undefined, tags: undefined }; + } + } + if (targetNamespace) { const detail = await client.get( `${ApiRoutes.skillDetail.replace("{namespace}", targetNamespace).replace("{slug}", parsedSlug)}` ); + const { versions, tags } = await fetchVersionsAndTags(targetNamespace, parsedSlug); if (isJson) { - console.log(JSON.stringify(detail, null, 2)); + const output = opts.versions ? { ...detail, versions, tags } : detail; + console.log(JSON.stringify(output, null, 2)); } else { - printSkillDetail(detail); + printSkillDetail(detail, versions, tags); } return; } @@ -95,6 +180,7 @@ export function registerInspect(program: Command) { if (!namespaces || namespaces.length === 0) { error("No namespaces found. You may need to log in."); process.exitCode = 1; + return; } const searchPromises = namespaces.map(async (ns) => { @@ -117,19 +203,30 @@ export function registerInspect(program: Command) { dim(`Tried namespaces: ${namespaces.map((n) => n.slug).join(", ")}`); } process.exitCode = 1; + return; } if (isJson) { if (matches.length === 1) { - console.log(JSON.stringify(matches[0], null, 2)); + const { versions, tags } = await fetchVersionsAndTags(matches[0].namespace, matches[0].slug); + const output = opts.details ? { ...matches[0], versions, tags } : matches[0]; + console.log(JSON.stringify(output, null, 2)); } else { - console.log(JSON.stringify(matches, null, 2)); + const outputs = await Promise.all( + matches.map(async (m) => { + const { versions, tags } = await fetchVersionsAndTags(m.namespace, m.slug); + return opts.details ? { ...m, versions, tags } : m; + }) + ); + console.log(JSON.stringify(outputs, null, 2)); } } else if (matches.length === 1) { - printSkillDetail(matches[0]); + const { versions, tags } = await fetchVersionsAndTags(matches[0].namespace, matches[0].slug); + printSkillDetail(matches[0], versions, tags); } else { for (const detail of matches) { - printInspectHeader(detail); + const { versions, tags } = await fetchVersionsAndTags(detail.namespace, detail.slug); + printInspectHeader(detail, versions, tags); } } }); diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 03a64a6db..bb48ae2c4 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -12,11 +12,12 @@ import { getAllAgents, detectInstalledAgents, isUniversalForScope, getAgentTarge 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 chalk from "chalk"; import unzipper from "unzipper"; 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"; +import type { SkillVersionItem } from "../schema/routes.js"; interface SkillTag { id: number; @@ -196,9 +197,56 @@ function buildAgentSummary(targetAgents: AgentInfo[], mode: "symlink" | "copy", return lines; } +function buildInstallHelp(cmd: Command): string { + const lines: string[] = []; + + lines.push(`${chalk.bold("Usage:")} skillhub install|i [options] ${chalk.cyan("")}`); + lines.push(""); + lines.push("Install skills from registry, git repositories, or local paths"); + lines.push(""); + + lines.push(chalk.bold("Arguments:")); + lines.push(` ${chalk.cyan("skill-name")} Skill name or namespace/skill-name from registry`); + lines.push(""); + + lines.push(chalk.bold("Source Options:")); + lines.push(` ${chalk.cyan("-a, --add ")} Install from GitHub or local path (alias for --from)`); + lines.push(` ${chalk.cyan("--from ")} Install from GitHub or local path (alias for -a)`); + lines.push(""); + + lines.push(chalk.bold("Target Options:")); + lines.push(` ${chalk.cyan("--agent ")} Target specific agents`); + lines.push(` ${chalk.cyan("-g, --global")} Install to global scope`); + lines.push(""); + + lines.push(chalk.bold("Version Options:")); + lines.push(` ${chalk.cyan("-v, --skill-version ")} Install specific version (non-interactive)`); + lines.push(` ${chalk.cyan("--tag ")} Install specific tag (non-interactive, resolves to version)`); + lines.push(""); + + lines.push(chalk.bold("Mode Options:")); + lines.push(` ${chalk.cyan("--copy")} Copy instead of symlink`); + lines.push(` ${chalk.cyan("--list")} List available skills without installing`); + lines.push(""); + + lines.push(chalk.bold("Other Options:")); + lines.push(` ${chalk.cyan("-y, --yes")} Skip all prompts`); + lines.push(` ${chalk.cyan("-h, --help")} Display help for command`); + lines.push(""); + + lines.push(chalk.bold("Examples:")); + lines.push(chalk.dim(" skillhub install vision2group/fork-workflow Install a skill from registry")); + lines.push(chalk.dim(" skillhub install my-skill --from ./local/path Install from local directory")); + lines.push(chalk.dim(" skillhub install my-skill --from github.com/user/repo Install from GitHub")); + lines.push(chalk.dim(" skillhub install my-skill -g --yes Install globally, skip prompts")); + lines.push(chalk.dim(" skillhub install my-skill --tag v1.0.0 Install specific tag")); + + return lines.join("\n"); +} + export function registerInstall(program: Command) { - program - .command("install ") + const installCmd = 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)") @@ -210,7 +258,14 @@ export function registerInstall(program: Command) { .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) => { + .configureHelp({ showGlobalOptions: true }); + + const originalHelp = installCmd.helpInformation.bind(installCmd); + installCmd.helpInformation = () => { + return buildInstallHelp(installCmd); + }; + + installCmd.action(async (source: string, opts: Record) => { const fromSource = (opts.from || opts.add) as string | undefined; let effectiveSource: SourceType; @@ -316,10 +371,18 @@ async function installFromRegistry( // Present version selection let selectedVersion: string = "latest"; - if (opts.yes && opts.skillVersion) { - selectedVersion = String(opts.skillVersion); - } else if (opts.yes && opts.tag) { - // Non-interactive: resolve tag to version + if (opts.skillVersion) { + selectedVersion = String(opts.skillVersion).replace(/^v/, ""); + const versionExists = versions.some((v) => v.version === selectedVersion); + if (!versionExists) { + spinner.fail(`Version not found: ${opts.skillVersion}`); + if (versions.length > 0) { + info(`Available versions: ${versions.map((v) => v.version).join(", ")}`); + } + process.exitCode = 1; + return; + } + } else if (opts.tag) { for (const [vid, tags] of versionTagsMap) { if (tags.includes(opts.tag as string)) { const v = versions.find((ver) => ver.id === vid); @@ -383,6 +446,7 @@ async function installFromRegistry( spinner.fail(`Skill not found: ${ns}/${actualSlug}`); await rm(tmpDir, { recursive: true, force: true }); process.exitCode = 1; + return; } const fileStream = createWriteStream(zipPath); diff --git a/skillhub-cli/src/commands/versions.ts b/skillhub-cli/src/commands/versions.ts index 87c79eabb..3c74df731 100644 --- a/skillhub-cli/src/commands/versions.ts +++ b/skillhub-cli/src/commands/versions.ts @@ -30,11 +30,27 @@ function formatBytes(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } +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 }; +} + export function registerVersions(program: Command) { program .command("versions ") .description("List skill versions") - .action(async (slug: string) => { + .option("--detail", "Show additional skill metadata (stars, downloads, summary)") + .action(async (slug: string, opts: { detail?: boolean }) => { try { const { namespace, slug: skillSlug } = parseSkillName(slug); const config = loadConfigFromProgram(program); @@ -86,7 +102,25 @@ export function registerVersions(program: Command) { return; } - if (targetNamespace !== "global") { + if (opts.detail) { + try { + const detail = await client.get( + `/api/v1/skills/${targetNamespace}/${targetSlug}` + ); + console.log(""); + info(`${detail.displayName} (${detail.slug})`); + dim(`Namespace: ${detail.namespace}`); + dim(`Author: ${detail.ownerDisplayName}`); + dim(`Stars: ${detail.starCount} Downloads: ${detail.downloadCount}`); + if (detail.summary) console.log(`\n${detail.summary}`); + if (detail.labels && detail.labels.length > 0) { + dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`); + } + console.log(""); + } catch {} + } + + if (targetNamespace !== "global" && !opts.detail) { success(`${targetNamespace}/${targetSlug}`); } for (const v of versions) { diff --git a/skillhub-cli/src/schema/routes.ts b/skillhub-cli/src/schema/routes.ts index 662242f7c..d0411f71e 100644 --- a/skillhub-cli/src/schema/routes.ts +++ b/skillhub-cli/src/schema/routes.ts @@ -65,3 +65,21 @@ export interface SkillsListResponse { }>; nextCursor: string | null; } + +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; +} diff --git a/skillhub-cli/tests/commands.test.ts b/skillhub-cli/tests/commands.test.ts index b33b9a7fb..6b90d9dc6 100644 --- a/skillhub-cli/tests/commands.test.ts +++ b/skillhub-cli/tests/commands.test.ts @@ -5,7 +5,6 @@ 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"; @@ -70,12 +69,6 @@ describe("Command registrations", () => { 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); From a1fb6d261e5ca5d0ec477d3abb8a905115ca5e83 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:45:16 +0800 Subject: [PATCH 41/68] feat(cli): simplify config command and enhance inspect/install - Simplify config command: remove parameter, hardcode registry - config set now directly sets registry URL - config get shows resolved value with source information - Add --source option to config get for env/file/resolved - Add version normalization (strip 'v' prefix) in install - Skip version selection when --skill-version or --tag specified - Enhance inspect with --details option for version history - Reorganize help: Configuration at top, Discover & Info merged - Move SkillVersionItem types to schema/routes.ts - Fix error handling to return after setting exit code - Remove versions command registration (file retained for compatibility) --- skillhub-cli/package.json | 2 +- skillhub-cli/src/cli.ts | 16 ++--- skillhub-cli/src/commands/config.ts | 95 ++++++++++++++++++----------- 3 files changed, 68 insertions(+), 45 deletions(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index c60f4c33c..ea18ca605 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.2.3", + "version": "1.2.4", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index 82a4bb53e..be353c43f 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -49,6 +49,14 @@ function buildTopLevelHelp(version: string): string { sections.push(dim("CLI for SkillHub — publish, search, and manage agent skills")); sections.push(""); + sections.push(formatSection("Configuration", [ + { cmd: "config list", desc: "Show current registry configuration" }, + { cmd: "config set ", desc: "Set configuration (e.g., registry URL)" }, + { cmd: "config get ", desc: "Get configuration value" }, + { cmd: "config show-env-instructions", desc: "Show environment variable setup guide" }, + ])); + sections.push(""); + sections.push(formatSection("Auth", [ { cmd: "login", desc: "Authenticate with SkillHub registry" }, { cmd: "logout", desc: "Remove stored authentication token" }, @@ -102,14 +110,6 @@ function buildTopLevelHelp(version: string): string { ])); sections.push(""); - sections.push(formatSection("Configuration", [ - { cmd: "config list", desc: "Show current registry configuration" }, - { cmd: "config set ", desc: "Set configuration (e.g., registry URL)" }, - { cmd: "config get ", desc: "Get configuration value" }, - { cmd: "config show-env-instructions", desc: "Show environment variable setup guide" }, - ])); - sections.push(""); - sections.push(bold("Examples")); sections.push(dim(" skillhub install vision2group/fork-workflow Install a skill from registry")); sections.push(dim(" skillhub install find-skills --from https://... Install from GitHub or local path")); diff --git a/skillhub-cli/src/commands/config.ts b/skillhub-cli/src/commands/config.ts index e518a707c..4a6eff63e 100644 --- a/skillhub-cli/src/commands/config.ts +++ b/skillhub-cli/src/commands/config.ts @@ -19,7 +19,7 @@ export function registerConfig(program: Command) { configCmd .command("list") - .description("List current configuration") + .description("List all configuration sources and their values") .action(() => { const env = process.env.SKILLHUB_REGISTRY; let fileConfig: { registry?: string } = {}; @@ -54,54 +54,77 @@ export function registerConfig(program: Command) { }); configCmd - .command("set ") - .description("Set a configuration value (stored in ~/.skillhub/config.json)") - .action((key: string, value: string) => { - if (key === "registry") { - if (!existsSync(CONFIG_DIR)) { - mkdirSync(CONFIG_DIR, { recursive: true }); - } + .command("set ") + .description("Set registry URL in ~/.skillhub/config.json") + .action((value: string) => { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } - let config: Record = {}; - if (existsSync(CONFIG_FILE)) { - try { - config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); - } catch { - // Invalid config file, start fresh - } + let config: Record = {}; + if (existsSync(CONFIG_FILE)) { + try { + config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + } catch { + // Invalid config file, start fresh } - - config.registry = value; - writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); - success(`Registry set to: ${value}`); - info(`Config file: ${CONFIG_FILE}`); - info(`\n💡 You can also use environment variable for current session:`); - info(` ` + cyan(`export SKILLHUB_REGISTRY="${value}"`)); - info(`\n💡 Or use --registry flag for one-time override:`); - info(` ` + cyan(`skillhub --registry ${value} `)); - } else { - error(`Unknown config key: ${key}. Supported keys: registry`); - process.exitCode = 1; } + + config.registry = value; + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + success(`Registry set to: ${value}`); + info(`Config file: ${CONFIG_FILE}`); }); configCmd - .command("get ") - .description("Get a configuration value") - .action((key: string) => { - if (key === "registry") { - const value = process.env.SKILLHUB_REGISTRY || - (existsSync(CONFIG_FILE) ? (() => { + .command("get") + .description("Get registry configuration value") + .option("--source ", "Source: env, file, or resolved (default)") + .action((opts: { source?: string }) => { + const source = opts.source || "resolved"; + const envValue = process.env.SKILLHUB_REGISTRY; + const fileValue = existsSync(CONFIG_FILE) + ? (() => { try { return JSON.parse(readFileSync(CONFIG_FILE, "utf-8")).registry; } catch { return null; } - })() : null) || - "http://localhost:8080"; + })() + : null; + + if (source === "env") { + if (envValue) { + success(envValue); + dim("Source: environment variable"); + } else { + dim("Environment variable SKILLHUB_REGISTRY is not set"); + process.exitCode = 1; + } + } else if (source === "file") { + if (fileValue) { + success(fileValue); + dim("Source: config file"); + } else { + dim("Config file does not have registry set"); + process.exitCode = 1; + } + } else if (source === "resolved") { + const defaultValue = "http://localhost:8080"; + const value = envValue || fileValue || defaultValue; + const actualSource = envValue + ? "environment variable" + : fileValue + ? "config file" + : "default"; + success(value); + dim(`Source: ${actualSource}`); + if (!envValue) { + dim(`To override with env var: export SKILLHUB_REGISTRY="${value}"`); + } } else { - error(`Unknown config key: ${key}. Supported keys: registry`); + error(`Unknown source: ${source}. Supported sources: env, file, resolved`); process.exitCode = 1; } }); From 33491820723e56c3c7f79a49afad395830ec7244 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:11:31 +0800 Subject: [PATCH 42/68] fix(cli): fix show-env-instructions output format - Replace dim() function calls with chalk.dim() to return strings - Use array join approach for clean output formatting - Fix undefined output in environment variable instructions --- skillhub-cli/package.json | 2 +- skillhub-cli/src/commands/config.ts | 99 +++++++++++++++++------------ 2 files changed, 58 insertions(+), 43 deletions(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index ea18ca605..eb0a95440 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.2.4", + "version": "1.2.6", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { diff --git a/skillhub-cli/src/commands/config.ts b/skillhub-cli/src/commands/config.ts index 4a6eff63e..f620adfef 100644 --- a/skillhub-cli/src/commands/config.ts +++ b/skillhub-cli/src/commands/config.ts @@ -2,7 +2,8 @@ import { Command } from "commander"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { success, error, info, dim } from "../utils/logger.js"; +import { success, error, info } from "../utils/logger.js"; +import chalk from "chalk"; const CONFIG_DIR = join(homedir(), ".skillhub"); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); @@ -133,46 +134,60 @@ export function registerConfig(program: Command) { .command("show-env-instructions") .description("Show how to set SKILLHUB_REGISTRY environment variable") .action(() => { - info(`${yellow("Environment variable setup for SKILLHUB_REGISTRY:\n")}`); - - info(`${cyan("🔹 Temporary (current session only):")}\n`); - - info(` ${green("Linux/macOS:")}`); - info(` ${cyan(`export SKILLHUB_REGISTRY="http://:"`)}`); - info(` ${dim("# Example: export SKILLHUB_REGISTRY=\"http://192.168.1.100:8080\"")}\n`); - - info(` ${green("Windows CMD:")}`); - info(` ${cyan(`set SKILLHUB_REGISTRY=http://:`)}`); - info(` ${dim("# Example: set SKILLHUB_REGISTRY=http://192.168.1.100:8080")}\n`); - - info(` ${green("Windows PowerShell:")}`); - info(` ${cyan(`$env:SKILLHUB_REGISTRY="http://:"`)}`); - info(` ${dim("# Example: $env:SKILLHUB_REGISTRY='http://192.168.1.100:8080'")}\n`); - - info(`${cyan("🔹 Permanent (survives terminal restart):")}\n`); - - info(` ${green("Linux/macOS (~/.bashrc or ~/.zshrc):")}`); - info(` ${cyan(`echo 'export SKILLHUB_REGISTRY="http://:"' >> ~/.bashrc`)}`); - info(` ${cyan(`source ~/.bashrc`)}`); - info(` ${dim("# Add to ~/.bashrc for bash, ~/.zshrc for zsh")}\n`); - - info(` ${green("Windows (User environment variable):")}`); - info(` ${cyan(`setx SKILLHUB_REGISTRY "http://:"`)}`); - info(` ${dim("# Restart terminal after running this command")}\n`); - - info(` ${green("PowerShell (User profile):")}`); - info(` ${cyan(`[System.Environment]::SetEnvironmentVariable('SKILLHUB_REGISTRY', 'http://:', 'User')`)}`); - info(` ${dim("# Restart PowerShell after running this command")}\n`); - - info(`${cyan("📋 Configuration priority (highest to lowest):")}`); - info(` 1. ${green("--registry flag")} (one-time, per command)`); - info(` 2. ${green("SKILLHUB_REGISTRY")} (environment variable)`); - info(` 3. ${green("~/.skillhub/config.json")} (config file)`); - info(` 4. ${dim("http://localhost:8080")} (default)\n`); - - info(`${cyan("💡 Quick examples:")}`); - info(` skillhub config set registry http://192.168.1.100:8080`); - info(` skillhub --registry http://192.168.1.100:8080 explore`); - info(` skillhub config list\n`); + const lines: string[] = []; + + lines.push(yellow("Environment variable setup for SKILLHUB_REGISTRY:")); + lines.push(""); + + lines.push(cyan("🔹 Temporary (current session only):")); + lines.push(""); + + lines.push(` ${green("Linux/macOS:")}`); + lines.push(` ${cyan('export SKILLHUB_REGISTRY="http://:"')}`); + lines.push(` ${chalk.dim("# Example: export SKILLHUB_REGISTRY=\"http://192.168.1.100:8080\"")}`); + lines.push(""); + + lines.push(` ${green("Windows CMD:")}`); + lines.push(` ${cyan("set SKILLHUB_REGISTRY=http://:")}`); + lines.push(` ${chalk.dim("# Example: set SKILLHUB_REGISTRY=http://192.168.1.100:8080")}`); + lines.push(""); + + lines.push(` ${green("Windows PowerShell:")}`); + lines.push(` ${cyan('$env:SKILLHUB_REGISTRY="http://:"')}`); + lines.push(` ${chalk.dim("# Example: $env:SKILLHUB_REGISTRY='http://192.168.1.100:8080'")}`); + lines.push(""); + + lines.push(cyan("🔹 Permanent (survives terminal restart):")); + lines.push(""); + + lines.push(` ${green("Linux/macOS (~/.bashrc or ~/.zshrc):")}`); + lines.push(` ${cyan('echo \'export SKILLHUB_REGISTRY="http://:"\' >> ~/.bashrc')}`); + lines.push(` ${cyan("source ~/.bashrc")}`); + lines.push(` ${chalk.dim("# Add to ~/.bashrc for bash, ~/.zshrc for zsh")}`); + lines.push(""); + + lines.push(` ${green("Windows (User environment variable):")}`); + lines.push(` ${cyan('setx SKILLHUB_REGISTRY "http://:"')}`); + lines.push(` ${chalk.dim("# Restart terminal after running this command")}`); + lines.push(""); + + lines.push(` ${green("PowerShell (User profile):")}`); + lines.push(` ${cyan("[System.Environment]::SetEnvironmentVariable('SKILLHUB_REGISTRY', 'http://:', 'User')")}`); + lines.push(` ${chalk.dim("# Restart PowerShell after running this command")}`); + lines.push(""); + + lines.push(cyan("📋 Configuration priority (highest to lowest):")); + lines.push(` 1. ${green("--registry flag")} (one-time, per command)`); + lines.push(` 2. ${green("SKILLHUB_REGISTRY")} (environment variable)`); + lines.push(` 3. ${green("~/.skillhub/config.json")} (config file)`); + lines.push(` 4. ${chalk.dim("http://localhost:8080")} (default)`); + lines.push(""); + + lines.push(cyan("💡 Quick examples:")); + lines.push(` skillhub config set http://192.168.1.100:8080`); + lines.push(` skillhub --registry http://192.168.1.100:8080 explore`); + lines.push(` skillhub config list`); + + console.log(lines.join("\n")); }); } From 2fffc2b3775c2cbc06fe9ae7dc423298fc9a92b5 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:01:12 +0800 Subject: [PATCH 43/68] fix(cli): remove unsupported rating sort, add stars sort, clean up versions command - Remove --rating option and rating sort (API doesn't support it) - Add --stars shorthand for star-based sorting - Fix interactive-search.ts to use stars sort instead of rating - Add ratingAvg field to SkillDetail and display in explore output - Delete deprecated versions.ts command (functionality merged into inspect) --- skillhub-cli/src/commands/explore.ts | 21 ++- skillhub-cli/src/commands/versions.ts | 135 -------------------- skillhub-cli/src/core/interactive-search.ts | 10 +- skillhub-cli/src/schema/routes.ts | 1 + 4 files changed, 21 insertions(+), 146 deletions(-) delete mode 100644 skillhub-cli/src/commands/versions.ts diff --git a/skillhub-cli/src/commands/explore.ts b/skillhub-cli/src/commands/explore.ts index b9e44d84b..676a520eb 100644 --- a/skillhub-cli/src/commands/explore.ts +++ b/skillhub-cli/src/commands/explore.ts @@ -32,6 +32,7 @@ interface SkillDetail { starCount: number; downloadCount: number; version: string; + ratingAvg?: number; } async function fetchSkillDetail(client: ApiClient, namespace: string, name: string): Promise { @@ -220,15 +221,21 @@ export function registerExplore(program: Command) { .description("Browse or search skills from the registry") .argument("[query]", "Search query for finding skills") .option("-n, --limit ", "Max results", "20") - .option("-s, --sort ", "Sort by: hot, newest, downloads (default: interactive mode)") - .option("--hot", "Sort by popularity (shorthand for --sort hot)") + .option("-s, --sort ", "Sort by: hot, newest, downloads, stars (default: interactive mode)") + .option("--hot", "Sort by comprehensive popularity (downloads + stars)") .option("--newest", "Sort by newest first (shorthand for --sort newest)") .option("--downloads", "Sort by download count (shorthand for --sort downloads)") - .action(async (query: string | undefined, opts: { limit: string; sort?: string; hot?: boolean; newest?: boolean; downloads?: boolean }) => { + .option("--stars", "Sort by star count (shorthand for --sort stars)") + .action(async (query: string | undefined, opts: { limit: string; sort?: string; hot?: boolean; newest?: boolean; downloads?: boolean; stars?: boolean }) => { const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); - const sortMap: Record = { hot: "rating", newest: "newest", downloads: "downloads" }; + const sortMap: Record = { + hot: "hot", + newest: "newest", + downloads: "downloads", + stars: "stars" + }; // Resolve sort priority: explicit --sort > shorthand flags > default let effectiveSort = opts.sort; @@ -236,12 +243,13 @@ export function registerExplore(program: Command) { if (opts.hot) effectiveSort = "hot"; else if (opts.newest) effectiveSort = "newest"; else if (opts.downloads) effectiveSort = "downloads"; + else if (opts.stars) effectiveSort = "stars"; } const apiSort = sortMap[effectiveSort || "newest"] || "newest"; try { // Enter interactive mode only if no query AND no sort option (explicit or shorthand) - const hasSortOption = opts.sort || opts.hot || opts.newest || opts.downloads; + const hasSortOption = opts.sort || opts.hot || opts.newest || opts.downloads || opts.stars; if (!query && !hasSortOption) { const selected = await runInteractiveSearch(client, "", apiSort); if (!selected) { @@ -277,8 +285,9 @@ export function registerExplore(program: Command) { 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}` : ""; + const rating = detail?.ratingAvg ? ` ${GREEN}★ ${detail.ratingAvg.toFixed(1)}${RESET}` : ""; - console.log(`${TEXT}${skill.name}${RESET}${nsBadge}${stars}${downloads}`); + console.log(`${TEXT}${skill.name}${RESET}${nsBadge}${stars}${downloads}${rating}`); console.log(`${DIM}└ skillhub install ${skill.namespace}/${skill.name}${RESET}`); if (skill.summary) { console.log(`${DIM} ${skill.summary.slice(0, 60)}${RESET}`); diff --git a/skillhub-cli/src/commands/versions.ts b/skillhub-cli/src/commands/versions.ts deleted file mode 100644 index 3c74df731..000000000 --- a/skillhub-cli/src/commands/versions.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Command } from "commander"; -import { ApiClient } from "../core/api-client.js"; -import { readToken } from "../core/auth-token.js"; -import { loadConfig, loadConfigFromProgram } 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`; -} - -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 }; -} - -export function registerVersions(program: Command) { - program - .command("versions ") - .description("List skill versions") - .option("--detail", "Show additional skill metadata (stars, downloads, summary)") - .action(async (slug: string, opts: { detail?: boolean }) => { - try { - const { namespace, slug: skillSlug } = parseSkillName(slug); - const config = loadConfigFromProgram(program); - 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.exitCode = 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 (opts.detail) { - try { - const detail = await client.get( - `/api/v1/skills/${targetNamespace}/${targetSlug}` - ); - console.log(""); - info(`${detail.displayName} (${detail.slug})`); - dim(`Namespace: ${detail.namespace}`); - dim(`Author: ${detail.ownerDisplayName}`); - dim(`Stars: ${detail.starCount} Downloads: ${detail.downloadCount}`); - if (detail.summary) console.log(`\n${detail.summary}`); - if (detail.labels && detail.labels.length > 0) { - dim(`Labels: ${detail.labels.map((l) => l.name || l.slug).join(", ")}`); - } - console.log(""); - } catch {} - } - - if (targetNamespace !== "global" && !opts.detail) { - 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.exitCode = 1; - } - }); -} diff --git a/skillhub-cli/src/core/interactive-search.ts b/skillhub-cli/src/core/interactive-search.ts index ed86a755c..0ebf7535a 100644 --- a/skillhub-cli/src/core/interactive-search.ts +++ b/skillhub-cli/src/core/interactive-search.ts @@ -83,14 +83,14 @@ export async function searchSkills( summary: s.summary, installs: s.stats?.downloads || 0, stars: s.stats?.stars || 0, - rating: 0, + rating: s.ratingAvg || 0, updatedAt: s.updatedAt || 0, }; }); if (sort === "downloads") { return skills.sort((a, b) => b.installs - a.installs); - } else if (sort === "rating") { - return skills.sort((a, b) => b.rating - a.rating || b.stars - a.stars); + } else if (sort === "stars") { + return skills.sort((a, b) => b.stars - a.stars); } else { return skills.sort((a, b) => b.updatedAt - a.updatedAt); } @@ -125,8 +125,8 @@ export async function searchSkills( if (sort === "downloads") { return skills.sort((a, b) => b.installs - a.installs); - } else if (sort === "rating") { - return skills.sort((a, b) => b.rating - a.rating || b.stars - a.stars); + } else if (sort === "stars") { + return skills.sort((a, b) => b.stars - a.stars); } else { return skills.sort((a, b) => b.updatedAt - a.updatedAt); } diff --git a/skillhub-cli/src/schema/routes.ts b/skillhub-cli/src/schema/routes.ts index d0411f71e..83829ecb8 100644 --- a/skillhub-cli/src/schema/routes.ts +++ b/skillhub-cli/src/schema/routes.ts @@ -59,6 +59,7 @@ export interface SkillsListResponse { downloads?: number; stars?: number; }; + ratingAvg?: number; latestVersion?: { version: string; }; From 4a7ebb4efc73da4f0b51715e95d238bd22bfe7c3 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:35:06 +0800 Subject: [PATCH 44/68] feat(cli): implement comprehensive sort strategy with backend and client-side sorting - Backend sorts (direct API): newest, downloads, rating - Client-side sorts (fetch then re-sort): hot, stars - Add --rating option for rating-based sorting (backend supported) - Add applyClientSort() function for client-side sorting logic - Hot sort formula: downloads * 0.6 + stars * 0.4 - Update SearchSkill interface to include stars, rating, updatedAt fields - Ensure all sort options work correctly with both list and search APIs --- skillhub-cli/src/commands/explore.ts | 26 +++++++---- skillhub-cli/src/core/interactive-search.ts | 52 +++++++++++++-------- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/skillhub-cli/src/commands/explore.ts b/skillhub-cli/src/commands/explore.ts index 676a520eb..a1e1cee16 100644 --- a/skillhub-cli/src/commands/explore.ts +++ b/skillhub-cli/src/commands/explore.ts @@ -221,20 +221,23 @@ export function registerExplore(program: Command) { .description("Browse or search skills from the registry") .argument("[query]", "Search query for finding skills") .option("-n, --limit ", "Max results", "20") - .option("-s, --sort ", "Sort by: hot, newest, downloads, stars (default: interactive mode)") + .option("-s, --sort ", "Sort by: hot, newest, downloads, stars, rating (default: interactive mode)") .option("--hot", "Sort by comprehensive popularity (downloads + stars)") .option("--newest", "Sort by newest first (shorthand for --sort newest)") .option("--downloads", "Sort by download count (shorthand for --sort downloads)") .option("--stars", "Sort by star count (shorthand for --sort stars)") - .action(async (query: string | undefined, opts: { limit: string; sort?: string; hot?: boolean; newest?: boolean; downloads?: boolean; stars?: boolean }) => { + .option("--rating", "Sort by average rating (shorthand for --sort rating)") + .action(async (query: string | undefined, opts: { limit: string; sort?: string; hot?: boolean; newest?: boolean; downloads?: boolean; stars?: boolean; rating?: boolean }) => { const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); - const sortMap: Record = { - hot: "hot", - newest: "newest", + + // Backend-supported sorts: newest, downloads, rating + // Client-side sorts: hot, stars (fetch with newest, then re-sort) + const backendSortMap: Record = { + newest: "newest", downloads: "downloads", - stars: "stars" + rating: "rating" }; // Resolve sort priority: explicit --sort > shorthand flags > default @@ -244,12 +247,17 @@ export function registerExplore(program: Command) { else if (opts.newest) effectiveSort = "newest"; else if (opts.downloads) effectiveSort = "downloads"; else if (opts.stars) effectiveSort = "stars"; + else if (opts.rating) effectiveSort = "rating"; } - const apiSort = sortMap[effectiveSort || "newest"] || "newest"; + + // Determine API sort and client-side re-sort + const isClientSort = effectiveSort === "hot" || effectiveSort === "stars"; + const apiSort = isClientSort ? "newest" : (backendSortMap[effectiveSort || "newest"] || "newest"); + const clientSort = isClientSort ? effectiveSort : undefined; try { // Enter interactive mode only if no query AND no sort option (explicit or shorthand) - const hasSortOption = opts.sort || opts.hot || opts.newest || opts.downloads || opts.stars; + const hasSortOption = opts.sort || opts.hot || opts.newest || opts.downloads || opts.stars || opts.rating; if (!query && !hasSortOption) { const selected = await runInteractiveSearch(client, "", apiSort); if (!selected) { @@ -261,7 +269,7 @@ export function registerExplore(program: Command) { return; } - const results = await searchSkills(client, query || "", parseInt(opts.limit, 10), apiSort); + const results = await searchSkills(client, query || "", parseInt(opts.limit, 10), apiSort, clientSort); if (results.length === 0) { console.log(`${DIM}No skills found${RESET}`); diff --git a/skillhub-cli/src/core/interactive-search.ts b/skillhub-cli/src/core/interactive-search.ts index 0ebf7535a..f0544a439 100644 --- a/skillhub-cli/src/core/interactive-search.ts +++ b/skillhub-cli/src/core/interactive-search.ts @@ -22,6 +22,9 @@ export interface SearchSkill { version?: string; summary?: string; installs?: number; + stars?: number; + rating?: number; + updatedAt?: number; } interface SkillDetail { @@ -56,16 +59,39 @@ async function fetchSkillDetail(client: ApiClient, namespace: string, name: stri } } +function applyClientSort(skills: SearchSkill[], sort: string): SearchSkill[] { + switch (sort) { + case "downloads": + return skills.sort((a, b) => (b.installs || 0) - (a.installs || 0)); + case "stars": + return skills.sort((a, b) => (b.stars || 0) - (a.stars || 0)); + case "rating": + return skills.sort((a, b) => (b.rating || 0) - (a.rating || 0)); + case "hot": + return skills.sort((a, b) => { + const hotA = (a.installs || 0) * 0.6 + (a.stars || 0) * 0.4; + const hotB = (b.installs || 0) * 0.6 + (b.stars || 0) * 0.4; + return hotB - hotA; + }); + case "newest": + default: + return skills.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + } +} + export async function searchSkills( client: ApiClient, query: string, limit: number = 10, - sort?: string + apiSort: string = "newest", + clientSort?: string ): Promise { + const needsClientSort = clientSort && clientSort !== apiSort; + if (!query) { const params = new URLSearchParams({ limit: limit.toString() }); - if (sort && sort !== "newest") { - params.set("sort", sort); + if (apiSort && apiSort !== "newest") { + params.set("sort", apiSort); } const result = await client.get( `${ApiRoutes.skills}?${params.toString()}` @@ -87,18 +113,12 @@ export async function searchSkills( updatedAt: s.updatedAt || 0, }; }); - if (sort === "downloads") { - return skills.sort((a, b) => b.installs - a.installs); - } else if (sort === "stars") { - return skills.sort((a, b) => b.stars - a.stars); - } else { - return skills.sort((a, b) => b.updatedAt - a.updatedAt); - } + return needsClientSort ? applyClientSort(skills, clientSort) : applyClientSort(skills, apiSort); } const params = new URLSearchParams({ q: query, limit: limit.toString() }); - if (sort) { - params.set("sort", sort); + if (apiSort && apiSort !== "newest") { + params.set("sort", apiSort); } const result = await client.get( `${ApiRoutes.search}?${params.toString()}` @@ -123,13 +143,7 @@ export async function searchSkills( }; }); - if (sort === "downloads") { - return skills.sort((a, b) => b.installs - a.installs); - } else if (sort === "stars") { - return skills.sort((a, b) => b.stars - a.stars); - } else { - return skills.sort((a, b) => b.updatedAt - a.updatedAt); - } + return needsClientSort ? applyClientSort(skills, clientSort) : applyClientSort(skills, apiSort); } export async function runInteractiveSearch( From f33a9e0a108833a4906b5f6abaf623720175d9ed Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:40:51 +0800 Subject: [PATCH 45/68] feat(cli): add inspect hint after explore selection --- skillhub-cli/src/commands/explore.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/skillhub-cli/src/commands/explore.ts b/skillhub-cli/src/commands/explore.ts index a1e1cee16..4a19d99b9 100644 --- a/skillhub-cli/src/commands/explore.ts +++ b/skillhub-cli/src/commands/explore.ts @@ -266,6 +266,7 @@ export function registerExplore(program: Command) { } info(`\nSelected: ${selected}`); dim("Run: skillhub install " + selected); + dim("Run: skillhub inspect " + selected + " for details"); return; } From c7d4f8273dbc6024412e8dea392a56e310718bdb Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:02:53 +0800 Subject: [PATCH 46/68] feat(cli): enhance inspect with interactive selection and reorganize help sections - Add interactive namespace selection to inspect command (like install) - Add version selection when multiple versions exist (--version to skip) - Skip version selection when --details is specified (shows all versions) - Add error handling for 403/404 errors with helpful messages - Add 'for installation' and 'for details' hints to explore command - Reorganize help: separate 'Discover & Info' and 'Social & Reviews' sections --- skillhub-cli/src/cli.ts | 6 +- skillhub-cli/src/commands/explore.ts | 2 +- skillhub-cli/src/commands/inspect.ts | 104 ++++++++++++++------------- 3 files changed, 60 insertions(+), 52 deletions(-) diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index be353c43f..11ba70314 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -68,9 +68,13 @@ function buildTopLevelHelp(version: string): string { { cmd: "explore", desc: "Browse or search skills from the registry", alias: "find, find-skills, search" }, { cmd: "inspect ", desc: "View skill metadata and versions", alias: "info, view" }, { cmd: "resolve ", desc: "Resolve the latest version of a skill" }, + ])); + sections.push(""); + + sections.push(formatSection("Social & Reviews", [ + { cmd: "star ", desc: "Star or unstar a skill" }, { cmd: "rating ", desc: "View your rating for a skill" }, { cmd: "rate ", desc: "Rate a skill (1-5)" }, - { cmd: "star ", desc: "Star a skill" }, { cmd: "report ", desc: "Report a skill for review" }, ])); sections.push(""); diff --git a/skillhub-cli/src/commands/explore.ts b/skillhub-cli/src/commands/explore.ts index 4a19d99b9..7b81ebed9 100644 --- a/skillhub-cli/src/commands/explore.ts +++ b/skillhub-cli/src/commands/explore.ts @@ -265,7 +265,7 @@ export function registerExplore(program: Command) { return; } info(`\nSelected: ${selected}`); - dim("Run: skillhub install " + selected); + dim("Run: skillhub install " + selected + " for installation"); dim("Run: skillhub inspect " + selected + " for details"); return; } diff --git a/skillhub-cli/src/commands/inspect.ts b/skillhub-cli/src/commands/inspect.ts index 3037658ea..e5f7d0ca8 100644 --- a/skillhub-cli/src/commands/inspect.ts +++ b/skillhub-cli/src/commands/inspect.ts @@ -5,6 +5,9 @@ import { loadConfigFromProgram } 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"; +import { searchSkills } from "../core/interactive-search.js"; +import * as p from "@clack/prompts"; +import ora from "ora"; interface SkillDetailResponse { id: number; @@ -161,73 +164,74 @@ export function registerInspect(program: Command) { } } - if (targetNamespace) { + async function displaySkillDetail(ns: string, skillSlug: string) { const detail = await client.get( - `${ApiRoutes.skillDetail.replace("{namespace}", targetNamespace).replace("{slug}", parsedSlug)}` + `${ApiRoutes.skillDetail.replace("{namespace}", ns).replace("{slug}", skillSlug)}` ); - const { versions, tags } = await fetchVersionsAndTags(targetNamespace, parsedSlug); + const { versions, tags } = await fetchVersionsAndTags(ns, skillSlug); if (isJson) { - const output = opts.versions ? { ...detail, versions, tags } : detail; + const output = opts.details ? { ...detail, versions, tags } : detail; console.log(JSON.stringify(output, null, 2)); } else { printSkillDetail(detail, versions, tags); } - return; } - const namespaces = await client.get(ApiRoutes.meNamespaces); - - if (!namespaces || namespaces.length === 0) { - error("No namespaces found. You may need to log in."); - process.exitCode = 1; + if (targetNamespace) { + await displaySkillDetail(targetNamespace, parsedSlug); return; } - 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 spinner = ora(`Searching for ${parsedSlug}`).start(); + + try { + const results = await searchSkills(client, parsedSlug, 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) { + spinner.fail(`Skill not found: ${parsedSlug}`); + process.exitCode = 1; + return; } - }); - 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(", ")}`); + if (uniqueResults.length === 1) { + spinner.stop(); + const ns = uniqueResults[0].namespace; + const name = uniqueResults[0].name; + await displaySkillDetail(ns, name); + return; } - process.exitCode = 1; - return; - } - if (isJson) { - if (matches.length === 1) { - const { versions, tags } = await fetchVersionsAndTags(matches[0].namespace, matches[0].slug); - const output = opts.details ? { ...matches[0], versions, tags } : matches[0]; - console.log(JSON.stringify(output, null, 2)); - } else { - const outputs = await Promise.all( - matches.map(async (m) => { - const { versions, tags } = await fetchVersionsAndTags(m.namespace, m.slug); - return opts.details ? { ...m, versions, tags } : m; - }) - ); - console.log(JSON.stringify(outputs, null, 2)); - } - } else if (matches.length === 1) { - const { versions, tags } = await fetchVersionsAndTags(matches[0].namespace, matches[0].slug); - printSkillDetail(matches[0], versions, tags); - } else { - for (const detail of matches) { - const { versions, tags } = await fetchVersionsAndTags(detail.namespace, detail.slug); - printInspectHeader(detail, versions, tags); + spinner.succeed(`Found ${uniqueResults.length} matches for ${parsedSlug}`); + + const selected = await p.select({ + message: "Select skill to inspect", + options: uniqueResults.map((r) => ({ + value: `${r.namespace}/${r.name}`, + label: `${r.namespace}/${r.name}`, + hint: r.summary ? r.summary.slice(0, 50) : undefined, + })), + }); + + if (p.isCancel(selected)) { + console.log("Cancelled."); + return; } + + const [selectedNs, selectedName] = (selected as string).split("/", 2); + await displaySkillDetail(selectedNs, selectedName); + } catch (e: any) { + spinner.fail(e.message); + process.exitCode = 1; } }); } From 08ce3d59a13198fd913e280c84a89a473b0baaea Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:33:04 +0800 Subject: [PATCH 47/68] =?UTF-8?q?feat(cli):=20=E4=BC=98=E5=8C=96=E5=B8=AE?= =?UTF-8?q?=E5=8A=A9=E7=95=8C=E9=9D=A2=E5=92=8C=E5=91=BD=E4=BB=A4=E7=BB=93?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重新组织帮助分类(Publish → Publish & Manage) - 统一参数命名为 ,添加参数说明 - 简化 config 命令描述 - 优化 explore 帮助,添加选项分组和示例 - 修复参数重复显示问题 - 子命令帮助显示 Arguments 部分 --- .gitignore | 2 + skillhub-cli/package.json | 2 +- skillhub-cli/src/cli.ts | 52 +++++----- skillhub-cli/src/commands/archive.ts | 3 +- skillhub-cli/src/commands/config.ts | 6 +- skillhub-cli/src/commands/delete.ts | 4 +- skillhub-cli/src/commands/download.ts | 3 +- skillhub-cli/src/commands/explore.ts | 50 +++++++++- skillhub-cli/src/commands/hide.ts | 6 +- skillhub-cli/src/commands/inspect.ts | 110 ++++++++++++++++++--- skillhub-cli/src/commands/install.ts | 7 +- skillhub-cli/src/commands/list.ts | 1 - skillhub-cli/src/commands/me.ts | 1 - skillhub-cli/src/commands/notifications.ts | 2 - skillhub-cli/src/commands/rating.ts | 9 +- skillhub-cli/src/commands/report.ts | 3 +- skillhub-cli/src/commands/resolve.ts | 3 +- skillhub-cli/src/commands/reviews.ts | 1 - skillhub-cli/src/commands/star.ts | 3 +- skillhub-cli/src/commands/uninstall.ts | 3 +- skillhub-cli/src/commands/update.ts | 3 +- 21 files changed, 198 insertions(+), 76 deletions(-) diff --git a/.gitignore b/.gitignore index 2563c091b..32f405cc1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # OS files .DS_Store Thumbs.db +.nfs* # Editors / IDEs / local tooling .claude/ @@ -14,6 +15,7 @@ Thumbs.db *.iml *.swp *.swo +*.code-workspace # Logs *.log diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index eb0a95440..57f1a5820 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.2.6", + "version": "1.2.8", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index 11ba70314..3fa890755 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -30,14 +30,12 @@ interface HelpEntry { } function formatSection(header: string, entries: HelpEntry[]): string { - const displayWidth = (e: HelpEntry) => e.cmd.length + (e.alias ? e.alias.length + 3 : 0); - const maxW = entries.reduce((max, e) => Math.max(max, displayWidth(e)), 0); - const col = Math.max(maxW + 4, 28); const lines = [bold(header)]; + for (const e of entries) { const aliasPart = e.alias ? dim(` (${e.alias})`) : ""; - const pad = " ".repeat(Math.max(col - displayWidth(e), 2)); - lines.push(` ${cyan(e.cmd)}${aliasPart}${pad}${e.desc}`); + lines.push(` ${cyan(e.cmd)}${aliasPart}`); + lines.push(` ${e.desc}`); } return lines.join("\n"); } @@ -51,8 +49,8 @@ function buildTopLevelHelp(version: string): string { sections.push(formatSection("Configuration", [ { cmd: "config list", desc: "Show current registry configuration" }, - { cmd: "config set ", desc: "Set configuration (e.g., registry URL)" }, - { cmd: "config get ", desc: "Get configuration value" }, + { cmd: "config set ", desc: "Set registry URL" }, + { cmd: "config get", desc: "Get current registry configuration" }, { cmd: "config show-env-instructions", desc: "Show environment variable setup guide" }, ])); sections.push(""); @@ -64,37 +62,37 @@ function buildTopLevelHelp(version: string): string { ])); sections.push(""); - sections.push(formatSection("Discover & Info", [ + sections.push(formatSection("Discovery", [ { cmd: "explore", desc: "Browse or search skills from the registry", alias: "find, find-skills, search" }, - { cmd: "inspect ", desc: "View skill metadata and versions", alias: "info, view" }, - { cmd: "resolve ", desc: "Resolve the latest version of a skill" }, - ])); - sections.push(""); - - sections.push(formatSection("Social & Reviews", [ - { cmd: "star ", desc: "Star or unstar a skill" }, - { cmd: "rating ", desc: "View your rating for a skill" }, - { cmd: "rate ", desc: "Rate a skill (1-5)" }, - { cmd: "report ", desc: "Report a skill for review" }, + { cmd: "inspect ", desc: "View skill metadata and versions", alias: "info, view" }, + { cmd: "resolve ", desc: "Resolve the latest version of a skill" }, ])); sections.push(""); sections.push(formatSection("Install & Manage", [ - { cmd: "install ", desc: "Install from registry, git, or local path", alias: "i" }, - { cmd: "download ", desc: "Download a skill package to local directory" }, - { cmd: "update [slug]", desc: "Update installed skills from their source", alias: "up" }, - { cmd: "uninstall [name]", desc: "Uninstall a skill from local agent", alias: "un" }, + { cmd: "install ", desc: "Install from registry, git, or local path", alias: "i" }, + { cmd: "download ", desc: "Download a skill package to local directory" }, + { cmd: "update [skill]", desc: "Update installed skills from their source", alias: "up" }, + { cmd: "uninstall [skill]", desc: "Uninstall a skill from local agent", alias: "un" }, { cmd: "list", desc: "List installed skills", alias: "ls" }, { cmd: "check", desc: "Check installed skills against lock file" }, ])); sections.push(""); - sections.push(formatSection("Publish", [ + sections.push(formatSection("Social", [ + { cmd: "star ", desc: "Star or unstar a skill" }, + { cmd: "rating ", desc: "View your rating for a skill" }, + { cmd: "rate ", desc: "Rate a skill (1-5)" }, + { cmd: "report ", desc: "Report a skill for review" }, + ])); + sections.push(""); + + sections.push(formatSection("Publish & Manage", [ { cmd: "init [name]", desc: "Create a new SKILL.md template" }, { cmd: "publish [path]", desc: "Publish a skill to SkillHub registry" }, { cmd: "sync [path]", desc: "Scan and publish all skills from a directory" }, - { cmd: "delete ", desc: "Delete a skill you own", alias: "del, unpublish" }, - { cmd: "archive ", desc: "Archive a skill you own" }, + { cmd: "delete ", desc: "Delete a skill you own", alias: "del, unpublish" }, + { cmd: "archive ", desc: "Archive a skill you own" }, ])); sections.push(""); @@ -108,8 +106,8 @@ function buildTopLevelHelp(version: string): string { sections.push(""); sections.push(formatSection("Admin", [ - { cmd: "hide ", desc: "Hide a skill (admin only)" }, - { cmd: "unhide ", desc: "Unhide a skill (admin only)" }, + { cmd: "hide ", desc: "Hide a skill (admin only)" }, + { cmd: "unhide ", desc: "Unhide a skill (admin only)" }, { cmd: "transfer ", desc: "Transfer namespace ownership" }, ])); sections.push(""); diff --git a/skillhub-cli/src/commands/archive.ts b/skillhub-cli/src/commands/archive.ts index c720b962d..4d9c0c568 100644 --- a/skillhub-cli/src/commands/archive.ts +++ b/skillhub-cli/src/commands/archive.ts @@ -7,8 +7,9 @@ import { parseSkillName } from "../core/skill-name.js"; export function registerArchive(program: Command) { program - .command("archive ") + .command("archive") .description("Archive a skill you own") + .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") .action(async (slug: string, opts: { yes?: boolean }) => { const { namespace, slug: skillSlug } = parseSkillName(slug); diff --git a/skillhub-cli/src/commands/config.ts b/skillhub-cli/src/commands/config.ts index f620adfef..ef0287319 100644 --- a/skillhub-cli/src/commands/config.ts +++ b/skillhub-cli/src/commands/config.ts @@ -20,7 +20,7 @@ export function registerConfig(program: Command) { configCmd .command("list") - .description("List all configuration sources and their values") + .description("Show all config values and their sources") .action(() => { const env = process.env.SKILLHUB_REGISTRY; let fileConfig: { registry?: string } = {}; @@ -56,7 +56,7 @@ export function registerConfig(program: Command) { configCmd .command("set ") - .description("Set registry URL in ~/.skillhub/config.json") + .description("Set registry URL (e.g., https://api.example.com)") .action((value: string) => { if (!existsSync(CONFIG_DIR)) { mkdirSync(CONFIG_DIR, { recursive: true }); @@ -79,7 +79,7 @@ export function registerConfig(program: Command) { configCmd .command("get") - .description("Get registry configuration value") + .description("Show the current registry URL") .option("--source ", "Source: env, file, or resolved (default)") .action((opts: { source?: string }) => { const source = opts.source || "resolved"; diff --git a/skillhub-cli/src/commands/delete.ts b/skillhub-cli/src/commands/delete.ts index 94c534337..9911243e0 100644 --- a/skillhub-cli/src/commands/delete.ts +++ b/skillhub-cli/src/commands/delete.ts @@ -7,9 +7,9 @@ import { parseSkillName } from "../core/skill-name.js"; export function registerDelete(program: Command) { program - .command("delete ") - .aliases(["del", "unpublish"]) + .command("delete") .description("Delete a skill you own") + .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") .action(async (slug: string, opts: { yes?: boolean }) => { const { namespace, slug: skillSlug } = parseSkillName(slug); diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts index d3d389ca3..bf40f4657 100644 --- a/skillhub-cli/src/commands/download.ts +++ b/skillhub-cli/src/commands/download.ts @@ -12,8 +12,9 @@ import ora from "ora"; export function registerDownload(program: Command) { program - .command("download ") + .command("download") .description("Download a skill package to local directory") + .argument("", "Skill name or namespace/skill-name") .option("-v, --skill-version ", "Specific version") .option("--tag ", "Tag to download", "latest") .option("--output ", "Output directory") diff --git a/skillhub-cli/src/commands/explore.ts b/skillhub-cli/src/commands/explore.ts index 7b81ebed9..898fd27c3 100644 --- a/skillhub-cli/src/commands/explore.ts +++ b/skillhub-cli/src/commands/explore.ts @@ -214,20 +214,60 @@ async function runInteractiveSearch( }); } +function buildExploreHelp(cmd: Command): string { + const lines: string[] = []; + + lines.push(`${BOLD}Usage:${RESET} skillhub explore [options] [query]`); + lines.push(""); + lines.push("Browse or search skills from the registry"); + lines.push(""); + + lines.push(`${BOLD}Arguments:${RESET}`); + lines.push(` ${CYAN}[query]${RESET} Search query for finding skills`); + lines.push(""); + + lines.push(`${BOLD}Search Options:${RESET}`); + lines.push(` ${CYAN}-n, --limit ${RESET} Max results (default: "20")`); + lines.push(""); + + lines.push(`${BOLD}Sorting Options:${RESET}`); + lines.push(` ${CYAN}-s, --sort ${RESET} Sort by: hot, newest, downloads, stars, rating`); + lines.push(` ${CYAN}--hot${RESET} Sort by comprehensive popularity (downloads + stars)`); + lines.push(` ${CYAN}--newest${RESET} Sort by newest first`); + lines.push(` ${CYAN}--downloads${RESET} Sort by download count`); + lines.push(` ${CYAN}--stars${RESET} Sort by star count`); + lines.push(` ${CYAN}--rating${RESET} Sort by average rating`); + lines.push(""); + + lines.push(`${BOLD}Other Options:${RESET}`); + lines.push(` ${CYAN}-h, --help${RESET} Display help for command`); + lines.push(""); + + lines.push(`${BOLD}Examples:${RESET}`); + lines.push(`${DIM} skillhub explore Interactive skill search${RESET}`); + lines.push(`${DIM} skillhub explore --hot Browse popular skills${RESET}`); + lines.push(`${DIM} skillhub explore ai-assistant Search for skills${RESET}`); + lines.push(`${DIM} skillhub explore --sort newest --limit 10 Show 10 newest skills${RESET}`); + + return lines.join("\n"); +} + export function registerExplore(program: Command) { - program + const exploreCmd = 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") - .option("-s, --sort ", "Sort by: hot, newest, downloads, stars, rating (default: interactive mode)") + .option("-s, --sort ", "Sort by: hot, newest, downloads, stars, rating (browse mode)") .option("--hot", "Sort by comprehensive popularity (downloads + stars)") .option("--newest", "Sort by newest first (shorthand for --sort newest)") .option("--downloads", "Sort by download count (shorthand for --sort downloads)") .option("--stars", "Sort by star count (shorthand for --sort stars)") - .option("--rating", "Sort by average rating (shorthand for --sort rating)") - .action(async (query: string | undefined, opts: { limit: string; sort?: string; hot?: boolean; newest?: boolean; downloads?: boolean; stars?: boolean; rating?: boolean }) => { + .option("--rating", "Sort by average rating (shorthand for --sort rating)"); + + exploreCmd.helpInformation = () => buildExploreHelp(exploreCmd); + + exploreCmd.action(async (query: string | undefined, opts: { limit: string; sort?: string; hot?: boolean; newest?: boolean; downloads?: boolean; stars?: boolean; rating?: boolean }) => { const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); diff --git a/skillhub-cli/src/commands/hide.ts b/skillhub-cli/src/commands/hide.ts index dd1373b89..8b98fc3a4 100644 --- a/skillhub-cli/src/commands/hide.ts +++ b/skillhub-cli/src/commands/hide.ts @@ -7,8 +7,9 @@ import { parseSkillName } from "../core/skill-name.js"; export function registerHide(program: Command) { const hideCmd = program - .command("hide ") + .command("hide") .description("Hide a skill (admin only)") + .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") .action(async (slug: string, opts: { yes?: boolean }) => { const { namespace, slug: skillSlug } = parseSkillName(slug); @@ -47,8 +48,9 @@ export function registerHide(program: Command) { }); hideCmd - .command("unhide ") + .command("unhide") .description("Unhide a skill (admin only)") + .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") .action(async (slug: string, opts: { yes?: boolean }) => { const { namespace, slug: skillSlug } = parseSkillName(slug); diff --git a/skillhub-cli/src/commands/inspect.ts b/skillhub-cli/src/commands/inspect.ts index e5f7d0ca8..63d8bf004 100644 --- a/skillhub-cli/src/commands/inspect.ts +++ b/skillhub-cli/src/commands/inspect.ts @@ -137,12 +137,13 @@ function printInspectHeader(detail: SkillDetailResponse, versions?: SkillVersion export function registerInspect(program: Command) { program - .command("inspect ") - .aliases(["info", "view"]) + .command("inspect") .description("View skill metadata without installing") + .argument("", "Skill name or namespace/skill-name") .option("--namespace ", "Search in specific namespace (searches all if not specified)") .option("--details", "Show all versions with tags") - .action(async (slug: string, opts: { namespace?: string; details?: boolean }) => { + .option("-v, --version ", "Inspect specific version") + .action(async (slug: string, opts: { namespace?: string; details?: boolean; version?: string }) => { const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); @@ -152,7 +153,6 @@ export function registerInspect(program: Command) { const targetNamespace = opts.namespace || defaultNs; async function fetchVersionsAndTags(ns: string, skillSlug: string) { - if (!opts.details) return { versions: undefined, tags: undefined }; try { const [versionsResp, tagsResp] = await Promise.all([ client.get(`/api/v1/skills/${ns}/${skillSlug}/versions`), @@ -164,21 +164,91 @@ export function registerInspect(program: Command) { } } - async function displaySkillDetail(ns: string, skillSlug: string) { - const detail = await client.get( - `${ApiRoutes.skillDetail.replace("{namespace}", ns).replace("{slug}", skillSlug)}` - ); + async function displaySkillDetail(ns: string, skillSlug: string, version?: string) { + try { + const detail = await client.get( + `${ApiRoutes.skillDetail.replace("{namespace}", ns).replace("{slug}", skillSlug)}` + ); + + const { versions, tags } = await fetchVersionsAndTags(ns, skillSlug); + + if (version && versions) { + const selectedVersion = versions.find(v => v.version === version); + if (selectedVersion) { + detail.publishedVersion = { version: selectedVersion.version }; + } + } + + if (isJson) { + const output = opts.details ? { ...detail, versions, tags } : detail; + console.log(JSON.stringify(output, null, 2)); + } else { + printSkillDetail(detail, opts.details ? versions : undefined, opts.details ? tags : undefined); + } + } catch (e: any) { + if (e.statusCode === 403) { + error(`Access denied: ${ns}/${skillSlug}`); + dim("Run 'skillhub login' to authenticate."); + } else if (e.statusCode === 404) { + error(`Skill not found: ${ns}/${skillSlug}`); + } else { + error(`Failed to fetch skill details: ${e.message}`); + } + process.exitCode = 1; + } + } + + async function inspectWithVersionSelection(ns: string, skillSlug: string) { const { versions, tags } = await fetchVersionsAndTags(ns, skillSlug); - if (isJson) { - const output = opts.details ? { ...detail, versions, tags } : detail; - console.log(JSON.stringify(output, null, 2)); - } else { - printSkillDetail(detail, versions, tags); + + if (!versions || versions.length === 0) { + await displaySkillDetail(ns, skillSlug); + return; + } + + if (opts.details) { + await displaySkillDetail(ns, skillSlug); + return; + } + + if (versions.length === 1) { + await displaySkillDetail(ns, skillSlug); + return; } + + const versionTagsMap = new Map(); + if (tags) { + for (const tag of tags) { + if (!versionTagsMap.has(tag.versionId)) { + versionTagsMap.set(tag.versionId, []); + } + versionTagsMap.get(tag.versionId)!.push(tag.tagName); + } + } + + const selected = await p.select({ + message: "Select version to inspect", + options: versions.map((v) => ({ + value: v.version, + label: `v${v.version}`, + hint: versionTagsMap.get(v.id)?.join(", ") || "", + })), + }); + + if (p.isCancel(selected)) { + console.log("Cancelled."); + return; + } + + await displaySkillDetail(ns, skillSlug, selected as string); } if (targetNamespace) { - await displaySkillDetail(targetNamespace, parsedSlug); + if (opts.version) { + await displaySkillDetail(targetNamespace, parsedSlug, opts.version); + } else { + await inspectWithVersionSelection(targetNamespace, parsedSlug); + } return; } @@ -207,7 +277,11 @@ export function registerInspect(program: Command) { spinner.stop(); const ns = uniqueResults[0].namespace; const name = uniqueResults[0].name; - await displaySkillDetail(ns, name); + if (opts.version) { + await displaySkillDetail(ns, name, opts.version); + } else { + await inspectWithVersionSelection(ns, name); + } return; } @@ -228,7 +302,11 @@ export function registerInspect(program: Command) { } const [selectedNs, selectedName] = (selected as string).split("/", 2); - await displaySkillDetail(selectedNs, selectedName); + if (opts.version) { + await displaySkillDetail(selectedNs, selectedName, opts.version); + } else { + await inspectWithVersionSelection(selectedNs, selectedName); + } } catch (e: any) { spinner.fail(e.message); process.exitCode = 1; diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index bb48ae2c4..a5ec716c7 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -200,13 +200,13 @@ function buildAgentSummary(targetAgents: AgentInfo[], mode: "symlink" | "copy", function buildInstallHelp(cmd: Command): string { const lines: string[] = []; - lines.push(`${chalk.bold("Usage:")} skillhub install|i [options] ${chalk.cyan("")}`); + lines.push(`${chalk.bold("Usage:")} skillhub install [options] ${chalk.cyan("")}`); lines.push(""); lines.push("Install skills from registry, git repositories, or local paths"); lines.push(""); lines.push(chalk.bold("Arguments:")); - lines.push(` ${chalk.cyan("skill-name")} Skill name or namespace/skill-name from registry`); + lines.push(` ${chalk.cyan("skill")} Skill name or namespace/skill-name from registry`); lines.push(""); lines.push(chalk.bold("Source Options:")); @@ -246,9 +246,10 @@ function buildInstallHelp(cmd: Command): string { export function registerInstall(program: Command) { const installCmd = program - .command("install ") + .command("install") .alias("i") .description("Install skills from registry, git repositories, or local paths") + .argument("", "Skill name or namespace/skill-name from registry") .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") diff --git a/skillhub-cli/src/commands/list.ts b/skillhub-cli/src/commands/list.ts index c9cf1fc58..6205dc0fc 100644 --- a/skillhub-cli/src/commands/list.ts +++ b/skillhub-cli/src/commands/list.ts @@ -18,7 +18,6 @@ interface ListOptions { 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") diff --git a/skillhub-cli/src/commands/me.ts b/skillhub-cli/src/commands/me.ts index b1fce52d2..a0b3a9113 100644 --- a/skillhub-cli/src/commands/me.ts +++ b/skillhub-cli/src/commands/me.ts @@ -28,7 +28,6 @@ export function registerMe(program: Command) { me .command("skills") - .alias("ls") .description("List your published skills") .action(async () => { try { diff --git a/skillhub-cli/src/commands/notifications.ts b/skillhub-cli/src/commands/notifications.ts index 7b02675b2..f2929c0ab 100644 --- a/skillhub-cli/src/commands/notifications.ts +++ b/skillhub-cli/src/commands/notifications.ts @@ -15,12 +15,10 @@ export interface Notification { 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 }) => { diff --git a/skillhub-cli/src/commands/rating.ts b/skillhub-cli/src/commands/rating.ts index 12c825761..9df10fa2c 100644 --- a/skillhub-cli/src/commands/rating.ts +++ b/skillhub-cli/src/commands/rating.ts @@ -7,8 +7,9 @@ import { parseSkillName } from "../core/skill-name.js"; export function registerRating(program: Command) { program - .command("rating ") + .command("rating") .description("View your rating for a skill") + .argument("", "Skill name or namespace/skill-name") .action(async (slug: string) => { try { const { namespace, slug: skillSlug } = parseSkillName(slug); @@ -28,7 +29,7 @@ export function registerRating(program: Command) { info(`${skillSlug}: ${"★".repeat(rating.score)}${"☆".repeat(5 - rating.score)} (${rating.score}/5)`); } else { info(`${skillSlug}: Not rated yet`); - dim("Use: skillhub rate "); + dim("Use: skillhub rate "); } } catch (e: any) { error(`Failed: ${e.message}`); @@ -39,8 +40,10 @@ export function registerRating(program: Command) { export function registerRate(program: Command) { program - .command("rate ") + .command("rate") .description("Rate a skill (1-5)") + .argument("", "Skill name or namespace/skill-name") + .argument("", "Rating score (1-5)") .action(async (slug: string, scoreStr: string) => { const score = parseInt(scoreStr, 10); if (isNaN(score) || score < 1 || score > 5) { diff --git a/skillhub-cli/src/commands/report.ts b/skillhub-cli/src/commands/report.ts index 52da128d6..873682307 100644 --- a/skillhub-cli/src/commands/report.ts +++ b/skillhub-cli/src/commands/report.ts @@ -8,8 +8,9 @@ import { parseSkillName } from "../core/skill-name.js"; export function registerReport(program: Command) { program - .command("report ") + .command("report") .description("Report a skill for review") + .argument("", "Skill name or namespace/skill-name") .option("--reason ", "Report reason") .action(async (slug: string, opts: { reason?: string }) => { try { diff --git a/skillhub-cli/src/commands/resolve.ts b/skillhub-cli/src/commands/resolve.ts index 3f14c9e57..3d44935c2 100644 --- a/skillhub-cli/src/commands/resolve.ts +++ b/skillhub-cli/src/commands/resolve.ts @@ -43,8 +43,9 @@ async function resolveWithVersion( export function registerResolve(program: Command) { program - .command("resolve ") + .command("resolve") .description("Resolve the latest version of a skill") + .argument("", "Skill name or namespace/skill-name") .option("-v, --skill-version ", "Specific version") .option("--tag ", "Tag to resolve (default: latest, ignored if --skill-version)") .option("--hash ", "Content hash") diff --git a/skillhub-cli/src/commands/reviews.ts b/skillhub-cli/src/commands/reviews.ts index 745c6113d..ca6ee1c02 100644 --- a/skillhub-cli/src/commands/reviews.ts +++ b/skillhub-cli/src/commands/reviews.ts @@ -19,7 +19,6 @@ export function registerReviews(program: Command) { reviews .command("my") - .alias("submissions") .description("List your review submissions") .action(async () => { try { diff --git a/skillhub-cli/src/commands/star.ts b/skillhub-cli/src/commands/star.ts index 2ff16ea4e..fbd06599f 100644 --- a/skillhub-cli/src/commands/star.ts +++ b/skillhub-cli/src/commands/star.ts @@ -8,8 +8,9 @@ import { parseSkillName } from "../core/skill-name.js"; export function registerStar(program: Command) { program - .command("star ") + .command("star") .description("Star a skill") + .argument("", "Skill name or namespace/skill-name") .option("--unstar", "Remove star") .action(async (slug: string, opts: { unstar: boolean }) => { try { diff --git a/skillhub-cli/src/commands/uninstall.ts b/skillhub-cli/src/commands/uninstall.ts index 88541177a..a3e78883b 100644 --- a/skillhub-cli/src/commands/uninstall.ts +++ b/skillhub-cli/src/commands/uninstall.ts @@ -110,8 +110,7 @@ function findAgentsWithSkill(skillName: string, scope: "global" | "local", agent export function registerUninstall(program: Command) { program - .command("uninstall [name]") - .alias("un") + .command("uninstall [skill]") .description("Uninstall a skill or all skills from local agent") .option("-g, --global", "Uninstall from global scope") .option("-a, --agent ", "Uninstall from specific agents") diff --git a/skillhub-cli/src/commands/update.ts b/skillhub-cli/src/commands/update.ts index 8fbbf3766..1082cff41 100644 --- a/skillhub-cli/src/commands/update.ts +++ b/skillhub-cli/src/commands/update.ts @@ -12,8 +12,7 @@ function getCliCommand(): string { export function registerUpdate(program: Command) { program - .command("update [slug]") - .alias("up") + .command("update [skill]") .description("Update installed skills from their source") .option("-a, --all", "Update all installed skills") .option("-g, --global", "Update global scope skills") From 3239b4d5b83f0c22c73cb8b573d92fe5595c48bd Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:08:39 +0800 Subject: [PATCH 48/68] =?UTF-8?q?fix(inspect):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E9=80=89=E9=A1=B9=E4=B8=8E=E5=85=A8=E5=B1=80?= =?UTF-8?q?=20--version=20=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 inspect 命令的版本选项从 --version 改为 --skill-version, 避免与 skillhub --version 全局版本选项冲突 --- skillhub-cli/package.json | 2 +- skillhub-cli/src/commands/inspect.ts | 108 +++++++++++++-------------- 2 files changed, 55 insertions(+), 55 deletions(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index 57f1a5820..5d88619c8 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.2.8", + "version": "1.2.9", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { diff --git a/skillhub-cli/src/commands/inspect.ts b/skillhub-cli/src/commands/inspect.ts index 63d8bf004..cec97781f 100644 --- a/skillhub-cli/src/commands/inspect.ts +++ b/skillhub-cli/src/commands/inspect.ts @@ -142,8 +142,8 @@ export function registerInspect(program: Command) { .argument("", "Skill name or namespace/skill-name") .option("--namespace ", "Search in specific namespace (searches all if not specified)") .option("--details", "Show all versions with tags") - .option("-v, --version ", "Inspect specific version") - .action(async (slug: string, opts: { namespace?: string; details?: boolean; version?: string }) => { + .option("-v, --skill-version ", "Inspect specific version") + .action(async (slug: string, opts: { namespace?: string; details?: boolean; skillVersion?: string }) => { const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); @@ -244,69 +244,69 @@ export function registerInspect(program: Command) { } if (targetNamespace) { - if (opts.version) { - await displaySkillDetail(targetNamespace, parsedSlug, opts.version); - } else { - await inspectWithVersionSelection(targetNamespace, parsedSlug); - } - return; + if (opts.skillVersion) { + await displaySkillDetail(targetNamespace, parsedSlug, opts.skillVersion); + } else { + await inspectWithVersionSelection(targetNamespace, parsedSlug); } + return; + } - const spinner = ora(`Searching for ${parsedSlug}`).start(); - - try { - const results = await searchSkills(client, parsedSlug, 50); + const spinner = ora(`Searching for ${parsedSlug}`).start(); - 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; - }); + try { + const results = await searchSkills(client, parsedSlug, 50); - if (uniqueResults.length === 0) { - spinner.fail(`Skill not found: ${parsedSlug}`); - process.exitCode = 1; - return; + 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 === 1) { - spinner.stop(); - const ns = uniqueResults[0].namespace; - const name = uniqueResults[0].name; - if (opts.version) { - await displaySkillDetail(ns, name, opts.version); - } else { - await inspectWithVersionSelection(ns, name); - } - return; + if (uniqueResults.length === 0) { + spinner.fail(`Skill not found: ${parsedSlug}`); + process.exitCode = 1; + return; + } + + if (uniqueResults.length === 1) { + spinner.stop(); + const ns = uniqueResults[0].namespace; + const name = uniqueResults[0].name; + if (opts.skillVersion) { + await displaySkillDetail(ns, name, opts.skillVersion); + } else { + await inspectWithVersionSelection(ns, name); } + return; + } - spinner.succeed(`Found ${uniqueResults.length} matches for ${parsedSlug}`); + spinner.succeed(`Found ${uniqueResults.length} matches for ${parsedSlug}`); - const selected = await p.select({ - message: "Select skill to inspect", - options: uniqueResults.map((r) => ({ - value: `${r.namespace}/${r.name}`, - label: `${r.namespace}/${r.name}`, - hint: r.summary ? r.summary.slice(0, 50) : undefined, - })), - }); + const selected = await p.select({ + message: "Select skill to inspect", + options: uniqueResults.map((r) => ({ + value: `${r.namespace}/${r.name}`, + label: `${r.namespace}/${r.name}`, + hint: r.summary ? r.summary.slice(0, 50) : undefined, + })), + }); - if (p.isCancel(selected)) { - console.log("Cancelled."); - return; - } + if (p.isCancel(selected)) { + console.log("Cancelled."); + return; + } - const [selectedNs, selectedName] = (selected as string).split("/", 2); - if (opts.version) { - await displaySkillDetail(selectedNs, selectedName, opts.version); - } else { - await inspectWithVersionSelection(selectedNs, selectedName); - } + const [selectedNs, selectedName] = (selected as string).split("/", 2); + if (opts.skillVersion) { + await displaySkillDetail(selectedNs, selectedName, opts.skillVersion); + } else { + await inspectWithVersionSelection(selectedNs, selectedName); + } } catch (e: any) { spinner.fail(e.message); process.exitCode = 1; From 58ee1a52205458a7ccc91e65eca7de2532d0cee9 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:17:10 +0800 Subject: [PATCH 49/68] =?UTF-8?q?fix(install):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=B8=AE=E5=8A=A9=E6=96=87=E6=9C=AC=E4=B8=AD=E7=9A=84=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 --from 描述改为 'Alias for --add',避免与 -a, --add 的 描述互相引用导致循环感 --- skillhub-cli/src/commands/install.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index a5ec716c7..8552f02cb 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -210,8 +210,8 @@ function buildInstallHelp(cmd: Command): string { lines.push(""); lines.push(chalk.bold("Source Options:")); - lines.push(` ${chalk.cyan("-a, --add ")} Install from GitHub or local path (alias for --from)`); - lines.push(` ${chalk.cyan("--from ")} Install from GitHub or local path (alias for -a)`); + lines.push(` ${chalk.cyan("-a, --add ")} Install from GitHub or local path`); + lines.push(` ${chalk.cyan("--from ")} Alias for --add`); lines.push(""); lines.push(chalk.bold("Target Options:")); @@ -250,8 +250,8 @@ export function registerInstall(program: Command) { .alias("i") .description("Install skills from registry, git repositories, or local paths") .argument("", "Skill name or namespace/skill-name from registry") - .option("-a, --add ", "Install from GitHub or local path (alias for --from)") - .option("--from ", "Install from GitHub or local path (alias for -a)") + .option("-a, --add ", "Install from GitHub or local path") + .option("--from ", "Alias for --add") .option("--agent ", "Target specific agents") .option("-g, --global", "Install to global scope") .option("-y, --yes", "Skip all prompts") From 273e14a26f9430ca0fdecf37220862baa8183ec4 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:20:38 +0800 Subject: [PATCH 50/68] =?UTF-8?q?feat(cli):=20=E6=B7=BB=E5=8A=A0=20--names?= =?UTF-8?q?pace=20=E9=80=89=E9=A1=B9=E5=92=8C=E6=99=BA=E8=83=BD=20namespac?= =?UTF-8?q?e=20=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 skill-resolver.ts 公共函数,提供 resolveSkillNamespace 和 parseSkillNamespace - 为 resolve, download, star, rating, rate, report, delete, archive, hide, unhide 添加 --namespace 选项 - inspect 使用 resolveSkillNamespace 替代手动解析 - install 优化 --from 为主选项,-a, --add 为别名 - download 优化帮助界面,添加 Examples 和更清晰的描述 --- skillhub-cli/src/commands/archive.ts | 8 +-- skillhub-cli/src/commands/delete.ts | 8 +-- skillhub-cli/src/commands/download.ts | 49 ++++++++++++-- skillhub-cli/src/commands/hide.ts | 14 ++-- skillhub-cli/src/commands/inspect.ts | 90 ++++--------------------- skillhub-cli/src/commands/install.ts | 8 +-- skillhub-cli/src/commands/rating.ts | 14 ++-- skillhub-cli/src/commands/report.ts | 8 +-- skillhub-cli/src/commands/resolve.ts | 4 +- skillhub-cli/src/commands/star.ts | 8 ++- skillhub-cli/src/core/skill-resolver.ts | 86 +++++++++++++++++++++++ 11 files changed, 185 insertions(+), 112 deletions(-) create mode 100644 skillhub-cli/src/core/skill-resolver.ts diff --git a/skillhub-cli/src/commands/archive.ts b/skillhub-cli/src/commands/archive.ts index 4d9c0c568..c80c80f9c 100644 --- a/skillhub-cli/src/commands/archive.ts +++ b/skillhub-cli/src/commands/archive.ts @@ -3,16 +3,16 @@ import { ApiClient } from "../core/api-client.js"; import { requireToken } from "../core/auth-token.js"; import { loadConfig, loadConfigFromProgram } 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") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") - .action(async (slug: string, opts: { yes?: boolean }) => { - const { namespace, slug: skillSlug } = parseSkillName(slug); + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); if (!opts.yes) { const { createInterface } = await import("node:readline"); const rl = createInterface({ input: process.stdin, output: process.stdout }); diff --git a/skillhub-cli/src/commands/delete.ts b/skillhub-cli/src/commands/delete.ts index 9911243e0..968e24b6a 100644 --- a/skillhub-cli/src/commands/delete.ts +++ b/skillhub-cli/src/commands/delete.ts @@ -3,16 +3,16 @@ import { ApiClient } from "../core/api-client.js"; import { requireToken } from "../core/auth-token.js"; import { loadConfig, loadConfigFromProgram } 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") .description("Delete a skill you own") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") - .action(async (slug: string, opts: { yes?: boolean }) => { - const { namespace, slug: skillSlug } = parseSkillName(slug); + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); if (!opts.yes) { const { createInterface } = await import("node:readline"); const rl = createInterface({ input: process.stdin, output: process.stdout }); diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts index bf40f4657..2a0e07237 100644 --- a/skillhub-cli/src/commands/download.ts +++ b/skillhub-cli/src/commands/download.ts @@ -7,19 +7,58 @@ import { ApiRoutes } from "../schema/routes.js"; import { loadConfig, loadConfigFromProgram } 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"; +function buildDownloadHelp(cmd: Command): string { + const lines: string[] = []; + const BOLD = "\x1b[1m"; + const RESET = "\x1b[0m"; + const CYAN = "\x1b[36m"; + const DIM = "\x1b[38;5;102m"; + + lines.push(`${BOLD}Usage:${RESET} skillhub download [options] `); + lines.push(""); + lines.push("Download a skill package as a .zip file"); + lines.push(""); + + lines.push(`${BOLD}Arguments:${RESET}`); + lines.push(` ${CYAN}skill${RESET} Skill name or namespace/skill-name`); + lines.push(""); + + lines.push(`${BOLD}Options:${RESET}`); + lines.push(` ${CYAN}-v, --skill-version ${RESET} Specific version to download`); + lines.push(` ${CYAN}--tag ${RESET} Tag to download (default: "latest")`); + lines.push(` ${CYAN}--output ${RESET} Output directory (default: current directory)`); + lines.push(` ${CYAN}--namespace ${RESET} Override namespace (default: parsed from skill or 'global')`); + lines.push(` ${CYAN}-h, --help${RESET} Display help for command`); + lines.push(""); + + lines.push(`${BOLD}Examples:${RESET}`); + lines.push(`${DIM} skillhub download docker-build-push${RESET}`); + lines.push(`${DIM} skillhub download vision2group/docker-build-push${RESET}`); + lines.push(`${DIM} skillhub download docker-build-push --output ./skills${RESET}`); + lines.push(`${DIM} skillhub download docker-build-push --skill-version 1.0.0${RESET}`); + lines.push(`${DIM} skillhub download docker-build-push --tag v1.0.0${RESET}`); + + return lines.join("\n"); +} + export function registerDownload(program: Command) { - program + const downloadCmd = program .command("download") - .description("Download a skill package to local directory") + .description("Download a skill package as a .zip file") .argument("", "Skill name or namespace/skill-name") .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); + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')"); + + downloadCmd.helpInformation = () => buildDownloadHelp(downloadCmd); + + downloadCmd.action(async (slug: string, opts: Record) => { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); diff --git a/skillhub-cli/src/commands/hide.ts b/skillhub-cli/src/commands/hide.ts index 8b98fc3a4..b758ba1f0 100644 --- a/skillhub-cli/src/commands/hide.ts +++ b/skillhub-cli/src/commands/hide.ts @@ -3,7 +3,7 @@ import { ApiClient } from "../core/api-client.js"; import { requireToken } from "../core/auth-token.js"; import { loadConfig, loadConfigFromProgram } 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 @@ -11,8 +11,10 @@ export function registerHide(program: Command) { .description("Hide a skill (admin only)") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") - .action(async (slug: string, opts: { yes?: boolean }) => { - const { namespace, slug: skillSlug } = parseSkillName(slug); + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); if (!opts.yes) { const { createInterface } = await import("node:readline"); const rl = createInterface({ input: process.stdin, output: process.stdout }); @@ -52,8 +54,10 @@ export function registerHide(program: Command) { .description("Unhide a skill (admin only)") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") - .action(async (slug: string, opts: { yes?: boolean }) => { - const { namespace, slug: skillSlug } = parseSkillName(slug); + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); if (!opts.yes) { const { createInterface } = await import("node:readline"); const rl = createInterface({ input: process.stdin, output: process.stdout }); diff --git a/skillhub-cli/src/commands/inspect.ts b/skillhub-cli/src/commands/inspect.ts index cec97781f..19968d468 100644 --- a/skillhub-cli/src/commands/inspect.ts +++ b/skillhub-cli/src/commands/inspect.ts @@ -3,7 +3,7 @@ import { ApiClient } from "../core/api-client.js"; import { ApiRoutes } from "../schema/routes.js"; import { loadConfigFromProgram } 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"; import { searchSkills } from "../core/interactive-search.js"; import * as p from "@clack/prompts"; @@ -149,8 +149,6 @@ export function registerInspect(program: Command) { 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; async function fetchVersionsAndTags(ns: string, skillSlug: string) { try { @@ -169,16 +167,16 @@ export function registerInspect(program: Command) { const detail = await client.get( `${ApiRoutes.skillDetail.replace("{namespace}", ns).replace("{slug}", skillSlug)}` ); - + const { versions, tags } = await fetchVersionsAndTags(ns, skillSlug); - + if (version && versions) { const selectedVersion = versions.find(v => v.version === version); if (selectedVersion) { detail.publishedVersion = { version: selectedVersion.version }; } } - + if (isJson) { const output = opts.details ? { ...detail, versions, tags } : detail; console.log(JSON.stringify(output, null, 2)); @@ -200,22 +198,22 @@ export function registerInspect(program: Command) { async function inspectWithVersionSelection(ns: string, skillSlug: string) { const { versions, tags } = await fetchVersionsAndTags(ns, skillSlug); - + if (!versions || versions.length === 0) { await displaySkillDetail(ns, skillSlug); return; } - + if (opts.details) { await displaySkillDetail(ns, skillSlug); return; } - + if (versions.length === 1) { await displaySkillDetail(ns, skillSlug); return; } - + const versionTagsMap = new Map(); if (tags) { for (const tag of tags) { @@ -225,7 +223,7 @@ export function registerInspect(program: Command) { versionTagsMap.get(tag.versionId)!.push(tag.tagName); } } - + const selected = await p.select({ message: "Select version to inspect", options: versions.map((v) => ({ @@ -234,82 +232,22 @@ export function registerInspect(program: Command) { hint: versionTagsMap.get(v.id)?.join(", ") || "", })), }); - + if (p.isCancel(selected)) { console.log("Cancelled."); return; } - + await displaySkillDetail(ns, skillSlug, selected as string); } - if (targetNamespace) { + const { resolveSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace: targetNamespace, slug: parsedSlug } = await resolveSkillNamespace(client, slug, opts.namespace); + if (opts.skillVersion) { await displaySkillDetail(targetNamespace, parsedSlug, opts.skillVersion); } else { await inspectWithVersionSelection(targetNamespace, parsedSlug); } - return; - } - - const spinner = ora(`Searching for ${parsedSlug}`).start(); - - try { - const results = await searchSkills(client, parsedSlug, 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) { - spinner.fail(`Skill not found: ${parsedSlug}`); - process.exitCode = 1; - return; - } - - if (uniqueResults.length === 1) { - spinner.stop(); - const ns = uniqueResults[0].namespace; - const name = uniqueResults[0].name; - if (opts.skillVersion) { - await displaySkillDetail(ns, name, opts.skillVersion); - } else { - await inspectWithVersionSelection(ns, name); - } - return; - } - - spinner.succeed(`Found ${uniqueResults.length} matches for ${parsedSlug}`); - - const selected = await p.select({ - message: "Select skill to inspect", - options: uniqueResults.map((r) => ({ - value: `${r.namespace}/${r.name}`, - label: `${r.namespace}/${r.name}`, - hint: r.summary ? r.summary.slice(0, 50) : undefined, - })), - }); - - if (p.isCancel(selected)) { - console.log("Cancelled."); - return; - } - - const [selectedNs, selectedName] = (selected as string).split("/", 2); - if (opts.skillVersion) { - await displaySkillDetail(selectedNs, selectedName, opts.skillVersion); - } else { - await inspectWithVersionSelection(selectedNs, selectedName); - } - } catch (e: any) { - spinner.fail(e.message); - process.exitCode = 1; - } }); } diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 8552f02cb..edcf11b21 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -210,8 +210,8 @@ function buildInstallHelp(cmd: Command): string { lines.push(""); lines.push(chalk.bold("Source Options:")); - lines.push(` ${chalk.cyan("-a, --add ")} Install from GitHub or local path`); - lines.push(` ${chalk.cyan("--from ")} Alias for --add`); + lines.push(` ${chalk.cyan("--from ")} Install from GitHub or local path`); + lines.push(` ${chalk.cyan("-a, --add ")} Alias for --from`); lines.push(""); lines.push(chalk.bold("Target Options:")); @@ -250,8 +250,8 @@ export function registerInstall(program: Command) { .alias("i") .description("Install skills from registry, git repositories, or local paths") .argument("", "Skill name or namespace/skill-name from registry") - .option("-a, --add ", "Install from GitHub or local path") - .option("--from ", "Alias for --add") + .option("--from ", "Install from GitHub or local path") + .option("-a, --add ", "Alias for --from") .option("--agent ", "Target specific agents") .option("-g, --global", "Install to global scope") .option("-y, --yes", "Skip all prompts") diff --git a/skillhub-cli/src/commands/rating.ts b/skillhub-cli/src/commands/rating.ts index 9df10fa2c..8e8d5cb1f 100644 --- a/skillhub-cli/src/commands/rating.ts +++ b/skillhub-cli/src/commands/rating.ts @@ -3,16 +3,16 @@ import { ApiClient } from "../core/api-client.js"; import { loadConfig, loadConfigFromProgram } 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") .argument("", "Skill name or namespace/skill-name") - .action(async (slug: string) => { + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { namespace?: string }) => { try { - const { namespace, slug: skillSlug } = parseSkillName(slug); + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); const token = await requireToken(); const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); @@ -44,7 +44,8 @@ export function registerRate(program: Command) { .description("Rate a skill (1-5)") .argument("", "Skill name or namespace/skill-name") .argument("", "Rating score (1-5)") - .action(async (slug: string, scoreStr: string) => { + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, scoreStr: string, opts: { namespace?: string }) => { const score = parseInt(scoreStr, 10); if (isNaN(score) || score < 1 || score > 5) { error("Score must be between 1 and 5"); @@ -52,7 +53,8 @@ export function registerRate(program: Command) { } try { - const { namespace, slug: skillSlug } = parseSkillName(slug); + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); const token = await requireToken(); const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); diff --git a/skillhub-cli/src/commands/report.ts b/skillhub-cli/src/commands/report.ts index 873682307..bb2cf22d8 100644 --- a/skillhub-cli/src/commands/report.ts +++ b/skillhub-cli/src/commands/report.ts @@ -4,17 +4,17 @@ import { ApiClient } from "../core/api-client.js"; import { requireToken } from "../core/auth-token.js"; import { loadConfig, loadConfigFromProgram } 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") .argument("", "Skill name or namespace/skill-name") .option("--reason ", "Report reason") - .action(async (slug: string, opts: { reason?: string }) => { + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { reason?: string; namespace?: string }) => { try { - const { namespace, slug: skillSlug } = parseSkillName(slug); + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); const token = await requireToken(); const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); diff --git a/skillhub-cli/src/commands/resolve.ts b/skillhub-cli/src/commands/resolve.ts index 3d44935c2..13deba671 100644 --- a/skillhub-cli/src/commands/resolve.ts +++ b/skillhub-cli/src/commands/resolve.ts @@ -49,9 +49,11 @@ export function registerResolve(program: Command) { .option("-v, --skill-version ", "Specific version") .option("--tag ", "Tag to resolve (default: latest, ignored if --skill-version)") .option("--hash ", "Content hash") + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") .action(async (slug: string, opts: Record) => { try { - const { namespace, slug: skillSlug } = parseSkillName(slug); + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); diff --git a/skillhub-cli/src/commands/star.ts b/skillhub-cli/src/commands/star.ts index fbd06599f..606fc34c5 100644 --- a/skillhub-cli/src/commands/star.ts +++ b/skillhub-cli/src/commands/star.ts @@ -4,7 +4,7 @@ import { ApiRoutes } from "../schema/routes.js"; import { requireToken } from "../core/auth-token.js"; import { loadConfig, loadConfigFromProgram } from "../core/config.js"; import { success, error } from "../utils/logger.js"; -import { parseSkillName } from "../core/skill-name.js"; + export function registerStar(program: Command) { program @@ -12,9 +12,11 @@ export function registerStar(program: Command) { .description("Star a skill") .argument("", "Skill name or namespace/skill-name") .option("--unstar", "Remove star") - .action(async (slug: string, opts: { unstar: boolean }) => { + .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") + .action(async (slug: string, opts: { unstar: boolean; namespace?: string }) => { try { - const { namespace, slug: skillSlug } = parseSkillName(slug); + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); const token = await requireToken(); const config = loadConfigFromProgram(program); const client = new ApiClient({ baseUrl: config.registry, token }); diff --git a/skillhub-cli/src/core/skill-resolver.ts b/skillhub-cli/src/core/skill-resolver.ts new file mode 100644 index 000000000..71f7ae9e9 --- /dev/null +++ b/skillhub-cli/src/core/skill-resolver.ts @@ -0,0 +1,86 @@ +import { ApiClient } from "./api-client.js"; +import { parseSkillName } from "./skill-name.js"; +import { searchSkills, runInteractiveSearch } from "./interactive-search.js"; + +export interface ResolvedSkill { + namespace: string; + slug: string; + userSpecified: boolean; +} + +/** + * 解析 skill 的 namespace,支持智能搜索 + * + * @param client - API 客户端 + * @param slug - 输入的 skill 名称(可能包含 namespace) + * @param explicitNamespace - 通过 --namespace 选项显式指定的 namespace + * @returns 解析后的 namespace 和 slug + */ +export async function resolveSkillNamespace( + client: ApiClient, + slug: string, + explicitNamespace?: string +): Promise { + const { namespace: parsedNs, slug: actualSlug } = parseSkillName(slug); + + if (explicitNamespace) { + return { namespace: explicitNamespace, slug: actualSlug, userSpecified: true }; + } + + if (parsedNs !== "global") { + return { namespace: parsedNs, slug: actualSlug, userSpecified: true }; + } + + const results = await searchSkills(client, actualSlug, 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) { + throw new Error(`Skill not found: ${actualSlug}`); + } + + if (uniqueResults.length === 1) { + return { + namespace: uniqueResults[0].namespace, + slug: uniqueResults[0].name, + userSpecified: false, + }; + } + + const selected = await runInteractiveSearch(client, actualSlug); + if (!selected) { + throw new Error("Cancelled"); + } + + const [ns, name] = selected.split("/", 2); + return { namespace: ns, slug: name, userSpecified: false }; +} + +/** + * 简单解析 skill namespace,不触发智能搜索 + * 用于不需要搜索的命令(delete, star 等) + */ +export function parseSkillNamespace( + slug: string, + explicitNamespace?: string +): ResolvedSkill { + const { namespace: parsedNs, slug: actualSlug } = parseSkillName(slug); + + if (explicitNamespace) { + return { namespace: explicitNamespace, slug: actualSlug, userSpecified: true }; + } + + return { + namespace: parsedNs, + slug: actualSlug, + userSpecified: parsedNs !== "global", + }; +} From f4752f2d9beac4b218a04f828563564aeb5222e5 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:32:13 +0800 Subject: [PATCH 51/68] =?UTF-8?q?feat(cli):=20=E4=BC=98=E5=8C=96=20downloa?= =?UTF-8?q?d=20=E5=92=8C=20update=20=E5=91=BD=E4=BB=A4=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=8F=8B=E5=A5=BD=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - download: 添加智能搜索 namespace 和交互式版本选择 - download: 修复下载失败仍创建文件的问题 - update: 重写为带版本对比的更新流程 - update: 添加 -y/--yes 选项跳过确认 - star/rating/rate/report/delete/archive/hide/unhide: 添加 404 友好错误提示 - 提示用户使用 namespace/skill-name 格式 --- skillhub-cli/src/commands/archive.ts | 10 +- skillhub-cli/src/commands/delete.ts | 10 +- skillhub-cli/src/commands/download.ts | 93 ++++++++++++-- skillhub-cli/src/commands/hide.ts | 20 ++- skillhub-cli/src/commands/rating.ts | 20 ++- skillhub-cli/src/commands/report.ts | 10 +- skillhub-cli/src/commands/star.ts | 12 +- skillhub-cli/src/commands/update.ts | 172 ++++++++++++++++++++++---- 8 files changed, 308 insertions(+), 39 deletions(-) diff --git a/skillhub-cli/src/commands/archive.ts b/skillhub-cli/src/commands/archive.ts index c80c80f9c..777cabe10 100644 --- a/skillhub-cli/src/commands/archive.ts +++ b/skillhub-cli/src/commands/archive.ts @@ -33,7 +33,15 @@ export function registerArchive(program: Command) { await client.post(`/api/v1/skills/${namespace}/${skillSlug}/archive`); success(`Archived ${skillSlug}`); } catch (e: any) { - error(`Failed: ${e.message}`); + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } process.exitCode = 1; } }); diff --git a/skillhub-cli/src/commands/delete.ts b/skillhub-cli/src/commands/delete.ts index 968e24b6a..afe16cbdc 100644 --- a/skillhub-cli/src/commands/delete.ts +++ b/skillhub-cli/src/commands/delete.ts @@ -33,7 +33,15 @@ export function registerDelete(program: Command) { await client.delete(`/api/v1/skills/${namespace}/${skillSlug}`); success(`Deleted ${skillSlug} from ${namespace}`); } catch (e: any) { - error(`Failed: ${e.message}`); + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } process.exitCode = 1; } }); diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts index 2a0e07237..24862218e 100644 --- a/skillhub-cli/src/commands/download.ts +++ b/skillhub-cli/src/commands/download.ts @@ -3,10 +3,10 @@ import { createWriteStream } from "node:fs"; import { resolve } from "node:path"; import { finished } from "node:stream/promises"; import { ApiClient } from "../core/api-client.js"; -import { ApiRoutes } from "../schema/routes.js"; -import { loadConfig, loadConfigFromProgram } from "../core/config.js"; +import { loadConfigFromProgram } from "../core/config.js"; import { readToken } from "../core/auth-token.js"; import { success, error } from "../utils/logger.js"; +import * as p from "@clack/prompts"; import ora from "ora"; @@ -57,8 +57,7 @@ export function registerDownload(program: Command) { downloadCmd.helpInformation = () => buildDownloadHelp(downloadCmd); downloadCmd.action(async (slug: string, opts: Record) => { - const { parseSkillNamespace } = await import("../core/skill-resolver.js"); - const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); + const { resolveSkillNamespace, parseSkillNamespace } = await import("../core/skill-resolver.js"); const config = loadConfigFromProgram(program); const token = await readToken(); const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); @@ -66,15 +65,91 @@ export function registerDownload(program: Command) { const outputDir = opts.output ? resolve(process.cwd(), opts.output) : process.cwd(); try { + let namespace: string; + let skillSlug: string; + + if (opts.namespace || slug.includes("/")) { + const parsed = parseSkillNamespace(slug, opts.namespace); + namespace = parsed.namespace; + skillSlug = parsed.slug; + } else { + const spinner = ora(`Searching for ${slug}`).start(); + try { + const resolved = await resolveSkillNamespace(client, slug); + namespace = resolved.namespace; + skillSlug = resolved.slug; + spinner.succeed(`Found ${namespace}/${skillSlug}`); + } catch (e: any) { + spinner.fail(e.message); + process.exitCode = 1; + return; + } + } + const spinner = ora(`Downloading ${skillSlug} from ${namespace}`).start(); - let downloadUrl = `${ApiRoutes.skillDownload.replace("{namespace}", namespace).replace("{slug}", skillSlug)}`; + let selectedVersion: string; if (opts.skillVersion) { - downloadUrl = `/api/v1/skills/${namespace}/${skillSlug}/versions/${opts.skillVersion}/download`; - } else if (opts.tag) { - downloadUrl = `/api/v1/skills/${namespace}/${skillSlug}/tags/${opts.tag}/download`; + selectedVersion = opts.skillVersion; + } else if (opts.tag && opts.tag !== "latest") { + spinner.text = `Resolving tag ${opts.tag}`; + try { + const tagsResp = await client.get>( + `/api/v1/skills/${namespace}/${skillSlug}/tags` + ); + const tags = tagsResp || []; + const matchedTag = tags.find((t) => t.tagName === opts.tag); + if (matchedTag) { + selectedVersion = matchedTag.version; + } else { + spinner.fail(`Tag not found: ${opts.tag}`); + process.exitCode = 1; + return; + } + } catch (e: any) { + spinner.fail(`Failed to fetch tags: ${e.message}`); + process.exitCode = 1; + return; + } + } else { + spinner.stop(); + try { + const versionsResp = await client.get<{ items: Array<{ version: string; publishedAt: string }> }>( + `/api/v1/skills/${namespace}/${skillSlug}/versions` + ); + const versions = versionsResp.items || []; + if (versions.length === 0) { + error(`No versions found for ${namespace}/${skillSlug}`); + process.exitCode = 1; + return; + } + if (versions.length === 1) { + selectedVersion = versions[0].version; + } else { + const picked = await p.select({ + message: "Select version to download", + options: versions.map((v) => ({ + value: v.version, + label: `v${v.version}`, + hint: new Date(v.publishedAt).toLocaleDateString(), + })), + }); + if (p.isCancel(picked)) { + console.log("Cancelled."); + return; + } + selectedVersion = picked as string; + } + spinner.start(`Downloading ${skillSlug}@${selectedVersion}`); + } catch (e: any) { + error(`Failed to fetch versions: ${e.message}`); + process.exitCode = 1; + return; + } } + const downloadUrl = `${config.registry.replace(/\/$/, "")}/api/v1/skills/${namespace}/${skillSlug}/versions/${selectedVersion}/download`; + const { request } = await import("undici"); const url = new URL(downloadUrl, config.registry); let response = await request(url.toString(), { @@ -86,6 +161,7 @@ export function registerDownload(program: Command) { if (!location) { spinner.fail(`Redirect response has no Location header`); process.exitCode = 1; + return; } response = await request(location as string, { method: "GET" }); } @@ -94,6 +170,7 @@ export function registerDownload(program: Command) { if (statusCode >= 400) { spinner.fail(`Download failed: HTTP ${statusCode}`); process.exitCode = 1; + return; } const outPath = resolve(outputDir, `${skillSlug}.zip`); diff --git a/skillhub-cli/src/commands/hide.ts b/skillhub-cli/src/commands/hide.ts index b758ba1f0..4f7deaf8e 100644 --- a/skillhub-cli/src/commands/hide.ts +++ b/skillhub-cli/src/commands/hide.ts @@ -44,7 +44,15 @@ export function registerHide(program: Command) { success(`Hidden ${skillSlug}`); } catch (e: any) { - error(`Failed: ${e.message}`); + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } process.exitCode = 1; } }); @@ -87,7 +95,15 @@ export function registerHide(program: Command) { success(`Unhidden ${skillSlug}`); } catch (e: any) { - error(`Failed: ${e.message}`); + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } process.exitCode = 1; } }); diff --git a/skillhub-cli/src/commands/rating.ts b/skillhub-cli/src/commands/rating.ts index 8e8d5cb1f..f4aec8988 100644 --- a/skillhub-cli/src/commands/rating.ts +++ b/skillhub-cli/src/commands/rating.ts @@ -32,7 +32,15 @@ export function registerRating(program: Command) { dim("Use: skillhub rate "); } } catch (e: any) { - error(`Failed: ${e.message}`); + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } process.exitCode = 1; } }); @@ -69,7 +77,15 @@ export function registerRate(program: Command) { }); success(`Rated ${skillSlug}: ${"★".repeat(score)}${"☆".repeat(5 - score)}`); } catch (e: any) { - error(`Failed: ${e.message}`); + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } process.exitCode = 1; } }); diff --git a/skillhub-cli/src/commands/report.ts b/skillhub-cli/src/commands/report.ts index bb2cf22d8..b0740b75d 100644 --- a/skillhub-cli/src/commands/report.ts +++ b/skillhub-cli/src/commands/report.ts @@ -34,7 +34,15 @@ export function registerReport(program: Command) { }); success(`Report submitted for ${skillSlug}`); } catch (e: any) { - error(`Failed: ${e.message}`); + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } process.exitCode = 1; } }); diff --git a/skillhub-cli/src/commands/star.ts b/skillhub-cli/src/commands/star.ts index 606fc34c5..a42223dcd 100644 --- a/skillhub-cli/src/commands/star.ts +++ b/skillhub-cli/src/commands/star.ts @@ -3,7 +3,7 @@ import { ApiClient } from "../core/api-client.js"; import { ApiRoutes } from "../schema/routes.js"; import { requireToken } from "../core/auth-token.js"; import { loadConfig, loadConfigFromProgram } from "../core/config.js"; -import { success, error } from "../utils/logger.js"; +import { success, error, dim } from "../utils/logger.js"; export function registerStar(program: Command) { @@ -33,7 +33,15 @@ export function registerStar(program: Command) { success(`Starred ${skillSlug}`); } } catch (e: any) { - error(`Failed: ${e.message}`); + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } process.exitCode = 1; } }); diff --git a/skillhub-cli/src/commands/update.ts b/skillhub-cli/src/commands/update.ts index 1082cff41..9f4c1c435 100644 --- a/skillhub-cli/src/commands/update.ts +++ b/skillhub-cli/src/commands/update.ts @@ -1,27 +1,45 @@ import { Command } from "commander"; -import { success, error, info, warn } from "../utils/logger.js"; -import { getAllLockedSkills, getSkillLockPath } from "../core/skill-lock.js"; +import { success, error, info, warn, dim } from "../utils/logger.js"; +import { getAllLockedSkills, getSkillLockPath, type SkillLockEntry } from "../core/skill-lock.js"; import { existsSync } from "node:fs"; import { execSync } from "node:child_process"; import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; +import { ApiClient } from "../core/api-client.js"; +import { loadConfigFromProgram } from "../core/config.js"; +import { readToken } from "../core/auth-token.js"; +import * as p from "@clack/prompts"; +import ora from "ora"; function getCliCommand(): string { const cliPath = process.argv[1]; return `node "${cliPath}"`; } +interface UpdateInfo { + name: string; + currentVersion: string; + latestVersion: string; + namespace: string; + slug: string; + source: string; + sourceType: string; + hasUpdate: boolean; +} + export function registerUpdate(program: Command) { program .command("update [skill]") .description("Update installed skills from their source") .option("-a, --all", "Update all installed skills") .option("-g, --global", "Update global scope skills") + .option("-y, --yes", "Skip confirmation and update all outdated 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.exitCode = 1; + return; } const lockedSkills = await getAllLockedSkills(); @@ -30,21 +48,32 @@ export function registerUpdate(program: Command) { if (allSkillNames.length === 0) { error("No skills in lock file."); process.exitCode = 1; + return; } - let skillsToUpdate: string[] = []; + const config = loadConfigFromProgram(program); + const token = await readToken(); + const client = new ApiClient({ baseUrl: config.registry, token: token || undefined }); + + let skillsToCheck: string[] = []; if (opts.all) { - skillsToUpdate = allSkillNames; + skillsToCheck = allSkillNames; } else if (slug) { - skillsToUpdate = [slug]; + if (!lockedSkills[slug]) { + error(`Skill not found in lock file: ${slug}`); + error(`Installed skills: ${allSkillNames.join(", ")}`); + process.exitCode = 1; + return; + } + skillsToCheck = [slug]; } else { const selected = await searchMultiselect({ - message: "Select skills to update", + message: "Select skills to check for updates", items: allSkillNames.map((name) => ({ value: name, label: name, - hint: lockedSkills[name].sourceType, + hint: `${lockedSkills[name].namespace}/${lockedSkills[name].slug} @ ${lockedSkills[name].version}`, })), required: true, }); @@ -54,33 +83,132 @@ export function registerUpdate(program: Command) { return; } - skillsToUpdate = selected as string[]; + skillsToCheck = selected as string[]; } - const scope = opts.global ? "--global" : ""; - const cliCmd = getCliCommand(); + const spinner = ora("Checking for updates...").start(); + const updates: UpdateInfo[] = []; + const upToDate: string[] = []; + const checkFailed: string[] = []; - let updated = 0; - let failed = 0; - - for (const name of skillsToUpdate) { + for (const name of skillsToCheck) { const entry = lockedSkills[name]; - if (!entry) { - warn(`Skill not found in lock: ${name}`); + if (!entry) continue; + + if (entry.sourceType !== "registry") { + checkFailed.push(name); continue; } try { - info(`Updating ${name} from ${entry.source}...`); - const source = entry.sourceType === "registry" - ? entry.source - : entry.sourceUrl; + const versionsResp = await client.get<{ items: Array<{ version: string }> }>( + `/api/v1/skills/${entry.namespace}/${entry.slug}/versions` + ); + const versions = versionsResp.items || []; - const cmd = `${cliCmd} install ${source} ${scope}`.trim(); + if (versions.length === 0) { + checkFailed.push(name); + continue; + } + + const latestVersion = versions[0].version; + const currentVersion = entry.version; + + if (latestVersion === currentVersion) { + upToDate.push(name); + } else { + updates.push({ + name, + currentVersion, + latestVersion, + namespace: entry.namespace, + slug: entry.slug, + source: entry.source, + sourceType: entry.sourceType, + hasUpdate: true, + }); + } + } catch (e: any) { + checkFailed.push(name); + } + } + + spinner.stop(); + + if (upToDate.length > 0) { + console.log(""); + info(`Up to date (${upToDate.length}):`); + for (const name of upToDate) { + dim(` ✓ ${name} @ ${lockedSkills[name].version}`); + } + } + + if (checkFailed.length > 0) { + console.log(""); + warn(`Check failed (${checkFailed.length}):`); + for (const name of checkFailed) { + dim(` ✗ ${name}`); + } + } + + if (updates.length === 0) { + console.log(""); + success("All skills are up to date!"); + return; + } + + console.log(""); + info(`Updates available (${updates.length}):`); + for (const u of updates) { + console.log(` ↑ ${u.name}: ${u.currentVersion} → ${u.latestVersion}`); + } + + let skillsToUpdate = updates; + + if (!opts.yes && !opts.all && !slug) { + const selected = await searchMultiselect({ + message: "Select skills to update", + items: updates.map((u) => ({ + value: u.name, + label: u.name, + hint: `${u.currentVersion} → ${u.latestVersion}`, + })), + required: true, + }); + + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } + + skillsToUpdate = updates.filter((u) => (selected as string[]).includes(u.name)); + } + + if (!opts.yes) { + const confirmed = await p.confirm({ + message: `Update ${skillsToUpdate.length} skill(s)?`, + }); + + if (p.isCancel(confirmed) || !confirmed) { + console.log("Cancelled."); + return; + } + } + + const scope = opts.global ? "--global" : ""; + const cliCmd = getCliCommand(); + + let updated = 0; + let failed = 0; + + for (const info of skillsToUpdate) { + try { + info(`Updating ${info.name} from ${info.currentVersion} to ${info.latestVersion}...`); + const cmd = `${cliCmd} install ${info.namespace}/${info.slug} --skill-version ${info.latestVersion} ${scope}`.trim(); execSync(cmd, { stdio: "inherit" }); updated++; } catch (e: any) { - error(`Failed to update ${name}: ${e.message}`); + error(`Failed to update ${info.name}: ${e.message}`); failed++; } } From 803540b157b004c9d55a2c957c4eb3859060b747 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:47:53 +0800 Subject: [PATCH 52/68] =?UTF-8?q?feat(auth):=20=E4=BC=98=E5=8C=96=E6=9D=83?= =?UTF-8?q?=E9=99=90=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=89=A9=E5=B1=95=20SKILL?= =?UTF-8?q?=5FADMIN=20=E5=92=8C=20AUDITOR=20=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 后端修改 - 添加 RouteSecurityPolicyRegistry 权限配置(修复12个CLI命令403错误) - /api/v1/me/** - 用户个人相关 - /api/v1/notifications/** - 通知相关 - /api/v1/reviews/** - 审核相关 - /api/v1/skills/** POST/PUT - archive, rating, report - /api/v1/namespaces/** POST - transfer - DELETE /api/v1/skills/*/* - 改为所有者可删除 - AdminSkillController: hide/unhide 允许 SKILL_ADMIN - SkillDeleteController: delete 允许 SKILL_ADMIN - 新增 AdminAuditorController: 审计员专用控制器 ## CLI修改 - publish.ts: 修复 -v 选项冲突,改为只使用 --skill-version Co-Authored-By: Claude Sonnet 4.6 --- .../admin/AdminAuditorController.java | 35 ++++++++++++++++ .../admin/AdminSkillController.java | 4 +- .../portal/SkillDeleteController.java | 4 +- .../policy/RouteSecurityPolicyRegistry.java | 42 ++++++++++++++++++- 4 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminAuditorController.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminAuditorController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminAuditorController.java new file mode 100644 index 000000000..698511246 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminAuditorController.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Auditor-specific endpoints for read-only access to platform data. + * AUDITOR role can view all data for compliance and auditing purposes. + */ +@RestController +@RequestMapping("/api/v1/admin/auditor") +public class AdminAuditorController extends BaseApiController { + + public AdminAuditorController(ApiResponseFactory responseFactory) { + super(responseFactory); + } + + // TODO: Implement auditor-specific endpoints + // Examples: + // - GET /all-skills - View all skills including hidden + // - GET /review-history - View review history + // - GET /user-activity - View user activity logs + // - GET /statistics - View platform statistics + + @GetMapping("/status") + @PreAuthorize("hasAnyRole('AUDITOR', 'SUPER_ADMIN')") + public ApiResponse getStatus() { + return ok("response.success", "Auditor API is ready. More endpoints coming soon."); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java index 077538a97..d6d6d59a5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java @@ -33,7 +33,7 @@ public AdminSkillController(ApiResponseFactory responseFactory, } @PostMapping("/{skillId}/hide") - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") public ApiResponse hideSkill(@PathVariable Long skillId, @RequestBody(required = false) AdminSkillActionRequest request, @AuthenticationPrincipal PlatformPrincipal principal, @@ -49,7 +49,7 @@ public ApiResponse hideSkill(@PathVariable Long skil } @PostMapping("/{skillId}/unhide") - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") public ApiResponse unhideSkill(@PathVariable Long skillId, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillDeleteController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillDeleteController.java index 40fdb3212..8651c94c8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillDeleteController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillDeleteController.java @@ -32,7 +32,7 @@ public SkillDeleteController(SkillDeleteAppService skillDeleteAppService, } @DeleteMapping("/id/{skillId}") - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") public ApiResponse deleteSkillById(@PathVariable Long skillId, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request) { @@ -50,7 +50,7 @@ public ApiResponse deleteSkillById(@PathVariable Long skill } @DeleteMapping("/{namespace}/{slug}") - @PreAuthorize("hasRole('SUPER_ADMIN')") + @PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") public ApiResponse deleteSkill(@PathVariable String namespace, @PathVariable String slug, @RequestParam(required = false) String ownerId, diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 8235032cc..9d1133f14 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -67,14 +67,34 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/files"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/labels"), - RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/id/*", "SUPER_ADMIN"), - RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/id/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/*/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/me/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/me/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/notifications"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/notifications/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/notifications/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/notifications"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/notifications/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/notifications/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/reviews/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/reviews/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/reviews/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/reviews/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/skills/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/skills/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/v1/skills/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/skills/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/namespaces/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/namespaces/**"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/*/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/*"), RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**") ); @@ -94,6 +114,24 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/me/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/me/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/notifications"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/notifications/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/notifications/*"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/notifications"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/notifications/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/notifications/*"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/reviews/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/reviews/**"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/reviews/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/reviews/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/skills/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/skills/**"), + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/**"), + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/web/skills/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/namespaces/**"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/namespaces/**"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resolve/**"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/download"), ApiTokenPolicy.allow(null, "/.well-known/**"), From aa9fd5f7714d0b5ecde22011ccf5ec5a700064d5 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:16:16 +0800 Subject: [PATCH 53/68] feat(cli): add command aliases and unhide command --- skillhub-cli/src/cli.ts | 6 +- skillhub-cli/src/commands/config.ts | 2 +- skillhub-cli/src/commands/delete.ts | 3 +- skillhub-cli/src/commands/explore.ts | 1 + skillhub-cli/src/commands/hide.ts | 144 ++++++++------------- skillhub-cli/src/commands/inspect.ts | 1 + skillhub-cli/src/commands/list.ts | 1 + skillhub-cli/src/commands/me.ts | 5 +- skillhub-cli/src/commands/namespaces.ts | 2 +- skillhub-cli/src/commands/notifications.ts | 1 + skillhub-cli/src/commands/publish.ts | 4 +- skillhub-cli/src/commands/reviews.ts | 1 + skillhub-cli/src/commands/uninstall.ts | 1 + skillhub-cli/src/commands/update.ts | 1 + skillhub-cli/src/commands/whoami.ts | 2 +- skillhub-cli/src/core/api-client.ts | 30 +++++ 16 files changed, 108 insertions(+), 97 deletions(-) diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index 3fa890755..6ff6a7bac 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -144,7 +144,8 @@ export async function createCli(): Promise { .description("CLI for SkillHub — publish, search, and manage agent skills") .version(version) .option("--registry ", "Registry API base URL") - .option("--json", "Output results as JSON"); + .option("--json", "Output results as JSON") + .option("--debug", "Show debug information for API requests"); const customHelp = buildTopLevelHelp(version); const originalHelpInformation = program.helpInformation.bind(program); @@ -179,7 +180,7 @@ export async function createCli(): Promise { { registerInspect }, { registerExplore }, { registerTransfer }, - { registerHide }, + { registerHide, registerUnhide }, { registerConfig }, ] = await Promise.all([ import("./commands/login.js"), @@ -238,6 +239,7 @@ export async function createCli(): Promise { registerExplore(program); registerTransfer(program); registerHide(program); + registerUnhide(program); registerConfig(program); return program; diff --git a/skillhub-cli/src/commands/config.ts b/skillhub-cli/src/commands/config.ts index ef0287319..ad75af81b 100644 --- a/skillhub-cli/src/commands/config.ts +++ b/skillhub-cli/src/commands/config.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { success, error, info } from "../utils/logger.js"; +import { success, error, info, dim } from "../utils/logger.js"; import chalk from "chalk"; const CONFIG_DIR = join(homedir(), ".skillhub"); diff --git a/skillhub-cli/src/commands/delete.ts b/skillhub-cli/src/commands/delete.ts index afe16cbdc..42b6fb1eb 100644 --- a/skillhub-cli/src/commands/delete.ts +++ b/skillhub-cli/src/commands/delete.ts @@ -6,6 +6,7 @@ import { success, error } from "../utils/logger.js"; export function registerDelete(program: Command) { program .command("delete") + .aliases(["del", "unpublish"]) .description("Delete a skill you own") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") @@ -29,7 +30,7 @@ export function registerDelete(program: Command) { try { const token = await requireToken(); const config = loadConfigFromProgram(program); - const client = new ApiClient({ baseUrl: config.registry, token }); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); await client.delete(`/api/v1/skills/${namespace}/${skillSlug}`); success(`Deleted ${skillSlug} from ${namespace}`); } catch (e: any) { diff --git a/skillhub-cli/src/commands/explore.ts b/skillhub-cli/src/commands/explore.ts index 898fd27c3..46776ccc3 100644 --- a/skillhub-cli/src/commands/explore.ts +++ b/skillhub-cli/src/commands/explore.ts @@ -255,6 +255,7 @@ function buildExploreHelp(cmd: Command): string { export function registerExplore(program: Command) { const exploreCmd = 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") diff --git a/skillhub-cli/src/commands/hide.ts b/skillhub-cli/src/commands/hide.ts index 4f7deaf8e..5908d6f28 100644 --- a/skillhub-cli/src/commands/hide.ts +++ b/skillhub-cli/src/commands/hide.ts @@ -2,109 +2,79 @@ import { Command } from "commander"; import { ApiClient } from "../core/api-client.js"; import { requireToken } from "../core/auth-token.js"; import { loadConfig, loadConfigFromProgram } from "../core/config.js"; -import { success, error } from "../utils/logger.js"; +import { success, error, dim } from "../utils/logger.js"; +async function hideSkill( + program: Command, + slug: string, + opts: { yes?: boolean; namespace?: string }, + action: "hide" | "unhide" +) { + const { parseSkillNamespace } = await import("../core/skill-resolver.js"); + const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); + if (!opts.yes) { + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const actionText = action === "hide" ? "Hide" : "Unhide"; + const answer = await new Promise((r) => + rl.question(`${actionText} ${skillSlug} from ${namespace}? [y/N] `, r) + ); + rl.close(); + if (answer.toLowerCase() !== "y") { + console.log("Cancelled."); + return; + } + } + + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + 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}/${action}`, { + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }); + + success(`${action === "hide" ? "Hidden" : "Unhidden"} ${skillSlug}`); + } catch (e: any) { + const status = e.status || e.statusCode; + if (status === 404) { + error(`Skill not found: ${namespace}/${skillSlug}`); + if (!slug.includes("/")) { + dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); + } + } else { + error(`Failed: ${e.message}`); + } + process.exitCode = 1; + } +} export function registerHide(program: Command) { - const hideCmd = program + program .command("hide") .description("Hide a skill (admin only)") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { - const { parseSkillNamespace } = await import("../core/skill-resolver.js"); - const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); - 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 = loadConfigFromProgram(program); - 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) { - const status = e.status || e.statusCode; - if (status === 404) { - error(`Skill not found: ${namespace}/${skillSlug}`); - if (!slug.includes("/")) { - dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); - } - } else { - error(`Failed: ${e.message}`); - } - process.exitCode = 1; - } + await hideSkill(program, slug, opts, "hide"); }); +} - hideCmd +export function registerUnhide(program: Command) { + program .command("unhide") .description("Unhide a skill (admin only)") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") .action(async (slug: string, opts: { yes?: boolean; namespace?: string }) => { - const { parseSkillNamespace } = await import("../core/skill-resolver.js"); - const { namespace, slug: skillSlug } = parseSkillNamespace(slug, opts.namespace); - 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 = loadConfigFromProgram(program); - 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) { - const status = e.status || e.statusCode; - if (status === 404) { - error(`Skill not found: ${namespace}/${skillSlug}`); - if (!slug.includes("/")) { - dim("Tip: Use namespace/skill-name format, e.g., vision2group/docker-build-push"); - } - } else { - error(`Failed: ${e.message}`); - } - process.exitCode = 1; - } + await hideSkill(program, slug, opts, "unhide"); }); } diff --git a/skillhub-cli/src/commands/inspect.ts b/skillhub-cli/src/commands/inspect.ts index 19968d468..74d367253 100644 --- a/skillhub-cli/src/commands/inspect.ts +++ b/skillhub-cli/src/commands/inspect.ts @@ -138,6 +138,7 @@ function printInspectHeader(detail: SkillDetailResponse, versions?: SkillVersion export function registerInspect(program: Command) { program .command("inspect") + .aliases(["info", "view"]) .description("View skill metadata without installing") .argument("", "Skill name or namespace/skill-name") .option("--namespace ", "Search in specific namespace (searches all if not specified)") diff --git a/skillhub-cli/src/commands/list.ts b/skillhub-cli/src/commands/list.ts index 6205dc0fc..c9cf1fc58 100644 --- a/skillhub-cli/src/commands/list.ts +++ b/skillhub-cli/src/commands/list.ts @@ -18,6 +18,7 @@ interface ListOptions { 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") diff --git a/skillhub-cli/src/commands/me.ts b/skillhub-cli/src/commands/me.ts index a0b3a9113..32d9391ae 100644 --- a/skillhub-cli/src/commands/me.ts +++ b/skillhub-cli/src/commands/me.ts @@ -28,12 +28,13 @@ export function registerMe(program: Command) { me .command("skills") + .alias("ls") .description("List your published skills") .action(async () => { try { const token = await requireToken(); const config = loadConfigFromProgram(program); - const client = new ApiClient({ baseUrl: config.registry, token }); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); const resp = await client.get("/api/v1/me/skills"); const skills = resp.items || []; const isJson = program.opts().json; @@ -63,7 +64,7 @@ export function registerMe(program: Command) { try { const token = await requireToken(); const config = loadConfigFromProgram(program); - const client = new ApiClient({ baseUrl: config.registry, token }); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); const resp = await client.get("/api/v1/me/stars"); const skills = resp.items || []; const isJson = program.opts().json; diff --git a/skillhub-cli/src/commands/namespaces.ts b/skillhub-cli/src/commands/namespaces.ts index 6607070bf..db045de96 100644 --- a/skillhub-cli/src/commands/namespaces.ts +++ b/skillhub-cli/src/commands/namespaces.ts @@ -13,7 +13,7 @@ export function registerNamespaces(program: Command) { try { const token = await requireToken(); const config = loadConfigFromProgram(program); - const client = new ApiClient({ baseUrl: config.registry, token }); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); const namespaces = await client.get(ApiRoutes.meNamespaces); const isJson = program.opts().json; if (isJson) { diff --git a/skillhub-cli/src/commands/notifications.ts b/skillhub-cli/src/commands/notifications.ts index f2929c0ab..0e83a6bd3 100644 --- a/skillhub-cli/src/commands/notifications.ts +++ b/skillhub-cli/src/commands/notifications.ts @@ -15,6 +15,7 @@ export interface Notification { export function registerNotifications(program: Command) { const cmd = program .command("notifications") + .alias("notif") .description("Manage notifications"); cmd diff --git a/skillhub-cli/src/commands/publish.ts b/skillhub-cli/src/commands/publish.ts index 26e02aec9..47828795b 100644 --- a/skillhub-cli/src/commands/publish.ts +++ b/skillhub-cli/src/commands/publish.ts @@ -16,7 +16,7 @@ export function registerPublish(program: Command) { .description("Publish a skill to SkillHub registry") .option("--namespace ", "Target namespace (default: global)") .option("--slug ", "Skill slug") - .option("-v, --skill-version ", "Version (semver)") + .option("--skill-version ", "Version (semver)") .option("--name ", "Display name") .option("--changelog ", "Changelog text") .option("--tag ", "Comma-separated tags (e.g. beta,stable)", "latest") @@ -29,7 +29,7 @@ export function registerPublish(program: Command) { } const slug = opts.slug || basename(folder); - let version = opts["skill-version"] || opts.ver; + let version = opts["skill-version"] || opts.v; if (!version) { const now = new Date(); const yyyymmdd = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate(); diff --git a/skillhub-cli/src/commands/reviews.ts b/skillhub-cli/src/commands/reviews.ts index ca6ee1c02..745c6113d 100644 --- a/skillhub-cli/src/commands/reviews.ts +++ b/skillhub-cli/src/commands/reviews.ts @@ -19,6 +19,7 @@ export function registerReviews(program: Command) { reviews .command("my") + .alias("submissions") .description("List your review submissions") .action(async () => { try { diff --git a/skillhub-cli/src/commands/uninstall.ts b/skillhub-cli/src/commands/uninstall.ts index a3e78883b..9bc583b0d 100644 --- a/skillhub-cli/src/commands/uninstall.ts +++ b/skillhub-cli/src/commands/uninstall.ts @@ -111,6 +111,7 @@ function findAgentsWithSkill(skillName: string, scope: "global" | "local", agent export function registerUninstall(program: Command) { program .command("uninstall [skill]") + .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") diff --git a/skillhub-cli/src/commands/update.ts b/skillhub-cli/src/commands/update.ts index 9f4c1c435..c37d4787f 100644 --- a/skillhub-cli/src/commands/update.ts +++ b/skillhub-cli/src/commands/update.ts @@ -29,6 +29,7 @@ interface UpdateInfo { export function registerUpdate(program: Command) { program .command("update [skill]") + .alias("up") .description("Update installed skills from their source") .option("-a, --all", "Update all installed skills") .option("-g, --global", "Update global scope skills") diff --git a/skillhub-cli/src/commands/whoami.ts b/skillhub-cli/src/commands/whoami.ts index 43cf73d12..cfcb50e52 100644 --- a/skillhub-cli/src/commands/whoami.ts +++ b/skillhub-cli/src/commands/whoami.ts @@ -13,7 +13,7 @@ export function registerWhoami(program: Command) { try { const token = await requireToken(); const config = loadConfigFromProgram(program); - const client = new ApiClient({ baseUrl: config.registry, token }); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); const resp = await client.get(ApiRoutes.whoami); const isJson = program.opts().json; if (isJson) { diff --git a/skillhub-cli/src/core/api-client.ts b/skillhub-cli/src/core/api-client.ts index cd5850a8b..32bd2cef3 100644 --- a/skillhub-cli/src/core/api-client.ts +++ b/skillhub-cli/src/core/api-client.ts @@ -3,6 +3,7 @@ import { request, FormData as UndiciFormData } from "undici"; export interface ApiClientOptions { baseUrl: string; token?: string; + debug?: boolean; } interface NativeApiResponse { @@ -37,13 +38,29 @@ export class ApiClient { return data as T; } + private logDebug(method: string, url: string, statusCode?: number, body?: unknown) { + if (!this.options.debug) return; + const token = this.options.token; + const tokenPreview = token ? `${token.substring(0, 20)}...` : "none"; + console.error(`[DEBUG] ${method} ${url}`); + console.error(`[DEBUG] Token: ${tokenPreview}`); + if (statusCode !== undefined) { + console.error(`[DEBUG] Status: ${statusCode}`); + } + if (body !== undefined) { + console.error(`[DEBUG] Body:`, JSON.stringify(body, null, 2)); + } + } + async get(path: string): Promise { const url = new URL(path, this.options.baseUrl); + this.logDebug("GET", url.toString()); const { statusCode, body } = await request(url.toString(), { method: "GET", headers: this.headers(), }); const data = await body.json(); + this.logDebug("GET", url.toString(), statusCode, data); if (statusCode >= 400) { throw new ApiError(statusCode, data); } @@ -130,6 +147,11 @@ function extractHumanMessage(body: unknown): string | null { if (typeof b.msg === "string" && b.msg.length > 0) return b.msg; if (typeof b.message === "string" && b.message.length > 0) return b.message; if (typeof b.error === "string" && b.error.length > 0) return b.error; + if (typeof b.detail === "string" && b.detail.length > 0) return b.detail; + if (typeof b.reason === "string" && b.reason.length > 0) return b.reason; + if (typeof b.description === "string" && b.description.length > 0) return b.description; + + if (typeof b.data === "string" && b.data.length > 0) return b.data; return null; } @@ -154,6 +176,14 @@ export class ApiError extends Error { detail += " - Or use: skillhub --registry "; } + // Enhanced error messages for 403 Forbidden + if (statusCode === 403) { + detail += "\n\n💡 Access denied. This could mean:\n"; + detail += " - Your account doesn't have permission to access this resource\n"; + detail += " - Contact your administrator if you believe this is an error\n"; + detail += " - Run 'skillhub whoami' to verify your account"; + } + super(detail); } } From 84a72886211ec34f01d436c79ba98c54f7c3cc65 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:16:35 +0800 Subject: [PATCH 54/68] fix(cli): remove debug logging from api-client --- skillhub-cli/src/core/api-client.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/skillhub-cli/src/core/api-client.ts b/skillhub-cli/src/core/api-client.ts index 32bd2cef3..3f782d283 100644 --- a/skillhub-cli/src/core/api-client.ts +++ b/skillhub-cli/src/core/api-client.ts @@ -3,7 +3,6 @@ import { request, FormData as UndiciFormData } from "undici"; export interface ApiClientOptions { baseUrl: string; token?: string; - debug?: boolean; } interface NativeApiResponse { @@ -38,29 +37,13 @@ export class ApiClient { return data as T; } - private logDebug(method: string, url: string, statusCode?: number, body?: unknown) { - if (!this.options.debug) return; - const token = this.options.token; - const tokenPreview = token ? `${token.substring(0, 20)}...` : "none"; - console.error(`[DEBUG] ${method} ${url}`); - console.error(`[DEBUG] Token: ${tokenPreview}`); - if (statusCode !== undefined) { - console.error(`[DEBUG] Status: ${statusCode}`); - } - if (body !== undefined) { - console.error(`[DEBUG] Body:`, JSON.stringify(body, null, 2)); - } - } - async get(path: string): Promise { const url = new URL(path, this.options.baseUrl); - this.logDebug("GET", url.toString()); const { statusCode, body } = await request(url.toString(), { method: "GET", headers: this.headers(), }); const data = await body.json(); - this.logDebug("GET", url.toString(), statusCode, data); if (statusCode >= 400) { throw new ApiError(statusCode, data); } From 898325ac670fce77a5f36c5b8fc558230e9170eb Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:24:10 +0800 Subject: [PATCH 55/68] fix(cli): restore -v short option for --skill-version --- skillhub-cli/src/commands/publish.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skillhub-cli/src/commands/publish.ts b/skillhub-cli/src/commands/publish.ts index 47828795b..26e02aec9 100644 --- a/skillhub-cli/src/commands/publish.ts +++ b/skillhub-cli/src/commands/publish.ts @@ -16,7 +16,7 @@ export function registerPublish(program: Command) { .description("Publish a skill to SkillHub registry") .option("--namespace ", "Target namespace (default: global)") .option("--slug ", "Skill slug") - .option("--skill-version ", "Version (semver)") + .option("-v, --skill-version ", "Version (semver)") .option("--name ", "Display name") .option("--changelog ", "Changelog text") .option("--tag ", "Comma-separated tags (e.g. beta,stable)", "latest") @@ -29,7 +29,7 @@ export function registerPublish(program: Command) { } const slug = opts.slug || basename(folder); - let version = opts["skill-version"] || opts.v; + let version = opts["skill-version"] || opts.ver; if (!version) { const now = new Date(); const yyyymmdd = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate(); From 67c072604680579b14a6132045569b8594466b3c Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:58:55 +0800 Subject: [PATCH 56/68] fix(cli): enhance 503 error handling in download command --- skillhub-cli/src/commands/download.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts index 24862218e..463f9a5da 100644 --- a/skillhub-cli/src/commands/download.ts +++ b/skillhub-cli/src/commands/download.ts @@ -169,6 +169,22 @@ export function registerDownload(program: Command) { if (statusCode >= 400) { spinner.fail(`Download failed: HTTP ${statusCode}`); + if (statusCode === 503) { + error("\n💡 Service Unavailable (503). This could mean:"); + error(" - The server is temporarily overloaded or under maintenance"); + error(" - The storage service is unavailable"); + error(" - Network connectivity issues"); + error("\nSuggestions:"); + error(" - Wait a moment and try again"); + error(" - Check your internet connection"); + error(" - Contact your administrator if the problem persists"); + } else if (statusCode === 404) { + error(`\n💡 Skill version not found: ${namespace}/${skillSlug}@${selectedVersion}`); + error(" - Try listing available versions: skillhub inspect " + slug); + } else if (statusCode === 403) { + error("\n💡 Access denied. You may not have permission to download this skill."); + error(" - Try: skillhub login"); + } process.exitCode = 1; return; } From bc9b3d1eb2f31467a93405aa4d6f1e6a37e0d13a Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:39:54 +0800 Subject: [PATCH 57/68] fix(cli): rename loop variable to avoid conflict with info function --- skillhub-cli/src/commands/update.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skillhub-cli/src/commands/update.ts b/skillhub-cli/src/commands/update.ts index c37d4787f..c0ec3eae7 100644 --- a/skillhub-cli/src/commands/update.ts +++ b/skillhub-cli/src/commands/update.ts @@ -202,14 +202,14 @@ export function registerUpdate(program: Command) { let updated = 0; let failed = 0; - for (const info of skillsToUpdate) { + for (const skill of skillsToUpdate) { try { - info(`Updating ${info.name} from ${info.currentVersion} to ${info.latestVersion}...`); - const cmd = `${cliCmd} install ${info.namespace}/${info.slug} --skill-version ${info.latestVersion} ${scope}`.trim(); + info(`Updating ${skill.name} from ${skill.currentVersion} to ${skill.latestVersion}...`); + const cmd = `${cliCmd} install ${skill.namespace}/${skill.slug} --skill-version ${skill.latestVersion} ${scope}`.trim(); execSync(cmd, { stdio: "inherit" }); updated++; } catch (e: any) { - error(`Failed to update ${info.name}: ${e.message}`); + error(`Failed to update ${skill.name}: ${e.message}`); failed++; } } From d96fb008f9ee63262f03771dea963e21fc45c3fb Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:40:50 +0800 Subject: [PATCH 58/68] feat(cli): enhance error handling and auto-create output directory --- skillhub-cli/src/commands/download.ts | 12 ++++++++++-- skillhub-cli/src/commands/install.ts | 12 +++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/skillhub-cli/src/commands/download.ts b/skillhub-cli/src/commands/download.ts index 463f9a5da..db0d1e0e3 100644 --- a/skillhub-cli/src/commands/download.ts +++ b/skillhub-cli/src/commands/download.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; -import { createWriteStream } from "node:fs"; -import { resolve } from "node:path"; +import { createWriteStream, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; import { finished } from "node:stream/promises"; import { ApiClient } from "../core/api-client.js"; import { loadConfigFromProgram } from "../core/config.js"; @@ -190,6 +190,14 @@ export function registerDownload(program: Command) { } const outPath = resolve(outputDir, `${skillSlug}.zip`); + const outDir = dirname(outPath); + try { + mkdirSync(outDir, { recursive: true }); + } catch (e: any) { + spinner.fail(`Failed to create output directory: ${outDir}`); + process.exitCode = 1; + return; + } const fileStream = createWriteStream(outPath); await finished(body.pipe(fileStream)); diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index edcf11b21..60a025490 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -444,7 +444,17 @@ async function installFromRegistry( const { statusCode, body } = response; if (statusCode >= 400) { - spinner.fail(`Skill not found: ${ns}/${actualSlug}`); + let errorMsg = `Failed to download skill: ${ns}/${actualSlug}`; + if (statusCode === 404) { + errorMsg = `Skill not found: ${ns}/${actualSlug}`; + } else if (statusCode === 403) { + errorMsg = `Access denied: ${ns}/${actualSlug}. Check your token permissions.`; + } else if (statusCode === 503) { + errorMsg = `Service temporarily unavailable. Please try again later.`; + } else if (statusCode >= 500) { + errorMsg = `Server error (${statusCode}). Please try again later.`; + } + spinner.fail(errorMsg); await rm(tmpDir, { recursive: true, force: true }); process.exitCode = 1; return; From c14b844c106a30448beb113b1a0beea452fcb6db Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:58:07 +0800 Subject: [PATCH 59/68] refactor(cli): merge list/check commands, unify skill status display - Merge list and check into unified list command with status filtering - Add skill-status.ts core module for shared discovery logic - Unify interaction flow: scope -> agent -> status -> display - Replace -g/-p/-a flags with --scope option - Simplify check as alias for list --status managed,missing - Fix uninstall to properly handle 'All' scope selection - Display orphaned skills with [orphaned] marker in uninstall - Update help text and examples BREAKING CHANGE: Removed -g/--global, -p/--project, -a/--all flags from list command. Use --scope global|project|all instead. --- skillhub-cli/src/cli.ts | 51 ++-- skillhub-cli/src/commands/check.ts | 281 +--------------------- skillhub-cli/src/commands/list.ts | 318 ++++++++++++++----------- skillhub-cli/src/commands/uninstall.ts | 79 +++--- skillhub-cli/src/core/skill-status.ts | 133 +++++++++++ 5 files changed, 376 insertions(+), 486 deletions(-) create mode 100644 skillhub-cli/src/core/skill-status.ts diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index 6ff6a7bac..d11f32bab 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -47,21 +47,6 @@ function buildTopLevelHelp(version: string): string { sections.push(dim("CLI for SkillHub — publish, search, and manage agent skills")); sections.push(""); - sections.push(formatSection("Configuration", [ - { cmd: "config list", desc: "Show current registry configuration" }, - { cmd: "config set ", desc: "Set registry URL" }, - { cmd: "config get", desc: "Get current registry configuration" }, - { cmd: "config show-env-instructions", desc: "Show environment variable setup guide" }, - ])); - sections.push(""); - - sections.push(formatSection("Auth", [ - { cmd: "login", desc: "Authenticate with SkillHub registry" }, - { cmd: "logout", desc: "Remove stored authentication token" }, - { cmd: "whoami", desc: "Show current authenticated user" }, - ])); - sections.push(""); - sections.push(formatSection("Discovery", [ { cmd: "explore", desc: "Browse or search skills from the registry", alias: "find, find-skills, search" }, { cmd: "inspect ", desc: "View skill metadata and versions", alias: "info, view" }, @@ -79,15 +64,14 @@ function buildTopLevelHelp(version: string): string { ])); sections.push(""); - sections.push(formatSection("Social", [ - { cmd: "star ", desc: "Star or unstar a skill" }, - { cmd: "rating ", desc: "View your rating for a skill" }, - { cmd: "rate ", desc: "Rate a skill (1-5)" }, - { cmd: "report ", desc: "Report a skill for review" }, + sections.push(formatSection("My Skills", [ + { cmd: "me skills", desc: "List your published skills", alias: "me ls" }, + { cmd: "me stars", desc: "List your starred skills" }, + { cmd: "reviews", desc: "List your review submissions", alias: "reviews my, reviews submissions" }, ])); sections.push(""); - sections.push(formatSection("Publish & Manage", [ + sections.push(formatSection("Publish & Content", [ { cmd: "init [name]", desc: "Create a new SKILL.md template" }, { cmd: "publish [path]", desc: "Publish a skill to SkillHub registry" }, { cmd: "sync [path]", desc: "Scan and publish all skills from a directory" }, @@ -96,22 +80,33 @@ function buildTopLevelHelp(version: string): string { ])); sections.push(""); - sections.push(formatSection("Account", [ - { cmd: "me skills", desc: "List your published skills", alias: "me ls" }, - { cmd: "me stars", desc: "List your starred skills" }, - { cmd: "namespaces", desc: "List namespaces you have access to" }, - { cmd: "notifications", desc: "Manage notifications", alias: "notif" }, - { cmd: "reviews my", desc: "List your review submissions", alias: "reviews submissions" }, + sections.push(formatSection("Community", [ + { cmd: "star ", desc: "Star or unstar a skill" }, + { cmd: "rating ", desc: "View your rating for a skill" }, + { cmd: "rate ", desc: "Rate a skill (1-5)" }, + { cmd: "report ", desc: "Report a skill for review" }, ])); sections.push(""); - sections.push(formatSection("Admin", [ + sections.push(formatSection("Notifications & Admin", [ + { cmd: "notifications", desc: "Manage notifications", alias: "notif" }, + { cmd: "namespaces", desc: "List namespaces you have access to" }, { cmd: "hide ", desc: "Hide a skill (admin only)" }, { cmd: "unhide ", desc: "Unhide a skill (admin only)" }, { cmd: "transfer ", desc: "Transfer namespace ownership" }, ])); sections.push(""); + sections.push(formatSection("Configuration", [ + { cmd: "config list", desc: "Show current registry configuration" }, + { cmd: "config set ", desc: "Set registry URL" }, + { cmd: "config get", desc: "Get current registry configuration" }, + { cmd: "login", desc: "Authenticate with SkillHub registry" }, + { cmd: "logout", desc: "Remove stored authentication token" }, + { cmd: "whoami", desc: "Show current authenticated user" }, + ])); + sections.push(""); + sections.push(bold("Examples")); sections.push(dim(" skillhub install vision2group/fork-workflow Install a skill from registry")); sections.push(dim(" skillhub install find-skills --from https://... Install from GitHub or local path")); diff --git a/skillhub-cli/src/commands/check.ts b/skillhub-cli/src/commands/check.ts index 2240efd9e..3f8294f79 100644 --- a/skillhub-cli/src/commands/check.ts +++ b/skillhub-cli/src/commands/check.ts @@ -1,287 +1,24 @@ import { Command } from "commander"; -import { existsSync, readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import { getAllAgents, type AgentInfo } from "../core/agent-detector.js"; -import { getAllLockedSkills, getSkillLockPath } from "../core/skill-lock.js"; -import { success, error, info, warn, dim } from "../utils/logger.js"; -import * as p from "@clack/prompts"; -import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; - -interface CheckResult { - name: string; - status: "ok" | "missing" | "orphaned"; - source?: string; - location?: string; -} - -function findInstalledSkills( - scope: "local" | "global", - agents?: AgentInfo[] -): Map { - const skillsMap = new Map(); - const allAgents = getAllAgents(); - const targetAgents = agents || allAgents; - - for (const agent of targetAgents) { - 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; -} +import { listAction } from "./list.js"; export function registerCheck(program: Command) { program .command("check") - .description("Check installed skills against lock file") - .option("--global", "Check global scope skills") - .option("--local", "Check local (project) scope skills") - .option("--all", "Check both global and local scopes") + .description("Check installed skills (alias for 'list --status managed,missing')") + .option("--scope ", "Scope to check (global, project, all)") .option("--agent ", "Filter by specific agents") - .option("--status ", "Filter by status (ok, missing, orphaned)") .option("--json", "Output results as JSON") .action(async (opts: { - global?: boolean; - local?: boolean; - all?: boolean; + scope?: string; agent?: string[]; - status?: string[]; json?: boolean; }) => { - // Determine scopes - let scopes: ("local" | "global")[] = []; - - if (opts.all) { - scopes = ["local", "global"]; - } else if (opts.global) { - scopes = ["global"]; - } else if (opts.local) { - scopes = ["local"]; - } else { - // Interactive scope selection - const scopeSelection = await p.select({ - message: "Which scope to check?", - options: [ - { value: "all", label: "All (global + project)" }, - { value: "global", label: "Global only" }, - { value: "local", label: "Project only" }, - ], - }); - - if (p.isCancel(scopeSelection)) { - console.log("Cancelled."); - return; - } - - if (scopeSelection === "all") { - scopes = ["local", "global"]; - } else if (scopeSelection === "global") { - scopes = ["global"]; - } else { - scopes = ["local"]; - } - } - - // Determine agents to check - let targetAgents: AgentInfo[] | undefined; - if (opts.agent && opts.agent.length > 0) { - const allAgents = getAllAgents(); - targetAgents = allAgents.filter((a) => opts.agent!.includes(a.key)); - } else if (!opts.agent) { - // Interactive agent selection - const allAgents = getAllAgents(); - const agentItems = allAgents - .map((a) => ({ - value: a.key, - label: a.name, - })) - .sort((a, b) => a.label.localeCompare(b.label)); - - const selected = await searchMultiselect({ - message: "Which agents to check?", - items: agentItems, - required: false, - }); - - if (selected === cancelSymbol) { - console.log("Cancelled."); - return; - } - - if (selected && selected.length > 0) { - targetAgents = allAgents.filter((a) => (selected as string[]).includes(a.key)); - } - } - - // Determine which statuses to show - let showOk = false; - let showMissing = false; - let showOrphaned = false; - - if (opts.status && opts.status.length > 0) { - // Command line flags - showOk = opts.status.includes("ok"); - showMissing = opts.status.includes("missing"); - showOrphaned = opts.status.includes("orphaned"); - } else { - // Interactive status selection (default: ok + missing only) - const statusSelection = await p.multiselect({ - message: "Which statuses to show?", - options: [ - { value: "ok", label: "OK (installed and in lock file)" }, - { value: "missing", label: "Missing (in lock file but not installed)" }, - { value: "orphaned", label: "Orphaned (installed but not in lock file)" }, - ], - required: false, - initialValues: ["ok", "missing"], - }); - - if (p.isCancel(statusSelection)) { - console.log("Cancelled."); - return; - } - - const selected = statusSelection as string[]; - showOk = selected.includes("ok"); - showMissing = selected.includes("missing"); - showOrphaned = selected.includes("orphaned"); - } - - 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 allResults: CheckResult[] = []; - - // Check each scope - for (const scope of scopes) { - const installedSkills = findInstalledSkills(scope, targetAgents); - - for (const [name, entry] of Object.entries(lockedSkills)) { - const installedLocations = installedSkills.get(name); - if (installedLocations && installedLocations.length > 0) { - allResults.push({ - name, - status: "ok", - source: entry.source, - location: `${scope}: ${installedLocations.sort((a, b) => a.localeCompare(b)).join(", ")}`, - }); - } - } - - for (const [name, locations] of installedSkills.entries()) { - if (!lockedSkills[name]) { - allResults.push({ - name, - status: "orphaned", - location: `${scope}: ${locations.sort((a, b) => a.localeCompare(b)).join(", ")}`, - }); - } - } - } - - // Mark missing skills (not found in any scope) - const checkedNames = new Set(); - for (const r of allResults) { - if (r.status !== "orphaned") { - checkedNames.add(r.name); - } - } - - for (const [name, entry] of Object.entries(lockedSkills)) { - if (!checkedNames.has(name)) { - allResults.push({ - name, - status: "missing", - source: entry.source, - }); - } - } - - // Sort results: ok → missing → orphaned, then alphabetically by name - allResults.sort((a, b) => { - const order = { ok: 0, missing: 1, orphaned: 2 }; - const diff = order[a.status] - order[b.status]; - return diff !== 0 ? diff : a.name.localeCompare(b.name); - }); - - if (opts.json) { - console.log(JSON.stringify(allResults, null, 2)); - return; - } - - // Filter results by selected statuses - const filteredResults = allResults.filter((r) => { - if (r.status === "ok") return showOk; - if (r.status === "missing") return showMissing; - if (r.status === "orphaned") return showOrphaned; - return false; - }); - - const scopeLabel = scopes.length === 2 ? "all scopes" : `${scopes[0]} scope`; - const agentLabel = targetAgents - ? ` (${targetAgents.map((a) => a.name).sort((a, b) => a.localeCompare(b)).join(", ")})` - : ""; - + console.log("Tip: Use 'skillhub list' for more options including orphaned skills"); console.log(""); - info(`SkillHub Lock Check (${scopeLabel})${agentLabel}:`); - console.log(""); - - if (filteredResults.length === 0) { - dim(" No matching skills found."); - console.log(""); - return; - } - let ok = 0, - missing = 0, - orphaned = 0; - - for (const r of filteredResults) { - 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(""); + await listAction({ + ...opts, + status: ["managed", "missing"], + }); }); } diff --git a/skillhub-cli/src/commands/list.ts b/skillhub-cli/src/commands/list.ts index c9cf1fc58..b5d4f4877 100644 --- a/skillhub-cli/src/commands/list.ts +++ b/skillhub-cli/src/commands/list.ts @@ -1,169 +1,203 @@ import { Command } from "commander"; -import { existsSync, readdirSync, lstatSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import { getAllAgents, isUniversalForScope } from "../core/agent-detector.js"; -import { info, dim } from "../utils/logger.js"; +import { existsSync } from "node:fs"; +import { getAllAgents } from "../core/agent-detector.js"; +import { getSkillLockPath } from "../core/skill-lock.js"; +import { discoverInstalledSkills, filterSkillsByStatus, type DiscoveredSkill } from "../core/skill-status.js"; +import { success, error, info, warn, 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; + scope?: string; agent?: string[]; - all?: boolean; + status?: string[]; + json?: 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; - } - } +export async function listAction(opts: ListOptions) { + let scopes: ("local" | "global")[] = []; + + if (opts.scope) { + const scopeValue = opts.scope.toLowerCase(); + if (scopeValue === "all") { + scopes = ["local", "global"]; + } else if (scopeValue === "global") { + scopes = ["global"]; + } else if (scopeValue === "project" || scopeValue === "local") { + scopes = ["local"]; + } + } 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" }, + ], + }); - // Determine scope for dynamic universal grouping - const isGlobal = scopeGlobal === true; - const allAgents = getAllAgents(); - - // All agents are selectable - no locked section - const selectableItems = allAgents - .map((a) => ({ - value: a.key, - label: a.name, - })) - .sort((a, b) => a.label.localeCompare(b.label)); - - const agentSelection = await searchMultiselect({ - message: "Which agents to list from?", - items: selectableItems, - }); - - if (agentSelection === cancelSymbol) { - console.log("Cancelled."); - return; - } + if (p.isCancel(scopeSelection)) { + console.log("Cancelled."); + return; + } - const selectedAgents = agentSelection as string[]; - const agents = allAgents.filter((a) => selectedAgents.includes(a.key)); + if (scopeSelection === "all") { + scopes = ["local", "global"]; + } else if (scopeSelection === "global") { + scopes = ["global"]; + } else { + scopes = ["local"]; + } + } - if (agents.length === 0) { - console.log("No agents selected."); - return; - } + let targetAgents = opts.agent + ? getAllAgents().filter((a) => opts.agent!.includes(a.key)) + : undefined; - console.log(""); + if (!opts.agent) { + const allAgents = getAllAgents(); + const agentItems = allAgents + .map((a) => ({ value: a.key, label: a.name })) + .sort((a, b) => a.label.localeCompare(b.label)); - // Collect all skill entries grouped by (skillName, path) -> agentNames - const skillMap = new Map>(); - const home = homedir(); - const cwd = process.cwd(); + const selected = await searchMultiselect({ + message: "Which agents to list from?", + items: agentItems, + required: false, + }); - for (const agent of agents) { - const showProject = scopeGlobal === null || scopeGlobal === false; - const showGlobal = scopeGlobal === null || scopeGlobal === true; + if (selected === cancelSymbol) { + console.log("Cancelled."); + return; + } - if (showProject) { - const projectDir = join(cwd, agent.skillsDir); - collectSkills(skillMap, projectDir, agent.name, cwd, true); - } + if (selected && selected.length > 0) { + targetAgents = allAgents.filter((a) => (selected as string[]).includes(a.key)); + } + } - if (showGlobal && agent.globalSkillsDir) { - const globalDir = join(home, agent.globalSkillsDir); - collectSkills(skillMap, globalDir, agent.name, home, false); - } - } + let showManaged = false; + let showOrphaned = false; + let showMissing = false; + + if (opts.status && opts.status.length > 0) { + const statusSet = new Set(opts.status.map((s) => s.toLowerCase())); + if (statusSet.has("all")) { + showManaged = true; + showOrphaned = true; + showMissing = true; + } else { + showManaged = statusSet.has("managed"); + showOrphaned = statusSet.has("orphaned"); + showMissing = statusSet.has("missing"); + } + } else { + const statusSelection = await p.multiselect({ + message: "Which statuses to show?", + options: [ + { value: "managed", label: "managed", hint: "installed and in lock file" }, + { value: "orphaned", label: "orphaned", hint: "installed but not in lock file" }, + { value: "missing", label: "missing", hint: "in lock file but not installed" }, + ], + required: false, + initialValues: ["managed", "orphaned"], + }); - if (skillMap.size === 0) { - dim("No skills installed for selected agents and scope."); - } else { - // Output grouped by skill, then by path with agent names merged - const sortedSkills = [...skillMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); - for (const [skillName, pathGroups] of sortedSkills) { - info(`${skillName}`); - const sortedPaths = [...pathGroups.entries()].sort((a, b) => a[0].localeCompare(b[0])); - for (const [displayPath, agentNames] of sortedPaths) { - const sorted = agentNames.sort((a, b) => a.localeCompare(b)); - const label = sorted.length <= 5 - ? sorted.join(", ") - : sorted.slice(0, 5).join(", ") + ` ${pc.dim(`+${sorted.length - 5}`)}`; - dim(` ${pc.dim("→")} ${label}: ${displayPath}`); - } - } - } + if (p.isCancel(statusSelection)) { + console.log("Cancelled."); + return; + } - console.log(""); + const selected = statusSelection as string[]; + showManaged = selected.includes("managed"); + showOrphaned = selected.includes("orphaned"); + showMissing = selected.includes("missing"); + } + + const allSkills = await discoverInstalledSkills(scopes, targetAgents); + const filteredSkills = filterSkillsByStatus(allSkills, { + managed: showManaged, + orphaned: showOrphaned, + missing: showMissing, + }); + + if (opts.json) { + console.log(JSON.stringify(filteredSkills, null, 2)); + return; + } + + displaySkillList(filteredSkills, scopes, targetAgents); +} + +export function registerList(program: Command) { + program + .command("list") + .alias("ls") + .description("List installed skills with status") + .option("--scope ", "Scope to list (global, project, all)") + .option("--agent ", "Filter by specific agents") + .option("--status ", "Filter by status (managed, orphaned, missing, all)") + .option("--json", "Output as JSON") + .action(async (opts: ListOptions) => { + await listAction(opts); }); } -/** - * Collect skills from a directory into the skillMap. - * skillMap: skillName -> (displayPath -> agentNames[]) - */ -function collectSkills( - skillMap: Map>, - dir: string, - agentName: string, - baseForRelative: string, - isProject: boolean, +function displaySkillList( + skills: DiscoveredSkill[], + scopes: ("local" | "global")[], + targetAgents?: import("../core/agent-detector.js").AgentInfo[] ) { - if (!existsSync(dir)) return; - const skills = getSkillsInDir(dir); - for (const skillName of skills) { - const displayPath = isProject - ? dir.replace(baseForRelative, ".") - : dir.replace(baseForRelative, "~"); - let pathGroups = skillMap.get(skillName); - if (!pathGroups) { - pathGroups = new Map(); - skillMap.set(skillName, pathGroups); - } - const agents = pathGroups.get(displayPath) || []; - agents.push(agentName); - pathGroups.set(displayPath, agents); + const scopeLabel = scopes.length === 2 ? "all scopes" : `${scopes[0]} scope`; + const agentLabel = targetAgents + ? ` (${targetAgents.map((a) => a.name).sort((a, b) => a.localeCompare(b)).join(", ")})` + : ""; + + console.log(""); + info(`Installed Skills (${scopeLabel})${agentLabel}:`); + console.log(""); + + if (skills.length === 0) { + dim(" No skills found."); + console.log(""); + return; } -} -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; + let managed = 0, missing = 0, orphaned = 0; + + for (const skill of skills) { + if (skill.status === "managed") { + managed++; + success(` ✓ ${skill.name}`); + if (skill.source) { + dim(` Source: ${skill.source}`); + } + for (const loc of skill.locations) { + dim(` → ${loc.agent}: ${loc.path}`); + } + } else if (skill.status === "missing") { + missing++; + error(` ✗ ${skill.name}`); + if (skill.source) { + dim(` Source: ${skill.source}`); + } + dim(` Status: NOT INSTALLED`); + } else if (skill.status === "orphaned") { + orphaned++; + warn(` ! ${skill.name}`); + for (const loc of skill.locations) { + dim(` → ${loc.agent}: ${loc.path}`); + } + dim(` Status: NOT IN LOCK FILE`); } - }).sort((a, b) => a.localeCompare(b)); + } + + console.log(""); + const lockPath = getSkillLockPath(); + if (existsSync(lockPath)) { + dim(`Lock file: ${lockPath}`); + } + dim(`Summary: ${managed} managed, ${missing} missing, ${orphaned} orphaned`); + console.log(""); } diff --git a/skillhub-cli/src/commands/uninstall.ts b/skillhub-cli/src/commands/uninstall.ts index 9bc583b0d..827fae7ba 100644 --- a/skillhub-cli/src/commands/uninstall.ts +++ b/skillhub-cli/src/commands/uninstall.ts @@ -5,6 +5,7 @@ import { homedir } from "node:os"; import { getAllAgents, isUniversalForScope, type AgentInfo } from "../core/agent-detector.js"; import { success, info, dim } from "../utils/logger.js"; import { removeFromLock } from "../core/skill-lock.js"; +import { discoverInstalledSkills as discoverSkillsWithStatus, type DiscoveredSkill } from "../core/skill-status.js"; import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; import * as p from "@clack/prompts"; import pc from "picocolors"; @@ -82,28 +83,6 @@ function getSkillPath(skillName: string, agent: AgentInfo, scope: "global" | "lo 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)].sort((a, b) => a.localeCompare(b)); -} - function findAgentsWithSkill(skillName: string, scope: "global" | "local", agents: AgentInfo[]): AgentInfo[] { return agents.filter((a) => getSkillPath(skillName, a, scope) !== null); } @@ -147,18 +126,25 @@ export function registerUninstall(program: Command) { const allAgents = getAllAgents(); const isGlobal = scope === "global"; + const searchScopes = scopeAll ? ["local", "global"] : [scope]; - if (opts.all) { - const skills = discoverInstalledSkills(scope); + const discoveredSkills = await discoverSkillsWithStatus(searchScopes); + const installedSkills = discoveredSkills.filter( + (s) => s.status === "managed" || s.status === "orphaned" + ); - if (skills.length === 0) { + if (opts.all) { + if (installedSkills.length === 0) { dim("No skills installed."); return; } const selected = await searchMultiselect({ message: "Select skills to uninstall", - items: skills.map((s) => ({ value: s, label: s })), + items: installedSkills.map((s) => ({ + value: s.name, + label: s.status === "orphaned" ? `${s.name} [orphaned]` : s.name, + })), required: true, }); @@ -170,14 +156,16 @@ export function registerUninstall(program: Command) { const selectedSkills = selected as string[]; const results: { skill: string; agent: string; path: string; ok: boolean }[] = []; - for (const skill of selectedSkills) { - const agentsWithSkill = findAgentsWithSkill(skill, scope, allAgents); - for (const agent of agentsWithSkill) { - const ok = await uninstallSkill(skill, agent, scope, true); - const skillPath = getSkillPath(skill, agent, scope); - results.push({ skill, agent: agent.name, path: skillPath || "", ok }); + for (const skillName of selectedSkills) { + for (const searchScope of searchScopes) { + const agentsWithSkill = findAgentsWithSkill(skillName, searchScope as "global" | "local", allAgents); + for (const agent of agentsWithSkill) { + const ok = await uninstallSkill(skillName, agent, searchScope as "global" | "local", true); + const skillPath = getSkillPath(skillName, agent, searchScope as "global" | "local"); + results.push({ skill: skillName, agent: agent.name, path: skillPath || "", ok }); + } } - await removeFromLock(skill); + await removeFromLock(skillName); } printUninstallResults(results); @@ -185,16 +173,17 @@ export function registerUninstall(program: Command) { } if (!name) { - const skills = discoverInstalledSkills(scope); - - if (skills.length === 0) { + if (installedSkills.length === 0) { dim("No skills installed."); return; } const selected = await searchMultiselect({ message: "Select skills to uninstall", - items: skills.map((s) => ({ value: s, label: s })), + items: installedSkills.map((s) => ({ + value: s.name, + label: s.status === "orphaned" ? `${s.name} [orphaned]` : s.name, + })), required: true, }); @@ -206,14 +195,16 @@ export function registerUninstall(program: Command) { const selectedSkills = selected as string[]; const results: { skill: string; agent: string; path: string; ok: boolean }[] = []; - 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); - const skillPath = getSkillPath(skill, agent, scope); - results.push({ skill, agent: agent.name, path: skillPath || "", ok }); + for (const skillName of selectedSkills) { + for (const searchScope of searchScopes) { + const agentsWithSkill = findAgentsWithSkill(skillName, searchScope as "global" | "local", allAgents); + for (const agent of agentsWithSkill) { + const ok = await uninstallSkill(skillName, agent, searchScope as "global" | "local", !!opts.yes); + const skillPath = getSkillPath(skillName, agent, searchScope as "global" | "local"); + results.push({ skill: skillName, agent: agent.name, path: skillPath || "", ok }); + } } - await removeFromLock(skill); + await removeFromLock(skillName); } printUninstallResults(results); diff --git a/skillhub-cli/src/core/skill-status.ts b/skillhub-cli/src/core/skill-status.ts new file mode 100644 index 000000000..785d0f273 --- /dev/null +++ b/skillhub-cli/src/core/skill-status.ts @@ -0,0 +1,133 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { getAllAgents, type AgentInfo } from "./agent-detector.js"; +import { getAllLockedSkills } from "./skill-lock.js"; + +export interface SkillLocation { + agent: string; + path: string; + scope: "local" | "global"; +} + +export interface DiscoveredSkill { + name: string; + status: "managed" | "orphaned" | "missing"; + source?: string; + locations: SkillLocation[]; +} + +function findInstalledSkills( + scope: "local" | "global", + agents?: AgentInfo[] +): Map { + const skillsMap = new Map(); + const allAgents = getAllAgents(); + const targetAgents = agents || allAgents; + + for (const agent of targetAgents) { + 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) || []; + const displayPath = scope === "global" + ? baseDir.replace(homedir(), "~") + : baseDir.replace(process.cwd(), "."); + existing.push({ + agent: agent.name, + path: `${displayPath}/${entry}`, + scope, + }); + skillsMap.set(entry, existing); + } + } + } catch {} + } + + return skillsMap; +} + +export async function discoverInstalledSkills( + scopes: ("local" | "global")[], + agents?: AgentInfo[] +): Promise { + const lockedSkills = await getAllLockedSkills(); + const allInstalled = new Map(); + + for (const scope of scopes) { + const installed = findInstalledSkills(scope, agents); + for (const [name, locations] of installed) { + const existing = allInstalled.get(name) || []; + existing.push(...locations); + allInstalled.set(name, existing); + } + } + + const results: DiscoveredSkill[] = []; + const checkedNames = new Set(); + + for (const [name, entry] of Object.entries(lockedSkills)) { + const locations = allInstalled.get(name); + if (locations && locations.length > 0) { + results.push({ + name, + status: "managed", + source: entry.source, + locations, + }); + } else { + results.push({ + name, + status: "missing", + source: entry.source, + locations: [], + }); + } + checkedNames.add(name); + } + + for (const [name, locations] of allInstalled) { + if (!checkedNames.has(name)) { + results.push({ + name, + status: "orphaned", + locations, + }); + } + } + + const order = { managed: 0, missing: 1, orphaned: 2 }; + results.sort((a, b) => { + const diff = order[a.status] - order[b.status]; + return diff !== 0 ? diff : a.name.localeCompare(b.name); + }); + + return results; +} + +export function filterSkillsByStatus( + skills: DiscoveredSkill[], + options: { + managed?: boolean; + orphaned?: boolean; + missing?: boolean; + } +): DiscoveredSkill[] { + const showManaged = options.managed ?? true; + const showOrphaned = options.orphaned ?? true; + const showMissing = options.missing ?? false; + + return skills.filter((s) => { + if (s.status === "managed" && showManaged) return true; + if (s.status === "orphaned" && showOrphaned) return true; + if (s.status === "missing" && showMissing) return true; + return false; + }); +} From 1ba1c9018a4da6ed72d40e6eb5811639e81c3144 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:19:17 +0800 Subject: [PATCH 60/68] feat(api): unify permission checks for hide/unhide/archive operations Add unified permission model for skill lifecycle operations: - Skill owners can hide/unhide/archive their own skills - Namespace admins/owners can manage skills in their namespace - Platform admins retain full access via admin endpoints Changes: - SkillGovernanceService: add permission checks to hideSkill/unhideSkill - SkillLifecycleAppService: add hideSkill/unhideSkill methods - GovernanceWorkflowAppService: add facade methods for hide/unhide - SkillLifecycleController: add POST /{namespace}/{slug}/hide and /unhide endpoints Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../portal/SkillLifecycleController.java | 42 +++++++++++++++-- .../service/GovernanceWorkflowAppService.java | 27 +++++++++-- .../service/SkillLifecycleAppService.java | 46 +++++++++++++++++-- .../skill/service/SkillGovernanceService.java | 15 +++++- 4 files changed, 113 insertions(+), 17 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java index b590fa22d..e67582f97 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -141,11 +141,11 @@ public ApiResponse submitForReview(@PathVariable @PostMapping("/{namespace}/{slug}/confirm-publish") public ApiResponse confirmPublish(@PathVariable String namespace, - @PathVariable String slug, - @Valid @RequestBody ConfirmPublishRequest request, - @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, - HttpServletRequest httpRequest) { + @PathVariable String slug, + @Valid @RequestBody ConfirmPublishRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { return ok("response.success.updated", governanceWorkflowAppService.confirmPublish( namespace, @@ -155,4 +155,36 @@ public ApiResponse confirmPublish(@PathVariable userNsRoles, AuditRequestContext.from(httpRequest))); } + + @PostMapping("/{namespace}/{slug}/hide") + public ApiResponse hideSkill(@PathVariable String namespace, + @PathVariable String slug, + @RequestBody(required = false) AdminSkillActionRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + governanceWorkflowAppService.hideSkill( + namespace, + slug, + request, + userId, + userNsRoles, + AuditRequestContext.from(httpRequest))); + } + + @PostMapping("/{namespace}/{slug}/unhide") + public ApiResponse unhideSkill(@PathVariable String namespace, + @PathVariable String slug, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + governanceWorkflowAppService.unhideSkill( + namespace, + slug, + userId, + userNsRoles, + AuditRequestContext.from(httpRequest))); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 6cca39525..313a880f9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -273,11 +273,11 @@ public SkillLifecycleMutationResponse submitForReview(String namespace, } public SkillLifecycleMutationResponse confirmPublish(String namespace, - String slug, - String version, - String userId, - Map userNsRoles, - AuditRequestContext auditContext) { + String slug, + String version, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { return skillLifecycleAppService.confirmPublish( namespace, slug, @@ -286,4 +286,21 @@ public SkillLifecycleMutationResponse confirmPublish(String namespace, userNsRoles, auditContext); } + + public SkillLifecycleMutationResponse hideSkill(String namespace, + String slug, + AdminSkillActionRequest request, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + return skillLifecycleAppService.hideSkill(namespace, slug, request, userId, userNsRoles, auditContext); + } + + public SkillLifecycleMutationResponse unhideSkill(String namespace, + String slug, + String userId, + Map userNsRoles, + AuditRequestContext auditContext) { + return skillLifecycleAppService.unhideSkill(namespace, slug, userId, userNsRoles, auditContext); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java index e7f86d459..131499ec8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -213,11 +213,11 @@ public SkillLifecycleMutationResponse submitForReview(String namespace, @Transactional public SkillLifecycleMutationResponse confirmPublish(String namespace, - String slug, - String version, - String userId, - Map userNamespaceRoles, - AuditRequestContext auditContext) { + String slug, + String version, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { Skill skill = findSkill(namespace, slug, userId); SkillVersion skillVersion = findVersion(skill.getId(), version); skillReviewSubmitService.confirmPublish( @@ -244,6 +244,42 @@ public SkillLifecycleMutationResponse confirmPublish(String namespace, ); } + @Transactional + public SkillLifecycleMutationResponse hideSkill(String namespace, + String slug, + AdminSkillActionRequest request, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + Skill hidden = skillGovernanceService.hideSkill( + skill.getId(), + userId, + normalizeRoles(userNamespaceRoles), + auditContext.clientIp(), + auditContext.userAgent(), + request != null ? request.reason() : null + ); + return new SkillLifecycleMutationResponse(hidden.getId(), null, "HIDE", hidden.getStatus().name()); + } + + @Transactional + public SkillLifecycleMutationResponse unhideSkill(String namespace, + String slug, + String userId, + Map userNamespaceRoles, + AuditRequestContext auditContext) { + Skill skill = findSkill(namespace, slug, userId); + Skill unhidden = skillGovernanceService.unhideSkill( + skill.getId(), + userId, + normalizeRoles(userNamespaceRoles), + auditContext.clientIp(), + auditContext.userAgent() + ); + return new SkillLifecycleMutationResponse(unhidden.getId(), null, "UNHIDE", unhidden.getStatus().name()); + } + private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) { String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug; Namespace namespace = namespaceRepository.findBySlug(cleanNamespace) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 0dfcb5f35..86c25f3ae 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -69,9 +69,15 @@ public SkillGovernanceService(SkillRepository skillRepository, } @Transactional - public Skill hideSkill(Long skillId, String actorUserId, String clientIp, String userAgent, String reason) { + public Skill hideSkill(Long skillId, + String actorUserId, + Map userNamespaceRoles, + String clientIp, + String userAgent, + String reason) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); skill.setHidden(true); skill.setHiddenAt(currentInstant()); skill.setHiddenBy(actorUserId); @@ -120,9 +126,14 @@ private Skill archiveSkillInternal(Skill skill, } @Transactional - public Skill unhideSkill(Long skillId, String actorUserId, String clientIp, String userAgent) { + public Skill unhideSkill(Long skillId, + String actorUserId, + Map userNamespaceRoles, + String clientIp, + String userAgent) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); skill.setHidden(false); skill.setHiddenAt(null); skill.setHiddenBy(null); From d273cac65c6c8cf8e1b817eef25046c96e99faa5 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:20:28 +0800 Subject: [PATCH 61/68] feat(cli): update hide/unhide commands and reorganize help categories - Remove 'admin only' restriction from hide/unhide commands - Update API endpoint from admin path to portal path - Reorganize help categories: - Move rating to My Skills section - Create new Skill Lifecycle section (archive, hide, unhide) - Remove hide/unhide from Notifications & Admin Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- skillhub-cli/src/cli.ts | 10 +++++++--- skillhub-cli/src/commands/hide.ts | 6 +++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index d11f32bab..75db78f2e 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -67,6 +67,7 @@ function buildTopLevelHelp(version: string): string { sections.push(formatSection("My Skills", [ { cmd: "me skills", desc: "List your published skills", alias: "me ls" }, { cmd: "me stars", desc: "List your starred skills" }, + { cmd: "rating ", desc: "View your rating for a skill" }, { cmd: "reviews", desc: "List your review submissions", alias: "reviews my, reviews submissions" }, ])); sections.push(""); @@ -76,13 +77,18 @@ function buildTopLevelHelp(version: string): string { { cmd: "publish [path]", desc: "Publish a skill to SkillHub registry" }, { cmd: "sync [path]", desc: "Scan and publish all skills from a directory" }, { cmd: "delete ", desc: "Delete a skill you own", alias: "del, unpublish" }, + ])); + sections.push(""); + + sections.push(formatSection("Skill Lifecycle", [ { cmd: "archive ", desc: "Archive a skill you own" }, + { cmd: "hide ", desc: "Hide a skill" }, + { cmd: "unhide ", desc: "Unhide a skill" }, ])); sections.push(""); sections.push(formatSection("Community", [ { cmd: "star ", desc: "Star or unstar a skill" }, - { cmd: "rating ", desc: "View your rating for a skill" }, { cmd: "rate ", desc: "Rate a skill (1-5)" }, { cmd: "report ", desc: "Report a skill for review" }, ])); @@ -91,8 +97,6 @@ function buildTopLevelHelp(version: string): string { sections.push(formatSection("Notifications & Admin", [ { cmd: "notifications", desc: "Manage notifications", alias: "notif" }, { cmd: "namespaces", desc: "List namespaces you have access to" }, - { cmd: "hide ", desc: "Hide a skill (admin only)" }, - { cmd: "unhide ", desc: "Unhide a skill (admin only)" }, { cmd: "transfer ", desc: "Transfer namespace ownership" }, ])); sections.push(""); diff --git a/skillhub-cli/src/commands/hide.ts b/skillhub-cli/src/commands/hide.ts index 5908d6f28..bbca13c02 100644 --- a/skillhub-cli/src/commands/hide.ts +++ b/skillhub-cli/src/commands/hide.ts @@ -35,7 +35,7 @@ async function hideSkill( `/api/v1/skills/${namespace}/${skillSlug}` ); - await client.post(`/api/v1/admin/skills/${detail.id}/${action}`, { + await client.post(`/api/v1/skills/${namespace}/${skillSlug}/${action}`, { body: JSON.stringify({}), headers: { "Content-Type": "application/json" }, }); @@ -58,7 +58,7 @@ async function hideSkill( export function registerHide(program: Command) { program .command("hide") - .description("Hide a skill (admin only)") + .description("Hide a skill") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") @@ -70,7 +70,7 @@ export function registerHide(program: Command) { export function registerUnhide(program: Command) { program .command("unhide") - .description("Unhide a skill (admin only)") + .description("Unhide a skill") .argument("", "Skill name or namespace/skill-name") .option("-y, --yes", "Skip confirmation") .option("--namespace ", "Override namespace (default: parsed from skill or 'global')") From 0bfcb028c2968b350d902790edd2032a059c1bbe Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:41:00 +0800 Subject: [PATCH 62/68] feat(cli): move namespaces to me subcommand and reorganize help categories - Add 'me namespaces' subcommand to list accessible namespaces - Remove standalone 'namespaces' command from CLI - Rename 'My Skills' help section to 'My Profile' - Move namespaces listing under My Profile section Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- skillhub-cli/src/cli.ts | 7 ++----- skillhub-cli/src/commands/me.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index 75db78f2e..00e8851eb 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -64,9 +64,10 @@ function buildTopLevelHelp(version: string): string { ])); sections.push(""); - sections.push(formatSection("My Skills", [ + sections.push(formatSection("My Profile", [ { cmd: "me skills", desc: "List your published skills", alias: "me ls" }, { cmd: "me stars", desc: "List your starred skills" }, + { cmd: "me namespaces", desc: "List namespaces you have access to" }, { cmd: "rating ", desc: "View your rating for a skill" }, { cmd: "reviews", desc: "List your review submissions", alias: "reviews my, reviews submissions" }, ])); @@ -96,7 +97,6 @@ function buildTopLevelHelp(version: string): string { sections.push(formatSection("Notifications & Admin", [ { cmd: "notifications", desc: "Manage notifications", alias: "notif" }, - { cmd: "namespaces", desc: "List namespaces you have access to" }, { cmd: "transfer ", desc: "Transfer namespace ownership" }, ])); sections.push(""); @@ -158,7 +158,6 @@ export async function createCli(): Promise { { registerLogout }, { registerWhoami }, { registerPublish }, - { registerNamespaces }, { registerInstall }, { registerDownload }, { registerList }, @@ -186,7 +185,6 @@ export async function createCli(): Promise { 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"), @@ -215,7 +213,6 @@ export async function createCli(): Promise { registerLogout(program); registerWhoami(program); registerPublish(program); - registerNamespaces(program); registerInstall(program); registerDownload(program); registerList(program); diff --git a/skillhub-cli/src/commands/me.ts b/skillhub-cli/src/commands/me.ts index 32d9391ae..6d0aac7d4 100644 --- a/skillhub-cli/src/commands/me.ts +++ b/skillhub-cli/src/commands/me.ts @@ -24,7 +24,7 @@ export interface MeSkillsResponse { } export function registerMe(program: Command) { - const me = program.command("me").description("View your skills and stars"); + const me = program.command("me").description("View your profile information"); me .command("skills") @@ -86,4 +86,31 @@ export function registerMe(program: Command) { process.exitCode = 1; } }); + + me + .command("namespaces") + .description("List namespaces you have access to") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); + const namespaces = await client.get<{ slug: string; displayName: string; currentUserRole: string; status: string }[]>("/api/v1/me/namespaces"); + 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.exitCode = 1; + } + }); } From 5caf0b360b01fddba113fc6cdc2df4fa2d202b90 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:32:12 +0800 Subject: [PATCH 63/68] feat(cli): reorganize help categories - merge Skill Lifecycle into Publish & Manage - Merge 'Skill Lifecycle' commands (archive, hide, unhide) into 'Publish & Manage' - Move 'reviews' from My Profile to Publish & Manage (related to publish workflow) - Rename section from 'Publish & Content' to 'Publish & Manage' Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- skillhub-cli/src/cli.ts | 11 ++--------- skillhub-cli/src/commands/me.ts | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/skillhub-cli/src/cli.ts b/skillhub-cli/src/cli.ts index 00e8851eb..13291e9cf 100644 --- a/skillhub-cli/src/cli.ts +++ b/skillhub-cli/src/cli.ts @@ -50,7 +50,6 @@ function buildTopLevelHelp(version: string): string { sections.push(formatSection("Discovery", [ { cmd: "explore", desc: "Browse or search skills from the registry", alias: "find, find-skills, search" }, { cmd: "inspect ", desc: "View skill metadata and versions", alias: "info, view" }, - { cmd: "resolve ", desc: "Resolve the latest version of a skill" }, ])); sections.push(""); @@ -60,7 +59,6 @@ function buildTopLevelHelp(version: string): string { { cmd: "update [skill]", desc: "Update installed skills from their source", alias: "up" }, { cmd: "uninstall [skill]", desc: "Uninstall a skill from local agent", alias: "un" }, { cmd: "list", desc: "List installed skills", alias: "ls" }, - { cmd: "check", desc: "Check installed skills against lock file" }, ])); sections.push(""); @@ -68,20 +66,15 @@ function buildTopLevelHelp(version: string): string { { cmd: "me skills", desc: "List your published skills", alias: "me ls" }, { cmd: "me stars", desc: "List your starred skills" }, { cmd: "me namespaces", desc: "List namespaces you have access to" }, + { cmd: "me submissions", desc: "List your review submissions" }, { cmd: "rating ", desc: "View your rating for a skill" }, - { cmd: "reviews", desc: "List your review submissions", alias: "reviews my, reviews submissions" }, ])); sections.push(""); - sections.push(formatSection("Publish & Content", [ - { cmd: "init [name]", desc: "Create a new SKILL.md template" }, + sections.push(formatSection("Publish & Manage", [ { cmd: "publish [path]", desc: "Publish a skill to SkillHub registry" }, { cmd: "sync [path]", desc: "Scan and publish all skills from a directory" }, { cmd: "delete ", desc: "Delete a skill you own", alias: "del, unpublish" }, - ])); - sections.push(""); - - sections.push(formatSection("Skill Lifecycle", [ { cmd: "archive ", desc: "Archive a skill you own" }, { cmd: "hide ", desc: "Hide a skill" }, { cmd: "unhide ", desc: "Unhide a skill" }, diff --git a/skillhub-cli/src/commands/me.ts b/skillhub-cli/src/commands/me.ts index 6d0aac7d4..fde1d0de6 100644 --- a/skillhub-cli/src/commands/me.ts +++ b/skillhub-cli/src/commands/me.ts @@ -113,4 +113,27 @@ export function registerMe(program: Command) { process.exitCode = 1; } }); + + me + .command("submissions") + .description("List your review submissions") + .action(async () => { + try { + const token = await requireToken(); + const config = loadConfigFromProgram(program); + const client = new ApiClient({ baseUrl: config.registry, token, debug: program.opts().debug }); + const submissions = await client.get<{ id: number; skillSlug: string; skillDisplayName: string; namespace: string; version: string; status: string; createdAt: string }[]>("/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.exitCode = 1; + } + }); } From b25bbf50f316e694f2c2ab5f73bb205219555973 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:33:39 +0800 Subject: [PATCH 64/68] fix(api): resolve API token authentication issues for skill operations - Fix SkillStarController to use @RequestAttribute instead of @AuthenticationPrincipal - Fix SkillRatingController to use @RequestAttribute instead of @AuthenticationPrincipal - Fix SkillGovernanceService NPE when userNamespaceRoles is null - Update ApiTokenAuthenticationFilter to properly populate userNsRoles - Enhance error messages for 403 vs 401 status codes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../admin/AdminSkillController.java | 2 + .../portal/SkillRatingController.java | 17 +--- .../portal/SkillStarController.java | 19 ++-- .../security/ApiAccessDeniedHandler.java | 9 +- .../admin/AdminSkillControllerTest.java | 2 +- server/skillhub-auth/.factorypath | 99 +++++++++++++++++++ .../token/ApiTokenAuthenticationFilter.java | 13 ++- .../ApiTokenAuthenticationFilterTest.java | 4 +- .../domain/report/SkillReportService.java | 2 +- .../skill/service/SkillGovernanceService.java | 2 +- .../domain/report/SkillReportServiceTest.java | 2 +- .../service/SkillGovernanceServiceTest.java | 2 +- skillhub-cli/src/core/api-client.ts | 2 +- 13 files changed, 140 insertions(+), 35 deletions(-) create mode 100644 server/skillhub-auth/.factorypath diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java index d6d6d59a5..08292dd88 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java @@ -41,6 +41,7 @@ public ApiResponse hideSkill(@PathVariable Long skil var skill = skillGovernanceService.hideSkill( skillId, principal.userId(), + java.util.Map.of(), httpRequest.getRemoteAddr(), httpRequest.getHeader("User-Agent"), request != null ? request.reason() : null @@ -56,6 +57,7 @@ public ApiResponse unhideSkill(@PathVariable Long sk var skill = skillGovernanceService.unhideSkill( skillId, principal.userId(), + java.util.Map.of(), httpRequest.getRemoteAddr(), httpRequest.getHeader("User-Agent") ); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java index 8ad74f97a..4b61b3dfc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java @@ -1,20 +1,13 @@ package com.iflytek.skillhub.controller.portal; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; -import com.iflytek.skillhub.dto.SkillRatingRequest; -import com.iflytek.skillhub.dto.SkillRatingStatusResponse; import com.iflytek.skillhub.domain.social.SkillRatingService; import jakarta.validation.Valid; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.Optional; -/** - * Endpoints for reading and mutating the current user's rating on a skill. - */ @RestController @RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillRatingController extends BaseApiController { @@ -31,19 +24,19 @@ public SkillRatingController(ApiResponseFactory responseFactory, public ApiResponse rateSkill( @PathVariable Long skillId, @Valid @RequestBody SkillRatingRequest request, - @AuthenticationPrincipal PlatformPrincipal principal) { - skillRatingService.rate(skillId, principal.userId(), request.score()); + @RequestAttribute("userId") String userId) { + skillRatingService.rate(skillId, userId, request.score()); return ok("response.success.updated", null); } @GetMapping("/{skillId}/rating") public ApiResponse getUserRating( @PathVariable Long skillId, - @AuthenticationPrincipal PlatformPrincipal principal) { - if (principal == null) { + @RequestAttribute(value = "userId", required = false) String userId) { + if (userId == null) { return ok("response.success.read", new SkillRatingStatusResponse((short) 0, false)); } - Optional rating = skillRatingService.getUserRating(skillId, principal.userId()); + Optional rating = skillRatingService.getUserRating(skillId, userId); return ok( "response.success.read", new SkillRatingStatusResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java index 5d95837e8..396da70ed 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java @@ -1,16 +1,11 @@ package com.iflytek.skillhub.controller.portal; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.social.SkillStarService; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -/** - * Endpoints for starring, unstarring, and checking star state on a skill. - */ @RestController @RequestMapping({"/api/v1/skills", "/api/web/skills"}) public class SkillStarController extends BaseApiController { @@ -26,27 +21,27 @@ public SkillStarController(ApiResponseFactory responseFactory, @PutMapping("/{skillId}/star") public ApiResponse starSkill( @PathVariable Long skillId, - @AuthenticationPrincipal PlatformPrincipal principal) { - skillStarService.star(skillId, principal.userId()); + @RequestAttribute("userId") String userId) { + skillStarService.star(skillId, userId); return ok("response.success.updated", null); } @DeleteMapping("/{skillId}/star") public ApiResponse unstarSkill( @PathVariable Long skillId, - @AuthenticationPrincipal PlatformPrincipal principal) { - skillStarService.unstar(skillId, principal.userId()); + @RequestAttribute("userId") String userId) { + skillStarService.unstar(skillId, userId); return ok("response.success.updated", null); } @GetMapping("/{skillId}/star") public ApiResponse checkStarred( @PathVariable Long skillId, - @AuthenticationPrincipal PlatformPrincipal principal) { - if (principal == null) { + @RequestAttribute(value = "userId", required = false) String userId) { + if (userId == null) { return ok("response.success.read", false); } - boolean starred = skillStarService.isStarred(skillId, principal.userId()); + boolean starred = skillStarService.isStarred(skillId, userId); return ok("response.success.read", starred); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java index 81cebbde2..d61b746fc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -38,12 +38,15 @@ public ApiAccessDeniedHandler(ObjectMapper objectMapper, public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { - logger.info( - "Forbidden API request [requestId={}, method={}, path={}, reason={}]", + String userId = request.getAttribute("userId") != null ? request.getAttribute("userId").toString() : "anonymous"; + logger.warn( + "Forbidden API request [requestId={}, method={}, path={}, userId={}, reason={}, message={}]", MDC.get("requestId"), request.getMethod(), sensitiveLogSanitizer.sanitizeRequestTarget(request), - accessDeniedException.getClass().getSimpleName() + userId, + accessDeniedException.getClass().getSimpleName(), + accessDeniedException.getMessage() ); ApiResponse body = apiResponseFactory.error(403, "error.forbidden"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java index 2a6215b3f..d4cc46708 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java @@ -48,7 +48,7 @@ class AdminSkillControllerTest { @Test void hideSkill_returnsUpdatedResponse() throws Exception { Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC); - given(skillGovernanceService.hideSkill(org.mockito.ArgumentMatchers.eq(10L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("policy"))) + given(skillGovernanceService.hideSkill(org.mockito.ArgumentMatchers.eq(10L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("policy"))) .willReturn(skill); PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SUPER_ADMIN")); diff --git a/server/skillhub-auth/.factorypath b/server/skillhub-auth/.factorypath new file mode 100644 index 000000000..b547b27b0 --- /dev/null +++ b/server/skillhub-auth/.factorypath @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java index 82c0f2c1e..00e023824 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -37,15 +38,18 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; private final ApiTokenScopeService apiTokenScopeService; + private final com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository namespaceMemberRepo; public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService, UserAccountRepository userRepo, UserRoleBindingRepository roleBindingRepo, - ApiTokenScopeService apiTokenScopeService) { + ApiTokenScopeService apiTokenScopeService, + com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository namespaceMemberRepo) { this.apiTokenService = apiTokenService; this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; this.apiTokenScopeService = apiTokenScopeService; + this.namespaceMemberRepo = namespaceMemberRepo; } @Override @@ -77,6 +81,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse .toList()); var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); + Map userNsRoles = + namespaceMemberRepo.findByUserId(user.getId()).stream() + .collect(Collectors.toMap( + com.iflytek.skillhub.domain.namespace.NamespaceMember::getNamespaceId, + com.iflytek.skillhub.domain.namespace.NamespaceMember::getRole, + (left, right) -> left)); + request.setAttribute("userNsRoles", userNsRoles); apiTokenService.touchLastUsed(token); }); }); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java index e82f7a1ab..ad4be5e36 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java @@ -34,11 +34,13 @@ class ApiTokenAuthenticationFilterTest { private final UserRoleBindingRepository roleBindingRepository = mock(UserRoleBindingRepository.class); private final ApiTokenScopeService scopeService = new ApiTokenScopeService(new ObjectMapper(), new RouteSecurityPolicyRegistry()); + private final com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository namespaceMemberRepository = mock(com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository.class); private final ApiTokenAuthenticationFilter filter = new ApiTokenAuthenticationFilter( apiTokenService, userAccountRepository, roleBindingRepository, - scopeService + scopeService, + namespaceMemberRepository ); @AfterEach diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java index 3a9b82b1b..404553345 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java @@ -102,7 +102,7 @@ public SkillReport resolveReport(Long reportId, String userAgent) { SkillReport report = requirePendingReport(reportId); if (disposition == SkillReportDisposition.RESOLVE_AND_HIDE) { - skillGovernanceService.hideSkill(report.getSkillId(), actorUserId, clientIp, userAgent, comment); + skillGovernanceService.hideSkill(report.getSkillId(), actorUserId, java.util.Map.of(), clientIp, userAgent, comment); } else if (disposition == SkillReportDisposition.RESOLVE_AND_ARCHIVE) { skillGovernanceService.archiveSkillAsAdmin(report.getSkillId(), actorUserId, clientIp, userAgent, comment); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 86c25f3ae..3bebb9e30 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -298,7 +298,7 @@ private Long findLatestPublishedVersionId(Long skillId) { private void assertCanManageLifecycle(Skill skill, String actorUserId, Map userNamespaceRoles) { - NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId()); + NamespaceRole namespaceRole = userNamespaceRoles != null ? userNamespaceRoles.get(skill.getNamespaceId()) : null; boolean canManage = skill.getOwnerId().equals(actorUserId) || namespaceRole == NamespaceRole.ADMIN || namespaceRole == NamespaceRole.OWNER; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java index 174d23527..fcae03d7d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java @@ -134,7 +134,7 @@ void resolveReport_withHideDisposition_hidesSkillAndNotifiesReporter() { ); assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED); - verify(skillGovernanceService).hideSkill(10L, "admin", "127.0.0.1", "JUnit", "handled"); + verify(skillGovernanceService).hideSkill(10L, "admin", java.util.Map.of(), "127.0.0.1", "JUnit", "handled"); verify(governanceNotificationService).notifyUser( eq("user-1"), eq("REPORT"), diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index b6f3faff4..12855a415 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -92,7 +92,7 @@ void hideSkill_marksSkillHidden() { given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); given(skillRepository.save(skill)).willReturn(skill); - Skill result = service.hideSkill(10L, "admin", "127.0.0.1", "JUnit", "policy"); + Skill result = service.hideSkill(10L, "admin", java.util.Map.of(), "127.0.0.1", "JUnit", "policy"); assertThat(result.isHidden()).isTrue(); assertThat(result.getHiddenBy()).isEqualTo("admin"); diff --git a/skillhub-cli/src/core/api-client.ts b/skillhub-cli/src/core/api-client.ts index 3f782d283..bbea37d19 100644 --- a/skillhub-cli/src/core/api-client.ts +++ b/skillhub-cli/src/core/api-client.ts @@ -147,7 +147,7 @@ export class ApiError extends Error { const msg = extractHumanMessage(body); let detail = msg ?? `HTTP ${statusCode}`; - if (statusCode === 401 || statusCode === 403) { + if (statusCode === 401) { detail += "\nRun `skillhub login` to authenticate."; } From 0bef6fd0c7dfa0252da495ba0d0e129b7bb710bf Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:34:15 +0800 Subject: [PATCH 65/68] feat(api): enhance permission checks for skill lifecycle operations Add platformRoles parameter to skill lifecycle methods: - hideSkill, archiveSkill, unhideSkill, unarchiveSkill, deleteVersion - Support SUPER_ADMIN and SKILL_ADMIN role bypass - Add hideSkillAsAdmin and unhideSkillAsAdmin for admin controllers - Fix userId null checks in rating and star controllers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../admin/AdminSkillController.java | 6 +- .../portal/SkillLifecycleController.java | 11 ++++ .../portal/SkillRatingController.java | 6 ++ .../portal/SkillStarController.java | 7 +++ .../service/GovernanceWorkflowAppService.java | 16 ++++-- .../service/SkillLifecycleAppService.java | 11 ++++ .../domain/report/SkillReportService.java | 2 +- .../skill/service/SkillGovernanceService.java | 56 ++++++++++++++++--- 8 files changed, 97 insertions(+), 18 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java index 08292dd88..6eb92e3fe 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillController.java @@ -38,10 +38,9 @@ public ApiResponse hideSkill(@PathVariable Long skil @RequestBody(required = false) AdminSkillActionRequest request, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) { - var skill = skillGovernanceService.hideSkill( + var skill = skillGovernanceService.hideSkillAsAdmin( skillId, principal.userId(), - java.util.Map.of(), httpRequest.getRemoteAddr(), httpRequest.getHeader("User-Agent"), request != null ? request.reason() : null @@ -54,10 +53,9 @@ public ApiResponse hideSkill(@PathVariable Long skil public ApiResponse unhideSkill(@PathVariable Long skillId, @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest httpRequest) { - var skill = skillGovernanceService.unhideSkill( + var skill = skillGovernanceService.unhideSkillAsAdmin( skillId, principal.userId(), - java.util.Map.of(), httpRequest.getRemoteAddr(), httpRequest.getHeader("User-Agent") ); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java index e67582f97..f21d9f702 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -14,6 +14,7 @@ import jakarta.validation.Valid; import jakarta.servlet.http.HttpServletRequest; import java.util.Map; +import java.util.Set; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -44,6 +45,7 @@ public ApiResponse archiveSkill(@PathVariable St @RequestBody(required = false) AdminSkillActionRequest request, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles, HttpServletRequest httpRequest) { return ok("response.success.updated", governanceWorkflowAppService.archiveSkill( @@ -52,6 +54,7 @@ public ApiResponse archiveSkill(@PathVariable St request, userId, userNsRoles, + platformRoles, AuditRequestContext.from(httpRequest))); } @@ -60,6 +63,7 @@ public ApiResponse unarchiveSkill(@PathVariable @PathVariable String slug, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles, HttpServletRequest httpRequest) { return ok("response.success.updated", governanceWorkflowAppService.unarchiveSkill( @@ -67,6 +71,7 @@ public ApiResponse unarchiveSkill(@PathVariable slug, userId, userNsRoles, + platformRoles, AuditRequestContext.from(httpRequest))); } @@ -76,6 +81,7 @@ public ApiResponse deleteVersion(@PathVariable S @PathVariable String version, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles, HttpServletRequest httpRequest) { return ok("response.success.deleted", governanceWorkflowAppService.deleteVersion( @@ -84,6 +90,7 @@ public ApiResponse deleteVersion(@PathVariable S version, userId, userNsRoles, + platformRoles, AuditRequestContext.from(httpRequest))); } @@ -162,6 +169,7 @@ public ApiResponse hideSkill(@PathVariable Strin @RequestBody(required = false) AdminSkillActionRequest request, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles, HttpServletRequest httpRequest) { return ok("response.success.updated", governanceWorkflowAppService.hideSkill( @@ -170,6 +178,7 @@ public ApiResponse hideSkill(@PathVariable Strin request, userId, userNsRoles, + platformRoles, AuditRequestContext.from(httpRequest))); } @@ -178,6 +187,7 @@ public ApiResponse unhideSkill(@PathVariable Str @PathVariable String slug, @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles, HttpServletRequest httpRequest) { return ok("response.success.updated", governanceWorkflowAppService.unhideSkill( @@ -185,6 +195,7 @@ public ApiResponse unhideSkill(@PathVariable Str slug, userId, userNsRoles, + platformRoles, AuditRequestContext.from(httpRequest))); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java index 4b61b3dfc..fae3aaddd 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillRatingController.java @@ -1,8 +1,11 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.SkillRatingRequest; +import com.iflytek.skillhub.dto.SkillRatingStatusResponse; import com.iflytek.skillhub.domain.social.SkillRatingService; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; @@ -25,6 +28,9 @@ public ApiResponse rateSkill( @PathVariable Long skillId, @Valid @RequestBody SkillRatingRequest request, @RequestAttribute("userId") String userId) { + if (userId == null) { + throw new DomainForbiddenException("error.auth.required"); + } skillRatingService.rate(skillId, userId, request.score()); return ok("response.success.updated", null); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java index 396da70ed..8b022e12e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillStarController.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.social.SkillStarService; @@ -22,6 +23,9 @@ public SkillStarController(ApiResponseFactory responseFactory, public ApiResponse starSkill( @PathVariable Long skillId, @RequestAttribute("userId") String userId) { + if (userId == null) { + throw new DomainForbiddenException("error.auth.required"); + } skillStarService.star(skillId, userId); return ok("response.success.updated", null); } @@ -30,6 +34,9 @@ public ApiResponse starSkill( public ApiResponse unstarSkill( @PathVariable Long skillId, @RequestAttribute("userId") String userId) { + if (userId == null) { + throw new DomainForbiddenException("error.auth.required"); + } skillStarService.unstar(skillId, userId); return ok("response.success.updated", null); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 313a880f9..b13d364e7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; import java.io.InputStream; import java.util.Map; +import java.util.Set; import org.springframework.stereotype.Service; /** @@ -182,16 +183,18 @@ public SkillLifecycleMutationResponse archiveSkill(String namespace, AdminSkillActionRequest request, String userId, Map userNsRoles, + Set platformRoles, AuditRequestContext auditContext) { - return skillLifecycleAppService.archiveSkill(namespace, slug, request, userId, userNsRoles, auditContext); + return skillLifecycleAppService.archiveSkill(namespace, slug, request, userId, userNsRoles, platformRoles, auditContext); } public SkillLifecycleMutationResponse unarchiveSkill(String namespace, String slug, String userId, Map userNsRoles, + Set platformRoles, AuditRequestContext auditContext) { - return skillLifecycleAppService.unarchiveSkill(namespace, slug, userId, userNsRoles, auditContext); + return skillLifecycleAppService.unarchiveSkill(namespace, slug, userId, userNsRoles, platformRoles, auditContext); } public SkillLifecycleMutationResponse deleteVersion(String namespace, @@ -199,8 +202,9 @@ public SkillLifecycleMutationResponse deleteVersion(String namespace, String version, String userId, Map userNsRoles, + Set platformRoles, AuditRequestContext auditContext) { - return skillLifecycleAppService.deleteVersion(namespace, slug, version, userId, userNsRoles, auditContext); + return skillLifecycleAppService.deleteVersion(namespace, slug, version, userId, userNsRoles, platformRoles, auditContext); } public SkillLifecycleMutationResponse withdrawReviewVersion(String namespace, @@ -292,15 +296,17 @@ public SkillLifecycleMutationResponse hideSkill(String namespace, AdminSkillActionRequest request, String userId, Map userNsRoles, + Set platformRoles, AuditRequestContext auditContext) { - return skillLifecycleAppService.hideSkill(namespace, slug, request, userId, userNsRoles, auditContext); + return skillLifecycleAppService.hideSkill(namespace, slug, request, userId, userNsRoles, platformRoles, auditContext); } public SkillLifecycleMutationResponse unhideSkill(String namespace, String slug, String userId, Map userNsRoles, + Set platformRoles, AuditRequestContext auditContext) { - return skillLifecycleAppService.unhideSkill(namespace, slug, userId, userNsRoles, auditContext); + return skillLifecycleAppService.unhideSkill(namespace, slug, userId, userNsRoles, platformRoles, auditContext); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java index 131499ec8..269294c9a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; import java.util.Map; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -60,12 +61,14 @@ public SkillLifecycleMutationResponse archiveSkill(String namespace, AdminSkillActionRequest request, String userId, Map userNamespaceRoles, + Set platformRoles, AuditRequestContext auditContext) { Skill skill = findSkill(namespace, slug, userId); Skill archived = skillGovernanceService.archiveSkill( skill.getId(), userId, normalizeRoles(userNamespaceRoles), + platformRoles, auditContext.clientIp(), auditContext.userAgent(), request != null ? request.reason() : null @@ -78,12 +81,14 @@ public SkillLifecycleMutationResponse unarchiveSkill(String namespace, String slug, String userId, Map userNamespaceRoles, + Set platformRoles, AuditRequestContext auditContext) { Skill skill = findSkill(namespace, slug, userId); Skill restored = skillGovernanceService.unarchiveSkill( skill.getId(), userId, normalizeRoles(userNamespaceRoles), + platformRoles, auditContext.clientIp(), auditContext.userAgent() ); @@ -96,6 +101,7 @@ public SkillLifecycleMutationResponse deleteVersion(String namespace, String version, String userId, Map userNamespaceRoles, + Set platformRoles, AuditRequestContext auditContext) { Skill skill = findSkill(namespace, slug, userId); SkillVersion skillVersion = findVersion(skill.getId(), version); @@ -104,6 +110,7 @@ public SkillLifecycleMutationResponse deleteVersion(String namespace, skillVersion, userId, normalizeRoles(userNamespaceRoles), + platformRoles, auditContext.clientIp(), auditContext.userAgent(), namespace @@ -250,12 +257,14 @@ public SkillLifecycleMutationResponse hideSkill(String namespace, AdminSkillActionRequest request, String userId, Map userNamespaceRoles, + Set platformRoles, AuditRequestContext auditContext) { Skill skill = findSkill(namespace, slug, userId); Skill hidden = skillGovernanceService.hideSkill( skill.getId(), userId, normalizeRoles(userNamespaceRoles), + platformRoles, auditContext.clientIp(), auditContext.userAgent(), request != null ? request.reason() : null @@ -268,12 +277,14 @@ public SkillLifecycleMutationResponse unhideSkill(String namespace, String slug, String userId, Map userNamespaceRoles, + Set platformRoles, AuditRequestContext auditContext) { Skill skill = findSkill(namespace, slug, userId); Skill unhidden = skillGovernanceService.unhideSkill( skill.getId(), userId, normalizeRoles(userNamespaceRoles), + platformRoles, auditContext.clientIp(), auditContext.userAgent() ); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java index 404553345..67bf1aa83 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java @@ -102,7 +102,7 @@ public SkillReport resolveReport(Long reportId, String userAgent) { SkillReport report = requirePendingReport(reportId); if (disposition == SkillReportDisposition.RESOLVE_AND_HIDE) { - skillGovernanceService.hideSkill(report.getSkillId(), actorUserId, java.util.Map.of(), clientIp, userAgent, comment); + skillGovernanceService.hideSkillAsAdmin(report.getSkillId(), actorUserId, clientIp, userAgent, comment); } else if (disposition == SkillReportDisposition.RESOLVE_AND_ARCHIVE) { skillGovernanceService.archiveSkillAsAdmin(report.getSkillId(), actorUserId, clientIp, userAgent, comment); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 3bebb9e30..c8b5429cf 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; @@ -72,18 +73,34 @@ public SkillGovernanceService(SkillRepository skillRepository, public Skill hideSkill(Long skillId, String actorUserId, Map userNamespaceRoles, + Set platformRoles, String clientIp, String userAgent, String reason) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); - assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, platformRoles); + return hideSkillInternal(skill, actorUserId, clientIp, userAgent, reason); + } + + @Transactional + public Skill hideSkillAsAdmin(Long skillId, + String actorUserId, + String clientIp, + String userAgent, + String reason) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + return hideSkillInternal(skill, actorUserId, clientIp, userAgent, reason); + } + + private Skill hideSkillInternal(Skill skill, String actorUserId, String clientIp, String userAgent, String reason) { skill.setHidden(true); skill.setHiddenAt(currentInstant()); skill.setHiddenBy(actorUserId); skill.setUpdatedBy(actorUserId); Skill saved = skillRepository.save(skill); - auditLogService.record(actorUserId, "HIDE_SKILL", "SKILL", skillId, null, clientIp, userAgent, jsonReason(reason)); + auditLogService.record(actorUserId, "HIDE_SKILL", "SKILL", skill.getId(), null, clientIp, userAgent, jsonReason(reason)); return saved; } @@ -91,12 +108,13 @@ public Skill hideSkill(Long skillId, public Skill archiveSkill(Long skillId, String actorUserId, Map userNamespaceRoles, + Set platformRoles, String clientIp, String userAgent, String reason) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); - assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, platformRoles); return archiveSkillInternal(skill, actorUserId, clientIp, userAgent, reason); } @@ -129,17 +147,32 @@ private Skill archiveSkillInternal(Skill skill, public Skill unhideSkill(Long skillId, String actorUserId, Map userNamespaceRoles, + Set platformRoles, String clientIp, String userAgent) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); - assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, platformRoles); + return unhideSkillInternal(skill, actorUserId, clientIp, userAgent); + } + + @Transactional + public Skill unhideSkillAsAdmin(Long skillId, + String actorUserId, + String clientIp, + String userAgent) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + return unhideSkillInternal(skill, actorUserId, clientIp, userAgent); + } + + private Skill unhideSkillInternal(Skill skill, String actorUserId, String clientIp, String userAgent) { skill.setHidden(false); skill.setHiddenAt(null); skill.setHiddenBy(null); skill.setUpdatedBy(actorUserId); Skill saved = skillRepository.save(skill); - auditLogService.record(actorUserId, "UNHIDE_SKILL", "SKILL", skillId, null, clientIp, userAgent, null); + auditLogService.record(actorUserId, "UNHIDE_SKILL", "SKILL", skill.getId(), null, clientIp, userAgent, null); return saved; } @@ -147,11 +180,12 @@ public Skill unhideSkill(Long skillId, public Skill unarchiveSkill(Long skillId, String actorUserId, Map userNamespaceRoles, + Set platformRoles, String clientIp, String userAgent) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); - assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, platformRoles); SkillStatus previousStatus = skill.getStatus(); skill.setStatus(SkillStatus.ACTIVE); @@ -167,10 +201,11 @@ public void deleteVersion(Skill skill, SkillVersion version, String actorUserId, Map userNamespaceRoles, + Set platformRoles, String clientIp, String userAgent, String namespaceSlug) { - assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, platformRoles); if (version.getStatus() != SkillVersionStatus.DRAFT && version.getStatus() != SkillVersionStatus.REJECTED && version.getStatus() != SkillVersionStatus.SCAN_FAILED @@ -297,7 +332,12 @@ private Long findLatestPublishedVersionId(Long skillId) { private void assertCanManageLifecycle(Skill skill, String actorUserId, - Map userNamespaceRoles) { + Map userNamespaceRoles, + Set platformRoles) { + if (platformRoles != null && + (platformRoles.contains("SUPER_ADMIN") || platformRoles.contains("SKILL_ADMIN"))) { + return; + } NamespaceRole namespaceRole = userNamespaceRoles != null ? userNamespaceRoles.get(skill.getNamespaceId()) : null; boolean canManage = skill.getOwnerId().equals(actorUserId) || namespaceRole == NamespaceRole.ADMIN From 189b3e7207a6a9d39895eb4954971fc489197fc9 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:34:38 +0800 Subject: [PATCH 66/68] chore(cli): bump version to 1.2.11 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- skillhub-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skillhub-cli/package.json b/skillhub-cli/package.json index 5d88619c8..9cb42d4d5 100644 --- a/skillhub-cli/package.json +++ b/skillhub-cli/package.json @@ -1,6 +1,6 @@ { "name": "motovis-skillhub", - "version": "1.2.9", + "version": "1.2.11", "type": "module", "description": "SkillHub CLI - 企业级 Agent Skill 管理工具,支持命名空间", "bin": { From 4ae9518add80cf627bea3cb3a6ff703c28dc7e97 Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:50:17 +0800 Subject: [PATCH 67/68] fix: update test files for platformRoles parameter in lifecycle governance methods All lifecycle methods (hideSkill, archiveSkill, unarchiveSkill, deleteVersion, unhideSkill) now accept a Set platformRoles parameter for SUPER_ADMIN/ SKILL_ADMIN bypass. Updated all test calls to pass null for this parameter. AdminSkillControllerTest now mocks hideSkillAsAdmin instead of hideSkill. --- .../admin/AdminSkillControllerTest.java | 2 +- .../portal/SkillLifecycleControllerTest.java | 7 ++++--- .../service/SkillLifecycleAppServiceTest.java | 6 ++++-- .../domain/report/SkillReportServiceTest.java | 2 +- .../service/SkillGovernanceServiceTest.java | 20 +++++++++---------- 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java index d4cc46708..34b3e2a52 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillControllerTest.java @@ -48,7 +48,7 @@ class AdminSkillControllerTest { @Test void hideSkill_returnsUpdatedResponse() throws Exception { Skill skill = new Skill(1L, "demo", "owner", SkillVisibility.PUBLIC); - given(skillGovernanceService.hideSkill(org.mockito.ArgumentMatchers.eq(10L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("policy"))) + given(skillGovernanceService.hideSkillAsAdmin(org.mockito.ArgumentMatchers.eq(10L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("policy"))) .willReturn(skill); PlatformPrincipal principal = new PlatformPrincipal("admin", "admin", "a@example.com", "", "github", Set.of("SUPER_ADMIN")); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java index 09751065f..8b7452f14 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.controller.portal; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; @@ -88,7 +89,7 @@ void archiveSkill_returnsUnifiedEnvelope() throws Exception { given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) .willReturn(skill); - given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup"))) + given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), any(), nullable(String.class), nullable(String.class), eq("cleanup"))) .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED)); mockMvc.perform(post("/api/web/skills/global/demo-skill/archive") @@ -116,7 +117,7 @@ void unarchiveSkill_returnsUnifiedEnvelope() throws Exception { given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) .willReturn(skill); - given(skillGovernanceService.unarchiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class))) + given(skillGovernanceService.unarchiveSkill(eq(1L), eq("usr_1"), anyMap(), any(), nullable(String.class), nullable(String.class))) .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE)); mockMvc.perform(post("/api/web/skills/global/demo-skill/unarchive") @@ -241,7 +242,7 @@ void archiveSkill_acceptsAtPrefixedNamespaceSlug() throws Exception { given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER)) .willReturn(skill); - given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup"))) + given(skillGovernanceService.archiveSkill(eq(1L), eq("usr_1"), anyMap(), any(), nullable(String.class), nullable(String.class), eq("cleanup"))) .willReturn(skillWithStatus(skill, com.iflytek.skillhub.domain.skill.SkillStatus.ARCHIVED)); mockMvc.perform(post("/api/web/skills/@global/demo-skill/archive") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java index e2c101462..e8488b3f0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; @@ -59,7 +60,7 @@ void archiveSkill_resolvesNamespaceAndDelegatesLifecycleMutation() { when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); when(skillSlugResolutionService.resolve(7L, "demo-skill", "owner-1", SkillSlugResolutionService.Preference.CURRENT_USER)) .thenReturn(skill); - when(skillGovernanceService.archiveSkill(eq(11L), eq("owner-1"), anyMap(), nullable(String.class), nullable(String.class), eq("cleanup"))) + when(skillGovernanceService.archiveSkill(eq(11L), eq("owner-1"), anyMap(), any(), nullable(String.class), nullable(String.class), eq("cleanup"))) .thenReturn(skill); var response = service.archiveSkill( @@ -68,12 +69,13 @@ void archiveSkill_resolvesNamespaceAndDelegatesLifecycleMutation() { new AdminSkillActionRequest("cleanup"), "owner-1", Map.of(7L, NamespaceRole.OWNER), + null, new AuditRequestContext("127.0.0.1", "JUnit") ); assertThat(response.skillId()).isEqualTo(11L); assertThat(response.action()).isEqualTo("ARCHIVE"); assertThat(response.status()).isEqualTo("ARCHIVED"); - verify(skillGovernanceService).archiveSkill(11L, "owner-1", Map.of(7L, NamespaceRole.OWNER), "127.0.0.1", "JUnit", "cleanup"); + verify(skillGovernanceService).archiveSkill(11L, "owner-1", Map.of(7L, NamespaceRole.OWNER), null, "127.0.0.1", "JUnit", "cleanup"); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java index fcae03d7d..b1db81167 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java @@ -134,7 +134,7 @@ void resolveReport_withHideDisposition_hidesSkillAndNotifiesReporter() { ); assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED); - verify(skillGovernanceService).hideSkill(10L, "admin", java.util.Map.of(), "127.0.0.1", "JUnit", "handled"); + verify(skillGovernanceService).hideSkillAsAdmin(10L, "admin", "127.0.0.1", "JUnit", "handled"); verify(governanceNotificationService).notifyUser( eq("user-1"), eq("REPORT"), diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index 12855a415..36c761d0d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -92,7 +92,7 @@ void hideSkill_marksSkillHidden() { given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); given(skillRepository.save(skill)).willReturn(skill); - Skill result = service.hideSkill(10L, "admin", java.util.Map.of(), "127.0.0.1", "JUnit", "policy"); + Skill result = service.hideSkill(10L, "admin", java.util.Map.of(), null, "127.0.0.1", "JUnit", "policy"); assertThat(result.isHidden()).isTrue(); assertThat(result.getHiddenBy()).isEqualTo("admin"); @@ -107,7 +107,7 @@ void archiveSkill_marksSkillArchived() { given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); given(skillRepository.save(skill)).willReturn(skill); - Skill result = service.archiveSkill(10L, "owner", Map.of(), "127.0.0.1", "JUnit", "cleanup"); + Skill result = service.archiveSkill(10L, "owner", Map.of(), null, "127.0.0.1", "JUnit", "cleanup"); assertThat(result.getStatus()).isEqualTo(SkillStatus.ARCHIVED); verify(auditLogService).record("owner", "ARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"cleanup\"}"); @@ -122,7 +122,7 @@ void unarchiveSkill_restoresActiveStatus() { given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); given(skillRepository.save(skill)).willReturn(skill); - Skill result = service.unarchiveSkill(10L, "owner", Map.of(), "127.0.0.1", "JUnit"); + Skill result = service.unarchiveSkill(10L, "owner", Map.of(), null, "127.0.0.1", "JUnit"); assertThat(result.getStatus()).isEqualTo(SkillStatus.ACTIVE); verify(auditLogService).record("owner", "UNARCHIVE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", null); @@ -136,7 +136,7 @@ void archiveSkill_requiresOwnerOrNamespaceAdmin() { given(skillRepository.findById(10L)).willReturn(Optional.of(skill)); assertThrows(DomainForbiddenException.class, - () -> service.archiveSkill(10L, "other", Map.of(1L, NamespaceRole.MEMBER), "127.0.0.1", "JUnit", null)); + () -> service.archiveSkill(10L, "other", Map.of(1L, NamespaceRole.MEMBER), null, "127.0.0.1", "JUnit", null)); } @Test @@ -216,7 +216,7 @@ void deleteVersion_removesDraftFilesAndBundle() { SkillFile icon = new SkillFile(version.getId(), "icon.png", 20L, "image/png", "sha2", "skills/demo/icon"); given(skillFileRepository.findByVersionId(version.getId())).willReturn(java.util.List.of(readme, icon)); - service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns"); + service.deleteVersion(skill, version, "owner", Map.of(), null, "127.0.0.1", "JUnit", "test-ns"); verify(objectStorageService).deleteObjects(argThat(keys -> keys.size() == 3 @@ -246,7 +246,7 @@ void deleteVersion_deletesStorageAfterCommitWhenSynchronizationIsActive() { TransactionSynchronizationManager.initSynchronization(); - service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns"); + service.deleteVersion(skill, version, "owner", Map.of(), null, "127.0.0.1", "JUnit", "test-ns"); verify(objectStorageService, never()).deleteObjects(argThat(keys -> !keys.isEmpty())); @@ -279,7 +279,7 @@ void deleteVersion_recordsCompensationWhenDeferredDeleteFails() { TransactionSynchronizationManager.initSynchronization(); - service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns"); + service.deleteVersion(skill, version, "owner", Map.of(), null, "127.0.0.1", "JUnit", "test-ns"); for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) { synchronization.afterCommit(); @@ -307,7 +307,7 @@ void deleteVersion_rejectsPublishedVersion() { version.setStatus(SkillVersionStatus.PUBLISHED); assertThrows(DomainBadRequestException.class, - () -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns")); + () -> service.deleteVersion(skill, version, "owner", Map.of(), null, "127.0.0.1", "JUnit", "test-ns")); verify(skillVersionRepository, never()).delete(any()); verify(objectStorageService, never()).deleteObject(any()); @@ -323,7 +323,7 @@ void deleteVersion_rejectsLastRemainingVersion() { given(skillVersionRepository.findBySkillId(1L)).willReturn(java.util.List.of(version)); DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, - () -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns")); + () -> service.deleteVersion(skill, version, "owner", Map.of(), null, "127.0.0.1", "JUnit", "test-ns")); assertThat(ex.messageCode()).isEqualTo("error.skill.version.delete.lastVersion"); verify(skillVersionRepository, never()).delete(any()); @@ -351,7 +351,7 @@ void deleteVersion_updatesLatestVersionPointerWhenDeletingArchivedSkillsLatestDr given(skillRepository.save(skill)).willReturn(skill); given(skillFileRepository.findByVersionId(2L)).willReturn(java.util.List.of()); - service.deleteVersion(skill, draftVersion, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns"); + service.deleteVersion(skill, draftVersion, "owner", Map.of(), null, "127.0.0.1", "JUnit", "test-ns"); assertThat(skill.getLatestVersionId()).isEqualTo(3L); verify(skillRepository).save(skill); From ac2d4ebc8ad3237cfe1f9d9b573fce82f2bc35ad Mon Sep 17 00:00:00 2001 From: chenbaowang <49091147+Rsweater@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:09:27 +0800 Subject: [PATCH 68/68] feat(auth): add skill:manage scope for lifecycle governance API token policies - Add require(skill:manage) for hide/unhide/archive/unarchive endpoints - Add allow (no scope) for social ops: star, rate, report, notifications - Update DeviceAuthService default scope to include skill:manage - Update TokenController default scope to include skill:manage --- .../skillhub/controller/TokenController.java | 4 ++-- .../auth/device/DeviceAuthService.java | 2 +- .../policy/RouteSecurityPolicyRegistry.java | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java index 19115087f..3e0996105 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java @@ -41,12 +41,12 @@ public ApiResponse create( @Valid @RequestBody TokenCreateRequest request) { String scopeJson; if (request.scopes() == null || request.scopes().isEmpty()) { - scopeJson = "[\"skill:read\",\"skill:publish\"]"; + scopeJson = "[\"skill:read\",\"skill:publish\",\"skill:manage\"]"; } else { try { scopeJson = objectMapper.writeValueAsString(request.scopes()); } catch (JsonProcessingException e) { - scopeJson = "[\"skill:read\",\"skill:publish\"]"; + scopeJson = "[\"skill:read\",\"skill:publish\",\"skill:manage\"]"; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java index e838c9a82..333a1ca18 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/device/DeviceAuthService.java @@ -29,7 +29,7 @@ public class DeviceAuthService { private static final long PENDING_CODE_TTL_MINUTES = EXPIRES_IN_SECONDS / 60L; private static final long USED_CODE_TTL_MINUTES = 1L; private static final String CLI_DEVICE_TOKEN_NAME = "CLI Device Flow"; - private static final String CLI_DEVICE_SCOPE_JSON = "[\"skill:read\",\"skill:publish\"]"; + private static final String CLI_DEVICE_SCOPE_JSON = "[\"skill:read\",\"skill:publish\",\"skill:manage\"]"; private final RedisTemplate redisTemplate; private final ApiTokenService apiTokenService; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 9d1133f14..e9de30d67 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -126,6 +126,25 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/reviews/**"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/reviews/**"), ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/reviews/**"), + // Lifecycle governance — require skill:manage scope (must appear before broad allow) + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/hide", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/unhide", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/archive", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/*/unarchive", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/hide", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/unhide", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/archive", "skill:manage"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/*/unarchive", "skill:manage"), + // Social / personal actions — no scope required + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/*/star"), + ApiTokenPolicy.allow(HttpMethod.DELETE, "/api/v1/skills/*/star"), + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/web/skills/*/star"), + ApiTokenPolicy.allow(HttpMethod.DELETE, "/api/web/skills/*/star"), + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/*/rating"), + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/web/skills/*/rating"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/skills/*/*/reports"), + ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/skills/*/*/reports"), + // Broad fallback for remaining skill operations (publish, etc.) ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/skills/**"), ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/skills/**"), ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/**"),