diff --git a/package-lock.json b/package-lock.json index a83336f..4e7ac6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.4.1", + "@vitest/spy": "^4.1.4", "concurrently": "^9.1.2", "lucide-react": "^0.509.0", "react": "^19.1.0", @@ -33,7 +34,8 @@ "tailwindcss": "^4.1.4", "tsx": "^4.19.4", "typescript": "^5.8.3", - "vite": "^6.3.4" + "vite": "^6.3.4", + "vitest": "^4.1.4" }, "engines": { "node": ">=20" @@ -1409,6 +1411,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -1726,6 +1735,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1784,6 +1811,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -1858,6 +1998,16 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1976,6 +2126,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2229,6 +2389,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -2300,6 +2467,26 @@ "node": ">=4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -3157,6 +3344,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -3215,6 +3413,13 @@ "node": "20 || >=22" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3644,6 +3849,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3690,6 +3902,13 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3699,6 +3918,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", @@ -3800,6 +4026,23 @@ "node": ">=20" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3817,6 +4060,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toad-cache": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", @@ -4489,6 +4742,96 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4504,6 +4847,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index d978072..19ac79e 100644 --- a/package.json +++ b/package.json @@ -21,31 +21,34 @@ "dev:server": "tsx watch server/index.ts", "dev:web": "vite", "build": "vite build && tsc -p tsconfig.server.json --noCheck", - "start": "node dist/server/index.js" + "start": "node dist/server/index.js", + "test": "vitest run" }, "dependencies": { - "fastify": "^5.3.3", - "@fastify/static": "^8.1.0", "@fastify/cors": "^11.0.1", + "@fastify/static": "^8.1.0", "@fastify/websocket": "^11.0.2", - "gray-matter": "^4.0.3", "chokidar": "^4.0.3", - "glob": "^11.0.2" + "fastify": "^5.3.3", + "glob": "^11.0.2", + "gray-matter": "^4.0.3" }, "devDependencies": { - "typescript": "^5.8.3", - "tsx": "^4.19.4", - "concurrently": "^9.1.2", - "vite": "^6.3.4", + "@tailwindcss/vite": "^4.1.4", + "@types/node": "^22.15.3", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.4.1", + "@vitest/spy": "^4.1.4", + "concurrently": "^9.1.2", + "lucide-react": "^0.509.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@types/node": "^22.15.3", "tailwindcss": "^4.1.4", - "@tailwindcss/vite": "^4.1.4", - "lucide-react": "^0.509.0" + "tsx": "^4.19.4", + "typescript": "^5.8.3", + "vite": "^6.3.4", + "vitest": "^4.1.4" }, "engines": { "node": ">=20" diff --git a/server/scanner/discovery.test.ts b/server/scanner/discovery.test.ts new file mode 100644 index 0000000..762dcf0 --- /dev/null +++ b/server/scanner/discovery.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fullScan } from './discovery' +import fs from 'fs/promises' +import os from 'os' +import path from 'path' + +// Mock dependencies +vi.mock('fs/promises') +vi.mock('os', () => ({ + default: { + homedir: vi.fn().mockReturnValue('/Users/testuser') + }, + homedir: vi.fn().mockReturnValue('/Users/testuser') +})) +vi.mock('./parser', () => ({ + parseSkillMd: vi.fn().mockResolvedValue({ + frontmatter: { name: 'Mock Skill' }, + content: 'Mock content', + rawContent: '---\nname: Mock Skill\n---\nMock content' + }), + listSkillFiles: vi.fn().mockResolvedValue(['SKILL.md', 'index.js']), + getSkillMdPath: vi.fn((dir) => path.join(dir, 'SKILL.md')) +})) +vi.mock('./symlink', () => ({ + resolveSymlink: vi.fn((p) => Promise.resolve({ isSymlink: false, realPath: p })), + identifySource: vi.fn(() => 'local') +})) + +// --------------------------------------------------------------------------- +// Helper: build fs mock from a declarative path map +// --------------------------------------------------------------------------- + +interface FsEntry { + isDir?: boolean + children?: string[] // directory listing names + content?: string // file content for readFile + throwReaddir?: string // error message to throw from readdir + throwStat?: string // error message to throw from stat +} + +function buildFsMocks(map: Record) { + vi.mocked(fs.stat).mockImplementation(async (p: any) => { + if (typeof p !== 'string') throw new Error('ENOENT') + const entry = map[p] + if (entry?.throwStat) throw new Error(entry.throwStat) + if (entry) return { isDirectory: () => !!entry.isDir, mtime: new Date(), size: 100 } as any + // Default: skill subdirs inside a known parent are dirs + for (const key of Object.keys(map)) { + const parent = path.dirname(key) + if (p.length > parent.length && p.startsWith(parent + path.sep) && map[key].isDir) { + return { isDirectory: () => true, mtime: new Date(), size: 100 } as any + } + } + throw new Error('ENOENT') + }) + + vi.mocked(fs.readdir).mockImplementation(async (p: any) => { + if (typeof p !== 'string') return [] + const entry = map[p] + if (entry?.throwReaddir) throw new Error(entry.throwReaddir) + if (entry?.children) { + return entry.children.map(name => ({ + name, + isDirectory: () => map[path.join(p, name)]?.isDir ?? false, + isFile: () => !(map[path.join(p, name)]?.isDir ?? false), + })) as any + } + return [] + }) + + vi.mocked(fs.access).mockImplementation(async (p: any) => { + if (typeof p !== 'string') throw new Error('ENOENT') + if (p.endsWith('SKILL.md')) return undefined + throw new Error('ENOENT') + }) + + vi.mocked(fs.readFile).mockImplementation(async (p: any) => { + if (typeof p !== 'string') throw new Error('ENOENT') + // settings.json — default: nothing disabled + if (p.includes('settings.json')) return JSON.stringify({ permissions: { deny: [] } }) + // plugin config — default: no plugins + if (p.includes('plugins') && p.endsWith('config.json')) return JSON.stringify({}) + // SKILL.md files + if (p.endsWith('SKILL.md')) return '---\nname: Mock Skill\n---\nContent' + throw new Error('ENOENT') + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('fullScan', () => { + const mockHome = '/Users/testuser' + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(os.homedir).mockReturnValue(mockHome) + // Safe defaults — everything missing + buildFsMocks({}) + }) + + // ----------------------------------------------------------------------- + it('should return empty result when no skill directories exist', async () => { + const result = await fullScan() + + expect(result.skills).toEqual([]) + expect(result.projects).toEqual([]) + expect(result.conflicts).toEqual([]) + expect(result.stats.total).toBe(0) + expect(result.stats.global).toBe(0) + expect(result.stats.project).toBe(0) + // scannedPaths should have entries (one per agent global path) but all non-existent + expect(result.scannedPaths.every(p => !p.exists)).toBe(true) + }) + + // ----------------------------------------------------------------------- + it('should scan global and project skills and aggregate results', async () => { + const globalDir = path.join(mockHome, '.claude', 'skills') + const commonDir = path.join(mockHome, 'Documents') + const projectDir = path.join(commonDir, 'my-project') + const projectSkillsDir = path.join(projectDir, '.claude', 'skills') + + buildFsMocks({ + [globalDir]: { + isDir: true, + children: ['GlobalSkill'], + }, + [path.join(globalDir, 'GlobalSkill')]: { + isDir: true, + children: ['SKILL.md'], + }, + [commonDir]: { + isDir: true, + children: ['my-project'], + }, + [projectDir]: { + isDir: true, + children: [], + }, + [projectSkillsDir]: { + isDir: true, + children: ['ProjectSkill'], + }, + [path.join(projectSkillsDir, 'ProjectSkill')]: { + isDir: true, + children: ['SKILL.md'], + }, + }) + + const result = await fullScan() + + expect(result.stats.total).toBeGreaterThanOrEqual(2) + expect(result.scannedPaths.some(p => p.label.startsWith('global:'))).toBe(true) + expect(result.scannedPaths.some(p => p.label.startsWith('project:'))).toBe(true) + // project should appear with correct skillCount + const proj = result.projects.find(p => p.name === 'my-project') + expect(proj).toBeDefined() + expect(proj!.skillCount).toBeGreaterThanOrEqual(1) + }) + + // ----------------------------------------------------------------------- + it('should isolate errors in one directory without affecting others', async () => { + // Two global agent dirs: claude-code succeeds, cursor fails silently + // scanSkillDir catches readdir errors internally and returns [] + const claudeDir = path.join(mockHome, '.claude', 'skills') + const cursorDir = path.join(mockHome, '.cursor', 'skills') + + buildFsMocks({ + [claudeDir]: { + isDir: true, + children: ['WorkingSkill'], + }, + [path.join(claudeDir, 'WorkingSkill')]: { + isDir: true, + children: ['SKILL.md'], + }, + [cursorDir]: { + isDir: true, + throwReaddir: 'Permission Denied', + }, + }) + + const result = await fullScan() + + // claude-code global should succeed + const claudeReport = result.scannedPaths.find(p => p.label === 'global:claude-code') + expect(claudeReport).toBeDefined() + expect(claudeReport!.count).toBeGreaterThanOrEqual(1) + + // cursor global should exist but yield 0 skills (error swallowed by scanSkillDir) + const cursorReport = result.scannedPaths.find(p => p.label === 'global:cursor') + expect(cursorReport).toBeDefined() + expect(cursorReport!.exists).toBe(true) + expect(cursorReport!.count).toBe(0) + + // Total should still include the working skill + expect(result.stats.total).toBeGreaterThanOrEqual(1) + }) + + // ----------------------------------------------------------------------- + it('should scan extra paths from SKILL_HUB_EXTRA_PATHS env var', async () => { + const extraDir = '/opt/custom-skills' + + buildFsMocks({ + [extraDir]: { + isDir: true, + children: ['ExtraSkill'], + }, + [path.join(extraDir, 'ExtraSkill')]: { + isDir: true, + children: ['SKILL.md'], + }, + }) + + process.env.SKILL_HUB_EXTRA_PATHS = extraDir + try { + const result = await fullScan() + + const extraReport = result.scannedPaths.find(p => p.label.startsWith('extra:')) + expect(extraReport).toBeDefined() + expect(extraReport!.count).toBeGreaterThanOrEqual(1) + } finally { + delete process.env.SKILL_HUB_EXTRA_PATHS + } + }) + + // ----------------------------------------------------------------------- + it('should mark skills as disabled when listed in settings deny list', async () => { + const globalDir = path.join(mockHome, '.claude', 'skills') + + buildFsMocks({ + [globalDir]: { + isDir: true, + children: ['MySkill'], + }, + [path.join(globalDir, 'MySkill')]: { + isDir: true, + children: ['SKILL.md'], + }, + }) + + // Override readFile to return a deny list matching the mocked skill name + vi.mocked(fs.readFile).mockImplementation(async (p: any) => { + if (typeof p !== 'string') throw new Error('ENOENT') + if (p.includes('settings.json')) { + // parseSkillMd mock returns name 'Mock Skill', so deny that + return JSON.stringify({ permissions: { deny: ['Skill(Mock Skill)'] } }) + } + if (p.endsWith('SKILL.md')) return '---\nname: Mock Skill\n---\nContent' + if (p.includes('plugins') && p.endsWith('config.json')) return JSON.stringify({}) + throw new Error('ENOENT') + }) + + const result = await fullScan() + const skill = result.skills.find(s => s.name === 'Mock Skill') + expect(skill).toBeDefined() + expect(skill!.enabled).toBe(false) + }) + + // ----------------------------------------------------------------------- + it('should detect conflicts when skills share the same name', async () => { + const globalDir = path.join(mockHome, '.claude', 'skills') + const commonDir = path.join(mockHome, 'Documents') + const projectDir = path.join(commonDir, 'my-project') + const projectSkillsDir = path.join(projectDir, '.claude', 'skills') + + // Both dirs contain an entry; parseSkillMd mock returns name 'Mock Skill' for both + // so they'll conflict on that name + const globalSkillDir = path.join(globalDir, 'skill-a') + const projectSkillDir = path.join(projectSkillsDir, 'skill-b') + + buildFsMocks({ + [globalDir]: { isDir: true, children: ['skill-a'] }, + [globalSkillDir]: { isDir: true, children: ['SKILL.md'] }, + [commonDir]: { isDir: true, children: ['my-project'] }, + [projectDir]: { isDir: true, children: [] }, + [projectSkillsDir]: { isDir: true, children: ['skill-b'] }, + [projectSkillDir]: { isDir: true, children: ['SKILL.md'] }, + }) + + const result = await fullScan() + + expect(result.conflicts.length).toBeGreaterThanOrEqual(1) + // Both get name 'Mock Skill' from the mock, so conflict is on that name + const conflict = result.conflicts.find(c => c.name === 'Mock Skill') + expect(conflict).toBeDefined() + expect(conflict!.skills.length).toBeGreaterThanOrEqual(2) + for (const s of conflict!.skills) { + expect(s.hasConflict).toBe(true) + } + }) +}) diff --git a/server/scanner/discovery.ts b/server/scanner/discovery.ts index 6f0bf74..afac080 100644 --- a/server/scanner/discovery.ts +++ b/server/scanner/discovery.ts @@ -386,57 +386,70 @@ export async function fullScan(): Promise { } } + // Discover plugin and project directories in parallel + const [pluginSkillDirs, discoveredProjects] = await Promise.all([ + discoverPluginSkillDirs(), + discoverProjects(), + ]) + + const scanTasks: Promise[] = [] + // 1. Global skills — loop over every agent's global paths for (const { agent, path: globalDir } of allAgentGlobalAbsPaths(homedir)) { - allSkills.push( - ...(await scanAndReport(`global:${agent.id}`, globalDir, 'global', agent.id)), - ) + scanTasks.push(scanAndReport(`global:${agent.id}`, globalDir, 'global', agent.id)) } // 2. Plugin skills — Claude Code only for now - const pluginSkillDirs = await discoverPluginSkillDirs() for (const pluginDir of pluginSkillDirs) { const pluginName = path.relative(path.join(homedir, '.claude', 'plugins'), pluginDir) - allSkills.push( - ...(await scanAndReport(`plugin:${pluginName}`, pluginDir, 'plugin', 'claude-code')), - ) + scanTasks.push(scanAndReport(`plugin:${pluginName}`, pluginDir, 'plugin', 'claude-code')) } // 3. Project skills — for each project, scan every agent's project paths - const discoveredProjects = await discoverProjects() const projects: Project[] = [] for (const proj of discoveredProjects) { - let projectTotal = 0 for (const agent of AGENTS) { for (const rel of agent.projectPaths) { const skillsDir = path.join(proj.path, rel) - const projectSkills = await scanAndReport( - `project:${proj.name}:${agent.id}`, - skillsDir, - 'project', - agent.id, - proj.name, - proj.path, + scanTasks.push( + scanAndReport( + `project:${proj.name}:${agent.id}`, + skillsDir, + 'project', + agent.id, + proj.name, + proj.path, + ), ) - allSkills.push(...projectSkills) - projectTotal += projectSkills.length } } - projects.push({ - name: proj.name, - path: proj.path, - skillCount: projectTotal, - }) + // skillCount populated after Promise.all resolves + projects.push({ name: proj.name, path: proj.path, skillCount: 0 }) } // 4. Extra paths from SKILL_HUB_EXTRA_PATHS — agent unknown for (const extra of parseExtraPaths()) { - allSkills.push( - ...(await scanAndReport(`extra:${path.basename(extra)}`, extra, 'project', 'unknown')), + scanTasks.push( + scanAndReport(`extra:${path.basename(extra)}`, extra, 'project', 'unknown'), ) } + // Await all concurrently + const taskResults = await Promise.all(scanTasks) + for (const result of taskResults) { + allSkills.push(...result) + } + + // Update project counts from the aggregated results + const countByPath = new Map() + for (const s of allSkills) { + if (s.projectPath) countByPath.set(s.projectPath, (countByPath.get(s.projectPath) || 0) + 1) + } + for (const p of projects) { + p.skillCount = countByPath.get(p.path) || 0 + } + // Deduplicate by realPath (symlinks can point to the same skill from multiple roots) const seen = new Set() const dedupedSkills: Skill[] = [] diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6b177fe --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config' +import path from 'path' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['server/**/*.test.ts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, 'server'), + }, + }, +})