From 4cc651f8f7b7e365c3f75d83429d104d3450901c Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 01:05:04 -0600 Subject: [PATCH 1/3] feat: implement config routes for agent definitions, templates, and prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add read-only Fastify route plugin with three endpoints: - GET /api/config — returns agent configuration from config.yaml - GET /api/templates — lists templates with source tracking (local vs global) - GET /api/prompts — lists prompts with deduplication across local/global Closes #72 --- package-lock.json | 645 +++++++++++++++++++++++++++++++ package.json | 2 + src/server/routes/config.test.ts | 215 +++++++++++ src/server/routes/config.ts | 136 +++++++ 4 files changed, 998 insertions(+) create mode 100644 src/server/routes/config.test.ts create mode 100644 src/server/routes/config.ts diff --git a/package-lock.json b/package-lock.json index a036a8f..52a467b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "0.3.3", "license": "MIT", "dependencies": { + "@fastify/cors": "^11.2.0", "commander": "^14.0.0", "cron-parser": "^5.5.0", "execa": "^9.5.2", + "fastify": "^5.7.4", "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", "write-file-atomic": "^7.0.0", @@ -474,6 +476,137 @@ "node": ">=18" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -513,6 +646,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", @@ -1048,6 +1187,12 @@ "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", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1061,6 +1206,39 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1078,6 +1256,35 @@ "node": ">=12" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1173,6 +1380,19 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cron-parser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", @@ -1227,6 +1447,15 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1322,6 +1551,125 @@ "node": ">=12.0.0" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1355,6 +1703,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1435,6 +1797,15 @@ "node": ">=0.8.19" } }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -1494,6 +1865,68 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1638,6 +2071,15 @@ "node": ">=0.10.0" } }, + "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", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -1696,6 +2138,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -1824,6 +2303,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -1841,6 +2336,12 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1855,6 +2356,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -1875,6 +2394,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -1884,6 +2412,22 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", @@ -1929,6 +2473,68 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1969,6 +2575,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1989,6 +2604,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2084,6 +2708,18 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2145,6 +2781,15 @@ "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", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", diff --git a/package.json b/package.json index b4cd8bf..8df132f 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,11 @@ ], "license": "MIT", "dependencies": { + "@fastify/cors": "^11.2.0", "commander": "^14.0.0", "cron-parser": "^5.5.0", "execa": "^9.5.2", + "fastify": "^5.7.4", "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", "write-file-atomic": "^7.0.0", diff --git a/src/server/routes/config.test.ts b/src/server/routes/config.test.ts new file mode 100644 index 0000000..d400afd --- /dev/null +++ b/src/server/routes/config.test.ts @@ -0,0 +1,215 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { configRoutes } from './config.js'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ppg-config-routes-')); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +function buildApp(projectRoot: string) { + const app = Fastify({ logger: false }); + app.register(configRoutes, { projectRoot }); + return app; +} + +// --- GET /api/config --- + +describe('GET /api/config', () => { + test('returns default config when no config.yaml exists', async () => { + const app = buildApp(tmpDir); + const res = await app.inject({ method: 'GET', url: '/api/config' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.sessionName).toBe('ppg'); + expect(body.defaultAgent).toBe('claude'); + expect(body.agents).toBeInstanceOf(Array); + expect(body.agents.length).toBeGreaterThanOrEqual(3); + expect(body.agents.find((a: { name: string }) => a.name === 'claude')).toBeTruthy(); + expect(body.envFiles).toEqual(['.env', '.env.local']); + expect(body.symlinkNodeModules).toBe(true); + }); + + test('merges user config.yaml with defaults', async () => { + const ppgDir = path.join(tmpDir, '.ppg'); + await fs.mkdir(ppgDir, { recursive: true }); + await fs.writeFile( + path.join(ppgDir, 'config.yaml'), + 'sessionName: custom\ndefaultAgent: codex\nagents:\n myagent:\n name: myagent\n command: myagent --fast\n interactive: false\n', + ); + + const app = buildApp(tmpDir); + const res = await app.inject({ method: 'GET', url: '/api/config' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.sessionName).toBe('custom'); + expect(body.defaultAgent).toBe('codex'); + // Default agents are preserved + expect(body.agents.find((a: { name: string }) => a.name === 'claude')).toBeTruthy(); + // Custom agent is added + const myagent = body.agents.find((a: { name: string }) => a.name === 'myagent'); + expect(myagent).toBeTruthy(); + expect(myagent.command).toBe('myagent --fast'); + expect(myagent.interactive).toBe(false); + }); + + test('returns agents as array not object', async () => { + const app = buildApp(tmpDir); + const res = await app.inject({ method: 'GET', url: '/api/config' }); + + const body = res.json(); + expect(Array.isArray(body.agents)).toBe(true); + for (const agent of body.agents) { + expect(agent).toHaveProperty('name'); + expect(agent).toHaveProperty('command'); + expect(agent).toHaveProperty('interactive'); + } + }); +}); + +// --- GET /api/templates --- + +describe('GET /api/templates', () => { + test('returns empty array when no templates exist', async () => { + const app = buildApp(tmpDir); + const res = await app.inject({ method: 'GET', url: '/api/templates' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.templates).toEqual([]); + }); + + test('returns local templates with source and metadata', async () => { + const tplDir = path.join(tmpDir, '.ppg', 'templates'); + await fs.mkdir(tplDir, { recursive: true }); + await fs.writeFile( + path.join(tplDir, 'task.md'), + '# Task Template\n\nDo {{TASK}} in {{WORKTREE_PATH}}\n', + ); + + const app = buildApp(tmpDir); + const res = await app.inject({ method: 'GET', url: '/api/templates' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.templates).toHaveLength(1); + expect(body.templates[0]).toEqual({ + name: 'task', + description: 'Task Template', + variables: ['TASK', 'WORKTREE_PATH'], + source: 'local', + }); + }); + + test('returns multiple templates sorted', async () => { + const tplDir = path.join(tmpDir, '.ppg', 'templates'); + await fs.mkdir(tplDir, { recursive: true }); + await fs.writeFile(path.join(tplDir, 'alpha.md'), '# Alpha\n'); + await fs.writeFile(path.join(tplDir, 'beta.md'), '# Beta\n{{VAR}}\n'); + + const app = buildApp(tmpDir); + const res = await app.inject({ method: 'GET', url: '/api/templates' }); + + const body = res.json(); + expect(body.templates).toHaveLength(2); + const names = body.templates.map((t: { name: string }) => t.name); + expect(names).toContain('alpha'); + expect(names).toContain('beta'); + }); + + test('deduplicates variables in template', async () => { + const tplDir = path.join(tmpDir, '.ppg', 'templates'); + await fs.mkdir(tplDir, { recursive: true }); + await fs.writeFile( + path.join(tplDir, 'dupe.md'), + '{{NAME}} and {{NAME}} and {{OTHER}}\n', + ); + + const app = buildApp(tmpDir); + const res = await app.inject({ method: 'GET', url: '/api/templates' }); + + const body = res.json(); + expect(body.templates[0].variables).toEqual(['NAME', 'OTHER']); + }); +}); + +// --- GET /api/prompts --- + +describe('GET /api/prompts', () => { + test('returns empty array when no prompts exist', async () => { + const app = buildApp(tmpDir); + const res = await app.inject({ method: 'GET', url: '/api/prompts' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.prompts).toEqual([]); + }); + + test('returns local prompts with source and metadata', async () => { + const pDir = path.join(tmpDir, '.ppg', 'prompts'); + await fs.mkdir(pDir, { recursive: true }); + await fs.writeFile( + path.join(pDir, 'review.md'), + '# Code Review\n\nReview {{BRANCH}} for issues\n', + ); + + const app = buildApp(tmpDir); + const res = await app.inject({ method: 'GET', url: '/api/prompts' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.prompts).toHaveLength(1); + expect(body.prompts[0]).toEqual({ + name: 'review', + description: 'Code Review', + variables: ['BRANCH'], + source: 'local', + }); + }); + + test('deduplicates prompts across local and global (local wins)', async () => { + // Local prompt + const localDir = path.join(tmpDir, '.ppg', 'prompts'); + await fs.mkdir(localDir, { recursive: true }); + await fs.writeFile(path.join(localDir, 'shared.md'), '# Local Shared\n'); + + // Global prompt with same name — we can't easily write to ~/.ppg/prompts + // in a test, so we test the dedup logic via the entry listing behavior. + // The key assertion is that only one entry appears for a given name. + + const app = buildApp(tmpDir); + const res = await app.inject({ method: 'GET', url: '/api/prompts' }); + + const body = res.json(); + const sharedEntries = body.prompts.filter( + (p: { name: string }) => p.name === 'shared', + ); + expect(sharedEntries).toHaveLength(1); + expect(sharedEntries[0].source).toBe('local'); + }); + + test('ignores non-.md files in prompts directory', async () => { + const pDir = path.join(tmpDir, '.ppg', 'prompts'); + await fs.mkdir(pDir, { recursive: true }); + await fs.writeFile(path.join(pDir, 'valid.md'), '# Valid Prompt\n'); + await fs.writeFile(path.join(pDir, 'readme.txt'), 'not a prompt'); + await fs.writeFile(path.join(pDir, '.hidden'), 'hidden file'); + + const app = buildApp(tmpDir); + const res = await app.inject({ method: 'GET', url: '/api/prompts' }); + + const body = res.json(); + expect(body.prompts).toHaveLength(1); + expect(body.prompts[0].name).toBe('valid'); + }); +}); diff --git a/src/server/routes/config.ts b/src/server/routes/config.ts new file mode 100644 index 0000000..a4f9ae2 --- /dev/null +++ b/src/server/routes/config.ts @@ -0,0 +1,136 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { FastifyInstance } from 'fastify'; +import { loadConfig } from '../../core/config.js'; +import { listTemplatesWithSource } from '../../core/template.js'; +import { + templatesDir, + globalTemplatesDir, + promptsDir, + globalPromptsDir, +} from '../../lib/paths.js'; + +export interface ConfigRouteOptions { + projectRoot: string; +} + +interface TemplateResponse { + name: string; + description: string; + variables: string[]; + source: 'local' | 'global'; +} + +interface PromptResponse { + name: string; + description: string; + variables: string[]; + source: 'local' | 'global'; +} + +async function listPromptEntries( + projectRoot: string, +): Promise> { + const localDir = promptsDir(projectRoot); + const globalDir = globalPromptsDir(); + + let localFiles: string[] = []; + try { + localFiles = (await fs.readdir(localDir)).filter((f) => f.endsWith('.md')).sort(); + } catch { + // directory doesn't exist + } + + let globalFiles: string[] = []; + try { + globalFiles = (await fs.readdir(globalDir)).filter((f) => f.endsWith('.md')).sort(); + } catch { + // directory doesn't exist + } + + const seen = new Set(); + const result: Array<{ name: string; source: 'local' | 'global' }> = []; + + for (const file of localFiles) { + const name = file.replace(/\.md$/, ''); + seen.add(name); + result.push({ name, source: 'local' }); + } + + for (const file of globalFiles) { + const name = file.replace(/\.md$/, ''); + if (!seen.has(name)) { + result.push({ name, source: 'global' }); + } + } + + return result; +} + +async function enrichWithMetadata( + name: string, + source: 'local' | 'global', + localDir: string, + globalDir: string, +): Promise<{ name: string; description: string; variables: string[]; source: 'local' | 'global' }> { + const dir = source === 'local' ? localDir : globalDir; + const filePath = path.join(dir, `${name}.md`); + const content = await fs.readFile(filePath, 'utf-8'); + const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? ''; + const description = firstLine.replace(/^#+\s*/, '').trim(); + const vars = [...content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]); + const uniqueVars = [...new Set(vars)]; + + return { name, description, variables: uniqueVars, source }; +} + +export async function configRoutes( + app: FastifyInstance, + opts: ConfigRouteOptions, +): Promise { + const { projectRoot } = opts; + + // GET /api/config — agent configuration from config.yaml + app.get('/api/config', async () => { + const config = await loadConfig(projectRoot); + return { + sessionName: config.sessionName, + defaultAgent: config.defaultAgent, + agents: Object.values(config.agents), + envFiles: config.envFiles, + symlinkNodeModules: config.symlinkNodeModules, + }; + }); + + // GET /api/templates — templates with source tracking + app.get('/api/templates', async () => { + const entries = await listTemplatesWithSource(projectRoot); + const templates: TemplateResponse[] = await Promise.all( + entries.map(({ name, source }) => + enrichWithMetadata( + name, + source, + templatesDir(projectRoot), + globalTemplatesDir(), + ), + ), + ); + return { templates }; + }); + + // GET /api/prompts — prompts with deduplication across local/global + app.get('/api/prompts', async () => { + const entries = await listPromptEntries(projectRoot); + const prompts: PromptResponse[] = await Promise.all( + entries.map(({ name, source }) => + enrichWithMetadata( + name, + source, + promptsDir(projectRoot), + globalPromptsDir(), + ), + ), + ); + return { prompts }; + }); +} From 6367900cb5e84cae18b9df6cc3a88b1917fa8554 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:56:35 -0600 Subject: [PATCH 2/3] refactor: extract shared prompt/metadata modules and fix test isolation - Extract listPromptsWithSource and enrichEntryMetadata to core/prompt.ts (eliminates duplication between commands/list.ts and routes/config.ts) - Update commands/list.ts to use shared core/prompt.ts module - Simplify routes/config.ts: remove duplicate types and inline logic - Mock globalTemplatesDir/globalPromptsDir in route tests to prevent host filesystem leakage - Close Fastify instances in afterEach for proper cleanup - Fix dedup tests to exercise actual local-shadows-global behavior - Add tests for global-only entries and invalid YAML error path - Remove redundant shape-only test per TDD guidelines - Add auth expectation comment to route plugin - Add core/prompt.test.ts with 10 unit tests --- src/commands/list.ts | 77 ++------------- src/core/prompt.test.ts | 127 +++++++++++++++++++++++++ src/core/prompt.ts | 63 +++++++++++++ src/server/routes/config.test.ts | 157 +++++++++++++++++++------------ src/server/routes/config.ts | 84 ++--------------- 5 files changed, 303 insertions(+), 205 deletions(-) create mode 100644 src/core/prompt.test.ts create mode 100644 src/core/prompt.ts diff --git a/src/commands/list.ts b/src/commands/list.ts index 866e0c3..f1c1e9c 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,7 +1,6 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; import { getRepoRoot } from '../core/worktree.js'; import { listTemplatesWithSource } from '../core/template.js'; +import { listPromptsWithSource, enrichEntryMetadata } from '../core/prompt.js'; import { listSwarmsWithSource, loadSwarm } from '../core/swarm.js'; import { templatesDir, promptsDir, globalTemplatesDir, globalPromptsDir } from '../lib/paths.js'; import { PpgError } from '../lib/errors.js'; @@ -34,18 +33,9 @@ async function listTemplatesCommand(options: ListOptions): Promise { } const templates = await Promise.all( - entries.map(async ({ name, source }) => { - const dir = source === 'local' ? templatesDir(projectRoot) : globalTemplatesDir(); - const filePath = path.join(dir, `${name}.md`); - const content = await fs.readFile(filePath, 'utf-8'); - const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? ''; - const description = firstLine.replace(/^#+\s*/, '').trim(); - - const vars = [...content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]); - const uniqueVars = [...new Set(vars)]; - - return { name, description, variables: uniqueVars, source }; - }), + entries.map(({ name, source }) => + enrichEntryMetadata(name, source, templatesDir(projectRoot), globalTemplatesDir()), + ), ); if (options.json) { @@ -111,52 +101,10 @@ async function listSwarmsCommand(options: ListOptions): Promise { console.log(formatTable(swarms, columns)); } -interface PromptEntry { - name: string; - source: 'local' | 'global'; -} - -async function listPromptEntries(projectRoot: string): Promise { - const localDir = promptsDir(projectRoot); - const globalDir = globalPromptsDir(); - - let localFiles: string[] = []; - try { - localFiles = (await fs.readdir(localDir)).filter((f) => f.endsWith('.md')).sort(); - } catch { - // directory doesn't exist - } - - let globalFiles: string[] = []; - try { - globalFiles = (await fs.readdir(globalDir)).filter((f) => f.endsWith('.md')).sort(); - } catch { - // directory doesn't exist - } - - const seen = new Set(); - const result: PromptEntry[] = []; - - for (const file of localFiles) { - const name = file.replace(/\.md$/, ''); - seen.add(name); - result.push({ name, source: 'local' }); - } - - for (const file of globalFiles) { - const name = file.replace(/\.md$/, ''); - if (!seen.has(name)) { - result.push({ name, source: 'global' }); - } - } - - return result; -} - async function listPromptsCommand(options: ListOptions): Promise { const projectRoot = await getRepoRoot(); - const entries = await listPromptEntries(projectRoot); + const entries = await listPromptsWithSource(projectRoot); if (entries.length === 0) { if (options.json) { @@ -168,18 +116,9 @@ async function listPromptsCommand(options: ListOptions): Promise { } const prompts = await Promise.all( - entries.map(async ({ name, source }) => { - const dir = source === 'local' ? promptsDir(projectRoot) : globalPromptsDir(); - const filePath = path.join(dir, `${name}.md`); - const content = await fs.readFile(filePath, 'utf-8'); - const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? ''; - const description = firstLine.replace(/^#+\s*/, '').trim(); - - const vars = [...content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]); - const uniqueVars = [...new Set(vars)]; - - return { name, description, variables: uniqueVars, source }; - }), + entries.map(({ name, source }) => + enrichEntryMetadata(name, source, promptsDir(projectRoot), globalPromptsDir()), + ), ); if (options.json) { diff --git a/src/core/prompt.test.ts b/src/core/prompt.test.ts new file mode 100644 index 0000000..4857088 --- /dev/null +++ b/src/core/prompt.test.ts @@ -0,0 +1,127 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +let tmpDir: string; +let globalDir: string; + +vi.mock('../lib/paths.js', async () => { + const actual = await vi.importActual('../lib/paths.js'); + return { + ...actual, + globalPromptsDir: () => path.join(globalDir, 'prompts'), + }; +}); + +// Dynamic import after mock setup +const { listPromptsWithSource, enrichEntryMetadata } = await import('./prompt.js'); + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ppg-prompt-')); + globalDir = path.join(tmpDir, 'global'); + await fs.mkdir(path.join(globalDir, 'prompts'), { recursive: true }); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('listPromptsWithSource', () => { + test('given no directories, should return empty array', async () => { + const entries = await listPromptsWithSource(tmpDir); + expect(entries).toEqual([]); + }); + + test('given local prompts, should return with local source', async () => { + const localDir = path.join(tmpDir, '.ppg', 'prompts'); + await fs.mkdir(localDir, { recursive: true }); + await fs.writeFile(path.join(localDir, 'review.md'), '# Review\n'); + await fs.writeFile(path.join(localDir, 'fix.md'), '# Fix\n'); + + const entries = await listPromptsWithSource(tmpDir); + expect(entries).toEqual([ + { name: 'fix', source: 'local' }, + { name: 'review', source: 'local' }, + ]); + }); + + test('given global prompts, should return with global source', async () => { + await fs.writeFile(path.join(globalDir, 'prompts', 'shared.md'), '# Shared\n'); + + const entries = await listPromptsWithSource(tmpDir); + expect(entries).toEqual([{ name: 'shared', source: 'global' }]); + }); + + test('given same name in local and global, should prefer local', async () => { + const localDir = path.join(tmpDir, '.ppg', 'prompts'); + await fs.mkdir(localDir, { recursive: true }); + await fs.writeFile(path.join(localDir, 'shared.md'), '# Local\n'); + await fs.writeFile(path.join(globalDir, 'prompts', 'shared.md'), '# Global\n'); + + const entries = await listPromptsWithSource(tmpDir); + expect(entries).toEqual([{ name: 'shared', source: 'local' }]); + }); + + test('given non-.md files, should ignore them', async () => { + const localDir = path.join(tmpDir, '.ppg', 'prompts'); + await fs.mkdir(localDir, { recursive: true }); + await fs.writeFile(path.join(localDir, 'valid.md'), '# Valid\n'); + await fs.writeFile(path.join(localDir, 'readme.txt'), 'not a prompt'); + + const entries = await listPromptsWithSource(tmpDir); + expect(entries).toEqual([{ name: 'valid', source: 'local' }]); + }); +}); + +describe('enrichEntryMetadata', () => { + test('given markdown file, should extract description from first line', async () => { + const dir = path.join(tmpDir, 'md'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'task.md'), '# My Task\n\nBody here\n'); + + const result = await enrichEntryMetadata('task', 'local', dir, dir); + expect(result.description).toBe('My Task'); + }); + + test('given template variables, should extract unique vars', async () => { + const dir = path.join(tmpDir, 'md'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, 'task.md'), + '{{NAME}} and {{NAME}} and {{OTHER}}\n', + ); + + const result = await enrichEntryMetadata('task', 'local', dir, dir); + expect(result.variables).toEqual(['NAME', 'OTHER']); + }); + + test('given no variables, should return empty array', async () => { + const dir = path.join(tmpDir, 'md'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'plain.md'), '# Plain text\n'); + + const result = await enrichEntryMetadata('plain', 'local', dir, dir); + expect(result.variables).toEqual([]); + }); + + test('given global source, should read from global dir', async () => { + const localDir = path.join(tmpDir, 'local'); + const gDir = path.join(tmpDir, 'gbl'); + await fs.mkdir(gDir, { recursive: true }); + await fs.writeFile(path.join(gDir, 'task.md'), '# Global Task\n'); + + const result = await enrichEntryMetadata('task', 'global', localDir, gDir); + expect(result.description).toBe('Global Task'); + expect(result.source).toBe('global'); + }); + + test('given empty first line, should skip to first non-empty line', async () => { + const dir = path.join(tmpDir, 'md'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'task.md'), '\n\n# Actual Title\n'); + + const result = await enrichEntryMetadata('task', 'local', dir, dir); + expect(result.description).toBe('Actual Title'); + }); +}); diff --git a/src/core/prompt.ts b/src/core/prompt.ts new file mode 100644 index 0000000..8371fb1 --- /dev/null +++ b/src/core/prompt.ts @@ -0,0 +1,63 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { promptsDir, globalPromptsDir } from '../lib/paths.js'; + +export interface PromptEntry { + name: string; + source: 'local' | 'global'; +} + +export interface EnrichedEntry { + name: string; + description: string; + variables: string[]; + source: 'local' | 'global'; + [key: string]: unknown; +} + +async function readMdNames(dir: string): Promise { + try { + const files = await fs.readdir(dir); + return files.filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, '')).sort(); + } catch { + return []; + } +} + +export async function listPromptsWithSource(projectRoot: string): Promise { + const localNames = await readMdNames(promptsDir(projectRoot)); + const globalNames = await readMdNames(globalPromptsDir()); + + const seen = new Set(); + const result: PromptEntry[] = []; + + for (const name of localNames) { + seen.add(name); + result.push({ name, source: 'local' }); + } + + for (const name of globalNames) { + if (!seen.has(name)) { + result.push({ name, source: 'global' }); + } + } + + return result; +} + +export async function enrichEntryMetadata( + name: string, + source: 'local' | 'global', + localDir: string, + globalDir: string, +): Promise { + const dir = source === 'local' ? localDir : globalDir; + const filePath = path.join(dir, `${name}.md`); + const content = await fs.readFile(filePath, 'utf-8'); + const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? ''; + const description = firstLine.replace(/^#+\s*/, '').trim(); + const vars = [...content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]); + const uniqueVars = [...new Set(vars)]; + + return { name, description, variables: uniqueVars, source }; +} diff --git a/src/server/routes/config.test.ts b/src/server/routes/config.test.ts index d400afd..9e1a551 100644 --- a/src/server/routes/config.test.ts +++ b/src/server/routes/config.test.ts @@ -1,22 +1,37 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import Fastify from 'fastify'; -import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { configRoutes } from './config.js'; let tmpDir: string; +let globalDir: string; +let app: FastifyInstance; + +vi.mock('../../lib/paths.js', async () => { + const actual = await vi.importActual('../../lib/paths.js'); + return { + ...actual, + globalTemplatesDir: () => path.join(globalDir, 'templates'), + globalPromptsDir: () => path.join(globalDir, 'prompts'), + }; +}); beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ppg-config-routes-')); + globalDir = path.join(tmpDir, 'global'); + await fs.mkdir(path.join(globalDir, 'templates'), { recursive: true }); + await fs.mkdir(path.join(globalDir, 'prompts'), { recursive: true }); }); afterEach(async () => { + await app?.close(); await fs.rm(tmpDir, { recursive: true, force: true }); }); function buildApp(projectRoot: string) { - const app = Fastify({ logger: false }); + app = Fastify({ logger: false }); app.register(configRoutes, { projectRoot }); return app; } @@ -24,9 +39,9 @@ function buildApp(projectRoot: string) { // --- GET /api/config --- describe('GET /api/config', () => { - test('returns default config when no config.yaml exists', async () => { - const app = buildApp(tmpDir); - const res = await app.inject({ method: 'GET', url: '/api/config' }); + test('given no config.yaml, should return default config', async () => { + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/config' }); expect(res.statusCode).toBe(200); const body = res.json(); @@ -39,7 +54,7 @@ describe('GET /api/config', () => { expect(body.symlinkNodeModules).toBe(true); }); - test('merges user config.yaml with defaults', async () => { + test('given user config.yaml, should merge with defaults', async () => { const ppgDir = path.join(tmpDir, '.ppg'); await fs.mkdir(ppgDir, { recursive: true }); await fs.writeFile( @@ -47,49 +62,45 @@ describe('GET /api/config', () => { 'sessionName: custom\ndefaultAgent: codex\nagents:\n myagent:\n name: myagent\n command: myagent --fast\n interactive: false\n', ); - const app = buildApp(tmpDir); - const res = await app.inject({ method: 'GET', url: '/api/config' }); + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/config' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.sessionName).toBe('custom'); expect(body.defaultAgent).toBe('codex'); - // Default agents are preserved expect(body.agents.find((a: { name: string }) => a.name === 'claude')).toBeTruthy(); - // Custom agent is added const myagent = body.agents.find((a: { name: string }) => a.name === 'myagent'); expect(myagent).toBeTruthy(); expect(myagent.command).toBe('myagent --fast'); expect(myagent.interactive).toBe(false); }); - test('returns agents as array not object', async () => { - const app = buildApp(tmpDir); - const res = await app.inject({ method: 'GET', url: '/api/config' }); + test('given invalid YAML, should return 500 error', async () => { + const ppgDir = path.join(tmpDir, '.ppg'); + await fs.mkdir(ppgDir, { recursive: true }); + await fs.writeFile(path.join(ppgDir, 'config.yaml'), ':\n bad: [yaml\n'); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/config' }); - const body = res.json(); - expect(Array.isArray(body.agents)).toBe(true); - for (const agent of body.agents) { - expect(agent).toHaveProperty('name'); - expect(agent).toHaveProperty('command'); - expect(agent).toHaveProperty('interactive'); - } + expect(res.statusCode).toBe(500); }); }); // --- GET /api/templates --- describe('GET /api/templates', () => { - test('returns empty array when no templates exist', async () => { - const app = buildApp(tmpDir); - const res = await app.inject({ method: 'GET', url: '/api/templates' }); + test('given no template dirs, should return empty array', async () => { + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/templates' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.templates).toEqual([]); }); - test('returns local templates with source and metadata', async () => { + test('given local template, should return source and metadata', async () => { const tplDir = path.join(tmpDir, '.ppg', 'templates'); await fs.mkdir(tplDir, { recursive: true }); await fs.writeFile( @@ -97,8 +108,8 @@ describe('GET /api/templates', () => { '# Task Template\n\nDo {{TASK}} in {{WORKTREE_PATH}}\n', ); - const app = buildApp(tmpDir); - const res = await app.inject({ method: 'GET', url: '/api/templates' }); + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/templates' }); expect(res.statusCode).toBe(200); const body = res.json(); @@ -111,23 +122,38 @@ describe('GET /api/templates', () => { }); }); - test('returns multiple templates sorted', async () => { + test('given global template, should return with global source', async () => { + await fs.writeFile( + path.join(globalDir, 'templates', 'shared.md'), + '# Global Template\n\n{{VAR}}\n', + ); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/templates' }); + + const body = res.json(); + expect(body.templates).toHaveLength(1); + expect(body.templates[0].name).toBe('shared'); + expect(body.templates[0].source).toBe('global'); + }); + + test('given same name in local and global, should prefer local', async () => { const tplDir = path.join(tmpDir, '.ppg', 'templates'); await fs.mkdir(tplDir, { recursive: true }); - await fs.writeFile(path.join(tplDir, 'alpha.md'), '# Alpha\n'); - await fs.writeFile(path.join(tplDir, 'beta.md'), '# Beta\n{{VAR}}\n'); + await fs.writeFile(path.join(tplDir, 'shared.md'), '# Local Version\n'); + await fs.writeFile(path.join(globalDir, 'templates', 'shared.md'), '# Global Version\n'); - const app = buildApp(tmpDir); - const res = await app.inject({ method: 'GET', url: '/api/templates' }); + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/templates' }); const body = res.json(); - expect(body.templates).toHaveLength(2); - const names = body.templates.map((t: { name: string }) => t.name); - expect(names).toContain('alpha'); - expect(names).toContain('beta'); + const shared = body.templates.filter((t: { name: string }) => t.name === 'shared'); + expect(shared).toHaveLength(1); + expect(shared[0].source).toBe('local'); + expect(shared[0].description).toBe('Local Version'); }); - test('deduplicates variables in template', async () => { + test('given duplicate variables, should deduplicate', async () => { const tplDir = path.join(tmpDir, '.ppg', 'templates'); await fs.mkdir(tplDir, { recursive: true }); await fs.writeFile( @@ -135,8 +161,8 @@ describe('GET /api/templates', () => { '{{NAME}} and {{NAME}} and {{OTHER}}\n', ); - const app = buildApp(tmpDir); - const res = await app.inject({ method: 'GET', url: '/api/templates' }); + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/templates' }); const body = res.json(); expect(body.templates[0].variables).toEqual(['NAME', 'OTHER']); @@ -146,16 +172,16 @@ describe('GET /api/templates', () => { // --- GET /api/prompts --- describe('GET /api/prompts', () => { - test('returns empty array when no prompts exist', async () => { - const app = buildApp(tmpDir); - const res = await app.inject({ method: 'GET', url: '/api/prompts' }); + test('given no prompt dirs, should return empty array', async () => { + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/prompts' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.prompts).toEqual([]); }); - test('returns local prompts with source and metadata', async () => { + test('given local prompt, should return source and metadata', async () => { const pDir = path.join(tmpDir, '.ppg', 'prompts'); await fs.mkdir(pDir, { recursive: true }); await fs.writeFile( @@ -163,8 +189,8 @@ describe('GET /api/prompts', () => { '# Code Review\n\nReview {{BRANCH}} for issues\n', ); - const app = buildApp(tmpDir); - const res = await app.inject({ method: 'GET', url: '/api/prompts' }); + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/prompts' }); expect(res.statusCode).toBe(200); const body = res.json(); @@ -177,36 +203,47 @@ describe('GET /api/prompts', () => { }); }); - test('deduplicates prompts across local and global (local wins)', async () => { - // Local prompt + test('given same name in local and global, should prefer local', async () => { const localDir = path.join(tmpDir, '.ppg', 'prompts'); await fs.mkdir(localDir, { recursive: true }); await fs.writeFile(path.join(localDir, 'shared.md'), '# Local Shared\n'); + await fs.writeFile(path.join(globalDir, 'prompts', 'shared.md'), '# Global Shared\n'); - // Global prompt with same name — we can't easily write to ~/.ppg/prompts - // in a test, so we test the dedup logic via the entry listing behavior. - // The key assertion is that only one entry appears for a given name. - - const app = buildApp(tmpDir); - const res = await app.inject({ method: 'GET', url: '/api/prompts' }); + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/prompts' }); const body = res.json(); - const sharedEntries = body.prompts.filter( - (p: { name: string }) => p.name === 'shared', + const shared = body.prompts.filter((p: { name: string }) => p.name === 'shared'); + expect(shared).toHaveLength(1); + expect(shared[0].source).toBe('local'); + expect(shared[0].description).toBe('Local Shared'); + }); + + test('given global-only prompt, should return with global source', async () => { + await fs.writeFile( + path.join(globalDir, 'prompts', 'global-only.md'), + '# Global Prompt\n\n{{WHO}}\n', ); - expect(sharedEntries).toHaveLength(1); - expect(sharedEntries[0].source).toBe('local'); + + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/prompts' }); + + const body = res.json(); + expect(body.prompts).toHaveLength(1); + expect(body.prompts[0].name).toBe('global-only'); + expect(body.prompts[0].source).toBe('global'); + expect(body.prompts[0].variables).toEqual(['WHO']); }); - test('ignores non-.md files in prompts directory', async () => { + test('given non-.md files, should ignore them', async () => { const pDir = path.join(tmpDir, '.ppg', 'prompts'); await fs.mkdir(pDir, { recursive: true }); await fs.writeFile(path.join(pDir, 'valid.md'), '# Valid Prompt\n'); await fs.writeFile(path.join(pDir, 'readme.txt'), 'not a prompt'); await fs.writeFile(path.join(pDir, '.hidden'), 'hidden file'); - const app = buildApp(tmpDir); - const res = await app.inject({ method: 'GET', url: '/api/prompts' }); + const server = buildApp(tmpDir); + const res = await server.inject({ method: 'GET', url: '/api/prompts' }); const body = res.json(); expect(body.prompts).toHaveLength(1); diff --git a/src/server/routes/config.ts b/src/server/routes/config.ts index a4f9ae2..81d490d 100644 --- a/src/server/routes/config.ts +++ b/src/server/routes/config.ts @@ -1,8 +1,7 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; import type { FastifyInstance } from 'fastify'; import { loadConfig } from '../../core/config.js'; import { listTemplatesWithSource } from '../../core/template.js'; +import { listPromptsWithSource, enrichEntryMetadata } from '../../core/prompt.js'; import { templatesDir, globalTemplatesDir, @@ -14,75 +13,8 @@ export interface ConfigRouteOptions { projectRoot: string; } -interface TemplateResponse { - name: string; - description: string; - variables: string[]; - source: 'local' | 'global'; -} - -interface PromptResponse { - name: string; - description: string; - variables: string[]; - source: 'local' | 'global'; -} - -async function listPromptEntries( - projectRoot: string, -): Promise> { - const localDir = promptsDir(projectRoot); - const globalDir = globalPromptsDir(); - - let localFiles: string[] = []; - try { - localFiles = (await fs.readdir(localDir)).filter((f) => f.endsWith('.md')).sort(); - } catch { - // directory doesn't exist - } - - let globalFiles: string[] = []; - try { - globalFiles = (await fs.readdir(globalDir)).filter((f) => f.endsWith('.md')).sort(); - } catch { - // directory doesn't exist - } - - const seen = new Set(); - const result: Array<{ name: string; source: 'local' | 'global' }> = []; - - for (const file of localFiles) { - const name = file.replace(/\.md$/, ''); - seen.add(name); - result.push({ name, source: 'local' }); - } - - for (const file of globalFiles) { - const name = file.replace(/\.md$/, ''); - if (!seen.has(name)) { - result.push({ name, source: 'global' }); - } - } - - return result; -} - -async function enrichWithMetadata( - name: string, - source: 'local' | 'global', - localDir: string, - globalDir: string, -): Promise<{ name: string; description: string; variables: string[]; source: 'local' | 'global' }> { - const dir = source === 'local' ? localDir : globalDir; - const filePath = path.join(dir, `${name}.md`); - const content = await fs.readFile(filePath, 'utf-8'); - const firstLine = content.split('\n').find((l) => l.trim().length > 0) ?? ''; - const description = firstLine.replace(/^#+\s*/, '').trim(); - const vars = [...content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]); - const uniqueVars = [...new Set(vars)]; - - return { name, description, variables: uniqueVars, source }; -} +// Auth note: these routes expect the parent server to register an onRequest +// auth hook before this plugin (e.g. Bearer token via createAuthHook). export async function configRoutes( app: FastifyInstance, @@ -105,9 +37,9 @@ export async function configRoutes( // GET /api/templates — templates with source tracking app.get('/api/templates', async () => { const entries = await listTemplatesWithSource(projectRoot); - const templates: TemplateResponse[] = await Promise.all( + const templates = await Promise.all( entries.map(({ name, source }) => - enrichWithMetadata( + enrichEntryMetadata( name, source, templatesDir(projectRoot), @@ -120,10 +52,10 @@ export async function configRoutes( // GET /api/prompts — prompts with deduplication across local/global app.get('/api/prompts', async () => { - const entries = await listPromptEntries(projectRoot); - const prompts: PromptResponse[] = await Promise.all( + const entries = await listPromptsWithSource(projectRoot); + const prompts = await Promise.all( entries.map(({ name, source }) => - enrichWithMetadata( + enrichEntryMetadata( name, source, promptsDir(projectRoot), From 15ef3b9ec9d25d84ad5f6db0fed86829af500875 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:35:00 -0600 Subject: [PATCH 3/3] test: fix manifest mock typing in spawn tests --- src/commands/spawn.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..2ca6003 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -7,6 +7,7 @@ import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; import * as tmux from '../core/tmux.js'; +import type { Manifest } from '../types/manifest.js'; vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises'); @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -function createManifest(tmuxWindow = '') { +function createManifest(tmuxWindow = ''): Manifest { return { version: 1 as const, projectRoot: '/tmp/repo', @@ -93,7 +94,7 @@ function createManifest(tmuxWindow = '') { baseBranch: 'main', status: 'active' as const, tmuxWindow, - agents: {} as Record, + agents: {} as Manifest['worktrees'][string]['agents'], createdAt: '2026-02-27T00:00:00.000Z', }, }, @@ -103,7 +104,7 @@ function createManifest(tmuxWindow = '') { } describe('spawnCommand', () => { - let manifestState = createManifest(); + let manifestState: Manifest = createManifest(); let nextAgent = 1; let nextSession = 1;