diff --git a/package.json b/package.json index a7b0fea3e..a5cd4d1a5 100644 --- a/package.json +++ b/package.json @@ -132,8 +132,10 @@ "ora": "^9.3.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "sigstore": "^4.1.0", "slugify": "^1.6.9", "smol-toml": "^1.6.1", + "tar": "^7.5.9", "ws": "^8.20.0", "zod": "^3.25.76" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c522e165f..83f4b860f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,12 +96,18 @@ importers: react-dom: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) + sigstore: + specifier: ^4.1.0 + version: 4.1.0 slugify: specifier: ^1.6.9 version: 1.6.9 smol-toml: specifier: ^1.6.1 version: 1.6.1 + tar: + specifier: 7.5.11 + version: 7.5.11 ws: specifier: ^8.20.0 version: 8.20.0 @@ -1076,6 +1082,10 @@ packages: resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@gar/promise-retry@1.0.3': + resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} + engines: {node: ^20.17.0 || >=22.9.0} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1360,6 +1370,18 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@npmcli/agent@4.0.0': + resolution: {integrity: sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/fs@5.0.0': + resolution: {integrity: sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/redact@4.0.0': + resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==} + engines: {node: ^20.17.0 || >=22.9.0} + '@oclif/core@4.10.5': resolution: {integrity: sha512-qcdCF7NrdWPfme6Kr34wwljRCXbCVpL1WVxiNy0Ep6vbWKjxAjFQwuhqkoyL0yjI+KdwtLcOCGn5z2yzdijc8w==} engines: {node: '>=18.0.0'} @@ -1578,56 +1600,67 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.40.2': resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} @@ -1639,11 +1672,13 @@ packages: resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1678,6 +1713,30 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sigstore/bundle@4.0.0': + resolution: {integrity: sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/core@3.2.0': + resolution: {integrity: sha512-kxHrDQ9YgfrWUSXU0cjsQGv8JykOFZQ9ErNKbFPWzk3Hgpwu8x2hHrQ9IdA8yl+j9RTLTC3sAF3Tdq1IQCP4oA==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/protobuf-specs@0.5.1': + resolution: {integrity: sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@sigstore/sign@4.1.1': + resolution: {integrity: sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/tuf@4.0.2': + resolution: {integrity: sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@sigstore/verify@3.1.0': + resolution: {integrity: sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==} + engines: {node: ^20.17.0 || >=22.9.0} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -1955,24 +2014,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -2048,6 +2111,14 @@ packages: '@tsconfig/node20@20.1.5': resolution: {integrity: sha512-Vm8e3WxDTqMGPU4GATF9keQAIy1Drd7bPwlgzKJnZtoOsTm1tduUTbDjg0W5qERvGuxPI2h9RbMufH0YdfBylA==} + '@tufjs/canonical-json@2.0.0': + resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@tufjs/models@4.1.0': + resolution: {integrity: sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==} + engines: {node: ^20.17.0 || >=22.9.0} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -2569,6 +2640,10 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + cacache@20.0.4: + resolution: {integrity: sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==} + engines: {node: ^20.17.0 || >=22.9.0} + cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} @@ -3307,6 +3382,10 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3585,6 +3664,10 @@ packages: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -4054,6 +4137,10 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + make-fetch-happen@15.0.5: + resolution: {integrity: sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==} + engines: {node: ^20.17.0 || >=22.9.0} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4143,6 +4230,30 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass-collect@2.0.1: + resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass-fetch@5.0.2: + resolution: {integrity: sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@2.0.0: + resolution: {integrity: sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -4196,6 +4307,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -4367,6 +4482,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -4540,6 +4659,10 @@ packages: resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==} engines: {node: '>=18'} + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -4838,6 +4961,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sigstore@4.1.0: + resolution: {integrity: sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==} + engines: {node: ^20.17.0 || >=22.9.0} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -4846,6 +4973,10 @@ packages: resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} engines: {node: '>=8.0.0'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} @@ -4853,6 +4984,14 @@ packages: snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.8: + resolution: {integrity: sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sort-object-keys@1.1.3: resolution: {integrity: sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==} @@ -4880,6 +5019,10 @@ packages: spdx-license-ids@3.0.21: resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} + ssri@13.0.1: + resolution: {integrity: sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==} + engines: {node: ^20.17.0 || >=22.9.0} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -5148,6 +5291,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tuf-js@4.1.0: + resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==} + engines: {node: ^20.17.0 || >=22.9.0} + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -5483,6 +5630,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} @@ -6411,6 +6561,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@gar/promise-retry@1.0.3': {} + '@humanfs/core@0.19.1': {} '@humanfs/core@0.19.2': @@ -6745,6 +6897,22 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@npmcli/agent@4.0.0': + dependencies: + agent-base: 7.1.4 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 11.3.5 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + '@npmcli/fs@5.0.0': + dependencies: + semver: 7.7.4 + + '@npmcli/redact@4.0.0': {} + '@oclif/core@4.10.5': dependencies: ansi-escapes: 4.3.2 @@ -7004,6 +7172,38 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@sigstore/bundle@4.0.0': + dependencies: + '@sigstore/protobuf-specs': 0.5.1 + + '@sigstore/core@3.2.0': {} + + '@sigstore/protobuf-specs@0.5.1': {} + + '@sigstore/sign@4.1.1': + dependencies: + '@gar/promise-retry': 1.0.3 + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.2.0 + '@sigstore/protobuf-specs': 0.5.1 + make-fetch-happen: 15.0.5 + proc-log: 6.1.0 + transitivePeerDependencies: + - supports-color + + '@sigstore/tuf@4.0.2': + dependencies: + '@sigstore/protobuf-specs': 0.5.1 + tuf-js: 4.1.0 + transitivePeerDependencies: + - supports-color + + '@sigstore/verify@3.1.0': + dependencies: + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.2.0 + '@sigstore/protobuf-specs': 0.5.1 + '@sindresorhus/is@4.6.0': {} '@sindresorhus/is@5.6.0': {} @@ -7480,6 +7680,13 @@ snapshots: '@tsconfig/node20@20.1.5': {} + '@tufjs/canonical-json@2.0.0': {} + + '@tufjs/models@4.1.0': + dependencies: + '@tufjs/canonical-json': 2.0.0 + minimatch: 10.2.3 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -8113,6 +8320,19 @@ snapshots: cac@6.7.14: {} + cacache@20.0.4: + dependencies: + '@npmcli/fs': 5.0.0 + fs-minipass: 3.0.3 + glob: 13.0.6 + lru-cache: 11.3.5 + minipass: 7.1.3 + minipass-collect: 2.0.1 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + p-map: 7.0.4 + ssri: 13.0.1 + cacheable-lookup@5.0.4: {} cacheable-lookup@7.0.0: {} @@ -9063,6 +9283,10 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.3 + fsevents@2.3.2: optional: true @@ -9280,7 +9504,6 @@ snapshots: debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color - optional: true http-proxy@1.18.1: dependencies: @@ -9383,6 +9606,8 @@ snapshots: interpret@1.4.0: {} + ip-address@10.2.0: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -9824,6 +10049,23 @@ snapshots: make-error@1.3.6: {} + make-fetch-happen@15.0.5: + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/agent': 4.0.0 + '@npmcli/redact': 4.0.0 + cacache: 20.0.4 + http-cache-semantics: 4.1.1 + minipass: 7.1.3 + minipass-fetch: 5.0.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 1.0.0 + proc-log: 6.1.0 + ssri: 13.0.1 + transitivePeerDependencies: + - supports-color + math-intrinsics@1.1.0: {} merge-stream@2.0.0: {} @@ -9889,6 +10131,34 @@ snapshots: minimist@1.2.8: {} + minipass-collect@2.0.1: + dependencies: + minipass: 7.1.3 + + minipass-fetch@5.0.2: + dependencies: + minipass: 7.1.3 + minipass-sized: 2.0.0 + minizlib: 3.1.0 + optionalDependencies: + iconv-lite: 0.7.2 + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@2.0.0: + dependencies: + minipass: 7.1.3 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + minipass@7.1.3: {} minizlib@3.1.0: @@ -9928,6 +10198,8 @@ snapshots: negotiator@0.6.4: {} + negotiator@1.0.0: {} + nice-try@1.0.5: {} no-case@3.0.4: @@ -10141,6 +10413,8 @@ snapshots: dependencies: p-limit: 3.1.0 + p-map@7.0.4: {} + package-json-from-dist@1.0.1: {} param-case@3.0.4: @@ -10281,6 +10555,8 @@ snapshots: dependencies: parse-ms: 4.0.0 + proc-log@6.1.0: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -10649,6 +10925,17 @@ snapshots: signal-exit@4.1.0: {} + sigstore@4.1.0: + dependencies: + '@sigstore/bundle': 4.0.0 + '@sigstore/core': 3.2.0 + '@sigstore/protobuf-specs': 0.5.1 + '@sigstore/sign': 4.1.1 + '@sigstore/tuf': 4.0.2 + '@sigstore/verify': 3.1.0 + transitivePeerDependencies: + - supports-color + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -10657,6 +10944,8 @@ snapshots: slugify@1.6.9: {} + smart-buffer@4.2.0: {} + smol-toml@1.6.1: {} snake-case@3.0.4: @@ -10664,6 +10953,19 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + socks: 2.8.8 + transitivePeerDependencies: + - supports-color + + socks@2.8.8: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + sort-object-keys@1.1.3: {} sort-package-json@2.15.1: @@ -10695,6 +10997,10 @@ snapshots: spdx-license-ids@3.0.21: {} + ssri@13.0.1: + dependencies: + minipass: 7.1.3 + stackback@0.0.2: {} std-env@4.1.0: {} @@ -10974,6 +11280,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tuf-js@4.1.0: + dependencies: + '@tufjs/models': 4.1.0 + debug: 4.4.3(supports-color@8.1.1) + make-fetch-happen: 15.0.5 + transitivePeerDependencies: + - supports-color + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -11294,6 +11608,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yallist@5.0.0: {} yocto-queue@0.1.0: {} diff --git a/scripts/postinstall-welcome.ts b/scripts/postinstall-welcome.ts index e0697f4bd..cfbf90b07 100644 --- a/scripts/postinstall-welcome.ts +++ b/scripts/postinstall-welcome.ts @@ -60,7 +60,7 @@ console.log(` Version: ${version}\n`); // Display the welcome messages console.log('Ably CLI installed successfully!'); -console.log('To get started, explore commands:'); -console.log(' ably --help'); -console.log('\nOr log in to your Ably account:'); -console.log(' ably login'); \ No newline at end of file +console.log('\nGet started in one command (authenticate + install Agent Skills):'); +console.log(' ably init'); +console.log('\nOr explore:'); +console.log(' ably --help'); \ No newline at end of file diff --git a/src/base-command.ts b/src/base-command.ts index 2186fc3e7..1495d8cec 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -71,6 +71,11 @@ export const WEB_CLI_RESTRICTED_COMMANDS = [ // File-reading commands can expose server filesystem contents in web CLI mode "push:config:set-apns", "push:config:set-fcm", + + // Agent-skills onboarding writes/removes files on the local filesystem — + // in web CLI mode these would touch the server's filesystem, not the user's. + "init", + "skills*", ]; /* Additional restricted commands when running in anonymous web CLI mode */ @@ -107,6 +112,7 @@ export const INTERACTIVE_UNSUITABLE_COMMANDS = [ "autocomplete", // Autocomplete setup is not needed in interactive mode "config", // Config editing is not suitable for interactive mode "version", // Version is shown at startup and available via --version + "init", // One-time setup; not meaningful inside an already-running session ]; // List of commands that should not show account/app info @@ -877,9 +883,12 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { } // Emit a terminal "completed" line so JSON consumers know the command is done. + // Suppressed when the command is being delegated to from another command + // (the outer command emits its own terminator). const isJsonMode = this.argv.includes("--json") || this.argv.includes("--pretty-json"); - if (isJsonMode) { + const suppressCompleted = this.argv.includes("--skip-completed-status"); + if (isJsonMode && !suppressCompleted) { const flags: BaseFlags = this.argv.includes("--pretty-json") ? ({ "pretty-json": true } as BaseFlags) : ({ json: true } as BaseFlags); diff --git a/src/commands/accounts/login.ts b/src/commands/accounts/login.ts index 4cc7dc256..0fedd0932 100644 --- a/src/commands/accounts/login.ts +++ b/src/commands/accounts/login.ts @@ -66,13 +66,24 @@ export default class AccountsLogin extends ControlBaseCommand { default: false, description: "Do not open a browser", }), + "skip-logo": Flags.boolean({ + default: false, + hidden: true, + }), + "skip-completed-status": Flags.boolean({ + default: false, + hidden: true, + description: + "Suppress the trailing JSON {status:'completed'} record. Used when this command is delegated to from another command (e.g. `init`) so the outer command's terminator is the only one in the stream.", + }), }; public async run(): Promise { const { flags } = await this.parse(AccountsLogin); - // Display ASCII art logo if not in JSON mode - if (!this.shouldOutputJson(flags)) { + // Display ASCII art logo if not in JSON mode and the caller hasn't + // opted out (e.g. `ably init` already prints the logo and delegates here). + if (!this.shouldOutputJson(flags) && !flags["skip-logo"]) { displayLogo(this.log.bind(this)); } diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 000000000..bffbc6d23 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,186 @@ +import { Flags } from "@oclif/core"; +import chalk from "chalk"; + +import { AblyBaseCommand } from "../base-command.js"; +import { coreGlobalFlags } from "../flags.js"; +import { + runSkillsInstall, + SkillsInstallOutput, +} from "../services/skills-install-runner.js"; +import { TARGET_CONFIGS } from "../services/skills-installer.js"; +import { resolveSkillsTargets } from "../services/skills-target-prompt.js"; +import { BaseFlags } from "../types/cli.js"; +import { displayLogo } from "../utils/logo.js"; +import { formatHeading, formatResource } from "../utils/output.js"; +import isTestMode from "../utils/test-mode.js"; + +export default class Init extends AblyBaseCommand { + static override description = + "Set up Ably for AI-powered development — authenticate and install Agent Skills"; + + static override examples = [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --target claude-code", + "<%= config.bin %> <%= command.id %> --target cursor --target windsurf", + "<%= config.bin %> <%= command.id %> --target auto", + "<%= config.bin %> <%= command.id %> --json", + ]; + + static override flags = { + ...coreGlobalFlags, + target: Flags.string({ + char: "t", + multiple: true, + options: ["auto", ...Object.keys(TARGET_CONFIGS)], + default: ["auto"], + description: "Target IDE(s) to install skills for", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(Init); + const jsonMode = this.shouldOutputJson(flags); + + if (flags.target.includes("auto") && flags.target.length > 1) { + this.fail( + new Error( + "--target auto cannot be combined with explicit targets. Use either auto-detect or named targets, not both.", + ), + flags, + "init", + ); + } + + if (!jsonMode) { + displayLogo(this.log.bind(this)); + } + + await this.runAuth(flags); + + const resolvedTargets = await resolveSkillsTargets({ + flags, + jsonMode, + log: this.log.bind(this), + warn: (msg) => this.logWarning(msg, flags), + exit: () => this.exit(130), + }); + if (resolvedTargets === null) { + if (!jsonMode) this.displayGettingStarted(); + return; + } + + try { + await runSkillsInstall( + { target: resolvedTargets }, + this.buildInstallOutput(flags), + ); + } catch (error) { + this.fail(error, flags, "init"); + } + + if (!jsonMode) { + this.displayGettingStarted(); + } + } + + private buildInstallOutput(flags: BaseFlags): SkillsInstallOutput { + return { + jsonMode: this.shouldOutputJson(flags), + progress: (msg) => this.logProgress(msg, flags), + success: (msg) => this.logSuccessMessage(msg, flags), + warning: (msg) => this.logWarning(msg, flags), + log: (msg) => this.log(msg), + emitResult: (data) => this.logJsonResult(data, flags), + }; + } + + private displayGettingStarted(): void { + const $ = chalk.green("$"); + const cmd = (s: string) => chalk.cyan(s); + const note = (s: string) => chalk.dim(s); + + this.log(`${formatHeading("Getting started with the Ably CLI")}\n`); + this.log( + "The Ably CLI lets you publish messages, subscribe to channels, manage", + ); + this.log("apps and keys, and explore Ably from your terminal.\n"); + + this.log("Try it — open two terminals and run:"); + this.log( + ` ${$} ${cmd("ably channels subscribe my-channel")} ${note("# terminal 1")}`, + ); + this.log( + ` ${$} ${cmd('ably channels publish my-channel "hello world"')} ${note("# terminal 2")}\n`, + ); + + this.log("Useful next steps:"); + this.log( + ` ${$} ${cmd("ably --help")} ${note("# browse all commands")}\n`, + ); + + this.log("Docs: https://ably.com/docs/cli\n"); + } + + private async runAuth(flags: BaseFlags): Promise { + if (this.hasControlApiAccess()) { + if (!this.shouldOutputJson(flags)) { + const account = this.configManager.getCurrentAccount(); + const label = account?.accountName + ? `${account.accountName}${account.accountId ? ` (${account.accountId})` : ""}` + : "stored credentials"; + this.logSuccessMessage( + `Already authenticated with ${formatResource(label)}.`, + flags, + ); + } + return; + } + + if (!this.shouldOutputJson(flags)) { + this.log(`\n${formatHeading("Authenticate with Ably")}\n`); + } + + // accounts:login handles JSON mode natively — emitting an + // awaiting_authorization event with userCode + verificationUri so + // headless callers can render the device-flow prompt themselves. + // We pass --skip-logo to avoid printing the Ably ASCII art twice + // (init already printed it above). + const loginArgv: string[] = ["--skip-logo"]; + if (flags.json) loginArgv.push("--json"); + else if (flags["pretty-json"]) loginArgv.push("--pretty-json"); + // Suppress accounts:login's terminal {status:"completed"} JSON line so + // init's own terminator in finally() is the only one in the stream. + if (flags.json || flags["pretty-json"]) { + loginArgv.push("--skip-completed-status"); + } + + // Test hook: intercept the accounts:login delegation so unit tests can + // verify init's unauthenticated branch without spinning up the real + // OAuth device-code flow. Tests set globalThis.__TEST_MOCKS__.runLogin to + // a recording function or one that throws. + const loginRunner = + isTestMode() && globalThis.__TEST_MOCKS__?.runLogin + ? ( + globalThis.__TEST_MOCKS__ as { + runLogin: (argv: string[]) => Promise; + } + ).runLogin + : (argv: string[]) => this.config.runCommand("accounts:login", argv); + + try { + await loginRunner(loginArgv); + } catch (error) { + this.fail(error, flags, "init"); + } + } + + // Checks for Control API auth (account-level OAuth access token), which is + // what `accounts:login` provides. Data-plane env vars (ABLY_API_KEY / + // ABLY_TOKEN) intentionally do NOT count here — they only authenticate the + // realtime/REST product API and don't grant Control API access (apps, keys, + // queues, integrations, etc.) that the rest of the CLI relies on. + private hasControlApiAccess(): boolean { + if (process.env.ABLY_ACCESS_TOKEN) return true; + return Boolean(this.configManager.getAccessToken()); + } +} diff --git a/src/commands/skills/index.ts b/src/commands/skills/index.ts new file mode 100644 index 000000000..ac1d2530b --- /dev/null +++ b/src/commands/skills/index.ts @@ -0,0 +1,13 @@ +import { BaseTopicCommand } from "../../base-topic-command.js"; + +export default class Skills extends BaseTopicCommand { + protected topicName = "skills"; + protected commandGroup = "Agent Skills"; + + static override description = "Install Ably Agent Skills for AI coding tools"; + + static override examples = [ + "<%= config.bin %> <%= command.id %> install", + "<%= config.bin %> <%= command.id %> install --target claude-code", + ]; +} diff --git a/src/commands/skills/install.ts b/src/commands/skills/install.ts new file mode 100644 index 000000000..65f72920e --- /dev/null +++ b/src/commands/skills/install.ts @@ -0,0 +1,79 @@ +import { Flags } from "@oclif/core"; + +import { AblyBaseCommand } from "../../base-command.js"; +import { coreGlobalFlags } from "../../flags.js"; +import { + runSkillsInstall, + SkillsInstallOutput, +} from "../../services/skills-install-runner.js"; +import { TARGET_CONFIGS } from "../../services/skills-installer.js"; +import { resolveSkillsTargets } from "../../services/skills-target-prompt.js"; +import { BaseFlags } from "../../types/cli.js"; + +export default class SkillsInstall extends AblyBaseCommand { + static override description = + "Install Ably Agent Skills into AI coding tools"; + + static override examples = [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --target claude-code", + "<%= config.bin %> <%= command.id %> --target cursor --target windsurf", + "<%= config.bin %> <%= command.id %> --target auto", + "<%= config.bin %> <%= command.id %> --json", + ]; + + static override flags = { + ...coreGlobalFlags, + target: Flags.string({ + char: "t", + multiple: true, + options: ["auto", ...Object.keys(TARGET_CONFIGS)], + default: ["auto"], + description: "Target IDE(s) to install skills for", + }), + }; + + async run(): Promise { + const { flags } = await this.parse(SkillsInstall); + const jsonMode = this.shouldOutputJson(flags); + + if (flags.target.includes("auto") && flags.target.length > 1) { + this.fail( + new Error( + "--target auto cannot be combined with explicit targets. Use either auto-detect or named targets, not both.", + ), + flags, + "skillsInstall", + ); + } + + const resolvedTargets = await resolveSkillsTargets({ + flags, + jsonMode, + log: this.log.bind(this), + warn: (msg) => this.logWarning(msg, flags), + exit: () => this.exit(130), + }); + if (resolvedTargets === null) return; + + try { + await runSkillsInstall( + { target: resolvedTargets }, + this.buildInstallOutput(flags), + ); + } catch (error) { + this.fail(error, flags, "skillsInstall"); + } + } + + protected buildInstallOutput(flags: BaseFlags): SkillsInstallOutput { + return { + jsonMode: this.shouldOutputJson(flags), + progress: (msg) => this.logProgress(msg, flags), + success: (msg) => this.logSuccessMessage(msg, flags), + warning: (msg) => this.logWarning(msg, flags), + log: (msg) => this.log(msg), + emitResult: (data) => this.logJsonResult(data, flags), + }; + } +} diff --git a/src/services/claude-plugin-installer.ts b/src/services/claude-plugin-installer.ts new file mode 100644 index 000000000..09ed6c05a --- /dev/null +++ b/src/services/claude-plugin-installer.ts @@ -0,0 +1,159 @@ +import { execFile } from "node:child_process"; + +import isTestMode from "../utils/test-mode.js"; +import { SKILLS_REPO } from "./skills-downloader.js"; + +export enum PluginInstallStatus { + Installed = "installed", + AlreadyInstalled = "already-installed", + Partial = "partial", + Error = "error", +} + +export interface PluginInstallFailure { + name: string; + error: string; +} + +export interface PluginInstallResult { + status: PluginInstallStatus; + pluginsInstalled: string[]; + pluginsAlreadyInstalled: string[]; + pluginsFailed: PluginInstallFailure[]; + /** Top-level error (e.g. manifest fetch failed). Only set when status is "error". */ + error?: string; +} + +interface MarketplaceManifest { + name: string; + plugins: { name: string }[]; +} + +function runClaude( + args: string[], +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile("claude", args, { timeout: 30_000 }, (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr || error.message)); + } else { + resolve({ stdout, stderr }); + } + }); + }); +} + +function isAlreadyInstalledMessage(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes("already installed") || + lower.includes("already exists") || + lower.includes("already added") + ); +} + +async function fetchMarketplaceManifest( + repo: string, + ref: string, +): Promise { + const url = `https://raw.githubusercontent.com/${repo}/${encodeURIComponent(ref)}/.claude-plugin/marketplace.json`; + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to fetch marketplace manifest from ${repo}@${ref}: ${response.statusText}`, + ); + } + return (await response.json()) as MarketplaceManifest; +} + +export async function installClaudePlugin( + ref: string, +): Promise { + if (isTestMode() && globalThis.__TEST_MOCKS__?.installClaudePlugin) { + return ( + globalThis.__TEST_MOCKS__ as { + installClaudePlugin: (ref: string) => Promise; + } + ).installClaudePlugin(ref); + } + let manifest: MarketplaceManifest; + try { + manifest = await fetchMarketplaceManifest(SKILLS_REPO, ref); + } catch (error) { + return { + status: PluginInstallStatus.Error, + pluginsInstalled: [], + pluginsAlreadyInstalled: [], + pluginsFailed: [], + error: error instanceof Error ? error.message : String(error), + }; + } + + const marketplaceName = manifest.name; + const pluginNames = manifest.plugins.map((p) => p.name); + + if (pluginNames.length === 0) { + return { + status: PluginInstallStatus.Error, + pluginsInstalled: [], + pluginsAlreadyInstalled: [], + pluginsFailed: [], + error: "No plugins found in marketplace", + }; + } + + try { + await runClaude(["plugin", "marketplace", "add", SKILLS_REPO]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!isAlreadyInstalledMessage(message)) { + return { + status: PluginInstallStatus.Error, + pluginsInstalled: [], + pluginsAlreadyInstalled: [], + pluginsFailed: [], + error: message, + }; + } + } + + const pluginsInstalled: string[] = []; + const pluginsAlreadyInstalled: string[] = []; + const pluginsFailed: PluginInstallFailure[] = []; + + for (const pluginName of pluginNames) { + try { + await runClaude([ + "plugin", + "install", + `${pluginName}@${marketplaceName}`, + ]); + pluginsInstalled.push(pluginName); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isAlreadyInstalledMessage(message)) { + pluginsAlreadyInstalled.push(pluginName); + } else { + pluginsFailed.push({ name: pluginName, error: message }); + } + } + } + + let status: PluginInstallStatus; + if (pluginsFailed.length === pluginNames.length) { + status = PluginInstallStatus.Error; + } else if (pluginsFailed.length > 0) { + status = PluginInstallStatus.Partial; + } else if (pluginsInstalled.length === 0) { + status = PluginInstallStatus.AlreadyInstalled; + } else { + status = PluginInstallStatus.Installed; + } + + return { + status, + pluginsInstalled, + pluginsAlreadyInstalled, + pluginsFailed, + }; +} diff --git a/src/services/skills-attestation-verifier.ts b/src/services/skills-attestation-verifier.ts new file mode 100644 index 000000000..9bea05920 --- /dev/null +++ b/src/services/skills-attestation-verifier.ts @@ -0,0 +1,133 @@ +import * as crypto from "node:crypto"; +import type { Bundle } from "sigstore"; + +import isTestMode from "../utils/test-mode.js"; + +/** + * Repo whose attestations we'll fetch and trust. Locked to the canonical + * agent-skills repo so a fork can't pretend to be us. + */ +export const ATTESTATION_REPO = "ably/agent-skills"; + +/** + * Workflow file path that must have produced the attestation. The cert SAN + * URI ends in `/@`, so locking this prevents an + * attestation from any *other* workflow file in the same repo from being + * accepted (e.g. a malicious workflow added in a PR). + */ +export const ATTESTATION_WORKFLOW_PATH = ".github/workflows/release.yml"; + +/** OIDC issuer GitHub Actions uses to sign Sigstore identity certificates. */ +const GITHUB_ACTIONS_OIDC_ISSUER = + "https://token.actions.githubusercontent.com"; + +const escapeRegex = (s: string) => s.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +interface AttestationsResponse { + attestations?: Array<{ + bundle: Bundle; + repository_id?: number; + }>; +} + +export interface AttestationVerificationOptions { + repo: string; + workflowPath: string; +} + +export interface AttestationVerificationResult { + /** SHA-256 of the verified tarball, hex-encoded. */ + tarballSha256: string; + /** Cert SAN identity that signed the attestation (the workflow URI). */ + signerIdentity: string; +} + +/** + * Verify a tarball against GitHub's published SLSA build-provenance attestation. + * + * Steps: + * 1. SHA-256 the tarball. + * 2. Pull the attestation bundle from `repos//attestations/sha256:`. + * 3. Verify cryptographically via sigstore (Sigstore Public Good trust root). + * 4. Enforce a SAN-URI policy that locks the signer to `/` + * on either a release tag (`refs/tags/v*`) or `refs/heads/main` + * (workflow_dispatch path). + * + * Throws on any verification failure — there is no fallback. + */ +export async function verifyTarballAttestation( + tarball: Buffer, + opts: AttestationVerificationOptions, +): Promise { + const tarballSha256 = crypto + .createHash("sha256") + .update(tarball) + .digest("hex"); + + // Test hook: oclif's command loader can sidestep vitest's module-mocking, + // so we expose a per-test override that returns a canned result. Real + // verification (network + cryptographic checks against Sigstore Public + // Good) only runs in production. Tests set __TEST_MOCKS__.verifyAttestation + // to either a result or a function that throws, to exercise both branches. + if (isTestMode() && globalThis.__TEST_MOCKS__?.verifyAttestation) { + const hook = globalThis.__TEST_MOCKS__ as { + verifyAttestation: ( + sha256: string, + opts: AttestationVerificationOptions, + ) => + | Promise + | AttestationVerificationResult; + }; + return hook.verifyAttestation(tarballSha256, opts); + } + + const url = `https://api.github.com/repos/${opts.repo}/attestations/sha256:${tarballSha256}`; + const response = await fetch(url, { + headers: { Accept: "application/vnd.github+json" }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error( + `Failed to fetch attestation for ${opts.repo}@${tarballSha256.slice(0, 12)}: ${response.status} ${response.statusText}. ` + + `The release tarball is not attested by ${opts.repo} — refusing to install.`, + ); + } + + const data = (await response.json()) as AttestationsResponse; + if (!data.attestations || data.attestations.length === 0) { + throw new Error( + `No attestations found for ${opts.repo}@${tarballSha256.slice(0, 12)} — refusing to install.`, + ); + } + + // sigstore-js's SAN check uses String.prototype.match(), which interprets + // the policy string as a regex. We anchor with ^...$ so we require a full + // match, escape regex metacharacters in the inputs, and require the workflow + // ref to be a release tag (`refs/tags/v*`). Branches (including main) are + // rejected: an attacker with write access to main could otherwise click + // "Run workflow" from main and produce a valid attestation. + // workflow_dispatch re-runs must select the tag in "Use workflow from" — + // not main — for the attestation to verify. + const sanPolicy = `^https://github\\.com/${escapeRegex(opts.repo)}/${escapeRegex(opts.workflowPath)}@refs/tags/v[0-9].*$`; + + const { verify } = await import("sigstore"); + + let lastError: Error | undefined; + for (const att of data.attestations) { + try { + const signer = await verify(att.bundle, tarball, { + certificateIssuer: GITHUB_ACTIONS_OIDC_ISSUER, + certificateIdentityURI: sanPolicy, + }); + const signerIdentity = + signer.identity?.subjectAlternativeName ?? ""; + return { tarballSha256, signerIdentity }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + + throw new Error( + `Attestation verification failed for ${opts.repo}@${tarballSha256.slice(0, 12)}: ${lastError?.message ?? "unknown error"}`, + ); +} diff --git a/src/services/skills-downloader.ts b/src/services/skills-downloader.ts new file mode 100644 index 000000000..fda2010c6 --- /dev/null +++ b/src/services/skills-downloader.ts @@ -0,0 +1,235 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import { createGunzip } from "node:zlib"; +import { Readable } from "node:stream"; +import { extract } from "tar"; + +import { + ATTESTATION_REPO, + ATTESTATION_WORKFLOW_PATH, + verifyTarballAttestation, +} from "./skills-attestation-verifier.js"; + +export const SKILLS_REPO = ATTESTATION_REPO; + +export interface DownloadedSkill { + name: string; + directory: string; + description?: string; +} + +export interface SkillsSource { + repo: string; + tag: string; + name: string; + /** Commit SHA of the released tag — for human auditability. */ + sha: string; + /** SHA-256 of the verified release tarball, hex-encoded. */ + tarballSha256: string; + /** Cert SAN URI of the workflow that produced the attestation. */ + attestedBy: string; +} + +export interface SkillsDownloadResult { + skills: DownloadedSkill[]; + source: SkillsSource; +} + +interface ReleaseInfo { + tag_name: string; + name: string; +} + +interface TagRef { + object: { sha: string; type: string; url: string }; +} + +interface TagObject { + object: { sha: string }; +} + +interface SkillFrontmatter { + name?: string; + description?: string; +} + +function parseSkillFrontmatter(content: string): SkillFrontmatter { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return {}; + const block = match[1]!; + const result: SkillFrontmatter = {}; + + const nameMatch = block.match(/^name:[ \t]*(.+)$/m); + if (nameMatch) result.name = nameMatch[1]!.trim(); + + // YAML folded scalar: `description: >` (or `|`) followed by indented lines + const foldedMatch = block.match( + /^description:[ \t]*[>|][-+]?[ \t]*\r?\n((?:[ \t]+.*(?:\r?\n|$))+)/m, + ); + if (foldedMatch) { + const lines = foldedMatch[1]! + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + result.description = lines.join(" "); + } else { + const inlineMatch = block.match(/^description:[ \t]*(.+)$/m); + if (inlineMatch) result.description = inlineMatch[1]!.trim(); + } + + return result; +} + +async function fetchJson(url: string, errorPrefix: string): Promise { + const response = await fetch(url, { + headers: { Accept: "application/vnd.github+json" }, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw new Error( + `${errorPrefix}: ${response.status} ${response.statusText}`, + ); + } + return (await response.json()) as T; +} + +async function resolveLatestRelease( + repo: string, +): Promise<{ tag: string; name: string; sha: string }> { + const release = await fetchJson( + `https://api.github.com/repos/${repo}/releases/latest`, + `Failed to resolve latest release of ${repo} — please retry`, + ); + + // Resolve the tag ref → commit SHA. Annotated tags need a second hop. + const tagRef = await fetchJson( + `https://api.github.com/repos/${repo}/git/refs/tags/${encodeURIComponent(release.tag_name)}`, + `Failed to resolve tag ${release.tag_name} of ${repo}`, + ); + + let sha = tagRef.object.sha; + if (tagRef.object.type === "tag") { + const tagObject = await fetchJson( + tagRef.object.url, + `Failed to dereference tag ${release.tag_name} of ${repo}`, + ); + sha = tagObject.object.sha; + } + + return { + tag: release.tag_name, + name: release.name || release.tag_name, + sha, + }; +} + +/** + * Build the release-asset URL produced by `ably/agent-skills`'s release + * workflow. The asset name format `-.tar.gz` is fixed in + * `.github/workflows/release.yml` (ably/agent-skills) — keep these in sync. + */ +function releaseAssetUrl(repo: string, tag: string): string { + const repoName = repo.split("/")[1]!; + return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${repoName}-${tag}.tar.gz`; +} + +async function fetchTarballAsBuffer(url: string, tag: string): Promise { + const response = await fetch(url, { + signal: AbortSignal.timeout(60_000), + }); + if (!response.ok) { + throw new Error( + `Failed to download skills release asset for ${SKILLS_REPO}@${tag}: ${response.status} ${response.statusText}. ` + + `The release may be missing the attested tarball asset.`, + ); + } + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); +} + +export class SkillsDownloader { + private tempDir: string | null = null; + + async download(): Promise { + const release = await resolveLatestRelease(SKILLS_REPO); + + // Always fetch from the release asset URL — that's the file the SLSA + // attestation is signed against. The auto-generated /archive/refs/tags/ + // tarball is not attested and must be ignored. + const tarballUrl = releaseAssetUrl(SKILLS_REPO, release.tag); + const tarball = await fetchTarballAsBuffer(tarballUrl, release.tag); + + // Verify the SLSA build-provenance attestation BEFORE extracting. If + // verification fails (no bundle, wrong signer, signature mismatch, etc.), + // we throw and never touch the tarball contents on disk. + const verification = await verifyTarballAttestation(tarball, { + repo: SKILLS_REPO, + workflowPath: ATTESTATION_WORKFLOW_PATH, + }); + + this.tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ably-skills-")); + + await pipeline( + Readable.from(tarball), + createGunzip(), + extract({ cwd: this.tempDir, strip: 1 }), + ); + + const source: SkillsSource = { + repo: SKILLS_REPO, + tag: release.tag, + name: release.name, + sha: release.sha, + tarballSha256: verification.tarballSha256, + attestedBy: verification.signerIdentity, + }; + + const skills = this.findSkills(this.tempDir, source); + return { skills, source }; + } + + private findSkills(baseDir: string, source: SkillsSource): DownloadedSkill[] { + const skillsDir = path.join(baseDir, "skills"); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + throw new Error( + `Skills directory missing in ${source.repo}@${source.tag}`, + { cause: error }, + ); + } + throw error; + } + + const skills: DownloadedSkill[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const fullPath = path.join(skillsDir, entry.name); + const skillFile = path.join(fullPath, "SKILL.md"); + let content: string; + try { + content = fs.readFileSync(skillFile, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + const { description } = parseSkillFrontmatter(content); + skills.push({ name: entry.name, directory: fullPath, description }); + } + + return skills; + } + + cleanup(): void { + if (this.tempDir) { + fs.rmSync(this.tempDir, { recursive: true, force: true }); + this.tempDir = null; + } + } +} diff --git a/src/services/skills-install-runner.ts b/src/services/skills-install-runner.ts new file mode 100644 index 000000000..445578471 --- /dev/null +++ b/src/services/skills-install-runner.ts @@ -0,0 +1,329 @@ +import chalk from "chalk"; + +import { formatHeading, formatLabel, formatResource } from "../utils/output.js"; +import { + installClaudePlugin, + PluginInstallStatus, +} from "./claude-plugin-installer.js"; +import { + DownloadedSkill, + SkillsDownloader, + SkillsSource, +} from "./skills-downloader.js"; +import { + CLAUDE_CODE, + InstallResult, + SkillsInstaller, + TARGET_CONFIGS, +} from "./skills-installer.js"; +import { + DetectedTool, + InstallMethod, + detectTools as runToolDetection, +} from "./tool-detector.js"; + +export interface SkillsInstallFlags { + target: string[]; +} + +export interface SkillsInstallOutput { + jsonMode: boolean; + /** Progress note (silent in JSON mode). */ + progress(message: string): void; + /** Success line (silent in JSON mode, stderr otherwise). */ + success(message: string): void; + /** Warning (also surfaced as JSON status in JSON mode). */ + warning(message: string): void; + /** Raw stdout line — used for headings and skill list in non-JSON mode. */ + log(message: string): void; + /** Final JSON result envelope. */ + emitResult(data: Record): void; +} + +export interface SkillsInstallSummary { + skills: DownloadedSkill[]; + results: InstallResult[]; + pluginInstalled: boolean; + detectedTools: DetectedTool[]; + source?: SkillsSource; +} + +export async function runSkillsInstall( + flags: SkillsInstallFlags, + output: SkillsInstallOutput, +): Promise { + const isAutoDetect = flags.target.includes("auto"); + // Always run full tool detection so the JSON envelope's + // `installation.detectedTools` has a stable shape regardless of how the + // command was invoked. Cost is one cheap probe per supported tool. + const detectedTools: DetectedTool[] = await detectTargets(output, { + log: isAutoDetect, + }); + let fileCopyTargets: string[]; + + if (isAutoDetect) { + const found = detectedTools.filter((t) => t.detected); + if (found.length === 0) { + output.warning( + "No AI coding tools detected. Use --target to specify targets manually.", + ); + if (output.jsonMode) { + output.emitResult({ + installation: { + skills: [], + installed: [], + pluginInstalled: false, + detectedTools, + }, + }); + } + return { + skills: [], + results: [], + pluginInstalled: false, + detectedTools, + }; + } + + fileCopyTargets = found + .filter((t) => t.installMethod === InstallMethod.FileCopy) + .map((t) => t.id) + .filter((id) => id in TARGET_CONFIGS); + } else { + fileCopyTargets = SkillsInstaller.resolveTargets(flags.target); + } + + const hasClaudePlugin = isAutoDetect + ? detectedTools.some((t) => t.id === CLAUDE_CODE && t.detected) + : fileCopyTargets.includes(CLAUDE_CODE) && + detectedTools.some((t) => t.id === CLAUDE_CODE && t.detected); + + if (hasClaudePlugin) { + fileCopyTargets = fileCopyTargets.filter((id) => id !== CLAUDE_CODE); + } + + const downloader = new SkillsDownloader(); + let skills: DownloadedSkill[] = []; + let source: SkillsSource | undefined; + const allResults: InstallResult[] = []; + let pluginInstalled = false; + + try { + if (!output.jsonMode && (fileCopyTargets.length > 0 || hasClaudePlugin)) { + output.log(`\n${formatHeading("Installing skills")}\n`); + } + + if (fileCopyTargets.length > 0 || hasClaudePlugin) { + const downloaded = await downloadSkills(downloader, output); + skills = downloaded.skills; + source = downloaded.source; + } + + if (hasClaudePlugin) { + // `source` is guaranteed set here: the download block above runs + // whenever `hasClaudePlugin || fileCopyTargets.length > 0`. + const outcome = await installClaudeCodePlugin(output, source!.sha); + if ( + outcome === PluginInstallStatus.Installed || + outcome === PluginInstallStatus.AlreadyInstalled || + outcome === PluginInstallStatus.Partial + ) { + pluginInstalled = true; + } else { + // Plugin install failed — fall back to file-copy for Claude Code. + // `skills` is already populated above because hasClaudePlugin was true. + fileCopyTargets.push(CLAUDE_CODE); + } + } + + if (fileCopyTargets.length > 0 && skills.length > 0) { + const installer = new SkillsInstaller(); + const { results } = installer.install({ + skills, + targets: fileCopyTargets, + }); + + for (const result of results) { + output.success( + `${result.name.padEnd(12)} → ${chalk.dim(result.directory + "/")}`, + ); + } + + allResults.push(...results); + } + + if (output.jsonMode) { + output.emitResult({ + installation: { + skills: skills.map((s) => ({ + name: s.name, + description: s.description, + })), + installed: allResults, + pluginInstalled, + ...(source && { source }), + detectedTools, + }, + }); + } else { + displaySummary(output, allResults, pluginInstalled, skills); + } + + return { + skills, + results: allResults, + pluginInstalled, + detectedTools, + source, + }; + } finally { + downloader.cleanup(); + } +} + +async function detectTargets( + output: SkillsInstallOutput, + opts: { log: boolean } = { log: true }, +): Promise { + if (opts.log) output.progress("Scanning for AI coding tools"); + + const detected = await runToolDetection(); + + if (!opts.log || output.jsonMode) return detected; + + const found = detected.filter((t) => t.detected); + const notFound = detected.filter((t) => !t.detected); + + output.log( + `\n${formatLabel("Detected")} ${found.length} AI coding tool${found.length === 1 ? "" : "s"}`, + ); + for (const tool of found) { + const method = + tool.installMethod === InstallMethod.Plugin + ? "plugin install" + : "file copy"; + output.log( + ` ${chalk.green("●")} ${formatResource(tool.name.padEnd(15))} ${chalk.dim(`(${tool.evidence})`.padEnd(28))} → ${method}`, + ); + } + if (notFound.length > 0) { + output.log( + chalk.dim(`\nNot found: ${notFound.map((t) => t.name).join(", ")}`), + ); + } + + return detected; +} + +async function downloadSkills( + downloader: SkillsDownloader, + output: SkillsInstallOutput, +): Promise<{ skills: DownloadedSkill[]; source: SkillsSource }> { + output.progress("Downloading skills from GitHub"); + const result = await downloader.download(); + output.success( + `Downloaded ${result.skills.length} skills (verified ${result.source.repo}@${result.source.tag}).`, + ); + return result; +} + +async function installClaudeCodePlugin( + output: SkillsInstallOutput, + ref: string, +): Promise { + const claude = "Claude Code".padEnd(12); + output.progress(`${claude} → installing via plugin system`); + const result = await installClaudePlugin(ref); + + switch (result.status) { + case PluginInstallStatus.Installed: { + output.success(`${claude} → installed via plugin system.`); + break; + } + case PluginInstallStatus.AlreadyInstalled: { + output.success(`${claude} → already installed (plugin).`); + break; + } + case PluginInstallStatus.Partial: { + const failedNames = result.pluginsFailed.map((p) => p.name).join(", "); + output.warning( + `${claude} → installed with errors (failed: ${failedNames}).`, + ); + for (const failure of result.pluginsFailed) { + output.warning(` ${failure.name}: ${failure.error}`); + } + break; + } + default: { + output.warning(`${claude} → plugin failed, falling back to file copy.`); + } + } + + return result.status; +} + +function summarizeDescription( + description: string | undefined, + maxWidth: number, +): string { + if (!description) return ""; + const cleaned = description + .replace(/^ALWAYS use when /i, "Use when ") + .replaceAll(/\s+/g, " ") + .trim(); + const sentenceEnd = cleaned.search(/[.!?](\s|$)/); + const firstSentence = + sentenceEnd > 0 ? cleaned.slice(0, sentenceEnd + 1) : cleaned; + if (firstSentence.length <= maxWidth) return firstSentence; + return firstSentence.slice(0, Math.max(0, maxWidth - 1)).trimEnd() + "…"; +} + +function displaySkillList( + output: SkillsInstallOutput, + skills: DownloadedSkill[], +): void { + const nameWidth = Math.max(...skills.map((s) => s.name.length)); + const termWidth = process.stdout.columns || 100; + const descWidth = Math.max(40, termWidth - nameWidth - 8); + + output.log(""); + for (const skill of skills) { + const summary = summarizeDescription(skill.description, descWidth); + const paddedName = formatResource(skill.name.padEnd(nameWidth)); + if (summary) { + output.log(`${chalk.dim("•")} ${paddedName} ${chalk.dim(summary)}`); + } else { + output.log(`${chalk.dim("•")} ${paddedName}`); + } + } +} + +function displaySummary( + output: SkillsInstallOutput, + results: InstallResult[], + pluginInstalled: boolean, + skills: DownloadedSkill[], +): void { + const totalInstalled = results.reduce((sum, r) => sum + r.skillCount, 0); + const errors = results.flatMap((r) => + r.skills.filter((s) => s.status === "error"), + ); + + if (errors.length > 0) { + output.warning("Some skills failed to install:"); + for (const err of errors) { + output.log(` ${formatResource(err.skillName)}: ${err.error ?? ""}`); + } + } + + if (totalInstalled > 0 || pluginInstalled) { + if (skills.length > 0) { + output.log(`\n${formatHeading("Installed skills")}`); + displaySkillList(output, skills); + output.log(""); + } + output.success("Done. Restart your IDE to activate Ably skills."); + } else { + output.warning("No new skills were installed."); + } +} diff --git a/src/services/skills-installer.ts b/src/services/skills-installer.ts new file mode 100644 index 000000000..d45348bdd --- /dev/null +++ b/src/services/skills-installer.ts @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DownloadedSkill } from "./skills-downloader.js"; + +export interface InstallResult { + target: string; + name: string; + directory: string; + skillCount: number; + skills: SkillResult[]; +} + +export interface SkillResult { + skillName: string; + status: "installed" | "error"; + error?: string; +} + +interface TargetConfig { + name: string; + /** Path relative to the user's home directory. */ + relativeDir: string; +} + +export const CLAUDE_CODE = "claude-code"; + +export const TARGET_CONFIGS: Record = { + [CLAUDE_CODE]: { + name: "Claude Code", + relativeDir: path.join(".claude", "skills"), + }, + cursor: { + name: "Cursor", + relativeDir: path.join(".cursor", "skills"), + }, + vscode: { + name: "VS Code", + // Per code.visualstudio.com/docs/copilot/customization/agent-skills, + // Copilot loads personal skills from ~/.copilot/skills/. ~/.vscode/ is + // VS Code's extension dir, not a skills root. + relativeDir: path.join(".copilot", "skills"), + }, + windsurf: { + name: "Windsurf", + // Per docs.windsurf.com/windsurf/cascade/skills, Cascade loads global + // skills from ~/.codeium/windsurf/skills/, matching the rest of + // Codeium's per-user config tree (memories, global_workflows). + relativeDir: path.join(".codeium", "windsurf", "skills"), + }, +}; + +/** + * Resolve a target's install directory under the user's home directory. + * Reads `os.homedir()` lazily so tests can redirect via `HOME`. + */ +export function getTargetDirectory(targetKey: string): string | undefined { + const config = TARGET_CONFIGS[targetKey]; + if (!config) return undefined; + return path.join(os.homedir(), config.relativeDir); +} + +export class SkillsInstaller { + install(options: { skills: DownloadedSkill[]; targets: string[] }): { + results: InstallResult[]; + } { + const { skills, targets } = options; + const results: InstallResult[] = []; + + for (const targetKey of targets) { + const config = TARGET_CONFIGS[targetKey]; + const baseDir = getTargetDirectory(targetKey); + if (!config || !baseDir) continue; + + const skillResults: SkillResult[] = []; + + for (const skill of skills) { + const destDir = path.join(baseDir, skill.name); + skillResults.push(this.installSkill(skill, destDir)); + } + + const installed = skillResults.filter( + (r) => r.status === "installed", + ).length; + + results.push({ + target: targetKey, + name: config.name, + directory: baseDir, + skillCount: installed, + skills: skillResults, + }); + } + + return { results }; + } + + private installSkill(skill: DownloadedSkill, destDir: string): SkillResult { + try { + fs.rmSync(destDir, { recursive: true, force: true }); + fs.mkdirSync(destDir, { recursive: true }); + fs.cpSync(skill.directory, destDir, { recursive: true }); + return { skillName: skill.name, status: "installed" }; + } catch (error) { + return { + skillName: skill.name, + status: "error", + error: error instanceof Error ? error.message : String(error), + }; + } + } + + static resolveTargets(targets: string[]): string[] { + if (targets.includes("auto")) { + return []; + } + return targets; + } +} diff --git a/src/services/skills-target-prompt.ts b/src/services/skills-target-prompt.ts new file mode 100644 index 000000000..ed7aad3b0 --- /dev/null +++ b/src/services/skills-target-prompt.ts @@ -0,0 +1,133 @@ +import { checkbox } from "@inquirer/prompts"; +import * as readline from "node:readline"; + +import { formatHeading } from "../utils/output.js"; +import { runInquirerWithReadlineRestore } from "../utils/readline-helper.js"; +import isTestMode from "../utils/test-mode.js"; +import { detectTools } from "./tool-detector.js"; +import { TARGET_CONFIGS } from "./skills-installer.js"; + +export interface TargetPromptOptions { + /** Stdout writer for headings (e.g. command's `this.log`). */ + log: (msg: string) => void; + /** Warning emitter (e.g. command's `this.logWarning(msg, flags)`). */ + warn: (msg: string) => void; + /** SIGINT handler that exits cleanly (e.g. `this.exit(130)`). */ + onSigint: () => void; +} + +/** + * Auto-detect installed AI coding tools and prompt the user to choose which to + * configure. Returns: + * - `string[]` — chosen target IDs (may be empty if user picked nothing) + * - `null` — no tools detected, or the user cancelled the prompt + * + * Only call this when stdout/stdin are TTYs and JSON mode is off; the caller + * is responsible for that gating so it can fall through to non-interactive + * auto-install when appropriate. + */ +export async function promptForTargets( + opts: TargetPromptOptions, +): Promise { + opts.log(`\n${formatHeading("Detecting AI coding tools")}\n`); + + const detected = await detectTools(); + const choices = detected + .filter((t) => t.detected && t.id in TARGET_CONFIGS) + .map((t) => ({ + name: t.name, + value: t.id, + checked: true, + })); + + if (choices.length === 0) { + opts.warn( + "No AI coding tools detected. Use --target to specify editors manually.", + ); + return null; + } + + // Test hook: short-circuit the interactive prompt so unit tests don't have + // to drive a real TTY. Tests set globalThis.__TEST_MOCKS__.checkboxResponse + // to either an array (the picked targets) or "throw" (simulate cancel). + if ( + isTestMode() && + globalThis.__TEST_MOCKS__?.checkboxResponse !== undefined + ) { + const response = ( + globalThis.__TEST_MOCKS__ as { checkboxResponse: string[] | "throw" } + ).checkboxResponse; + return response === "throw" ? null : response; + } + + // Take ownership of SIGINT during the prompt: inquirer's signal-exit + // handler rejects the prompt promise, but Node still tears down the + // top-level await before our catch runs. By exiting (130) here we make + // cancellation deterministic and avoid the "unsettled top-level await" + // warning. + process.once("SIGINT", opts.onSigint); + + // When running inside the `ably interactive` shell, we must restore the + // shell's readline state after inquirer takes over raw mode — otherwise + // arrow keys emit escape sequences and the prompt never redraws. + const interactiveReadline = + process.env.ABLY_INTERACTIVE_MODE === "true" + ? ((globalThis as Record) + .__ablyInteractiveReadline as readline.Interface | null) + : null; + + try { + return await runInquirerWithReadlineRestore( + () => + checkbox({ + message: "Which editor(s) would you like to configure?", + choices, + instructions: + " (Press to toggle, to toggle all, to confirm)", + }), + interactiveReadline, + ); + } catch { + return null; + } finally { + process.removeListener("SIGINT", opts.onSigint); + } +} + +export interface ResolveTargetsOptions { + flags: { target: string[] }; + jsonMode: boolean; + log: (msg: string) => void; + warn: (msg: string) => void; + exit: () => void; +} + +/** + * Apply the auto-detect → interactive-prompt → resolved-targets flow shared by + * `init` and `skills install`. Returns the targets to install, or `null` if + * the user cancelled or selected nothing (callers should bail out). + * + * When `--target auto` is not set, or when stdio is non-TTY, returns + * `flags.target` unchanged so the runner can do its own auto-detection. + */ +export async function resolveSkillsTargets( + opts: ResolveTargetsOptions, +): Promise { + const isAutoDetect = opts.flags.target.includes("auto"); + const isInteractive = + !opts.jsonMode && Boolean(process.stdout.isTTY && process.stdin.isTTY); + + if (!(isAutoDetect && isInteractive)) return opts.flags.target; + + const picked = await promptForTargets({ + log: opts.log, + warn: opts.warn, + onSigint: opts.exit, + }); + if (picked === null) return null; + if (picked.length === 0) { + opts.warn("No editors selected — skipping skill installation."); + return null; + } + return picked; +} diff --git a/src/services/tool-detector.ts b/src/services/tool-detector.ts new file mode 100644 index 000000000..b67238dda --- /dev/null +++ b/src/services/tool-detector.ts @@ -0,0 +1,195 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import isTestMode from "../utils/test-mode.js"; + +export enum InstallMethod { + Plugin = "plugin", + FileCopy = "file-copy", +} + +export const Platform = { + Darwin: "darwin", + Linux: "linux", + Windows: "win32", +} as const; + +export interface DetectedTool { + id: string; + name: string; + detected: boolean; + /** First piece of evidence found (e.g. "cli: claude", "config: ~/.cursor"). Empty when not detected. */ + evidence: string; + installMethod: InstallMethod; +} + +interface ToolCheck { + id: string; + name: string; + installMethod: InstallMethod; + cliNames?: string[]; + macApps?: string[]; + linuxPaths?: string[]; + winPaths?: string[]; + configDirs?: string[]; +} + +const home = os.homedir(); +const localAppData = + process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"); +const programFiles = process.env.ProgramFiles || "C:\\Program Files"; + +const TOOL_CHECKS: ToolCheck[] = [ + { + id: "claude-code", + name: "Claude Code", + installMethod: InstallMethod.Plugin, + cliNames: ["claude"], + configDirs: [path.join(home, ".claude")], + }, + { + id: "cursor", + name: "Cursor", + installMethod: InstallMethod.FileCopy, + cliNames: ["cursor"], + macApps: ["/Applications/Cursor.app"], + linuxPaths: [ + "/usr/share/cursor", + "/opt/Cursor", + path.join(home, ".local", "share", "cursor"), + ], + winPaths: [ + path.join(localAppData, "Programs", "Cursor", "Cursor.exe"), + path.join(localAppData, "cursor"), + ], + configDirs: [path.join(home, ".cursor")], + }, + { + id: "vscode", + name: "VS Code", + installMethod: InstallMethod.FileCopy, + cliNames: ["code"], + macApps: ["/Applications/Visual Studio Code.app"], + linuxPaths: ["/usr/share/code", "/snap/code/current", "/usr/bin/code"], + winPaths: [ + path.join(programFiles, "Microsoft VS Code", "Code.exe"), + path.join(localAppData, "Programs", "Microsoft VS Code", "Code.exe"), + ], + configDirs: [path.join(home, ".vscode")], + }, + { + id: "windsurf", + name: "Windsurf", + installMethod: InstallMethod.FileCopy, + cliNames: ["windsurf"], + macApps: ["/Applications/Windsurf.app"], + linuxPaths: ["/opt/Windsurf"], + winPaths: [path.join(localAppData, "Programs", "Windsurf", "Windsurf.exe")], + configDirs: [path.join(home, ".windsurf")], + }, +]; + +function checkCli(name: string): Promise { + const cmd = process.platform === Platform.Windows ? "where" : "which"; + return new Promise((resolve) => { + execFile(cmd, [name], { timeout: 2000 }, (error, stdout) => { + if (error) { + resolve(null); + } else { + resolve(stdout.trim()); + } + }); + }); +} + +function checkPath(filePath: string): boolean { + try { + return fs.existsSync(filePath); + } catch { + return false; + } +} + +async function detectToolFromCheck(check: ToolCheck): Promise { + // CLI binaries + if (check.cliNames) { + const results = await Promise.all( + check.cliNames.map((name) => checkCli(name)), + ); + const hit = results.findIndex(Boolean); + if (hit !== -1) { + return makeDetected(check, `cli: ${check.cliNames[hit]}`); + } + } + + // Platform-specific app paths + const platformPathKey: Partial< + Record + > = { + darwin: "macApps", + linux: "linuxPaths", + win32: "winPaths", + }; + const pathKey = platformPathKey[process.platform]; + const appPaths = pathKey ? check[pathKey] : undefined; + if (appPaths) { + const hit = appPaths.find((p) => checkPath(p)); + if (hit) return makeDetected(check, `app: ${path.basename(hit)}`); + } + + // Config directories + if (check.configDirs) { + const hit = check.configDirs.find((d) => checkPath(d)); + if (hit) return makeDetected(check, `config: ${hit.replace(home, "~")}`); + } + + return { + id: check.id, + name: check.name, + detected: false, + evidence: "", + installMethod: check.installMethod, + }; +} + +function makeDetected(check: ToolCheck, evidence: string): DetectedTool { + return { + id: check.id, + name: check.name, + detected: true, + evidence, + installMethod: check.installMethod, + }; +} + +export async function detectTools(): Promise { + if (isTestMode() && globalThis.__TEST_MOCKS__?.detectTools) { + return ( + globalThis.__TEST_MOCKS__ as { + detectTools: () => Promise; + } + ).detectTools(); + } + return Promise.all(TOOL_CHECKS.map((check) => detectToolFromCheck(check))); +} + +/** + * Focused probe for a single tool's CLI binary. Used when an explicit + * `--target` is given and we only need to learn whether one specific tool's + * CLI is on PATH (e.g. claude), without paying for a full multi-tool scan. + */ +export async function detectTool(toolId: string): Promise { + if (isTestMode() && globalThis.__TEST_MOCKS__?.detectTools) { + const tools = await ( + globalThis.__TEST_MOCKS__ as { + detectTools: () => Promise; + } + ).detectTools(); + return tools.find((t) => t.id === toolId) ?? null; + } + const check = TOOL_CHECKS.find((c) => c.id === toolId); + if (!check) return null; + return detectToolFromCheck(check); +} diff --git a/test/e2e/skills/skills-e2e.test.ts b/test/e2e/skills/skills-e2e.test.ts new file mode 100644 index 000000000..217a589e5 --- /dev/null +++ b/test/e2e/skills/skills-e2e.test.ts @@ -0,0 +1,116 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { runCommand } from "../../helpers/command-helpers.js"; +import { + cleanupTrackedResources, + resetTestTracking, + setupTestFailureHandler, +} from "../../helpers/e2e-test-helper.js"; + +// Skills install hits GitHub to fetch the agent-skills tarball + extracts it. +// Give it generous headroom over the default 20s e2e timeout. +const SKILLS_TIMEOUT_MS = 60000; + +interface JsonRecord { + type: string; + command: string; + success?: boolean; + status?: string; + exitCode?: number; + installation?: { + skills: Array<{ name: string; description?: string }>; + installed: Array<{ + target: string; + skillCount: number; + skills: Array<{ skillName: string; status: string }>; + }>; + pluginInstalled: boolean; + }; +} + +function parseNdjson(stdout: string): JsonRecord[] { + return stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line) as JsonRecord); +} + +describe("Skills install E2E", () => { + let tempHome: string; + + beforeEach(() => { + resetTestTracking(); + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "ably-skills-e2e-")); + }); + + afterEach(async () => { + await cleanupTrackedResources(); + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it( + "downloads the published bundle and installs skills into the cursor target", + async () => { + setupTestFailureHandler( + "downloads the published bundle and installs skills into the cursor target", + ); + + const result = await runCommand( + ["skills", "install", "--target", "cursor", "--json"], + { + env: { HOME: tempHome, NODE_OPTIONS: "--no-inspect" }, + timeoutMs: SKILLS_TIMEOUT_MS, + }, + ); + + expect(result.exitCode).toBe(0); + + const records = parseNdjson(result.stdout); + const resultRecord = records.find((r) => r.type === "result"); + const completedRecord = records.find( + (r) => r.type === "status" && r.status === "completed", + ); + + expect(resultRecord, "missing result envelope").toBeDefined(); + expect(resultRecord!.success).toBe(true); + expect(resultRecord!.installation).toBeDefined(); + expect(resultRecord!.installation!.skills.length).toBeGreaterThan(0); + + const cursorEntry = resultRecord!.installation!.installed.find( + (r) => r.target === "cursor", + ); + expect(cursorEntry, "cursor target not in installed list").toBeDefined(); + expect(cursorEntry!.skillCount).toBeGreaterThan(0); + expect(cursorEntry!.skills.every((s) => s.status === "installed")).toBe( + true, + ); + + expect(completedRecord, "missing completed status line").toBeDefined(); + expect(completedRecord!.exitCode).toBe(0); + + const skillsDir = path.join(tempHome, ".cursor", "skills"); + expect(fs.existsSync(skillsDir)).toBe(true); + + const onDisk = fs + .readdirSync(skillsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + const expectedSkills = cursorEntry!.skills.map((s) => s.skillName); + expect(onDisk.toSorted()).toEqual([...expectedSkills].toSorted()); + + // Every installed skill should expose a SKILL.md — confirms tar extraction + // wrote real content, not just empty directories. + for (const name of onDisk) { + expect(fs.existsSync(path.join(skillsDir, name, "SKILL.md"))).toBe( + true, + ); + } + }, + SKILLS_TIMEOUT_MS + 5000, + ); +}); diff --git a/test/unit/commands/init.test.ts b/test/unit/commands/init.test.ts new file mode 100644 index 000000000..d4336b0d1 --- /dev/null +++ b/test/unit/commands/init.test.ts @@ -0,0 +1,498 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { create as tarCreate } from "tar"; + +import { + type DetectedTool, + InstallMethod, +} from "../../../src/services/tool-detector.js"; +import { getMockConfigManager } from "../../helpers/mock-config-manager.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../helpers/standard-tests.js"; + +const fetchMock = vi.fn(); +globalThis.fetch = fetchMock as typeof fetch; + +// State for the prompt-stubbing test hook in init.ts and the tool-detector +// test hook. Tests set these in beforeEach; the source modules read them via +// globalThis.__TEST_MOCKS__. +const detectorState: { tools: DetectedTool[] } = { tools: [] }; + +const ALL_UNDETECTED: DetectedTool[] = [ + { + id: "claude-code", + name: "Claude Code", + detected: false, + evidence: "", + installMethod: InstallMethod.Plugin, + }, + { + id: "cursor", + name: "Cursor", + detected: false, + evidence: "", + installMethod: InstallMethod.FileCopy, + }, + { + id: "vscode", + name: "VS Code", + detected: false, + evidence: "", + installMethod: InstallMethod.FileCopy, + }, + { + id: "windsurf", + name: "Windsurf", + detected: false, + evidence: "", + installMethod: InstallMethod.FileCopy, + }, +]; + +async function buildSkillsTarball(...names: string[]): Promise { + const stagingDir = fs.mkdtempSync( + path.join(os.tmpdir(), "init-skills-stage-"), + ); + const repoDir = path.join(stagingDir, "agent-skills-v0.1.0"); + const skillsRoot = path.join(repoDir, "skills"); + fs.mkdirSync(skillsRoot, { recursive: true }); + for (const name of names) { + const skillDir = path.join(skillsRoot, name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + `---\nname: ${name}\ndescription: Test skill ${name}\n---\n# ${name}\n`, + ); + } + const stream = tarCreate({ gzip: true, cwd: stagingDir }, [ + "agent-skills-v0.1.0", + ]); + const chunks: Buffer[] = []; + for await (const chunk of stream as unknown as AsyncIterable) { + chunks.push(chunk); + } + fs.rmSync(stagingDir, { recursive: true, force: true }); + return Buffer.concat(chunks); +} + +const TEST_RELEASE = { + tag: "v0.1.0", + name: "v0.1.0", + sha: "abc123def456789012345678901234567890abcd", +}; + +function mockFetchWithTarball(buffer: Buffer): void { + // Default attestation verification to "passes". Per-test overrides can set + // __TEST_MOCKS__.verifyAttestation to a function that throws to exercise + // the failure path (downloader rejects, command surfaces a clear error). + globalThis.__TEST_MOCKS__ = { + ...globalThis.__TEST_MOCKS__, + verifyAttestation: (sha256: string) => ({ + tarballSha256: sha256, + signerIdentity: `https://github.com/ably/agent-skills/.github/workflows/release.yml@refs/tags/${TEST_RELEASE.tag}`, + }), + }; + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/releases/latest")) { + return { + ok: true, + statusText: "OK", + json: async () => ({ + tag_name: TEST_RELEASE.tag, + name: TEST_RELEASE.name, + }), + } as unknown as Response; + } + if (url.includes("/git/refs/tags/")) { + return { + ok: true, + statusText: "OK", + json: async () => ({ + object: { sha: TEST_RELEASE.sha, type: "commit", url: "" }, + }), + } as unknown as Response; + } + if (url.includes("/attestations/sha256:")) { + return { + ok: true, + statusText: "OK", + json: async () => ({ + attestations: [{ bundle: { mediaType: "fake-bundle" } }], + }), + } as unknown as Response; + } + if (url.includes("/releases/download/")) { + return { + ok: true, + statusText: "OK", + arrayBuffer: async () => + buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ), + } as unknown as Response; + } + return { + ok: false, + status: 404, + statusText: `Unexpected URL: ${url}`, + } as unknown as Response; + }); +} + +describe("init command", () => { + let tempDir: string; + let originalCwd: string; + let originalHome: string | undefined; + let originalPath: string | undefined; + let originalAccessToken: string | undefined; + let originalStdoutIsTTY: boolean | undefined; + let originalStdinIsTTY: boolean | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "init-test-")); + originalCwd = process.cwd(); + process.chdir(tempDir); + originalHome = process.env.HOME; + process.env.HOME = tempDir; + // Hide the host's `claude` CLI so the plugin install path is not exercised. + originalPath = process.env.PATH; + process.env.PATH = tempDir; + // Skip the auth flow — the happy path tests focus on skill installation. + originalAccessToken = process.env.ABLY_ACCESS_TOKEN; + process.env.ABLY_ACCESS_TOKEN = "test-access-token"; + + originalStdoutIsTTY = process.stdout.isTTY; + originalStdinIsTTY = process.stdin.isTTY; + + fetchMock.mockReset(); + detectorState.tools = ALL_UNDETECTED; + globalThis.__TEST_MOCKS__ = { + ...globalThis.__TEST_MOCKS__, + detectTools: () => Promise.resolve(detectorState.tools), + }; + }); + + afterEach(() => { + process.chdir(originalCwd); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (originalAccessToken === undefined) { + delete process.env.ABLY_ACCESS_TOKEN; + } else { + process.env.ABLY_ACCESS_TOKEN = originalAccessToken; + } + if (originalStdoutIsTTY === undefined) { + delete (process.stdout as { isTTY?: boolean }).isTTY; + } else { + process.stdout.isTTY = originalStdoutIsTTY; + } + if (originalStdinIsTTY === undefined) { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } else { + process.stdin.isTTY = originalStdinIsTTY; + } + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + if (globalThis.__TEST_MOCKS__) { + delete (globalThis.__TEST_MOCKS__ as Record).detectTools; + delete (globalThis.__TEST_MOCKS__ as Record) + .checkboxResponse; + delete (globalThis.__TEST_MOCKS__ as Record).runLogin; + delete (globalThis.__TEST_MOCKS__ as Record) + .verifyAttestation; + } + vi.restoreAllMocks(); + }); + + standardHelpTests("init", import.meta.url); + standardArgValidationTests("init", import.meta.url); + standardFlagTests("init", import.meta.url, ["--target", "--json"]); + + describe("functionality", () => { + it("should install skills to the requested target when already authenticated", async () => { + mockFetchWithTarball(await buildSkillsTarball("ably-pubsub")); + + const { error } = await runCommand( + ["init", "--target", "cursor"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + // 3 fetches: /releases/latest, /git/refs/tags/, then the release + // asset. Attestation verification is short-circuited by the + // __TEST_MOCKS__.verifyAttestation hook, so the /attestations/sha256: + // endpoint isn't hit in tests. + expect(fetchMock).toHaveBeenCalledTimes(3); + expect( + fs.existsSync( + path.join(tempDir, ".cursor", "skills", "ably-pubsub", "SKILL.md"), + ), + ).toBe(true); + }); + + it("should emit structured JSON describing the install with --json", async () => { + mockFetchWithTarball(await buildSkillsTarball("ably-pubsub")); + + const { stdout, error } = await runCommand( + ["init", "--target", "cursor", "--json"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + const lines = stdout.trim().split("\n"); + const resultLine = lines.find((line) => { + try { + return (JSON.parse(line) as { type?: string }).type === "result"; + } catch { + return false; + } + }); + expect(resultLine).toBeDefined(); + const record = JSON.parse(resultLine!) as { + type: string; + success: boolean; + installation: { + installed: Array<{ target: string; skillCount: number }>; + }; + }; + expect(record.type).toBe("result"); + expect(record.success).toBe(true); + expect(record.installation.installed).toHaveLength(1); + expect(record.installation.installed[0]!.target).toBe("cursor"); + expect(record.installation.installed[0]!.skillCount).toBe(1); + }); + }); + + describe("error handling", () => { + it("should delegate to skills:install and surface release-resolution failures", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + } as Response); + + const { error } = await runCommand( + ["init", "--target", "cursor"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch( + /Failed to resolve latest release|Not Found/i, + ); + }); + }); + + describe("authentication", () => { + it("should treat stored config token as already-authenticated", async () => { + // Drop the env-var path so hasControlApiAccess() must check + // configManager.getAccessToken() — MockConfigManager provides one by default. + delete process.env.ABLY_ACCESS_TOKEN; + mockFetchWithTarball(await buildSkillsTarball("ably-pubsub")); + + const { stderr, error } = await runCommand( + ["init", "--target", "cursor"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + expect(stderr).toMatch(/Already authenticated/i); + expect( + fs.existsSync( + path.join(tempDir, ".cursor", "skills", "ably-pubsub", "SKILL.md"), + ), + ).toBe(true); + }); + + it("should delegate to accounts:login when neither env var nor stored token is set", async () => { + // Drop both auth sources so hasControlApiAccess() returns false and + // runAuth() must fall through to the accounts:login delegation. + delete process.env.ABLY_ACCESS_TOKEN; + getMockConfigManager().clearAccounts(); + mockFetchWithTarball(await buildSkillsTarball("ably-pubsub")); + + const recordedArgv: string[][] = []; + (globalThis.__TEST_MOCKS__ as Record).runLogin = async ( + argv: string[], + ) => { + recordedArgv.push(argv); + }; + + const { stdout, error } = await runCommand( + ["init", "--target", "cursor"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + // The "Authenticate with Ably" heading is only printed by the unauth branch + // of runAuth(). Its presence proves we delegated rather than skipping. + expect(stdout).toMatch(/Authenticate with Ably/); + // The login runner was invoked exactly once with --skip-logo so the + // Ably ASCII logo isn't printed twice (init prints it). + expect(recordedArgv).toEqual([["--skip-logo"]]); + }); + + it("should reject --target auto combined with explicit targets", async () => { + const { error } = await runCommand( + ["init", "--target", "auto", "--target", "cursor"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch( + /--target auto cannot be combined with explicit targets/i, + ); + }); + + it("should pass --json through to accounts:login when delegating", async () => { + delete process.env.ABLY_ACCESS_TOKEN; + getMockConfigManager().clearAccounts(); + mockFetchWithTarball(await buildSkillsTarball("ably-pubsub")); + + const recordedArgv: string[][] = []; + (globalThis.__TEST_MOCKS__ as Record).runLogin = async ( + argv: string[], + ) => { + recordedArgv.push(argv); + }; + + await runCommand( + ["init", "--target", "cursor", "--json"], + import.meta.url, + ); + + expect(recordedArgv).toEqual([ + ["--skip-logo", "--json", "--skip-completed-status"], + ]); + }); + + it("should surface accounts:login failures via this.fail", async () => { + delete process.env.ABLY_ACCESS_TOKEN; + getMockConfigManager().clearAccounts(); + + (globalThis.__TEST_MOCKS__ as Record).runLogin = + async () => { + throw new Error("device authorization denied"); + }; + + const { error } = await runCommand( + ["init", "--target", "cursor"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/device authorization denied/i); + }); + }); + + describe("interactive prompt", () => { + it("should skip skill install when no tools are detected", async () => { + // Force interactive mode so promptForTargets() runs. + process.stdout.isTTY = true; + process.stdin.isTTY = true; + // detectorState.tools defaults to ALL_UNDETECTED in beforeEach. + + const { stdout, stderr, error } = await runCommand( + ["init"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + expect(stderr).toMatch(/No AI coding tools detected/i); + // Getting started block should still be shown. + expect(stdout).toMatch(/Getting started with the Ably CLI/); + // No skills install should have happened — no fetch, no skill files. + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should warn and skip install when user picks zero editors", async () => { + process.stdout.isTTY = true; + process.stdin.isTTY = true; + detectorState.tools = [ + { + id: "cursor", + name: "Cursor", + detected: true, + evidence: "config: ~/.cursor", + installMethod: InstallMethod.FileCopy, + }, + ...ALL_UNDETECTED.filter((t) => t.id !== "cursor"), + ]; + (globalThis.__TEST_MOCKS__ as Record).checkboxResponse = + []; + + const { stderr, error } = await runCommand(["init"], import.meta.url); + + expect(error).toBeUndefined(); + expect(stderr).toMatch(/No editors selected/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should treat prompt cancellation as null and skip install", async () => { + process.stdout.isTTY = true; + process.stdin.isTTY = true; + detectorState.tools = [ + { + id: "cursor", + name: "Cursor", + detected: true, + evidence: "config: ~/.cursor", + installMethod: InstallMethod.FileCopy, + }, + ...ALL_UNDETECTED.filter((t) => t.id !== "cursor"), + ]; + (globalThis.__TEST_MOCKS__ as Record).checkboxResponse = + "throw"; + + const { stdout, error } = await runCommand(["init"], import.meta.url); + + expect(error).toBeUndefined(); + expect(stdout).toMatch(/Getting started with the Ably CLI/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should install skills for editor(s) the user picks", async () => { + process.stdout.isTTY = true; + process.stdin.isTTY = true; + detectorState.tools = [ + { + id: "cursor", + name: "Cursor", + detected: true, + evidence: "config: ~/.cursor", + installMethod: InstallMethod.FileCopy, + }, + ...ALL_UNDETECTED.filter((t) => t.id !== "cursor"), + ]; + (globalThis.__TEST_MOCKS__ as Record).checkboxResponse = + ["cursor"]; + mockFetchWithTarball(await buildSkillsTarball("ably-pubsub")); + + const { error } = await runCommand(["init"], import.meta.url); + + expect(error).toBeUndefined(); + expect( + fs.existsSync( + path.join(tempDir, ".cursor", "skills", "ably-pubsub", "SKILL.md"), + ), + ).toBe(true); + }); + }); +}); diff --git a/test/unit/commands/skills/install.test.ts b/test/unit/commands/skills/install.test.ts new file mode 100644 index 000000000..fb0ab79fc --- /dev/null +++ b/test/unit/commands/skills/install.test.ts @@ -0,0 +1,545 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { create as tarCreate } from "tar"; + +import { + type DetectedTool, + InstallMethod, +} from "../../../../src/services/tool-detector.js"; +import { + type PluginInstallResult, + PluginInstallStatus, +} from "../../../../src/services/claude-plugin-installer.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../helpers/standard-tests.js"; + +const fetchMock = vi.fn(); +globalThis.fetch = fetchMock as typeof fetch; + +// Mutable state for the test injection hooks in tool-detector and +// claude-plugin-installer. Tests reset / reconfigure this in beforeEach to +// control per-test behaviour without relying on host-installed AI editors +// or a real `claude` CLI. The source modules read these via globalThis.__TEST_MOCKS__. +const detectorState: { tools: DetectedTool[] } = { tools: [] }; +const pluginInstallState: { result: PluginInstallResult } = { + result: { + status: PluginInstallStatus.Installed, + pluginsInstalled: [], + pluginsAlreadyInstalled: [], + pluginsFailed: [], + }, +}; + +const ALL_UNDETECTED: DetectedTool[] = [ + { + id: "claude-code", + name: "Claude Code", + detected: false, + evidence: "", + installMethod: InstallMethod.Plugin, + }, + { + id: "cursor", + name: "Cursor", + detected: false, + evidence: "", + installMethod: InstallMethod.FileCopy, + }, + { + id: "vscode", + name: "VS Code", + detected: false, + evidence: "", + installMethod: InstallMethod.FileCopy, + }, + { + id: "windsurf", + name: "Windsurf", + detected: false, + evidence: "", + installMethod: InstallMethod.FileCopy, + }, +]; + +async function buildSkillsTarball(...names: string[]): Promise { + const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "skills-stage-")); + const repoDir = path.join(stagingDir, "agent-skills-v0.1.0"); + const skillsRoot = path.join(repoDir, "skills"); + fs.mkdirSync(skillsRoot, { recursive: true }); + for (const name of names) { + const skillDir = path.join(skillsRoot, name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + `---\nname: ${name}\ndescription: Test skill ${name}\n---\n# ${name}\n`, + ); + } + const stream = tarCreate({ gzip: true, cwd: stagingDir }, [ + "agent-skills-v0.1.0", + ]); + const chunks: Buffer[] = []; + for await (const chunk of stream as unknown as AsyncIterable) { + chunks.push(chunk); + } + fs.rmSync(stagingDir, { recursive: true, force: true }); + return Buffer.concat(chunks); +} + +const TEST_RELEASE = { + tag: "v0.1.0", + name: "v0.1.0", + sha: "abc123def456789012345678901234567890abcd", +}; + +function mockFetchWithTarball(buffer: Buffer): void { + // Default attestation verification to "passes". Per-test overrides can set + // __TEST_MOCKS__.verifyAttestation to a function that throws to exercise + // the failure path. + globalThis.__TEST_MOCKS__ = { + ...globalThis.__TEST_MOCKS__, + verifyAttestation: (sha256: string) => ({ + tarballSha256: sha256, + signerIdentity: `https://github.com/ably/agent-skills/.github/workflows/release.yml@refs/tags/${TEST_RELEASE.tag}`, + }), + }; + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/releases/latest")) { + return { + ok: true, + statusText: "OK", + json: async () => ({ + tag_name: TEST_RELEASE.tag, + name: TEST_RELEASE.name, + }), + } as unknown as Response; + } + if (url.includes("/git/refs/tags/")) { + return { + ok: true, + statusText: "OK", + json: async () => ({ + object: { sha: TEST_RELEASE.sha, type: "commit", url: "" }, + }), + } as unknown as Response; + } + if (url.includes("/attestations/sha256:")) { + return { + ok: true, + statusText: "OK", + json: async () => ({ + attestations: [{ bundle: { mediaType: "fake-bundle" } }], + }), + } as unknown as Response; + } + if (url.includes("/releases/download/")) { + return { + ok: true, + statusText: "OK", + arrayBuffer: async () => + buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ), + } as unknown as Response; + } + return { + ok: false, + status: 404, + statusText: `Unexpected URL: ${url}`, + } as unknown as Response; + }); +} + +function setDetected(tools: DetectedTool[]): void { + detectorState.tools = tools; +} + +function setPluginResult(result: PluginInstallResult): void { + pluginInstallState.result = result; +} + +describe("skills:install command", () => { + let tempDir: string; + let originalCwd: string; + let originalHome: string | undefined; + let originalPath: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "skills-install-test-")); + originalCwd = process.cwd(); + process.chdir(tempDir); + originalHome = process.env.HOME; + process.env.HOME = tempDir; + // Hide the host's `claude` CLI so the plugin install path is not exercised. + originalPath = process.env.PATH; + process.env.PATH = tempDir; + + fetchMock.mockReset(); + // Wire test injection hooks read by tool-detector / claude-plugin-installer. + setDetected(ALL_UNDETECTED); + setPluginResult({ + status: PluginInstallStatus.Installed, + pluginsInstalled: ["ably-realtime"], + pluginsAlreadyInstalled: [], + pluginsFailed: [], + }); + globalThis.__TEST_MOCKS__ = { + ...globalThis.__TEST_MOCKS__, + detectTools: () => Promise.resolve(detectorState.tools), + installClaudePlugin: () => Promise.resolve(pluginInstallState.result), + }; + }); + + afterEach(() => { + process.chdir(originalCwd); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + if (globalThis.__TEST_MOCKS__) { + delete (globalThis.__TEST_MOCKS__ as Record).detectTools; + delete (globalThis.__TEST_MOCKS__ as Record) + .installClaudePlugin; + delete (globalThis.__TEST_MOCKS__ as Record) + .verifyAttestation; + } + vi.restoreAllMocks(); + }); + + standardHelpTests("skills:install", import.meta.url); + standardArgValidationTests("skills:install", import.meta.url); + standardFlagTests("skills:install", import.meta.url, ["--target", "--json"]); + + describe("flags", () => { + it("should list all target options in help", async () => { + const { stdout } = await runCommand( + ["skills:install", "--help"], + import.meta.url, + ); + expect(stdout).toContain("claude-code"); + expect(stdout).toContain("cursor"); + expect(stdout).toContain("auto"); + expect(stdout).toContain("vscode"); + expect(stdout).toContain("windsurf"); + }); + }); + + describe("functionality", () => { + it("should install downloaded skills into the requested target directory", async () => { + mockFetchWithTarball( + await buildSkillsTarball("ably-pubsub", "ably-chat"), + ); + + const { error } = await runCommand( + ["skills:install", "--target", "cursor"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + // 3 fetches: /releases/latest, /git/refs/tags/, then the release + // asset. Attestation verification is short-circuited by the + // __TEST_MOCKS__.verifyAttestation hook, so the /attestations/sha256: + // endpoint isn't hit in tests. + expect(fetchMock).toHaveBeenCalledTimes(3); + expect( + fs.existsSync( + path.join(tempDir, ".cursor", "skills", "ably-pubsub", "SKILL.md"), + ), + ).toBe(true); + expect( + fs.existsSync( + path.join(tempDir, ".cursor", "skills", "ably-chat", "SKILL.md"), + ), + ).toBe(true); + }); + + it("should emit structured JSON describing the install with --json", async () => { + mockFetchWithTarball(await buildSkillsTarball("ably-pubsub")); + + const { stdout, error } = await runCommand( + ["skills:install", "--target", "cursor", "--json"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + const firstLine = stdout.trim().split("\n")[0]!; + const record = JSON.parse(firstLine) as { + type: string; + success: boolean; + installation: { + skills: Array<{ name: string; description: string }>; + installed: Array<{ target: string; skillCount: number }>; + pluginInstalled: boolean; + }; + }; + expect(record.type).toBe("result"); + expect(record.success).toBe(true); + expect(record.installation.skills).toEqual([ + { name: "ably-pubsub", description: "Test skill ably-pubsub" }, + ]); + expect(record.installation.installed).toHaveLength(1); + expect(record.installation.installed[0]!.target).toBe("cursor"); + expect(record.installation.installed[0]!.skillCount).toBe(1); + expect(record.installation.pluginInstalled).toBe(false); + }); + }); + + describe("error handling", () => { + it("should reject --target auto combined with explicit targets", async () => { + const { error } = await runCommand( + ["skills:install", "--target", "auto", "--target", "cursor"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch( + /--target auto cannot be combined with explicit targets/i, + ); + }); + + it("should fail loudly when no release is published", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + } as Response); + + const { error } = await runCommand( + ["skills:install", "--target", "cursor"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toMatch( + /Failed to resolve latest release|Not Found/i, + ); + }); + }); + + describe("auto-detect", () => { + it("should warn and skip install when no AI tools are detected", async () => { + const { stderr, error } = await runCommand( + ["skills:install"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + expect(stderr).toMatch(/No AI coding tools detected/i); + // No tarball download should have happened. + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should emit JSON envelope with empty installed list when nothing is detected", async () => { + const { stdout, error } = await runCommand( + ["skills:install", "--json"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + const resultLine = stdout + .trim() + .split("\n") + .find((line) => { + try { + return (JSON.parse(line) as { type?: string }).type === "result"; + } catch { + return false; + } + }); + expect(resultLine).toBeDefined(); + const record = JSON.parse(resultLine!) as { + installation: { + installed: unknown[]; + pluginInstalled: boolean; + detectedTools: { detected: boolean }[]; + }; + }; + expect(record.installation.installed).toEqual([]); + expect(record.installation.pluginInstalled).toBe(false); + expect(record.installation.detectedTools.every((t) => !t.detected)).toBe( + true, + ); + }); + + it("should install to detected file-copy targets without --target", async () => { + setDetected([ + ...ALL_UNDETECTED.filter((t) => t.id !== "cursor"), + { + id: "cursor", + name: "Cursor", + detected: true, + evidence: "config: ~/.cursor", + installMethod: InstallMethod.FileCopy, + }, + ]); + mockFetchWithTarball(await buildSkillsTarball("ably-pubsub")); + + const { error } = await runCommand(["skills:install"], import.meta.url); + + expect(error).toBeUndefined(); + expect( + fs.existsSync( + path.join(tempDir, ".cursor", "skills", "ably-pubsub", "SKILL.md"), + ), + ).toBe(true); + }); + }); + + describe("Claude Code plugin path", () => { + it("should install via plugin system when claude CLI is available", async () => { + setDetected([ + { + id: "claude-code", + name: "Claude Code", + detected: true, + evidence: "cli: claude", + installMethod: InstallMethod.Plugin, + }, + ...ALL_UNDETECTED.filter((t) => t.id !== "claude-code"), + ]); + setPluginResult({ + status: PluginInstallStatus.Installed, + pluginsInstalled: ["ably-realtime"], + pluginsAlreadyInstalled: [], + pluginsFailed: [], + }); + mockFetchWithTarball(await buildSkillsTarball("ably-pubsub")); + + const { stdout, error } = await runCommand( + ["skills:install", "--target", "claude-code", "--json"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + const record = JSON.parse( + stdout + .trim() + .split("\n") + .find((line) => { + try { + return (JSON.parse(line) as { type?: string }).type === "result"; + } catch { + return false; + } + })!, + ) as { + installation: { pluginInstalled: boolean; installed: unknown[] }; + }; + expect(record.installation.pluginInstalled).toBe(true); + // Should NOT have file-copied skills into ~/.claude when plugin path succeeds. + expect(record.installation.installed).toEqual([]); + expect( + fs.existsSync( + path.join(tempDir, ".claude", "skills", "ably-pubsub", "SKILL.md"), + ), + ).toBe(false); + }); + + it("should fall back to file-copy when plugin install fails", async () => { + setDetected([ + { + id: "claude-code", + name: "Claude Code", + detected: true, + evidence: "cli: claude", + installMethod: InstallMethod.Plugin, + }, + ...ALL_UNDETECTED.filter((t) => t.id !== "claude-code"), + ]); + setPluginResult({ + status: PluginInstallStatus.Error, + pluginsInstalled: [], + pluginsAlreadyInstalled: [], + pluginsFailed: [], + error: "claude install failed", + }); + mockFetchWithTarball(await buildSkillsTarball("ably-pubsub")); + + const { stdout, error } = await runCommand( + ["skills:install", "--target", "claude-code", "--json"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + const record = JSON.parse( + stdout + .trim() + .split("\n") + .find((line) => { + try { + return (JSON.parse(line) as { type?: string }).type === "result"; + } catch { + return false; + } + })!, + ) as { + installation: { + pluginInstalled: boolean; + installed: { target: string; skillCount: number }[]; + }; + }; + expect(record.installation.pluginInstalled).toBe(false); + expect(record.installation.installed).toHaveLength(1); + expect(record.installation.installed[0]!.target).toBe("claude-code"); + // File-copy fallback should write into ~/.claude/skills. + expect( + fs.existsSync( + path.join(tempDir, ".claude", "skills", "ably-pubsub", "SKILL.md"), + ), + ).toBe(true); + }); + + it("should treat partial plugin install as success and skip file-copy", async () => { + setDetected([ + { + id: "claude-code", + name: "Claude Code", + detected: true, + evidence: "cli: claude", + installMethod: InstallMethod.Plugin, + }, + ...ALL_UNDETECTED.filter((t) => t.id !== "claude-code"), + ]); + setPluginResult({ + status: PluginInstallStatus.Partial, + pluginsInstalled: ["ably-realtime"], + pluginsAlreadyInstalled: [], + pluginsFailed: [{ name: "ably-chat", error: "plugin chat broke" }], + }); + mockFetchWithTarball(await buildSkillsTarball("ably-pubsub")); + + const { stdout, stderr, error } = await runCommand( + ["skills:install", "--target", "claude-code"], + import.meta.url, + ); + + expect(error).toBeUndefined(); + // Warning is emitted on stderr in non-JSON mode. + const warning = stderr + stdout; + expect(warning).toMatch(/installed with errors/i); + expect(warning).toMatch(/ably-chat/); + // Still considered a success path → no file-copy fallback. + expect( + fs.existsSync( + path.join(tempDir, ".claude", "skills", "ably-pubsub", "SKILL.md"), + ), + ).toBe(false); + }); + }); +}); diff --git a/test/unit/services/claude-plugin-installer.test.ts b/test/unit/services/claude-plugin-installer.test.ts new file mode 100644 index 000000000..7692ad562 --- /dev/null +++ b/test/unit/services/claude-plugin-installer.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { execFile } from "node:child_process"; + +vi.mock("node:child_process", () => ({ + execFile: vi.fn(), +})); + +const mockManifest = { + name: "ably-agent-skills", + plugins: [{ name: "ably-realtime" }, { name: "ably-chat" }], +}; + +const fetchMock = vi.fn(); +globalThis.fetch = fetchMock; + +const { installClaudePlugin } = + await import("../../../src/services/claude-plugin-installer.js"); + +const TEST_REF = "v1.2.3"; + +describe("claude-plugin-installer", () => { + const mockedExecFile = vi.mocked(execFile); + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock.mockImplementation((url: string) => { + if (!url.includes(`/${encodeURIComponent(TEST_REF)}/`)) { + return Promise.resolve({ + ok: false, + statusText: `Unexpected ref in URL: ${url}`, + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockManifest), + }); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should fetch manifest and install all plugins on success", async () => { + mockedExecFile.mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { + (cb as (err: Error | null, stdout: string, stderr: string) => void)( + null, + "OK", + "", + ); + return undefined as never; + }, + ); + + const result = await installClaudePlugin(TEST_REF); + + expect(result.status).toBe("installed"); + expect(result.pluginsInstalled).toEqual(["ably-realtime", "ably-chat"]); + expect(result.error).toBeUndefined(); + }); + + it("should return already-installed when every plugin install reports already exists", async () => { + mockedExecFile.mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { + (cb as (err: Error | null, stdout: string, stderr: string) => void)( + new Error("already exists"), + "", + "already exists", + ); + return undefined as never; + }, + ); + + const result = await installClaudePlugin(TEST_REF); + + expect(result.status).toBe("already-installed"); + expect(result.pluginsAlreadyInstalled).toEqual([ + "ably-realtime", + "ably-chat", + ]); + expect(result.pluginsInstalled).toEqual([]); + expect(result.pluginsFailed).toEqual([]); + }); + + it("should return error when every plugin install fails", async () => { + mockedExecFile.mockImplementation( + (_cmd: unknown, args: unknown, _opts: unknown, cb: unknown) => { + const argList = args as string[]; + // Allow marketplace add to succeed; fail per-plugin installs. + if (argList[0] === "plugin" && argList[1] === "marketplace") { + (cb as (err: Error | null, stdout: string, stderr: string) => void)( + null, + "OK", + "", + ); + } else { + (cb as (err: Error | null, stdout: string, stderr: string) => void)( + new Error("command failed"), + "", + "command failed", + ); + } + return undefined as never; + }, + ); + + const result = await installClaudePlugin(TEST_REF); + + expect(result.status).toBe("error"); + expect(result.pluginsFailed).toHaveLength(2); + expect(result.pluginsInstalled).toEqual([]); + }); + + it("should return partial when some plugins fail and others succeed", async () => { + mockedExecFile.mockImplementation( + (_cmd: unknown, args: unknown, _opts: unknown, cb: unknown) => { + const argList = args as string[]; + const last = argList.at(-1) ?? ""; + if (typeof last === "string" && last.startsWith("ably-chat")) { + (cb as (err: Error | null, stdout: string, stderr: string) => void)( + new Error("network error"), + "", + "network error", + ); + } else { + (cb as (err: Error | null, stdout: string, stderr: string) => void)( + null, + "OK", + "", + ); + } + return undefined as never; + }, + ); + + const result = await installClaudePlugin(TEST_REF); + + expect(result.status).toBe("partial"); + expect(result.pluginsInstalled).toEqual(["ably-realtime"]); + expect(result.pluginsFailed).toEqual([ + { name: "ably-chat", error: "network error" }, + ]); + }); + + it("should continue past existing plugins and install the rest", async () => { + mockedExecFile.mockImplementation( + (_cmd: unknown, args: unknown, _opts: unknown, cb: unknown) => { + const argList = args as string[]; + const last = argList.at(-1) ?? ""; + if (typeof last === "string" && last.startsWith("ably-realtime")) { + (cb as (err: Error | null, stdout: string, stderr: string) => void)( + new Error("already installed"), + "", + "already installed", + ); + } else { + (cb as (err: Error | null, stdout: string, stderr: string) => void)( + null, + "OK", + "", + ); + } + return undefined as never; + }, + ); + + const result = await installClaudePlugin(TEST_REF); + + expect(result.status).toBe("installed"); + expect(result.pluginsInstalled).toEqual(["ably-chat"]); + expect(result.pluginsAlreadyInstalled).toEqual(["ably-realtime"]); + expect(result.pluginsFailed).toEqual([]); + }); + + it("should return error when manifest fetch fails", async () => { + fetchMock.mockResolvedValue({ + ok: false, + statusText: "Not Found", + }); + + const result = await installClaudePlugin(TEST_REF); + + expect(result.status).toBe("error"); + expect(result.error).toContain("Failed to fetch marketplace manifest"); + }); + + it("should call claude with correct arguments derived from manifest", async () => { + const calls: string[][] = []; + mockedExecFile.mockImplementation( + (_cmd: unknown, args: unknown, _opts: unknown, cb: unknown) => { + calls.push(args as string[]); + (cb as (err: Error | null, stdout: string, stderr: string) => void)( + null, + "OK", + "", + ); + return undefined as never; + }, + ); + + await installClaudePlugin(TEST_REF); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe( + `https://raw.githubusercontent.com/ably/agent-skills/${encodeURIComponent(TEST_REF)}/.claude-plugin/marketplace.json`, + ); + + // 1 marketplace add + 2 plugin installs + expect(calls).toHaveLength(3); + expect(calls[0]).toEqual([ + "plugin", + "marketplace", + "add", + "ably/agent-skills", + ]); + expect(calls[1]).toEqual([ + "plugin", + "install", + "ably-realtime@ably-agent-skills", + ]); + expect(calls[2]).toEqual([ + "plugin", + "install", + "ably-chat@ably-agent-skills", + ]); + }); + + it("should return error when manifest has no plugins", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ name: "empty", plugins: [] }), + }); + + const result = await installClaudePlugin(TEST_REF); + + expect(result.status).toBe("error"); + expect(result.error).toContain("No plugins found"); + }); +}); diff --git a/test/unit/services/skills-downloader.test.ts b/test/unit/services/skills-downloader.test.ts new file mode 100644 index 000000000..3738ae47a --- /dev/null +++ b/test/unit/services/skills-downloader.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { create as tarCreate } from "tar"; + +// `sigstore.verify` does cryptographic + transparency-log checks against the +// real Sigstore Public Good infrastructure. We stub it so unit tests can +// exercise the downloader's verification *plumbing* (fetching the bundle, +// invoking the verifier, surfacing the result) without leaving the host. +const verifyMock = vi.fn(); +vi.mock("sigstore", async () => ({ + verify: (...args: unknown[]) => verifyMock(...args), +})); + +const { SKILLS_REPO, SkillsDownloader } = + await import("../../../src/services/skills-downloader.js"); + +const TEST_RELEASE = { + tag: "v0.1.0", + name: "v0.1.0", + sha: "abc123def456789012345678901234567890abcd", +}; + +interface BuildOpts { + /** Direct children of skills/ — each gets a SKILL.md. */ + skills?: string[]; + /** Top-level (non-skills/) entries with a SKILL.md — should be ignored. */ + nonSkillTopLevel?: string[]; + /** Nested directories under skills/// with their own SKILL.md — should be ignored. */ + nestedUnderSkill?: { parent: string; sub: string }[]; + /** Skip creating the skills/ directory entirely (to test the missing-dir error). */ + omitSkillsDir?: boolean; +} + +async function buildTarball(opts: BuildOpts): Promise { + const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "skills-dl-test-")); + const repoDir = path.join(stagingDir, "agent-skills-v0.1.0"); + fs.mkdirSync(repoDir, { recursive: true }); + + if (!opts.omitSkillsDir) { + const skillsRoot = path.join(repoDir, "skills"); + fs.mkdirSync(skillsRoot, { recursive: true }); + for (const name of opts.skills ?? []) { + const skillDir = path.join(skillsRoot, name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + `---\nname: ${name}\ndescription: Test skill ${name}\n---\n# ${name}\n`, + ); + } + for (const { parent, sub } of opts.nestedUnderSkill ?? []) { + const parentDir = path.join(skillsRoot, parent); + fs.mkdirSync(parentDir, { recursive: true }); + fs.writeFileSync( + path.join(parentDir, "SKILL.md"), + `---\nname: ${parent}\ndescription: parent skill\n---\n`, + ); + const nestedDir = path.join(parentDir, sub); + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync( + path.join(nestedDir, "SKILL.md"), + `---\nname: ${sub}\ndescription: nested fake skill\n---\n`, + ); + } + } + + for (const name of opts.nonSkillTopLevel ?? []) { + const dir = path.join(repoDir, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "SKILL.md"), + `---\nname: ${name}\ndescription: should be ignored\n---\n`, + ); + } + + const stream = tarCreate({ gzip: true, cwd: stagingDir }, [ + "agent-skills-v0.1.0", + ]); + const chunks: Buffer[] = []; + for await (const chunk of stream as unknown as AsyncIterable) { + chunks.push(chunk); + } + fs.rmSync(stagingDir, { recursive: true, force: true }); + return Buffer.concat(chunks); +} + +interface FetchOpts { + /** Override the release-resolution status (default 200). */ + releaseStatus?: number; + /** Override the asset-fetch status (default 200). */ + assetStatus?: number; + /** Override the attestation-fetch status (default 200). */ + attestationStatus?: number; + /** Force the attestations API to return zero attestations. */ + noAttestations?: boolean; +} + +const fetchMock = vi.fn(); +const originalFetch = globalThis.fetch; + +function wireFetch(buffer: Buffer | null, opts: FetchOpts = {}): void { + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/releases/latest")) { + if ((opts.releaseStatus ?? 200) !== 200) { + return { + ok: false, + status: opts.releaseStatus, + statusText: "Not Found", + } as unknown as Response; + } + return { + ok: true, + statusText: "OK", + json: async () => ({ + tag_name: TEST_RELEASE.tag, + name: TEST_RELEASE.name, + }), + } as unknown as Response; + } + if (url.includes("/git/refs/tags/")) { + return { + ok: true, + statusText: "OK", + json: async () => ({ + object: { sha: TEST_RELEASE.sha, type: "commit", url: "" }, + }), + } as unknown as Response; + } + if (url.includes("/attestations/sha256:")) { + if ((opts.attestationStatus ?? 200) !== 200) { + return { + ok: false, + status: opts.attestationStatus, + statusText: "Not Found", + } as unknown as Response; + } + return { + ok: true, + statusText: "OK", + json: async () => + opts.noAttestations + ? { attestations: [] } + : { attestations: [{ bundle: { mediaType: "fake-bundle" } }] }, + } as unknown as Response; + } + if (url.includes("/releases/download/")) { + if ((opts.assetStatus ?? 200) !== 200) { + return { + ok: false, + status: opts.assetStatus, + statusText: "Not Found", + } as unknown as Response; + } + if (buffer === null) { + return { + ok: false, + status: 500, + statusText: "Internal Server Error", + } as unknown as Response; + } + return { + ok: true, + statusText: "OK", + arrayBuffer: async () => + buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ), + } as unknown as Response; + } + return { + ok: false, + status: 404, + statusText: `Unexpected URL: ${url}`, + } as unknown as Response; + }); +} + +describe("SkillsDownloader", () => { + beforeEach(() => { + fetchMock.mockReset(); + verifyMock.mockReset(); + // Default: attestation verification succeeds with a believable signer. + verifyMock.mockResolvedValue({ + identity: { + subjectAlternativeName: `https://github.com/${SKILLS_REPO}/.github/workflows/release.yml@refs/tags/${TEST_RELEASE.tag}`, + }, + }); + globalThis.fetch = fetchMock as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe("constants", () => { + it("should target the official agent-skills repo", () => { + expect(SKILLS_REPO).toBe("ably/agent-skills"); + }); + }); + + describe("download (release pinning + attestation)", () => { + it("should fetch the release asset, verify attestation, and surface source metadata", async () => { + wireFetch(await buildTarball({ skills: ["ably-pubsub", "ably-chat"] })); + const downloader = new SkillsDownloader(); + try { + const result = await downloader.download(); + expect(result.source.repo).toBe("ably/agent-skills"); + expect(result.source.tag).toBe(TEST_RELEASE.tag); + expect(result.source.sha).toBe(TEST_RELEASE.sha); + expect(result.source.tarballSha256).toMatch(/^[0-9a-f]{64}$/); + expect(result.source.attestedBy).toContain( + ".github/workflows/release.yml", + ); + expect(result.skills.map((s) => s.name).toSorted()).toEqual([ + "ably-chat", + "ably-pubsub", + ]); + const assetCall = fetchMock.mock.calls.find((c) => + String(c[0]).includes("/releases/download/"), + ); + expect(assetCall).toBeDefined(); + expect(String(assetCall![0])).toContain("agent-skills-v0.1.0.tar.gz"); + expect(verifyMock).toHaveBeenCalledTimes(1); + } finally { + downloader.cleanup(); + } + }); + + it("should fail loudly when no release is published", async () => { + wireFetch(null, { releaseStatus: 404 }); + const downloader = new SkillsDownloader(); + try { + await expect(downloader.download()).rejects.toThrow( + /Failed to resolve latest release/i, + ); + } finally { + downloader.cleanup(); + } + }); + + it("should fail loudly when the release asset is missing", async () => { + wireFetch(null, { assetStatus: 404 }); + const downloader = new SkillsDownloader(); + try { + await expect(downloader.download()).rejects.toThrow( + /Failed to download skills release asset/i, + ); + } finally { + downloader.cleanup(); + } + }); + + it("should fail loudly when no attestation exists for the tarball", async () => { + wireFetch(await buildTarball({ skills: ["ably-pubsub"] }), { + attestationStatus: 404, + }); + const downloader = new SkillsDownloader(); + try { + await expect(downloader.download()).rejects.toThrow( + /Failed to fetch attestation/i, + ); + } finally { + downloader.cleanup(); + } + }); + + it("should fail loudly when the attestations list is empty", async () => { + wireFetch(await buildTarball({ skills: ["ably-pubsub"] }), { + noAttestations: true, + }); + const downloader = new SkillsDownloader(); + try { + await expect(downloader.download()).rejects.toThrow( + /No attestations found/i, + ); + } finally { + downloader.cleanup(); + } + }); + + it("should fail loudly when sigstore.verify rejects", async () => { + wireFetch(await buildTarball({ skills: ["ably-pubsub"] })); + verifyMock.mockRejectedValue(new Error("certificate identity error")); + const downloader = new SkillsDownloader(); + try { + await expect(downloader.download()).rejects.toThrow( + /Attestation verification failed.*certificate identity error/i, + ); + } finally { + downloader.cleanup(); + } + }); + }); + + describe("findSkills (skills/ scoping)", () => { + it("should ignore top-level non-skills/ directories that contain SKILL.md", async () => { + wireFetch( + await buildTarball({ + skills: ["ably-pubsub"], + nonSkillTopLevel: ["test", ".github"], + }), + ); + const downloader = new SkillsDownloader(); + try { + const result = await downloader.download(); + expect(result.skills.map((s) => s.name)).toEqual(["ably-pubsub"]); + } finally { + downloader.cleanup(); + } + }); + + it("should not recurse into subdirectories of a skill", async () => { + wireFetch( + await buildTarball({ + skills: ["ably-pubsub"], + nestedUnderSkill: [{ parent: "ably-chat", sub: "references" }], + }), + ); + const downloader = new SkillsDownloader(); + try { + const result = await downloader.download(); + expect(result.skills.map((s) => s.name).toSorted()).toEqual([ + "ably-chat", + "ably-pubsub", + ]); + } finally { + downloader.cleanup(); + } + }); + + it("should throw when the tarball has no skills/ directory", async () => { + wireFetch(await buildTarball({ omitSkillsDir: true })); + const downloader = new SkillsDownloader(); + try { + await expect(downloader.download()).rejects.toThrow( + /Skills directory missing/i, + ); + } finally { + downloader.cleanup(); + } + }); + }); + + describe("cleanup", () => { + it("should not throw when called before any download", () => { + const downloader = new SkillsDownloader(); + expect(() => downloader.cleanup()).not.toThrow(); + expect(() => downloader.cleanup()).not.toThrow(); + }); + }); +}); diff --git a/test/unit/services/skills-installer.test.ts b/test/unit/services/skills-installer.test.ts new file mode 100644 index 000000000..7467f0b95 --- /dev/null +++ b/test/unit/services/skills-installer.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { SkillsInstaller } from "../../../src/services/skills-installer.js"; +import { DownloadedSkill } from "../../../src/services/skills-downloader.js"; + +describe("SkillsInstaller", () => { + let tempSrcDir: string; + let tempDestDir: string; + let skills: DownloadedSkill[]; + let originalHome: string | undefined; + + beforeEach(() => { + tempSrcDir = fs.mkdtempSync(path.join(os.tmpdir(), "skills-src-")); + tempDestDir = fs.mkdtempSync(path.join(os.tmpdir(), "skills-dest-")); + originalHome = process.env.HOME; + process.env.HOME = tempDestDir; + + const skill1Dir = path.join(tempSrcDir, "ably-pubsub"); + const skill2Dir = path.join(tempSrcDir, "ably-chat"); + + fs.mkdirSync(skill1Dir, { recursive: true }); + fs.writeFileSync(path.join(skill1Dir, "SKILL.md"), "# Pub/Sub Skill"); + fs.mkdirSync(path.join(skill1Dir, "references"), { recursive: true }); + fs.writeFileSync( + path.join(skill1Dir, "references", "api.md"), + "API reference content", + ); + + fs.mkdirSync(skill2Dir, { recursive: true }); + fs.writeFileSync(path.join(skill2Dir, "SKILL.md"), "# Chat Skill"); + + skills = [ + { name: "ably-pubsub", directory: skill1Dir }, + { name: "ably-chat", directory: skill2Dir }, + ]; + }); + + afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (fs.existsSync(tempSrcDir)) { + fs.rmSync(tempSrcDir, { recursive: true, force: true }); + } + if (fs.existsSync(tempDestDir)) { + fs.rmSync(tempDestDir, { recursive: true, force: true }); + } + }); + + describe("resolveTargets", () => { + it("should pass through specific targets", () => { + const targets = SkillsInstaller.resolveTargets(["claude-code"]); + expect(targets).toEqual(["claude-code"]); + }); + + it("should pass through multiple targets", () => { + const targets = SkillsInstaller.resolveTargets(["claude-code", "cursor"]); + expect(targets).toEqual(["claude-code", "cursor"]); + }); + + it('should return empty array for "auto"', () => { + const targets = SkillsInstaller.resolveTargets(["auto"]); + expect(targets).toEqual([]); + }); + }); + + describe("install", () => { + it("should install skills to the specified target directory", () => { + const installer = new SkillsInstaller(); + const originalCwd = process.cwd(); + process.chdir(tempDestDir); + + try { + const { results } = installer.install({ + skills, + + targets: ["claude-code"], + }); + + expect(results).toHaveLength(1); + expect(results[0]!.target).toBe("claude-code"); + expect(results[0]!.name).toBe("Claude Code"); + expect(results[0]!.skillCount).toBe(2); + + const skillDir = path.join( + tempDestDir, + ".claude", + "skills", + "ably-pubsub", + ); + expect(fs.existsSync(skillDir)).toBe(true); + expect(fs.existsSync(path.join(skillDir, "SKILL.md"))).toBe(true); + expect(fs.existsSync(path.join(skillDir, "references", "api.md"))).toBe( + true, + ); + + const chatDir = path.join( + tempDestDir, + ".claude", + "skills", + "ably-chat", + ); + expect(fs.existsSync(chatDir)).toBe(true); + expect(fs.existsSync(path.join(chatDir, "SKILL.md"))).toBe(true); + } finally { + process.chdir(originalCwd); + } + }); + + it("should install to multiple targets", () => { + const installer = new SkillsInstaller(); + const originalCwd = process.cwd(); + process.chdir(tempDestDir); + + try { + const { results } = installer.install({ + skills, + + targets: ["claude-code", "cursor", "vscode"], + }); + + expect(results).toHaveLength(3); + + for (const target of [".claude", ".cursor", ".copilot"]) { + expect( + fs.existsSync( + path.join( + tempDestDir, + target, + "skills", + "ably-pubsub", + "SKILL.md", + ), + ), + ).toBe(true); + } + } finally { + process.chdir(originalCwd); + } + }); + + it("should always overwrite existing skills with the latest version", () => { + const installer = new SkillsInstaller(); + const originalCwd = process.cwd(); + process.chdir(tempDestDir); + + try { + const existingDir = path.join( + tempDestDir, + ".claude", + "skills", + "ably-pubsub", + ); + fs.mkdirSync(existingDir, { recursive: true }); + fs.writeFileSync(path.join(existingDir, "SKILL.md"), "# Old content"); + + const { results } = installer.install({ + skills, + + targets: ["claude-code"], + }); + + expect(results[0]!.skills[0]!.status).toBe("installed"); + expect(results[0]!.skills[1]!.status).toBe("installed"); + expect(results[0]!.skillCount).toBe(2); + + const content = fs.readFileSync( + path.join(existingDir, "SKILL.md"), + "utf8", + ); + expect(content).toBe("# Pub/Sub Skill"); + } finally { + process.chdir(originalCwd); + } + }); + + it("should ignore unknown target keys", () => { + const installer = new SkillsInstaller(); + const originalCwd = process.cwd(); + process.chdir(tempDestDir); + + try { + const { results } = installer.install({ + skills, + + targets: ["unknown-target"], + }); + + expect(results).toHaveLength(0); + } finally { + process.chdir(originalCwd); + } + }); + + it("should copy all skill contents recursively", () => { + const scriptsDir = path.join(tempSrcDir, "ably-pubsub", "scripts"); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.writeFileSync( + path.join(scriptsDir, "setup.sh"), + "#!/bin/bash\necho hello", + ); + + const installer = new SkillsInstaller(); + const originalCwd = process.cwd(); + process.chdir(tempDestDir); + + try { + installer.install({ + skills, + + targets: ["claude-code"], + }); + + const destScriptsDir = path.join( + tempDestDir, + ".claude", + "skills", + "ably-pubsub", + "scripts", + ); + expect(fs.existsSync(destScriptsDir)).toBe(true); + expect(fs.existsSync(path.join(destScriptsDir, "setup.sh"))).toBe(true); + + const content = fs.readFileSync( + path.join(destScriptsDir, "setup.sh"), + "utf8", + ); + expect(content).toBe("#!/bin/bash\necho hello"); + } finally { + process.chdir(originalCwd); + } + }); + }); +}); diff --git a/test/unit/services/tool-detector.test.ts b/test/unit/services/tool-detector.test.ts new file mode 100644 index 000000000..87850722d --- /dev/null +++ b/test/unit/services/tool-detector.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { execFile } from "node:child_process"; +import fs from "node:fs"; + +vi.mock("node:child_process", () => ({ + execFile: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + existsSync: vi.fn(), + }, + }; +}); + +// Import after mocking +const { detectTools } = await import("../../../src/services/tool-detector.js"); + +describe("tool-detector", () => { + const mockedExecFile = vi.mocked(execFile); + const mockedExistsSync = vi.mocked(fs.existsSync); + + beforeEach(() => { + vi.clearAllMocks(); + mockedExistsSync.mockReturnValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should return all tools as not detected when nothing is found", async () => { + mockedExecFile.mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { + (cb as (err: Error | null, stdout: string) => void)( + new Error("not found"), + "", + ); + return undefined as never; + }, + ); + + const results = await detectTools(); + + expect(results.length).toBeGreaterThanOrEqual(4); + for (const tool of results) { + expect(tool.detected).toBe(false); + expect(tool.evidence).toBe(""); + } + }); + + it("should detect a tool via CLI binary", async () => { + mockedExecFile.mockImplementation( + (_cmd: unknown, args: unknown, _opts: unknown, cb: unknown) => { + const argList = args as string[]; + if (argList[0] === "claude") { + (cb as (err: Error | null, stdout: string) => void)( + null, + "/usr/local/bin/claude", + ); + } else { + (cb as (err: Error | null, stdout: string) => void)( + new Error("not found"), + "", + ); + } + return undefined as never; + }, + ); + + const results = await detectTools(); + const claudeCode = results.find((t) => t.id === "claude-code"); + + expect(claudeCode).toBeDefined(); + expect(claudeCode!.detected).toBe(true); + expect(claudeCode!.evidence).toBe("cli: claude"); + expect(claudeCode!.installMethod).toBe("plugin"); + }); + + it("should detect a tool via config directory", async () => { + mockedExecFile.mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { + (cb as (err: Error | null, stdout: string) => void)( + new Error("not found"), + "", + ); + return undefined as never; + }, + ); + + mockedExistsSync.mockImplementation((p: fs.PathLike) => { + const pathStr = String(p); + return pathStr.includes(".cursor"); + }); + + const results = await detectTools(); + const cursor = results.find((t) => t.id === "cursor"); + + expect(cursor).toBeDefined(); + expect(cursor!.detected).toBe(true); + expect(cursor!.evidence).toMatch(/^config:/); + expect(cursor!.installMethod).toBe("file-copy"); + }); + + it("should detect multiple tools simultaneously", async () => { + mockedExecFile.mockImplementation( + (_cmd: unknown, args: unknown, _opts: unknown, cb: unknown) => { + const argList = args as string[]; + if (argList[0] === "claude" || argList[0] === "cursor") { + (cb as (err: Error | null, stdout: string) => void)( + null, + `/usr/local/bin/${argList[0]}`, + ); + } else { + (cb as (err: Error | null, stdout: string) => void)( + new Error("not found"), + "", + ); + } + return undefined as never; + }, + ); + + const results = await detectTools(); + const detected = results.filter((t) => t.detected); + + expect(detected.length).toBeGreaterThanOrEqual(2); + expect(detected.some((t) => t.id === "claude-code")).toBe(true); + expect(detected.some((t) => t.id === "cursor")).toBe(true); + }); +});