diff --git a/.gitignore b/.gitignore index 3eef307..d4541df 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,7 @@ data/ .idea/ **/.env -**/.ccpa \ No newline at end of file +**/.ccpa + +# Worktrees +.worktrees/ \ No newline at end of file diff --git a/examples/basic/.claude/commands/complex-test.md b/examples/basic/.claude/commands/complex-test.md new file mode 100644 index 0000000..0ff3cad --- /dev/null +++ b/examples/basic/.claude/commands/complex-test.md @@ -0,0 +1,11 @@ +--- +description: Test command with complex frontmatter +author: QA Agent +version: 1.0 +extra_field: should be ignored +--- + +# Complex Test Command + +This command tests frontmatter parsing with multiple fields. +Only the description should be extracted. \ No newline at end of file diff --git a/examples/basic/.claude/commands/no-frontmatter.md b/examples/basic/.claude/commands/no-frontmatter.md new file mode 100644 index 0000000..7dba318 --- /dev/null +++ b/examples/basic/.claude/commands/no-frontmatter.md @@ -0,0 +1,4 @@ +# No Frontmatter Command + +This command has no frontmatter at all. +The system should handle this gracefully. \ No newline at end of file diff --git a/examples/basic/.claude/commands/test-command.md b/examples/basic/.claude/commands/test-command.md new file mode 100644 index 0000000..10fe858 --- /dev/null +++ b/examples/basic/.claude/commands/test-command.md @@ -0,0 +1,10 @@ +--- +description: A simple test command for QA verification +--- + +# Test Command + +This is a test command for verifying the dynamic command mapping feature. +When users type /test-command, this command should be executed. + +Please respond with "Test command executed successfully!" \ No newline at end of file diff --git a/package.json b/package.json index b440660..0009422 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "typecheck": "tsc --noEmit", "lint": "biome check src", "lint:fix": "biome check --write src", + "test": "vitest", + "test:run": "vitest run", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", "prepublishOnly": "pnpm run build", "prepare": "husky" }, @@ -46,9 +50,11 @@ "devDependencies": { "@biomejs/biome": "^2.3.11", "@types/node": "^25.0.8", + "@vitest/ui": "^4.0.18", "husky": "^9.1.7", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" }, "engines": { "node": ">=18" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ced207a..197ab69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: '@types/node': specifier: ^25.0.8 version: 25.0.8 + '@vitest/ui': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18) husky: specifier: ^9.1.7 version: 9.1.7 @@ -39,6 +42,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.0.8)(@vitest/ui@4.0.18)(tsx@4.21.0) packages: @@ -254,6 +260,9 @@ packages: '@grammyjs/types@3.23.0': resolution: {integrity: sha512-D3jQ4UWERPsyR3op/YFudMMIPNTU47vy7L51uO9/73tMELmjO/+LX5N36/Y0CG5IQfIsz43MxiHI5rgsK0/k+g==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -269,13 +278,191 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@25.0.8': resolution: {integrity: sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/ui@4.0.18': + resolution: {integrity: sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==} + peerDependencies: + vitest: 4.0.18 + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -284,6 +471,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -301,11 +492,17 @@ packages: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -314,6 +511,10 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -321,10 +522,25 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + flatted@3.3.4: + resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -373,6 +589,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -388,9 +607,18 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -408,6 +636,9 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -420,10 +651,20 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} @@ -434,6 +675,10 @@ packages: resolution: {integrity: sha512-NFnZqUliT+OHkRXVSf8vdOr13N1wv31hRryVjqbreVh/SDCNaI6mnRDDq89HVRCbem1SAl7yj04OANeqP0nT6A==} hasBin: true + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} @@ -458,6 +703,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -477,16 +727,33 @@ packages: resolution: {integrity: sha512-Jex+xw5Mg2qMZL3qnzXIfaxEtBaC4n7xifqaqtrZDdlheR70OGkydrPJWT0V1cA1k3nanC86x9FwAmQl6w3Klw==} engines: {node: '>=18'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -495,10 +762,29 @@ packages: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -515,6 +801,80 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + 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/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -526,6 +886,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + zod@4.3.5: resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} @@ -646,6 +1011,8 @@ snapshots: '@grammyjs/types@3.23.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -660,20 +1027,162 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@polka/url@1.0.0-next.29': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + '@types/node@25.0.8': dependencies: undici-types: 7.16.0 + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.0.8)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.0.8)(tsx@4.21.0) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/ui@4.0.18(vitest@4.0.18)': + dependencies: + '@vitest/utils': 4.0.18 + fflate: 0.8.2 + flatted: 3.3.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@25.0.8)(@vitest/ui@4.0.18)(tsx@4.21.0) + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 + assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} braces@3.0.3: dependencies: fill-range: 7.1.1 + chai@6.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -686,6 +1195,8 @@ snapshots: dotenv@17.2.3: {} + es-module-lexer@1.7.0: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -715,6 +1226,10 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + event-target-shim@5.0.1: {} execa@5.1.1: @@ -729,6 +1244,8 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + expect-type@1.3.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -741,10 +1258,18 @@ snapshots: dependencies: reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fflate@0.8.2: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + flatted@3.3.4: {} + fsevents@2.3.3: optional: true @@ -784,6 +1309,10 @@ snapshots: isexe@2.0.0: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -795,8 +1324,12 @@ snapshots: mimic-fn@2.1.0: {} + mrmime@2.0.1: {} + ms@2.1.3: {} + nanoid@3.3.11: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -810,6 +1343,8 @@ snapshots: dependencies: path-key: 3.1.1 + obug@2.1.1: {} + on-exit-leak-free@2.1.2: {} onetime@5.1.2: @@ -818,8 +1353,14 @@ snapshots: path-key@3.1.1: {} + pathe@2.0.3: {} + + picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.3: {} + pino-abstract-transport@3.0.0: dependencies: split2: 4.2.0 @@ -840,6 +1381,12 @@ snapshots: sonic-boom: 4.2.0 thread-stream: 4.0.0 + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + process-warning@5.0.0: {} queue-microtask@1.2.3: {} @@ -854,6 +1401,37 @@ snapshots: reusify@1.1.0: {} + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -871,24 +1449,51 @@ snapshots: execa: 5.1.1 fast-glob: 3.3.3 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} + split2@4.2.0: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + strip-final-newline@2.0.0: {} thread-stream@4.0.0: dependencies: real-require: 0.2.0 + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tr46@0.0.3: {} tsx@4.21.0: @@ -902,6 +1507,57 @@ snapshots: undici-types@7.16.0: {} + vite@7.3.1(@types/node@25.0.8)(tsx@4.21.0): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.8 + fsevents: 2.3.3 + tsx: 4.21.0 + + vitest@4.0.18(@types/node@25.0.8)(@vitest/ui@4.0.18)(tsx@4.21.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.8)(tsx@4.21.0)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.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: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.0.8)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.0.8 + '@vitest/ui': 4.0.18(vitest@4.0.18) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: @@ -913,4 +1569,9 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + zod@4.3.5: {} diff --git a/src/__tests__/basic.test.ts b/src/__tests__/basic.test.ts new file mode 100644 index 0000000..cd20fc2 --- /dev/null +++ b/src/__tests__/basic.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +describe("Testing Framework Verification", () => { + it("should run basic test successfully", () => { + expect(2 + 2).toBe(4); + }); + + it("should handle async operations", async () => { + const result = await Promise.resolve("test"); + expect(result).toBe("test"); + }); + + it("should work with Date objects (timestamp validation)", () => { + const timestamp = 1709520000; + const date = new Date(timestamp * 1000); + const isoString = date.toISOString(); + + expect(isoString).toBe("2024-03-04T02:40:00.000Z"); + expect(timestamp).toBe(1709520000); + }); +}); diff --git a/src/bot.ts b/src/bot.ts index bc03eb8..f447c0c 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,6 +1,7 @@ import { execSync } from "node:child_process"; import { Bot } from "grammy"; import { clearHandler } from "./bot/commands/clear.js"; +import { registerDynamicCommands } from "./bot/commands/dynamic.js"; import { helpHandler } from "./bot/commands/help.js"; import { startHandler } from "./bot/commands/start.js"; import { @@ -57,11 +58,14 @@ export async function startBot(): Promise { bot.use(authMiddleware); bot.use(rateLimitMiddleware); - // Register commands + // Register built-in commands bot.command("start", startHandler); bot.command("help", helpHandler); bot.command("clear", clearHandler); + // Register dynamic Claude commands + await registerDynamicCommands(bot); + // Text message handler bot.on("message:text", textHandler); diff --git a/src/bot/__tests__/commands.dynamic.test.ts b/src/bot/__tests__/commands.dynamic.test.ts new file mode 100644 index 0000000..eda94ab --- /dev/null +++ b/src/bot/__tests__/commands.dynamic.test.ts @@ -0,0 +1,482 @@ +import type { Bot, Context } from "grammy"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { executeClaudeCommand } from "../../claude/commandExecutor.js"; +import { discoverClaudeCommands } from "../../claude/commands.js"; +import { getLogger } from "../../logger.js"; +import { + getDynamicCommandsHelp, + registerDynamicCommands, +} from "../commands/dynamic.js"; + +// Mock dependencies +vi.mock("../../claude/commands.js"); +vi.mock("../../claude/commandExecutor.js"); +vi.mock("../../logger.js"); + +describe("Dynamic Commands - Registration and Help", () => { + let mockBot: Bot; + let _mockContext: Context; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock bot + mockBot = { + command: vi.fn(), + } as unknown as Bot; + + // Mock context + _mockContext = { + message: { + text: "/test-command arg1 arg2", + }, + } as unknown as Context; + + // Mock logger + vi.mocked(getLogger).mockReturnValue({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType); + + // Mock command executor + vi.mocked(executeClaudeCommand).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("registerDynamicCommands", () => { + it("should register discovered commands with the bot", async () => { + const mockCommands = [ + { + name: "test-command", + filePath: "/test/commands/test-command.md", + description: "A test command", + content: "# Test Command", + }, + { + name: "another-command", + filePath: "/test/commands/another-command.md", + description: "Another test command", + content: "# Another Command", + }, + ]; + + vi.mocked(discoverClaudeCommands).mockResolvedValue(mockCommands); + + await registerDynamicCommands(mockBot); + + // Verify bot.command was called for each discovered command + expect(mockBot.command).toHaveBeenCalledTimes(2); + expect(mockBot.command).toHaveBeenCalledWith( + "test-command", + expect.any(Function), + ); + expect(mockBot.command).toHaveBeenCalledWith( + "another-command", + expect.any(Function), + ); + + // Verify logging + expect(getLogger().debug).toHaveBeenCalledWith( + expect.objectContaining({ + commandName: "test-command", + description: "A test command", + }), + "Registered dynamic Claude command", + ); + + expect(getLogger().info).toHaveBeenCalledWith( + { count: 2 }, + "Registered dynamic Claude commands", + ); + }); + + it("should handle no commands found", async () => { + vi.mocked(discoverClaudeCommands).mockResolvedValue([]); + + await registerDynamicCommands(mockBot); + + expect(mockBot.command).not.toHaveBeenCalled(); + expect(getLogger().debug).toHaveBeenCalledWith( + "No Claude commands found to register", + ); + }); + + it("should handle registration errors gracefully", async () => { + const error = new Error("Registration failed"); + vi.mocked(discoverClaudeCommands).mockRejectedValue(error); + + await registerDynamicCommands(mockBot); + + expect(mockBot.command).not.toHaveBeenCalled(); + expect(getLogger().error).toHaveBeenCalledWith( + { error }, + "Failed to register dynamic commands", + ); + }); + + it("should create proper command handlers", async () => { + const mockCommands = [ + { + name: "test-command", + filePath: "/test/commands/test-command.md", + description: "A test command", + content: "# Test Command", + }, + ]; + + vi.mocked(discoverClaudeCommands).mockResolvedValue(mockCommands); + + await registerDynamicCommands(mockBot); + + // Get the registered handler + const handlerCall = vi.mocked(mockBot.command).mock.calls[0]; + const [commandName, handler] = handlerCall; + + expect(commandName).toBe("test-command"); + expect(handler).toBeInstanceOf(Function); + + // Test the handler + const mockCtx = { + message: { + text: "/test-command arg1 arg2 arg3", + }, + } as unknown as Context; + + await (handler as (ctx: Context) => Promise)(mockCtx); + + expect(executeClaudeCommand).toHaveBeenCalledWith( + mockCtx, + "test-command", + "arg1 arg2 arg3", + ); + }); + + it("should handle command with no arguments", async () => { + const mockCommands = [ + { + name: "test-command", + filePath: "/test/commands/test-command.md", + description: "A test command", + content: "# Test Command", + }, + ]; + + vi.mocked(discoverClaudeCommands).mockResolvedValue(mockCommands); + + await registerDynamicCommands(mockBot); + + const handlerCall = vi.mocked(mockBot.command).mock.calls[0]; + const [, handler] = handlerCall; + + // Test handler with no arguments + const mockCtx = { + message: { + text: "/test-command", + }, + } as unknown as Context; + + await (handler as (ctx: Context) => Promise)(mockCtx); + + expect(executeClaudeCommand).toHaveBeenCalledWith( + mockCtx, + "test-command", + undefined, + ); + }); + + it("should handle command with only whitespace arguments", async () => { + const mockCommands = [ + { + name: "test-command", + filePath: "/test/commands/test-command.md", + description: "A test command", + content: "# Test Command", + }, + ]; + + vi.mocked(discoverClaudeCommands).mockResolvedValue(mockCommands); + + await registerDynamicCommands(mockBot); + + const handlerCall = vi.mocked(mockBot.command).mock.calls[0]; + const [, handler] = handlerCall; + + // Test handler with whitespace-only arguments + const mockCtx = { + message: { + text: "/test-command \t \n ", + }, + } as unknown as Context; + + await (handler as (ctx: Context) => Promise)(mockCtx); + + expect(executeClaudeCommand).toHaveBeenCalledWith( + mockCtx, + "test-command", + undefined, + ); + }); + + it("should handle missing message text", async () => { + const mockCommands = [ + { + name: "test-command", + filePath: "/test/commands/test-command.md", + description: "A test command", + content: "# Test Command", + }, + ]; + + vi.mocked(discoverClaudeCommands).mockResolvedValue(mockCommands); + + await registerDynamicCommands(mockBot); + + const handlerCall = vi.mocked(mockBot.command).mock.calls[0]; + const [, handler] = handlerCall; + + // Test handler with missing message text + const mockCtx = { + message: {}, + } as unknown as Context; + + await (handler as (ctx: Context) => Promise)(mockCtx); + + expect(executeClaudeCommand).toHaveBeenCalledWith( + mockCtx, + "test-command", + undefined, + ); + }); + }); + + describe("getDynamicCommandsHelp", () => { + it("should generate help text for discovered commands", async () => { + const mockCommands = [ + { + name: "test-command", + filePath: "/test/commands/test-command.md", + description: "A test command for testing", + content: "# Test Command", + }, + { + name: "help-me", + filePath: "/test/commands/help-me.md", + description: "Gets help with tasks", + content: "# Help Command", + }, + { + name: "no-description", + filePath: "/test/commands/no-description.md", + description: undefined, + content: "# No Description", + }, + ]; + + vi.mocked(discoverClaudeCommands).mockResolvedValue(mockCommands); + + const help = await getDynamicCommandsHelp(); + + const expectedHelp = `*Claude Commands:* +/test-command - A test command for testing +/help-me - Gets help with tasks +/no-description - No description available +`; + + expect(help).toBe(expectedHelp); + }); + + it("should return empty string when no commands found", async () => { + vi.mocked(discoverClaudeCommands).mockResolvedValue([]); + + const help = await getDynamicCommandsHelp(); + + expect(help).toBe(""); + }); + + it("should handle commands with empty descriptions", async () => { + const mockCommands = [ + { + name: "empty-desc", + filePath: "/test/commands/empty-desc.md", + description: "", + content: "# Empty Description", + }, + ]; + + vi.mocked(discoverClaudeCommands).mockResolvedValue(mockCommands); + + const help = await getDynamicCommandsHelp(); + + expect(help).toBe(`*Claude Commands:* +/empty-desc - No description available +`); + }); + + it("should handle discovery errors gracefully", async () => { + const error = new Error("Discovery failed"); + vi.mocked(discoverClaudeCommands).mockRejectedValue(error); + + const help = await getDynamicCommandsHelp(); + + expect(help).toBe(""); + expect(getLogger().error).toHaveBeenCalledWith( + { error }, + "Failed to get dynamic commands help", + ); + }); + + it("should handle commands with special characters in descriptions", async () => { + const mockCommands = [ + { + name: "special-chars", + filePath: "/test/commands/special-chars.md", + description: "Command with *markdown* and _special_ chars!", + content: "# Special Chars", + }, + ]; + + vi.mocked(discoverClaudeCommands).mockResolvedValue(mockCommands); + + const help = await getDynamicCommandsHelp(); + + expect(help).toBe(`*Claude Commands:* +/special-chars - Command with *markdown* and _special_ chars! +`); + }); + + it("should handle very long command descriptions", async () => { + const longDescription = + "This is a very long command description that exceeds normal length expectations and might cause formatting issues in some interfaces but should be handled gracefully"; + + const mockCommands = [ + { + name: "long-desc", + filePath: "/test/commands/long-desc.md", + description: longDescription, + content: "# Long Description", + }, + ]; + + vi.mocked(discoverClaudeCommands).mockResolvedValue(mockCommands); + + const help = await getDynamicCommandsHelp(); + + expect(help).toBe(`*Claude Commands:* +/long-desc - ${longDescription} +`); + }); + }); + + describe("Command Argument Parsing", () => { + it("should correctly parse simple arguments", async () => { + const mockCommands = [ + { + name: "test-cmd", + filePath: "/test/commands/test-cmd.md", + description: "Test command", + content: "# Test", + }, + ]; + + vi.mocked(discoverClaudeCommands).mockResolvedValue(mockCommands); + await registerDynamicCommands(mockBot); + + const handlerCall = vi.mocked(mockBot.command).mock.calls[0]; + const [, handler] = handlerCall; + + const testCases = [ + { text: "/test-cmd hello world", expectedArgs: "hello world" }, + { text: "/test-cmd single", expectedArgs: "single" }, + { text: "/test-cmd", expectedArgs: undefined }, + { text: "/test-cmd spaced args ", expectedArgs: "spaced args" }, + { + text: '/test-cmd "quoted argument"', + expectedArgs: '"quoted argument"', + }, + { text: "/test-cmd --flag value", expectedArgs: "--flag value" }, + ]; + + for (const testCase of testCases) { + vi.clearAllMocks(); + + const mockCtx = { + message: { text: testCase.text }, + } as unknown as Context; + + await (handler as (ctx: Context) => Promise)(mockCtx); + + expect(executeClaudeCommand).toHaveBeenCalledWith( + mockCtx, + "test-cmd", + testCase.expectedArgs, + ); + } + }); + + it("should handle commands with hyphens and underscores", async () => { + const mockCommands = [ + { + name: "multi-word-command", + filePath: "/test/commands/multi-word-command.md", + description: "Multi word command", + content: "# Multi Word", + }, + { + name: "snake_case_command", + filePath: "/test/commands/snake_case_command.md", + description: "Snake case command", + content: "# Snake Case", + }, + ]; + + vi.mocked(discoverClaudeCommands).mockResolvedValue(mockCommands); + await registerDynamicCommands(mockBot); + + expect(mockBot.command).toHaveBeenCalledWith( + "multi-word-command", + expect.any(Function), + ); + expect(mockBot.command).toHaveBeenCalledWith( + "snake_case_command", + expect.any(Function), + ); + }); + }); + + describe("Error Handling and Edge Cases", () => { + it("should handle executeClaudeCommand failures gracefully", async () => { + const mockCommands = [ + { + name: "failing-command", + filePath: "/test/commands/failing-command.md", + description: "A command that fails", + content: "# Failing Command", + }, + ]; + + vi.mocked(discoverClaudeCommands).mockResolvedValue(mockCommands); + vi.mocked(executeClaudeCommand).mockRejectedValue( + new Error("Execution failed"), + ); + + await registerDynamicCommands(mockBot); + + const handlerCall = vi.mocked(mockBot.command).mock.calls[0]; + const [, handler] = handlerCall; + + const mockCtx = { + message: { text: "/failing-command test" }, + } as unknown as Context; + + // Should not throw error, should handle gracefully + await expect( + (handler as (ctx: Context) => Promise)(mockCtx), + ).rejects.toThrow("Execution failed"); + }); + }); +}); diff --git a/src/bot/__tests__/handlers.timestamp.test.ts b/src/bot/__tests__/handlers.timestamp.test.ts new file mode 100644 index 0000000..21b8acb --- /dev/null +++ b/src/bot/__tests__/handlers.timestamp.test.ts @@ -0,0 +1,329 @@ +import type { Context } from "grammy"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { executeClaudeQuery } from "../../claude/executor.js"; +import { getConfig } from "../../config.js"; +import { getLogger } from "../../logger.js"; +import { sendChunkedResponse } from "../../telegram/chunker.js"; +import { sendDownloadFiles } from "../../telegram/fileSender.js"; +import { + ensureUserSetup, + getDownloadsPath, + getSessionId, + saveSessionId, +} from "../../user/setup.js"; +import { textHandler } from "../handlers/text.js"; + +// Mock all dependencies +vi.mock("../../claude/executor.js"); +vi.mock("../../config.js"); +vi.mock("../../logger.js"); +vi.mock("../../user/setup.js"); +vi.mock("../../telegram/chunker.js"); +vi.mock("../../telegram/fileSender.js"); + +describe("Message Handlers - Timestamp Extraction", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock config + vi.mocked(getConfig).mockReturnValue({ + telegram: { botToken: "test-token" }, + access: { allowedUserIds: [] }, + dataDir: "/test/data", + rateLimit: { max: 100, windowMs: 60000 }, + logging: { level: "info" }, + claude: { command: "claude" }, + }); + + // Mock logger + vi.mocked(getLogger).mockReturnValue({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType); + + // Mock user setup functions + vi.mocked(ensureUserSetup).mockResolvedValue(); + vi.mocked(getDownloadsPath).mockReturnValue("/test/downloads"); + vi.mocked(getSessionId).mockResolvedValue("test-session"); + vi.mocked(saveSessionId).mockResolvedValue(); + + // Mock executor + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: true, + output: "Test response", + sessionId: "test-session-new", + }); + + // Mock telegram functions + vi.mocked(sendChunkedResponse).mockResolvedValue(); + vi.mocked(sendDownloadFiles).mockResolvedValue(0); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("Text Handler - Timestamp Extraction", () => { + it("should extract timestamp from ctx.message.date and pass to executor", async () => { + const testTimestamp = 1709520000; // March 4, 2024 + + const mockContext = { + from: { id: 123, username: "testuser", first_name: "Test" }, + message: { + text: "Test message", + date: testTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + // Verify executeClaudeQuery was called with the timestamp + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: testTimestamp, + }), + ); + }); + + it("should handle undefined timestamp gracefully", async () => { + const mockContext = { + from: { id: 123, username: "testuser", first_name: "Test" }, + message: { + text: "Test message", + // date is undefined + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + // Verify executeClaudeQuery was called with undefined timestamp + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: undefined, + }), + ); + }); + + it("should handle zero timestamp", async () => { + const mockContext = { + from: { id: 123, username: "testuser", first_name: "Test" }, + message: { + text: "Test message", + date: 0, // Zero timestamp + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + // Verify executeClaudeQuery was called with zero timestamp + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: 0, + }), + ); + }); + + it("should pass all required parameters along with timestamp", async () => { + const testTimestamp = 1709520000; + + const mockContext = { + from: { id: 123, username: "testuser", first_name: "Test" }, + message: { + text: "Test message for Claude", + date: testTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + // Verify all parameters are passed correctly + expect(executeClaudeQuery).toHaveBeenCalledWith({ + prompt: "Test message for Claude", + userDir: expect.stringMatching(/\/test\/data\/123$/), + downloadsPath: "/test/downloads", + sessionId: "test-session", + onProgress: expect.any(Function), + messageTimestamp: testTimestamp, + }); + }); + + it("should not call executor when message text is missing", async () => { + const mockContext = { + from: { id: 123 }, + message: { + date: 1709520000, + // text is undefined + }, + chat: { id: 456 }, + } as unknown as Context; + + await textHandler(mockContext); + + // Should not call executor when text is missing + expect(executeClaudeQuery).not.toHaveBeenCalled(); + }); + + it("should not call executor when user ID is missing", async () => { + const mockContext = { + // from is undefined + message: { + text: "Test message", + date: 1709520000, + }, + chat: { id: 456 }, + } as unknown as Context; + + await textHandler(mockContext); + + // Should not call executor when user ID is missing + expect(executeClaudeQuery).not.toHaveBeenCalled(); + }); + }); + + describe("Edge Cases and Error Scenarios", () => { + it("should handle negative timestamps correctly", async () => { + const negativeTimestamp = -86400; // One day before Unix epoch + + const mockContext = { + from: { id: 123 }, + message: { + text: "Test message", + date: negativeTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: negativeTimestamp, + }), + ); + }); + + it("should handle very large timestamps", async () => { + const largeTimestamp = 4102444800; // Year 2099 + + const mockContext = { + from: { id: 123 }, + message: { + text: "Test message", + date: largeTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: largeTimestamp, + }), + ); + }); + + it("should continue normal execution flow even with timestamp extraction", async () => { + const testTimestamp = 1709520000; + + const mockContext = { + from: { id: 123, username: "testuser" }, + message: { + text: "Test message", + date: testTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + // Verify full execution flow + expect(ensureUserSetup).toHaveBeenCalled(); + expect(getSessionId).toHaveBeenCalled(); + expect(executeClaudeQuery).toHaveBeenCalled(); + expect(sendChunkedResponse).toHaveBeenCalledWith( + mockContext, + "Test response", + ); + expect(sendDownloadFiles).toHaveBeenCalled(); + expect(saveSessionId).toHaveBeenCalledWith( + expect.any(String), + "test-session-new", + ); + }); + }); + + describe("Error Handling in Handler", () => { + it("should handle errors gracefully even when timestamp is present", async () => { + vi.mocked(executeClaudeQuery).mockRejectedValue(new Error("Test error")); + + const mockContext = { + from: { id: 123 }, + message: { + text: "Test message", + date: 1709520000, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + // Should have tried to call executor with timestamp + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: 1709520000, + }), + ); + + // Should have sent error message to user + expect(mockContext.reply).toHaveBeenCalledWith( + "An error occurred: Test error", + ); + }); + }); +}); diff --git a/src/bot/commands/dynamic.improved.ts b/src/bot/commands/dynamic.improved.ts new file mode 100644 index 0000000..482b0bb --- /dev/null +++ b/src/bot/commands/dynamic.improved.ts @@ -0,0 +1,242 @@ +import type { Bot, Context } from "grammy"; +import { executeClaudeCommand } from "../../claude/commandExecutor.js"; +import { + clearCommandsCache, + getCommandValidationErrors, + getValidClaudeCommands, +} from "../../claude/commands.improved.js"; +import { getLogger } from "../../logger.js"; + +// Cache registered command names to avoid duplicate registrations +const registeredCommands: Set = new Set(); + +/** + * Register all valid discovered Claude commands with the Telegram bot + */ +export async function registerDynamicCommands(bot: Bot): Promise { + const logger = getLogger(); + + try { + // Get only valid commands (filtering out conflicts and invalid names) + const commands = await getValidClaudeCommands(); + const validationErrors = await getCommandValidationErrors(); + + if (validationErrors.length > 0) { + logger.warn( + { + errors: validationErrors, + count: validationErrors.length, + }, + "Found invalid Claude commands that will be skipped", + ); + } + + if (commands.length === 0) { + logger.debug("No valid Claude commands found to register"); + return; + } + + let registeredCount = 0; + let skippedCount = 0; + + // Register each valid command + for (const command of commands) { + try { + // Skip if already registered + if (registeredCommands.has(command.name)) { + logger.debug( + { commandName: command.name }, + "Command already registered, skipping", + ); + skippedCount++; + continue; + } + + // Create command handler with improved error handling + const handler = async (ctx: Context): Promise => { + try { + // Extract arguments from the command message + const messageText = ctx.message?.text || ""; + const commandPattern = new RegExp(`^/${command.name}\\s*(.*)$`); + const match = messageText.match(commandPattern); + const args = match?.[1]?.trim() || undefined; + + // Execute the command with error handling + await executeClaudeCommand(ctx, command.name, args); + } catch (error) { + logger.error( + { + error, + commandName: command.name, + userId: ctx.from?.id, + }, + "Error in dynamic command handler", + ); + + // Send user-friendly error message + try { + await ctx.reply( + `❌ An error occurred while executing /${command.name}. Please try again later.`, + { parse_mode: "Markdown" }, + ); + } catch (replyError) { + // If even the error reply fails, just log it + logger.error({ error: replyError }, "Failed to send error reply"); + } + } + }; + + // Register the command with the bot + bot.command(command.name, handler); + registeredCommands.add(command.name); + registeredCount++; + + logger.debug( + { + commandName: command.name, + description: command.description, + filePath: command.filePath, + }, + "Registered dynamic Claude command", + ); + } catch (error) { + logger.error( + { error, commandName: command.name }, + "Failed to register individual command", + ); + skippedCount++; + } + } + + logger.info( + { + registered: registeredCount, + skipped: skippedCount, + invalid: validationErrors.length, + total: commands.length + validationErrors.length, + }, + "Dynamic Claude command registration complete", + ); + } catch (error) { + logger.error({ error }, "Failed to register dynamic commands"); + } +} + +/** + * Get formatted help text for all valid discovered Claude commands + */ +export async function getDynamicCommandsHelp(): Promise { + try { + const commands = await getValidClaudeCommands(); + + if (commands.length === 0) { + return ""; + } + + let help = "*Claude Commands:*\n"; + + // Sort commands alphabetically for better UX + const sortedCommands = commands.sort((a, b) => + a.name.localeCompare(b.name), + ); + + for (const command of sortedCommands) { + const description = command.description || "No description available"; + // Truncate very long descriptions for better formatting + const truncatedDescription = + description.length > 80 + ? `${description.slice(0, 77)}...` + : description; + + help += `/${command.name} - ${truncatedDescription}\n`; + } + + // Add information about invalid commands if any exist + const validationErrors = await getCommandValidationErrors(); + if (validationErrors.length > 0) { + help += `\n_Note: ${validationErrors.length} command(s) skipped due to validation errors._`; + } + + return help; + } catch (error) { + const logger = getLogger(); + logger.error({ error }, "Failed to get dynamic commands help"); + return ""; + } +} + +/** + * Refresh command registration (useful when commands are added/removed) + */ +export async function refreshDynamicCommands(bot: Bot): Promise { + const logger = getLogger(); + + try { + // Clear caches + clearCommandsCache(); + registeredCommands.clear(); + + logger.info("Cleared command caches, re-registering commands"); + + // Re-register commands + await registerDynamicCommands(bot); + } catch (error) { + logger.error({ error }, "Failed to refresh dynamic commands"); + } +} + +/** + * Get registration statistics for monitoring + */ +export async function getDynamicCommandStats(): Promise<{ + registered: number; + invalid: number; + errors: Array<{ name: string; error: string }>; +}> { + try { + const validCommands = await getValidClaudeCommands(); + const errors = await getCommandValidationErrors(); + + return { + registered: validCommands.length, + invalid: errors.length, + errors, + }; + } catch (error) { + const logger = getLogger(); + logger.error({ error }, "Failed to get command stats"); + + return { + registered: 0, + invalid: 0, + errors: [], + }; + } +} + +/** + * Check if a command name would conflict with built-in commands + */ +export function wouldConflictWithBuiltIn(commandName: string): boolean { + // Use the same built-in list from the commands module + const builtIns = [ + "start", + "help", + "clear", + "stop", + "restart", + "settings", + "version", + "status", + "ping", + "cancel", + ]; + return builtIns.includes(commandName.toLowerCase()); +} + +/** + * Get list of currently registered dynamic command names + */ +export function getRegisteredCommandNames(): string[] { + return Array.from(registeredCommands).sort(); +} diff --git a/src/bot/commands/dynamic.ts b/src/bot/commands/dynamic.ts new file mode 100644 index 0000000..3fbf3d6 --- /dev/null +++ b/src/bot/commands/dynamic.ts @@ -0,0 +1,77 @@ +import type { Bot, Context } from "grammy"; +import { executeClaudeCommand } from "../../claude/commandExecutor.js"; +import { discoverClaudeCommands } from "../../claude/commands.js"; +import { getLogger } from "../../logger.js"; + +/** + * Register all discovered Claude commands with the Telegram bot + */ +export async function registerDynamicCommands(bot: Bot): Promise { + const logger = getLogger(); + + try { + const commands = await discoverClaudeCommands(); + + if (commands.length === 0) { + logger.debug("No Claude commands found to register"); + return; + } + + // Register each discovered command + for (const command of commands) { + const handler = async (ctx: Context): Promise => { + // Extract arguments from the command message + // For /command arg1 arg2, we get the text after the command + const messageText = ctx.message?.text || ""; + const commandPattern = new RegExp(`^/${command.name}\\s*(.*)$`); + const match = messageText.match(commandPattern); + const args = match?.[1]?.trim() || undefined; + + await executeClaudeCommand(ctx, command.name, args); + }; + + // Register the command with the bot + bot.command(command.name, handler); + + logger.debug( + { + commandName: command.name, + description: command.description, + }, + "Registered dynamic Claude command", + ); + } + + logger.info( + { count: commands.length }, + "Registered dynamic Claude commands", + ); + } catch (error) { + logger.error({ error }, "Failed to register dynamic commands"); + } +} + +/** + * Get formatted help text for all discovered Claude commands + */ +export async function getDynamicCommandsHelp(): Promise { + try { + const commands = await discoverClaudeCommands(); + + if (commands.length === 0) { + return ""; + } + + let help = "*Claude Commands:*\n"; + for (const command of commands) { + const description = command.description || "No description available"; + help += `/${command.name} - ${description}\n`; + } + + return help; + } catch (error) { + const logger = getLogger(); + logger.error({ error }, "Failed to get dynamic commands help"); + return ""; + } +} diff --git a/src/bot/commands/help.ts b/src/bot/commands/help.ts index b0c95b2..efd4d1a 100644 --- a/src/bot/commands/help.ts +++ b/src/bot/commands/help.ts @@ -1,20 +1,24 @@ import type { Context } from "grammy"; +import { getDynamicCommandsHelp } from "./dynamic.js"; export async function helpHandler(ctx: Context): Promise { - await ctx.reply( + const dynamicHelp = await getDynamicCommandsHelp(); + + const helpText = `*Claude Code Telegram Bot*\n\n` + - `*Commands:*\n` + - `/start - Welcome message\n` + - `/help - Show this help\n` + - `/clear - Clear conversation history\n\n` + - `*Usage:*\n` + - `Just send any message to chat with Claude.\n` + - `You can also send images and documents for analysis.\n\n` + - `Your conversation history is preserved between messages. ` + - `Use /clear to start a fresh conversation.\n\n` + - `*Configuration:*\n` + - `Claude reads configuration from your .claude folder.\n` + - `Edit CLAUDE.md for system prompts and .claude/settings.json for permissions.`, - { parse_mode: "Markdown" }, - ); + `*Built-in Commands:*\n` + + `/start - Welcome message\n` + + `/help - Show this help\n` + + `/clear - Clear conversation history\n\n` + + (dynamicHelp ? `${dynamicHelp}\n` : "") + + `*Usage:*\n` + + `Just send any message to chat with Claude.\n` + + `You can also send images and documents for analysis.\n\n` + + `Your conversation history is preserved between messages. ` + + `Use /clear to start a fresh conversation.\n\n` + + `*Configuration:*\n` + + `Claude reads configuration from your .claude folder.\n` + + `Edit CLAUDE.md for system prompts and .claude/settings.json for permissions.`; + + await ctx.reply(helpText, { parse_mode: "Markdown" }); } diff --git a/src/bot/handlers/document.ts b/src/bot/handlers/document.ts index 447a0ab..1221ec4 100644 --- a/src/bot/handlers/document.ts +++ b/src/bot/handlers/document.ts @@ -59,6 +59,7 @@ export async function documentHandler(ctx: Context): Promise { const userId = ctx.from?.id; const document = ctx.message?.document; const caption = ctx.message?.caption || "Please analyze this document."; + const messageTimestamp = ctx.message?.date; if (!userId || !document) { return; @@ -143,6 +144,7 @@ export async function documentHandler(ctx: Context): Promise { downloadsPath, sessionId, onProgress, + messageTimestamp, }); try { diff --git a/src/bot/handlers/photo.ts b/src/bot/handlers/photo.ts index 6bd54e9..4112f8a 100644 --- a/src/bot/handlers/photo.ts +++ b/src/bot/handlers/photo.ts @@ -24,6 +24,7 @@ export async function photoHandler(ctx: Context): Promise { const userId = ctx.from?.id; const photo = ctx.message?.photo; const caption = ctx.message?.caption || "Please analyze this image."; + const messageTimestamp = ctx.message?.date; if (!userId || !photo || photo.length === 0) { return; @@ -94,6 +95,7 @@ export async function photoHandler(ctx: Context): Promise { downloadsPath, sessionId, onProgress, + messageTimestamp, }); try { diff --git a/src/bot/handlers/text.ts b/src/bot/handlers/text.ts index 9334ab4..0fb54af 100644 --- a/src/bot/handlers/text.ts +++ b/src/bot/handlers/text.ts @@ -20,6 +20,7 @@ export async function textHandler(ctx: Context): Promise { const logger = getLogger(); const userId = ctx.from?.id; const messageText = ctx.message?.text; + const messageTimestamp = ctx.message?.date; if (!userId || !messageText) { return; @@ -83,6 +84,7 @@ export async function textHandler(ctx: Context): Promise { downloadsPath, sessionId, onProgress, + messageTimestamp, }); logger.debug( { success: result.success, error: result.error }, diff --git a/src/bot/handlers/voice.ts b/src/bot/handlers/voice.ts index 058747a..88af006 100644 --- a/src/bot/handlers/voice.ts +++ b/src/bot/handlers/voice.ts @@ -49,6 +49,7 @@ export async function voiceHandler(ctx: Context): Promise { const logger = getLogger(); const userId = ctx.from?.id; const voice = ctx.message?.voice; + const messageTimestamp = ctx.message?.date; if (!userId || !voice) { return; @@ -173,6 +174,7 @@ export async function voiceHandler(ctx: Context): Promise { downloadsPath, sessionId, onProgress, + messageTimestamp, }); // Delete status message diff --git a/src/claude/__tests__/commandExecutor.test.ts b/src/claude/__tests__/commandExecutor.test.ts new file mode 100644 index 0000000..e4c9d01 --- /dev/null +++ b/src/claude/__tests__/commandExecutor.test.ts @@ -0,0 +1,614 @@ +import type { Context } from "grammy"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getConfig } from "../../config.js"; +import { getLogger } from "../../logger.js"; +import { sendChunkedResponse } from "../../telegram/chunker.js"; +import { sendDownloadFiles } from "../../telegram/fileSender.js"; +import { + ensureUserSetup, + getDownloadsPath, + getSessionId, + saveSessionId, +} from "../../user/setup.js"; +import { executeClaudeCommand } from "../commandExecutor.js"; +import { getClaudeCommand } from "../commands.js"; +import { executeClaudeQuery } from "../executor.js"; + +// Mock dependencies +vi.mock("../commands.js"); +vi.mock("../executor.js"); +vi.mock("../../config.js"); +vi.mock("../../logger.js"); +vi.mock("../../user/setup.js"); +vi.mock("../../telegram/chunker.js"); +vi.mock("../../telegram/fileSender.js"); + +describe("Command Executor - Integration Tests", () => { + let mockContext: Context; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock context + mockContext = { + from: { id: 123 }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + // Mock config + vi.mocked(getConfig).mockReturnValue({ + telegram: { botToken: "test-token" }, + access: { allowedUserIds: [] }, + dataDir: "/test/data", + rateLimit: { max: 100, windowMs: 60000 }, + logging: { level: "info" }, + claude: { command: "claude" }, + }); + + // Mock logger + vi.mocked(getLogger).mockReturnValue({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType); + + // Mock user setup functions + vi.mocked(ensureUserSetup).mockResolvedValue(); + vi.mocked(getDownloadsPath).mockReturnValue("/test/downloads"); + vi.mocked(getSessionId).mockResolvedValue("test-session"); + vi.mocked(saveSessionId).mockResolvedValue(); + + // Mock telegram functions + vi.mocked(sendChunkedResponse).mockResolvedValue(); + vi.mocked(sendDownloadFiles).mockResolvedValue(0); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("Successful Command Execution", () => { + it("should execute Claude command successfully", async () => { + const mockCommand = { + name: "test-command", + filePath: "/test/commands/test-command.md", + description: "A test command", + content: "# Test Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: true, + output: "Command executed successfully", + sessionId: "new-session-123", + }); + + await executeClaudeCommand(mockContext, "test-command", "arg1 arg2"); + + // Verify command lookup + expect(getClaudeCommand).toHaveBeenCalledWith("test-command"); + + // Verify user setup + expect(ensureUserSetup).toHaveBeenCalledWith("/test/data/123"); + + // Verify Claude execution with correct prompt + expect(executeClaudeQuery).toHaveBeenCalledWith({ + prompt: "/test-command arg1 arg2", + userDir: "/test/data/123", + downloadsPath: "/test/downloads", + sessionId: "test-session", + onProgress: expect.any(Function), + }); + + // Verify response + expect(sendChunkedResponse).toHaveBeenCalledWith( + mockContext, + "Command executed successfully", + ); + + // Verify session save + expect(saveSessionId).toHaveBeenCalledWith( + "/test/data/123", + "new-session-123", + ); + + // Verify files sent + expect(sendDownloadFiles).toHaveBeenCalledWith( + mockContext, + "/test/data/123", + ); + }); + + it("should handle command without arguments", async () => { + const mockCommand = { + name: "simple-command", + filePath: "/test/commands/simple-command.md", + description: "A simple command", + content: "# Simple Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: true, + output: "Simple command result", + }); + + await executeClaudeCommand(mockContext, "simple-command"); + + expect(executeClaudeQuery).toHaveBeenCalledWith({ + prompt: "/simple-command", + userDir: "/test/data/123", + downloadsPath: "/test/downloads", + sessionId: "test-session", + onProgress: expect.any(Function), + }); + }); + + it("should handle command with empty arguments", async () => { + const mockCommand = { + name: "test-command", + filePath: "/test/commands/test-command.md", + description: "A test command", + content: "# Test Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: true, + output: "Command result", + }); + + await executeClaudeCommand(mockContext, "test-command", " \t \n "); + + expect(executeClaudeQuery).toHaveBeenCalledWith({ + prompt: "/test-command", + userDir: "/test/data/123", + downloadsPath: "/test/downloads", + sessionId: "test-session", + onProgress: expect.any(Function), + }); + }); + + it("should handle file downloads", async () => { + const mockCommand = { + name: "file-command", + filePath: "/test/commands/file-command.md", + description: "A command that creates files", + content: "# File Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: true, + output: "Files created", + sessionId: "session-with-files", + }); + vi.mocked(sendDownloadFiles).mockResolvedValue(3); + + await executeClaudeCommand(mockContext, "file-command", "create files"); + + expect(sendDownloadFiles).toHaveBeenCalledWith( + mockContext, + "/test/data/123", + ); + expect(getLogger().info).toHaveBeenCalledWith( + { filesSent: 3, commandName: "file-command" }, + "Sent download files to user", + ); + }); + }); + + describe("Error Handling", () => { + it("should handle command not found", async () => { + vi.mocked(getClaudeCommand).mockResolvedValue(null); + + await executeClaudeCommand(mockContext, "nonexistent-command"); + + expect(mockContext.reply).toHaveBeenCalledWith( + "❌ Command `nonexistent-command` not found.", + { parse_mode: "Markdown" }, + ); + + // Should not proceed with execution + expect(executeClaudeQuery).not.toHaveBeenCalled(); + }); + + it("should handle missing user ID", async () => { + const mockCommand = { + name: "test-command", + filePath: "/test/commands/test-command.md", + description: "A test command", + content: "# Test Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + + const mockContextNoUser = { + from: undefined, + chat: { id: 456 }, + reply: vi.fn(), + } as unknown as Context; + + await executeClaudeCommand(mockContextNoUser, "test-command"); + + expect(mockContextNoUser.reply).toHaveBeenCalledWith( + "❌ Unable to identify user.", + ); + expect(executeClaudeQuery).not.toHaveBeenCalled(); + }); + + it("should handle Claude execution failure", async () => { + const mockCommand = { + name: "failing-command", + filePath: "/test/commands/failing-command.md", + description: "A command that fails", + content: "# Failing Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: false, + output: "", + error: "Claude execution failed", + }); + + await executeClaudeCommand(mockContext, "failing-command", "test args"); + + expect(sendChunkedResponse).toHaveBeenCalledWith( + mockContext, + "Claude execution failed", + ); + + expect(getLogger().info).toHaveBeenCalledWith( + expect.objectContaining({ + commandName: "failing-command", + args: "test args", + userId: 123, + success: false, + }), + "Claude command executed", + ); + }); + + it("should handle user setup failure", async () => { + const mockCommand = { + name: "test-command", + filePath: "/test/commands/test-command.md", + description: "A test command", + content: "# Test Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(ensureUserSetup).mockRejectedValue(new Error("Setup failed")); + + await executeClaudeCommand(mockContext, "test-command"); + + expect(mockContext.reply).toHaveBeenCalledWith( + expect.stringMatching( + /❌ Failed to execute command `test-command`.*Setup failed/s, + ), + { parse_mode: "Markdown" }, + ); + + expect(getLogger().error).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.any(Error), + commandName: "test-command", + }), + "Failed to execute Claude command", + ); + }); + + it("should handle executeClaudeQuery exception", async () => { + const mockCommand = { + name: "exception-command", + filePath: "/test/commands/exception-command.md", + description: "A command that throws", + content: "# Exception Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(executeClaudeQuery).mockRejectedValue( + new Error("Execution threw exception"), + ); + + await executeClaudeCommand(mockContext, "exception-command", "test"); + + expect(mockContext.reply).toHaveBeenCalledWith( + "❌ Failed to execute command `exception-command`.\n\nError: Execution threw exception", + { parse_mode: "Markdown" }, + ); + }); + }); + + describe("Progress Handling", () => { + it("should show and update progress messages", async () => { + const mockCommand = { + name: "long-command", + filePath: "/test/commands/long-command.md", + description: "A long running command", + content: "# Long Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + + let progressCallback: ((message: string) => void) | undefined; + + vi.mocked(executeClaudeQuery).mockImplementation(async (options) => { + progressCallback = options.onProgress; + return { + success: true, + output: "Long command completed", + }; + }); + + const executePromise = executeClaudeCommand(mockContext, "long-command"); + + // Wait a bit for initial setup + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify initial status message + expect(mockContext.reply).toHaveBeenCalledWith("_Executing command..._", { + parse_mode: "Markdown", + }); + + // Test progress updates + if (progressCallback) { + await progressCallback("Reading files..."); + await progressCallback("Processing data..."); + } + + await executePromise; + + // Verify status message deletion + expect(mockContext.api.deleteMessage).toHaveBeenCalledWith(456, 789); + }); + + it("should handle status message edit failures gracefully", async () => { + const mockCommand = { + name: "test-command", + filePath: "/test/commands/test-command.md", + description: "A test command", + content: "# Test Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(mockContext.api.editMessageText).mockRejectedValue( + new Error("Edit failed"), + ); + + let progressCallback: ((message: string) => void) | undefined; + + vi.mocked(executeClaudeQuery).mockImplementation(async (options) => { + progressCallback = options.onProgress; + return { success: true, output: "Done" }; + }); + + const executePromise = executeClaudeCommand(mockContext, "test-command"); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Should not throw when edit fails + if (progressCallback) { + await expect( + progressCallback("Progress update"), + ).resolves.toBeUndefined(); + } + + await executePromise; + }); + + it("should throttle progress updates", async () => { + const mockCommand = { + name: "test-command", + filePath: "/test/commands/test-command.md", + description: "A test command", + content: "# Test Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + + // Mock Date.now to simulate time passage + let currentTime = 1000000000; // baseline time + const dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => { + return currentTime; + }); + + let progressCallback: ((message: string) => void) | undefined; + + vi.mocked(executeClaudeQuery).mockImplementation(async (options) => { + progressCallback = options.onProgress; + return { success: true, output: "Done" }; + }); + + const executePromise = executeClaudeCommand(mockContext, "test-command"); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + if (progressCallback) { + // First update: advance time by 3000ms (> 2000ms threshold) and different message + currentTime += 3000; + await progressCallback("Update 1"); + + // Subsequent updates: rapid fire (no time advancement) should be throttled + await progressCallback("Update 2"); + await progressCallback("Update 3"); + + // Should only edit message once due to throttling + expect(mockContext.api.editMessageText).toHaveBeenCalledTimes(1); + expect(mockContext.api.editMessageText).toHaveBeenCalledWith( + 456, + 789, + "_Update 1_", + { parse_mode: "Markdown" }, + ); + } + + await executePromise; + + // Restore Date.now + dateNowSpy.mockRestore(); + }); + }); + + describe("Session Management", () => { + it("should use existing session ID", async () => { + const mockCommand = { + name: "session-command", + filePath: "/test/commands/session-command.md", + description: "A command with session", + content: "# Session Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(getSessionId).mockResolvedValue("existing-session-456"); + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: true, + output: "Session command result", + }); + + await executeClaudeCommand(mockContext, "session-command"); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "existing-session-456", + }), + ); + }); + + it("should handle missing session ID", async () => { + const mockCommand = { + name: "no-session-command", + filePath: "/test/commands/no-session-command.md", + description: "A command without session", + content: "# No Session Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(getSessionId).mockResolvedValue(null); + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: true, + output: "No session result", + }); + + await executeClaudeCommand(mockContext, "no-session-command"); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: null, + }), + ); + + // Should not try to save session if none returned + expect(saveSessionId).not.toHaveBeenCalled(); + }); + + it("should save new session ID when returned", async () => { + const mockCommand = { + name: "new-session-command", + filePath: "/test/commands/new-session-command.md", + description: "A command that creates new session", + content: "# New Session Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: true, + output: "New session created", + sessionId: "brand-new-session", + }); + + await executeClaudeCommand(mockContext, "new-session-command"); + + expect(saveSessionId).toHaveBeenCalledWith( + "/test/data/123", + "brand-new-session", + ); + expect(getLogger().debug).toHaveBeenCalledWith( + { sessionId: "brand-new-session" }, + "Session saved", + ); + }); + }); + + describe("Edge Cases and Input Validation", () => { + it("should handle commands with special characters in arguments", async () => { + const mockCommand = { + name: "special-command", + filePath: "/test/commands/special-command.md", + description: "Command with special chars", + content: "# Special Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: true, + output: "Special chars handled", + }); + + const specialArgs = + 'arg with spaces "quoted arg" --flag=value $var @mention #hashtag'; + await executeClaudeCommand(mockContext, "special-command", specialArgs); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: `/special-command ${specialArgs}`, + }), + ); + }); + + it("should handle very long command arguments", async () => { + const mockCommand = { + name: "long-args-command", + filePath: "/test/commands/long-args-command.md", + description: "Command with long args", + content: "# Long Args Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: true, + output: "Long args processed", + }); + + const longArgs = "a".repeat(1000); + await executeClaudeCommand(mockContext, "long-args-command", longArgs); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: `/long-args-command ${longArgs}`, + }), + ); + }); + + it("should handle Unicode characters in arguments", async () => { + const mockCommand = { + name: "unicode-command", + filePath: "/test/commands/unicode-command.md", + description: "Command with unicode", + content: "# Unicode Command", + }; + + vi.mocked(getClaudeCommand).mockResolvedValue(mockCommand); + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: true, + output: "Unicode handled", + }); + + const unicodeArgs = "Hello 世界 🌍 émojis and ñoñó"; + await executeClaudeCommand(mockContext, "unicode-command", unicodeArgs); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: `/unicode-command ${unicodeArgs}`, + }), + ); + }); + }); +}); diff --git a/src/claude/__tests__/commands.improved.test.ts b/src/claude/__tests__/commands.improved.test.ts new file mode 100644 index 0000000..a7e564e --- /dev/null +++ b/src/claude/__tests__/commands.improved.test.ts @@ -0,0 +1,418 @@ +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getWorkingDirectory } from "../../config.js"; +import { getLogger } from "../../logger.js"; + +// Mock dependencies +vi.mock("../../config.js"); +vi.mock("../../logger.js"); + +// Mock fs operations at the top level +const mockReaddir = vi.fn(); +const mockReadFile = vi.fn(); + +vi.mock("node:fs/promises", () => ({ + readdir: mockReaddir, + readFile: mockReadFile, +})); + +describe("Improved Claude Commands - Performance and Validation", () => { + const testWorkingDir = "/test/working"; + const _testCommandsDir = join(testWorkingDir, ".claude", "commands"); + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock working directory + vi.mocked(getWorkingDirectory).mockReturnValue(testWorkingDir); + + // Mock logger + vi.mocked(getLogger).mockReturnValue({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("Command Validation", () => { + it("should reject commands that conflict with built-ins", async () => { + // Import after mocks are set up + const { discoverClaudeCommands, clearCommandsCache } = await import( + "../commands.improved.js" + ); + + clearCommandsCache(); // Clear cache before test + + // Setup mock data with conflicting command names + mockReaddir.mockResolvedValue([ + "start.md", + "help.md", + "valid-command.md", + ]); + + mockReadFile + .mockResolvedValueOnce(`--- +description: This conflicts with built-in start command +--- +# Start Command`) + .mockResolvedValueOnce(`--- +description: This conflicts with built-in help command +--- +# Help Command`) + .mockResolvedValueOnce(`--- +description: This is a valid command +--- +# Valid Command`); + + const commands = await discoverClaudeCommands(); + + expect(commands).toHaveLength(3); + + // Check that built-in conflicts are marked as invalid + const startCommand = commands.find((cmd) => cmd.name === "start"); + const helpCommand = commands.find((cmd) => cmd.name === "help"); + const validCommand = commands.find((cmd) => cmd.name === "valid-command"); + + expect(startCommand?.isValid).toBe(false); + expect(startCommand?.validationError).toContain( + "conflicts with built-in command", + ); + + expect(helpCommand?.isValid).toBe(false); + expect(helpCommand?.validationError).toContain( + "conflicts with built-in command", + ); + + expect(validCommand?.isValid).toBe(true); + expect(validCommand?.validationError).toBeUndefined(); + }); + + it("should reject commands with invalid characters", async () => { + const { discoverClaudeCommands, clearCommandsCache } = await import( + "../commands.improved.js" + ); + + clearCommandsCache(); + + mockReaddir.mockResolvedValue([ + "valid-command_123.md", + "invalid@command.md", + "another#invalid.md", + "valid.dots.command.md", + ]); + + mockReadFile + .mockResolvedValue("# Test Command") + .mockResolvedValue("# Test Command") + .mockResolvedValue("# Test Command") + .mockResolvedValue("# Test Command"); + + const commands = await discoverClaudeCommands(); + + expect(commands).toHaveLength(4); + + const validCommand1 = commands.find( + (cmd) => cmd.name === "valid-command_123", + ); + const invalidCommand1 = commands.find( + (cmd) => cmd.name === "invalid@command", + ); + const invalidCommand2 = commands.find( + (cmd) => cmd.name === "another#invalid", + ); + const validCommand2 = commands.find( + (cmd) => cmd.name === "valid.dots.command", + ); + + expect(validCommand1?.isValid).toBe(true); + expect(invalidCommand1?.isValid).toBe(false); + expect(invalidCommand1?.validationError).toContain("invalid characters"); + expect(invalidCommand2?.isValid).toBe(false); + expect(invalidCommand2?.validationError).toContain("invalid characters"); + expect(validCommand2?.isValid).toBe(true); + }); + + it("should reject commands with invalid length or format", async () => { + const { discoverClaudeCommands, clearCommandsCache } = await import( + "../commands.improved.js" + ); + + clearCommandsCache(); + + const longCommandName = "a".repeat(70); // Too long + mockReaddir.mockResolvedValue([ + ".invalid-start.md", + "invalid-end-.md", + `${longCommandName}.md`, + "valid-command.md", + ]); + + mockReadFile + .mockResolvedValue("# Test Command") + .mockResolvedValue("# Test Command") + .mockResolvedValue("# Test Command") + .mockResolvedValue("# Test Command"); + + const commands = await discoverClaudeCommands(); + + expect(commands).toHaveLength(4); + + const invalidStart = commands.find( + (cmd) => cmd.name === ".invalid-start", + ); + const invalidEnd = commands.find((cmd) => cmd.name === "invalid-end-"); + const tooLong = commands.find((cmd) => cmd.name === longCommandName); + const valid = commands.find((cmd) => cmd.name === "valid-command"); + + expect(invalidStart?.isValid).toBe(false); + expect(invalidStart?.validationError).toContain("cannot start or end"); + + expect(invalidEnd?.isValid).toBe(false); + expect(invalidEnd?.validationError).toContain("cannot start or end"); + + expect(tooLong?.isValid).toBe(false); + expect(tooLong?.validationError).toContain("too long"); + + expect(valid?.isValid).toBe(true); + }); + + it("should sanitize command descriptions", async () => { + const { discoverClaudeCommands, clearCommandsCache } = await import( + "../commands.improved.js" + ); + + clearCommandsCache(); + + mockReaddir.mockResolvedValue(["sanitize-test.md"]); + mockReadFile.mockResolvedValue(`--- +description: "Command with and control chars \x00\x08" +--- +# Sanitize Test`); + + const commands = await discoverClaudeCommands(); + + expect(commands).toHaveLength(1); + expect(commands[0].description).not.toContain("